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
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
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.
// 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; // → trueHeaders
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.
// 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
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.
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)
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-Typeis taken from the blob'stypewhen non-empty;Content-Lengthis the byte count. - BufferSource (
ArrayBuffer, typed arrays,DataView) — the bytes are copied; noContent-Typeis contributed. - USVString — unpaired UTF-16 surrogate halves are replaced with
U+FFFD, then UTF-8 encoded.Content-Typeistext/plain;charset=UTF-8. - URLSearchParams — serialised via the WHATWG URL Standard's
application/x-www-form-urlencoded serializer.Content-Typeisapplication/x-www-form-urlencoded;charset=UTF-8. - FormData — serialised as
multipart/form-dataper WHATWG HTML §4.10.22.8 + RFC 7578. Boundary is a 128-bitSecureRandomvalue (32 hex chars under a fixed ASCII prefix; well within the RFC 2046 70-character cap).Content-Typeismultipart/form-data; boundary=.0x0A/0x0D/0x22bytes in field names and filenames are escaped as%0A/%0D/%22; CR / LF in non-file values is normalised to CRLF. File parts emitContent-Typefromblob.typewhen non-empty, otherwiseapplication/octet-stream. - ReadableStream — passed through as-is. No
Content-Typeand noContent-Lengthare contributed; the requesting code must set them explicitly if it needs them. When used as aRequestbody the constructor requiresduplex: "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 JDKHttpClient.fromPublisherBodyPublisher, 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 freshArrayBufferover the body's bytes.bytes()— resolves with a freshUint8Array.text()— UTF-8 decode with leading BOM strip (matchesBlob.text()semantics).json()— UTF-8 decode plusJSON.parse; a parse failure rejects withSyntaxError.blob()— resolves with a freshBlobcarrying the body's bytes andContent-Type.formData()— selects the parser byContent-Type.application/x-www-form-urlencodedis parsed inline (split on&and=, replace+with space, percent-decode);multipart/form-datareads theboundaryparameter and routes through the multipart parser, which reverses the WHATWG%0A/%0D/%22escape on field names and filenames, returns string entries for parts without afilenameparameter, and returnsFileentries (withtypetaken from the part'sContent-Typeor defaulted toapplication/octet-stream) otherwise. The preamble and epilogue around the boundary delimiters are ignored per RFC 2046, and bare-LF line terminators are tolerated.
// 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 asTypeError(spec §6.1.1 step 6). Fragments are preserved on the parsed URL —request.urlround-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 methodsCONNECT/TRACE/TRACKare rejected asTypeError(case-insensitive). - Headers. Filled via the spec's "fill" algorithm with the guard derived from
mode—request-no-corsformode: "no-cors",requestotherwise.Set-Cookieis rejected from request-direction guards; forbidden request-headers are dropped silently. - Body. Extracted via the WHATWG
BodyInitalgorithm (Blob / BufferSource / FormData / URLSearchParams / USVString / ReadableStream).GET/HEADwith a non-null body reject asTypeError. AReadableStreambody requiresduplex: "half"; full-duplex is not supported (matches the cross-runtime consensus onwhatwg/fetch#1254). [SameObject].request.headers,request.signal, andrequest.bodyreturn the same JS object on every read.- Signal.
init.signalis used directly when present; otherwise a fresh non-abortedAbortSignalis created (or the inputRequest's signal is reused wheninputis itself aRequest). Request.clone(). Permitted whilebodyUsedisfalse. Source-known bodies (Blob / BufferSource / string / FormData) share their backing bytes; cloning aReadableStream-backed body is currently rejected — propertee()integration lands withfetch()dispatch.
Defaults match the spec: method → "GET", mode → "cors", credentials → "same-origin", cache → "default", redirect → "follow", destination → "", referrer → "about:client", referrerPolicy → "", integrity → "", keepalive → false, duplex → "half".
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
BodyInitalgorithm before any status validation. The observable side effect (aReadableStreambody gets locked even on a subsequent throw) matches the spec ordering. - Status range.
init.statusmust be in the inclusive range 200–599; outside throwsRangeError. Default is200. - 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, or304throwsTypeErrorper Fetch §2.2.3. (101 is also rejected by the status range check first;103 Early Hintsis not a null-body status — informational responses may carry a body.) - Headers. Built with the
responseguard 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, andHeaders.getSetCookie()returns the raw insertion-order list (uncombinable per RFC 6265).Response.error()andResponse.redirect()still produceimmutableheaders — mutation throwsTypeErrorregardless 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.headersreturns the same JS object on every read.ok. True for the inclusive range 200–299.url/redirected. Always""/falsefor guest-constructed responses (urlListis empty); the futurefetch()dispatch will populate these for actual network responses.
Static factories (Fetch §5.5):
Response.error()— network error. Type"error", status0, empty status text, null body, immutable empty headers.Response.redirect(url, status = 302)—urlis parsed without a base URL (Elide is a server runtime with no document base). Absolute URLs ("https://example.com/dest") succeed; relative URLs ("/dest") throwTypeError— 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 vianew Response(null, { status: 302, headers: { Location: "/dest" } })if you specifically need a relative redirect.statusmust be one of301,302,303,307,308— anything else throwsRangeError. TheLocationheader 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) throwsTypeError.Response.json(data, init?)—datais serialised throughJSON.stringify;undefinedrejects asTypeErrorper the Infra "serialize a JavaScript value to JSON bytes" algorithm.Content-Typedefaults toapplication/jsonunlessinit.headersalready 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.
// 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".
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.zipcodecs (Inflater/Deflater) plus a per-instance state machine that handles the header / trailer for gzip. - Decompression-bomb defence. Each
DecompressionStreamenforces an output-byte cap: an absolute limit of 1 GiB and a maximum decompression ratio of 1024:1. Exceeding either aborts the transform withTypeError("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 viapipeThroughfor the normal flow.
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.redirectselects between"follow"(default),"error", and"manual". The follow path caps at 20 hops and synthesises anopaqueredirectresponse for the manual path. - Cross-origin credential scrubbing. When a redirect crosses origins,
Authorization,Cookie, andProxy-Authorizationare stripped from the forwarded request (CVE-2024-30260, CVE-2022-31151). - Subresource Integrity (SRI).
init.integrityaccepts a space-separated metadata string withsha256-,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 underlyingHttpClient.sendAsyncCompletableFutureis cancelled (HTTP/1.1 connection torn down or HTTP/2 stream reset per JDK) and the response-bodyInputStreamis closed so a slow body read also terminates promptly. The promise rejects with the signal'sreason— the guest-suppliedAbortController.abort(reason)value when present, otherwise a freshDOMException("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.
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:
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.
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.submitFromExternalto build the guestRequest, invoke the handler, and attach resolve/reject reactions. The virtual thread waits on aCompletableFuturefor completion before writing the wire response. - Body cap.
maxBodyBytesis enforced as the request body streams in — exceeded requests fail with413 Payload Too Largeand never reach the guest handler. - Timeout.
idleTimeoutMsbounds the wall-clock between enqueueing the realm-thread task and completing the response. Exceeded requests respond504 Gateway Timeoutand release the handler. - AbortSignal. Each
request.signalis tracked per-server; the handle'sclose()firessignal.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, andcloseare readonly + non-configurable + non-writable per WebIDL dictionary semantics. A forgottenclose()is recovered by aCleaneron the handle's GC. - HTTPS. Supplying
cert+key(both PEM, key in PKCS#8 format) routes the bind through the JDKHttpsServer. The transport parses the cert chain viaCertificateFactory.X.509, builds aPKCS#12keystore in memory, and constructs anSSLContextwith 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) raiseTypeErrorsynchronously so callers can recover programmatically. - What's deferred. Cloudflare-Workers-style module-form handler discovery (
export default { fetch(req, env, ctx) }) is tracked separately.
// 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.
| Area | Pass / total | Notes |
|---|---|---|
streams | 421 / 461 | WHATWG Streams Standard |
compression | 263 / 312 | WHATWG Compression Streams |
dom-abort | 33 / 35 | WHATWG DOM AbortController / AbortSignal |
encoding | 6373 / 8524 | WHATWG Encoding (TextEncoder / TextDecoder) |
url | 4416 / 4744 | WHATWG URL |
fetch | 331 / 628 | WHATWG Fetch — constructors + body machinery; the wider basic / redirect / abort coverage waits on an internal test server |
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.