Skip to content
DocsconceptsDocumentation

File storage

Typed R2 buckets exposed as ctx.storage — uploading, serving via signed URLs, and deleting files from your functions.

Last updated:

@lunora/storage wraps Cloudflare R2 in a typed surface and threads it onto every function context as ctx.storage. A file is addressed by a string key you choose; the bucket and signing secret are configured once and never appear in a handler. As with everything else in Lunora, which operations you can reach depends on the kind of function you're in.

import { action, v } from "@/lunora/_generated/server";

export const importLogo = action.input({ key: v.string() }).action(async ({ ctx, args: { key } }) => {
    const upstream = await ctx.fetch("https://example.com/logo.png");
    await ctx.storage.store(key, await upstream.arrayBuffer(), { contentType: "image/png" });
    return ctx.storage.getUrl(key);
});

Which contexts expose what

ctx.storage is typed read-only inside a query or a mutation, and with the full read/write surface inside an action. The split is deliberate: queries are pure reads, and mutations run inside a transactional scope. Neither should perform a side-effectful R2 write or delete, because those can't participate in the transaction or be rolled back. Both can still read existing objects and mint signed URLs, since signing is an HMAC computation with no R2 round-trip.

The split is enforced by TypeScript, not by the runtime: one object is built per context and the type is what narrows it, so a mutation that casts past its type still reaches delete. Where you need the narrowing to hold against more than a compiler, add storageRules(...) — it rebuilds ctx.storage as an allowlist of the gated surface and refuses the members no rule can gate (getPresignedUrl, list), so nothing can route around a rule.

OperationQueryMutationAction
download / getMetadata / head✓✓✓
getUrl / getSignedUrl✓✓✓
deleteAfterCommit—✓—
generateUploadUrl / getPresignedUrl——✓
store / upload——✓
createMultipartUpload / resumeMultipartUpload——✓
list——✓
delete——✓

In short: serve and inspect files from anywhere; mint an upload URL or write and delete bytes only from an action. A mutation is not typed to delete bytes, but it can queue a delete that runs once its transaction commits — see Deleting.

Uploading

Choosing an upload path

PathUse it forRequestsProgress, pause/resume
Signed upload URL (generateUploadUrl)Small files from the browser: an avatar, an attachmentOne PUTNo
Resumable uploads (@lunora/storage/upload)Large files from the browser, flaky connections, a progress barOne per chunkYes (TUS, chunked REST)
storeBytes the server already holds: fetched, generated or transformedNone (in-process)—
MultipartStreaming a body above store's 16 MiB stream cap into R2One per partResumable by uploadId
Presigned S3 URLLarge objects that should never pass through the WorkerOne PUTNo

Signed upload URL

The browser-friendly pattern is a signed upload URL: an action mints a short-lived PUT URL, the browser uploads to it without holding any bucket credential, and a follow-up mutation records the key in your database. The URL points back at your own Worker, not at R2 — that is the point of it, since the request still passes your app's gates (auth, storage rules, rate limits) before the Worker verifies the signature and streams the bytes into the bucket. The trade-off is that the bytes flow through the Worker.

import { action, v } from "@/lunora/_generated/server";

export const requestUpload = action.input({ key: v.string(), contentType: v.string() }).action(async ({ ctx, args: { key, contentType } }) => {
    if (!ctx.auth.userId) throw new Error("must be signed in");
    const url = await ctx.storage.generateUploadUrl(`avatars/${ctx.auth.userId}/${key}`, {
        contentType,
        expiresInSeconds: 60,
    });
    return { url };
});

Server-side store

When the bytes already live server-side (fetched in the same action, generated, or transformed), upload them directly with store, which also enforces optional maxSize / allowedContentTypes guards:

await ctx.storage.store(key, body, { allowedContentTypes: ["image/png", "image/jpeg"], maxSize: 5_000_000 });

Uploading straight to R2

When you want the bytes off the Worker's CPU and bandwidth budget entirely — large objects, no per-request app logic — use a native S3 presigned URL instead: @lunora/storage's getPresignedUrl(key, { method: "PUT" }) signs a SigV4 URL that the client sends directly to R2's S3 endpoint. It needs R2 S3 API credentials (s3: { accountId, accessKeyId, secretAccessKey, bucket }) on the storage instance; without them the call throws. Because such a URL is a self-contained bearer credential that never touches your Worker, it cannot be gated by your app's storage rules and is deliberately absent from the rule-enforced ctx.storage surface.

Large objects through the Worker

store buffers a streamed body under its maxSize cap, so a stream uploaded with maxSize is capped at 16 MiB. Above that, stream the body into R2 with a multipart upload from an action: createMultipartUpload wraps R2's native multipart API, and every part except the last must be at least 5 MiB and the same size. Persist uploadId and pick the upload back up in a later request with resumeMultipartUpload(key, uploadId).

import { action, v } from "@/lunora/_generated/server";

export const importVideo = action.input({ url: v.string() }).action(async ({ ctx, args: { url } }) => {
    const upload = await ctx.storage.createMultipartUpload("videos/clip.mp4", { contentType: "video/mp4" });

    try {
        const parts = [];
        let partNumber = 1;

        for await (const chunk of fiveMiBChunks(url)) {
            parts.push(await upload.uploadPart(partNumber++, chunk));
        }

        return (await upload.complete(parts)).key;
    } catch (error) {
        await upload.abort();
        throw error;
    }
});

fiveMiBChunks stands for your own code that re-chunks a source into 5 MiB pieces. list(prefix, { cursor, limit }) is action-only too. Use it to sweep staging objects an abandoned upload left behind, and paginate on truncated:

let cursor: string | undefined;

do {
    const page = await ctx.storage.list("staging/", { cursor, limit: 100 });

    await Promise.all(page.objects.map(async (object) => ctx.storage.delete(object.key)));
    cursor = page.truncated ? page.cursor : undefined;
} while (cursor);

Under a procedure guarded by storageRules(...), createMultipartUpload and resumeMultipartUpload are checked against the bucket's write rules like store. list enumerates keys no read rule has vetted, so it rejects with FORBIDDEN there; the rule-scoped enumeration is ctx.db.system.query("_storage").

Resumable uploads from the browser

For large files, or anything that needs a progress bar or has to survive a dropped connection, mount the upload handler from @lunora/storage/upload on an HTTP route and drive it from the client with useUpload. The handler wraps @visulima/storage's handlers, and the client hooks re-export @visulima/storage-client, so Lunora does not hand-roll the wire protocol.

One handler speaks one protocol:

  • "tus" (the default): resumable. The client can pause, resume, and continue after a dropped connection from the last acknowledged byte.
  • "chunked-rest": plain REST chunks, one at a time and in order, which is how the bundled client sends them. Not resumable mid-chunk the way TUS is.
  • "multipart": one multipart/form-data request per file. Not resumable.

Mount the handler

authorize is the gate. It runs on every request of an upload: POST creates it, PATCH sends a chunk, HEAD asks where to resume, and on TUS DELETE cancels one in progress. It allows the request only when it returns exactly true; anything else, or a throw, answers 403. On TUS and chunked REST a request that declares a size over maxFileSize is answered 413 before the gate runs. The route is write-only: GET, and any other method the protocol does not upload with, answers 405 before the gate runs, so the upload route never serves a stored file. A finished upload cannot be deleted through the route at all: TUS refuses to terminate one, and the chunked-REST and multipart routes answer DELETE with 405. Removing a stored file is ctx.storage.delete in your own code.

The gate has two jobs. On POST it decides who may start an upload. On every other method it must check that the upload in the URL belongs to the caller, because anyone who learns an upload's id could otherwise append to it, or cancel it. So record the owner when the upload is created. In an HTTP action the caller is already resolved on ctx.auth:

// lunora/http.ts
import { createR2BindingUploadStorage, createUploadHandler } from "@lunora/storage/upload";
import { httpAction, httpRouter } from "lunorash/server";
import { env } from "cloudflare:workers";
import { internal } from "@/lunora/_generated/api";

// Writes through the Worker's R2 binding (`UPLOADS` in wrangler.jsonc): no S3
// credentials, and the same under `lunora dev` / miniflare as in production.
// Needs `nodejs_compat`.
const storage = createR2BindingUploadStorage(env.UPLOADS);

const MiB = 1024 * 1024;

/** The upload id in `/uploads/<id>`. */
const uploadId = (url: URL) => url.pathname.split("/").pop() ?? "";

const upload = httpAction(async (ctx, request) => {
    const { userId } = ctx.auth;

    if (userId === null) {
        return new Response("Unauthorized", { status: 401 });
    }

    const handler = createUploadHandler({
        storage,
        maxFileSize: 2048 * MiB, // 2 GiB, the ceiling for any upload
        // Lower it per upload from the type the client declares.
        maxFileSizeFor: ({ contentType }) => (contentType.startsWith("image/") ? 10 * MiB : undefined),
        authorize: async ({ method, url }) => method === "POST" || (await ctx.runQuery(internal.uploads.isOwner, { uploadId: uploadId(url), userId })),
    });
    const response = await handler.fetch(request);

    // The new upload's URL comes back in `Location`: record who owns it.
    const location = response.headers.get("location");

    if (request.method === "POST" && response.ok && location !== null) {
        await ctx.runMutation(internal.uploads.claim, { uploadId: uploadId(new URL(location, request.url)), userId });
    }

    return response;
});

const app = httpRouter();

// The create request goes to `/uploads`, every later one to `/uploads/<id>`.
app.all("/uploads", upload);
app.all("/uploads/*", upload);

export default app;

internal.uploads.claim and internal.uploads.isOwner are your own internal functions over an uploads table: one inserts { uploadId, userId }, the other reports whether that row exists. The same table is where you check ownership before you serve the file.

An upload's progress lives in the bucket, not in the Worker: one small state object per upload under _lunora/uploads/, written with conditional puts, so a chunk can land on any isolate and two requests for the same upload cannot both write it (the second gets a 409, and the client re-reads its offset and continues). Building the handler per request is therefore fine, and it is what lets the gate see this request's ctx.auth. The provider names the object from the upload id alone, so it cannot put each user's uploads under their own key prefix. Ownership lives in your table instead.

The state objects stay after an upload finishes (so HEAD still reports it complete), and so do those of uploads that were abandoned. Add an R2 object lifecycle rule that deletes objects under _lunora/uploads/ after a few days, longer than any upload should take.

maxFileSizeFor caps one upload below maxFileSize, from the MIME type and metadata its create request declares. It runs after authorize, on creates only: TUS and chunked REST fix the total when the upload starts, and a chunk past it is refused. What the client declares is not proof of what it sends, so pair a type-based cap with allowMIME on the provider (createR2BindingUploadStorage(env.UPLOADS, { allowMIME: ["image/*", "video/*"] })).

createR2UploadStorage is the other R2 provider: it writes over R2's S3 API with S3 credentials and no binding. Prefer createR2BindingUploadStorage: it needs no credentials and runs under wrangler dev.

Upload from the client

import { useUpload } from "@lunora/react/upload";

export function VideoUpload({ token }: { token: string }) {
    const { upload, progress, isUploading, isPaused, pause, resume, error } = useUpload({
        endpointTus: "/uploads",
        method: "tus",
        // Sent on every request, so the route's `ctx.auth` resolves the caller.
        headers: { authorization: `Bearer ${token}` },
        restrictions: { allowedFileTypes: ["video/*"], maxFileSize: 2 * 1024 * 1024 * 1024 },
        onSuccess: (file) => console.log("uploaded", file.id),
    });

    return (
        <>
            <input type="file" onChange={(event) => event.target.files?.[0] && void upload(event.target.files[0])} />
            {isUploading ? <progress value={progress} max={100} /> : null}
            {isUploading ? <button onClick={() => (isPaused ? void resume?.() : pause?.())}>{isPaused ? "Resume" : "Pause"}</button> : null}
            {error ? <p role="alert">{error.message}</p> : null}
        </>
    );
}

Pass method so the protocol is always the one your route speaks. Without it, useUpload uses the only endpoint you give it, or, given several, picks one by file size against tusThreshold (10 MB by default). Each handler speaks one protocol, so each endpoint needs its own route, such as a TUS handler on /uploads and a protocol: "multipart" handler on /uploads-form. Client restrictions reject a file before any request is sent. They are a convenience, not a check: the server's maxFileSize and authorize are what hold.

A failed upload surfaces on error as a plain Error whose message carries the HTTP status (Upload failed: 403). A file the client-side restrictions reject throws a RestrictionError.

The other clients ship the same uploader under their own names:

PackageEntry pointNotes
@lunora/react/uploaduseUpload, useTusUpload, useFileInput, …As above.
@lunora/vue/uploaduseUpload, useTusUpload, useFileInput, …Same options as React.
@lunora/svelte/uploadcreateUpload, createTusUpload, …Same options as React.
@lunora/solid/uploadcreateUpload, createTusUpload, …Same options as React.
@lunora/angular/uploadupload(options)TUS or chunked REST only (protocol); no size-based selection.
@lunora/client/uploadcreateUpload(options), createTusAdapter, …Framework-free; one endpoint plus a protocol (default "tus"), no size selection.

Authenticating the upload route

Send the caller's credential in a header (headers on the hook, a static object or an async function) or as the session cookie your auth setup already uses. The route's ctx.auth resolves it the same way it does for any other HTTP action. Never put a token in the upload URL: TUS clients store and reuse upload URLs to resume, and a URL ends up in logs and Referer headers.

Serving the result is a separate route; the upload route answers GET with 405. Register the upload routes on their own prefix, so a GET route that streams stored objects with serveStorageObject(ctx, key, request, authorize), or that checks a signed URL, cannot shadow them. Each route keeps its own gate: the upload authorize decides who may write, the serving route's authorize who may read. serveStorageObject reads through the Worker's .storage() R2 binding, so pass the upload provider the binding of that same bucket.

Limits

  • Request body size. Every request passes through the Worker, so each one must fit Cloudflare's request body limit for your plan (100 MB on Free and Pro). TUS and chunked REST send the file as chunks of chunkSize bytes, one request each. A multipart upload is a single request, so the whole file has to fit.
  • R2 part size. R2 requires every multipart part but the last to be at least 5 MiB and all of them the same size. createR2BindingUploadStorage coalesces chunks of any size into 5 MiB parts, so the client's chunkSize is free. Over createR2UploadStorage each chunk becomes one part, so chunkSize has to be the same 5 MiB or more on every chunk (the TUS client's default is 5 MiB).
  • Unsupported TUS extensions. The binding provider does not offer creation-defer-length, concatenation (parallel uploads) or checksum. A request that sends Upload-Checksum anyway gets a 400 before its body is read.
  • Method overrides. A request carrying X-HTTP-Method-Override (or X-HTTP-Method, X-Method-Override) gets a 405, so no request can pass the write gate as one method and run as another.
  • Chunked REST over either R2 provider takes one chunk at a time and in order: a chunk at any other offset is a 409 and is not stored, and over the binding provider so is one sent while another is streaming. The bundled client sends one chunk at a time, so its uploads complete; @lunora/storage has the details.
  • maxFileSize defaults to 100 MiB, has no unlimited setting, and must be a finite, non-negative number. Uploads over it get a 413. @lunora/storage covers how each protocol declares its size.
  • No authorize mounts an open, writable endpoint and logs a warning when the handler is built. Pass public: true only for a bucket that is meant to be open.

In tests, back the handler with @visulima/storage's memory provider (@visulima/storage/provider/memory) to run whole uploads, including pause and resume, without a bucket.

Serving files

getUrl(key) returns a stable public URL against the configured base. For private objects, getSignedUrl(key, { expiresInSeconds }) returns a short-lived URL that grants access without exposing the bucket. Because signing is HMAC-only, you can hand one out from a query to feed a <img src> reactively:

import { query, v } from "@/lunora/_generated/server";

export const avatarUrl = query.input({ key: v.string() }).query(async ({ ctx, args: { key } }) => {
    if (!(await ctx.storage.getMetadata(key))) return null;
    return ctx.storage.getSignedUrl(key, { expiresInSeconds: 300 });
});

To stream the body through your Worker instead, download(key) returns the R2 object — its metadata plus a body stream — or null when the object is absent. It is not a bare ReadableStream: read it with arrayBuffer() / text(), or pass object.body straight into a Response. getMetadata(key) returns the size, content-type, sha256, and any custom metadata without fetching the body. head(key) is the same body-free read one level down — the raw R2 object shape, with the etag and base64 digest an HTTP response needs. Reach for it when you need the object's size before deciding what to fetch (resolving a Range header is the usual case); a download() there would start a full-object body transfer you then throw away.

Under storageRules(...), head is gated as a read exactly like getMetadata — it reads the same object's metadata, so the same rules apply.

Named buckets

Select a non-default bucket with ctx.storage.bucket("name"); the returned accessor scopes every operation to that bucket and keeps the same read-only / full split as the bare ctx.storage. Bucket names are typed when you declare them in your schema, so a typo is a compile error.

The typed names come from the schema — every v.storage("exports") column — plus any defineStorageRule({ bucket: "exports" }), and "default" is always there. .storage({ buckets }) on the app config binds the bucket at runtime but is invisible to codegen (it is a runtime object of (env) => … selectors), so a bucket declared only there needs a v.storage(...) column or a rule as well before ctx.storage.bucket("exports") compiles.

await ctx.storage.bucket("exports").store(key, csv, { contentType: "text/csv" }); // action
const object = await ctx.storage.bucket("exports").download(key); // any context

Deleting

delete(key) removes an object and is action-only, for the same reason writes are:

import { action, v } from "@/lunora/_generated/server";

export const removeAttachment = action.input({ key: v.string() }).action(async ({ ctx, args: { key } }) => {
    await ctx.storage.delete(key);
});

A mutation that removes the row pointing at an object does not need to schedule anything: ctx.storage.deleteAfterCommit(key) queues the delete, and the queue is flushed once the transaction commits — and never if it rolled back, so the row and the bytes cannot disagree.

import { mutation, v } from "@/lunora/_generated/server";

export const remove = mutation.input({ id: v.id("attachments") }).mutation(async ({ ctx, args }) => {
    const attachment = await ctx.db.attachments.get(args.id);

    if (!attachment) {
        return;
    }

    await ctx.db.attachments.delete(args.id);
    ctx.storage.deleteAfterCommit(attachment.key);
});

It returns void, not a promise: nothing has been attempted when it returns, and the object is still there on the next line. The flush runs after the response wherever the host can defer it, so the caller never waits on R2; the trade is that an object can briefly outlive its row, and a failed delete leaks the object (logged with its key) rather than failing a mutation that already committed. ctx.storage.bucket("avatars").deleteAfterCommit(key) queues against that bucket.

The pairing holds through composition too: a mutation an action calls through ctx.runMutation opens its own transaction, so its queued deletes are flushed when that mutation commits and dropped when it rolls back — even where the action swallows the failure and returns normally. See @lunora/storage for the full treatment, including the one case that can still orphan an object.

See also