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:
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:
| Method | Required | Does |
|---|---|---|
put({ hash, bytes, contentType }) | Yes | Stores a blob and returns { storageRef }, which the registry records |
get(storageRef) | Yes | Returns { kind: 'stream', stream }, { kind: 'bytes', bytes }, or { kind: 'redirect', url } |
delete(storageRef) | Yes | Removes a blob |
createUpload({ hash, size, contentType }) | No | Returns 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.
| Path | When | Hash check | Size limit |
|---|---|---|---|
| Through the registry | database provider, or a custom provider without createUpload | The registry | The host's request limit, about 4.5 MB on Vercel. The database provider also applies maxInlineBytes. |
| Direct to storage | A custom provider with createUpload | Storage, through the signed URL | The 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
publishmust reach the storage URL. - If a direct upload fails,
publishstops. 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
storageRefthe 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.
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.