HTTP Server API: A Draft Specification

www.jasnell.me · 2026-08-21 · by Long Nguyen ·generic
A draft specification for a server-side HTTP API built on Fetch Standard types — ServerContext, tunnels, WebSocket, WebTransport, priority, error codes, and lifecycle management.

HTTP Server API: A Draft Specification

HTTP Server API: A Draft Specification

July 20, 2026

In Fetch Is Not Enough I described the problem: every JavaScript runtime uses Fetch's `Request` and `Response` for server-side work, but they've all diverged on handler signatures, WebSocket upgrade, lifecycle management, and everything else Fetch doesn't model — trailers, informational responses, extended CONNECT, HTTP Datagrams, priority.

Fetch Needs Error Codes went deeper on one gap: protocol-level error semantics (REFUSED\_STREAM, GOAWAY, etc.) that have no representation in the current error surface.

This post is a draft specification for a server-side HTTP API that tries to close those gaps. It assumes the Fetch Standard gains trailer support (#1940, #1941) and an `onInformation` callback (#1942) that I have proposed in WHATWG. It builds on standard `Request` and `Response` without extending or subtyping them.

Consider this is a conceptual draft. The WebIDL is illustrative, open questions remain, and some of the design choices are deliberately opinionated and concrete enough to argue about.

It is intentionally not a formal spec yet and represents only my own current thinking on what the API could look like. Please poke holes.

---

Introduction

The Fetch Standard defined `Request`, `Response`, `Headers`, and `fetch()` for browser HTTP clients. Server-side runtimes adopted these types but diverged on everything around them.

This specification defines a server-side API that:

  • Uses standard `Request` and `Response` without modification.
  • Introduces a `ServerContext` for connection metadata, server capabilities, and lifecycle — the things that belong to the processing environment, not the HTTP message.
  • Unifies HTTP/1.1, HTTP/2, and HTTP/3 behind one programming model.
  • Handles both request/response exchanges and tunnel protocols (extended CONNECT).
  • Defines primitives for the Capsule Protocol and HTTP Datagrams.
  • Goals

    1. One server-side programming model for all HTTP versions: A handler should not need to know whether a request arrived over HTTP/1.1, HTTP/2, or HTTP/3. Protocol-version-specific behavior is the implementation's concern, not the application's. 2. Standard Fetch types without extension: The `Request` a handler receives is a `Request`. The `Response` a handler returns is a `Response`. No duck-typing, no structural compatibility concerns, no server-specific subtypes that almost-but-not-quite match the standard types. 3. Clean separation of message and environment: The HTTP message (`Request`) is separate from the server processing environment (`ServerContext`). Connection metadata, lifecycle management, and server capabilities are properties of the context, not the request. 4. Incremental adoption: A handler that ignores the context and uses only `Request` and `Response` works unchanged. Server-specific capabilities are available when needed but never required. 5. Portability across runtimes: The same handler code should run on any conforming runtime without per-runtime adapters. 6. Extensibility for future protocols: Extended CONNECT is designed to carry new protocols. The handler model accommodates new `:protocol` values without API changes and allows for entirely new handlers to be defined.

    Non-Goals

    1. Routing: This specification does not define a router, URL pattern matching, or request dispatch. Routing is an application or framework concern. 2. Middleware: This specification does not define a middleware pipeline, plugin system, or request/response transformation chain. 3. Response helpers: This specification does not define convenience methods for building responses (no `ctx.json()`, `ctx.html()`, etc.). Handlers return a standard `Response`. 4. Replacing existing APIs: This does not replace `node:http`, `node:http2`, `Deno.serve()`, `Bun.serve()`, or any existing API. Existing APIs continue to work. 5. Browser implementation: This specification targets server-side runtimes.

    Relationship to Existing Specifications

  • Fetch Standard: This specification depends on the `Request`, `Response`, `Headers`, and `Body` definitions from Fetch. It assumes that `Request`and `Response` support trailers (`Promise<Headers>` `.trailers` property, `trailers` constructor option) and that `fetch()` supports an `onInformation` callback for receiving informational responses on the client side.
  • Streams Standard: `ReadableStream` and `WritableStream`are used for bodies, tunnel data, capsules, and datagrams.
  • RFC 9110: HTTP semantics, including trailer fields and informational responses.
  • RFC 9218: Extensible Prioritization Scheme for HTTP.
  • RFC 9297: HTTP Datagrams and the Capsule Protocol.
  • RFC 9221: Unreliable Datagram Extension to QUIC.
  • RFC 8441 / RFC 9220: WebSocket bootstrapping via extended CONNECT over HTTP/2 and HTTP/3.
  • RFC 9298: Proxying UDP in HTTP.
  • RFC 9484: Proxying IP in HTTP.
  • RFC 9651: Structured Field Values for HTTP.
  • Design Rationale

    #### Why a context object?

    The properties needed on the server side fall into distinct categories:

    | Category | Examples | Belongs to... | | ------------------- | ----------------------------- | ----------------------- | | The HTTP message | method, url, headers, body | The Request | | Connection metadata | remote address, ALPN protocol | The connection | | Server capabilities | informational responses | The response pipeline | | Execution lifecycle | waitUntil | The runtime environment |

    None of these are properties of the HTTP message itself. Putting them on `Request` conflates the message with its processing environment. A context object keeps them separate.

    Runtimes have independently arrived at similar patterns:

  • Cloudflare Workers: `fetch(request, env, ctx)` — three positional arguments, where `ctx`provides `waitUntil()`.
  • Deno: `Deno.serve((request, info) => ...)` — two arguments, where `info` provides `remoteAddr`and `completed`.
  • Hono: `(c) => ...` — a context object with `c.req` for the request.
  • A single context object avoids positional fragility and extends without signature changes.

    #### Why not extend Request and Response?

    Subtyping (`ServerRequest extends Request`) creates structural compatibility questions: which client-specific `Request` properties (`.cache`, `.credentials`, `.mode`, `.redirect`, `.destination`) should a server-side subtype expose, and with what values? These properties are meaningless for incoming server requests.

    Duck-typing (a `ServerRequest` that replicates `Request`'s interface) adds `instanceof` and type identity problems on top.

    Using a standard `Request` avoids both. `ctx.request instanceof Request` is `true`. Proxying is `return fetch(ctx.request)`.

    Conformance

    This specification has two layers:

    Core (handler model): The `ServerContext`, `ConnectContext`, handler object pattern, and the handler callback signatures. A conforming implementation MUST support this layer.

    Infrastructure (server lifecycle): The `serve()` function, `Server`, `Listener`, `Closeable`, `ListenOptions`, `ServerOptions`, and `TLSOptions`. An implementation MAY support this layer. An implementation that manages server lifecycle externally (e.g., an edge runtime where binding, TLS, and connection management are handled by the platform outside the application) is not required to expose `serve()`, `Server`, or `Listener`. Such an implementation is conforming as long as it implements the core layer.

    Cloudflare Workers is an example of a runtime that would implement the core layer but not the infrastructure layer. The application exports a handler object; the platform handles everything else. Node.js and Deno are examples of runtimes that would implement both layers.

    Support for the following features is OPTIONAL at both layers. An implementation that does not support an optional feature MUST still expose the relevant interface members and behave as specified for the unsupported case:

  • Priority
  • Informational responses
  • Denial with error codes
  • Extended CONNECT and tunnel protocols
  • WebTransport
  • HTTP Datagrams
  • HTTP/3 and QUIC
  • Infrastructure

    A server is an entity that accepts HTTP connections, receives requests, and sends responses.

    A handler is a JavaScript function provided by the application that processes incoming requests or tunnel establishment attempts.

    A tunnel is a long-lived bidirectional communication channel established through an HTTP connection via the CONNECT method or extended CONNECT.

    A connect protocol is the value of the `:protocol` pseudo-header in an extended CONNECT request, identifying the protocol being tunneled (e.g., `"websocket"`, `"webtransport"`, `"connect-udp"`, `"connect-ip"`).

    SocketAddress

    dictionary SocketAddress {
      DOMString address;
      unsigned short port;
      DOMString family;  // "IPv4" or "IPv6"
    };

    A `SocketAddress` represents a network endpoint. The `address` member is the IP address as a string. The `family` member indicates the address family.

    ServerContext

    A `ServerContext` is the server-side processing environment for an incoming HTTP request. It provides the `Request`, connection metadata, server capabilities, and lifecycle management.

    Interface Definition

    dictionary RequestPriority {
      unsigned short urgency = 3;    // 0–7, per RFC 9218
      boolean incremental = false;
    };
     
    callback PriorityCallback = undefined (RequestPriority priority);
     
    [Exposed=*]
    interface ServerContext {
      // The HTTP request
      [SameObject] readonly attribute Request request;
     
      // Connection metadata
      readonly attribute SocketAddress remoteAddress;
      readonly attribute DOMString alpnProtocol;
     
      // Priority
      readonly attribute RequestPriority clientPriority;
      attribute RequestPriority? serverPriority;
      undefined onPriority(PriorityCallback callback);
     
      // Informational responses
      undefined sendInformational(unsigned short status,
                                  optional HeadersInit headers);
     
      // Denial
      undefined deny(optional any error);
     
      // Lifecycle
      undefined waitUntil(Promise<any> promise);
    };

    The Request

    The `request` attribute returns a standard `Request` as defined in the Fetch Standard.

    The `Request` is constructed by the implementation from the incoming HTTP message. It has:

  • `method`: The HTTP method.
  • `url`: The full request URL, reconstructed from the request target, `Host` header, and connection properties (scheme, port).
  • `headers`: The request header fields.
  • `body`: The request body as a `ReadableStream`, or `null`.
  • `signal`: An `AbortSignal` that is aborted if the client disconnects or the connection is lost.
  • `trailers`: A `Promise<Headers>` that resolves to the trailing header fields after the body is consumed.
  • async fetch(ctx) {
      const { request } = ctx;
     
      const url = new URL(request.url);
      const body = await request.json();
      const trailers = await request.trailers;
     
      return new Response("OK");
    }

    Because `ctx.request` is a standard `Request`, it can be passed directly to client-side `fetch()`for proxying:

    async fetch(ctx) {
      return fetch(ctx.request);
    }

    Connection Metadata

    ctx.remoteAddress   // SocketAddress { address: "192.0.2.1", port: 52341, family: "IPv4" }
    ctx.alpnProtocol    // "h2", "http/1.1", "h3"

    The `remoteAddress` attribute returns the `SocketAddress` of the remote peer. If the remote address is not available (e.g., the runtime abstracts it away), the implementation MAY return a `SocketAddress` with empty `address` and `0` port.

    The `alpnProtocol` attribute returns the ALPN protocol identifier negotiated for this connection. Common values are `"http/1.1"`, `"h2"`, and `"h3"`. If ALPN was not negotiated (e.g., plaintext HTTP/1.1), the value is `"http/1.1"`.

    The `alpnProtocol` value identifies the HTTP version of the connection. Handlers generally should not branch on this value. It is provided for logging, debugging, and the rare case where application behavior legitimately depends on the transport.

    Priority

    RFC 9218 defines the Extensible Prioritization Scheme with `urgency` (0–7, default 3) and `incremental` (boolean, default false). Priority signals flow in two directions — client to server and server to implementation — represented by two properties:

  • `clientPriority` (read-only) — what the client asked for.
  • `serverPriority` (read/write) — what the server decided.
  • #### Client Priority

    The `clientPriority` attribute returns the client's current priority signal, parsed from the `Priority` request header.

    const { urgency, incremental } = ctx.clientPriority;
    // urgency: 3 (default), incremental: false (default)

    The `clientPriority` getter is live: it always reflects the most recent client signal. When the client sends a PRIORITY\_UPDATE frame (HTTP/2 or HTTP/3), the implementation updates the value returned by `ctx.clientPriority` before invoking any registered callback.

    If the request has no `Priority` header and no PRIORITY\_UPDATE frame has been received, `ctx.clientPriority` returns the default values (`{ urgency: 3, incremental: false }`).

    #### Reprioritization

    The `onPriority(callback)` method registers a callback that is invoked when a PRIORITY\_UPDATE frame is received for this request's stream. The callback receives a `RequestPriority` dictionary with the new values.

    ctx.onPriority(({ urgency, incremental }) => {
      if (urgency > 5) {
        throttleProcessing();
      }
    });

    PRIORITY\_UPDATE frames are hop-by-hop (HTTP/2 and HTTP/3 only) and may arrive at any time during the request lifecycle. If no callback is registered, PRIORITY\_UPDATE frames still update `ctx.clientPriority` — the handler is simply not notified synchronously.

    Only one callback may be registered. A subsequent call to `onPriority()` replaces the previous callback. Calling `onPriority(null)` removes the callback.

    For HTTP/1.1, where PRIORITY\_UPDATE frames do not exist, the callback is never invoked. The initial priority from the `Priority` header (if present) is still available via `ctx.clientPriority`.

    #### Server Priority

    The `serverPriority` attribute is a read/write property that overrides the server-internal scheduling priority for this response's delivery. It defaults to `null`, meaning "use the client's priority signal."

    ctx.clientPriority    // { urgency: 5, incremental: false }
    ctx.serverPriority    // null — no override, using client priority
     
    ctx.serverPriority = { urgency: 1 };
    ctx.serverPriority    // { urgency: 1 } — server's decision
     
    // Reset to "use client priority"
    ctx.serverPriority = null;

    When `serverPriority` is `null`, the implementation uses the client's priority signal (`clientPriority`) for scheduling. When `serverPriority` is set, the implementation uses the server's value instead, regardless of subsequent PRIORITY\_UPDATE frames from the client. (The client's updates still appear in `clientPriority` and still trigger the `onPriority` callback — the server simply overrides the scheduling decision.)

    async fetch(ctx) {
      const url = new URL(ctx.request.url);
     
      // Client requested hero image at low priority,
      // but we know it's above the fold
      if (url.pathname === '/hero.webp') {
        ctx.serverPriority = { urgency: 1 };
      }
     
      return fetchFromOrigin(ctx.request);
    }

    `serverPriority` is purely about server-internal scheduling. It does NOT set the `Priority`response header — that is for signaling priority preferences to intermediaries and is set directly on the `Response`:

    ctx.serverPriority = { urgency: 1 };   // internal scheduling
    return new Response(body, {
      headers: { 'Priority': 'u=1' },      // signal to intermediaries
    });

    These are intentionally separate concerns. A server may override internal scheduling without signaling intermediaries, or vice versa.

    Informational Responses

    ctx.sendInformational(103, {
      "Link": '</style.css>; rel=preload; as=style'
    });

    The `sendInformational(status, headers)` method sends an informational (1xx) response to the client.

    The `status` argument must be in the range 100–199 inclusive. If the status is outside this range, the implementation throws a `RangeError`.

    Notable informational status codes:

  • 100 Continue: Indicates the server is willing to accept the request body. Typically sent automatically by the implementation when `Expect: 100-continue` is present, but may be sent explicitly.
  • 103 Early Hints: Provides header fields (typically `Link` headers) that the client can use to start preloading resources before the final response (RFC 8297).
  • An implementation that does not support informational responses accepts the method call without error and silently discards it.

    The Fetch Standard's `onInformation` callback (in `fetch()` options) is the client-side counterpart: it receives informational responses. `sendInformational()` is the server-side counterpart: it sends them.

    Denial

    ctx.deny();  // "I'm not processing this request"

    The `deny(error)` method signals that the handler is declining to process the request. The request is not handled — no `Response` is generated. The handler returns `undefined` (or nothing) after calling `deny()`.

    At the protocol level, `deny()` resets the stream:

  • HTTP/2: `RST_STREAM` with an error code
  • HTTP/3: `RESET_STREAM` with an error code
  • HTTP/1.1: Implementation-defined (close the connection, or send a 503 response and close as a pragmatic fallback)
  • The optional `error` argument controls the protocol-level error code. If the error has a `.code`property, the implementation maps it to the appropriate protocol error code. The following abstract codes are defined:

    | Error code | Protocol signal | Meaning | | ----------------------------- | -------------------------------------------------------- | ----------------------------------------------------------- | | ERR\_HTTP\_REQUEST\_REJECTED | REFUSED\_STREAM (HTTP/2), H3\_REQUEST\_REJECTED (HTTP/3) | Not processed. Client may safely retry. | | ERR\_HTTP\_REQUEST\_CANCELLED | CANCEL (HTTP/2), H3\_REQUEST\_CANCELLED (HTTP/3) | Intentionally cancelled. May have been partially processed. | | ERR\_HTTP\_INTERNAL\_ERROR | INTERNAL\_ERROR (HTTP/2), H3\_INTERNAL\_ERROR (HTTP/3) | Internal error in the server. | | ERR\_HTTP\_CONNECT\_ERROR | CONNECT\_ERROR (HTTP/2), H3\_CONNECT\_ERROR (HTTP/3) | Tunnel-specific error. | | ERR\_HTTP\_GOAWAY | GOAWAY frame (HTTP/2), GOAWAY frame (HTTP/3) | Close the connection after finishing in-flight streams. |

    When no `error` is provided, or when the error has no `.code` or an unrecognized `.code`, the implementation defaults to REFUSED\_STREAM / H3\_REQUEST\_REJECTED. This is the correct default because `deny()` means the request was not processed, which is exactly the semantic REFUSED\_STREAM was designed to express. A client receiving this signal knows the request can be safely retried — including non-idempotent methods like POST.

    // Default: REFUSED_STREAM — "not processed, safe to retry"
    ctx.deny();
     
    // Explicit code
    ctx.deny(new TypeError("at capacity", {
      code: "ERR_HTTP_REQUEST_REJECTED"
    }));
     
    // Internal error
    ctx.deny(new TypeError("database unavailable", {
      code: "ERR_HTTP_INTERNAL_ERROR"
    }));
     
    // GOAWAY: reject this request and close the connection
    ctx.deny(new Error("misbehaving client", {
      code: "ERR_HTTP_GOAWAY"
    }));

    The `ERR_HTTP_GOAWAY` code is a connection-level signal. Unlike the other codes which reset a single stream, `ERR_HTTP_GOAWAY` instructs the implementation to:

    1. Refuse the current request (as with any `deny()` call). 2. Send a GOAWAY frame on the underlying connection, indicating that no new streams will be accepted. 3. Allow in-flight streams (requests already being processed by other handler invocations on the same connection) to complete normally.

    This provides a per-request escape hatch for connection-level concerns — rate limiting, credential revocation, or misbehavior detection — without exposing a connection object to the handler.

    For HTTP/1.1, `ERR_HTTP_GOAWAY` closes the connection after the current exchange. Since HTTP/1.1 connections are serial (ignoring pipelining), this is equivalent to closing the connection.

    Proactive GOAWAY (shutting down connections without a triggering request) is handled by `server.close()` and `server.destroy()` in the infrastructure layer, not by `deny()`. The `deny()` mechanism covers the reactive case where a handler decides during request processing that the connection should be closed.
    The error code mapping depends on the TC39 Error Code proposal, which adds a standardized `.code` property to the `Error` constructor options. If that proposal does not advance, the error code mechanism described here would need an alternative design — for instance, a dedicated options dictionary rather than an error object.

    Request Lifecycle

    ctx.waitUntil(backgroundTask());

    The `waitUntil(promise)` method extends the lifetime of the request processing beyond the return of the handler function. The server does not consider the request fully complete until all promises passed to `waitUntil()` have settled.

    This is analogous to `ExtendableEvent.waitUntil()` in Service Workers and `ctx.waitUntil()` in Cloudflare Workers.

    An implementation MAY impose an implementation-defined time limit on how long it will wait for outstanding `waitUntil()` promises to settle.

    ConnectContext

    A `ConnectContext` is the server-side processing environment for an incoming CONNECT or extended CONNECT request. It extends `ServerContext` with protocol identification and tunnel establishment capabilities.

    Interface Definition

    [Exposed=*]
    interface ConnectContext : ServerContext {
      // Protocol identification
      readonly attribute DOMString? connectProtocol;
     
      // Tunnel acceptance
      Promise<Tunnel> accept(optional ResponseInit init = {});
     
      // Protocol-specific upgrades
      WebSocket upgradeWebSocket(optional WebSocketUpgradeInit options = {});
      Promise<WebTransportSession> upgradeWebTransport();
    };
     
    dictionary WebSocketUpgradeInit {
      sequence<DOMString> protocol;  // Subprotocol negotiation
    };

    A `ConnectContext` has all the members of `ServerContext`.

    Protocol Identification

    The `connectProtocol` attribute returns the value of the `:protocol` pseudo-header for extended CONNECT requests:

  • `"websocket"` — WebSocket over HTTP/2 or HTTP/3
  • `"webtransport"` — WebTransport session
  • `"connect-udp"` — UDP proxying
  • `"connect-ip"` — IP proxying
  • Any other registered or private-use protocol identifier
  • `null` — Plain CONNECT (no `:protocol` pseudo-header; TCP tunneling)
  • HTTP/1.1 Upgrade Normalization

    HTTP/1.1 WebSocket connections use the `Upgrade: websocket` mechanism rather than extended CONNECT. To provide a unified handler model, an implementation normalizes HTTP/1.1 WebSocket upgrade requests into `ConnectContext` objects with `connectProtocol` set to `"websocket"`.

    When an HTTP/1.1 request is received with `GET`, `Upgrade: websocket`, and `Connection: Upgrade`, the implementation constructs a `ConnectContext` with `connectProtocol`set to `"websocket"` and the `request` preserving the original headers and URL.

    This normalization means WebSocket handling is in one place regardless of HTTP version.

    Accepting a Tunnel

    const tunnel = await ctx.accept();

    The `accept()` method accepts the CONNECT request and establishes a tunnel. It returns a `Promise<Tunnel>` that resolves when the tunnel is established.

    The optional `init` parameter allows setting response headers on the success response.

    WebSocket Upgrade

    const ws = ctx.upgradeWebSocket();

    The `upgradeWebSocket()` method is a convenience for WebSocket tunnel establishment. It performs the WebSocket-specific handshake (including subprotocol negotiation if `options.protocol` is provided) and returns a standard `WebSocket` object already in the `OPEN` state.

    If `connectProtocol` is not `"websocket"`, this method throws an `InvalidStateError`DOMException.

    WebTransport Upgrade

    const session = await ctx.upgradeWebTransport();

    The `upgradeWebTransport()` method establishes a WebTransport session. It returns a `Promise<WebTransportSession>` that resolves when the session is established.

    If `connectProtocol` is not `"webtransport"`, this method throws an `InvalidStateError`DOMException.

    Denying a Tunnel

    A `connect()` handler can deny a tunnel in two ways:

    Protocol-level denial via `ctx.deny()` (inherited from `ServerContext`). This resets the stream without sending an HTTP response:

    async connect(ctx) {
      ctx.deny();  // REFUSED_STREAM — client may retry
      return;
    }

    Application-level denial by returning a `Response`. This sends a standard HTTP error response:

    async connect(ctx) {
      return new Response(null, { status: 403 });
    }

    The choice depends on the intended signal: `deny()` says "I never processed this" (protocol-level); a `Response` says "I processed this and the answer is no" (application-level).

    Tunnel

    A `Tunnel` represents an established tunnel through an HTTP connection. It provides three layers of communication corresponding to the layers defined in the Capsule Protocol:

    1. Raw data stream: Bidirectional byte stream on the CONNECT data channel. 2. Capsule Protocol: Typed TLV-framed messages for control signaling. 3. HTTP Datagrams: Discrete messages that may be unreliable on HTTP/3.

    Interface Definition

    [Exposed=*]
    interface Tunnel {
      // Raw data stream
      readonly attribute ReadableStream readable;
      readonly attribute WritableStream writable;
     
      // Capsule Protocol
      CapsuleStream capsules();
     
      // HTTP Datagrams
      DatagramStream datagrams();
     
      // Lifecycle
      undefined close();
      readonly attribute Promise<undefined> closed;
    };
     
    interface CapsuleStream {
      readonly attribute ReadableStream readable;  // ReadableStream<Capsule>
      readonly attribute WritableStream writable;  // WritableStream<Capsule>
    };
     
    interface DatagramStream {
      readonly attribute ReadableStream readable;  // ReadableStream<Uint8Array>
      readonly attribute WritableStream writable;  // WritableStream<Uint8Array>
      readonly attribute boolean unreliable;
    };
     
    dictionary Capsule {
      unsigned long long type;
      Uint8Array data;
    };

    Raw Data Stream

    The `readable` and `writable` attributes provide direct access to the CONNECT data stream as a `ReadableStream` and `WritableStream` of bytes.

    For a plain CONNECT tunnel (no `:protocol`), the raw data stream carries the tunneled TCP payload. For WebSocket, the raw data stream carries WebSocket frames.

    For protocols that use the Capsule Protocol, consuming the raw data stream directly and consuming capsules are mutually exclusive.

    Capsule Protocol

    const { readable, writable } = tunnel.capsules();
     
    // Reading capsules
    for await (const capsule of readable) {
      console.log(capsule.type, capsule.data);
    }
     
    // Writing capsules
    const writer = writable.getWriter();
    await writer.write({ type: 0xff37a2, data: new Uint8Array([...]) });

    The `capsules()` method returns a `CapsuleStream` providing typed access to the Capsule Protocol on the CONNECT data stream. Calling `capsules()` consumes the raw data stream — subsequent access to `tunnel.readable` or `tunnel.writable` throws an `InvalidStateError`.

    HTTP Datagrams

    const dg = tunnel.datagrams();
     
    // Reading datagrams
    for await (const payload of dg.readable) {
      // payload is Uint8Array
    }
     
    // Writing datagrams
    const writer = dg.writable.getWriter();
    await writer.write(new Uint8Array([...]));
     
    // Check transport
    if (dg.unreliable) {
      // Native QUIC DATAGRAM frames — truly unreliable
    } else {
      // Capsule-based fallback — reliable delivery via the data stream
    }

    The `datagrams()` method returns a `DatagramStream` providing access to HTTP Datagrams associated with this tunnel.

    On HTTP/3 connections where QUIC DATAGRAM frames are available, datagrams are sent and received as unreliable QUIC DATAGRAM frames. The `unreliable` property is `true`.

    On HTTP/2 or HTTP/1.1 connections, datagrams are sent as DATAGRAM capsules (type `0x00`) on the data stream. The `unreliable` property is `false`. Delivery is reliable (TCP guarantees it), but the API is the same.

    Both CONNECT-UDP and CONNECT-IP use HTTP Datagrams for their data plane. The `datagrams()` API provides the foundation for both.

    WebSocket Upgrade

    WebSocket upgrade is handled through the `connect()` handler. The `upgradeWebSocket()` method on `ConnectContext` returns a standard `WebSocket` object.

    async connect(ctx) {
      if (ctx.connectProtocol === 'websocket') {
        const ws = ctx.upgradeWebSocket();
        ws.addEventListener('message', (e) => {
          ws.send(`Echo: ${e.data}`);
        });
        return;  // No response returned; the WebSocket owns the connection
      }
    }

    The returned `WebSocket` is in the `OPEN` state. The implementation has already completed the handshake (whether HTTP/1.1 Upgrade or HTTP/2/3 extended CONNECT).

    WebTransport

    WebTransport provides multiplexed streams and unreliable datagrams over an HTTP connection. On HTTP/3, it uses native QUIC streams and QUIC DATAGRAM frames. On HTTP/2, it falls back to the Capsule Protocol.

    Interface Definition

    [Exposed=*]
    interface WebTransportSession {
      // Streams
      readonly attribute ReadableStream incomingBidirectionalStreams;
      readonly attribute ReadableStream incomingUnidirectionalStreams;
      Promise<WebTransportBidirectionalStream> createBidirectionalStream();
      Promise<WritableStream> createUnidirectionalStream();
     
      // Datagrams
      readonly attribute DatagramStream datagrams;
     
      // Transport metadata
      readonly attribute DOMString transport;  // "quic" or "capsule"
     
      // Lifecycle
      undefined close(optional WebTransportCloseInfo closeInfo = {});
      readonly attribute Promise<WebTransportCloseInfo> closed;
      readonly attribute Promise<undefined> ready;
    };
     
    interface WebTransportBidirectionalStream {
      readonly attribute ReadableStream readable;
      readonly attribute WritableStream writable;
    };
     
    dictionary WebTransportCloseInfo {
      unsigned long closeCode = 0;
      USVString reason = "";
    };

    Session Establishment

    async connect(ctx) {
      if (ctx.connectProtocol === 'webtransport') {
        const session = await ctx.upgradeWebTransport();
     
        // Accept incoming bidirectional streams
        for await (const stream of session.incomingBidirectionalStreams) {
          handleStream(stream);  // { readable, writable }
        }
      }
    }

    Streams

    A WebTransport session can carry multiple independent streams:

  • Bidirectional streams: Opened by either side. Each has a `readable` and `writable`.
  • Unidirectional streams: Opened by one side, read by the other.
  • On HTTP/3, each WebTransport stream maps to a native QUIC stream. Streams are independent: a slow stream does not block others.

    On HTTP/2, streams are multiplexed over the single CONNECT data stream using the Capsule Protocol. Head-of-line blocking applies (TCP guarantees ordering).

    Datagrams

    const session = await ctx.upgradeWebTransport();
    const { readable, writable, unreliable } = session.datagrams;
     
    // Send datagram
    const writer = writable.getWriter();
    await writer.write(new Uint8Array([1, 2, 3]));
     
    // Receive datagrams
    for await (const dg of readable) {
      // dg is Uint8Array
    }

    WebTransport datagrams follow the same `DatagramStream` interface as tunnel datagrams.

    Transport Awareness

    The `transport` property indicates the underlying transport mechanism:

  • `"quic"`: Native QUIC streams and QUIC DATAGRAM frames. No head-of-line blocking between streams. Datagrams are unreliable.
  • `"capsule"`: Capsule Protocol over a single HTTP/2 data stream. Head-of-line blocking applies. Datagrams are reliable (sent as DATAGRAM capsules).
  • Handler Model

    An application provides one or two handler functions: `fetch()` for standard request/response exchanges, and optionally `connect()` for tunnel protocols.

    The FetchHandler

    callback FetchHandler = (Response or undefined or
                             Promise<Response or undefined>)
                            (ServerContext ctx);

    The `fetch()` handler receives a `ServerContext` and returns a `Response`, `undefined`, or a `Promise` resolving to one.

    Returning `undefined` is valid only after calling `ctx.deny()`. If the handler returns `undefined`without having called `deny()`, the implementation treats it as a programming error and sends a 500 Internal Server Error response.

    async fetch(ctx) {
      const { request } = ctx;
      const url = new URL(request.url);
     
      // Deny under load
      if (atCapacity) {
        ctx.deny();
        return;
      }
     
      if (url.pathname === '/api/data') {
        return Response.json({ ok: true });
      }
     
      return new Response("Not Found", { status: 404 });
    }

    The ConnectHandler

    callback ConnectHandler = (Response or undefined or
                               Promise<Response or undefined>)
                              (ConnectContext ctx);

    The `connect()` handler receives a `ConnectContext` and:

  • Returns `undefined` if the tunnel has been accepted or denied via `deny()`.
  • Returns a `Response` to deny the request at the application level.
  • Throws or returns a rejected promise, causing a 502 Bad Gateway response.
  • async connect(ctx) {
      switch (ctx.connectProtocol) {
        case 'websocket': {
          const ws = ctx.upgradeWebSocket();
          ws.addEventListener('message', e => ws.send(`Echo: ${e.data}`));
          return;
        }
        case 'webtransport': {
          const session = await ctx.upgradeWebTransport();
          handleWebTransport(session);
          return;
        }
        case 'connect-udp': {
          const tunnel = await ctx.accept();
          const dg = tunnel.datagrams();
          pipeUdpPayloads(dg);
          return;
        }
        default:
          return new Response(null, { status: 501 });
      }
    }

    If no `connect()` handler is provided, the implementation responds to CONNECT requests with 501 Not Implemented.

    Error Handling

    | Condition | Behavior | | ----------------------------------------------- | --------------------------------------- | | Handler calls ctx.deny(), returns undefined | Stream reset (per error code) | | Handler calls ctx.deny() with ERR\_HTTP\_GOAWAY | Stream reset + GOAWAY on the connection | | fetch() handler returns a Response | That response is sent | | fetch() handler throws | 500 Internal Server Error | | fetch() handler returns rejected promise | 500 Internal Server Error | | connect() handler returns a Response | That response is sent | | connect() handler returns undefined | Tunnel accepted (via accept/upgrade) | | connect() handler throws | 502 Bad Gateway | | connect() handler returns rejected promise | 502 Bad Gateway |

    The Handler Object

    Handlers are provided as an object with `fetch` and/or `connect` methods:

    dictionary HandlerObject {
      FetchHandler fetch;
      ConnectHandler connect;
    };
    const handler = {
      [Symbol.for('server.protocol')]: 1,
     
      fetch(ctx) {
        return new Response("Hello");
      },
      connect(ctx) {
        // ...
      },
    };
     
    serve(handler, { port: 8080 });

    When the handler is an object with methods, `this` inside the handler refers to the handler object. This allows the handler object to carry application state:

    const app = {
      [Symbol.for('server.protocol')]: 1,
      db: createPool(process.env.DATABASE_URL),
     
      async fetch(ctx) {
        const rows = await this.db.query('SELECT * FROM users');
        return Response.json(rows);
      },
     
      async [Symbol.asyncDispose]() {
        await this.db.end();
      },
    };
     
    serve(app, { port: 443, tls: { cert, key } });

    Declarative Export and Protocol Identification

    As an alternative to the imperative `serve()` call, an application may export a default handler object. Because existing runtimes already use `export default { fetch() {} }` with different handler signatures (e.g., Cloudflare Workers passes `(Request, Env, ExecutionContext)`, Deno passes `(Request, ServeHandlerInfo)`), a handler object includes a protocol marker so the runtime can distinguish this API from legacy invocation conventions.

    The marker is a `Symbol.for('server.protocol')` property with an integer version value:

    export default {
      [Symbol.for('server.protocol')]: 1,
     
      async fetch(ctx) {
        return new Response("Hello");
      },
     
      async connect(ctx) {
        if (ctx.connectProtocol === 'websocket') {
          const ws = ctx.upgradeWebSocket();
          ws.addEventListener('message', e => ws.send(e.data));
          return;
        }
        return new Response(null, { status: 501 });
      },
    };

    A conforming runtime that supports declarative export checks for the presence and value of `Symbol.for('server.protocol')` on the default export before invoking handler methods:

  • If `Symbol.for('server.protocol')` is present and its value is `1`, the runtime invokes `fetch()` with a `ServerContext` and `connect()` with a `ConnectContext`.
  • If `Symbol.for('server.protocol')` is absent, the runtime invokes handler methods using its existing (legacy) calling convention. Existing application code continues to work without modification.
  • If `Symbol.for('server.protocol')` is present but its value is not a recognized version, the runtime rejects the handler with a descriptive error.
  • The version number allows the protocol to evolve. This specification defines version `1`. Future revisions that change handler signatures would increment the version.

    The handler object is the portable unit. The same handler object works with both patterns:

    const handler = {
      [Symbol.for('server.protocol')]: 1,
      fetch(ctx) { return new Response("Hello"); },
    };
     
    // In Workers or similar edge runtime:
    export default handler;
     
    // In Node.js, Deno, or Bun:
    serve(handler, { port: 443, tls: { cert, key } });

    The handler code is identical. The deployment model differs.

    Server Configuration

    The serve() Function

    Server serve(HandlerObject handler, optional ServerOptions options = {});

    The `serve()` function creates a `Server`. If `hostname` and `port` (or `signal`) are provided in the options, the server begins listening immediately. Otherwise, the server is created in an unbound state and must be explicitly started with `server.listen()`.

    import { serve } from 'http';  // Module specifier is implementation-defined
     
    // One-step: create and listen
    const server = serve({
      [Symbol.for('server.protocol')]: 1,
      fetch(ctx) {
        return new Response("Hello");
      },
    }, {
      port: 443,
      hostname: '0.0.0.0',
      tls: {
        cert: readFileSync('cert.pem'),
        key: readFileSync('key.pem'),
      },
      quic: true,
    });
     
    // Two-step: create then listen
    const server = serve({
      [Symbol.for('server.protocol')]: 1,
      fetch(ctx) {
        return new Response("Hello");
      },
    }, {
      tls: { cert, key },
    });
     
    await server.listen({ port: 443, hostname: '0.0.0.0' });

    ServerOptions

    dictionary ServerOptions {
      unsigned short port;             // Port to bind (optional)
      DOMString hostname;              // Bind address (optional)
      TLSOptions tls;                  // TLS configuration (default)
      (boolean or QUICOptions) quic = false;  // Enable HTTP/3 (default)
      AbortSignal signal;              // Signal to stop the server
    };

    When `port` is `0`, the operating system assigns an available port. When `hostname` is omitted but `port` is provided, the default bind address is `"0.0.0.0"`.

    TLS Configuration

    dictionary TLSCertificate {
      (DOMString or BufferSource) cert;  // PEM certificate or chain
      (DOMString or BufferSource) key;   // PEM private key
    };
     
    callback SNICallback = (TLSCertificate or Promise<TLSCertificate> or null)
                           (DOMString hostname);
     
    dictionary TLSOptions : TLSCertificate {
      sequence<DOMString> alpn;          // ALPN protocols
                                         // (default: ["h2", "http/1.1"])
      (SNICallback or record<DOMString, TLSCertificate>) sni;
    };

    When `tls` is provided, the server listens for TLS connections and negotiates ALPN. The default ALPN list is `["h2", "http/1.1"]`.

    #### SNI-Based Certificate Selection

    The `sni` option enables serving multiple hostnames with different certificates on the same listener. It can be either an object mapping hostnames to certificates or a callback function.

    Object form — when the set of hostnames is known up front:

    serve(handler, {
      port: 443,
      tls: {
        cert: defaultCert,
        key: defaultKey,
        sni: {
          'example.com': { cert: exampleCert, key: exampleKey },
          '*.example.com': { cert: wildcardCert, key: wildcardKey },
          'other.net': { cert: otherCert, key: otherKey },
        },
      },
    });

    Keys support leading wildcard labels following RFC 6125Section 6.4.3 matching rules.

    Callback form — for dynamic selection (ACME, vault lookup, etc.):

    serve(handler, {
      port: 443,
      tls: {
        cert: defaultCert,
        key: defaultKey,
        async sni(hostname) {
          const record = await certStore.lookup(hostname);
          if (record) return { cert: record.cert, key: record.key };
          return null;  // fall through to default
        },
      },
    });

    QUIC Configuration

    When `quic` is `true` or a `QUICOptions` object, the server additionally listens for QUIC connections on the same port and supports HTTP/3.

    HTTP/3 requires TLS. If `quic` is enabled and `tls` is not provided, the implementation throws a `TypeError`.

    Server and Listener

    `Server` and `Listener` share a common interface for lifecycle management: `busy`, `close()`, `destroy()`, `closed`, and `Symbol.asyncDispose`. On a `Listener`, these apply to a single binding. On a `Server`, they apply to all listeners collectively.

    Interface Definitions

    interface mixin Closeable {
      attribute boolean busy;
      Promise<undefined> close();
      undefined destroy(optional any error);
      readonly attribute Promise<undefined> closed;
      async dispose();  // Symbol.asyncDispose
    };
     
    [Exposed=*]
    interface Listener {
      readonly attribute SocketAddress address;
    };
    Listener includes Closeable;
     
    [Exposed=*]
    interface Server {
      Promise<Listener> listen(ListenOptions options);
      iterable<Listener>;
    };
    Server includes Closeable;
     
    dictionary ListenOptions {
      unsigned short port = 0;         // 0 = OS-assigned
      DOMString hostname = "0.0.0.0";  // Bind address
      TLSOptions tls;                  // Per-listener TLS (overrides default)
      (boolean or QUICOptions) quic;   // Per-listener QUIC (overrides default)
    };

    Binding and Listeners

    const server = serve(handler, { tls: { cert, key } });
    const listener = await server.listen({ port: 443 });
    console.log(listener.address);
    // SocketAddress { address: "0.0.0.0", port: 443, ... }

    `listen()` may be called multiple times to bind the server to multiple addresses or ports:

    const server = serve(handler);
    const http  = await server.listen({ port: 80 });
    const https = await server.listen({ port: 443, tls: { cert, key, sni } });
    const quic  = await server.listen({ port: 443, tls: { cert, key }, quic: true });

    `Server` is iterable over its active listeners:

    for (const listener of server) {
      console.log(listener.address);
    }

    One-Step vs. Two-Step vs. Multi-Listener

    // One-step: serve() with port (single listener)
    const server = serve(handler, { port: 8080 });
     
    // Two-step: serve() then listen() (single listener)
    const server = serve(handler);
    await server.listen({ port: 8080 });
     
    // Multi-listener: HTTP + HTTPS + HTTP/3
    const server = serve(handler);
    await server.listen({ port: 80 });
    await server.listen({ port: 443, tls: { cert, key, sni } });
    await server.listen({ port: 443, tls: { cert, key }, quic: true });

    The Closeable Interface

    The `Closeable` mixin provides a uniform lifecycle interface shared by `Server` and `Listener`.

    #### busy

    server.busy = true;     // All listeners stop accepting new requests
    listener.busy = true;   // Only this listener stops accepting new requests

    When `busy` is `true`, new requests are not dispatched to handlers. In-flight requests continue to be processed.

    `busy` is intended for brief back-pressure scenarios such as waiting for a downstream dependency to recover, performing a configuration reload, or coordinating with a load balancer during a rolling deploy. For permanent shutdown, use `close()`.

    #### close()

    await listener.close();   // Close one binding
    await server.close();     // Close all bindings

    The `close()` method initiates graceful shutdown. On a listener, it stops accepting new connections, sends GOAWAY frames on HTTP/2 and HTTP/3 connections, allows in-flight requests to complete, and resolves when all connections are closed. On a server, it calls `close()` on every active listener and waits for all `waitUntil()` promises to settle.

    Closing a listener does not close the server. Other listeners remain active.

    const server = serve(handler);
    const http = await server.listen({ port: 80 });
    const https = await server.listen({ port: 443, tls: { cert, key } });
     
    // Stop serving plaintext, keep HTTPS
    await http.close();
     
    // Or use disposal for scoped listeners
    {
      await using temp = await server.listen({ port: 8080 });
      // temp listener is active
    }
    // temp listener has been closed, server continues

    #### destroy()

    listener.destroy();                  // Immediately terminate one binding
    server.destroy();                    // Immediately terminate everything
    server.destroy(new Error('fatal'));   // With an error

    The `destroy(error)` method immediately terminates without draining.

    | Behavior | close() | destroy() | | -------------------- | ------------------- | ------------------- | | New connections | Refused | Refused | | GOAWAY sent | Yes | No | | In-flight requests | Allowed to complete | Aborted immediately | | waitUntil() promises | Allowed to settle | Ignored | | closed promise | Resolves | Rejects (if error) | | Returns | Promise (async) | undefined (sync) |

    #### closed

    await listener.closed;   // Resolves when this listener is fully closed
    await server.closed;     // Resolves when all listeners are closed

    #### Symbol.asyncDispose

    Both `Server` and `Listener` implement `Symbol.asyncDispose`, which calls `close()` and waits for the `closed` promise.

    {
      await using server = serve(handler, { port: 8080 });
      // Server is running
    }
    // Server has been gracefully closed

    Signal-Based Termination

    If an `AbortSignal` is provided in `ServerOptions`, aborting the signal triggers abrupt termination (equivalent to calling `server.destroy(signal.reason)`).

    const ac = new AbortController();
    const server = serve(handler, { port: 8080, signal: ac.signal });
     
    // Later:
    ac.abort(new Error('shutting down'));  // Triggers destroy()
    await server.closed.catch(() => {});

    For graceful close, call `server.close()` directly rather than using the signal.

    HTTP Version Negotiation

    Transparent Protocol Handling

    A conforming implementation routes requests to the appropriate handler regardless of HTTP version. The `fetch()` handler receives all non-CONNECT requests. The `connect()` handler receives all CONNECT and extended CONNECT requests. The handler does not select which HTTP version to handle.

    Feature Availability by Protocol Version

    | Feature | HTTP/1.1 | HTTP/2 | HTTP/3 | | --------------------------- | --------------- | ---------------- | ---------------- | | Trailers (request) | Chunked TE only | Yes | Yes | | Trailers (response) | Chunked TE only | Yes | Yes | | Informational responses | Yes | Yes | Yes | | Extended CONNECT | No | Yes | Yes | | WebSocket | Via Upgrade | Via ext. CONNECT | Via ext. CONNECT | | WebTransport | No | Capsule fallback | Native QUIC | | HTTP Datagrams (unreliable) | No | No | Yes (QUIC DG) | | HTTP Datagrams (reliable) | No | Yes (capsule) | Yes (capsule) | | CONNECT-UDP | No | Capsule fallback | Native QUIC DG | | CONNECT-IP | No | Capsule fallback | Native QUIC DG | | Full-duplex streaming | No | Yes | Yes |

    Security Considerations

  • No CORS Enforcement: This specification is for server-side runtimes. CORS is a browser security mechanism. Server-side implementations do not enforce CORS restrictions.
  • Header Filtering: Implementations should apply appropriate header filtering. Hop-by-hop headers are processed by the implementation and generally not exposed in `Request` headers. Trailer field filtering per RFC 9110 Section 6.5.2 should remove fields not appropriate as trailers.
  • Tunnel Security: The `connect()` handler establishes tunnels that can carry arbitrary traffic. Applications must perform their own authorization before calling `accept()`, `upgradeWebSocket()`, or `upgradeWebTransport()`.
  • Denial of Service: WebTransport sessions can open many streams; implementations should impose limits. `waitUntil()` extends request lifetime; implementations should impose timeouts. The Capsule Protocol and datagrams can generate high throughput; implementations should apply flow control.
  • Complete Examples

    One-Step Server

    import { serve } from 'http';
    import { readFileSync } from 'fs';
     
    const app = {
      [Symbol.for('server.protocol')]: 1,
     
      async fetch(ctx) {
        const { request } = ctx;
        const url = new URL(request.url);
     
        // Send 103 Early Hints for the main page
        if (url.pathname === '/') {
          ctx.sendInformational(103, {
            "Link": '</style.css>; rel=preload; as=style'
          });
        }
     
        // Proxy a request
        if (url.pathname.startsWith('/proxy/')) {
          return fetch(ctx.request);
        }
     
        // Return a response with trailers
        if (url.pathname === '/download') {
          const { readable, writable } = new TransformStream();
          const digest = computeDigestWhileStreaming(writable);
     
          return new Response(readable, {
            headers: { "Trailer": "Content-Digest" },
            trailers: digest,
          });
        }
     
        // Background work after response
        ctx.waitUntil(logRequest(request));
     
        return new Response("Hello!");
      },
     
      async connect(ctx) {
        switch (ctx.connectProtocol) {
          case 'websocket': {
            const ws = ctx.upgradeWebSocket();
            ws.addEventListener('message', e => ws.send(`Echo: ${e.data}`));
            return;
          }
          case 'webtransport': {
            const session = await ctx.upgradeWebTransport();
            for await (const stream of session.incomingBidirectionalStreams) {
              handleBidiStream(stream);
            }
            return;
          }
          case 'connect-udp': {
            const tunnel = await ctx.accept();
            const dg = tunnel.datagrams();
            pipeUdpTraffic(dg);
            return;
          }
          default:
            return new Response(null, { status: 501 });
        }
      },
     
    };
     
    serve(app, {
      port: 443,
      tls: {
        cert: readFileSync('cert.pem'),
        key: readFileSync('key.pem'),
      },
      quic: true,
    });

    Multi-Homed Server with SNI

    import { serve } from 'http';
    import { readFileSync } from 'fs';
     
    const server = serve({
      [Symbol.for('server.protocol')]: 1,
     
      async fetch(ctx) {
        const host = ctx.request.headers.get('Host');
        return new Response(`Hello from ${host}!`);
      },
    });
     
    // Plaintext on port 80
    await server.listen({ port: 80 });
     
    // HTTPS on port 443 with SNI for multiple hostnames
    await server.listen({
      port: 443,
      tls: {
        cert: readFileSync('default-cert.pem'),
        key: readFileSync('default-key.pem'),
        sni: {
          'example.com': {
            cert: readFileSync('example-cert.pem'),
            key: readFileSync('example-key.pem'),
          },
          'api.example.com': {
            cert: readFileSync('api-cert.pem'),
            key: readFileSync('api-key.pem'),
          },
        },
      },
      quic: true,
    });
     
    for (const listener of server) {
      console.log(listener.address);
    }
    // SocketAddress { address: "0.0.0.0", port: 80, ... }
    // SocketAddress { address: "0.0.0.0", port: 443, ... }

    Lifecycle Management

    import { serve } from 'http';
     
    const server = serve({
      [Symbol.for('server.protocol')]: 1,
     
      async fetch(ctx) {
        return new Response("Hello!");
      },
    });
     
    await server.listen({ port: 8080 });
     
    // Temporarily stop accepting requests during config reload
    server.busy = true;
    await reloadConfiguration();
    server.busy = false;
     
    // Graceful close on SIGTERM
    process.on('SIGTERM', () => {
      server.close();
    });
     
    // Abrupt termination on unrecoverable error
    process.on('uncaughtException', (err) => {
      server.destroy(err);
    });
     
    await server.closed;

    Open Issues

    Structured Fields on Headers

    RFC 9651 defines typed values (integers, booleans, tokens, byte sequences, etc.) for HTTP fields. The `Headers` interface exposes only raw strings. Adding structured field parsing to `Headers` (e.g., a `getStructured()` method) would benefit many use cases beyond priority.

    This is a potential change to the Fetch Standard's `Headers` type and is out of scope for this specification, but would complement it.

    Full-Duplex Streaming

    HTTP/2 and HTTP/3 support full-duplex streaming: the request body and response body are independent streams that can be read/written concurrently with independent half-close.

    The Fetch Standard's `duplex` property currently allows only `"half"` for requests.

    For server-side handlers, full-duplex is implicit: the handler can begin writing a response (via a `ReadableStream` body) while the request body is still being received.

    Typed Stream Resets and Error Codes

    HTTP/2 RST\_STREAM and HTTP/3 RESET\_STREAM/STOP\_SENDING frames carry error codes. `AbortSignal.reason` provides an arbitrary JavaScript value but has no mapping to protocol-level error codes.

    The `deny()` method on `ServerContext` addresses one direction of this problem: a server sending a typed stream reset to a client. The same taxonomy applies in the other direction: when a client receives a stream reset from a server, the error code should be surfaced on the resulting `TypeError`. The TC39 Error Code proposalwould enable this.

    Module Specifier

    The import path for `serve()` is left implementation-defined. Possible values include:

  • `node:http` (extending the existing Node.js module)
  • `node:serve` (new Node.js module)
  • `http` (generic, non-Node.js-specific)
  • A standardized module specifier may be desirable if this API is adopted by WinterTC.

    ServerResponse

    This specification currently requires no server-specific response type. Handlers return a standard `Response` with trailer support provided by the Fetch Standard.

    If future server-specific response capabilities are identified (e.g., server push, priority signaling, response-side lifecycle), a `ServerResponse` type may be introduced. The handler model is designed to accommodate this: the `fetch()` handler's return type can be extended to include `ServerResponse` without breaking existing handlers that return `Response`.

    Addendum: Extending the Handler Model to Raw TCP Sockets

    The handler object pattern is designed to be extensible. New handler methods can be added without changing existing signatures. This addendum sketches how the model extends to support raw TCP ingress — connections that are not HTTP.

    Motivation

    Some server-side runtimes (e.g., Cloudflare Workers) support arbitrary TCP ingress where non-HTTP connections are routed to the application. These connections carry raw bytes — no HTTP framing, no request method, no headers. They need a different handler and a different context.

    SocketContext

    A `SocketContext` provides a WinterTC Socket and connection metadata. It is not related to `ServerContext` by inheritance — there is no HTTP `Request`, no `sendInformational()`, no `deny()`.

    [Exposed=*]
    interface SocketContext {
      [SameObject] readonly attribute Socket socket;
      readonly attribute SocketAddress remoteAddress;
      readonly attribute DOMString alpnProtocol;
      readonly attribute DOMString? serverName;
      undefined waitUntil(Promise<any> promise);
    };

  • `socket` — a `Socket` with `readable` and `writable` streams. Already connected; TLS (if any) is complete.
  • `remoteAddress` — the remote peer's `SocketAddress`.
  • `alpnProtocol` — ALPN protocol from TLS negotiation, or empty string. Can be any application-defined identifier, not just HTTP versions.
  • `serverName` — SNI hostname from TLS ClientHello, or `null`. Useful for multi-tenant routing.
  • `waitUntil()` — lifecycle extension, same as `ServerContext`.

SocketHandler

callback SocketHandler = (undefined or Promise<undefined>)
                         (SocketContext ctx);

A `socket()` method is added to the handler object:

export default {
  [Symbol.for('server.protocol')]: 1,
  fetch(ctx) { / HTTP request/response / },
  connect(ctx) { / HTTP tunnels / },
  socket(ctx) { / Raw TCP connections / },
};

Example

export default {
  [Symbol.for('server.protocol')]: 1,
 
  async fetch(ctx) {
    return new Response("Hello via HTTP");
  },
 
  async socket(ctx) {
    const { socket, remoteAddress, serverName } = ctx;
    const reader = socket.readable.getReader();
    const writer = socket.writable.getWriter();
 
    await writer.write(new TextEncoder().encode(
      `Hello ${remoteAddress.address}\r\n`
    ));
 
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      await writer.write(value);  // echo
    }
  },
};

Routing

How the implementation distinguishes HTTP from non-HTTP connections is implementation-defined. Common approaches include port-based routing, ALPN-based routing (a non-HTTP ALPN token), or protocol detection (inspecting the first bytes of the connection).

If no `socket()` handler is provided and a non-HTTP connection arrives, the implementation closes the connection. If the handler throws, the implementation closes the socket.

www.jasnell.me · 34 links

Comments (0)

No comments yet