Fetch Server API

Draft,

This version:
https://fetch-server.proposal.wintertc.org/
Issue Tracking:
GitHub
Editor:
(Cloudflare)

Introduction

This document is a proposal to ECMA TC55 (WinterTC). It has not been adopted as a standard and may change substantially based on committee feedback. It is published for review and discussion purposes.

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:

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.

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:

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

© 2026 Ecma International

Permission under Ecma’s copyright to copy, modify, prepare derivative works of, and distribute this work, with or without modification, for any purpose and without fee or royalty is hereby granted, provided that you include the full text of this copyright notice on ALL copies of the work or portions thereof.

THIS WORK IS PROVIDED "AS IS," AND COPYRIGHT HOLDERS MAKE NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE DOCUMENT WILL NOT INFRINGE ANY THIRD PARTY PATENTS, COPYRIGHTS, TRADEMARKS OR OTHER RIGHTS.

1. Scope

This proposal defines the Fetch Server API, a server-side HTTP API built on the Fetch Standard’s Request and Response types. It specifies:

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

A conforming implementation shall also conform to [ECMASCRIPT] and [WEBIDL].

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:

3. Normative references

The following documents are referred to in the text in such a way that some or all of their content constitutes requirements of this document.

References

Normative References

[DOM]
Anne van Kesteren. DOM Standard. Living Standard. URL: https://dom.spec.whatwg.org/
[ECMASCRIPT]
ECMAScript Language Specification. URL: https://tc39.es/ecma262/multipage/
[FETCH]
Anne van Kesteren. Fetch Standard. Living Standard. URL: https://fetch.spec.whatwg.org/
[STREAMS]
Adam Rice; et al. Streams Standard. Living Standard. URL: https://streams.spec.whatwg.org/
[WEBIDL]
Edgar Chen; Timothy Gu. Web IDL Standard. Living Standard. URL: https://webidl.spec.whatwg.org/

4. Terms and definitions

For the purposes of this document, the terms and definitions given in [ECMASCRIPT], the Fetch Standard [FETCH], the DOM Standard [DOM], and the following apply.

4.1. Fetch Server API

the server-side HTTP API defined by this specification

4.2. web-interoperable runtime

ECMAScript-based runtime environment as defined by WinterTC

4.3. server

an entity that accepts HTTP connections, receives requests, and sends responses

4.4. handler

a JavaScript function provided by the application that processes incoming requests or tunnel establishment attempts

4.5. tunnel

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

4.6. connect protocol

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

4.7. protocol marker

a Symbol.for('server.protocol') property on a handler object with an integer version value, used by the runtime to distinguish this API from legacy invocation conventions

5. Web IDL definitions

5.1. SocketAddress

dictionary SocketAddress {
  DOMString address;
  unsigned short port;
  DOMString family;
};

A SocketAddress represents a network endpoint. The address member is the IP address as a string. The family member indicates the address family: "IPv4" or "IPv6".

5.2. RequestPriority

dictionary RequestPriority {
  unsigned short urgency = 3;
  boolean incremental = false;
};

A RequestPriority represents a priority signal per RFC 9218. The urgency member is an integer in the range 0–7, where 0 is the highest priority and 7 is the lowest. The default is 3. The incremental member indicates whether incremental delivery is preferred.

5.3. Callback definitions

callback PriorityCallback = undefined (optional RequestPriority priority = {});

callback FetchHandler = any (ServerContext ctx);

callback ConnectHandler = any (ConnectContext ctx);

The FetchHandler callback receives a ServerContext and returns a Response, undefined, or a Promise resolving to one of those. The return type is specified as any because Web IDL cannot express (Response or undefined or Promise<Response or undefined>) as a callback return type.

The ConnectHandler callback receives a ConnectContext and returns a Response, undefined, or a Promise resolving to one of those.

5.4. Options dictionaries

dictionary WebSocketUpgradeInit {
  sequence<DOMString> protocol;
};

dictionary WebTransportCloseInfo {
  unsigned long closeCode = 0;
  USVString reason = "";
};

dictionary TLSCertificate {
  (DOMString or BufferSource) cert;
  (DOMString or BufferSource) key;
};

callback SNICallback = any (DOMString hostname);

dictionary TLSOptions : TLSCertificate {
  sequence<DOMString> alpn;
  (SNICallback or record<DOMString, TLSCertificate>) sni;
};

dictionary QUICOptions {
};

dictionary ServerOptions {
  unsigned short port;
  DOMString hostname;
  TLSOptions tls;
  (boolean or QUICOptions) quic = false;
  AbortSignal signal;
};

dictionary ListenOptions {
  unsigned short port = 0;
  DOMString hostname = "0.0.0.0";
  TLSOptions tls;
  (boolean or QUICOptions) quic;
};

dictionary HandlerObject {
  required FetchHandler fetch;
  ConnectHandler connect;
};

5.5. The ServerContext interface

[Exposed=*]
interface ServerContext {
  [SameObject] readonly attribute Request request;

  readonly attribute SocketAddress remoteAddress;
  readonly attribute DOMString alpnProtocol;

  readonly attribute RequestPriority clientPriority;
  attribute RequestPriority? serverPriority;
  undefined onPriority(PriorityCallback callback);

  undefined sendInformational(unsigned short status,
                              optional HeadersInit headers);

  undefined deny(optional any error);

  undefined waitUntil(Promise<any> promise);
};

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

5.6. The ConnectContext interface

[Exposed=*]
interface ConnectContext : ServerContext {
  readonly attribute DOMString? connectProtocol;

  Promise<Tunnel> accept(optional ResponseInit init = {});

  object upgradeWebSocket(optional WebSocketUpgradeInit options = {});
  Promise<WebTransportSession> upgradeWebTransport();
};

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

5.7. The Tunnel interface

[Exposed=*]
interface Tunnel {
  readonly attribute ReadableStream readable;
  readonly attribute WritableStream writable;

  CapsuleStream capsules();

  DatagramStream datagrams();

  undefined close();
  readonly attribute Promise<undefined> closed;
};

The Tunnel interface represents an established tunnel through an HTTP connection.

5.8. The CapsuleStream interface

[Exposed=*]
interface CapsuleStream {
  readonly attribute ReadableStream readable;
  readonly attribute WritableStream writable;
};

The CapsuleStream interface provides typed access to the Capsule Protocol on the CONNECT data stream. The readable attribute yields Capsule objects. The writable attribute accepts Capsule objects.

5.9. The Capsule dictionary

dictionary Capsule {
  unsigned long long type;
  Uint8Array data;
};

A Capsule represents a single typed capsule as defined in RFC 9297.

5.10. The DatagramStream interface

[Exposed=*]
interface DatagramStream {
  readonly attribute ReadableStream readable;
  readonly attribute WritableStream writable;
  readonly attribute boolean unreliable;
};

The DatagramStream interface provides access to HTTP Datagrams associated with a tunnel or WebTransportSession. The readable attribute yields Uint8Array payloads. The writable attribute accepts Uint8Array payloads. The unreliable attribute indicates whether datagrams are sent as unreliable QUIC DATAGRAM frames (true) or as reliable DATAGRAM capsules (false).

5.11. The WebTransportSession interface

[Exposed=*]
interface WebTransportSession {
  readonly attribute ReadableStream incomingBidirectionalStreams;
  readonly attribute ReadableStream incomingUnidirectionalStreams;
  Promise<WebTransportBidirectionalStream> createBidirectionalStream();
  Promise<WritableStream> createUnidirectionalStream();

  readonly attribute DatagramStream datagrams;

  readonly attribute DOMString transport;

  undefined close(optional WebTransportCloseInfo closeInfo = {});
  readonly attribute Promise<WebTransportCloseInfo> closed;
  readonly attribute Promise<undefined> ready;
};

The WebTransportSession interface represents an established WebTransport session, providing multiplexed streams and datagrams over an HTTP connection.

5.12. The WebTransportBidirectionalStream interface

[Exposed=*]
interface WebTransportBidirectionalStream {
  readonly attribute ReadableStream readable;
  readonly attribute WritableStream writable;
};

5.13. The Closeable interface mixin

interface mixin Closeable {
  attribute boolean busy;
  Promise<undefined> close();
  undefined destroy(optional any error);
  readonly attribute Promise<undefined> closed;
};

The Closeable mixin provides a uniform lifecycle interface shared by Server and Listener. Implementations of Closeable should support Symbol.asyncDispose, which calls close() and waits for the closed promise.

5.14. The Listener interface

[Exposed=*]
interface Listener {
  readonly attribute SocketAddress address;
};
Listener includes Closeable;

The Listener interface represents a single network binding. The address attribute returns the SocketAddress that this listener is bound to.

5.15. The Server interface

[Exposed=*]
interface Server {
  Promise<Listener> listen(optional ListenOptions options = {});
  iterable<Listener>;
};
Server includes Closeable;

The Server interface manages one or more listeners and dispatches incoming requests to handlers.

5.16. The serve() function

[Exposed=*]
namespace FetchServer {
  Server serve(HandlerObject handler, optional ServerOptions options = {});
};
The namespace FetchServer is used here for specification purposes. The actual module specifier is implementation-defined. Possible values include "http", "node:http", "node:serve", or a WinterTC-standardized module name. The import surface is an open question; see § 15.1 Module specifier.

6. ServerContext

6.1. 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:

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);
}

6.2. Connection metadata

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

Note: 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.

6.3. 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:

6.3.1. Client priority

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

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 clientPriority before invoking any registered callback.

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

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

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 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() with 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 clientPriority.

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

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

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.

6.4. Informational responses

The sendInformational(status, headers) method sends an informational (1xx) response to the client.
  1. If status is not in the range 100–199 inclusive, throw a RangeError.
  2. Construct a headers object from headers if provided.
  3. Send the informational response with status status and the constructed headers to the client.

Notable informational status codes:

An implementation that does not support informational responses accepts the method call without error and silently discards it.

Note: 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.

6.5. Denial

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:

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

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.

Note: Proactive GOAWAY (shutting down connections without a triggering request) is handled by close() and 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.

Note: 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.

6.6. Request lifecycle

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.

7. ConnectContext

7.1. Protocol identification

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

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

7.3. Accepting a tunnel

The accept(init) 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.

7.4. WebSocket upgrade

The upgradeWebSocket(options) 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.

7.5. WebTransport upgrade

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.

7.6. Denying a tunnel

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

Protocol-level denial via 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).

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

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

8.2. Capsule Protocol

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 readable or writable throws an "InvalidStateError" DOMException.
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([...]) });

8.3. HTTP Datagrams

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.

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

9.1. Session establishment

A WebTransportSession is obtained via upgradeWebTransport():

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 }
    }
  }
}

9.2. Streams

A WebTransportSession can carry multiple independent streams:

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

9.3. Datagrams

WebTransport datagrams follow the same DatagramStream interface as tunnel datagrams. The datagrams attribute provides a DatagramStream.

9.4. Transport awareness

The transport property indicates the underlying transport mechanism:

10. Handler model

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

10.1. The FetchHandler

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

Returning undefined is valid only after calling 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 });
}

10.2. The ConnectHandler

The connect() handler receives a ConnectContext and:

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.

10.3. Error handling

Condition Behavior
Handler calls deny(), returns undefined Stream reset (per error code)
Handler calls 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

10.4. The handler object

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

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 } });

10.5. 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:

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 } });

11. Server configuration

11.1. The serve() function

The serve(handler, options) 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 listen().
// 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' });

11.2. ServerOptions

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

11.3. TLS configuration

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

11.3.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 6125 Section 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
    },
  },
});

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

12. Server and Listener

Server and Listener share the Closeable interface for lifecycle management. On a Listener, these apply to a single binding. On a Server, they apply to all listeners collectively.

12.1. Binding and listeners

The listen(options) method binds the server to a network address and returns a Promise<Listener> that resolves when the binding is established.

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 } });
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);
}

12.1.1. 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 });

12.2. The Closeable interface

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

12.2.1. busy

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

Note: 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().

12.2.2. close()

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.

12.2.3. destroy()

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)

12.2.4. closed

The closed attribute returns a promise that resolves when the server or listener is fully closed.

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

12.3. Signal-based termination

If an AbortSignal is provided in ServerOptions, aborting the signal triggers abrupt termination (equivalent to calling destroy() with the signal’s 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 close() directly rather than using the signal.

13. HTTP version negotiation

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

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

14. Security considerations

15. Open issues

15.1. Module specifier

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

A standardized module specifier may be desirable if this API is adopted by WinterTC.

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

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

15.4. 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 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 proposal would enable this.

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

16. Addendum: 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.

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

16.2. 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 object socket;
  readonly attribute SocketAddress remoteAddress;
  readonly attribute DOMString alpnProtocol;
  readonly attribute DOMString? serverName;
  undefined waitUntil(Promise<any> promise);
};

16.3. 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 */ },
};

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

Index

Terms defined by this specification

Terms defined by reference

IDL Index

dictionary SocketAddress {
  DOMString address;
  unsigned short port;
  DOMString family;
};

dictionary RequestPriority {
  unsigned short urgency = 3;
  boolean incremental = false;
};

callback PriorityCallback = undefined (optional RequestPriority priority = {});

callback FetchHandler = any (ServerContext ctx);

callback ConnectHandler = any (ConnectContext ctx);

dictionary WebSocketUpgradeInit {
  sequence<DOMString> protocol;
};

dictionary WebTransportCloseInfo {
  unsigned long closeCode = 0;
  USVString reason = "";
};

dictionary TLSCertificate {
  (DOMString or BufferSource) cert;
  (DOMString or BufferSource) key;
};

callback SNICallback = any (DOMString hostname);

dictionary TLSOptions : TLSCertificate {
  sequence<DOMString> alpn;
  (SNICallback or record<DOMString, TLSCertificate>) sni;
};

dictionary QUICOptions {
};

dictionary ServerOptions {
  unsigned short port;
  DOMString hostname;
  TLSOptions tls;
  (boolean or QUICOptions) quic = false;
  AbortSignal signal;
};

dictionary ListenOptions {
  unsigned short port = 0;
  DOMString hostname = "0.0.0.0";
  TLSOptions tls;
  (boolean or QUICOptions) quic;
};

dictionary HandlerObject {
  required FetchHandler fetch;
  ConnectHandler connect;
};

[Exposed=*]
interface ServerContext {
  [SameObject] readonly attribute Request request;

  readonly attribute SocketAddress remoteAddress;
  readonly attribute DOMString alpnProtocol;

  readonly attribute RequestPriority clientPriority;
  attribute RequestPriority? serverPriority;
  undefined onPriority(PriorityCallback callback);

  undefined sendInformational(unsigned short status,
                              optional HeadersInit headers);

  undefined deny(optional any error);

  undefined waitUntil(Promise<any> promise);
};

[Exposed=*]
interface ConnectContext : ServerContext {
  readonly attribute DOMString? connectProtocol;

  Promise<Tunnel> accept(optional ResponseInit init = {});

  object upgradeWebSocket(optional WebSocketUpgradeInit options = {});
  Promise<WebTransportSession> upgradeWebTransport();
};

[Exposed=*]
interface Tunnel {
  readonly attribute ReadableStream readable;
  readonly attribute WritableStream writable;

  CapsuleStream capsules();

  DatagramStream datagrams();

  undefined close();
  readonly attribute Promise<undefined> closed;
};

[Exposed=*]
interface CapsuleStream {
  readonly attribute ReadableStream readable;
  readonly attribute WritableStream writable;
};

dictionary Capsule {
  unsigned long long type;
  Uint8Array data;
};

[Exposed=*]
interface DatagramStream {
  readonly attribute ReadableStream readable;
  readonly attribute WritableStream writable;
  readonly attribute boolean unreliable;
};

[Exposed=*]
interface WebTransportSession {
  readonly attribute ReadableStream incomingBidirectionalStreams;
  readonly attribute ReadableStream incomingUnidirectionalStreams;
  Promise<WebTransportBidirectionalStream> createBidirectionalStream();
  Promise<WritableStream> createUnidirectionalStream();

  readonly attribute DatagramStream datagrams;

  readonly attribute DOMString transport;

  undefined close(optional WebTransportCloseInfo closeInfo = {});
  readonly attribute Promise<WebTransportCloseInfo> closed;
  readonly attribute Promise<undefined> ready;
};

[Exposed=*]
interface WebTransportBidirectionalStream {
  readonly attribute ReadableStream readable;
  readonly attribute WritableStream writable;
};

interface mixin Closeable {
  attribute boolean busy;
  Promise<undefined> close();
  undefined destroy(optional any error);
  readonly attribute Promise<undefined> closed;
};

[Exposed=*]
interface Listener {
  readonly attribute SocketAddress address;
};
Listener includes Closeable;

[Exposed=*]
interface Server {
  Promise<Listener> listen(optional ListenOptions options = {});
  iterable<Listener>;
};
Server includes Closeable;

[Exposed=*]
namespace FetchServer {
  Server serve(HandlerObject handler, optional ServerOptions options = {});
};

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

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

Ecma International

Rue du Rhone 114

CH-1204 Geneva

Tel: +41 22 849 6000

Fax: +41 22 849 6001

Web: https://ecma-international.org/

© 2026 Ecma International

This draft document may be copied and furnished to others, and derivative works that comment on or otherwise explain it or assist in its implementation may be prepared, copied, published, and distributed, in whole or in part, without restriction of any kind, provided that the above copyright notice and this section are included on all such copies and derivative works. However, this document itself may not be modified in any way, including by removing the copyright notice or references to Ecma International, except as needed for the purpose of developing any document or deliverable produced by Ecma International.

This disclaimer is valid only prior to final version of this document. After approval all rights on the standard are reserved by Ecma International.

The limited permissions are granted through the standardization phase and will not be revoked by Ecma International or its successors or assigns during this time.

This document and the information contained herein is provided on an "AS IS" basis and ECMA INTERNATIONAL DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTY THAT THE USE OF THE INFORMATION HEREIN WILL NOT INFRINGE ANY OWNERSHIP RIGHTS OR ANY IMPLIED WARRANTIES OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE.

Software License

All Software contained in this document ("Software") is protected by copyright and is being made available under the "BSD License", included below. This Software may be subject to third party rights (rights from parties other than Ecma International), including patent rights, and no licenses under such third party rights are granted under this license even if the third party concerned is a member of Ecma International. SEE THE ECMA CODE OF CONDUCT IN PATENT MATTERS AVAILABLE AT https://ecma-international.org/memento/codeofconduct.htm FOR INFORMATION REGARDING THE LICENSING OF PATENT CLAIMS THAT ARE REQUIRED TO IMPLEMENT ECMA INTERNATIONAL STANDARDS.

Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:

  1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
  2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
  3. Neither the name of the authors nor Ecma International may be used to endorse or promote products derived from this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE ECMA INTERNATIONAL "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL ECMA INTERNATIONAL BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.