DeployKit

Manifests

Deploy files from object storage without copying them to local disk.

A manifest is a JSON list of files: each one's path, size, SHA-256 hash and a reference to where its bytes are stored. At deploy time, deploykit fetches files through a function you provide, only when it needs them.

Use a manifest when:

  • your files live in S3, R2 or similar, not on the deploying machine,
  • one process plans a deploy and another carries it out,
  • you want a record of exactly what was deployed.

The format

manifest.json
{
  "version": 1,
  "entries": [
    {
      "path": "index.html",
      "byteLength": 1024,
      "source": "sites/acme/v42/index.html",
      "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
    }
  ]
}
FieldMeaning
pathWhere the file is served in the deployment
byteLengthExact size in bytes
sourceA reference you choose and resolve later, such as a storage key
sha256Lowercase hex SHA-256 of the raw bytes
fingerprintsOptional host hashes that save downloads

Don't put signed URLs or credentials in source: manifests get stored and logged. Resolve the reference to a URL inside your reader instead.

A manifest holds up to 20,000 files and 16 MiB of JSON.

A complete pipeline

A build job hashes the output and uploads it to storage. Later, a deploy job deploys from storage. The two only share the manifest.

Build: hash the output and assign references

build-job.ts
import * as ManifestBuilder from "@deploykit/core/manifest-builder"

const prefix = `sites/${siteId}/${buildId}`

const manifest = ManifestBuilder.fromDirectory("./build", {
  source: path => `${prefix}/${path}`
}) // Effect<Manifest>, needs a FileSystem

Hashing streams each file, so it never holds a whole file in memory. It hashes four files at a time by default; set concurrency (1 to 32) and timeoutMs per file to change that. To build from an artifact rather than a directory, use manifestFromArtifact / ManifestBuilder.fromArtifact.

Build: upload the files and the manifest

Upload each file to its source reference with your storage SDK, then save the manifest. Use immutable keys, such as ones with a build id, so a file can't change after it's hashed.

build-job.ts
for (const entry of manifest.entries) {
  await storage.put(entry.source, await readFile(`./build/${entry.path}`))
}
await storage.put(`${prefix}/manifest.json`, JSON.stringify(manifest))

Deploy: read the manifest and stream the files

deploy-job.ts
import { Effect } from "effect"
import { Deploykit, Source } from "@deploykit/core"

const read = async (reference: string, signal: AbortSignal) => {
  const response = await fetch(await storage.signedUrl(reference), { signal })
  if (!response.ok || !response.body) throw new Error(`Cannot read ${reference}`)
  return response.body
}

const deploy = (appId: string, manifest: unknown) =>
  Effect.gen(function* () {
    const deploykit = yield* Deploykit.Deploykit
    return yield* deploykit.deployManifest(appId, manifest, Source.fromReadableStream(read))
  })

manifest can be any value, such as the result of JSON.parse: deployManifest validates it. For a JSON string, decode it first with Manifest.fromJson.

Writing the reader

  • It may be called more than once for the same file, for example on a retry. Return a fresh stream every time.
  • Pass signal on, so a cancelled deploy stops downloading.
  • Throwing turns into a SourceError for that file, which fails the deploy.
  • Reading a file has a 30 second limit.

Each file is checked against its byteLength and sha256 as it streams. If one doesn't match, the deploy fails with IntegrityError instead of publishing the wrong bytes.

With Effect you can also implement FileSource directly: { open: reference => Stream }. For small files, Source.fromPromise((reference, signal) => Promise<Uint8Array>) is simpler.

Skipping downloads with fingerprints

Hosts identify files by their own hash, not SHA-256. deploykit needs that hash to ask the host which files it's missing.

Without fingerprints, the adapter downloads every file once to compute the host's hash, then downloads the missing ones again to upload them. Files the host already has still cost one download.

With fingerprints, it asks the host straight away and only downloads what's missing.

{
  "path": "index.html",
  "byteLength": 1024,
  "source": "sites/acme/v42/index.html",
  "sha256": "9f86d08...",
  "fingerprints": [{ "recipe": "vercel-sha1-v1", "context": "", "value": "a94a8fe..." }]
}
Hostrecipecontextvalue
Vercelvercel-sha1-v1""SHA-1 of the raw bytes, hex
Cloudflare Pagescloudflare-blake3-b64ext-v1File extension without the dot, e.g. htmlPages' BLAKE3-based asset hash

The manifest builder only computes SHA-256, so add fingerprints yourself when you know them. For Pages, @deploykit/cloudflare exports pagesDigest to compute its hash. Unknown recipes are ignored, and a Pages fingerprint is ignored if the file's extension changed.

When the host says it already has a file, deploykit doesn't re-read it to check. A wrong fingerprint publishes whatever the host has under that hash. deploykit trusts whoever built the manifest.

Encoding

Manifest.encode(value) returns canonical JSON: paths and fingerprints sorted, fields in a fixed order. Two manifests for the same files encode identically, so you can compare or hash them.

Last updated on

On this page