WHATWG Fetch

Elide's WHATWG Fetch implementation is being built up in stages. This page is a placeholder for the user-visible API documentation; it will be fleshed out as the guest-visible classes land in subsequent commits.

Status

The foundation for the Fetch builtins is in place: spec primitives for HTTP token grammar, header name / value validation, forbidden method / header enforcement, MIME type parsing, and the AbortSignal.timeout(ms) static factory used to cancel in-flight requests after a deadline. Blob, File, Headers, FormData, the Body mixin machinery (with multipart and URL-encoded codecs), CompressionStream / DecompressionStream, Request, and Response are guest-visible globals (per WHATWG File API, WHATWG Fetch §5, WHATWG XHR §FormData, and WinterTC Minimum Common API). The remaining surface — the global fetch() function and the server-side handler shape — is covered in the sections below.

Blob

ts
declare class Blob {
  constructor(blobParts?: BlobPart[], options?: BlobPropertyBag);

  readonly size: number;
  readonly type: string;

  slice(start?: number, end?: number, contentType?: string): Blob;
  text(): Promise<string>;
  arrayBuffer(): Promise<ArrayBuffer>;
  bytes(): Promise<Uint8Array>;
  stream(): ReadableStream<Uint8Array>;
}

type BlobPart = ArrayBufferView | ArrayBuffer | Blob | string;

interface BlobPropertyBag {
  type?: string;
  endings?: "transparent" | "native";
}

The constructor concatenates the byte representations of every BlobPart element: ArrayBuffer / ArrayBufferView parts contribute their bytes verbatim (a copy is taken at construction time), Blob parts contribute their byte sequence (the source's type is ignored), and string parts are UTF-8 encoded. With endings: "native", every CR / LF / CRLF in a string part is folded to the platform's native line separator before encoding.

slice(start, end, contentType) returns a new Blob covering bytes [start, end) after spec-defined clamping (negative indices count from the end; both indices are clamped to [0, size]). The slice shares the parent's underlying byte storage internally — copies are not duplicated. text(), arrayBuffer(), and bytes() return promises that resolve immediately on the next microtask. stream() returns a fresh byte-mode ReadableStream that enqueues the blob's bytes in 64 KiB chunks before closing.

File

ts
declare class File extends Blob {
  constructor(fileBits: BlobPart[], fileName: string, options?: FilePropertyBag);

  readonly name: string;
  readonly lastModified: number;
}

interface FilePropertyBag extends BlobPropertyBag {
  lastModified?: number;
}
File is a Blob with a name and a last-modified timestamp. The prototype chain has File.prototype inheriting from Blob.prototype, so every Blob operation (slice, text, arrayBuffer, bytes, stream) is available on File instances unchanged. File.prototype.slice returns a Blob, not a File, per spec.

When lastModified is omitted, the constructor records Date.now() at construction time.

js
// Concatenating heterogeneous parts.
const blob = new Blob(["alpha", new Uint8Array([0xFF]), existingBlob]);

// Slicing.
const head = blob.slice(0, 16);
const tail = blob.slice(-16);

// Reading.
const txt = await new Blob(["hello"]).text();      // → "hello"
const buf = await new Blob(["hello"]).arrayBuffer(); // → ArrayBuffer(5)
const u8  = await new Blob(["hello"]).bytes();       // → Uint8Array(5)

// Streaming.
const stream = blob.stream();
for await (const chunk of stream) {
  // chunk is a Uint8Array.
}

// File construction.
const file = new File(["hello"], "greeting.txt", {
  type: "text/plain",
  lastModified: 1700000000000,
});
file instanceof Blob; // → true

Headers

ts
declare class Headers {
  constructor(init?: HeadersInit);

  append(name: ByteString, value: ByteString): void;
  delete(name: ByteString): void;
  get(name: ByteString): ByteString | null;
  getSetCookie(): ByteString[];
  has(name: ByteString): boolean;
  set(name: ByteString, value: ByteString): void;

  forEach(
    callback: (value: ByteString, key: ByteString, parent: Headers) => void,
    thisArg?: unknown,
  ): void;

  entries(): IterableIterator<[ByteString, ByteString]>;
  keys(): IterableIterator<ByteString>;
  values(): IterableIterator<ByteString>;
  [Symbol.iterator](): IterableIterator<[ByteString, ByteString]>;
}

type HeadersInit =
  | ReadonlyArray<readonly [ByteString, ByteString]>
  | Iterable<readonly [ByteString, ByteString]>
  | Record<ByteString, ByteString>
  | Headers;
Headers is the WHATWG Fetch §5.1 multimap of HTTP header names and values. Names are compared byte-case-insensitively (ASCII A–Z fold only — never Unicode case-folding) and preserve the casing of the first inserted form. Values are byte strings (every UTF‑16 code unit must be ≤ 0xFF); embedded NUL, CR, or LF cause TypeError, and leading or trailing HTTP whitespace is normalised away on every mutation.

The constructor accepts the WebIDL HeadersInit union: another Headers instance, an iterable of two-element pairs (Array, generator, Map.entries(), custom @@iterator), or a string-keyed record. null raises TypeError per WebIDL non-nullable union conversion. The record path uses own enumerable string keys only — it never walks the prototype chain and is safe against Object.prototype pollution.

get(name) returns the comma-joined value of all entries with that name, or null if absent. For Set-Cookie the joined form is observable but ambiguous (cookies legitimately contain commas) — guest code should always use getSetCookie(), which returns the raw list of values in insertion order.

Iteration (entries, keys, values, forEach, for...of) yields the spec's "sort and combine" output: names are byte-lowercased, sorted in ASCII byte order, and multi-value entries are combined with ", " — except Set-Cookie, which yields one output entry per stored value at its alphabetic position. Iterators are live with respect to mutation: insertions or deletions made between .next() calls are reflected in subsequent steps.

js
// Construction.
const h = new Headers([
  ["Content-Type", "application/json"],
  ["X-Custom", "1"],
]);

// Append + combine.
h.append("X-Custom", "2");
h.get("x-custom"); // → "1, 2" (case-insensitive lookup)

// Set replaces all values, keeps first-insertion casing.
h.set("X-Custom", "z");

// Set-Cookie has its own special accessor.
h.append("Set-Cookie", "session=abc");
h.append("Set-Cookie", "tracking=xyz");
h.getSetCookie(); // → ["session=abc", "tracking=xyz"]

// Iteration is alphabetic + Set-Cookie split.
for (const [name, value] of h) {
  // ...
}

Resource caps are enforced at every mutation: header value 16 KiB, header name 256 bytes, distinct names per instance 1024. Excess inputs raise TypeError rather than silently truncating or pegging the realm thread.

Headers participates in five guard modes per spec — none (default for new Headers(init)), request, request-no-cors, response, and immutable. The non-default guards are set internally when a Headers is exposed via a Request or Response, and gate forbidden-header writes (silent return) and CORS-unsafe values (silent return), or reject all mutations (TypeError for immutable).

FormData

ts
declare class FormData {
  constructor(form?: null, submitter?: null);

  append(name: USVString, value: USVString): void;
  append(name: USVString, blobValue: Blob, filename?: USVString): void;
  delete(name: USVString): void;
  get(name: USVString): FormDataEntryValue | null;
  getAll(name: USVString): FormDataEntryValue[];
  has(name: USVString): boolean;
  set(name: USVString, value: USVString): void;
  set(name: USVString, blobValue: Blob, filename?: USVString): void;

  forEach(
    callback: (value: FormDataEntryValue, key: USVString, parent: FormData) => void,
    thisArg?: unknown,
  ): void;

  entries(): IterableIterator<[USVString, FormDataEntryValue]>;
  keys(): IterableIterator<USVString>;
  values(): IterableIterator<FormDataEntryValue>;
  [Symbol.iterator](): IterableIterator<[USVString, FormDataEntryValue]>;
}

type FormDataEntryValue = File | USVString;
FormData is the WHATWG XHR §FormData entry list — an insertion-ordered, case-sensitive mapping from string names to either string values or File objects. Unlike Headers, the entry list is exposed directly without guards: every append produces a new entry at the tail, multi-value entries are kept distinct, and iteration walks the list in insertion order.

The constructor's HTMLFormElement / HTMLElement arguments are unsupported in a non-DOM runtime — passing anything non-null / non-undefined raises TypeError. The no-argument form, undefined, or explicit null all yield an empty FormData.

Per the spec's "create an entry" algorithm, a Blob value (not a File) is materialized into a fresh File whose name is the optional filename argument or "blob" by default; a File value combined with a filename argument produces a fresh File with that name; a File without a filename argument passes through unchanged. The conversion happens once at append / set time and the resulting File identity is preserved across subsequent reads — fd.get("k") and fd.getAll("k")[0] return the same File instance.

set(name, value) replaces the first existing entry with that name in place, drops any later duplicates, and otherwise appends at the tail — preserving the surviving entry's insertion position.
js
const fd = new FormData();
fd.append("name", "alice");
fd.append("tag", "first");
fd.append("tag", "second");

fd.get("tag"); // → "first"
fd.getAll("tag"); // → ["first", "second"]

// Blob → File normalization.
fd.append("upload", new Blob(["hello"], { type: "text/plain" }));
fd.get("upload"); // → File { name: "blob", size: 5, type: "text/plain" }

fd.append("doc", new Blob(["x"]), "report.pdf");
fd.get("doc").name; // → "report.pdf"

// Iteration preserves insertion order; @@iterator aliases entries().
for (const [name, value] of fd) {
  // ...
}

Resource caps are enforced at every mutation: entry name 64 KiB, string value 64 MiB, and 65 536 entries per instance. File payload sizes are bounded by the underlying Blob storage (currently capped at Integer.MAX_VALUE per the heap-backed implementation).

USVString coercion replaces unpaired UTF-16 surrogate code units with U+FFFD per WebIDL §3.2.10 — non-string name arguments are stringified first via the standard ToString rules.

Body (Request / Response mixin)

ts
interface Body {
  readonly body: ReadableStream | null;
  readonly bodyUsed: boolean;

  arrayBuffer(): Promise<ArrayBuffer>;
  blob(): Promise<Blob>;
  bytes(): Promise<Uint8Array>;
  formData(): Promise<FormData>;
  json(): Promise<unknown>;
  text(): Promise<USVString>;
}

type BodyInit =
  | Blob
  | BufferSource // ArrayBuffer | TypedArray | DataView
  | FormData
  | URLSearchParams
  | USVString
  | ReadableStream;
Body is the WHATWG Fetch mixin that Request and Response both compose. The surface itself lands on those interfaces in their respective constructors (which expose body and the six consumers as Request.prototype / Response.prototype members); the bullets below describe the shared semantics.

A body is constructed from a BodyInit value through the spec's "extract a body" algorithm:

  • Blob / File — the bytes are copied defensively. Content-Type is taken from the blob's type when non-empty; Content-Length is the byte count.
  • BufferSource (ArrayBuffer, typed arrays, DataView) — the bytes are copied; no Content-Type is contributed.
  • USVString — unpaired UTF-16 surrogate halves are replaced with U+FFFD, then UTF-8 encoded. Content-Type is text/plain;charset=UTF-8.
  • URLSearchParams — serialised via the WHATWG URL Standard's application/x-www-form-urlencoded serializer. Content-Type is application/x-www-form-urlencoded;charset=UTF-8.
  • FormData — serialised as multipart/form-data per WHATWG HTML §4.10.22.8 + RFC 7578. Boundary is a 128-bit SecureRandom value (32 hex chars under a fixed ASCII prefix; well within the RFC 2046 70-character cap). Content-Type is multipart/form-data; boundary=. 0x0A / 0x0D / 0x22 bytes in field names and filenames are escaped as %0A / %0D / %22; CR / LF in non-file values is normalised to CRLF. File parts emit Content-Type from blob.type when non-empty, otherwise application/octet-stream.
  • ReadableStream — passed through as-is. No Content-Type and no Content-Length are contributed; the requesting code must set them explicitly if it needs them. When used as a Request body the constructor requires duplex: "half" (WHATWG only defines "half" today; cross-runtime consensus on whatwg/fetch#1254). fetch() dispatch bridges the stream to the wire chunk-by-chunk via the JDK HttpClient.fromPublisher BodyPublisher, so multi-GB uploads stream without buffering the body in memory. 307/308 redirects with a streaming body fail (the stream has been consumed; the spec requires replaying the body).
  • null / undefined — produces a body-less Request / Response.
bodyUsed flips to true on the first successful call to any consumer (or on a first read of the underlying reader). Subsequent consumer calls throw TypeError.

The six consumers each return a fresh Promise:

  • arrayBuffer() — resolves with a fresh ArrayBuffer over the body's bytes.
  • bytes() — resolves with a fresh Uint8Array.
  • text() — UTF-8 decode with leading BOM strip (matches Blob.text() semantics).
  • json() — UTF-8 decode plus JSON.parse; a parse failure rejects with SyntaxError.
  • blob() — resolves with a fresh Blob carrying the body's bytes and Content-Type.
  • formData() — selects the parser by Content-Type. application/x-www-form-urlencoded is parsed inline (split on & and =, replace + with space, percent-decode); multipart/form-data reads the boundary parameter and routes through the multipart parser, which reverses the WHATWG %0A / %0D / %22 escape on field names and filenames, returns string entries for parts without a filename parameter, and returns File entries (with type taken from the part's Content-Type or defaulted to application/octet-stream) otherwise. The preamble and epilogue around the boundary delimiters are ignored per RFC 2046, and bare-LF line terminators are tolerated.
js
// Drain a response into JSON (block 8-9 surface, foundation lands now).
const data = await response.json();

// Stream a Blob as the request body.
const blob = new Blob(["hello"], { type: "text/plain" });
await fetch("https:<<>>
<<>>

<<>>
const form = new URLSearchParams({ user: "alice", role: "admin" });
await fetch("https://example.invalid/login", { method: "POST", body: form });
// → Content-Type "application/x-www-form-urlencoded;charset=UTF-8"

// Multipart form body with a file part.
const fd = new FormData();
fd.append("user", "alice");
fd.append("avatar", new File([imgBytes], "me.png", { type: "image/png" }));
await fetch("https://example.invalid/profile", { method: "POST", body: fd });
// → Content-Type "multipart/form-data; boundary=----elide-form-boundary-<hex>"

Request

new Request(input, init?) builds an immutable request descriptor. input is either a USVString URL or another Request; init is an optional RequestInit dictionary.

Implemented per WHATWG Fetch §6.1:

  • URL parsing. The URL is parsed without a base URL (Elide is a server runtime with no document base, matching Node.js / undici behaviour when no global origin is set). Absolute URLs succeed; relative URLs reject as TypeError (spec §6.1.1 step 4 under a base-less realm). Parse failure or embedded credentials (user:pass@) also reject as TypeError (spec §6.1.1 step 6). Fragments are preserved on the parsed URL — request.url round-trips the fragment per spec.
  • Method. Validated as an RFC 7230 token, normalised to canonical uppercase (GET/HEAD/POST/PUT/DELETE/OPTIONS). The three forbidden methods CONNECT / TRACE / TRACK are rejected as TypeError (case-insensitive).
  • Headers. Filled via the spec's "fill" algorithm with the guard derived from moderequest-no-cors for mode: "no-cors", request otherwise. Set-Cookie is rejected from request-direction guards; forbidden request-headers are dropped silently.
  • Body. Extracted via the WHATWG BodyInit algorithm (Blob / BufferSource / FormData / URLSearchParams / USVString / ReadableStream). GET / HEAD with a non-null body reject as TypeError. A ReadableStream body requires duplex: "half"; full-duplex is not supported (matches the cross-runtime consensus on whatwg/fetch#1254).
  • [SameObject]. request.headers, request.signal, and request.body return the same JS object on every read.
  • Signal. init.signal is used directly when present; otherwise a fresh non-aborted AbortSignal is created (or the input Request's signal is reused when input is itself a Request).
  • Request.clone(). Permitted while bodyUsed is false. Source-known bodies (Blob / BufferSource / string / FormData) share their backing bytes; cloning a ReadableStream-backed body is currently rejected — proper tee() integration lands with fetch() dispatch.

Defaults match the spec: method"GET", mode"cors", credentials"same-origin", cache"default", redirect"follow", destination"", referrer"about:client", referrerPolicy"", integrity"", keepalivefalse, duplex"half".

js
const req = new Request("https:<<>>
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ k: "v" }),
});
req.method;                 <<>>
req.headers.get("content-type"); // "application/json"
await req.json();           // { k: "v" }

Response

new Response(body?, init?) builds an HTTP response descriptor. Both arguments are optional — the default Response() is 200 OK with no body.

Implemented per WHATWG Fetch §5.5:

  • Body extraction first. The body is run through the BodyInit algorithm before any status validation. The observable side effect (a ReadableStream body gets locked even on a subsequent throw) matches the spec ordering.
  • Status range. init.status must be in the inclusive range 200–599; outside throws RangeError. Default is 200.
  • statusText grammar. Validated against the RFC 9112 reason-phrase production (HTAB / SP / VCHAR / obs-text); empty string is allowed, anything else with control characters or CR/LF throws TypeError. Default is "".
  • Null-body statuses. A non-null body combined with a status of 101, 204, 205, or 304 throws TypeError per Fetch §2.2.3. (101 is also rejected by the status range check first; 103 Early Hints is not a null-body status — informational responses may carry a body.)
  • Headers. Built with the response guard for the guest constructor. Unlike the browser-side spec algorithm, the guard does not drop the forbidden response-header names (Set-Cookie, Set-Cookie2): the spec's filter is a client-side protection against guest JS forging cookie state on a fetched response, and Elide is a server-side runtime where the guest is the legitimate producer of these headers. Same divergence as Bun / Deno / Cloudflare Workers. Headers.append("Set-Cookie", ...) reaches the wire, and Headers.getSetCookie() returns the raw insertion-order list (uncombinable per RFC 6265). Response.error() and Response.redirect() still produce immutable headers — mutation throws TypeError regardless of header name.
  • Content-Type. Injected from the extracted body's contributed type when the header list does not already contain Content-Type (matches the spec's "initialize a response" step 6.3).
  • [SameObject]. response.headers returns the same JS object on every read.
  • ok. True for the inclusive range 200–299.
  • url / redirected. Always "" / false for guest-constructed responses (urlList is empty); the future fetch() dispatch will populate these for actual network responses.

Static factories (Fetch §5.5):

  • Response.error() — network error. Type "error", status 0, empty status text, null body, immutable empty headers.
  • Response.redirect(url, status = 302)url is parsed without a base URL (Elide is a server runtime with no document base). Absolute URLs ("https://example.com/dest") succeed; relative URLs ("/dest") throw TypeError — matches Node.js / undici behaviour when no global origin is set. Pass an absolute URL to the redirect helper, or build the Location header yourself via new Response(null, { status: 302, headers: { Location: "/dest" } }) if you specifically need a relative redirect. status must be one of 301, 302, 303, 307, 308 — anything else throws RangeError. The Location header is set on the (immutable) result, bypassing the guard. The serialised URL is validated against the Fetch header-value rules — a URL whose serialisation contains NUL / CR / LF (defence-in-depth against header-value injection) throws TypeError.
  • Response.json(data, init?)data is serialised through JSON.stringify; undefined rejects as TypeError per the Infra "serialize a JavaScript value to JSON bytes" algorithm. Content-Type defaults to application/json unless init.headers already supplies one.
Response.clone() is permitted only when the body is not unusable — that is, the body's stream has neither been disturbed (read or cancelled) nor locked (an attached getReader() reader). Source-known bodies (text / Blob / BufferSource / FormData) are cloned with a fresh backing byte array per Fetch §clone-a-body "with new body"; cloning a ReadableStream-backed body throws TypeError until proper tee() integration lands with fetch() dispatch. Cloning a network-error or redirect Response preserves its immutable headers guard.

Both Response.json(data, init?) (static factory) and response.json() (Body mixin instance method) are available per spec — the latter is parsed via JSON.parse from the body bytes, and await new Response().json() on a null body rejects with SyntaxError per the Infra "parse JSON bytes" algorithm.

js
// Construct a response with a JSON body
const r1 = Response.json({ ok: true });
r1.status;                       // 200
r1.headers.get("content-type");  // "application/json"

// Manual construction with custom status + content type
const r2 = new Response("hi", { status: 201, statusText: "Created" });
await r2.text();                 // "hi"

// Network error and redirect helpers
const err = Response.error();
err.type;                        // "error"
const redir = Response.redirect("https:<<>>
redir.headers.get("location");   // "https://example.com/dest"

CompressionStream / DecompressionStream

new CompressionStream(format) / new DecompressionStream(format) build a TransformStream whose writable side consumes uncompressed (or compressed) bytes and whose readable side emits the inverse. Per WHATWG Compression Streams Standard, format is one of "deflate", "deflate-raw", "gzip".
ts
declare class CompressionStream extends TransformStream<BufferSource, Uint8Array> {
  constructor(format: "deflate" | "deflate-raw" | "gzip");
}

declare class DecompressionStream extends TransformStream<BufferSource, Uint8Array> {
  constructor(format: "deflate" | "deflate-raw" | "gzip");
}

Implementation notes:

  • Codec backing. Implemented on top of the JDK java.util.zip codecs (Inflater / Deflater) plus a per-instance state machine that handles the header / trailer for gzip.
  • Decompression-bomb defence. Each DecompressionStream enforces an output-byte cap: an absolute limit of 1 GiB and a maximum decompression ratio of 1024:1. Exceeding either aborts the transform with TypeError("decompression ratio cap exceeded"). Tunable via host (not guest) configuration.
  • Backpressure. Chunks are queued through the underlying TransformStream; the writable side does not signal capacity until the reader has drained earlier output. Pipe via pipeThrough for the normal flow.
js
const gz = new CompressionStream("gzip");
const stream = new Response("hello").body.pipeThrough(gz);
const compressed = new Uint8Array(await new Response(stream).arrayBuffer());
// → first 10 bytes are the gzip header (1F 8B …)

fetch() global

fetch(input, init?) issues an HTTP request and resolves with a Response. input is either a URL string, a URL, or a Request instance; init is the same shape as the Request constructor's second argument.

Implementation notes (Fetch §main fetch + §HTTP fetch):

  • URL parser. Inputs flow through the WHATWG URL parser (URLNative), not RFC 3986 — IDN/Punycode and host-spoofing parity with browsers.
  • Redirects. init.redirect selects between "follow" (default), "error", and "manual". The follow path caps at 20 hops and synthesises an opaqueredirect response for the manual path.
  • Cross-origin credential scrubbing. When a redirect crosses origins, Authorization, Cookie, and Proxy-Authorization are stripped from the forwarded request (CVE-2024-30260, CVE-2022-31151).
  • Subresource Integrity (SRI). init.integrity accepts a space-separated metadata string with sha256-, sha384-, sha512- algorithms. Strongest-wins per W3C SRI Level 2; base64 alphabet RFC 4648 standard (+/=, not URL-safe).
  • AbortSignal. A signal that is already aborted at dispatch time short-circuits to a DOMException("AbortError", "AbortError") rejection before any network work. Aborting in-flight is supported too: when the signal fires while the request is on the wire, the underlying HttpClient.sendAsync CompletableFuture is cancelled (HTTP/1.1 connection torn down or HTTP/2 stream reset per JDK) and the response-body InputStream is closed so a slow body read also terminates promptly. The promise rejects with the signal's reason — the guest-supplied AbortController.abort(reason) value when present, otherwise a fresh DOMException("AbortError"). Composed signals (AbortSignal.any([...])) work as well.
  • Body size cap. Response bodies are bounded at 100 MiB by default — values beyond reject with TypeError. Tunable host-side, not via guest.
  • Promise/A+ ordering. The dispatch posts back to the realm event loop through EventLoop.submitFromExternal, so resolution preserves the spec-mandated microtask ordering even when work happens on a JDK virtual thread.

The snippet below is self-contained: it spins a local server via Elide.serve (see the next section), then drives every fetch flow against it. Runnable as-is via elide run.

js
const server = Elide.serve({
  port: 0,
  async fetch(request) {
    const url = new URL(request.url);
    if (url.pathname === "/api") return Response.json({ ok: true, ts: Date.now() });
    if (url.pathname === "/submit") {
      const body = await request.json();
      return Response.json({ echoed: body });
    }
    if (url.pathname === "/slow") {
      await new Promise((r) => setTimeout(r, 100));
      return new Response("late");
    }
    return new Response("not found", { status: 404 });
  },
});
const base = `http:<<>>

<<>>
const r1 = await fetch(`${base}/api`);
const data = await r1.json();           <<>>

<<>>
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 5_000);
const r2 = await fetch(`${base}/submit`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ k: "v" }),
  signal: ctrl.signal,
});
console.log(await r2.json());           <<>>

<<>>
const fired = new AbortController();
fired.abort();
try {
  await fetch(`${base}/slow`, { signal: fired.signal });
} catch (e) {
  console.log(e.name);                  <<>>
}

<<>>
<<>>
<<>>
const inflight = new AbortController();
setTimeout(() => inflight.abort(), 10);
const t0 = Date.now();
try {
  await fetch(`${base}/slow`, { signal: inflight.signal });
} catch (e) {
  console.log(e.name, Date.now() - t0); // → "AbortError" <some-small-number>
}

server.close();

For Subresource Integrity (SRI), pin the expected hash of the served bytes:

js
const sriServer = Elide.serve({
  port: 0,
  fetch: () => new Response("alert(1)"),    // stable payload
});
// Compute the SHA-384 once and paste it as `sha384-<base64>`:
//   echo -n 'alert(1)' | openssl dgst -sha384 -binary | base64
const EXPECTED_SRI = "sha384-<base64-digest-from-the-command-above>";
await fetch(`http://127.0.0.1:${sriServer.port}/lib.min.js`, {
  integrity: EXPECTED_SRI,
});
sriServer.close();

Elide.serve

Elide.serve(options) binds an HTTP server whose dispatch fans each request out to a guest fetch handler. The handler returns a Response (or a Promise), and the server writes the result back to the wire.
ts
declare namespace Elide {
  function serve(options: ServeOptions): ServeHandle;
}

interface ServeOptions {
  /** Required fetch handler. Receives a Request, returns Response or Promise<Response>. */
  fetch: (request: Request) => Response | Promise<Response>;
  /** TCP port (0 = OS-picked; query via the returned handle). */
  port?: number;
  /** Bind interface (null = all). */
  hostname?: string | null;
  /**
   * PEM-encoded certificate and PKCS#8 private key for HTTPS. Both must be supplied together;
   * either alone raises `TypeError`. PKCS#1 (`-----BEGIN RSA PRIVATE KEY-----`) is rejected
   * with a conversion hint pointing at `openssl pkcs8 -topk8`.
   */
  cert?: string | null;
  key?: string | null;
  /** Per-request budget (ms) from realm-thread enqueue to response. 0 disables. */
  idleTimeoutMs?: number;
  /** Hard cap on inbound request body size; default 100 MiB. */
  maxBodyBytes?: number;
}

interface ServeHandle {
  readonly port: number;
  readonly hostname: string;
  readonly close: () => void;
}

Implementation notes:

  • Threading. Each inbound request lands on a JDK virtual thread; the dispatcher posts a single realm-thread task via EventLoop.submitFromExternal to build the guest Request, invoke the handler, and attach resolve/reject reactions. The virtual thread waits on a CompletableFuture for completion before writing the wire response.
  • Body cap. maxBodyBytes is enforced as the request body streams in — exceeded requests fail with 413 Payload Too Large and never reach the guest handler.
  • Timeout. idleTimeoutMs bounds the wall-clock between enqueueing the realm-thread task and completing the response. Exceeded requests respond 504 Gateway Timeout and release the handler.
  • AbortSignal. Each request.signal is tracked per-server; the handle's close() fires signal.abort() on every in-flight request before tearing down the transport. Per-connection disconnect detection is a follow-up (needs Netty-level hooks).
  • Multi-value headers. Request and response headers are passed through the transport as Map>, so Set-Cookie-style fields preserve their multiplicity end-to-end.
  • Handle properties. port, hostname, and close are readonly + non-configurable + non-writable per WebIDL dictionary semantics. A forgotten close() is recovered by a Cleaner on the handle's GC.
  • HTTPS. Supplying cert + key (both PEM, key in PKCS#8 format) routes the bind through the JDK HttpsServer. The transport parses the cert chain via CertificateFactory.X.509, builds a PKCS#12 keystore in memory, and constructs an SSLContext with the JDK 25 SunJSSE provider defaults (TLS 1.3 + TLS 1.2 fallback, negotiated cipher suites per the JDK's secure defaults). Bind failures (malformed PEM, unknown key algorithm, PKCS#1 input) raise TypeError synchronously so callers can recover programmatically.
  • What's deferred. Cloudflare-Workers-style module-form handler discovery (export default { fetch(req, env, ctx) }) is tracked separately.
js
// Smallest possible server
const handle = Elide.serve({
  fetch(request) {
    return new Response("hello " + new URL(request.url).pathname);
  },
});
console.log("listening on", handle.port);

// JSON echo with custom timeout
const echo = Elide.serve({
  port: 8080,
  idleTimeoutMs: 5_000,
  async fetch(request) {
    const data = await request.json();
    return Response.json({ received: data });
  },
});

// Tear down
echo.close();

// HTTPS — supply PEM-encoded cert (and optional chain) plus a PKCS#8 private key.
// Both must be present together; either alone raises TypeError. Generate a self-signed
// pair for local development with:
//
//   openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 \
//     -nodes -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
//   openssl pkcs8 -topk8 -nocrypt -in key.pem -out key-pkcs8.pem
//
// (then pass the file contents — Elide does not read paths for you.)
const tlsHandle = Elide.serve({
  port: 0,
  cert: certPemString,
  key: keyPemString,
  fetch(request) {
    return new Response("served over TLS");
  },
});

WPT conformance

Conformance against Web Platform Tests is tracked as a vendored subset under tools/conformance/wpt/, pinned to a single upstream revision (see tools/conformance/wpt/UPSTREAM_REVISION). Each area carries a baseline-.txt file with a pinned floor pass-count and a human-readable failure breakdown categorising remaining gaps as spec-gap, vendored-helper-missing, or engine-prereq.

Current floors (snapshot at the pinned revision):
AreaPass / totalNotes
streams421 / 461WHATWG Streams Standard
compression263 / 312WHATWG Compression Streams
dom-abort33 / 35WHATWG DOM AbortController / AbortSignal
encoding6373 / 8524WHATWG Encoding (TextEncoder / TextDecoder)
url4416 / 4744WHATWG URL
fetch331 / 628WHATWG Fetch — constructors + body machinery; the wider basic / redirect / abort coverage waits on an internal test server
The runner is bun tools/conformance/wpt-runner.mts --suite=; bun test --concurrent tools/test/smoke-shards/ with RUN_WPT=1 gates every area against its floor. Each baseline file documents how to raise the floor when a fix lands.

Spec sources