Skip to content

v26.9.0-beta.1

Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 04 Sep 13:39

Added

  • A request budget, not just a connection cap. createApp({ limits: { maxConcurrentRequests } })
    bounds how many handlers run at once, per server and per Fetch adapter instance; over the budget a
    request gets 503 with Retry-After: 1 instead of queueing behind the ones already running. It is
    opt-in and unlimited by default, and it counts executing handlers rather than open connections —
    the slot is released when routing and the handler finish, so a long-lived SSE stream or a WebSocket
    upgrade does not hold one for its lifetime. On Node a client disconnect releases the slot early. A
    handler that never returns keeps its slot, which is the honest behaviour for a budget of this shape.
    Contributed by @hgshreyas.

    Node's connection cap also stopped being silent: reaching maxConnections now logs a warning
    naming the dropped peer, rate-limited to one a minute. Until now the socket was destroyed with no
    HTTP response and nothing said so, which reads from the outside like a network fault.

  • createApp({ handleSignals: true }) registers SIGINT/SIGTERM to close and exit. Off by
    default, and that is the design rather than caution: a library that installs process-wide handlers
    behind your back is worse than one that installs none, because when the process exits is the
    application's call. Both halves are supported — keep the handler, or hand it over. What is not
    optional either way is that something calls close(); the teardown registry only runs from
    there, so a container SIGKILLed after its grace period skips every dispose() and reports
    nothing.

    Declared once and wired per runtime by whichever boot call runs — listen(), serveDeno() and
    serveBun() each attach it to the closer that drains their server, so process.on on Node and
    Bun and Deno.addSignalListener on Deno stop being the application's problem. close()
    unregisters, which means a second signal falls through to the platform default and ends the
    process at once: Ctrl-C twice is the way out of a teardown that is stuck.

  • The extension-point types are exported, not just the extension points. TransformerFn — the
    type of @Transformer's only argument — could not be imported, so a custom transformer was
    attached with its shape redeclared inline or borrowed off a value as typeof JsonTransformer.
    Checking the barrel for the same oversight turned up four more, all now exported: PluginApi,
    ScopeApi and ScopeNode, the chain reached through api.scope.add, without which a plugin split
    into named functions cannot annotate what it receives; plus Hooks and TeardownFn. Types only —
    nothing at runtime moved and no existing export changed.

  • The lifecycle stream has a contract now, not just events. request:end is documented as
    terminal and universal — it fires for every request shape and is the only one carrying the status
    the client received, which makes it the request counter. request:failed means handler code
    threw
    , which no status expresses on its own since a rendered 422 is also a throw, and
    route:unmatched means no route ran. Both are additional to request:end, not alternatives
    to it: one failing request emits three events and a 404 emits two, all sharing a requestId, and
    an exporter that treats them as separate outcomes counts the same request twice. Silently — the
    metrics just come out wrong.

    request:failed now carries status, so an error counter can break down by status without
    joining back through requestId for something the emitter already had. It is absent in exactly
    one case: a custom onError that threw while producing it, where the framework does not know what
    was sent and will not guess.

  • @needs('events') reaches the read-only half of the bus{ on }, the same narrowing
    plugins already get, exported as the Events type. app.bus was public and a plugin could
    subscribe, but the bus was not a graph token, so @needs('bus') failed at boot and nothing said
    why. It still is not one, and that is the design: handing emit to every node turns a one-way
    observation channel into something anything can forge events on. @needs('bus') now fails saying
    exactly that, and pointing at the two things that do work.

    A plugin remains the right home for observation, because it gets on and onShutdown together.
    This token gives the subscribe half alone — on() returns its own unsubscribe for a @Provider
    to release in dispose(), and a @Step should not subscribe at all, since it runs per request
    and would add a listener each time.

  • @Sse can emit an id:, so an EventSource reconnect has something to resume from.
    sse(data, { id, event, retry }) tags a stream item and the encoder writes the fields ahead of
    data:; an app that never calls it produces byte-identical output. Until now the encoder wrote one
    field, so the browser had nothing to put in Last-Event-ID and every automatic reconnect — the
    reason to choose SSE over a raw WebSocket — rebuilt the route's iterable from its start and lost
    the gap in silence. The other half already worked: the request envelope has always carried every
    header, so a handler could already read @header('last-event-id'); it simply always arrived empty.
    event: and retry: come along because the same envelope carries them, and neither was reachable
    before.

    green-tea stores nothing — no buffer, no retention window, no replay. It carries the marker in
    both directions and the handler decides what the gap means, because only the source knows: a paged
    log re-reads from an offset, a live sensor has no past worth delivering. An id containing a
    newline is rejected rather than stripped, since the SSE format is line-based and an id is exactly
    the value most likely to be built from a request — a cursor, a page token — so one newline would
    let a caller append fields to somebody else's stream. On an ndjson or negotiate-to-ndjson route
    the payload is unwrapped and the fields dropped. New exports: sse, isSseEvent, SseEvent,
    SseFields.

Changed

  • A request's security and CORS headers are computed once. They were derived twice per request
    and three times for a preflight — once in the adapter, to seed the headers a response written
    before routing still has to carry, and again during dispatch. Nothing was incorrect, but
    cors.origins is a predicate precisely so it can be a lookup: an allowlist in Redis, a tenant
    query. Running it two or three times charged the caller's latency budget and their backend for an
    answer whose inputs had not changed in between, and made a predicate with a counter in it count
    double.

  • Every request:end is now preceded by a request:start carrying the same requestId. The
    pairing held by accident until maxConcurrentRequests arrived: request:start had a single
    emitter, so nothing could break it, and a shed request emitted only the request:end. A consumer
    that opens per-request state on the first and closes it on the second — an in-flight gauge, most
    obviously — would have drifted under shedding, which is when an operator is reading it, and would
    have done so by producing a plausible wrong number rather than an error. It is a guarantee now,
    written next to LifecycleEvent and enforced by a test that enumerates every response shape.

  • An unmatched request carries a bounded route. route:unmatched was the one terminal request
    event with no route, so the only subject a consumer could reach was name — which on that event
    is the concrete, caller-controlled path. A matched path is bounded by the route table; an
    unmatched one is bounded by nothing, and a scanner walking /aaa, /aab, /aac is a memory leak
    with a metrics backend attached. It and the request:end that follows now carry
    route: '<unmatched>', exported as UNMATCHED_ROUTE. Written down alongside it: name is
    caller-controlled and must never be a metric label.

  • Framework token names are reserved. logger, rooms, events and bus cannot be declared
    by a module, plugin or mesh export; taking one is a boot error naming it. Built-ins used to be
    registered only if the name was free, so a provider called logger silently replaced the
    framework's own and every @needs('logger') in the app got something that was not the logger core
    writes to — a divergence discovered from a log line that never appeared. bus is reserved without
    being provided, so @needs('bus') cannot resolve to whatever a user happened to call bus.

    This can fail an app that boots today, which is the point of it, and the fix is to rename.

  • No per-request bookkeeping when no request budget is set. Every request registered a close
    listener and set a flag for maxConcurrentRequests, which is opt-in and unlimited by default — so
    most applications paid a closure and an EventEmitter registration per request, on the hot path,
    for a feature that was off. Unchanged where a budget is configured: the listener is what
    releases a slot when a client disconnects mid-handler.

  • Route ranking is settled when the route table is built, not on every request. Matching scanned
    every route registered under the request's method and ranked the candidates as it went, deriving
    each pattern's specificity from its source string per comparison. Both the scan and the ranking
    scale with the size of the route table, and neither can produce a different answer between two
    requests — the table is assembled once, after the graph is prepared, and handed to the adapter
    unchanged. Routes are now compiled, bucketed by method and ordered most-specific-first once, and
    matching returns at the first route that matches.

    Nothing about which route answers changes. Equal specificity still keeps registration order, which
    the ordering carries through a stable sort rather than through a scan that declined to replace its
    best on a tie. Two smaller savings ride along on the same path: a path segment holding no % skips
    decodeURIComponent entirely, and decoding is memoized per request rather than repeated for every
    candidate route that reaches the same parameter position.

    Worth nothing on a small route table and worth a great deal on a large one, which is the shape of
    the saving rather than a caveat on it: no measurable change at 6 routes, +3.4% at 50, and +12% to
    +14.9% at 200
    . That is also why it went unnoticed for two releases — the benchmark had no
    route-table-width dimension until this one, so every matcher change measured as noise regardless of
    its size.

  • Independent providers boot concurrently. Boot walked the topological order one node at a time,
    so an application paid the sum of its providers' latencies rather than its longest chain — three
    providers with no edges between them and 200ms of work each took 616ms for a graph whose critical
    path is 200ms; it now takes 210ms. The graph already proved which nodes cannot constrain each
    other, and flattening the sort was the only thing throwing that away: the ordered list is grouped
    back into dependency levels and each level runs together, with level N fully registered and
    warmed before N+1 starts. Nothing the graph derives changes, and this is the second thing users
    get for declaring needs/provides rather than ordering calls by hand — pruning was the first,
    and neither is available to a middleware chain, where nothing declares what is independent.

    Two consequences worth knowing. Teardown still runs in the exact reverse of boot: registration
    follows level order rather than completion order, which is what keeps that a guarantee instead of
    a race. And a required provider that fails no longer prevents its independent siblings from
    starting — they are already in flight — so whatever they opened is registered for teardown before
    the boot is aborted. On the bus, boot:provider:start no longer strictly alternates with :ok; a
    level emits its starts together and then its results.

Fixed

  • A cors.origins predicate that throws no longer takes the process down. The predicate runs on
    the request path, above the region where errors convert to a response, so a throw became a rejected
    promise nobody awaited — and Node's default for that is to exit. One cross-origin request was
    enough, onError never saw it, and the trigger is a browser: the predicate is only reached when an
    Origin header is present, so no test that forgets the header can catch it. A predicate that throws
    now denies the origin and the failure is logged. A lookup that failed has not said yes, and a
    broken allowlist must never widen into an open one.

  • The JSR package works. JSR serves src/ rather than the tsup build, and the ESM build's
    createRequire banner therefore never existed there — so every lazy require() in the source had
    nothing to resolve. @Html('file') and template mode died at boot on Deno with ReferenceError: require is not defined. Two other sites were worse than the crash because they answered
    confidently and wrongly: static reported "needs a filesystem and is unavailable on this runtime
    (edge)"
    while running on Deno, which has one, and multipart reported busboy as not installed
    while it sat in node_modules. Both blamed the runtime for a packaging problem, and both named a
    runtime the reader was not on.

    Every call site now resolves through one helper that prefers the ambient require — so both npm
    builds behave exactly as before — and otherwise rebuilds one from
    process.getBuiltinModule('node:module'), which Node, Deno and Bun all expose synchronously. On
    workerd, which offers neither, nothing changes and the guarded sites' "edge has no filesystem"
    story is finally the true one.

  • A custom onError that throws no longer takes the process down. createApp({ onError }) is
    the advertised way to render errors, it runs on the request path, and it ran with no boundary — a
    renderer that threw exited the process, exit code 1. It is the same shape as the CORS predicate
    crash above and easier to reach: not a cross-origin request, but any request that produces an
    error. The renderer produces the 404 too, so an app with a custom renderer and no matching route
    was one request away from exiting.

    It was also the worst-timed crash there was, since the renderer only runs once something has
    already gone wrong: an error occurs, the code written to report it fails, and instead of a
    degraded report the server ends. A renderer that throws now falls back to the built-in rendering —
    which is exactly what the option overrides — so the original error still gets its response, and
    the renderer's own failure is logged separately, naming both.

  • A stream's lifecycle is reported on every runtime, and joins back to its request. stream:open,
    stream:close and stream:error were emitted only by the Node adapter. Every Fetch runtime — Deno,
    Bun, workerd, and app.fetch() on Node — emitted none of them and broke the response with a
    transport error instead of writing the encoder's error frame. Both halves were silent: a consumer
    counting stream:error saw zero on three of the four runtimes while streams were failing normally,
    and the client got a truncated body indistinguishable from a clean end of stream. The Fetch path now
    emits all three and frames the error before closing cleanly, which is what the Node adapter always
    did and the better answer for the client — an SSE consumer that received an error event knows what
    happened, where a dropped connection tells it nothing.

    All three events now also carry the opening request's requestId and traceId, plus a bounded
    route. src/http/core.ts had documented them as carrying the id since the stream landed; they
    never did. The split they exist for is deliberate — a route returning an AsyncIterable is done in
    milliseconds while its connection may live for hours, so request:end fires at the handler's return
    and hour-long connections stay out of the same latency distribution as 2ms replies — but it only
    works if the two can be joined, and without the id an exporter could not say which request opened
    the connection still holding a slot. A WebSocket upgrade correlates itself: it is an HTTP request
    with headers like any other, so it adopts a gateway's x-request-id rather than opening a second
    identity, and carries transport: 'ws'.