@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();buckets binds the bucket; it does not name it to the type system. .storage() is a runtime object, so codegen never reads it: the
StorageBucketName union that types ctx.storage.bucket(name) is built from your schema — every v.storage("avatars") column — plus any
defineStorageRule({ bucket: "avatars" }), and always contains "default". Declare the bucket in one of those two places as well, or
ctx.storage.bucket("avatars") is a compile error even though the binding resolves at runtime:
// lunora/schema.ts — this is what puts "avatars" in StorageBucketName
export const schema = defineSchema({ profiles: defineTable({ image: v.storage("avatars") }) });ctx.storage is typed by context: query and mutation handlers get a
read-only surface (download, head, getMetadata, getSignedUrl, getUrl;
a mutation also gets deleteAfterCommit), and action handlers get the whole
API below plus bucket(name) — store / upload, delete,
generateUploadUrl, getPresignedUrl, multipart (createMultipartUpload /
resumeMultipartUpload) and list. download(key) resolves to the R2 object
(its metadata plus a body stream), not to a bare ReadableStream.
The read-only / action-only split is a type-level one. The object behind ctx.storage is the same in every context, so a mutation that casts past its
type — or plain JavaScript — can still reach delete. TypeScript is the guardrail; it is not a sandbox. The runtime allowlist is storageRules(...), which
rebuilds ctx.storage as exactly the gated surface — upload and multipart are checked as write, and getPresignedUrl and list, which no rule can
gate, reject with FORBIDDEN — so nothing can route around a rule. Reach for it whenever the split has to hold against more than a compiler.
Deleting an object with the row that owns it
A mutation runs inside the shard's storage transaction, which can roll back. An
R2 delete cannot — so delete stays action-only, and a mutation that removes the
row pointing at an object queues the object instead:
export const remove = mutation.input({ id: v.id("attachments") }).mutation(async ({ args, ctx }) => {
const attachment = await ctx.db.attachments.get(args.id);
if (!attachment) {
return;
}
await ctx.db.attachments.delete(args.id);
ctx.storage.deleteAfterCommit(attachment.key);
});The queue is flushed once the transaction commits, and never if it rolled back —
so the row and the bytes cannot disagree. deleteAfterCommit returns void
rather than a promise: nothing has been attempted yet 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 (Cloudflare can), so the caller does not wait on R2. The trade is that an object can briefly outlive its row, and that a failed delete leaks the object rather than failing a mutation that already committed — each failure is logged with its key. Nothing reads an object without its row, so the window itself is invisible.
ctx.storage.bucket("avatars").deleteAfterCommit(key) queues against that bucket.
An action needs none of this for its own deletes — it is not transactional, so it
calls delete directly — but a mutation it reaches through ctx.runMutation
still queues, and those deletes are flushed when that mutation's transaction
commits, not when the action returns. The pairing therefore survives composition:
the submutation opens its own transaction, so its queued deletes go with its
commit and are dropped when it rolls back — even where the action swallows the
failure and returns normally.
A mutation composed from another mutation can orphan an object. SQLite-in-DO has no savepoints, so a ctx.runMutation issued from inside a mutation
rides the enclosing transaction: if it throws and the outer handler swallows that, its ROWS stay (nothing rolled back) while its queued deletes are dropped
with it. The result is an orphaned object rather than a dangling row — the safe direction, but not the "the row and the bytes cannot disagree" guarantee. If
a delete has to be exact, do the delete-and-row work in one mutation rather than composing it from another.
Build the full Storage outside a handler (or to use the object-level reads,
ranged downloads, and multipart shown below) with
createStorage({ bucket, bucketName, publicBaseUrl?, signingSecret?, s3? }).
bucketName is required and has no default — it is bound into every signed
URL's HMAC, so a bucket signing under the wrong name mints URLs that verify
against somebody else's bucket. 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();
// To serve it over HTTP, use `serveStorageObject` from `@lunora/server` rather than
// echoing the stored content type: it adds `nosniff` and turns any non-image/media
// type into a download, so an uploaded `text/html` never renders on your origin.
}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.
With a delimiter, the keys that share a segment are rolled up into
delimitedPrefixes (the "folders") and are NOT in objects — a folder browser
must render both, or a listing of photos/ full of photos/2026/*.png looks
like an empty directory.
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 host + bucket + 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 / result.bucketName are now trusted.Every bucket of one .storage({ bucket, buckets }) declaration shares the same
publicBaseUrl and signingSecret, so the bucket name is bound into the HMAC
(and mirrored on the URL as &bucket=) — a URL minted for avatars does not
verify against invoices. Resolve the R2 binding your route serves from
result.bucketName, never from a caller-supplied parameter. On a PUT, compare
the request's Content-Type against result.contentType and answer 415 when
they differ: the pin is only worth what the serving route enforces.
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,
bucketName: "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 });s3 has to reach the createStorage call that built the instance you are
calling — a Storage without it throws INTERNAL: 's3' credentials are required on every getPresignedUrl. Build the instance yourself (as above)
when the app's .storage() declaration does not carry s3.
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. Both
are on ctx.storage inside an action, as is list — the way to sweep abandoned
staging objects.
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.
File storage walks through mounting it as an HTTP action, checking upload ownership, the client side, and the request-size limits. This is the reference.
createUploadHandler(options) returns { fetch(request), protocol }. Mount
fetch on the route your client uploads to; one handler speaks one protocol, so
each protocol needs its own route.
| Option | Meaning |
|---|---|
storage | The @visulima/storage provider the bytes land in: createR2BindingUploadStorage(env.BUCKET) (the Worker's R2 binding), createR2UploadStorage(...) (R2's S3 API, see below), or @visulima/storage/provider/memory in tests. |
authorize | ({ method, protocol, request, url }) => boolean | Promise\<boolean>. Runs on every request (POST create, PATCH chunk, HEAD resume, DELETE cancel on TUS). Only true allows it; anything else, or a throw, answers 403. |
protocol | "tus" (default, resumable), "chunked-rest" (resumable), or "multipart" (one request per file). |
maxFileSize | Bytes. Default 100 MiB, no unlimited setting; a value that is not a finite, non-negative number throws at construction. |
maxFileSizeFor | ({ contentType, metadata, declaredSize, method, request, … }) => number | undefined. A per-upload cap below maxFileSize, from what the create request declares (TUS Upload-Metadata, chunked-REST X-File-Metadata). See below. |
public | Confirms that omitting authorize is intentional (an open bucket) and silences the warning it logs. silent is an alias. |
An upload route is write-only. It answers only the methods its protocol
needs to upload, and anything else gets a 405 before authorize runs:
| Protocol | Allowed |
|---|---|
tus | POST, PATCH, HEAD, DELETE, OPTIONS |
chunked-rest | POST, PUT, PATCH, HEAD, OPTIONS |
multipart | POST, OPTIONS |
In particular GET is refused on every protocol, including /:id/metadata and
the collection path. @visulima/storage serves stored files over GET (the
bytes, byte ranges, the file record), and behind the upload gate that would let
anyone allowed to upload, or anyone at all on a public route, read any file by
id. Serve files with ctx.storage.download() or a
signed URL, which have their own read-side check.
The Allow header on a 405 lists exactly these methods. The CORS preflight
(OPTIONS) still answers @visulima/storage's full method list in
Access-Control-Allow-Methods; that is cosmetic, since the route refuses the
rest whatever the preflight says. On TUS it drops Tus-Checksum-Algorithm when
the provider verifies no checksum (see below).
A finished upload cannot be deleted through the handler either. On TUS, DELETE
only cancels an upload in progress. The chunked-REST and multipart handlers would
delete any stored file by id, so they do not take DELETE at all.
A request carrying a method-override header (X-HTTP-Method-Override,
X-HTTP-Method or X-Method-Override) is refused with the same 405, before
authorize runs. @visulima/storage's TUS handler honors
X-HTTP-Method-Override and would swap the method after the check above, so a
POST overridden to GET would pass the write gate and be served as a read.
The handler is also built with allowMethodOverride: false. Send each request
with its real method; the bundled client does.
A HEAD, PATCH or TUS DELETE answers only for an upload the route
created: an id without upload state is a 404, whatever object the bucket
holds under that key
(visulima/visulima#918).
A chunked-REST PUT (putFile on the client) creates \<name> and refuses
(409) if a file already exists under that name: an earlier upload, or any
object in the bucket at that key
(visulima/visulima#919).
@visulima/storage alone would replace an earlier upload. The check runs
after authorize and the size caps, so a request that would be refused anyway
gets that refusal, not a hint that the name is taken. A caller who may upload
can still learn whether a name is taken. A lookup that fails (other than a 404) refuses the PUT rather than letting it replace a file.
Over createR2BindingUploadStorage the final write is conditional too, so two
PUTs racing for one new name store exactly one file (atomically for a file
under 5 MiB, whose object is a single put; a larger one is checked again just
before R2 completes it, as complete() takes no precondition). Over
createR2UploadStorage it is a check before the write, so two PUTs racing
for one new name can both write.
On TUS every successful PATCH answers 204 with no body, the one that
completes the upload included; read the new offset from Upload-Offset.
How maxFileSize is enforced depends on the protocol. For TUS and chunked REST
the handler reads the largest size the request declares across Upload-Length
(TUS create), X-Total-Size (chunked-REST create) and Content-Length, and
answers 413 above the cap, before authorize runs. A TUS upload created with
Upload-Defer-Length declares no total, so this check does not cover it. For
multipart the handler does not read Content-Length, which covers the whole form
body including boundaries and would reject a file within the cap; the multipart
parser enforces the cap on the file itself.
maxFileSizeFor lowers the cap per upload, say 10 MiB for images and 2 GiB for
video. It runs after authorize, on the requests that create an upload (POST,
and the chunked-REST PUT) of TUS and chunked REST, and gets the decoded
metadata, the declared MIME type and the declared size. The type is resolved
the way the stored file's type is: on chunked REST the request's
Content-Type; on TUS the metadata's mimeType, else type, else filetype;
else application/octet-stream. Return a byte count, or undefined to leave
maxFileSize as the only cap; the result never raises the cap past
maxFileSize. TUS Upload-Metadata is parsed the way @visulima/storage
parses it (pairs split on , and trimmed, then split on one space), and
a header it would refuse (a pair of more than two parts, a duplicate key, a
reserved key such as uploadConcat, a value that is not base64) gets the same
400 from the route, before authorize and maxFileSizeFor run, so the type a
cap is decided on is always the type that is stored. A create that declares no
size is answered 413 once a cap applies, and so is one whose declared size is
not a non-negative integer (with or without a cap). A throw, or anything that is
not a finite, non-negative number, is a 403. The type is what the client says
the file is: check it with the provider's allowMIME too. Multipart forms are
not covered, since their size is only known once the form is parsed.
const handler = createUploadHandler({
storage: createR2BindingUploadStorage(env.UPLOADS),
authorize,
maxFileSize: 2 * 1024 * 1024 * 1024,
maxFileSizeFor: ({ contentType }) => (contentType.startsWith("image/") ? 10 * 1024 * 1024 : undefined),
});createR2BindingUploadStorage(bucket, { statePrefix?, ...options }) writes
uploads through the Worker's R2 binding, so it needs no S3 credentials and works
the same under wrangler dev / miniflare, in tests and in production. The
remaining options are @visulima/storage's (allowMIME, maxUploadSize,
filename, onComplete, …).
- Parts. R2 requires every multipart part but the last to be at least 5 MiB
and all of them the same size. Chunks of any size are coalesced into parts of
exactly 5 MiB (
R2_PART_SIZE); what does not fill a part waits in the bucket as a small segment object until a later chunk completes it. A request holds at most about two parts in memory. A file under 5 MiB never starts a multipart upload and is written with oneput. - Sizes. An upload must declare its size up front, as a non-negative
integer; a create without one is refused (
400). - State. Each upload's offset, metadata, multipart upload id, stored parts
and waiting segments are a JSON object under
statePrefix(default"_lunora/uploads/") in the same bucket, so a request can land on any isolate. Progress is recorded after every stored part, so a request that is killed loses at most the part it was working on. No uploaded object may be named under the prefix (400). The state of a finished upload is kept, shrunk to its file record, soHEADstill reports it complete; it and the state of abandoned uploads accumulate, and they appear in listings of the bucket. Add an R2 object lifecycle rule that deletes objects under the prefix after a few days, longer than any upload should take. - Removing uploads.
DELETE(TUS) orexpirationaborts an upload in progress, with its parts and segments. For a finished upload they drop only the state, never the stored file: removing a file isctx.storage.delete. - Concurrency. A chunk at an offset other than the stored one is a
409. A chunk also takes a lease on the upload with a conditional put, so a second request for the same upload while one is streaming normally gets a409, and the client re-reads the offset withHEADand continues. Only when one handler instance serves both requests (built once per isolate rather than per request) does@visulima/storageanswer the second TUSPATCHwith a423first, which the TUS client retries. The writer confirms the lease before storing each part and before finishing, so one whose lease lapsed stops (409) instead of writing on. A lease left behind by a request that died expires after a minute. Tiny chunks cannot pile up waiting segments: past eight, they are joined into one. - Interrupted chunks. When the client drops mid-chunk, the bytes that
arrived are kept and
HEADreports them, as TUS expects. - Not supported: the TUS
concatenationandcreation-defer-lengthextensions (not advertised; a request using one gets a501), and chunked-REST chunks sent out of order (409, see below).copy/moveon the provider are refused. - Checksums. The provider verifies no checksum itself, so the TUS
checksumextension is not advertised,OPTIONScarries noTus-Checksum-Algorithm, and aPATCHorPOSTthat sendsUpload-Checksumanyway gets a400(UnsupportedChecksumAlgorithm) beforeauthorizeruns, without its body being read.@visulima/storagewould otherwise buffer the chunk in memory to verify it, at about twice the chunk's size, which a few concurrent requests could use to exhaust the isolate.
The same refusal applies to any provider whose checksumTypes is empty
(@visulima/storage's memory provider among them), and OPTIONS there drops
checksum from Tus-Extension too. On a provider that verifies
some algorithms itself, @visulima/storage verifies the others by buffering the
chunk: it needs a Content-Length (411), a mismatch is a 460, and
createUploadHandler caps the chunk at 16 MiB (413, refused before any byte
is read), where upstream's default is 64 MiB.
createR2UploadStorage({ accountId, bucket, accessKeyId, secretAccessKey, endpoint?, partSize?, path? })
builds an R2 provider over the S3 API (aws4fetch, no AWS SDK; needs
nodejs_compat). path must match the route the handler is mounted on, and
endpoint is the account endpoint, an absolute https:// URL such as
https://\<accountId>.eu.r2.cloudflarestorage.com; pass it to pin a
jurisdiction. Plain http:// is accepted only to localhost, 127.0.0.1 or
[::1] (a local S3 such as MinIO). Anything else throws VALIDATION_ERROR
when the storage is built. Requests go to \<endpoint>/\<bucket>/\<key>, unless the endpoint already
names the bucket, as its last path segment or as a virtual-hosted
https://\<bucket>.\<accountId>.r2.cloudflarestorage.com. Each upload chunk
becomes one part of an R2 multipart upload, and R2 rejects parts under 5 MiB
except the last (and parts of unequal size), so a TUS client must send equal
chunks of at least 5 MiB (the TUS client's default chunkSize is 5 MiB). The
constructor throws on a missing credential: build it on first use rather than
at module scope. It takes no upload limits of its own, so the handler's
maxFileSize and maxFileSizeFor are the caps that apply.
Prefer createR2BindingUploadStorage. It needs no S3 credentials, runs
under wrangler dev, and has neither part-size restriction.
Chunked REST
For resumable uploads, use TUS (the default protocol, and useTusUpload on
the client). Over the R2 providers chunked REST takes one chunk at a time, so
the bundled client can only use it for a single chunk.
A client creates the upload with a POST carrying X-Chunked-Upload: true and
X-Total-Size, then sends each chunk as a PATCH with X-Chunk-Offset and
Content-Length. A chunk answers 202 with X-Upload-Offset until the one
that completes the file, which answers 200 with X-Upload-Complete: true and
the file record. HEAD reports X-Upload-Offset, X-Upload-Complete and
X-Received-Chunks, so a client resumes from X-Upload-Offset.
Over both R2 providers the chunks have to arrive one at a time, in order.
They store bytes as they arrive (createR2BindingUploadStorage into equal
5 MiB R2 parts, createR2UploadStorage as one part per chunk), so neither can
hold a chunk for a gap that is still open:
- A chunk at any offset other than the upload's current one, ahead of it or
overlapping bytes already stored, is a
409(FileConflict) and nothing of it is stored. Over the binding provider so is a second chunk while one is still streaming (the provider's lease);createR2UploadStoragehas no such lease, so send one chunk at a time there. - A refused chunk never counts towards completion.
X-Upload-OffsetandX-Upload-Completecome from the bytes the provider has stored, onPATCHand onHEADalike. - When a chunk is cut off mid-request over the binding provider, the bytes that
arrived are kept, and
HEADreports them. A raw protocol client re-reads the offset withHEADand continues from there. X-Received-Chunkslists the byte ranges stored, contiguous chunks merged into one, so the bundled client resuming an upload that is already complete sends no chunk.- A finished upload keeps its state on both providers, so a
HEADanswers it complete.
The bundled client (useChunkedRestUpload, createChunkedRestAdapter, and
useUpload when it picks chunked REST) sends one chunk at a time since
@visulima/storage-client 1.0.8, so multi-chunk uploads complete over both R2
providers, and an interrupted one resumes from X-Upload-Offset. Each chunk has
to fit Cloudflare's request body limit, and @visulima/storage caps a chunk at
100 MiB.
Upgrading
This release moves @visulima/storage to 2.0.33 and @visulima/storage-client
to 1.0.8. What changes for an upload route:
GETis refused (405) on every protocol, beforeauthorizeruns, as described above. Nothing that uploads needs it. Code that read files back through the upload route, or that listed it with@visulima/storage-client'suseGetFileList/createGetFileList, has to read throughctx.storage.download()or a signed URL instead.- A
PUTthat creates a file (chunked REST) needs an id of letters, digits,_and-, at most 255 characters, after its extension is stripped:PUT /upload/report-v2.pdfcreatesreport-v2,PUT /upload/report.v2.pdfis a400. - A
PUTno longer replaces anything.PUT /upload/\<name>creates\<name>and refuses (409) if it already exists, see above. Replace a file withctx.storageinstead. Locationon a chunked-RESTPUTorPATCHis now\<collection>/\<id>.\<ext>. It used to repeat the id (\<collection>/\<id>/\<id>.\<ext>).- Chunked REST works (it answered
400to every chunk before) over both R2 providers, within the limits above. - Method-override headers are refused (
405) on every protocol, beforeauthorizeruns. - TUS
Upload-Checksumis refused (400) on a provider that verifies no checksum itself,createR2BindingUploadStorageincluded, and an invalidUpload-Metadatais a400beforeauthorizeruns. - TUS follows tus 1.0 more strictly. The
PATCHthat completes an upload answers204with no body (it was200with the file record), so the client's progress now reaches 100%. APATCHat the wrong offset is a409, a second one for the same upload while one is streaming is a423, and a malformedUpload-Length,Upload-OffsetorUpload-Metadatais a400. - The TUS client's default
chunkSizeis 5 MiB (it was 1 MiB), and it retries a423. - The chunked-REST client sends one chunk at a time. It sent four in
parallel, which the handlers refuse (
423, or the R2 lease's409), so a multi-chunk upload failed. It now completes, over both R2 providers too. X-Received-Chunkslists merged byte ranges instead of one entry per chunk, so the record stays small. A client checking whether a chunk arrived tests whether a range covers it; the bundled client does.- Empty files are valid uploads on every protocol and over both R2 providers:
TUS
Upload-Length: 0, a chunked-REST create withX-Total-Size: 0, and aPUTwithContent-Length: 0. An emptyPUTis create-only like any other, so it gets a409on a taken name. createR2UploadStorageputs the bucket in the request path. It sent every request to\<endpoint>/\<key>, which R2 read as a bucket named after the file. It now goes to\<endpoint>/\<bucket>/\<key>; anendpointthat already names the bucket (in its path, or virtual-hosted) is not given it twice, and one that is not an absolutehttps://URL (orhttp://to a loopback host) throwsVALIDATION_ERROR.- Every upload gets a random id
(visulima/visulima#921).
The id was derived from the file's name, size and
lastModified, so a second client with the same file could take over the upload. A create that is sent again now starts a new upload; the bundled clients resume from theLocationthey kept, which is unaffected. - A finished upload keeps its state on
createR2UploadStorage, as it already did oncreateR2BindingUploadStorage: a small\<id>.METAobject beside the file, so aHEADreports it complete.