Skip to content
DocspackagesDocumentation

@lunora/storage

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

PackagesStorage

@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. 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, without 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. baseUrl (and .storage()'s publicBaseUrl) must be a bare origin: getSignedUrl rejects a URL that carries a path, since the signature binds only host + key and verification reconstructs the key from the full URL pathname. The Worker route handling the signed download/upload (mounted at that origin, e.g. GET /: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]);

End-user uploads (RLS-gated)

The admin studio upload path (storageUpload/_lunora/admin/storage) is gated by an adminToken, which is right for the file browser and wrong for end users. For browser-driven uploads with live progress, pause/resume, large-file resumable and per-part retry, @lunora/storage/upload mounts @visulima/storage's resumable upload handlers (TUS / chunked-REST / multipart) behind an app-supplied RLS gate. Drive it from the client with @lunora/react/upload (or @lunora/vue / @lunora/solid / @lunora/svelte, or the framework-agnostic @lunora/client/upload). Lunora does not hand-roll the uploader.

createUploadHandler returns a fetch(request) you mount on the route your client uploads to. The authorize callback is the RLS decision: it runs before every request (create, chunk PATCH, resume HEAD, delete) and denies fail-closed: returning false or throwing yields a 403, never a 500.

import { createR2UploadStorage, createUploadHandler } from "@lunora/storage/upload";

// Back the handler with R2 via the dependency-light `aws-light` provider
// (aws4fetch, no AWS SDK). Needs `nodejs_compat` in wrangler.jsonc.
const uploads = createUploadHandler({
    storage: createR2UploadStorage({
        accountId: env.CF_ACCOUNT_ID,
        bucket: "user-uploads",
        accessKeyId: env.R2_ACCESS_KEY_ID,
        secretAccessKey: env.R2_SECRET_ACCESS_KEY,
        path: "/upload",
    }),
    // RLS: resolve the caller from the request and allow only their own prefix.
    authorize: async ({ request }) => {
        const user = await resolveUser(request); // your session/JWT check

        return user !== null;
    },
});

export default {
    async fetch(request: Request): Promise<Response> {
        const url = new URL(request.url);

        if (url.pathname.startsWith("/upload")) {
            return uploads.fetch(request);
        }

        return handleRest(request);
    },
};

protocol defaults to "tus" (the resumable, pause/resume-capable path); pass "chunked-rest" or "multipart" to mount a different one. Use a memory provider (@visulima/storage/provider/memory) in tests to exercise the whole flow (progress, pause/resume, resume-after-drop) without a live bucket.