Skip to content

Add event.onDispose(): a portable end-of-event hook for resource cleanup #1487

Description

@pi0x

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".

Proposal

event.onDispose((reason?: unknown) => {
  clearInterval(interval);
});
  • 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 } = new TransformStream();
    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.

Happy to prototype this if the shape looks right.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions