Direct and multipart uploads

Upload directly to S3-compatible storage with resumable parts, checksums, owner quotas and private downloads.

On this page

@optz/uploads adds authenticated upload control APIs over S3-compatible storage. File bytes go directly from the browser to storage; OPTZ reserves quota, signs short-lived requests and approves a separate immutable copy. Existing small local uploads remain optz/storage.

Compose the server

The package is part of this workspace; add "@optz/uploads": "workspace:*" to an app's dependencies and run bun install at the repository root. Provider SDKs are confined to the server-only /s3 export, never the optional /client helper.

// app/lib/uploads.server.ts
import { openDatabase } from "optz/sqlite";
import { createUploads, createUploadHandler } from "@optz/uploads";
import { sqliteUploadStore } from "@optz/uploads/sqlite";
import { createS3Provider } from "@optz/uploads/s3";
import { getAuth } from "./auth.server";

const db = openDatabase(process.env.UPLOAD_DATABASE_PATH!);
const uploads = createUploads({
  store: sqliteUploadStore(db, { bytesPerOwner: 1024 ** 3, pendingPerOwner: 3 }),
  provider: createS3Provider({
    bucket: process.env.S3_BUCKET!,
    client: { endpoint: process.env.S3_ENDPOINT!, region: "auto", forcePathStyle: true },
    checksums: true,
    urlSeconds: 300,
  }),
  types: ["text/plain", "image/png", "image/jpeg", "application/pdf"],
  maxBytes: 100 * 1024 ** 2,
});

export const handleUpload = createUploadHandler({
  uploads,
  origin: process.env.PUBLIC_URL!,
  authorize: async request => {
    const session = await getAuth().getSession(request);
    return session ? { id: session.user.id } : null;
  },
});

For AWS use the bucket's region; R2 commonly uses auto, and B2 uses the region in its endpoint. Supply provider credentials through its supported server credential chain, IAM roles or Doppler. Keep the bucket private and configure CORS for the application's exact origin, PUT and the signed Content-Type/checksum headers. Only the file-upload page needs its storage origin added to connect-src; do not relax every page's CSP.

Mount the control route

// app/api/uploads/[operation].ts
import type { ApiContext } from "optz";
import { handleUpload } from "$lib/uploads.server";
export const POST = ({ request }: ApiContext) => handleUpload(request);

All actions require a session, same-origin mutation checks, bounded JSON and the trusted session's owner ID. The adapter uses a bounded per-owner request budget. Forwarded IP headers do not decide ownership or quotas.

POST actionInputResult
initiatename, type, sizeOpaque ID and upload plan; reserves quota before provider work
resumeidCurrent plan and provider-reported uploaded parts
signid, optional part, SHA-256 checksum when enabledShort-lived URL and required headers
completeidApproved private key and metadata; does not trust browser ETags
abortidAborts provider multipart state, removes staging objects and cancels the reservation
downloadidOwner-only, short-lived attachment URL for an approved object
deleteidRemoves the approved object and cancels its reservation

Quota reservations count active and completed uploads, plus recently cancelled uploads. Expired reservations are retained until cleanup removes provider resources. Cancellation holds its reservation for 16 minutes (the maximum signed-URL TTL plus clock skew), because an already issued staging PUT cannot be revoked. Cleanup deletes any replayed staging object before releasing the reservation. Opening and cancelling sessions therefore cannot immediately evade the byte budget. SQLite uses an atomic conditional insert and compare-and-swap finalization across connections/processes. Unknown and cross-owner IDs both answer 404.

Browser helper

import { uploadFile } from "@optz/uploads/client";

const controller = new AbortController();
const result = await uploadFile(selectedFile, {
  endpoint: "/api/uploads",
  signal: controller.signal,
  concurrency: 3,
  retries: 2,
  onProgress: (sent, total) => console.log(sent, total),
});

The helper has no provider SDK or persistent browser state. It uses File slices, fetch and Web Crypto. Failed calls attach uploadId to the error; retain it and pass resumeId after selecting the same file. A part is skipped only if the provider's SHA-256 matches the local slice. With checksums disabled, resumed parts are re-uploaded rather than trusting size or ETag as file identity. An error aborts sibling workers; retries and concurrency are bounded.

The default multipart threshold is 32 MiB and part size 8 MiB. Part buffers are bounded to 64 MiB, with concurrency 1–6. Whole-file checksum buffering applies only below the single-PUT threshold, which is capped at 64 MiB. Progress counts successful bytes, not speculative in-flight progress. Stopping retains resumable staging state; call the abort action to discard it.

Integrity and approval

The S3 adapter signs content length and, for a single PUT, content type. SHA-256 mode binds the checksum header and checks provider part metadata. Some compatible providers support only a subset of S3 checksum features; set checksums: false only after verifying that provider's capabilities. This mode still checks declared sizes and uses immutable promotion; it does not pretend that an ETag is SHA-256.

Completion lists provider parts, checks contiguous numbering and exact sizes, completes the multipart object, validates its metadata, then copies it to a different never-presigned key. Reusing an old staging URL cannot change approved bytes. An optional asynchronous inspect hook receives the immutable copy's key before approval; reject it to withhold access. This supports a scanner/content-inspection service without proxying the file through OPTZ.

Transport MIME declarations are not content inspection. Keep unapproved data private and serve downloads as attachments. The supplied S3 adapter uses native server-side CopyObject promotion and intentionally caps an object at 5 GiB, although providers may support larger objects. Its default application limit is 100 MiB. Larger-object approval requires a separate multipart-copy/worker design, not a silent removal of the approval boundary.

Cleanup and recovery

Schedule await uploads.cleanup() in an explicit maintenance job. It processes at most 100 eligible sessions per call, aborts multipart state and removes staging/unfinished promoted objects before releasing quota. Unfinished sessions become eligible after their upload lifetime plus the signed-URL grace period; cancelled sessions after the 16-minute hold. SQLite can reclaim a crashed finalization after a one-hour lease. Run cleanup repeatedly when more than 100 sessions have expired.

Also configure provider lifecycle rules to abort incomplete multipart uploads and expire the pending/ prefix. Staging URLs are bearer capabilities until expiry (maximum 15 minutes), so never log or publicly cache them. Completed object deletion uses an authenticated action.

The auth example includes a protected /files route and native progress/cancellation UI. Live provider compatibility and CORS still need verification with your bucket; local tests cover presigning plus a provider fixture's ownership, quota, integrity, scan and recovery behavior.

Primary references: S3 multipart limits, R2 compatibility, B2 S3 compatibility.