Seven breaking changes: four on surfaces every consumer touches, and three on
the write-side store contract. Five of them are compile errors. The two
exceptions are maxBytesFatal, which a store can ignore and still build, and
the interop status change, which no build sees at all, so read both even if
your build is green.
Two behavior changes are not compile errors. The first can turn a working read into a 404 and the second changes a status code a client may act on, so read them first.
Before either, one deployment note that no build and no request will show you:
every upload token minted by 2.x or 3.x stops resolving. The object stores
issued a token that encoded the destination key; 4.0.0 issues an opaque id and
keeps the key server-side, which is what closes the disclosure and forgery
described under Security in the changelog. The two shapes do not interoperate,
so an upload still in flight when the new build takes over answers 404 and its
client starts over. Drain the in-flight uploads first, or deploy when a restart
is acceptable. Nothing to change in your code: the token is opaque either way.
-
Read stores refuse the upload bookkeeping prefix.
fsStore,s3Store,r2Store,gcsStoreandazureStorenow answerObjectNotFoundErrorfor any key at or under areservedPrefix, which defaults to the sibling upload store's own prefix:.uploads/for fs and s3,.partial-content-uploads/for r2, gcs and azure. Pointing a read store and an upload store at one root or bucket made the upload store's namespace readable through the read path, serving the in-flight bytes of an unpublished upload and the sidecar naming the key it is destined for, which commonly embeds a filename.Nothing to do if your published keys live outside that prefix, which is the documented arrangement. If you were deliberately serving something under it, those requests are 404 now: either move the objects, or point the guard somewhere else.
// doc-check: fragment // no upload store shares this bucket: serve the whole namespace s3Store({ client, bucket, reservedPrefix: "" }); // you moved the upload store's prefix: match it s3Store({ client, bucket, reservedPrefix: "staging-uploads/" });
memoryStoretakes no such option and is unaffected, because its in-flight uploads live in a separate map rather than inobjects. A customObjectStoresharing a location with an upload store should apply the same refusal:reservedKeyGuard(prefix)is exported from the package root. -
An append to an already-complete upload answers
400on interop 3, 5 and 6, and200on interop 9. It answered200on every version. draft-05 Section 4.4.2 requires the refusal: the server "MUST NOT modify the upload resource and MUST respond with a 400 (Bad Request) status code". That status is how a client written against those revisions learns the upload it is retrying already finished, so answering a success told it something else. draft-12 Section 4.4.2 is the revision that permits the replay, saying the server "can choose to replay the final response to the client if the request to append to the completed upload is valid".This is only ever reached when the claimed offset IS the durable offset, so the request in question is a retry of a completion whose response the client never received, on both versions. Nothing to do if your client re-probes with
HEADon a400, which is the recovery every tus and IETF client already implements. If you have a client that treated the old200as confirmation, point it at interop 9 by sendingUpload-Draft-Interop-Version: 9: version 9 is served by default and replays the completion answer.// doc-check: fragment // Or pin the endpoint, if every client of it is current: createUploadHandler(store, { key, location, policy, interopVersions: [9] });
Serving fewer versions is the other direction:
interopVersions: [3, 5, 6]keeps the older wire and its400, at the cost of refusing an interop 9 client with a 400 naming the supported set. -
Store factories now throw at construction on missing options.
memoryStore/memoryUploadStorewithoutobjects,fsStore/fsUploadStorewith a missing or RELATIVEroot, ands3Store/s3UploadStorewithoutclientorbucketthrow aTypeErrornaming what to change. This surfaces a misconfiguration that used to appear as a502after a whole upload body had transferred, or as a store quietly reading fromprocess.cwd(). If a relativerootwas load-bearing for you, resolve it yourself (path.resolve(root)) and pass the result. -
createTusHandler: move your policy bounds insidepolicy. The flat spelling on the options object is gone, somaxSize,minSize,maxAppendSize,minAppendSizeandmaxAgeSecondsare read only from thepolicyobject, which is whatcreateUploadHandleralready required.// doc-check: fragment // before createTusHandler(store, { key, location, maxSize: 5e9, maxAgeSeconds: 86400 }); // after createTusHandler(store, { key, location, policy: { maxSize: 5e9, maxAgeSeconds: 86400 }, });
The flat form is now an unknown property, so TypeScript reports it rather than letting an unenforced bound ship. If you were setting some bounds flat and some under
policy, check the merged result you actually want: the flat ones used to win, and the two halves were combined without saying so. -
createTusHandler'sauditKey: it is(uploadToken: string) => string. The secondmetadataparameter is gone. Nothing ever passed it (the callback runs on HEAD/PATCH/DELETE, which carry no creation metadata), so a declared parameter was alwaysundefined. Only a callback that declared the parameter needs editing; drop it. To derive an audit id from creation metadata, map it in your ownonUploadEvent, where thecreatedevent and the metadata are both in hand. -
CancelSignalis removed:HeadObjectOptions.signalandGetObjectOptions.signalareAbortSignal. If you imported theCancelSignaltype frompartial-content, useAbortSignal. If you passed a hand-rolled object shaped like the old interface (aborted,throwIfAborted,addEventListener,removeEventListener), pass anAbortController'ssignalinstead. CustomObjectStoreimplementations that already typed theirsignalasAbortSignal, which is what every built-in store does and whatreq.signalis, need no change. -
Custom
ResumableWriteStore:getUploadStatemust returnkey, the storage key the upload publishes to, as given tocreateUpload. Your backend already persists it in order to publish at all, so this is usually one field read from the record you already load. The compiler reports the omission.// doc-check: fragment async getUploadState(uploadToken) { const record = await backend.find(uploadToken); if (!record) throw new UploadNotFoundError(uploadToken); return { key: record.key, // the object this upload publishes to offset: await backend.sumDurableBytes(uploadToken), // ...the rest, unchanged }; }
It closes a gap with no supported workaround: the key is produced by your
key()callback BEFORE a token exists, so a caller that generated one per upload had no way to pair the two afterwards, and correlating the callback's return with the token by position across the creation call is a race rather than an answer.UploadOrchestratorOptions.onUploadCompleteis the hook that now reports the pair at publication. -
Custom
ResumableWriteStore: declareenforcesOffsetCheck: true, and make sure it is true.appendChunkmust verify the claimedoffsetagainst durable state before writing and throwUploadOffsetConflictErrorinstead of writing when the two disagree. The check is a preflight once per call, at the start of it, not something repeated per durable flush.createUploadOrchestratorrefuses a store reportingfalse, the same way it already refusesatomicCompletion: false.// doc-check: fragment async appendChunk(uploadToken, offset, body, opts) { const durableOffset = await backend.durableSize(uploadToken); if (offset !== durableOffset) { throw new UploadOffsetConflictError(uploadToken, durableOffset); } // ...write... }
The lock does not make this redundant. It serializes writers but cannot prove the writer still holds it: an expiry-based distributed lock can be stolen from a holder that stalls past its TTL and then resumes, and this check is what makes that holder's next append a loud conflict rather than bytes interleaved into someone else's upload. Single-process deployments are not exempt, because the store outlives the assumption: the same store runs behind the in-process locker today and a distributed one tomorrow, with nothing in the code to notice the difference.
It is a preflight per call, not one atomic step with the bytes that follow, and the flag does not claim otherwise. A holder that stalls MID-call and resumes can still land the flush it was already performing, because the backends accept their writes unconditionally. If your backend DOES offer a precondition on the write (a generation match, an ETag condition, a create-if-absent), use it: that turns the residual into a refusal too. What bounds it otherwise is cooperative, and your
appendChunkmust participate: testopts.signalat every chunk boundary and stop when it aborts, which is what the built-in adapters do. -
Custom
ResumableWriteStore:appendChunkreceivesmaxBytesFatalalongsidemaxBytes, and must honor it. Previously every bound was terminal, so a chunked body running past a per-REQUEST ceiling destroyed the whole upload. Now only a representation bound (a known length, the policy's totalmaxSize) invalidates, and it reports the newUploadOverrunErrorcarrying the bound that was crossed; a request ceiling keeps the accepted prefix, leaves the resource healthy, and throws the newUploadAppendBoundErrorwith the durable offset. The orchestrator answers the first as a400when the bound was the client's declared length and a413when it was the policy maximum, and the second as a413the client resumes from. Both classes, theirisUploadOverrunError/isUploadAppendBoundErrormatchers and theFatalBoundReasontype reach you frompartial-content/tusandpartial-content/upload; the classes are additionally re-exported from every storage subpath, and all of them are matched byname.// doc-check: fragment if (crossedBound !== undefined && opts.maxBytesFatal) { await backend.markInvalidated(uploadToken); // terminal throw new UploadOverrunError(uploadToken, crossedBound); } else if (crossedBound !== undefined) { throw new UploadAppendBoundError(uploadToken, durableOffset); // resumable }
A store that keeps invalidating unconditionally still compiles and is still safe, but it keeps the old behavior of losing an upload over a per-request cap.
-
Behavior, source-compatible and silent: the audit key SUBSTITUTES for the token on every reporting surface. When a caller supplies an
auditKey,onUploadEventreceivesuploadToken: undefined, and so does the context passed toonError. Nothing stops compiling, becauseuploadTokenwas always optional on both, so a consumer that joins upload events or error records to its own records on the token gets silence rather than a type error. Both surfaces carry theauditKeyin its place, andUploadOrchestratorOptionsdeclares it on theonErrorcontext. Join on theauditKeyyou supply, or stop supplying one and accept that the capability lands in your log records: the token is the whole authorization a resumable upload carries, so handing it out beside an audit-safe identifier would put it in the exact records the identifier exists to keep it out of. -
Behavior, no code change required: an unknown-length body that runs past
maxAppendSizenow answers413with the upload intact and its accepted prefix durable, instead of terminally invalidating the resource. A client that retries in smaller chunks now succeeds where it previously had to start over. -
Behavior, no code change required: a creation rejected AFTER its resource was allocated now answers with a
Location, so a client whose creation-with-upload body was refused mid-stream can resume orDELETEthe prefix that landed instead of stranding it. Yourlocation()callback is called on that path too.Locationis already inTUS_EXPOSED_HEADERSandUPLOAD_EXPOSED_HEADERS, so a CORS layer built from those needs no change. -
Behavior, no code change required:
DELETEon an invalidated or expired upload now frees its bytes and answers204instead of410/404. If you treated that410as "already gone", you now get the success you were inferring. Probes and appends on a dead resource are unchanged. -
Behavior, no code change required: the tus dialect's
413reason phrase is "Content Too Large", the name RFC 9110 Section 15.5.14 gives the status. The status code, theTus-Max-Sizeheader and the body are unchanged, and RFC 9110 Section 15 has clients act on the code rather than the phrase. Only a test asserting the literal string "Request Entity Too Large" needs updating. -
Behavior, no code change required: a throwing
key/mime/filename/auditKeyextractor in the Hono adapter now becomes a hardened500and is reported toonErrorwithoperation: "context", which is what the Node adapter already did. If you relied on that rejection reaching Hono's own error handler, the extractor's failure now reachesonErrorinstead. -
Custom
ResumableWriteStore, optional cleanup:atomicCompletionandenforcesOffsetCheckare typedtruerather thanboolean. Only a store that annotated them asboolean(via an explicit type or a widened literal) needs a change, andcreateUploadOrchestratorwas already refusingfalseat construction, so nothing that worked stops working.as constor aResumableWriteStoreannotation on the object keeps the literal type. -
Optional cleanup:
await store.sweepExpired(cutoff)on a built-in store no longer needs?.. The factories returnSweepingWriteStore, which isResumableWriteStorewithsweepExpiredrequired. An existing?.still compiles; dropping it is what stops a future refactor from turning your retention job into a silent no-op. -
Custom
ResumableWriteStore, optional cleanup:exactOffsetRecoveryis now optional, joiningappendGranularityanduniformPartSize. Declaring it is still the honest thing to do, since it tells whoever picks your store whether a crash can leave the offset a few bytes behind the last acknowledged write, but nothing branches on it and you may drop it. -
Custom
ResumableWriteStore, optional cleanup:maxBytesFatalis now optional onAppendChunkOptions, and absent whenevermaxBytesis. A store that destructures it as required needs no change, since the field is present whenever there is a bound to cross; a store that narrowed it tobooleanin its own signature widens that toboolean | undefined, and treats absent the way it treatsfalse. -
Custom
ResumableWriteStore, run the harness once you are done. Three of the changes above are obligations nothing can verify at construction:createUploadOrchestratorsees that you DECLAREenforcesOffsetCheck: true, not whetherappendChunkrefuses a stale offset, and a store that declares the check without performing it serves uploads correctly until a lock is stolen from a holder that stalled past its TTL.// doc-check: fragment import { checkResumableWriteStore, formatConformanceReport } from "partial-content/conformance"; const report = await checkResumableWriteStore(() => myUploadStore()); console.log(formatConformanceReport(report)); if (!report.ok) throw new Error(`store failed ${report.failed} conformance check(s)`);
It needs no test framework and no dependencies, so the same call works as a CI step or a startup self-test. Pass
restartable: falseif your store is in-process by construction, which SKIPS the two cross-instance checks rather than passing them. -
Optional:
UploadOrchestratorOptions.lockTimeoutMsbounds how long a request waits for a contended upload resource before the dialect answers423, andmemoryUploadLocker({ acquireTimeoutMs })sets the in-process locker's own default. Both were previously fixed at 15 s:AcquireOptions.timeoutMswas public but unreachable, since the orchestrator is the only caller ofacquireon a served request and passed nothing. The dialect factories do not exposelockTimeoutMs, so changing it means building the orchestrator directly.
Two breaking changes, both on the write-side extension points. If you use the
built-in lockers (memoryUploadLocker, redisUploadLocker) and the built-in
stores, nothing changes for you.
-
Custom
UploadLocker:acquireloses itsonPreemptRequestedcallback parameter (acquire(uploadToken, opts?)), and the returnedUploadLockmust now carry asignal:{ signal: AbortSignal; release(): void }. Instead of invoking a callback to ask the holder to yield, create anAbortControllerat acquire time and abort it (reasonUPLOAD_PREEMPTED, exported) when a later acquirer wants the lock; exposecontroller.signalaslock.signal. The orchestrator threads that signal into the holder's store write, so preemption is a real cancellation rather than an advisory call.// doc-check: fragment // before acquire(token, onPreemptRequested, opts) { /* ... call onPreemptRequested() ... */ return { release() {} }; } // after acquire(token, opts) { const controller = new AbortController(); // ...abort(UPLOAD_PREEMPTED) it when a later acquirer arrives... return { signal: controller.signal, release() {} }; }
-
Custom
ResumableWriteStore:digestOnCompleteis now"sha256" | false(the"crc32c"option is gone). A store that cannot verify a whole- representation SHA-256 reportsfalse.
2.0 adds the resumable-upload surface (partial-content/tus,
partial-content/upload, and a ResumableWriteStore factory on every
storage subpath). Everything new is additive. Three existing type signatures
tightened; plain-JavaScript callers are unaffected by the first two, and the
third only surfaces if you switch on the operation string:
evaluateConditionalRequestopts.methodis now the union"GET" | "HEAD"(previously a wider string type). Passing any other method was never meaningful; a TypeScript caller forwardingreq.methodverbatim now needs to narrow it first (writes belong toevaluateConditionalWrite, everything else to your router's 405).preferSignedUrlinfo.methodis now the literal"GET". HEAD requests never consult the predicate (a metadata probe answered with a bare 302 defeats exactly the clients that send HEAD), so the field could never be anything else; the type now says so. Remove any deadmethod === "HEAD"branches.- The serve adapters'
onErroroperationunion gains"sign": signed-URL minting failures now report asoperation: "sign"(previously they were folded into"get"/"head"). If you switch exhaustively on the operation, add the arm.
Everything else is unchanged: no serving option was renamed or removed, and the wire behavior of the read side is identical.
Both are excellent libraries for their job: serving a local directory over
Node's http. Migrate when the job changes, typically because your bytes
moved to object storage (S3, R2, GCS, Azure), or because you need protocol
behavior they don't implement. If you are serving a static asset folder and
that's the whole job, keep what you have.
send and sirv resolve a URL path against a directory tree (index
files, extension fallbacks, dotfile policy). partial-content serves an
explicit storage key against a backend: you decide the key, the library
handles the HTTP protocol (200/206/304/412/416, HEAD). There is no directory
walking, so directory-oriented features have no equivalent here by design.
- Storage backends: S3-compatible, R2 native, GCS, Azure, any range-capable HTTP origin, local fs, in-memory. Same handler, same semantics.
- Multi-range requests:
sendcollapses any request with more than one range to a full 200 (see the single-range check in its range handling); partial-content serves realmultipart/byterangeswith coalescing and an amplification cap (maxRanges). - RFC 9530
Repr-Digestintegrity headers withWant-*negotiation. - Pinned reads: a concurrent overwrite cannot splice two file versions into one download (the storage read is conditioned on the validator the response advertises).
- Runtime portability: the same code runs on Node, Bun, Deno, and Workers (Fetch-primitive kernel; the Node adapter is one import away).
- Files that appear at runtime:
sirvin production mode snapshots the directory at boot and 404s files created afterwards (documented behavior;dev: truelifts it at a large throughput cost). partial-content always consults the backend.
No index.html resolution, no extension fallback (/page -> page.html),
no dotfile policy, no SPA single fallback. Those are directory-tree
concerns; put them in your router or keep sirv for that route. A hybrid is
normal: sirv for the SPA shell, partial-content for user files.
Precompressed sibling lookup is NOT on that list: precompressed: true
negotiates <key>.br / <key>.zst / <key>.gz over Accept-Encoding
(RFC 9110 Section 12.5.3) on every store, not just the filesystem. See the
row in the option table below for what it does that a static middleware's
gzip/brotli flags do not.
Before:
import send from "send";
app.get("/files/:name", (req, res) => {
send(req, req.params.name, { root: "/var/data", maxAge: "1d", immutable: true })
.on("error", (err) => res.sendStatus(err.status ?? 500))
.pipe(res);
});After:
import { serveObject } from "partial-content/node";
import { fsStore } from "partial-content/fs";
const store = fsStore({ root: "/var/data" });
app.get("/files/:name", serveObject(store, {
key: (req) => req.params.name,
cacheControl: "public, max-age=86400",
immutable: true,
}));Option mapping:
| send | partial-content |
|---|---|
root |
fsStore({ root }); keys are validated against root escape |
maxAge (ms) + cacheControl |
cacheControl: "<verbatim header>", you write the header string, so there is no ms-vs-seconds unit trap |
immutable |
immutable: true (appends the directive if not present) |
etag / lastModified |
always emitted from storage metadata (strong when a content hash exists, weak size+mtime otherwise) |
acceptRanges |
always on; bound multi-range fan-out with maxRanges (set 1 to degrade multi-range to a full 200, which is the behavior you are migrating from) |
start / end |
client ranges come from the Range header; for a server-forced byte window, call the kernel primitives directly |
dotfiles, index, extensions |
no equivalent, encode path policy in your key function or router |
Event mapping:
| send event | partial-content |
|---|---|
error |
onError(err, ctx) (storage-boundary failures; terminal status is in the response) |
headers |
securityHeaders(mime), a function of the response MIME type returning extra headers |
file |
onServe(event) |
stream / end |
onTransfer(event) (bytes expected vs transferred, completion flag) |
directory |
no equivalent (no directory semantics) |
Two behavioral differences worth knowing before you flip traffic:
Cache-Control: no-cacheon the request: send honors it during conditional evaluation, which means spec-compliantfetch()clients never get a 304 from send. partial-content ignores request cache directives (they address caches, not origin conditional evaluation). Your revalidation rate will go up, that is the intended fix.- Weak validators under If-Range: partial-content requires a strong validator on BOTH sides (RFC 9110 §13.1.5) and refuses the range when only a weak ETag exists server-side, falling back to a full 200 rather than risking a spliced body.
sirv is a static-asset middleware with a boot-time cache; most migrations
keep sirv for the asset folder and adopt partial-content for dynamic or
private files. If you are replacing it outright:
import { serveObject } from "partial-content/node";
import { fsStore } from "partial-content/fs";
const store = fsStore({ root: "public", cache: { ttlMs: 1000 } });
app.use("/assets", serveObject(store, {
key: (req) => req.path.slice(1),
cacheControl: "public, max-age=31536000",
immutable: true,
}));| sirv | partial-content |
|---|---|
dev: false boot snapshot |
opt-in hot-object cache (fsStore({ cache })): TTL-revalidated, serves runtime-created files, LRU-bounded (maxEntries, maxBytes) |
etag: false (default) |
validators always on, If-None-Match -> 304 works out of the box |
maxAge (seconds) + immutable |
cacheControl verbatim + immutable: true |
setHeaders(res, path, stats) |
securityHeaders(mime) |
onNoMatch |
404 response is built in; wrap the handler to customize |
gzip, brotli |
precompressed: true (or an explicit ["br", "zstd", "gzip"] preference), on every store rather than the local directory only. It negotiates with Accept-Encoding per RFC 9110 Section 12.5.3, then treats the sibling as the DISTINCT representation it is: the variant's own ETag and Last-Modified drive conditionals and If-Range, Content-Range addresses the encoded bytes (so a resumed download of a .br variant is byte-correct instead of being clamped against the identity size), and Vary: Accept-Encoding rides every success response for a compressible type so shared caches key on the request coding |
single, extensions, ignores, dotfiles |
no equivalent, router/CDN concerns here |
- Pick the store for where the bytes live (
/fs,/s3,/r2,/gcs,/azure,/http,/memory). - Write the
keyfunction; put any path policy (dotfiles, extensions) there. - Move cache policy into a verbatim
cacheControlstring (watch the unit: send'smaxAgewas milliseconds, sirv's was seconds). - Port
headers/setHeadershooks to thesecurityHeaders(mime)function. - Run your conditional-request paths (
If-None-Match,If-Range,Range) against the new handler;curl -H "Range: bytes=0-1"should return a 206 with an exactContent-Range.