You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Spun out of #1478 / #1484. That bug was SSE-specific, but the underlying gap is general — though it's narrower than "no lifecycle signal at all":
Aborted (client vanished mid-response) is already portable today via event.req.signal: srvx lazily wires a Node AbortController to res"close" (aborting only on premature close), and on Deno/Bun/Workers it's the native request signal. proxy already relies on this.
Finished (h3 is done; nothing more will be written) has no signal on any runtime except Node (res"close"), and there is no single place where both halves converge.
So every util needing cleanup-on-end either goes Node-only, or grows its own detection plus its own callback registry — and gets both wrong (see #1478).
The trap: "after toResponse" is not "end of event"
The obvious implementation — a .finally() on the response promise in src/h3.ts — is wrong. Measured on an SSE route via app.fetch() (reproduced independently: 3ms vs 154ms on a shorter stream):
3ms app.fetch() promise RESOLVED <-- a naive dispose hook would fire here
304ms body fully consumed
305ms stream actually finished (onClosed)
Response production happens ~100x too early on any streaming response. A hook wired there would fire while the stream is live, breaking precisely the utilities it is meant to serve.
Achievable fidelity
If core observes the body stream:
case
Node
Web runtimes
streaming body, normal end
accurate (res close)
accurate (body close)
streaming body, client disconnect
accurate
accurate (body cancel)
non-streaming body
accurate
approximate — fires at response production
body never consumed by the caller
accurate
never fires
The last two cells are irreducible: once the Response is handed to the runtime, bytes-landed is not observable on web. The API should therefore be documented as "the handler is done with this event", not "the client received it".
Opt-in and lazily armed. Core checks one symbol on the event; body observation happens only if something registered. A plain JSON route pays nothing.
reason argument:undefined on normal completion, the cancel/abort reason otherwise. Costs nothing now; tracing/metrics consumers (delivered vs. aborted) will need it, and retrofitting an argument later is painful.
Contract:
Fires exactly once, after global onResponse, on every termination path: normal end, client cancel, and body error (a source stream throwing mid-body must not skip disposal).
Callbacks run in registration order; sync throws and async rejections are both absorbed, but reported via console.error unless config.silent — a cleanup callback that always throws should be discoverable in dev.
The aggregate callback promise is passed to event.waitUntil() — on Workers, execution after the body finishes isn't otherwise guaranteed.
Registering after disposal fires immediately. Registrations are only guaranteed when made during request handling (handler / middleware / onResponse): on web runtimes, a first registration made mid-stream (e.g. from a timer after the response was produced) can never arm the observer. Node is immune.
Implementation notes
Observe via the pipeTo promise, not transformer hooks.flush misses errors, cancel misses normal end, and the transformer cancel hook is a newer spec addition with uneven runtime support. One construct covers all three termination paths portably:
const{ readable, writable }=newTransformStream();body.pipeTo(writable).then(fire,fire);// normal end, consumer cancel, source error
One queue hop per chunk, no copy. Worth benchmarking before committing, but it's opt-in per event.
Decide whether to wrap at body-production time, not on the finished Response. On Node, srvx's FastResponse keeps the raw body and fast-paths string/Uint8Array in _toNodeResponse; touching .body to ask "is this a stream?" materializes a native Response and kills that fast path. (On web the check is useless anyway — new Response("hi").body is always a ReadableStream.) Core already knows the real body type inside prepareResponseBody: stream → wrap; string/buffer/null → fire post-onResponse; Node → skip wrapping entirely and use res"close", which also keeps the non-streaming row accurate.
Error-handling hardening is half the point
#1484 (still open) initially had a try { cb() } catch {} that silently failed to catch async rejections, turning a user's rejected cleanup into a process-fatal unhandled rejection. Every util that grows its own callback registry will hit that same rake. One hardened implementation in core is the argument for this feature as much as portability is.
Why a callback rather than a new event.signal
The abort signal already exists as event.req.signal — that stays the way to react to a disconnect mid-flight (e.g. aborting an upstream fetch). onDispose is the converged end-of-event boundary: finish or abort. Overloading an AbortSignal to also mean "completed successfully" misleads more than the composability buys, and a signal (or callback, or anything else) is derivable from the core hook in a few lines.
An alternative shape worth weighing: event.disposed: Promise<unknown> (lazy, never-rejecting, resolves with the reason). Late registration, exactly-once, and hardened error absorption all fall out of promise semantics for free; onDispose sugar is then one line. Either shape works — the contract above is the substance.
(A note on Symbol.asyncDispose/using: scope-based disposal fires at handler return, which is exactly the too-early timing measured above — so no meaningful alignment there.)
Consumers
createEventStream — the motivating consumer. Replaces the Node-only res.once("close") with event.onDispose(() => this.close()), and fixes the web-only leak where a stream that is created but never send()-ed never runs its onClosed callbacks (pre-existing; verified present on app.fetch(), absent on Node; not covered by fix(event-stream): correct stream teardown on close and client disconnect #1484). Caveat per the fidelity table: a response nobody ever reads still leaks on web.
proxy / serveStatic / tracing — possible later adopters, case-by-case. Proxy in particular already gets teardown mostly for free (it forwards req.signal upstream, and runtime cancellation of the returned body propagates up the stream chain), so land createEventStream first rather than promising these.
Notes
This is an additive public API on H3Event, so it is cheapest to land before v2 stable.
Spun out of #1478 / #1484. That bug was SSE-specific, but the underlying gap is general — though it's narrower than "no lifecycle signal at all":
event.req.signal: srvx lazily wires a NodeAbortControllertores"close"(aborting only on premature close), and on Deno/Bun/Workers it's the native request signal.proxyalready relies on this.res"close"), and there is no single place where both halves converge.So every util needing cleanup-on-end either goes Node-only, or grows its own detection plus its own callback registry — and gets both wrong (see #1478).
The trap: "after
toResponse" is not "end of event"The obvious implementation — a
.finally()on the response promise insrc/h3.ts— is wrong. Measured on an SSE route viaapp.fetch()(reproduced independently: 3ms vs 154ms on a shorter stream):Response production happens ~100x too early on any streaming response. A hook wired there would fire while the stream is live, breaking precisely the utilities it is meant to serve.
Achievable fidelity
If core observes the body stream:
resclose)The last two cells are irreducible: once the
Responseis handed to the runtime, bytes-landed is not observable on web. The API should therefore be documented as "the handler is done with this event", not "the client received it".Proposal
reasonargument:undefinedon normal completion, the cancel/abort reason otherwise. Costs nothing now; tracing/metrics consumers (delivered vs. aborted) will need it, and retrofitting an argument later is painful.onResponse, on every termination path: normal end, client cancel, and body error (a source stream throwing mid-body must not skip disposal).console.errorunlessconfig.silent— a cleanup callback that always throws should be discoverable in dev.event.waitUntil()— on Workers, execution after the body finishes isn't otherwise guaranteed.onResponse): on web runtimes, a first registration made mid-stream (e.g. from a timer after the response was produced) can never arm the observer. Node is immune.Implementation notes
pipeTopromise, not transformer hooks.flushmisses errors,cancelmisses normal end, and the transformercancelhook is a newer spec addition with uneven runtime support. One construct covers all three termination paths portably:Response. On Node, srvx'sFastResponsekeeps the raw body and fast-paths string/Uint8Arrayin_toNodeResponse; touching.bodyto ask "is this a stream?" materializes a nativeResponseand kills that fast path. (On web the check is useless anyway —new Response("hi").bodyis always aReadableStream.) Core already knows the real body type insideprepareResponseBody: stream → wrap; string/buffer/null → fire post-onResponse; Node → skip wrapping entirely and useres"close", which also keeps the non-streaming row accurate.Error-handling hardening is half the point
#1484 (still open) initially had a
try { cb() } catch {}that silently failed to catch async rejections, turning a user's rejected cleanup into a process-fatal unhandled rejection. Every util that grows its own callback registry will hit that same rake. One hardened implementation in core is the argument for this feature as much as portability is.Why a callback rather than a new
event.signalThe abort signal already exists as
event.req.signal— that stays the way to react to a disconnect mid-flight (e.g. aborting an upstream fetch).onDisposeis the converged end-of-event boundary: finish or abort. Overloading anAbortSignalto also mean "completed successfully" misleads more than the composability buys, and a signal (or callback, or anything else) is derivable from the core hook in a few lines.An alternative shape worth weighing:
event.disposed: Promise<unknown>(lazy, never-rejecting, resolves with the reason). Late registration, exactly-once, and hardened error absorption all fall out of promise semantics for free;onDisposesugar is then one line. Either shape works — the contract above is the substance.(A note on
Symbol.asyncDispose/using: scope-based disposal fires at handler return, which is exactly the too-early timing measured above — so no meaningful alignment there.)Consumers
createEventStream— the motivating consumer. Replaces the Node-onlyres.once("close")withevent.onDispose(() => this.close()), and fixes the web-only leak where a stream that is created but neversend()-ed never runs itsonClosedcallbacks (pre-existing; verified present onapp.fetch(), absent on Node; not covered by fix(event-stream): correct stream teardown on close and client disconnect #1484). Caveat per the fidelity table: a response nobody ever reads still leaks on web.proxy/serveStatic/ tracing — possible later adopters, case-by-case. Proxy in particular already gets teardown mostly for free (it forwardsreq.signalupstream, and runtime cancellation of the returned body propagates up the stream chain), so landcreateEventStreamfirst rather than promising these.Notes
H3Event, so it is cheapest to land before v2 stable.Happy to prototype this if the shape looks right.