Blob

@silgi/blob stores 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. Storage keeps small values whole in memory; a blob is streamed. Its code is server only, and @silgi/blob/client has one function for browser uploads.

bun add @silgi/blob
import { defineConfig } from "silgi";

export default defineConfig({
  framework: "react",
  modules: ["@silgi/blob"],
  runtimeConfig: { s3Key: "", s3Secret: "", uploadSecret: "" },
  blob: {
    driver: "s3",
    bucket: "uploads",
    endpoint: "https://ACCOUNT.r2.cloudflarestorage.com",
    region: "auto",
    accessKeyId: { runtimeConfig: "s3Key" },
    secretAccessKey: { runtimeConfig: "s3Secret" },
    dev: { driver: "fs", base: ".data/blob" },
  },
});

Drivers

DriverRuns onStores
fsbun, node, static, silgi devthe bytes under <base>/objects/ and a JSON file with the type, size, ETag and metadata under <base>/meta/; base is .data/blob by default, relative to the working directory
s3every presetan object at <base><pathname> with its Content-Type and x-amz-meta-* headers; Silgi signs every request itself with fetch, on Bun too
cloudflare-r2cloudflarean object in the R2 bucket bound as binding (BLOB by default)

With no blob key you get fs at .data/blob on bun, node, static and under silgi dev, and cloudflare-r2 with the binding BLOB on cloudflare. On vercel and netlify the build fails, because their file systems do not keep files: @silgi/blob needs blob.driver on the vercel preset: its file system does not keep files; use "s3". A cloudflare-r2 binding needs an r2_buckets entry in your wrangler.json, or an id the build can write (Deploy).

The s3 driver takes the options of the s3 storage driver: bucket, region, endpoint, accessKeyId, secretAccessKey, sessionToken, virtualHostedStyle and base. What you leave out is read from S3_* and AWS_* variables. Every secret option also takes { runtimeConfig: "<key>" }, and an access key in the config file is a literal in your repository, so use it. R2 is the s3 driver with endpoint: "https://<account>.r2.cloudflarestorage.com" and region: "auto"; from Workers prefer the cloudflare-r2 driver, and use s3 when you need presigned or direct uploads.

dev replaces the config under silgi dev and in the prerender, so production can be S3 and your machine the disk. A cloudflare-r2 config under silgi dev without dev fails and tells you to set it.

Using a blob

A blob is an object under a pathname: /-separated, at most 1024 bytes of UTF-8, with no empty segment, ., .. or \. A bad pathname throws TypeError: Invalid blob pathname "a/../b".

import { defineHandler } from "silgi/server";
import { useBlob } from "@silgi/blob/server";

export const report = defineHandler({
  route: "/api/report",
  method: "POST",
  async handler() {
    const blob = useBlob();
    const stored = await blob.put("reports/2026/summary.txt", "All good", { customMetadata: { author: "ada" } });
    const head = await blob.head(stored.pathname);
    const page = await blob.list({ prefix: "reports/", folded: true, limit: 100 });
    await blob.delete("reports/2025/old.txt");
    return { size: head?.size, folders: page.folders, count: page.blobs.length };
  },
});
MethodDoes
put(pathname, body, options?)stores a string, bytes, Blob, File or stream and returns the BlobObject; replaces what is there. contentType defaults to the body's type, then the extension's, then application/octet-stream. addRandomSuffix: true gives a/photo-k3j9x0q2.jpg. customMetadata keys are a-z, 0-9 and -, values printable ASCII, 2048 bytes in all
get(pathname)a Blob, or undefined. It is lazy on fs and read whole on s3 and R2: to answer a request, use serve
head(pathname)the BlobObject (pathname, contentType, size, etag, uploadedAt, customMetadata) or undefined
list({ prefix, limit, cursor, folded })a page in pathname order, 1 to 1000 entries (default 1000), with a cursor while hasMore; folded: true groups what is below the next / into folders
delete(pathnames)removes one or many; a missing pathname does nothing
serve(event, pathname, { cacheControl })answers a request with the object
presign(pathname, { method, expiresIn, contentType })a URL for one GET or PUT straight on storage (s3 only)

A content type is one type/subtype with optional ; name=value parameters. A list such as image/png, text/html throws, because a browser would read it as text/html.

On s3 a stream of unknown length is written as a multipart upload in 10 MiB parts. On a case-insensitive disk (macOS, Windows) A.txt and a.txt are one file with fs and two objects on s3 and R2.

Serving files

serve answers GET and HEAD with the bytes streamed, content-type, content-length, etag, last-modified and accept-ranges: bytes. It answers 304 to a matching If-None-Match, 206 to a Range, 416 to an unsatisfiable one, and a missing pathname throws 404 Blob not found. Because a catch-all param is a list of segments, join it:

import { defineHandler } from "silgi/server";
import { useBlob } from "@silgi/blob/server";
import { requireUser } from "./blob-auth.ts";

export const file = defineHandler({
  method: "GET",
  route: "/files/**:pathname",
  raw: true,
  async handler({ pathname }: { pathname: string[] }, { event }) {
    await requireUser(event);
    return useBlob().serve(event, pathname.join("/"), { cacheControl: "private, max-age=60" });
  },
});

You decide who may read: check the user before serve, as the sample does. Every answer carries content-security-policy: default-src 'none'; style-src 'unsafe-inline'; sandbox and x-content-type-options: nosniff, whatever the type, so an uploaded HTML or SVG file cannot run script or read your origin. cache-control is private, no-cache by default, so no shared cache keeps a guarded file; pass cacheControl for a pathname that is never written twice (public, max-age=31536000, immutable). Headers and bytes are one version: if a put lands while serve reads, it reads again, and after three tries it answers 503 Blob changed while it was read.

Uploads

One request

A handler whose input holds a File is the upload: the generated stub sends it as multipart/form-data (Handlers). There is no upload helper to learn; ensureBlob checks the file and put stores it.

import { defineHandler } from "silgi/server";
import { ensureBlob, useBlob } from "@silgi/blob/server";
import { requireUser } from "./blob-auth.ts";

const fileInput = {
  "~standard": {
    version: 1 as const,
    vendor: "docs",
    validate: (value: unknown) => {
      const file = (value as { file?: unknown } | undefined)?.file;
      return file instanceof File ? { value: { file } } : { issues: [{ message: "file is required", path: ["file"] }] };
    },
  },
};

export const uploadAvatar = defineHandler({
  route: "/api/avatar",
  method: "POST",
  input: fileInput,
  form: { maxBodySize: 9 * 1024 * 1024, maxFileSize: 8 * 1024 * 1024, maxFiles: 1 },
  async handler({ file }: { file: File }, { event }) {
    const user = await requireUser(event);
    ensureBlob(file, { maxSize: "8MB", types: ["image"] });
    return useBlob().put(`avatars/${user.id}`, file);
  },
});

ensureBlob(file, { maxSize, types }) throws a 400 with a message the client may show (File size 9.2MB is over 8MB, File type text/plain is not allowed (allowed: image)). maxSize is bytes or "8MB" (KB = 1024 B); a types entry is a MIME type or a top-level type. The type is the one the client sent: it is not a safety check, and serve sandboxes whatever was stored.

The request is bound by the handler's form.maxBodySize (it defaults to server.maxBodySize, 1 MiB) and under it by the preset's body limit. Past either the answer is 413. A fetch upload reports no progress.

PresetRequest body limit
bun, node, silgi devserver.maxRequestBodySize, 128 MiB by default
cloudflare100 MB on Free and Pro zones, 200 MB on Business, up to 5 GB on Enterprise (Cloudflare's Workers limits)
vercel4.5 MB
netlify6 MB

Multipart: big files and progress

A file over one request's limit, or one that needs a progress bar, is sent in parts of 5 MiB or more, each its own request. One raw handler serves all the steps (create, upload, complete, abort) and useMultipartUpload in the browser calls them:

import { defineHandler } from "silgi/server";
import { handleMultipartUpload } from "@silgi/blob/server";
import { requireUser } from "./blob-auth.ts";

export const upload = defineHandler({
  method: "*",
  route: "/upload/:action",
  raw: true,
  async handler({ action }: { action: string }, { event }) {
    const user = await requireUser(event);
    return handleMultipartUpload(event, {
      action,
      pathname: (name) => `uploads/${user.id}/${name}`,
      ensure: { maxSize: "2GB" },
      direct: true,
      secret: { runtimeConfig: "uploadSecret" },
    });
  },
});
import { useMultipartUpload } from "@silgi/blob/client";

const upload = useMultipartUpload("/upload", { concurrency: 3 });

export async function send(file: File, onPercent: (percent: number) => void) {
  const { completed, abort } = upload(file, { onProgress: (sent, total) => onPercent(Math.round((sent / total) * 100)) });
  return { object: await completed, abort };
}

create takes the file's size, and ensure is checked there, before any part is sent. The pathname option maps the client's name to the stored pathname on every step, so the user check in your handler holds on each request: never store under a name the client chose without it. A refused upload never replaces the object at the pathname.

The uploadId is a token the server signs. Its key comes from runtime config: secret: { runtimeConfig: "uploadSecret" }, with SILGI_UPLOAD_SECRET set to at least 32 characters. A literal secret throws TypeError: handleMultipartUpload secret must be { runtimeConfig: "<key>" }; a secret in the code ends up in the repository, and an unset or short one answers 503 Upload endpoint is not configured. A token expires 24 hours after create.

Through the function (the default), each part passes your server, so it is bound by the body limit above: create refuses a partSize over it. On vercel and netlify a part of 5 MiB does not fit, so the call throws handleMultipartUpload on the vercel preset needs direct: true: ….

Direct (direct: true, the s3 driver only) sends each part from the browser straight to the bucket on a presigned URL. create, complete and abort still pass your handler, which holds the credentials, but no part touches the function and no body limit applies. The bucket must allow the browser to PUT and read the part's ETag, so give it a CORS rule. On S3 and in the CORS policy of an R2 bucket:

[
  {
    "AllowedOrigins": ["https://app.example.com"],
    "AllowedMethods": ["PUT"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }
]

With AllowedOrigins set to your app's origin. A browser upload without an exposed ETag rejects with Storage answered part 3 without a readable ETag; expose ETag in the bucket's CORS rules.

useMultipartUpload(url, { partSize, concurrency }) returns a function; calling it with a File gives { completed, abort }. partSize is 5 MiB to 5 GiB (default 10 MiB) and a file may have 10000 parts; concurrency is the parts in flight (default 1). onProgress(sent, total) runs after each part is stored. A part that fails on the network or with a 5xx is sent once more; then completed rejects and the upload is aborted. abort() never rejects.

Uploads are not resumable. An upload cut by a reload or a closed tab is left for storage to expire: add a lifecycle rule for incomplete multipart uploads to the bucket (fs drops folders older than 24 hours at the next create).

Presigned URLs

presign gives the browser a URL for one GET or PUT on the bucket, so the bytes never pass your server. It works on the s3 driver only; fs and cloudflare-r2 throw presign is not available on the fs driver; use serve(event, pathname), or the s3 driver. It is signed on every preset, Bun included.

import { createError, defineHandler } from "silgi/server";
import { ensureBlob, useBlob } from "@silgi/blob/server";
import { requireUser } from "./blob-auth.ts";

export const signUpload = defineHandler({
  route: "/api/sign-upload",
  method: "POST",
  async handler(_, { event }) {
    const user = await requireUser(event);
    const pathname = `inbox/${user.id}/photo.png`;
    return { pathname, url: await useBlob().presign(pathname, { method: "PUT", contentType: "image/png", expiresIn: 300 }) };
  },
});

export const confirmUpload = defineHandler({
  route: "/api/confirm-upload",
  method: "POST",
  async handler(_, { event }) {
    const user = await requireUser(event);
    const pathname = `inbox/${user.id}/photo.png`;
    const blob = useBlob();
    const head = await blob.head(pathname);
    if (!head) throw createError({ status: 404, message: "Nothing uploaded" });
    try {
      ensureBlob(head, { maxSize: "8MB", types: ["image"] });
    } catch (error) {
      await blob.delete(pathname);
      throw error;
    }
    return head;
  },
});

A PUT always signs a type: the client must send exactly that Content-Type or storage refuses it. Pass it in canonical form (text/plain; charset=utf-8, with the space), because presign returns only the URL. expiresIn is 1 to 604800 seconds (default 3600). The URL cannot limit the size, so check after the upload, as confirmUpload does: head the object, run ensureBlob on it and delete it when the check fails.

For tools that need to sign their own S3 requests, signS3Request and presignS3 are exported by silgi/server (Server API).