Github
Registry

Asset storage

Where a registry keeps asset blobs, how to plug in object storage, and how uploads and downloads travel.

By default, the registry stores asset blobs (icons, logos, fonts, sprites, archives) in PostgreSQL. Each blob can be at most 4 MB; assetStorage.maxInlineBytes changes the limit.

Custom provider

A server module can store blobs in another service:

handoff.config.registry.ts
runtime: {
  registry: {
    assetStorage: {
      provider: 'custom',
      module: './server/storage/s3.mjs',
      options: { bucket: 'handoff-assets', secretKey: fromEnv('S3_SECRET_KEY') },
    },
  },
},

The module default-exports a defineStorageProvider() result with these methods:

MethodRequiredDoes
put({ hash, bytes, contentType })YesStores a blob and returns { storageRef }, which the registry records
get(storageRef)YesReturns { kind: 'stream', stream }, { kind: 'bytes', bytes }, or { kind: 'redirect', url }
delete(storageRef)YesRemoves a blob
createUpload({ hash, size, contentType })NoReturns a signed { url, headers, storageRef } for a direct upload

Upload paths

publish uploads only the blobs the registry does not have. The active provider decides the path; no setting changes it.

PathWhenHash checkSize limit
Through the registrydatabase provider, or a custom provider without createUploadThe registryThe host's request limit, about 4.5 MB on Vercel. The database provider also applies maxInlineBytes.
Direct to storageA custom provider with createUploadStorage, through the signed URLThe storage's limit

For a direct upload, the registry gives the CLI a signed URL, and the CLI sends the blob straight to storage. The registry records the blob only after get finds it.

  • The machine that runs publish must reach the storage URL.
  • If a direct upload fails, publish stops. It does not retry through the registry.
  • The signed URL must make storage reject bytes that do not match hash.
  • The registry does not delete an uploaded object that it never records.

Download paths

With { kind: 'redirect' }, the registry sends the reader to storage. With bytes or stream, the registry sends the blob itself.

Changing the provider

The registry records which provider stores each blob.

  • Blobs in PostgreSQL stay readable after you switch to a custom provider. A publish does not move them.
  • If you change the custom provider or its storage, copy the objects first. The new provider must find each object by the storageRef the previous one returned.
  • After switching back to database, the registry cannot read the custom provider's blobs.

Example: S3-compatible storage

Works with Amazon S3, Cloudflare R2, MinIO, and other S3 APIs. Install @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner in the project.

server/storage/s3.mjs
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { defineStorageProvider } from 'handoff-app/providers'

export default defineStorageProvider(({ options }) => {
  const client = new S3Client({
    endpoint: options.endpoint,
    region: 'us-east-1',
    forcePathStyle: true,
    credentials: { accessKeyId: options.accessKey, secretAccessKey: options.secretKey },
  })
  const Bucket = options.bucket
  return {
    async put({ hash, bytes, contentType }) {
      await client.send(new PutObjectCommand({ Bucket, Key: hash, Body: bytes, ContentType: contentType }))
      return { storageRef: hash }
    },
    async get(storageRef) {
      const result = await client.send(new GetObjectCommand({ Bucket, Key: storageRef }))
      return { kind: 'stream', stream: result.Body, contentType: result.ContentType }
    },
    async delete(storageRef) {
      await client.send(new DeleteObjectCommand({ Bucket, Key: storageRef }))
    },
    async createUpload({ hash, contentType }) {
      const checksum = Buffer.from(hash, 'hex').toString('base64')
      const command = new PutObjectCommand({ Bucket, Key: hash, ContentType: contentType, ChecksumSHA256: checksum })
      const url = await getSignedUrl(client, command, {
        expiresIn: 900,
        unhoistableHeaders: new Set(['x-amz-checksum-sha256']),
      })
      return { url, headers: { 'Content-Type': contentType, 'x-amz-checksum-sha256': checksum }, storageRef: hash }
    },
  }
})

createUpload signs a SHA-256 checksum header, so storage rejects bytes that do not match hash. The handoff-app repository's RECIPES.md also has a Vercel Blob provider.

Cleanup

Since 2.1.0, after each publish and entity delete, the registry deletes blobs and artifact rows that nothing uses and that are older than 24 hours.

On this page