From 0880378abbb047e6addb12a453069dac6d1469e9 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 16 Sep 2026 00:08:50 -0500 Subject: [PATCH 1/4] feat(codemode): add the Headers built-in --- packages/codemode/interpreter-support.md | 32 +- packages/codemode/src/data.ts | 2 + .../codemode/src/interpreter/extensions.ts | 3 + packages/codemode/src/interpreter/globals.ts | 2 + .../codemode/src/interpreter/interpreter.ts | 12 +- .../codemode/src/interpreter/intrinsics.ts | 2 + packages/codemode/src/interpreter/objects.ts | 12 +- .../codemode/src/interpreter/references.ts | 2 + packages/codemode/src/stdlib/console.ts | 5 + packages/codemode/src/stdlib/headers.ts | 118 +++++ packages/codemode/src/stdlib/url.ts | 42 +- packages/codemode/src/stdlib/value.ts | 2 + packages/codemode/test/extensions.test.ts | 28 ++ packages/codemode/test/stdlib.test.ts | 146 +++++++ packages/codemode/test/web-wpt.test.ts | 412 ++++++++++++++++++ 15 files changed, 784 insertions(+), 36 deletions(-) create mode 100644 packages/codemode/src/stdlib/headers.ts diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index b59d69519c4d..454cf394ce68 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -29,7 +29,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262 Uint8Array is rejected with a hint to encode as text, and own `__proto__` keys are dropped so merging tool inputs or results cannot replace a prototype. In-program `JSON.stringify` keeps JS behavior except for the Error form and a promise, which is a `TypeError` with an await hint rather than a silent `{}`. -- [x] Live Date, RegExp, Map, Set, URL, URLSearchParams, and Uint8Array values inside CodeMode. +- [x] Live Date, RegExp, Map, Set, URL, URLSearchParams, Headers, and Uint8Array values inside CodeMode. - [x] Tool calls through the host-provided `tools` tree only. - [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is shadowable by program declarations like other globals. @@ -47,8 +47,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262 ## Values and literals - [x] `null`, `undefined`, booleans, finite and non-finite numbers, and strings. -- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, URLSearchParams, custom synchronous - iterators, and synchronous generators. +- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, URLSearchParams, Headers, custom + synchronous iterators, and synchronous generators. - [x] Object literals with shorthand, computed string/number keys, and spread following ToObject: data objects and arrays copy own enumerable keys, strings copy index keys, and other values contribute nothing. - [x] Template literals with interpolation. @@ -95,8 +95,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262 - [x] `if`/`else` and conditional expressions. - [x] `switch`, including default clauses and fallthrough. - [x] `for`, `while`, and `do...while`. -- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, custom synchronous iterators, and confined - synchronous generators. Abrupt completion invokes the iterator's optional `return()`. +- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, Headers, custom synchronous iterators, and + confined synchronous generators. Abrupt completion invokes the iterator's optional `return()`. - [x] `for...in` over own keys of plain objects, arrays, strings, and tool references; other values iterate nothing. - [x] Unlabeled `break` and `continue`. - [x] `try`, `catch`, optional catch bindings, and `finally`. @@ -127,7 +127,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262 string). A detached method loses its receiver, as in JS: `values.filter("abc".includes)` is a `TypeError` because `includes` is called without a string `this`. - [x] Constructors work as callbacks with JS call semantics: `Error` types construct (`messages.map(Error)`), - and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Promise`) throw a `TypeError`, + and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Headers`, `Promise`) throw a `TypeError`, like JS. - [x] Tool references and detached `Promise` statics are rejected as callbacks with a hint to wrap them in an arrow function. @@ -179,10 +179,10 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262 - [x] Sequence expressions (the comma operator). - [x] `await` for CodeMode promises and callable thenables; a plain value passes through unchanged, though every `await` still defers its continuation one reaction turn. -- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise. `new` on any - other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number` say - `new` is unsupported and point at the plain call, user-defined functions report the constructor gap below, and - non-callable values are not constructors. +- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, Headers, and Promise. `new` + on any other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number` + say `new` is unsupported and point at the plain call, user-defined functions report the constructor gap below, + and non-callable values are not constructors. - [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`. - [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`. - [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`. @@ -450,7 +450,13 @@ with a hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`). - [x] `crypto.randomUUID()` and `crypto.getRandomValues(uint8Array)`. - [x] `TextEncoder` and `TextDecoder` for UTF-8 only: any other label is a `RangeError`. `TextDecoder` accepts the `fatal` and `ignoreBOM` options; `decode` takes a Uint8Array or nothing. -- [ ] `crypto.subtle`, `Blob`, and `TextDecoder` streaming or non-UTF-8 encodings. +- [x] `new Headers()` from records, synchronous iterables of pairs, and Headers, wrapping the host's `Headers`: names + fold to lowercase, values are normalized and combined, and invalid names or values throw a `TypeError`. +- [x] Headers `append`, `delete`, `get`, `getSetCookie`, `has`, `set`, `forEach`, `keys`, `values`, and `entries`; + iteration is live and sorted by name, with `set-cookie` values kept apart. +- [x] Headers serialize to a `{ name: value }` object in JSON, in results, and in tool arguments. +- [ ] `Request`, `Response`, and `Blob`. +- [ ] `crypto.subtle` and `TextDecoder` streaming or non-UTF-8 encodings. ## Extensions @@ -460,8 +466,8 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls - [x] Each global is a function, callable but not constructible, run with `this` undefined. A global that shadows a built-in or another extension throws at `make`. - [x] Every value crossing in either direction is converted, never shared: plain objects and arrays are copied, - `Date`, `RegExp`, `URL`, `URLSearchParams`, `Map`, `Set`, and `Uint8Array` become fresh copies with their - contents converted (a host `ArrayBuffer` comes in as a `Uint8Array`; other typed arrays cannot come out), + `Date`, `RegExp`, `URL`, `URLSearchParams`, `Headers`, `Map`, `Set`, and `Uint8Array` become fresh copies with + their contents converted (a host `ArrayBuffer` comes in as a `Uint8Array`; other typed arrays cannot come out), errors cross as errors with their name and message, and a `__proto__` key is dropped. Functions, generators, un-awaited promises, and symbols cannot be passed in; a class instance, a symbol, or a BigInt cannot come out. - [x] A host function inside a result becomes a program function whose calls cross the same way, so a result can diff --git a/packages/codemode/src/data.ts b/packages/codemode/src/data.ts index b28adb6cd2c2..88ac2c61f059 100644 --- a/packages/codemode/src/data.ts +++ b/packages/codemode/src/data.ts @@ -17,6 +17,7 @@ import { record, SetObj, URLSearchParamsObj, + HeadersObj, } from "./interpreter/objects.js" import { typeofValue } from "./interpreter/references.js" @@ -69,6 +70,7 @@ const walk = ( ) } if (boundary && value instanceof URLSearchParamsObj) return value.params.toString() + if (value instanceof HeadersObj) return Object.fromEntries(value.headers) const target = boundary && value instanceof SetObj ? new Arr(ctx.builtins.Array, [...value.set]) : value if (stack.has(target)) throw typeError("Converting circular structure to JSON.") stack.add(target) diff --git a/packages/codemode/src/interpreter/extensions.ts b/packages/codemode/src/interpreter/extensions.ts index ca57d013fbef..1f70184c12e7 100644 --- a/packages/codemode/src/interpreter/extensions.ts +++ b/packages/codemode/src/interpreter/extensions.ts @@ -24,6 +24,7 @@ import { SetObj, URLObj, URLSearchParamsObj, + HeadersObj, } from "./objects.js" import { describeValue } from "./references.js" @@ -49,6 +50,7 @@ export const extensionGlobals = ( if (value instanceof RegExpObj) return new RegExp(value.regex.source, value.regex.flags) if (value instanceof URLObj) return new URL(value.url.href) if (value instanceof URLSearchParamsObj) return new URLSearchParams(value.params) + if (value instanceof HeadersObj) return new Headers(value.headers) const next = (item: unknown) => toHost(item, label, depth + 1, seen) if (value instanceof MapObj) return new Map([...value.map].map(([key, item]) => [next(key), next(item)])) if (value instanceof SetObj) return new Set([...value.set].map(next)) @@ -96,6 +98,7 @@ export const extensionGlobals = ( if (value instanceof URLSearchParams) { return new URLSearchParamsObj(builtins.URLSearchParams, new URLSearchParams(value)) } + if (value instanceof Headers) return new HeadersObj(builtins.Headers, new Headers(value)) const next = (item: unknown, path: string) => fromHost(item, path, depth + 1, seen) if (value instanceof Map) { const wrapped = new MapObj(builtins.Map) diff --git a/packages/codemode/src/interpreter/globals.ts b/packages/codemode/src/interpreter/globals.ts index a5d59a89132c..1773b3bc749b 100644 --- a/packages/codemode/src/interpreter/globals.ts +++ b/packages/codemode/src/interpreter/globals.ts @@ -11,6 +11,7 @@ import { objectGlobal } from "../stdlib/object.js" import { regexpGlobal } from "../stdlib/regexp.js" import { stringGlobal } from "../stdlib/string.js" import { uriGlobal, urlGlobal, urlSearchParamsGlobal } from "../stdlib/url.js" +import { headersGlobal } from "../stdlib/headers.js" import { coercion } from "../stdlib/value.js" import { base64Global, cryptoGlobal } from "../stdlib/web.js" import { ToolReference } from "../tool-runtime.js" @@ -80,6 +81,7 @@ const table: Record = { Set: (ctx) => setGlobal(ctx), URL: (ctx) => urlGlobal(ctx), URLSearchParams: (ctx) => urlSearchParamsGlobal(ctx), + Headers: (ctx) => headersGlobal(ctx), Uint8Array: (ctx) => uint8ArrayGlobal(ctx), TextEncoder: (ctx) => textEncoderGlobal(ctx), TextDecoder: (ctx) => textDecoderGlobal(ctx), diff --git a/packages/codemode/src/interpreter/interpreter.ts b/packages/codemode/src/interpreter/interpreter.ts index d7d81be0141f..37f12ed76027 100644 --- a/packages/codemode/src/interpreter/interpreter.ts +++ b/packages/codemode/src/interpreter/interpreter.ts @@ -84,6 +84,7 @@ import { PromiseObj, SetObj, URLSearchParamsObj, + HeadersObj, record, remove, set, @@ -653,7 +654,7 @@ class Frame { const cursor = iterator === undefined ? yield* self.iterate(right, node) : undefined if (iterator === undefined && cursor === undefined) { throw invalidData( - `${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams, or custom iterator value.`, + `${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, URLSearchParams, or Headers, or custom iterator value.`, node, ) } @@ -756,9 +757,11 @@ class Frame { ? value.set.values() : value instanceof URLSearchParamsObj ? value.params.entries() - : value instanceof Bytes - ? value.bytes.values() - : undefined + : value instanceof HeadersObj + ? value.headers.entries() + : value instanceof Bytes + ? value.bytes.values() + : undefined if (iterator !== undefined) { const proto = this.ctx.builtins.Array return Effect.succeed({ @@ -1848,6 +1851,7 @@ class Frame { value instanceof MapObj || value instanceof SetObj || value instanceof URLSearchParamsObj || + value instanceof HeadersObj || value instanceof Bytes ) { const cursor = yield* self.iterate(value, node) diff --git a/packages/codemode/src/interpreter/intrinsics.ts b/packages/codemode/src/interpreter/intrinsics.ts index 36835fed6dff..b7984b5cbd1e 100644 --- a/packages/codemode/src/interpreter/intrinsics.ts +++ b/packages/codemode/src/interpreter/intrinsics.ts @@ -29,6 +29,7 @@ const builtins = [ "Set", "URL", "URLSearchParams", + "Headers", "Uint8Array", "TextEncoder", "TextDecoder", @@ -80,6 +81,7 @@ export const createBuiltins = (): Builtins => { Set: plain(), URL: plain(), URLSearchParams: plain(), + Headers: plain(), Uint8Array: plain(), TextEncoder: plain(), TextDecoder: plain(), diff --git a/packages/codemode/src/interpreter/objects.ts b/packages/codemode/src/interpreter/objects.ts index 4b776801e741..678f87c7f4d6 100644 --- a/packages/codemode/src/interpreter/objects.ts +++ b/packages/codemode/src/interpreter/objects.ts @@ -156,6 +156,15 @@ export class URLSearchParamsObj extends Obj { } } +export class HeadersObj extends Obj { + constructor( + proto: Obj, + readonly headers: Headers, + ) { + super(proto) + } +} + export class URLObj extends Obj { readonly searchParams: URLSearchParamsObj constructor( @@ -181,13 +190,14 @@ export class Bytes extends Obj { /** Built-in objects that wrap a host value; data-like, but never plain data. */ export const isWrapper = ( value: unknown, -): value is DateObj | RegExpObj | MapObj | SetObj | URLObj | URLSearchParamsObj | Bytes => +): value is DateObj | RegExpObj | MapObj | SetObj | URLObj | URLSearchParamsObj | HeadersObj | Bytes => value instanceof DateObj || value instanceof RegExpObj || value instanceof MapObj || value instanceof SetObj || value instanceof URLObj || value instanceof URLSearchParamsObj || + value instanceof HeadersObj || value instanceof Bytes const MAX_ARRAY_INDEX = 4_294_967_295 diff --git a/packages/codemode/src/interpreter/references.ts b/packages/codemode/src/interpreter/references.ts index cea7990ea191..08f6d5c1a124 100644 --- a/packages/codemode/src/interpreter/references.ts +++ b/packages/codemode/src/interpreter/references.ts @@ -16,6 +16,7 @@ import { SetObj, URLObj, URLSearchParamsObj, + HeadersObj, } from "./objects.js" /** Values that cannot cross the data boundary. */ @@ -85,6 +86,7 @@ export const describeValue = (value: unknown): string => { if (value instanceof SetObj) return "a Set" if (value instanceof URLObj) return "a URL" if (value instanceof URLSearchParamsObj) return "a URLSearchParams" + if (value instanceof HeadersObj) return "a Headers" if (value instanceof Bytes) return "a Uint8Array" if (value instanceof GeneratorObj) return "a generator" if (isRuntimeReference(value)) return "a function" diff --git a/packages/codemode/src/stdlib/console.ts b/packages/codemode/src/stdlib/console.ts index a20360af9c0a..890684381e06 100644 --- a/packages/codemode/src/stdlib/console.ts +++ b/packages/codemode/src/stdlib/console.ts @@ -12,6 +12,7 @@ import { SetObj, URLObj, URLSearchParamsObj, + HeadersObj, } from "../interpreter/objects.js" import { containsOpaqueReference, isRuntimeReference } from "../interpreter/references.js" import type { Interpreter } from "../interpreter/interpreter.js" @@ -66,6 +67,10 @@ const formatConsoleValue = (value: unknown, seen: Set, depth: number): s if (value instanceof RegExpObj) return coerceToString(value) if (value instanceof URLObj) return coerceToString(value) if (value instanceof URLSearchParamsObj) return coerceToString(value) + if (value instanceof HeadersObj) { + const record = Object.fromEntries(value.headers) + return `Headers(${Object.keys(record).length}) ${JSON.stringify(record)}` + } if (value instanceof Bytes) return `Uint8Array(${value.bytes.length}) [${value.bytes.join(",")}]` if (depth > MAX_CONSOLE_DEPTH) return "..." if (seen.has(value)) return "[Circular]" diff --git a/packages/codemode/src/stdlib/headers.ts b/packages/codemode/src/stdlib/headers.ts new file mode 100644 index 000000000000..7601eaf3aaa1 --- /dev/null +++ b/packages/codemode/src/stdlib/headers.ts @@ -0,0 +1,118 @@ +import { Effect } from "effect" +import { constructor, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js" +import { typeError } from "../interpreter/model.js" +import { entries, isWrapper, Arr, HeadersObj, Obj } from "../interpreter/objects.js" +import { applyCollectionCallback } from "../interpreter/callback.js" +import { isRuntimeReference } from "../interpreter/references.js" +import type { Interpreter } from "../interpreter/interpreter.js" +import { coerceToString } from "./value.js" +import { readPairs } from "./url.js" + +// The host validates header names and values and throws its own TypeError; the program gets one of its own. +const attempt = (run: () => T): T => { + try { + return run() + } catch (error) { + throw typeError(error instanceof Error ? error.message : String(error)) + } +} + +const constructHeaders = (ctx: Interpreter, init: unknown, proto: Obj): Effect.Effect => { + const wrap = (headers: Headers) => new HeadersObj(proto, headers) + if (init === undefined) return Effect.succeed(wrap(new Headers())) + if (init instanceof HeadersObj) return Effect.succeed(wrap(new Headers(init.headers))) + return Effect.gen(function* () { + const pairs = init instanceof Obj ? yield* readPairs(ctx, init, "new Headers(...)") : undefined + if (pairs !== undefined) return wrap(attempt(() => new Headers(pairs))) + if (!(init instanceof Obj) || isWrapper(init) || isRuntimeReference(init)) { + throw typeError("new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.") + } + return wrap( + attempt(() => new Headers(Object.fromEntries(entries(init).map(([key, value]) => [key, coerceToString(value)])))), + ) + }) +} + +export const headersGlobal = (ctx: Interpreter) => { + const builtins = ctx.builtins + const proto = builtins.Headers + const headers = constructor(builtins, proto, { + name: "Headers", + call: requiresNew("Headers"), + construct: (args, newTarget) => constructHeaders(ctx, args[0], prototypeFrom(newTarget, proto)), + }) + const self = (thisValue: unknown, name: string) => receiver(HeadersObj, thisValue, `Headers.prototype.${name}`) + const wrap = (items: Array) => new Arr(builtins.Array, items) + const arg = (args: Array, index: number): string => coerceToString(args[index]) + const requireArgs = (name: string, args: Array, count: number): void => { + if (args.length < count) throw typeError(`Headers.${name} requires ${count} argument${count === 1 ? "" : "s"}.`) + } + methods(builtins, proto, [ + [ + "append", + 2, + (thisValue, args) => { + requireArgs("append", args, 2) + attempt(() => self(thisValue, "append").headers.append(arg(args, 0), arg(args, 1))) + return undefined + }, + ], + [ + "delete", + 1, + (thisValue, args) => { + requireArgs("delete", args, 1) + attempt(() => self(thisValue, "delete").headers.delete(arg(args, 0))) + return undefined + }, + ], + [ + "get", + 1, + (thisValue, args) => { + requireArgs("get", args, 1) + return attempt(() => self(thisValue, "get").headers.get(arg(args, 0))) + }, + ], + ["getSetCookie", 0, (thisValue) => wrap(self(thisValue, "getSetCookie").headers.getSetCookie())], + [ + "has", + 1, + (thisValue, args) => { + requireArgs("has", args, 1) + return attempt(() => self(thisValue, "has").headers.has(arg(args, 0))) + }, + ], + [ + "set", + 2, + (thisValue, args) => { + requireArgs("set", args, 2) + attempt(() => self(thisValue, "set").headers.set(arg(args, 0), arg(args, 1))) + return undefined + }, + ], + ["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").headers.keys()))], + ["values", 0, (thisValue) => wrap(Array.from(self(thisValue, "values").headers.values()))], + [ + "entries", + 0, + (thisValue) => + wrap(Array.from(self(thisValue, "entries").headers.entries(), ([key, value]) => wrap([key, value]))), + ], + [ + "forEach", + 1, + (thisValue, args) => { + requireArgs("forEach", args, 1) + const target = self(thisValue, "forEach") + const apply = applyCollectionCallback(ctx, args[0], "Headers.forEach") + return Effect.gen(function* () { + for (const [key, value] of Array.from(target.headers.entries())) yield* apply([value, key, target]) + return undefined + }) + }, + ], + ]) + return headers +} diff --git a/packages/codemode/src/stdlib/url.ts b/packages/codemode/src/stdlib/url.ts index 3c1c3b611830..e7223d780855 100644 --- a/packages/codemode/src/stdlib/url.ts +++ b/packages/codemode/src/stdlib/url.ts @@ -107,12 +107,10 @@ export const urlGlobal = (ctx: Interpreter) => { return url } -const readPair = (ctx: Interpreter, value: unknown): Effect.Effect, unknown, R> => +const readPair = (ctx: Interpreter, value: unknown, label: string): Effect.Effect, unknown, R> => Effect.gen(function* () { const cursor = yield* ctx.iterate(value) - if (cursor === undefined) { - throw typeError("new URLSearchParams(...) expects iterable [name, value] pairs.") - } + if (cursor === undefined) throw typeError(`${label} expects iterable [name, value] pairs.`) const items: Array = [] while (true) { const step = yield* cursor.next @@ -126,6 +124,26 @@ const readPair = (ctx: Interpreter, value: unknown): Effect.Effect( + ctx: Interpreter, + init: unknown, + label: string, +): Effect.Effect | undefined, unknown, R> => + Effect.gen(function* () { + const cursor = yield* ctx.iterate(init) + if (cursor === undefined) return undefined + const pairs: Array> = [] + while (true) { + const step = yield* cursor.next + if (step.done) { + if (pairs.some((entry) => entry.length !== 2)) throw typeError(`${label} expects iterable [name, value] pairs.`) + return pairs.map((entry): [string, string] => [entry[0] ?? "", entry[1] ?? ""]) + } + pairs.push(yield* preserveConsumerError(cursor, readPair(ctx, step.value, label))) + } + }) + const constructURLSearchParams = ( ctx: Interpreter, init: unknown, @@ -139,20 +157,8 @@ const constructURLSearchParams = ( return Effect.succeed(wrap(new URLSearchParams(coerceToString(init)))) } return Effect.gen(function* () { - const cursor = yield* ctx.iterate(init) - if (cursor !== undefined) { - const pairs: Array> = [] - while (true) { - const step = yield* cursor.next - if (step.done) { - if (pairs.some((entry) => entry.length !== 2)) { - throw typeError("new URLSearchParams(...) expects iterable [name, value] pairs.") - } - return wrap(new URLSearchParams(pairs.map((entry): [string, string] => [entry[0] ?? "", entry[1] ?? ""]))) - } - pairs.push(yield* preserveConsumerError(cursor, readPair(ctx, step.value))) - } - } + const pairs = yield* readPairs(ctx, init, "new URLSearchParams(...)") + if (pairs !== undefined) return wrap(new URLSearchParams(pairs)) if (isRuntimeReference(init)) { throw typeError("new URLSearchParams(...) expects a query string, data object, or synchronous iterable pairs.") } diff --git a/packages/codemode/src/stdlib/value.ts b/packages/codemode/src/stdlib/value.ts index 759699b0b8dc..68f577acd9d9 100644 --- a/packages/codemode/src/stdlib/value.ts +++ b/packages/codemode/src/stdlib/value.ts @@ -13,6 +13,7 @@ import { SetObj, URLObj, URLSearchParamsObj, + HeadersObj, } from "../interpreter/objects.js" import type { Interpreter } from "../interpreter/interpreter.js" @@ -28,6 +29,7 @@ export const coerceToString = (value: unknown): string => { if (value instanceof SetObj) return "[object Set]" if (value instanceof URLObj) return value.url.href if (value instanceof URLSearchParamsObj) return value.params.toString() + if (value instanceof HeadersObj) return "[object Headers]" if (value instanceof Bytes) return value.bytes.join(",") if (value instanceof ErrorObj) { // Match Error.prototype.toString: "name: message", or just one when the other is empty. diff --git a/packages/codemode/test/extensions.test.ts b/packages/codemode/test/extensions.test.ts index df208af10cd4..8d37ae785b4a 100644 --- a/packages/codemode/test/extensions.test.ts +++ b/packages/codemode/test/extensions.test.ts @@ -128,6 +128,34 @@ describe("values are converted at the boundary, never shared", () => { expect([...(held[0] as Set<{ z: number }>)][0]).toEqual({ z: 1 }) }) + test("Headers cross as copies in both directions", async () => { + const stored = new Headers({ "X-A": "1" }) + const target = CodeMode.make({ + extensions: [ + Extension.make({ + name: "http", + globals: { + headers: () => stored, + keep: (value: Headers) => { + held.push(value) + return value + }, + }, + }), + ], + }) + held.length = 0 + expect( + await value( + `const h = headers(); h.set("x-a", "2"); const back = keep(h); back.set("x-a", "3"); return [h instanceof Headers, h.get("x-a"), back === h, back.get("x-a"), [...back]]`, + target, + ), + ).toEqual([true, "2", false, "3", [["x-a", "3"]]]) + expect(stored.get("x-a")).toBe("1") + expect(held[0]).toBeInstanceOf(Headers) + expect((held[0] as Headers).get("x-a")).toBe("2") + }) + test("bytes cross as copies in both directions; ArrayBuffer comes in as Uint8Array", async () => { const stored = new Uint8Array([1, 2, 3]) const target = CodeMode.make({ diff --git a/packages/codemode/test/stdlib.test.ts b/packages/codemode/test/stdlib.test.ts index 29790eb8b961..33f50c12465e 100644 --- a/packages/codemode/test/stdlib.test.ts +++ b/packages/codemode/test/stdlib.test.ts @@ -635,6 +635,152 @@ describe("URL and URI helpers", () => { }) }) +describe("Headers", () => { + test("constructs from records, pairs, Maps, and Headers; names fold to lowercase and values combine", async () => { + expect( + await value(` + const headers = new Headers({ "Content-Type": "text/plain", "X-Count": 1, "X-Null": null }) + headers.append("Accept", "text/html") + headers.append("accept", "application/json") + headers.set("x-count", "2") + headers.delete("x-null") + const copy = new Headers(headers) + copy.set("content-type", "text/html") + return { + get: headers.get("content-type"), + missing: headers.get("x-missing"), + combined: headers.get("ACCEPT"), + has: [headers.has("Accept"), headers.has("x-null")], + count: headers.get("x-count"), + copied: [headers.get("content-type"), copy.get("content-type")], + pairs: [...new Headers([["b", "2"], ["A", "1"]])], + map: [...new Headers(new Map([["k", "v"]]))], + keys: headers.keys(), + values: headers.values(), + entries: headers.entries(), + } + `), + ).toEqual({ + get: "text/plain", + missing: null, + combined: "text/html, application/json", + has: [true, false], + count: "2", + copied: ["text/plain", "text/html"], + pairs: [ + ["a", "1"], + ["b", "2"], + ], + map: [["k", "v"]], + keys: ["accept", "content-type", "x-count"], + values: ["text/html, application/json", "text/plain", "2"], + entries: [ + ["accept", "text/html, application/json"], + ["content-type", "text/plain"], + ["x-count", "2"], + ], + }) + }) + + test("iterates in sorted order everywhere iteration is allowed, and getSetCookie keeps cookies apart", async () => { + expect( + await value(` + const headers = new Headers({ b: "2", a: "1" }) + headers.append("Set-Cookie", "x=1") + headers.append("set-cookie", "y=2") + const seen = [] + headers.forEach((value, name, self) => seen.push(name + "=" + value + ":" + (self === headers))) + const [first] = headers + function* pairs() { yield* headers } + return { + seen, + first, + spread: [...headers], + from: Array.from(headers).length, + generator: [...pairs()].length, + object: Object.fromEntries(headers), + cookies: headers.getSetCookie(), + } + `), + ).toEqual({ + seen: ["a=1:true", "b=2:true", "set-cookie=x=1:true", "set-cookie=y=2:true"], + first: ["a", "1"], + spread: [ + ["a", "1"], + ["b", "2"], + ["set-cookie", "x=1"], + ["set-cookie", "y=2"], + ], + from: 4, + generator: 4, + object: { a: "1", b: "2", "set-cookie": "y=2" }, + cookies: ["x=1", "y=2"], + }) + }) + + test("serializes as a name-to-value object at the boundary and in JSON; prints for console", async () => { + const result = await run(` + const headers = new Headers({ "X-A": "1", b: "2" }) + console.log(headers) + return { headers, json: JSON.stringify({ headers }), text: String(headers), type: typeof headers, is: headers instanceof Headers } + `) + expect(result.ok && result.value).toEqual({ + headers: { b: "2", "x-a": "1" }, + json: '{"headers":{"b":"2","x-a":"1"}}', + text: "[object Headers]", + type: "object", + is: true, + }) + expect(result.ok && result.logs?.[0]).toBe('Headers(2) {"b":"2","x-a":"1"}') + }) + + test("rejects what it cannot build from, and invalid names and values, with TypeErrors the program can catch", async () => { + expect( + await value(` + function message(run) { + try { run(); return null } catch (error) { return error instanceof TypeError ? error.message : error } + } + const headers = new Headers() + return [ + message(() => Headers()), + message(() => new Headers(null)), + message(() => new Headers(1)), + message(() => new Headers("a=1")), + message(() => new Headers(new Date())), + message(() => new Headers(() => 1)), + message(() => new Headers([["name"]])), + message(() => new Headers([["a", "b", "c"]])), + message(() => new Headers({ "bad name": "x" })), + message(() => new Headers({ name: "bad\u0000value" })), + message(() => headers.get("invalid\u0100")), + message(() => headers.has({})), + message(() => headers.set("a", "invalid\u0100")), + message(() => headers.append("a")), + message(() => headers.forEach()), + message(() => headers.forEach(1)), + ] + `), + ).toEqual([ + "Constructor Headers requires 'new'.", + "new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.", + "new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.", + "new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.", + "new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.", + "new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.", + "new Headers(...) expects iterable [name, value] pairs.", + "new Headers(...) expects iterable [name, value] pairs.", + expect.stringContaining("bad name"), + expect.stringContaining("invalid value"), + expect.stringContaining("Invalid header name"), + expect.stringContaining("[object Object]"), + expect.stringContaining("invalid value"), + "Headers.append requires 2 arguments.", + "Headers.forEach requires 1 argument.", + "Headers.forEach expects a function callback.", + ]) + }) +}) + describe("Map", () => { test("get/set/has/size with chaining", async () => { expect( diff --git a/packages/codemode/test/web-wpt.test.ts b/packages/codemode/test/web-wpt.test.ts index 41f53ec5c542..a19e66b81402 100644 --- a/packages/codemode/test/web-wpt.test.ts +++ b/packages/codemode/test/web-wpt.test.ts @@ -3,10 +3,14 @@ * - html/webappapis/atob/base64.any.js (btoa reference encoder, input list, and atob WebIDL cases) * - fetch/data-urls/resources/base64.json (copied to fixtures/wpt-base64.json) * - WebCryptoAPI/randomUUID.https.any.js + * - fetch/api/headers/{headers-basic,headers-casing,headers-combine,headers-errors,headers-normalize,header-setcookie}.any.js * * Copyright © web-platform-tests contributors. Governed by the 3-Clause BSD license in LICENSE.wpt. * * `assert_throws_dom("InvalidCharacterError", …)` becomes a check for a TypeError: CodeMode has no DOMException. + * Headers cases that need `Symbol.iterator`, iterator objects from `keys()`/`values()`/`entries()` (CodeMode returns + * arrays), or a custom iterator on a Headers instance are left out, as are two set-cookie cases that Bun's own Headers + * fails by sorting `set-cookie2` ahead of `set-cookie`. */ import { describe, expect, test } from "bun:test" import { Effect } from "effect" @@ -166,3 +170,411 @@ describe("crypto.randomUUID WPT parity (WebCryptoAPI/randomUUID.https.any.js)", ).toEqual([true, true, true, 768]) }) }) + +// Enough of testharness.js to run the Headers files close to verbatim; each `test` records its failure, if any. +const testharness = ` + const failures = [] + function test(run, name) { try { run() } catch (error) { failures.push(name + ": " + (error && error.message ? error.message : error)) } } + function assert_equals(actual, expected, message) { if (actual !== expected) throw new Error((message || "") + " expected " + JSON.stringify(expected) + " got " + JSON.stringify(actual)) } + function assert_true(actual, message) { assert_equals(actual, true, message) } + function assert_false(actual, message) { assert_equals(actual, false, message) } + function assert_array_equals(actual, expected, message) { assert_equals(JSON.stringify(actual), JSON.stringify(expected), message) } + function assert_nested_array_equals(actual, expected) { assert_array_equals(actual, expected) } + function assert_throws_js(type, run) { try { run() } catch (error) { if (error instanceof type) return; throw new Error("threw " + error.name) } throw new Error("did not throw") } + function assert_unreached() { throw new Error("unreachable") } +` + +describe("Headers WPT parity (fetch/api/headers)", () => { + test("headers-basic.any.js", async () => { + expect( + await value(` + ${testharness} + test(function() { new Headers() }, "Create headers from no parameter") + test(function() { new Headers(undefined) }, "Create headers from undefined parameter") + test(function() { new Headers({}) }, "Create headers from empty object") + var parameters = [null, 1] + parameters.forEach(function(parameter) { + test(function() { assert_throws_js(TypeError, function() { new Headers(parameter) }) }, "Create headers with " + parameter + " should throw") + }) + var headerDict = {"name1": "value1", "name2": "value2", "name3": "value3", "name4": null, "name5": undefined, "name6": 1, "Content-Type": "value4"} + var headerSeq = [] + for (var name in headerDict) headerSeq.push([name, headerDict[name]]) + test(function() { + var headers = new Headers(headerSeq) + for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name]) + assert_equals(headers.get("length"), null, "init should be treated as a sequence, not as a dictionary") + }, "Create headers with sequence") + test(function() { + var headers = new Headers(headerDict) + for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name]) + }, "Create headers with record") + test(function() { + var headers = new Headers(headerDict) + var headers2 = new Headers(headers) + for (name in headerDict) assert_equals(headers2.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name]) + }, "Create headers with existing headers") + test(function() { + var headers = new Headers() + for (name in headerDict) { + headers.append(name, headerDict[name]) + assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name]) + } + }, "Check append method") + test(function() { + var headers = new Headers() + for (name in headerDict) { + headers.set(name, headerDict[name]) + assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name]) + } + }, "Check set method") + test(function() { + var headers = new Headers(headerDict) + for (name in headerDict) assert_true(headers.has(name), "headers has name " + name) + assert_false(headers.has("nameNotInHeaders"), "headers do not have header: nameNotInHeaders") + }, "Check has method") + test(function() { + var headers = new Headers(headerDict) + for (name in headerDict) { + assert_true(headers.has(name), "headers have a header: " + name) + headers.delete(name) + assert_true(!headers.has(name), "headers do not have anymore a header: " + name) + } + }, "Check delete method") + test(function() { + var headers = new Headers(headerDict) + for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name]) + assert_equals(headers.get("nameNotInHeaders"), null, "header: nameNotInHeaders has no value") + }, "Check get method") + var headerEntriesDict = {"name1": "value1", "Name2": "value2", "name": "value3", "content-Type": "value4", "Content-Typ": "value5", "Content-Types": "value6"} + var sortedHeaderDict = {} + var headerValues = [] + var sortedHeaderKeys = Object.keys(headerEntriesDict).map(function(value) { + sortedHeaderDict[value.toLowerCase()] = headerEntriesDict[value] + headerValues.push(headerEntriesDict[value]) + return value.toLowerCase() + }).sort() + test(function() { + var headers = new Headers(headerEntriesDict) + assert_array_equals(headers.keys(), sortedHeaderKeys) + for (const key of headers.keys()) assert_true(sortedHeaderKeys.indexOf(key) != -1) + }, "Check keys method") + test(function() { + var headers = new Headers(headerEntriesDict) + assert_array_equals(headers.values(), sortedHeaderKeys.map((key) => sortedHeaderDict[key])) + for (const value of headers.values()) assert_true(headerValues.indexOf(value) != -1) + }, "Check values method") + test(function() { + var headers = new Headers(headerEntriesDict) + assert_array_equals(headers.entries(), sortedHeaderKeys.map((key) => [key, sortedHeaderDict[key]])) + for (const entry of headers.entries()) assert_equals(entry[1], sortedHeaderDict[entry[0]]) + }, "Check entries method") + test(function() { + var headers = new Headers(headerEntriesDict) + assert_array_equals([...headers], sortedHeaderKeys.map((key) => [key, sortedHeaderDict[key]])) + }, "Check Symbol.iterator method") + test(function() { + var headers = new Headers(headerEntriesDict) + var index = 0 + headers.forEach(function(value, key, container) { + assert_equals(headers, container) + assert_equals(key, sortedHeaderKeys[index]) + assert_equals(value, sortedHeaderDict[sortedHeaderKeys[index]]) + index++ + }) + assert_equals(index, sortedHeaderKeys.length) + }, "Check forEach method") + test(() => { + const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0"}) + const actualKeys = [] + const actualValues = [] + for (const [header, value] of headers) { + actualKeys.push(header) + actualValues.push(value) + headers.delete("foo") + } + assert_array_equals(actualKeys, ["bar", "baz"]) + assert_array_equals(actualValues, ["0", "1"]) + }, "Iteration skips elements removed while iterating") + test(() => { + const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"}) + const actualKeys = [] + const actualValues = [] + for (const [header, value] of headers) { + actualKeys.push(header) + actualValues.push(value) + if (header === "baz") headers.delete("bar") + } + assert_array_equals(actualKeys, ["bar", "baz", "quux"]) + assert_array_equals(actualValues, ["0", "1", "3"]) + }, "Removing elements already iterated over causes an element to be skipped during iteration") + test(() => { + const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"}) + const actualKeys = [] + const actualValues = [] + for (const [header, value] of headers) { + actualKeys.push(header) + actualValues.push(value) + if (header === "baz") headers.append("X-yZ", "4") + } + assert_array_equals(actualKeys, ["bar", "baz", "foo", "quux", "x-yz"]) + assert_array_equals(actualValues, ["0", "1", "2", "3", "4"]) + }, "Appending a value pair during iteration causes it to be reached during iteration") + test(() => { + const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"}) + const actualKeys = [] + const actualValues = [] + for (const [header, value] of headers) { + actualKeys.push(header) + actualValues.push(value) + if (header === "baz") headers.append("abc", "-1") + } + assert_array_equals(actualKeys, ["bar", "baz", "baz", "foo", "quux"]) + assert_array_equals(actualValues, ["0", "1", "1", "2", "3"]) + }, "Prepending a value pair before the current element position causes it to be skipped during iteration and adds the current element a second time") + return failures + `), + ).toEqual([]) + }) + + test("headers-casing.any.js", async () => { + expect( + await value(` + ${testharness} + var headerDictCase = {"UPPERCASE": "value1", "lowercase": "value2", "mixedCase": "value3", "Content-TYPE": "value4"} + function checkHeadersCase(originalName, headersToCheck, expectedDict) { + var lowCaseName = originalName.toLowerCase() + var upCaseName = originalName.toUpperCase() + var expectedValue = expectedDict[originalName] + assert_equals(headersToCheck.get(originalName), expectedValue, "name: " + originalName + " has value: " + expectedValue) + assert_equals(headersToCheck.get(lowCaseName), expectedValue, "name: " + lowCaseName + " has value: " + expectedValue) + assert_equals(headersToCheck.get(upCaseName), expectedValue, "name: " + upCaseName + " has value: " + expectedValue) + } + test(function() { + var headers = new Headers(headerDictCase) + for (const name in headerDictCase) checkHeadersCase(name, headers, headerDictCase) + }, "Create headers, names use characters with different case") + test(function() { + var headers = new Headers() + for (const name in headerDictCase) { + headers.append(name, headerDictCase[name]) + checkHeadersCase(name, headers, headerDictCase) + } + }, "Check append method, names use characters with different case") + test(function() { + var headers = new Headers() + for (const name in headerDictCase) { + headers.set(name, headerDictCase[name]) + checkHeadersCase(name, headers, headerDictCase) + } + }, "Check set method, names use characters with different case") + test(function() { + var headers = new Headers() + for (const name in headerDictCase) headers.set(name, headerDictCase[name]) + for (const name in headerDictCase) headers.delete(name.toLowerCase()) + for (const name in headerDictCase) assert_false(headers.has(name), "header " + name + " should have been deleted") + }, "Check delete method, names use characters with different case") + return failures + `), + ).toEqual([]) + }) + + test("headers-combine.any.js", async () => { + expect( + await value(` + ${testharness} + var headerSeqCombine = [["single", "singleValue"], ["double", "doubleValue1"], ["double", "doubleValue2"], ["triple", "tripleValue1"], ["triple", "tripleValue2"], ["triple", "tripleValue3"]] + var expectedDict = {"single": "singleValue", "double": "doubleValue1, doubleValue2", "triple": "tripleValue1, tripleValue2, tripleValue3"} + test(function() { + var headers = new Headers(headerSeqCombine) + for (const name in expectedDict) assert_equals(headers.get(name), expectedDict[name]) + }, "Create headers using same name for different values") + test(function() { + var headers = new Headers(headerSeqCombine) + for (const name in expectedDict) { + assert_true(headers.has(name), "name: " + name + " has value(s)") + headers.delete(name) + assert_false(headers.has(name), "name: " + name + " has no value(s) anymore") + } + }, "Check delete and has methods when using same name for different values") + test(function() { + var headers = new Headers(headerSeqCombine) + for (const name in expectedDict) { + headers.set(name, "newSingleValue") + assert_equals(headers.get(name), "newSingleValue", "name: " + name + " has value: newSingleValue") + } + }, "Check set methods when called with already used name") + test(function() { + var headers = new Headers(headerSeqCombine) + for (const name in expectedDict) { + var value = headers.get(name) + headers.append(name, "newSingleValue") + assert_equals(headers.get(name), (value + ", " + "newSingleValue")) + } + }, "Check append methods when called with already used name") + test(() => { + const headers = new Headers([["1", "a"], ["1", "b"]]) + for (let header of headers) assert_array_equals(header, ["1", "a, b"]) + }, "Iterate combined values") + test(() => { + const headers = new Headers([["2", "a"], ["1", "b"], ["2", "b"]]), expected = [["1", "b"], ["2", "a, b"]] + let i = 0 + for (let header of headers) { + assert_array_equals(header, expected[i]) + i++ + } + assert_equals(i, 2) + }, "Iterate combined values in sorted order") + return failures + `), + ).toEqual([]) + }) + + test("headers-errors.any.js", async () => { + expect( + await value(` + ${testharness} + test(function() { assert_throws_js(TypeError, function() { new Headers([["name"]]) }) }, "Create headers giving an array having one string as init argument") + test(function() { assert_throws_js(TypeError, function() { new Headers([["invalid", "invalidValue1", "invalidValue2"]]) }) }, "Create headers giving an array having three strings as init argument") + test(function() { assert_throws_js(TypeError, function() { new Headers([["invalid\u0100", "Value1"]]) }) }, "Create headers giving bad header name as init argument") + test(function() { assert_throws_js(TypeError, function() { new Headers([["name", "invalidValue\u0100"]]) }) }, "Create headers giving bad header value as init argument") + var badNames = ["invalid\u0100", {}] + var badValues = ["invalid\u0100"] + badNames.forEach(function(name) { + test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.get(name) }) }, "Check headers get with an invalid name " + name) + }) + badNames.forEach(function(name) { + test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.delete(name) }) }, "Check headers delete with an invalid name " + name) + }) + badNames.forEach(function(name) { + test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.has(name) }) }, "Check headers has with an invalid name " + name) + }) + badNames.forEach(function(name) { + test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.set(name, "Value1") }) }, "Check headers set with an invalid name " + name) + }) + badValues.forEach(function(value) { + test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.set("name", value) }) }, "Check headers set with an invalid value " + value) + }) + badNames.forEach(function(name) { + test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.append("invalid\u0100", "Value1") }) }, "Check headers append with an invalid name " + name) + }) + badValues.forEach(function(value) { + test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.append("name", value) }) }, "Check headers append with an invalid value " + value) + }) + test(function() { + var headers = new Headers([["name", "value"]]) + assert_throws_js(TypeError, function() { headers.forEach() }) + assert_throws_js(TypeError, function() { headers.forEach(undefined) }) + assert_throws_js(TypeError, function() { headers.forEach(1) }) + }, "Headers forEach throws if argument is not callable") + test(function() { + var headers = new Headers([["name1", "value1"], ["name2", "value2"], ["name3", "value3"]]) + var counter = 0 + try { + headers.forEach(function(value, name) { + counter++ + if (name == "name2") throw "error" + }) + } catch (e) { + assert_equals(counter, 2) + assert_equals(e, "error") + return + } + assert_unreached() + }, "Headers forEach loop should stop if callback is throwing exception") + return failures + `), + ).toEqual([]) + }) + + test("headers-normalize.any.js", async () => { + expect( + await value(` + ${testharness} + const expectations = { + "name1": [" space ", "space"], + "name2": ["\\ttab\\t", "tab"], + "name3": [" spaceAndTab\\t", "spaceAndTab"], + "name4": ["\\r\\n newLine", "newLine"], + "name5": ["newLine\\r\\n ", "newLine"], + "name6": ["\\r\\n\\tnewLine", "newLine"], + "name7": ["\\t\\f\\tnewLine\\n", "\\f\\tnewLine"], + "name8": ["newLine\\xa0", "newLine\\xa0"], + } + test(function () { + const headerDict = Object.fromEntries(Object.entries(expectations).map(([name, [actual]]) => [name, actual])) + var headers = new Headers(headerDict) + for (const name in expectations) { + const expected = expectations[name][1] + assert_equals(headers.get(name), expected, "name: " + name + " has normalized value: " + expected) + } + }, "Create headers with not normalized values") + test(function () { + var headers = new Headers() + for (const name in expectations) { + headers.append(name, expectations[name][0]) + const expected = expectations[name][1] + assert_equals(headers.get(name), expected, "name: " + name + " has value: " + expected) + } + }, "Check append method with not normalized values") + test(function () { + var headers = new Headers() + for (const name in expectations) { + headers.set(name, expectations[name][0]) + const expected = expectations[name][1] + assert_equals(headers.get(name), expected, "name: " + name + " has value: " + expected) + } + }, "Check set method with not normalized values") + return failures + `), + ).toEqual([]) + }) + + test("header-setcookie.any.js", async () => { + expect( + await value(` + ${testharness} + const headerList = [["set-cookie", "foo=bar"], ["Set-Cookie", "fizz=buzz; domain=example.com"]] + const setCookie2HeaderList = [["set-cookie2", "foo2=bar2"], ["Set-Cookie2", "fizz2=buzz2; domain=example2.com"]] + test(function () { + const headers = new Headers(headerList) + assert_equals(headers.get("set-cookie"), "foo=bar, fizz=buzz; domain=example.com") + }, "Headers.prototype.get combines set-cookie headers in order") + test(function () { + const headers = new Headers(headerList) + assert_nested_array_equals([...headers], [["set-cookie", "foo=bar"], ["set-cookie", "fizz=buzz; domain=example.com"]]) + }, "Headers iterator does not combine set-cookie headers") + test(function () { + const headers = new Headers(setCookie2HeaderList) + assert_nested_array_equals([...headers], [["set-cookie2", "foo2=bar2, fizz2=buzz2; domain=example2.com"]]) + }, "Headers iterator does not special case set-cookie2 headers") + test(function () { + const headers = new Headers([["set-cookie", "z=z"], ["set-cookie", "a=a"], ["set-cookie", "n=n"]]) + assert_nested_array_equals([...headers], [["set-cookie", "z=z"], ["set-cookie", "a=a"], ["set-cookie", "n=n"]]) + }, "Headers iterator preserves set-cookie ordering") + test(function () { + const headers = new Headers(headerList) + assert_true(headers.has("sEt-cOoKiE")) + }, "Headers.prototype.has works for set-cookie") + test(function () { + const headers = new Headers(headerList) + headers.set("set-cookie", "foo2=bar2") + assert_nested_array_equals([...headers], [["set-cookie", "foo2=bar2"]]) + }, "Headers.prototype.set works for set-cookie") + test(function () { + const headers = new Headers(headerList) + headers.delete("set-Cookie") + assert_nested_array_equals([...headers], []) + }, "Headers.prototype.delete works for set-cookie") + test(function () { assert_array_equals(new Headers().getSetCookie(), []) }, "Headers.prototype.getSetCookie with no headers present") + test(function () { assert_array_equals(new Headers([headerList[0]]).getSetCookie(), ["foo=bar"]) }, "Headers.prototype.getSetCookie with one header") + test(function () { assert_array_equals(new Headers({ "Set-Cookie": "foo=bar" }).getSetCookie(), ["foo=bar"]) }, "Headers.prototype.getSetCookie with one header created from an object") + test(function () { assert_array_equals(new Headers(headerList).getSetCookie(), ["foo=bar", "fizz=buzz; domain=example.com"]) }, "Headers.prototype.getSetCookie with multiple headers") + test(function () { assert_array_equals(new Headers([["set-cookie", ""]]).getSetCookie(), [""]) }, "Headers.prototype.getSetCookie with an empty header") + test(function () { assert_array_equals(new Headers([["set-cookie", "x"], ["set-cookie", "x"]]).getSetCookie(), ["x", "x"]) }, "Headers.prototype.getSetCookie with two equal headers") + test(function () { assert_array_equals(new Headers([["set-cookie2", "x"], ["set-cookie", "y"], ["set-cookie2", "z"]]).getSetCookie(), ["y"]) }, "Headers.prototype.getSetCookie ignores set-cookie2 headers") + test(function () { assert_array_equals(new Headers([["set-cookie", "z=z"], ["set-cookie", "a=a"], ["set-cookie", "n=n"]]).getSetCookie(), ["z=z", "a=a", "n=n"]) }, "Headers.prototype.getSetCookie preserves header ordering") + return failures + `), + ).toEqual([]) + }) +}) From e919bcc308dd6a944e67b669bb3931be4dfad62b Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 16 Sep 2026 00:14:04 -0500 Subject: [PATCH 2/4] docs(codemode): replace the stale Values section with Extension.make --- packages/codemode/README.md | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/codemode/README.md b/packages/codemode/README.md index 0e7fcb2f1d86..c82afb61bb9d 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -13,8 +13,8 @@ The idea of code mode was originally introduced by Cloudflare. See ## How it differs from JavaScript -- **Only supported APIs are available.** Programs can use the provided tools and supported JavaScript built-ins. APIs - such as `fetch`, timers, `process`, filesystem access, imports, and modules are unavailable. +- **Only supported APIs are available.** Programs can use the provided tools, supported JavaScript built-ins, and the + globals the host adds through extensions. Timers, `process`, filesystem access, imports, and modules are unavailable. - **Unfinished work is interrupted.** Tool calls and async functions start when called. When the program finishes, anything still running is interrupted. Unhandled rejections from un-awaited promises are returned as warnings. - **REPL-style results.** Without an explicit `return`, the final top-level expression becomes the result. `undefined` @@ -94,11 +94,19 @@ receive `{ extension, name, args }`. An `after` hook also receives how the call `failure` with its error, or `interrupted`). A failing `before` hook denies the call, and the program catches the failure as a thrown error. -### `Values` +### `Extension.make` -`Values` exports the runtime's non-JSON value classes: `Values.URL`, `Values.URLSearchParams`, `Values.Date`, -`Values.RegExp`, `Values.Map`, `Values.Set`, and `Values.Promise`. The interpreter recognizes these by class; a -program's `new URL(...)` is a `Values.URL` wrapping the host `URL`. `Values.isValue` narrows to the data-like kinds. +Extensions are host functions a program calls directly as globals, such as `fetch`. Unlike tools they are not in the +catalog, not counted against `maxToolCalls`, and not described to the model; the host decides what they mean. + +```ts +const web = Extension.make({ name: "web", globals: { fetch: (url: string) => globalThis.fetch(url) } }) +const runtime = CodeMode.make({ tools, extensions: [web] }) +``` + +Every value crossing in either direction is converted, never shared: arguments come in as copies, results go out as +copies, and a function inside a result is callable the same way. A global that shadows a built-in or another +extension throws at `make`. ### OpenAPI tools From c3d278a906eb2c29ebc2fa3ecff0ba2ba9835229 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 16 Sep 2026 00:39:27 -0500 Subject: [PATCH 3/4] fix(codemode): let Headers methods report a bad receiver --- packages/codemode/README.md | 2 +- packages/codemode/src/stdlib/console.ts | 5 +---- packages/codemode/src/stdlib/headers.ts | 7 ++++--- packages/codemode/test/stdlib.test.ts | 4 +++- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/codemode/README.md b/packages/codemode/README.md index c82afb61bb9d..810945d48cdd 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -106,7 +106,7 @@ const runtime = CodeMode.make({ tools, extensions: [web] }) Every value crossing in either direction is converted, never shared: arguments come in as copies, results go out as copies, and a function inside a result is callable the same way. A global that shadows a built-in or another -extension throws at `make`. +extension throws at `CodeMode.make`. ### OpenAPI tools diff --git a/packages/codemode/src/stdlib/console.ts b/packages/codemode/src/stdlib/console.ts index 890684381e06..2cf9db6f1f35 100644 --- a/packages/codemode/src/stdlib/console.ts +++ b/packages/codemode/src/stdlib/console.ts @@ -67,10 +67,7 @@ const formatConsoleValue = (value: unknown, seen: Set, depth: number): s if (value instanceof RegExpObj) return coerceToString(value) if (value instanceof URLObj) return coerceToString(value) if (value instanceof URLSearchParamsObj) return coerceToString(value) - if (value instanceof HeadersObj) { - const record = Object.fromEntries(value.headers) - return `Headers(${Object.keys(record).length}) ${JSON.stringify(record)}` - } + if (value instanceof HeadersObj) return `Headers ${JSON.stringify(Object.fromEntries(value.headers))}` if (value instanceof Bytes) return `Uint8Array(${value.bytes.length}) [${value.bytes.join(",")}]` if (depth > MAX_CONSOLE_DEPTH) return "..." if (seen.has(value)) return "[Circular]" diff --git a/packages/codemode/src/stdlib/headers.ts b/packages/codemode/src/stdlib/headers.ts index 7601eaf3aaa1..5cc20ebaf575 100644 --- a/packages/codemode/src/stdlib/headers.ts +++ b/packages/codemode/src/stdlib/headers.ts @@ -1,7 +1,7 @@ import { Effect } from "effect" import { constructor, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js" -import { typeError } from "../interpreter/model.js" -import { entries, isWrapper, Arr, HeadersObj, Obj } from "../interpreter/objects.js" +import { PendingThrow, typeError } from "../interpreter/model.js" +import { entries, Arr, HeadersObj, Obj } from "../interpreter/objects.js" import { applyCollectionCallback } from "../interpreter/callback.js" import { isRuntimeReference } from "../interpreter/references.js" import type { Interpreter } from "../interpreter/interpreter.js" @@ -13,6 +13,7 @@ const attempt = (run: () => T): T => { try { return run() } catch (error) { + if (error instanceof PendingThrow) throw error throw typeError(error instanceof Error ? error.message : String(error)) } } @@ -24,7 +25,7 @@ const constructHeaders = (ctx: Interpreter, init: unknown, proto: Obj): Ef return Effect.gen(function* () { const pairs = init instanceof Obj ? yield* readPairs(ctx, init, "new Headers(...)") : undefined if (pairs !== undefined) return wrap(attempt(() => new Headers(pairs))) - if (!(init instanceof Obj) || isWrapper(init) || isRuntimeReference(init)) { + if (!(init instanceof Obj) || isRuntimeReference(init)) { throw typeError("new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.") } return wrap( diff --git a/packages/codemode/test/stdlib.test.ts b/packages/codemode/test/stdlib.test.ts index 33f50c12465e..5e2de410fe89 100644 --- a/packages/codemode/test/stdlib.test.ts +++ b/packages/codemode/test/stdlib.test.ts @@ -731,7 +731,7 @@ describe("Headers", () => { type: "object", is: true, }) - expect(result.ok && result.logs?.[0]).toBe('Headers(2) {"b":"2","x-a":"1"}') + expect(result.ok && result.logs?.[0]).toBe('Headers {"b":"2","x-a":"1"}') }) test("rejects what it cannot build from, and invalid names and values, with TypeErrors the program can catch", async () => { @@ -758,6 +758,7 @@ describe("Headers", () => { message(() => headers.append("a")), message(() => headers.forEach()), message(() => headers.forEach(1)), + message(() => { const get = headers.get; return get("a") }), ] `), ).toEqual([ @@ -777,6 +778,7 @@ describe("Headers", () => { "Headers.append requires 2 arguments.", "Headers.forEach requires 1 argument.", "Headers.forEach expects a function callback.", + "Headers.prototype.get called on incompatible receiver undefined.", ]) }) }) From 99084d8a9bfb803c6cb6fc89264abe615b97343e Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 16 Sep 2026 13:19:43 -0500 Subject: [PATCH 4/4] test(codemode): keep only the Headers WPT cases that exercise the runtime --- packages/codemode/src/stdlib/headers.ts | 1 - packages/codemode/test/web-wpt.test.ts | 191 +----------------------- 2 files changed, 2 insertions(+), 190 deletions(-) diff --git a/packages/codemode/src/stdlib/headers.ts b/packages/codemode/src/stdlib/headers.ts index 5cc20ebaf575..d96c556b1267 100644 --- a/packages/codemode/src/stdlib/headers.ts +++ b/packages/codemode/src/stdlib/headers.ts @@ -21,7 +21,6 @@ const attempt = (run: () => T): T => { const constructHeaders = (ctx: Interpreter, init: unknown, proto: Obj): Effect.Effect => { const wrap = (headers: Headers) => new HeadersObj(proto, headers) if (init === undefined) return Effect.succeed(wrap(new Headers())) - if (init instanceof HeadersObj) return Effect.succeed(wrap(new Headers(init.headers))) return Effect.gen(function* () { const pairs = init instanceof Obj ? yield* readPairs(ctx, init, "new Headers(...)") : undefined if (pairs !== undefined) return wrap(attempt(() => new Headers(pairs))) diff --git a/packages/codemode/test/web-wpt.test.ts b/packages/codemode/test/web-wpt.test.ts index a19e66b81402..bb507fd9df25 100644 --- a/packages/codemode/test/web-wpt.test.ts +++ b/packages/codemode/test/web-wpt.test.ts @@ -3,14 +3,13 @@ * - html/webappapis/atob/base64.any.js (btoa reference encoder, input list, and atob WebIDL cases) * - fetch/data-urls/resources/base64.json (copied to fixtures/wpt-base64.json) * - WebCryptoAPI/randomUUID.https.any.js - * - fetch/api/headers/{headers-basic,headers-casing,headers-combine,headers-errors,headers-normalize,header-setcookie}.any.js + * - fetch/api/headers/{headers-basic,headers-errors}.any.js * * Copyright © web-platform-tests contributors. Governed by the 3-Clause BSD license in LICENSE.wpt. * * `assert_throws_dom("InvalidCharacterError", …)` becomes a check for a TypeError: CodeMode has no DOMException. * Headers cases that need `Symbol.iterator`, iterator objects from `keys()`/`values()`/`entries()` (CodeMode returns - * arrays), or a custom iterator on a Headers instance are left out, as are two set-cookie cases that Bun's own Headers - * fails by sorting `set-cookie2` ahead of `set-cookie`. + * arrays), or a custom iterator on a Headers instance are left out. */ import { describe, expect, test } from "bun:test" import { Effect } from "effect" @@ -179,7 +178,6 @@ const testharness = ` function assert_true(actual, message) { assert_equals(actual, true, message) } function assert_false(actual, message) { assert_equals(actual, false, message) } function assert_array_equals(actual, expected, message) { assert_equals(JSON.stringify(actual), JSON.stringify(expected), message) } - function assert_nested_array_equals(actual, expected) { assert_array_equals(actual, expected) } function assert_throws_js(type, run) { try { run() } catch (error) { if (error instanceof type) return; throw new Error("threw " + error.name) } throw new Error("did not throw") } function assert_unreached() { throw new Error("unreachable") } ` @@ -336,99 +334,6 @@ describe("Headers WPT parity (fetch/api/headers)", () => { ).toEqual([]) }) - test("headers-casing.any.js", async () => { - expect( - await value(` - ${testharness} - var headerDictCase = {"UPPERCASE": "value1", "lowercase": "value2", "mixedCase": "value3", "Content-TYPE": "value4"} - function checkHeadersCase(originalName, headersToCheck, expectedDict) { - var lowCaseName = originalName.toLowerCase() - var upCaseName = originalName.toUpperCase() - var expectedValue = expectedDict[originalName] - assert_equals(headersToCheck.get(originalName), expectedValue, "name: " + originalName + " has value: " + expectedValue) - assert_equals(headersToCheck.get(lowCaseName), expectedValue, "name: " + lowCaseName + " has value: " + expectedValue) - assert_equals(headersToCheck.get(upCaseName), expectedValue, "name: " + upCaseName + " has value: " + expectedValue) - } - test(function() { - var headers = new Headers(headerDictCase) - for (const name in headerDictCase) checkHeadersCase(name, headers, headerDictCase) - }, "Create headers, names use characters with different case") - test(function() { - var headers = new Headers() - for (const name in headerDictCase) { - headers.append(name, headerDictCase[name]) - checkHeadersCase(name, headers, headerDictCase) - } - }, "Check append method, names use characters with different case") - test(function() { - var headers = new Headers() - for (const name in headerDictCase) { - headers.set(name, headerDictCase[name]) - checkHeadersCase(name, headers, headerDictCase) - } - }, "Check set method, names use characters with different case") - test(function() { - var headers = new Headers() - for (const name in headerDictCase) headers.set(name, headerDictCase[name]) - for (const name in headerDictCase) headers.delete(name.toLowerCase()) - for (const name in headerDictCase) assert_false(headers.has(name), "header " + name + " should have been deleted") - }, "Check delete method, names use characters with different case") - return failures - `), - ).toEqual([]) - }) - - test("headers-combine.any.js", async () => { - expect( - await value(` - ${testharness} - var headerSeqCombine = [["single", "singleValue"], ["double", "doubleValue1"], ["double", "doubleValue2"], ["triple", "tripleValue1"], ["triple", "tripleValue2"], ["triple", "tripleValue3"]] - var expectedDict = {"single": "singleValue", "double": "doubleValue1, doubleValue2", "triple": "tripleValue1, tripleValue2, tripleValue3"} - test(function() { - var headers = new Headers(headerSeqCombine) - for (const name in expectedDict) assert_equals(headers.get(name), expectedDict[name]) - }, "Create headers using same name for different values") - test(function() { - var headers = new Headers(headerSeqCombine) - for (const name in expectedDict) { - assert_true(headers.has(name), "name: " + name + " has value(s)") - headers.delete(name) - assert_false(headers.has(name), "name: " + name + " has no value(s) anymore") - } - }, "Check delete and has methods when using same name for different values") - test(function() { - var headers = new Headers(headerSeqCombine) - for (const name in expectedDict) { - headers.set(name, "newSingleValue") - assert_equals(headers.get(name), "newSingleValue", "name: " + name + " has value: newSingleValue") - } - }, "Check set methods when called with already used name") - test(function() { - var headers = new Headers(headerSeqCombine) - for (const name in expectedDict) { - var value = headers.get(name) - headers.append(name, "newSingleValue") - assert_equals(headers.get(name), (value + ", " + "newSingleValue")) - } - }, "Check append methods when called with already used name") - test(() => { - const headers = new Headers([["1", "a"], ["1", "b"]]) - for (let header of headers) assert_array_equals(header, ["1", "a, b"]) - }, "Iterate combined values") - test(() => { - const headers = new Headers([["2", "a"], ["1", "b"], ["2", "b"]]), expected = [["1", "b"], ["2", "a, b"]] - let i = 0 - for (let header of headers) { - assert_array_equals(header, expected[i]) - i++ - } - assert_equals(i, 2) - }, "Iterate combined values in sorted order") - return failures - `), - ).toEqual([]) - }) - test("headers-errors.any.js", async () => { expect( await value(` @@ -485,96 +390,4 @@ describe("Headers WPT parity (fetch/api/headers)", () => { `), ).toEqual([]) }) - - test("headers-normalize.any.js", async () => { - expect( - await value(` - ${testharness} - const expectations = { - "name1": [" space ", "space"], - "name2": ["\\ttab\\t", "tab"], - "name3": [" spaceAndTab\\t", "spaceAndTab"], - "name4": ["\\r\\n newLine", "newLine"], - "name5": ["newLine\\r\\n ", "newLine"], - "name6": ["\\r\\n\\tnewLine", "newLine"], - "name7": ["\\t\\f\\tnewLine\\n", "\\f\\tnewLine"], - "name8": ["newLine\\xa0", "newLine\\xa0"], - } - test(function () { - const headerDict = Object.fromEntries(Object.entries(expectations).map(([name, [actual]]) => [name, actual])) - var headers = new Headers(headerDict) - for (const name in expectations) { - const expected = expectations[name][1] - assert_equals(headers.get(name), expected, "name: " + name + " has normalized value: " + expected) - } - }, "Create headers with not normalized values") - test(function () { - var headers = new Headers() - for (const name in expectations) { - headers.append(name, expectations[name][0]) - const expected = expectations[name][1] - assert_equals(headers.get(name), expected, "name: " + name + " has value: " + expected) - } - }, "Check append method with not normalized values") - test(function () { - var headers = new Headers() - for (const name in expectations) { - headers.set(name, expectations[name][0]) - const expected = expectations[name][1] - assert_equals(headers.get(name), expected, "name: " + name + " has value: " + expected) - } - }, "Check set method with not normalized values") - return failures - `), - ).toEqual([]) - }) - - test("header-setcookie.any.js", async () => { - expect( - await value(` - ${testharness} - const headerList = [["set-cookie", "foo=bar"], ["Set-Cookie", "fizz=buzz; domain=example.com"]] - const setCookie2HeaderList = [["set-cookie2", "foo2=bar2"], ["Set-Cookie2", "fizz2=buzz2; domain=example2.com"]] - test(function () { - const headers = new Headers(headerList) - assert_equals(headers.get("set-cookie"), "foo=bar, fizz=buzz; domain=example.com") - }, "Headers.prototype.get combines set-cookie headers in order") - test(function () { - const headers = new Headers(headerList) - assert_nested_array_equals([...headers], [["set-cookie", "foo=bar"], ["set-cookie", "fizz=buzz; domain=example.com"]]) - }, "Headers iterator does not combine set-cookie headers") - test(function () { - const headers = new Headers(setCookie2HeaderList) - assert_nested_array_equals([...headers], [["set-cookie2", "foo2=bar2, fizz2=buzz2; domain=example2.com"]]) - }, "Headers iterator does not special case set-cookie2 headers") - test(function () { - const headers = new Headers([["set-cookie", "z=z"], ["set-cookie", "a=a"], ["set-cookie", "n=n"]]) - assert_nested_array_equals([...headers], [["set-cookie", "z=z"], ["set-cookie", "a=a"], ["set-cookie", "n=n"]]) - }, "Headers iterator preserves set-cookie ordering") - test(function () { - const headers = new Headers(headerList) - assert_true(headers.has("sEt-cOoKiE")) - }, "Headers.prototype.has works for set-cookie") - test(function () { - const headers = new Headers(headerList) - headers.set("set-cookie", "foo2=bar2") - assert_nested_array_equals([...headers], [["set-cookie", "foo2=bar2"]]) - }, "Headers.prototype.set works for set-cookie") - test(function () { - const headers = new Headers(headerList) - headers.delete("set-Cookie") - assert_nested_array_equals([...headers], []) - }, "Headers.prototype.delete works for set-cookie") - test(function () { assert_array_equals(new Headers().getSetCookie(), []) }, "Headers.prototype.getSetCookie with no headers present") - test(function () { assert_array_equals(new Headers([headerList[0]]).getSetCookie(), ["foo=bar"]) }, "Headers.prototype.getSetCookie with one header") - test(function () { assert_array_equals(new Headers({ "Set-Cookie": "foo=bar" }).getSetCookie(), ["foo=bar"]) }, "Headers.prototype.getSetCookie with one header created from an object") - test(function () { assert_array_equals(new Headers(headerList).getSetCookie(), ["foo=bar", "fizz=buzz; domain=example.com"]) }, "Headers.prototype.getSetCookie with multiple headers") - test(function () { assert_array_equals(new Headers([["set-cookie", ""]]).getSetCookie(), [""]) }, "Headers.prototype.getSetCookie with an empty header") - test(function () { assert_array_equals(new Headers([["set-cookie", "x"], ["set-cookie", "x"]]).getSetCookie(), ["x", "x"]) }, "Headers.prototype.getSetCookie with two equal headers") - test(function () { assert_array_equals(new Headers([["set-cookie2", "x"], ["set-cookie", "y"], ["set-cookie2", "z"]]).getSetCookie(), ["y"]) }, "Headers.prototype.getSetCookie ignores set-cookie2 headers") - test(function () { assert_array_equals(new Headers([["set-cookie", "z=z"], ["set-cookie", "a=a"], ["set-cookie", "n=n"]]).getSetCookie(), ["z=z", "a=a", "n=n"]) }, "Headers.prototype.getSetCookie preserves header ordering") - return failures - `), - ).toEqual([]) - }) })