PackagesStorage

@lunora/storage

R2-backed object storage with worker-signed and S3 presigned URLs.

@lunora/storage wraps a Cloudflare R2 binding with a typed API (upload, download, delete, list, getMetadata, multipart) and two URL schemes: a worker-signed URL the browser uses to upload/download through your Worker (so your app gates the request), and a native S3 presigned URL that hits R2 directly.

Wiring

Declare the bucket on your app. The builder calls createStorage for you and exposes the result as ctx.storage in every handler — and the same declaration backs the studio file browser.

// lunora/app.ts — defineApp is emitted by codegen into _generated/app
import { defineApp } from "@/lunora/_generated/app";

export default defineApp<Env>()
    .shard((env) => env.SHARD)
    .storage({
        bucket: (env) => env.FILES,
        // Extra named buckets, reached via ctx.storage.bucket("avatars").
        buckets: { avatars: (env) => env.AVATARS },
        publicBaseUrl: (env) => env.PUBLIC_STORAGE_BASE_URL,
        signingSecret: (env) => env.STORAGE_SECRET,
    })
    .build();

ctx.storage is a narrower projection of the API below: query and mutation handlers get a read-only surface (download, getMetadata, getSignedUrl, getUrl, list), and only action handlers get the full read/write surface (store, delete, generateUploadUrl, multipart, presigned). On ctx.storage, download(key) resolves to the ReadableStream directly.

Build the full Storage outside a handler — or to use the object-level reads, ranged downloads, and multipart shown below — with createStorage({ bucket, publicBaseUrl?, signingSecret?, s3? }). The examples that follow use such an instance, named storage.

Direct upload from an action

Minting an upload URL is a write capability, so it lives on an action. A query or mutation only gets the read-only projection — no generateUploadUrl, store, upload, or delete.

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

export const uploadAvatar = action.input({ key: v.string(), contentType: v.string() }).action(async ({ ctx, args: { key, contentType } }) => {
    const scopedKey = `avatars/${ctx.auth.userId ?? "anonymous"}/${key}`;

    // PUT URL with the Content-Type pinned into the HMAC — the client must
    // upload with exactly this Content-Type or verification fails.
    const url = await ctx.storage.generateUploadUrl(scopedKey, { contentType, expiresInSeconds: 60 });

    return { key: scopedKey, url };
});

Client-supplied keys can address peer data. Always namespace keys with a per-tenant prefix (scopeKey(userId, key) or a manual `users/${userId}/${key}` ). Lunora rejects .., NUL bytes, and leading / on every key automatically, but that check does not enforce tenancy.

Reading

On a createStorage instance, download returns an R2 object whose body is a ReadableStream (or null when the object is absent). Read it with arrayBuffer() / text(), or stream body straight into a Response.

const object = await storage.download("avatars/abc.png");

if (object) {
    const bytes = await object.arrayBuffer();
    // or: return new Response(object.body, { headers: { "content-type": object.httpMetadata?.contentType ?? "" } });
}

Pass { range } to stream only a byte window — R2 resolves the range server-side, so the unwanted bytes never reach the Worker:

const head = await storage.download(key, { range: { offset: 0, length: 1024 } });

getMetadata(key) reads size/content-type/sha256/upload time without fetching the body (an R2 HEAD), returning null when the object is absent.

list(prefix?, { cursor, limit, delimiter }) paginates: echo cursor back for the next page while truncated is true. The cursor is opaque — treat it as a string, don't parse it.

let cursor: string | undefined;
do {
    const page = await storage.list("avatars/", { cursor, limit: 100 });
    // ...use page.objects
    cursor = page.truncated ? page.cursor : undefined;
} while (cursor);

Signed URLs

A worker-signed URL resolves back through your Worker, so the request still passes your auth/policy/rate-limit gates before the body is served. The Worker route handling GET /storage/:key calls verifySignedUrl to check the signature and expiry:

import { verifySignedUrl } from "@lunora/storage";

const result = await verifySignedUrl(request.url, env.STORAGE_SECRET);

if (!result.valid) {
    // Do NOT echo result.reason to the client — "expired" vs "bad_signature"
    // is a signing oracle. It is for server logs only.
    return new Response("Forbidden", { status: 403 });
}
// result.key / result.method / result.contentType are now trusted.

getSignedUrl(key, { method, expiresInSeconds, contentType }) mints the URL. expiresInSeconds must be positive and at most 7 days. For a PUT URL, contentType is baked into the signature so the upload is only valid with that exact Content-Type; it is ignored for GET. generateUploadUrl is the Convex-compatible alias for a PUT signed URL.

Presigned URLs (direct to R2)

getPresignedUrl(key, { method, expiresInSeconds }) mints a native S3 presigned URL (SigV4) that hits R2 directly, bypassing the Worker. Use it for large transfers where you don't need per-request app gating. It needs R2 S3 API credentials, passed as s3 to createStorage:

const storage = createStorage({
    bucket: env.FILES,
    s3: {
        accountId: env.R2_ACCOUNT_ID,
        accessKeyId: env.R2_ACCESS_KEY_ID,
        secretAccessKey: env.R2_SECRET_ACCESS_KEY,
        bucket: "files",
    },
});

const url = await storage.getPresignedUrl("exports/report.csv", { method: "GET", expiresInSeconds: 900 });

Large objects (multipart)

For very large objects use R2's native multipart upload: createMultipartUpload returns a handle whose uploadPart / complete / abort you drive yourself. Persist uploadId to resume across requests with resumeMultipartUpload.

const multipart = await storage.createMultipartUpload("videos/clip.mp4", { contentType: "video/mp4" });
const part = await multipart.uploadPart(1, chunk);
await multipart.complete([part]);

See also