@silgi/blob
The first-party blob module. Add it to modules and set its options under the blob config key. Files of any size, with their type and metadata, on the disk, on S3 (or any S3 API: R2, MinIO) or on R2 through its binding. A body is streamed, never held whole in memory, except where get says so.
// silgi.config.ts
export default defineConfig({
modules: ["@silgi/blob"],
runtimeConfig: { s3Secret: "" },
blob: {
driver: "s3",
bucket: "uploads",
endpoint: "https://<account>.r2.cloudflarestorage.com",
region: "auto",
accessKeyId: "AKIA…",
secretAccessKey: { runtimeConfig: "s3Secret" },
dev: { driver: "fs" },
},
});
default
The module, as defineModule({ meta: { name: "blob", configKey: "blob" } }). Its setup checks the options (a wrong value is a build error naming the key), picks the driver, refuses @silgi/blob/server in client code, and fails the build when the driver cannot run on the preset.
Without a blob key the store is fs at .data/blob on bun, node, static and under silgi dev, and the cloudflare-r2 binding BLOB on cloudflare. On vercel and netlify the build fails: @silgi/blob needs blob.driver on the vercel preset: its file system does not keep files; use "s3". A cloudflare build whose wrangler config (wrangler.json or wrangler.jsonc, with the cloudflare.env block) declares no r2_buckets entry for the binding fails: blob uses the cloudflare-r2 binding "BLOB", which the app's wrangler config does not declare; add an r2_buckets entry with binding "BLOB".
BlobConfig
type BlobConfig = (
| { driver: "fs"; base?: string }
| { driver: "s3"; bucket?: Secret; region?: Secret; endpoint?: Secret; accessKeyId?: Secret; secretAccessKey?: Secret;
sessionToken?: Secret; virtualHostedStyle?: boolean; base?: string }
| { driver: "cloudflare-r2"; binding?: string; base?: string }
) & { dev?: BlobConfig };
type Secret = string | { runtimeConfig: string };
| Driver | Runs on | Stores |
|---|---|---|
fs | bun, node, static, silgi dev | { id, contentType, size, etag, uploadedAt, customMetadata } at <base>/meta/<pathname>.json, naming the bytes at <base>/objects/<pathname>.<id>.data; each segment percent-encoded (. too), at most 200 bytes once encoded; base default .data/blob, against the working directory |
s3 | every preset | the object at <base><pathname> with its Content-Type and x-amz-meta-*; every request is signed by signS3Request over fetch |
cloudflare-r2 | cloudflare | the object at <base><pathname> in env[binding] (default BLOB), the type in httpMetadata |
s3 options not given come from S3_<NAME> or AWS_<NAME> in the environment (S3_BUCKET, AWS_SECRET_ACCESS_KEY, …); { runtimeConfig: "s3Secret" } reads runtimeConfig.s3Secret at the first request, and an unset one throws blob.secretAccessKey reads runtimeConfig.s3Secret, which is not set. A stream body that does not end within 10 MiB is written on s3 and cloudflare-r2 as a multipart upload in 10 MiB parts. dev replaces the config under silgi dev and in the prerender; cloudflare-r2 cannot run under silgi dev, so it needs a dev.
BlobObject, BlobListItem, BlobList, BlobPutOptions, BlobServeOptions
interface BlobObject { pathname: string; contentType: string; size: number; etag: string; uploadedAt: Date; customMetadata: Record<string, string> }
interface BlobListItem { pathname: string; size: number; etag: string; uploadedAt: Date }
interface BlobList { blobs: BlobListItem[]; cursor?: string; hasMore: boolean; folders?: string[] }
interface BlobPutOptions { contentType?: string; addRandomSuffix?: boolean; customMetadata?: Record<string, string> }
interface BlobServeOptions { cacheControl?: string }
etag is quoted, the HTTP form. A contentType is one type/subtype with optional ; name=value parameters, stored in lowercase type, subtype and parameter names; a list (image/png, text/html) or anything else throws TypeError: Invalid blob content type "image/png, text/html": use one type/subtype with optional ; parameters, from the option and from a Blob's own type alike. A customMetadata key holds a-z, 0-9 and -, a value printable ASCII with no leading or trailing space, and keys and values together hold at most 2048 bytes (S3's limit), so each one is the same on every driver; otherwise put throws Invalid customMetadata key "Author": use a-z, 0-9 and "-", Invalid customMetadata value for "k": use printable ASCII with no leading or trailing space or customMetadata is 2049 bytes; keys and values together may hold at most 2048 (S3's limit).
BlobStore
interface BlobStore {
put(pathname: string, body: string | ArrayBuffer | Uint8Array | Blob | ReadableStream<Uint8Array>, options?: BlobPutOptions): Promise<BlobObject>;
get(pathname: string): Promise<Blob | undefined>;
head(pathname: string): Promise<BlobObject | undefined>;
list(options?: { prefix?: string; limit?: number; cursor?: string; folded?: boolean }): Promise<BlobList>;
delete(pathnames: string | string[]): Promise<void>;
serve(event: SilgiEvent, pathname: string, options?: BlobServeOptions): Promise<Response>;
presign(pathname: string, options: { method: "GET" | "PUT"; expiresIn?: number; contentType?: string }): Promise<string>;
}
A pathname is /-separated, a leading / dropped, at most 1024 bytes of UTF-8; an empty segment, ., .., a \, NUL or a lone surrogate throws TypeError: Invalid blob pathname "a/../b".
putstores the body and returns the object; a put to an existing pathname replaces it.contentTypedefaults to the body'stype(aBloborFile), else the type of the extension, elseapplication/octet-stream.addRandomSuffix: trueputs-<8 base36>before the extension (a/photo-k3j9x0q2.jpg).getreturns aBlob, lazy onfsand read whole ons3andcloudflare-r2; to answer a request with a large object, useserve.getandheadreturnundefinedfor a missing pathname.listpages in pathname order:limit1 – 1000 (default 1000),cursorthe opaque value of the page before,folded: truegroups what is below the next/intofolders(each ending in/). Each entry is aBlobListItem, what every backend's listing gives in one request;headgives a pathname's type and metadata.deleteof a missing pathname does nothing.serveanswersGETandHEADwith the bytes streamed,content-type,content-length,etag,last-modifiedandaccept-ranges: bytes. A matchingIf-None-Match, or without one anIf-Modified-Sincenot before the upload, answers 304; oneRangeanswers 206 withcontent-range, an unsatisfiable one 416 withcontent-range: bytes */<size>, several 200 with the whole body; anIf-Rangesends the whole body. Every answer carriescontent-security-policy: default-src 'none'; style-src 'unsafe-inline'; sandboxandx-content-type-options: nosniff, whatever the type and the app's security headers, so an uploaded page cannot run script or read the app's origin.cache-controlisprivate, no-cache(no shared cache keeps the blob, a browser revalidates with the ETag);cacheControlreplaces it, for examplepublic, max-age=31536000, immutablefor a pathname that is never written twice. The headers and the bytes are one version: the bytes are read only if the pathname still holds the ETag the headers carry (If-Matchons3,onlyIfoncloudflare-r2, the object file the metadata names onfs); a put in between makesserveread again, and after three tries it answers 503Blob changed while it was read. A missing pathname throws a 404Blob not found.presignreturns a URL for oneGETorPUTof the pathname straight on storage, so the bytes never pass the server; it is signed bypresignS3ofsilgi/serveron every preset and validexpiresInseconds, 1 to 604800 (default 3600; otherwiseRangeError: presign expiresIn must be 1 to 604800 seconds (got 0)). It works on thes3driver only, R2 through its S3 endpoint;fsandcloudflare-r2throwpresign is not available on the fs driver; use serve(event, pathname), or the s3 driver. APUTalways signs a type, so the client must send exactly thatContent-Typeand storage refuses another:contentType, or without it the typeputwould store (the extension's, elseapplication/octet-stream).contentTypeis checked asput's and must be given in canonical form, the value that is signed:text/plain;charset=utf-8throwsTypeError: Invalid presign content type "text/plain;charset=utf-8": pass "text/plain; charset=utf-8", the form that is signed. AGETtakes nocontentType(TypeError: presign contentType is signed on a PUT only: a GET sends no body). The URL does not limit the size:headthe object when the client says it is done, pass the result toensureBlob, anddeleteit when the rule fails. A browser on another origin needs the bucket's CORS rules to allow the method.
@silgi/blob/server
useBlob
function useBlob(): BlobStore;
The app's store on the driver the build chose, opened at its first use. A catch-all route param is a string[] of the URL's segments, so a route serving a pathname joins it.
import { defineHandler } from "silgi/server";
import { useBlob } from "@silgi/blob/server";
export const file = defineHandler({
method: "GET",
route: "/files/**:pathname",
raw: true,
async handler({ pathname }, { event }) {
await requireUser(event);
return useBlob().serve(event, pathname.join("/"));
},
});
ensureBlob
function ensureBlob(blob: Blob | BlobObject, options: { maxSize?: number | `${number}${"B" | "KB" | "MB" | "GB"}`; types?: string[] }): void;
Throws a 400 File size 9.2MB is over 8MB (the size in maxSize's unit, KB = 1024 B) or File type text/plain is not allowed (allowed: image). A types entry is a MIME type (image/png) or a top-level type (image). Without both it throws TypeError: ensureBlob needs maxSize or types. It takes a Blob or File, or the BlobObject that head returns (its contentType is the type checked), so an object uploaded on a presigned URL is checked after the upload. The type checked is the one the client sent, so it is not a safety check; serve sandboxes active types whatever it said.
handleMultipartUpload, MultipartUploadOptions
function handleMultipartUpload(event: SilgiEvent, options: MultipartUploadOptions): Promise<Response>;
interface MultipartUploadOptions {
action: string; // the route's :action param
pathname?: (name: string) => string; // the client's name → the stored pathname
ensure?: { maxSize?: number | `${number}${"B" | "KB" | "MB" | "GB"}`; types?: string[] }; // ensureBlob's options
put?: { addRandomSuffix?: boolean; customMetadata?: Record<string, string> };
direct?: boolean; // s3 only: parts go from the browser to storage
expiresIn?: number; // seconds a direct part URL is valid, default 900
secret: { runtimeConfig: string }; // the runtime config key of the secret that signs upload tokens
}
A file over one request's body limit, or one that needs progress, is sent in parts of 5 MiB or more, each its own request. A single request needs no helper: a handler with a File in its input is the upload, bound by its form.maxBodySize and the preset's request body limit (4.5 MB on vercel, 6 MB on netlify). One raw handler serves every step:
import { defineHandler } from "silgi/server";
import { handleMultipartUpload } from "@silgi/blob/server";
export const upload = defineHandler({
method: "*",
route: "/upload/:action",
raw: true,
async handler({ action }, { event }) {
const user = await requireUser(event);
return handleMultipartUpload(event, {
action,
pathname: (name) => `uploads/${user.id}/${name}`,
ensure: { maxSize: "2GB" },
direct: true,
secret: { runtimeConfig: "uploadSecret" },
});
},
});
| Step | Request | Answer |
|---|---|---|
create | POST …/create { pathname, contentType?, size, partSize? } | { pathname, uploadId, partSize }, and direct: true in direct mode |
upload, through the function | PUT …/upload?pathname=&uploadId=&partNumber= with the part's bytes | { partNumber, etag } |
upload, direct: true | POST …/upload { pathname, uploadId, partNumber, size } | { partNumber, url }: a presigned UploadPart URL |
complete | POST …/complete { pathname, uploadId, parts: [{ partNumber, etag }] } | the BlobObject |
abort | DELETE …/abort?pathname=&uploadId= | 204 |
-
pathnameis the client's name;options.pathnamemaps it to the stored pathname on every step, so the handler decides where a user may write on each request. Withput.addRandomSuffixthe suffix is added to the name atcreate, and thepathnameanswered carries it. -
createneedssize, the file's bytes (create needs size (the file's bytes));ensureis checked there, before any part, withensureBlob's 400 messages. The type stored iscontentType, else the extension's, checked asput's.partSizeis 5 MiB to 5 GiB (default 10 MiB) and the file at most 10000 parts (A file of 52434042880 bytes needs more than 10000 parts of 5242880 bytes). Through the function,partSizemust also fit the request body limit (server.maxRequestBodySize, or 100 MB on cloudflare):create partSize 209715200 is over the request body limit of 100000000 bytes on the cloudflare preset; send smaller parts, or use direct: true. -
The
uploadIdis a token the server signs (HMAC-SHA-256) over the stored pathname,size,partSize, storage's id and an expiry 24 hours aftercreate, with a key derived fromruntimeConfig[secret.runtimeConfig]. Set it from the environment (runtimeConfig: { uploadSecret: "" }andSILGI_UPLOAD_SECRET), at least 32 characters; unset or shorter, every step answers 503Upload endpoint is not configuredand the server logshandleMultipartUpload needs runtimeConfig.uploadSecret (at least 32 characters). A literal throwsTypeError: handleMultipartUpload secret must be { runtimeConfig: "<key>" }; a secret in the code ends up in the repository. Changing the secret ends the uploads in flight. Each part must hold exactly its share:partSizebytes, the rest ofsizefor the last; a part through the function of other bytes is 400 (413 above its share), a direct part'ssizeis signed into its URL ascontent-length, so storage refuses another.completetakes exactly the parts 1 to n. So the object that takes the pathname is always the size and typecreatechecked: a refused upload never replaces what is at the pathname, and nothing refused is ever stored there. -
Another step answers 404
Unknown upload step "list", a step with the wrong method 405The upload step "create" takes POSTwithallow, a malformed body, a part of the wrong size or past the last, or a part list storage refuses 400Invalid upload request, anuploadIdthat does not verify, has expired or belongs to another pathname 404Upload not found.abortof an upload that is gone answers 204. An error thrown by the app'spathnamefunction is a 500 whose message is not sent, unless it is an HTTP error (createError), which answers as it is. -
Through the function
s3andcloudflare-r2use their native multipart upload andfskeeps parts in<base>/multipart/<uploadId>/<n>untilcompletejoins them orabortremoves them; a folder older than 24 hours is dropped at the nextcreate. On vercel and netlify a part leaves no room under the request body limit, so a call withoutdirectthrowshandleMultipartUpload on the vercel preset needs direct: true: a part of at least 5 MiB is over the function's 4.5 MB request body(netlify:… is within 1 MB of the function's 6 MB request body). -
Direct (
direct: true) needs thes3driver (handleMultipartUpload direct: true needs the s3 driver (blob.driver is "fs")):create,completeandabortgo through the handler, each part from the browser to storage on a URL signed bypresignS3, so no part passes the function. The bucket's CORS rules must allowPUTfrom the app's origin and exposeETag; on S3, and in an R2 bucket's CORS policy:[{ "AllowedOrigins": ["https://app.example.com"], "AllowedMethods": ["PUT"], "AllowedHeaders": ["*"], "ExposeHeaders": ["ETag"], "MaxAgeSeconds": 3600 }] -
Uploads are not resumable: an upload cut by a reload or a closed tab is left for storage to expire (an S3 or R2 lifecycle rule for incomplete multipart uploads).
@silgi/blob/client
useMultipartUpload, MultipartUploadClientOptions, MultipartFileOptions, MultipartUpload
function useMultipartUpload(url: string, options?: MultipartUploadClientOptions): (file: File, options?: MultipartFileOptions) => MultipartUpload;
interface MultipartUploadClientOptions { partSize?: number; concurrency?: number }
interface MultipartFileOptions { pathname?: string; onProgress?: (sent: number, total: number) => void }
interface MultipartUpload { completed: Promise<BlobObject>; abort(): Promise<void> }
The client of handleMultipartUpload at url (/upload for the handler above): plain fetch with no UI library, the same in every adapter.
import { useMultipartUpload } from "@silgi/blob/client";
const upload = useMultipartUpload("/upload", { concurrency: 3 });
const { completed, abort } = upload(file, { onProgress: (sent, total) => console.log(sent / total) });
const object = await completed;
- It sends
size: file.size,partSizeand the file's type atcreate, then each part through the handler, or, when theuploadanswer carries aurl, to that URL withPUT, reading itsETagheader. A storage answer without a readableETagrejectscompletedwithStorage answered part 3 without a readable ETag; expose ETag in the bucket's CORS rules. partSizeis 5 MiB to 5 GiB, S3's limits (default 10 MiB; the last part may be smaller);concurrencyis the parts in flight (default 1). Otherwise it throwsRangeError: useMultipartUpload partSize must be a whole number of bytes from 5 MiB to 5 GiB (got 1048576)orRangeError: useMultipartUpload concurrency must be a whole number from 1 (got 0). A file over 10000 parts rejectscompletedbefore any request:RangeError: A file of 52434042880 bytes needs more than 10000 parts of 5242880 bytes; raise partSize.pathnameis the name sent atcreate, defaultfile.name.onProgress(sent, total)is called after each part is stored,sentthe bytes of the stored parts:fetchreports no progress inside a request.- A part that failed on the network or with a 5xx is sent once more (a 4xx is not); then the upload is aborted and
completedrejects with the handler's message (File size 2.0MB is over 1MB) and itsstatus.abort()beforecompleteis sent stops the parts in flight, calls theabortstep and resolves when it answered;completedthen rejects with anAbortError. Oncecompleteis sent,abort()no longer stops the upload: it resolves whencompletedsettles, andcompletedresolves with the object.abort()never rejects.