feat(codemode): expand standard library support (#35566)

This commit is contained in:
Aiden Cline 2026-07-06 10:36:02 -05:00 committed by GitHub
commit 5b2d90a441
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 833 additions and 1248 deletions

View file

@ -243,12 +243,13 @@ CodeMode executes a deliberately bounded JavaScript subset. It supports:
- Optional chaining, nullish coalescing, templates, spread (arrays, strings, Maps, Sets), and `try`/`catch`.
- Common array, string, number, `Object`, `Math`, and `JSON` operations. Mutating array methods include `push`/`pop`/`shift`/`unshift`/`splice` (removes in place and returns the removed elements)/`fill`/`copyWithin`; array `keys`/`values`/`entries` return **arrays** (matching the Map/Set convention) and work with `for...of` and spread. String methods include `localeCompare` (locale/options arguments ignored), `normalize`, and the `trimLeft`/`trimRight` aliases. `Object.keys` also accepts arrays (index strings, as in JS) and tool references: `Object.keys(tools)` lists the top-level namespaces, including `$codemode`, and `Object.keys(tools.ns)` lists the names at that node (a callable tool enumerates as `[]`; an unknown path is an `UnknownTool` diagnostic). `Object.values`/`Object.entries` on a tool reference fail with a pointer at `Object.keys(tools)` and `tools.$codemode.search`.
- `Date` - `Date.now()`/`Date.parse()`/`Date.UTC()`, `new Date(...)`, the getter methods, and date arithmetic/comparison via the time value. Dates stringify as ISO (`toString` included, for determinism across host timezones).
- Regular expressions - `/literals/` and `new RegExp(...)` with `test`/`exec` (stateful `lastIndex` for `g`), plus string `match`/`matchAll`/`replace`/`replaceAll`/`split`/`search` with patterns. Match results are arrays carrying `index` and named `groups` as own properties (`input` is omitted). Invalid patterns, invalid flags, and missing-`g` calls fail with catchable errors that say what was wrong and how to fix it (escaping hints, the exact `/pattern/g` to write). Patterns run on the host engine, so pathological backtracking is bounded only by the execution timeout. Function replacers are not supported.
- Regular expressions - `/literals/` and `new RegExp(...)` with `test`/`exec` (stateful `lastIndex` for `g`), plus string `match`/`matchAll`/`replace`/`replaceAll`/`split`/`search` with patterns. Match results are arrays carrying `index` and named `groups` as own properties (`input` is omitted). `replace` and `replaceAll` accept function replacers with captures, offset, input, and named groups; callbacks run sequentially, may await tool calls, and have their results coerced to strings. Invalid patterns, invalid flags, and missing-`g` calls fail with catchable errors that say what was wrong and how to fix it (escaping hints, the exact `/pattern/g` to write). Patterns run on the host engine, so pathological backtracking is bounded only by the execution timeout.
- `Map` and `Set` - construction from entries/arrays/strings, `get`/`set`/`add`/`has`/`delete`/`clear`/`size`/`forEach`, and `keys`/`values`/`entries` returning **arrays** (not iterators).
- URL helpers - `URL` resolution and mutation, linked `URLSearchParams`, `URL.canParse`/`URL.parse`, URI and URI-component encoding/decoding, and query parameter construction, lookup, mutation, sorting, callbacks, and materialization. URLSearchParams iteration methods return arrays, matching the Map/Set convention.
- First-class promises - an un-awaited `tools.ns.tool(...)` is a promise value whose call starts immediately on a supervised fiber; `await` resolves it (awaiting a non-promise value is a no-op, and `return tools.ns.tool(...)` resolves like an async-function return). `Promise.all`, `Promise.allSettled`, and `Promise.race` accept any array mixing promises and plain values (built inline, beforehand, or via spread); `Promise.resolve`/`Promise.reject` construct settled promises. `Promise.allSettled` rejection reasons are the same plain `{ name?, message }` data a `catch` binding sees, and `Promise.race` interrupts its losing in-flight calls. At most 8 tool calls run concurrently. When a program completes, still-running un-awaited calls are awaited before the execution ends; a failure from a call that was never awaited surfaces as an unhandled-rejection diagnostic.
- `throw value` and `throw new Error(message)` for explicit program failure. `Error` (and `TypeError`/`RangeError`/`SyntaxError`/`ReferenceError`/`EvalError`/`URIError`) are real constructors, callable with or without `new`; error values are plain `{ name, message }` data that additionally satisfy `instanceof Error` (a specific type matches itself and `Error`, as in JS). Every caught failure - thrown errors, interpreter runtime errors, and tool failures - is `instanceof Error` in a `catch` block; a thrown non-error value (`throw "text"`) is not, matching JS. Caught failures carry the `name` the equivalent real-JS failure would have - `JSON.parse` and invalid regex patterns produce a `SyntaxError` (satisfying `instanceof SyntaxError`), an unknown identifier a `ReferenceError`, assigning to a constant a `TypeError`, a bad `normalize` form a `RangeError`; failures with no specific analogue (including tool failures) are named `"Error"`. `instanceof` also recognizes `Date`, `RegExp`, `Map`, `Set`, `Array`, `Object`, and `Promise`; any other right-hand side is a catchable error.
- `throw value` and `throw new Error(message)` for explicit program failure. `Error` (and `TypeError`/`RangeError`/`SyntaxError`/`ReferenceError`/`EvalError`/`URIError`) are real constructors, callable with or without `new`; error values are plain `{ name, message }` data that additionally satisfy `instanceof Error` (a specific type matches itself and `Error`, as in JS). Every caught failure - thrown errors, interpreter runtime errors, and tool failures - is `instanceof Error` in a `catch` block; a thrown non-error value (`throw "text"`) is not, matching JS. Caught failures carry the `name` the equivalent real-JS failure would have - `JSON.parse` and invalid regex patterns produce a `SyntaxError` (satisfying `instanceof SyntaxError`), an unknown identifier a `ReferenceError`, assigning to a constant a `TypeError`, a bad `normalize` form a `RangeError`; failures with no specific analogue (including tool failures) are named `"Error"`. `instanceof` also recognizes `Date`, `RegExp`, `Map`, `Set`, `URL`, `URLSearchParams`, `Array`, `Object`, and `Promise`; any other right-hand side is a catchable error.
Inside a program, Date/RegExp/Map/Set values stay live everywhere: the internal data checkpoints (`Object.*` helpers, spread, coercion inputs) preserve the instances, so `Object.values({ d: date })[0].getTime()` and a spread copy of an object holding a Map keep working. Only at the host boundary (final result, tool arguments, `JSON.stringify`) do the four value types serialize exactly as `JSON.stringify` would: a Date becomes its ISO string (`null` when invalid) and RegExp/Map/Set become `{}`. Promise values never cross a data boundary: an un-awaited promise in a result or tool argument produces a diagnostic that says to await it, instead of serializing to `{}`.
Inside a program, standard-library values stay live everywhere: the internal data checkpoints (`Object.*` helpers, spread, coercion inputs) preserve the instances, so `Object.values({ d: date })[0].getTime()` and a spread copy of an object holding a Map keep working. Only at the host boundary (final result, tool arguments, `JSON.stringify`) do they serialize exactly as `JSON.stringify` would: Date and URL become strings (an invalid Date becomes `null`), while RegExp, Map, Set, and URLSearchParams become `{}`. Promise values never cross a data boundary: an un-awaited promise in a result or tool argument produces a diagnostic that says to await it, instead of serializing to `{}`.
It does not expose `eval`, dynamic imports, modules, classes, generators, timers, host globals, prototype mutation, custom promise constructors (`new Promise`), promise chaining (`.then`/`.catch`/`.finally` - `await` with `try`/`catch` is the supported style), or arbitrary method calls. Unsupported syntax returns an `UnsupportedSyntax` diagnostic with a source location when available.

File diff suppressed because it is too large Load diff

View file

@ -16,7 +16,16 @@ import {
} from "./tool-runtime.js"
import type { Definition } from "./tool.js"
import { ToolError } from "./tool-error.js"
import { isSandboxValue, SandboxDate, SandboxMap, SandboxPromise, SandboxRegExp, SandboxSet } from "./values.js"
import {
isSandboxValue,
SandboxDate,
SandboxMap,
SandboxPromise,
SandboxRegExp,
SandboxSet,
SandboxURL,
SandboxURLSearchParams,
} from "./values.js"
/** A tool call admitted during an execution. */
export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescription } from "./tool-runtime.js"
@ -184,7 +193,7 @@ type StatementResult =
| { kind: "continue" }
type MemberReference = {
target: SafeObject | Array<unknown>
target: SafeObject | Array<unknown> | SandboxURL
key: string | number
}
@ -217,7 +226,18 @@ class PromiseMethodReference {
// A built-in global namespace (`Object`, `Math`, `JSON`, `Array`, ...); members resolve to a
// GlobalMethodReference, except known constants (e.g. `Math.PI`) which resolve to a value.
type GlobalNamespaceName = "Object" | "Math" | "JSON" | "Array" | "console" | "Date" | "RegExp" | "Map" | "Set"
type GlobalNamespaceName =
| "Object"
| "Math"
| "JSON"
| "Array"
| "console"
| "Date"
| "RegExp"
| "Map"
| "Set"
| "URL"
| "URLSearchParams"
class GlobalNamespace {
constructor(readonly name: GlobalNamespaceName) {}
@ -234,6 +254,10 @@ class CoercionFunction {
constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat") {}
}
class UriFunction {
constructor(readonly name: "encodeURI" | "encodeURIComponent" | "decodeURI" | "decodeURIComponent") {}
}
class ProgramThrow {
constructor(readonly value: unknown) {}
}
@ -355,7 +379,7 @@ const errorConstructors = new Set([
"URIError",
])
const valueConstructors = new Set(["Date", "RegExp", "Map", "Set"])
const valueConstructors = new Set(["Date", "RegExp", "Map", "Set", "URL", "URLSearchParams"])
const dateMethods = new Set([
"getTime",
@ -400,10 +424,52 @@ const regexpProperties = new Set([
const mapMethods = new Set(["get", "set", "has", "delete", "clear", "forEach", "keys", "values", "entries"])
const setMethods = new Set(["add", "has", "delete", "clear", "forEach", "keys", "values", "entries"])
const urlProperties = new Set([
"href",
"origin",
"protocol",
"username",
"password",
"host",
"hostname",
"port",
"pathname",
"search",
"hash",
])
const urlWritableProperties = new Set([
"href",
"protocol",
"username",
"password",
"host",
"hostname",
"port",
"pathname",
"search",
"hash",
])
const urlMethods = new Set(["toString", "toJSON"])
const urlStatics = new Set(["canParse", "parse"])
const urlSearchParamsMethods = new Set([
"append",
"delete",
"get",
"getAll",
"has",
"set",
"sort",
"forEach",
"keys",
"values",
"entries",
"toString",
])
const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit")
const supportedSyntaxMessage =
"Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, and Promise.all/allSettled/race/resolve/reject over arrays mixing promises and plain values for parallel tool calls (promise chaining with .then/.catch is not supported - use await with try/catch)."
"Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set/URL/URLSearchParams, URI encoding helpers, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, and Promise.all/allSettled/race/resolve/reject over arrays mixing promises and plain values for parallel tool calls (promise chaining with .then/.catch is not supported - use await with try/catch)."
const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRuntimeError =>
new InterpreterRuntimeError(
@ -662,6 +728,7 @@ const isRuntimeReference = (value: unknown): boolean =>
value instanceof PromiseMethodReference ||
value instanceof SandboxPromise ||
value instanceof CoercionFunction ||
value instanceof UriFunction ||
value instanceof ErrorConstructorReference ||
isSandboxValue(value)
@ -677,7 +744,7 @@ const containsRuntimeReference = (value: unknown, seen = new Set<object>()): boo
return contains
}
// Like containsRuntimeReference, but sandbox value types (Date/RegExp/Map/Set) count as data:
// Like containsRuntimeReference, but sandbox standard-library values count as data:
// operators and switch treat them as ordinary object operands (identity equality, ToPrimitive
// coercion) rather than rejecting them as opaque interpreter machinery.
const containsOpaqueReference = (value: unknown, seen = new Set<object>()): boolean => {
@ -707,6 +774,7 @@ const typeofValue = (value: unknown): string => {
value instanceof ErrorConstructorReference
)
return "function"
if (value instanceof UriFunction) return "function"
if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object"
if (value instanceof GlobalNamespace) {
return value.name === "Math" || value.name === "JSON" || value.name === "console" ? "object" : "function"
@ -733,6 +801,10 @@ const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean =>
return lhs instanceof SandboxMap
case "Set":
return lhs instanceof SandboxSet
case "URL":
return lhs instanceof SandboxURL
case "URLSearchParams":
return lhs instanceof SandboxURLSearchParams
case "Array":
return Array.isArray(lhs)
case "Object":
@ -746,7 +818,7 @@ const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean =>
return false
}
throw new InterpreterRuntimeError(
"The right-hand side of 'instanceof' must be a constructor CodeMode knows: Error (or a specific error type like TypeError), Date, RegExp, Map, Set, Array, Object, or Promise.",
"The right-hand side of 'instanceof' must be a constructor CodeMode knows: Error (or a specific error type like TypeError), Date, RegExp, Map, Set, URL, URLSearchParams, Array, Object, or Promise.",
node,
)
}
@ -885,14 +957,6 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
break
case "replace":
case "replaceAll": {
if (args[0] instanceof CodeModeFunction || args[1] instanceof CodeModeFunction) {
throw new InterpreterRuntimeError(
`String.${name} does not support function replacers in CodeMode; use match/matchAll and rebuild the string instead.`,
node,
"UnsupportedSyntax",
[supportedSyntaxMessage],
)
}
if (args[0] instanceof SandboxRegExp) {
const pattern = (args[0] as SandboxRegExp).regex
const replacement = str(1)
@ -1028,6 +1092,8 @@ const coerceToString = (value: unknown): string => {
if (value instanceof SandboxRegExp) return `/${value.regex.source}/${value.regex.flags}`
if (value instanceof SandboxMap) return "[object Map]"
if (value instanceof SandboxSet) return "[object Set]"
if (value instanceof SandboxURL) return value.url.href
if (value instanceof SandboxURLSearchParams) return value.params.toString()
if (typeof value === "object") {
return Array.isArray(value)
? value.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",")
@ -1072,7 +1138,7 @@ const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node: AstNo
const invokeObjectMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
const requireObject = (): Record<string, unknown> => {
const value = boundedData(args[0], `Object.${name} input`)
// Sandbox values (Date/RegExp/Map/Set) have no own enumerable properties in JS, so the
// Sandbox standard-library values have no own enumerable properties in JS, so the
// Object.* helpers see them as empty objects - never their interpreter internals.
if (isSandboxValue(value)) return {}
if (value === null || typeof value !== "object" || Array.isArray(value)) {
@ -1124,6 +1190,11 @@ const invokeObjectMethod = (name: string, args: Array<unknown>, node: AstNode):
for (const [key, item] of (args[0] as SandboxMap).map.entries()) guardedSet(out, coerceToString(key), item)
return out
}
if (args[0] instanceof SandboxURLSearchParams) {
const out: Record<string, unknown> = Object.create(null)
for (const [key, value] of args[0].params.entries()) guardedSet(out, key, value)
return out
}
const pairs = boundedData(args[0], "Object.fromEntries input")
if (!Array.isArray(pairs))
throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node)
@ -1241,6 +1312,9 @@ const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): u
if (args[0] instanceof SandboxMap)
return Array.from((args[0] as SandboxMap).map.entries(), ([key, item]) => [key, item])
if (args[0] instanceof SandboxSet) return Array.from((args[0] as SandboxSet).set.values())
if (args[0] instanceof SandboxURLSearchParams) {
return Array.from(args[0].params.entries(), ([key, value]) => [key, value])
}
const source = boundedData(args[0], "Array.from input")
if (typeof source === "string") return Array.from(source)
if (Array.isArray(source)) return [...source]
@ -1385,6 +1459,52 @@ const invokeRegExpMethod = (value: SandboxRegExp, name: string, args: Array<unkn
}
}
const uriArgument = (value: unknown, label: string): string => coerceToString(boundedData(value, label))
const invokeUriFunction = (ref: UriFunction, args: Array<unknown>, node: AstNode): string => {
const value = uriArgument(args[0], `${ref.name} input`)
try {
switch (ref.name) {
case "encodeURI":
return encodeURI(value)
case "encodeURIComponent":
return encodeURIComponent(value)
case "decodeURI":
return decodeURI(value)
case "decodeURIComponent":
return decodeURIComponent(value)
}
} catch (error) {
throw new InterpreterRuntimeError(
`${ref.name} received malformed URI data: ${error instanceof Error ? error.message : String(error)}`,
node,
).as("URIError")
}
}
const urlArgument = (value: unknown, label: string): string =>
value instanceof SandboxURL ? value.url.href : uriArgument(value, label)
const invokeURLStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
if (!urlStatics.has(name)) throw new InterpreterRuntimeError(`URL.${name} is not available in CodeMode.`, node)
if (args.length === 0) {
throw new InterpreterRuntimeError(`URL.${name} requires a URL argument.`, node).as("TypeError")
}
const input = urlArgument(args[0], `URL.${name} input`)
const base = args[1] === undefined ? undefined : urlArgument(args[1], `URL.${name} base`)
try {
const url = new URL(input, base)
return name === "canParse" ? true : new SandboxURL(url)
} catch {
return name === "canParse" ? false : null
}
}
const invokeURLMethod = (value: SandboxURL, name: string, node: AstNode): string => {
if (name === "toString" || name === "toJSON") return value.url.href
throw new InterpreterRuntimeError(`URL method '${name}' is not available in CodeMode.`, node)
}
const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unknown>, node: AstNode): unknown => {
if (ref.namespace === "console")
throw new InterpreterRuntimeError(`console.${ref.name} is not available in CodeMode.`, node)
@ -1393,12 +1513,18 @@ const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unknown>, no
if (ref.namespace === "Array") return invokeArrayStatic(ref.name, args, node)
if (ref.namespace === "Number") return invokeNumberStatic(ref.name, args, node)
if (ref.namespace === "String") return invokeStringStatic(ref.name, args, node)
if (ref.namespace === "URL") return invokeURLStatic(ref.name, args, node)
if (ref.namespace === "Date") {
if (!dateStatics.has(ref.name))
throw new InterpreterRuntimeError(`Date.${ref.name} is not available in CodeMode.`, node)
return invokeDateStatic(ref.name, args, node)
}
if (ref.namespace === "RegExp" || ref.namespace === "Map" || ref.namespace === "Set") {
if (
ref.namespace === "RegExp" ||
ref.namespace === "Map" ||
ref.namespace === "Set" ||
ref.namespace === "URLSearchParams"
) {
throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available in CodeMode.`, node)
}
return invokeJsonMethod(ref.name, args, node)
@ -1411,6 +1537,8 @@ const spreadItems = (spread: unknown): Array<unknown> | undefined => {
if (spread instanceof SandboxMap)
return Array.from(spread.map.entries(), ([key, item]): Array<unknown> => [key, item])
if (spread instanceof SandboxSet) return Array.from(spread.set.values())
if (spread instanceof SandboxURLSearchParams)
return Array.from(spread.params.entries(), ([key, value]): Array<unknown> => [key, value])
return undefined
}
@ -1485,6 +1613,12 @@ class Interpreter<R> {
globalScope.set("RegExp", { mutable: false, value: new GlobalNamespace("RegExp") })
globalScope.set("Map", { mutable: false, value: new GlobalNamespace("Map") })
globalScope.set("Set", { mutable: false, value: new GlobalNamespace("Set") })
globalScope.set("URL", { mutable: false, value: new GlobalNamespace("URL") })
globalScope.set("URLSearchParams", { mutable: false, value: new GlobalNamespace("URLSearchParams") })
globalScope.set("encodeURI", { mutable: false, value: new UriFunction("encodeURI") })
globalScope.set("encodeURIComponent", { mutable: false, value: new UriFunction("encodeURIComponent") })
globalScope.set("decodeURI", { mutable: false, value: new UriFunction("decodeURI") })
globalScope.set("decodeURIComponent", { mutable: false, value: new UriFunction("decodeURIComponent") })
// Error constructors are real values, so `x instanceof Error` works and `Error("msg")`
// (with or without `new`) constructs a branded { name, message } error object.
for (const name of errorConstructors) {
@ -2331,8 +2465,12 @@ class Interpreter<R> {
return self.constructRegExp(args, node)
case "Map":
return self.constructMap(args[0], node)
default:
case "Set":
return self.constructSet(args[0], node)
case "URL":
return self.constructURL(args, node)
default:
return self.constructURLSearchParams(args[0], node)
}
})
}
@ -2421,6 +2559,67 @@ class Interpreter<R> {
return target
}
private constructURL(args: Array<unknown>, node: AstNode): SandboxURL {
if (args.length === 0) {
throw new InterpreterRuntimeError("new URL(...) requires a URL string and an optional base URL.", node).as(
"TypeError",
)
}
const input = urlArgument(args[0], "new URL input")
const base = args[1] === undefined ? undefined : urlArgument(args[1], "new URL base")
try {
return new SandboxURL(new URL(input, base))
} catch {
throw new InterpreterRuntimeError(
`new URL(...) received an invalid URL${base === undefined ? "" : " or base URL"}.`,
node,
).as("TypeError")
}
}
private constructURLSearchParams(init: unknown, node: AstNode): SandboxURLSearchParams {
if (init === undefined) return new SandboxURLSearchParams(new URLSearchParams())
if (init instanceof SandboxURLSearchParams) {
return new SandboxURLSearchParams(new URLSearchParams(init.params))
}
if (typeof init === "string") return new SandboxURLSearchParams(new URLSearchParams(init))
if (init === null || typeof init === "number" || typeof init === "boolean") {
return new SandboxURLSearchParams(new URLSearchParams(coerceToString(init)))
}
if (init instanceof SandboxMap) {
return this.constructURLSearchParams(
Array.from(init.map.entries(), ([key, value]) => [key, value]),
node,
)
}
if (Array.isArray(init)) {
const entries = init.map((pair) => {
if (!Array.isArray(pair) || pair.length !== 2) {
throw new InterpreterRuntimeError(
"new URLSearchParams(...) expects an array of [name, value] pairs.",
node,
).as("TypeError")
}
return [uriArgument(pair[0], "URLSearchParams name"), uriArgument(pair[1], "URLSearchParams value")] as [
string,
string,
]
})
return new SandboxURLSearchParams(new URLSearchParams(entries))
}
if (isSandboxValue(init)) return new SandboxURLSearchParams(new URLSearchParams())
const data = boundedData(init, "new URLSearchParams input")
if (data === null || typeof data !== "object") {
throw new InterpreterRuntimeError(
"new URLSearchParams(...) expects a query string, data object, array of pairs, or URLSearchParams.",
node,
).as("TypeError")
}
return new SandboxURLSearchParams(
new URLSearchParams(Object.fromEntries(Object.entries(data).map(([key, value]) => [key, coerceToString(value)]))),
)
}
private evaluateBinaryExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
const operator = getString(node, "operator")
const self = this
@ -2699,6 +2898,9 @@ class Interpreter<R> {
if (callable instanceof CoercionFunction) {
return boundedData(invokeCoercion(callable, args, node), `${callable.name} result`)
}
if (callable instanceof UriFunction) {
return invokeUriFunction(callable, args, node)
}
// `Error("msg")` without `new` constructs an error exactly like `new Error("msg")`, as in JS.
if (callable instanceof ErrorConstructorReference) {
return createErrorValue(callable.name, args[0] === undefined ? "" : coerceToString(args[0]))
@ -2759,6 +2961,8 @@ class Interpreter<R> {
if (value instanceof SandboxPromise) return "[Promise (await it to get its value)]"
if (value instanceof SandboxDate) return coerceToString(value)
if (value instanceof SandboxRegExp) return coerceToString(value)
if (value instanceof SandboxURL) return coerceToString(value)
if (value instanceof SandboxURLSearchParams) return coerceToString(value)
if (depth > MAX_CONSOLE_DEPTH) return "..."
if (seen.has(value)) return "[Circular]"
if (value instanceof SandboxMap) {
@ -3026,6 +3230,12 @@ class Interpreter<R> {
node: AstNode,
): Effect.Effect<unknown, unknown, R> {
if (typeof ref.receiver === "string") {
if (
(ref.name === "replace" || ref.name === "replaceAll") &&
(args[1] instanceof CodeModeFunction || args[1] instanceof CoercionFunction || args[1] instanceof UriFunction)
) {
return this.invokeStringReplacer(ref.receiver, ref.name, args, node)
}
return Effect.succeed(invokeStringMethod(ref.receiver, ref.name, args, node))
}
if (typeof ref.receiver === "number") {
@ -3046,23 +3256,95 @@ class Interpreter<R> {
if (ref.receiver instanceof SandboxSet) {
return this.invokeSetMethod(ref.receiver, ref.name, args, node)
}
if (ref.receiver instanceof SandboxURL) {
return Effect.succeed(invokeURLMethod(ref.receiver, ref.name, node))
}
if (ref.receiver instanceof SandboxURLSearchParams) {
return this.invokeURLSearchParamsMethod(ref.receiver, ref.name, args, node)
}
throw new InterpreterRuntimeError(`Method '${ref.name}' is not available in CodeMode.`, node)
}
// Runs a Map/Set callback (forEach) accepting a user function or a builtin coercion,
private invokeStringReplacer(
value: string,
name: "replace" | "replaceAll",
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> {
const apply = this.applyCollectionCallback(args[1], `String.${name}`, node)
const matches: Array<{ readonly match: string; readonly offset: number; readonly args: Array<unknown> }> = []
const collect = (...callbackArgs: Array<unknown>): string => {
const match = callbackArgs[0]
const groups = callbackArgs[callbackArgs.length - 1]
const hasGroups = groups !== null && typeof groups === "object"
const offset = callbackArgs[callbackArgs.length - (hasGroups ? 3 : 2)]
if (typeof match !== "string" || typeof offset !== "number") {
throw new InterpreterRuntimeError(`String.${name} produced an invalid replacement match.`, node)
}
if (hasGroups) {
const safeGroups: SafeObject = Object.create(null) as SafeObject
for (const [key, group] of Object.entries(groups)) {
if (!isBlockedMember(key)) safeGroups[key] = group
}
callbackArgs[callbackArgs.length - 1] = safeGroups
}
matches.push({ match, offset, args: callbackArgs })
return match
}
const pattern = args[0]
if (pattern instanceof SandboxRegExp) {
if (name === "replaceAll" && !pattern.regex.global) {
throw new InterpreterRuntimeError(
`String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.regex.source}/${pattern.regex.flags}g, or use String.replace to replace only the first match.`,
node,
)
}
if (name === "replace") value.replace(pattern.regex, collect)
else value.replaceAll(pattern.regex, collect)
} else {
if (typeof pattern !== "string") {
throw new InterpreterRuntimeError(`String.${name} expects argument 1 to be a string.`, node)
}
if (name === "replace") value.replace(pattern, collect)
else value.replaceAll(pattern, collect)
}
return Effect.gen(function* () {
const output: Array<string> = []
let end = 0
for (const match of matches) {
output.push(
value.slice(end, match.offset),
coerceToString(boundedData(yield* apply(match.args), `String.${name} replacer result`)),
)
end = match.offset + match.match.length
}
output.push(value.slice(end))
return boundedData(output.join(""), `String.${name} result`)
})
}
// Runs a collection callback accepting a user function or supported builtin callable,
// mirroring the array-method callback contract.
private applyCollectionCallback(
callback: unknown,
name: string,
node: AstNode,
): (args: Array<unknown>) => Effect.Effect<unknown, unknown, R> {
if (!(callback instanceof CodeModeFunction) && !(callback instanceof CoercionFunction)) {
if (
!(callback instanceof CodeModeFunction) &&
!(callback instanceof CoercionFunction) &&
!(callback instanceof UriFunction)
) {
throw new InterpreterRuntimeError(`${name} expects a function callback.`, node)
}
return (callbackArgs) =>
callback instanceof CoercionFunction
? Effect.succeed(invokeCoercion(callback, callbackArgs, node))
: this.invokeFunction(callback, callbackArgs)
: callback instanceof UriFunction
? Effect.succeed(invokeUriFunction(callback, callbackArgs, node))
: this.invokeFunction(callback, callbackArgs)
}
private invokeMapMethod(
@ -3145,6 +3427,81 @@ class Interpreter<R> {
}
}
private invokeURLSearchParamsMethod(
target: SandboxURLSearchParams,
name: string,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> {
const arg = (index: number): string => uriArgument(args[index], `URLSearchParams.${name} argument ${index + 1}`)
const requireArgs = (count: number): void => {
if (args.length < count) {
throw new InterpreterRuntimeError(
`URLSearchParams.${name} requires ${count} argument${count === 1 ? "" : "s"}.`,
node,
).as("TypeError")
}
}
switch (name) {
case "append": {
requireArgs(2)
return Effect.sync(() => {
target.params.append(arg(0), arg(1))
return undefined
})
}
case "delete": {
requireArgs(1)
return Effect.sync(() => {
if (args[1] !== undefined) target.params.delete(arg(0), arg(1))
else target.params.delete(arg(0))
return undefined
})
}
case "get":
requireArgs(1)
return Effect.sync(() => target.params.get(arg(0)))
case "getAll":
requireArgs(1)
return Effect.sync(() => target.params.getAll(arg(0)))
case "has":
requireArgs(1)
return Effect.sync(() =>
args[1] !== undefined ? target.params.has(arg(0), arg(1)) : target.params.has(arg(0)),
)
case "set": {
requireArgs(2)
return Effect.sync(() => {
target.params.set(arg(0), arg(1))
return undefined
})
}
case "sort":
return Effect.sync(() => {
target.params.sort()
return undefined
})
case "keys":
return Effect.sync(() => Array.from(target.params.keys()))
case "values":
return Effect.sync(() => Array.from(target.params.values()))
case "entries":
return Effect.sync(() => Array.from(target.params.entries(), ([key, value]): Array<unknown> => [key, value]))
case "toString":
return Effect.sync(() => target.params.toString())
case "forEach": {
requireArgs(1)
const apply = this.applyCollectionCallback(args[0], "URLSearchParams.forEach", node)
return Effect.gen(function* () {
for (const [key, value] of Array.from(target.params.entries())) yield* apply([value, key, target])
return undefined
})
}
default:
throw new InterpreterRuntimeError(`URLSearchParams method '${name}' is not available in CodeMode.`, node)
}
}
private invokeArrayMethod(
target: Array<unknown>,
name: string,
@ -3254,17 +3611,23 @@ class Interpreter<R> {
}
const callback = args[0]
if (!(callback instanceof CodeModeFunction) && !(callback instanceof CoercionFunction)) {
if (
!(callback instanceof CodeModeFunction) &&
!(callback instanceof CoercionFunction) &&
!(callback instanceof UriFunction)
) {
throw new InterpreterRuntimeError(`Array.${name} expects a function callback.`, node)
}
const self = this
// Accept a user arrow function or a builtin coercion callable (Boolean/String/Number), so the
// idioms `filter(Boolean)` / `map(String)` / `map(Number)` work as in JS. Coercions are
// synchronous; only CodeModeFunctions can await tool calls.
// Accept a user function or supported builtin callable, so idioms such as
// `filter(Boolean)`, `map(String)`, and `map(encodeURIComponent)` work as in JS. Builtins
// are synchronous; only CodeModeFunctions can await tool calls.
const apply = (callbackArgs: Array<unknown>): Effect.Effect<unknown, unknown, R> =>
callback instanceof CoercionFunction
? Effect.succeed(invokeCoercion(callback, callbackArgs, node))
: self.invokeFunction(callback, callbackArgs)
: callback instanceof UriFunction
? Effect.succeed(invokeUriFunction(callback, callbackArgs, node))
: self.invokeFunction(callback, callbackArgs)
return Effect.gen(function* () {
// Iterate a snapshot taken at call time so a callback that mutates the array can't
// self-extend the loop - matching JS, where elements appended during iteration are not visited.
@ -3418,7 +3781,7 @@ class Interpreter<R> {
const spread = yield* self.evaluateExpression(getNode(property, "argument"))
// JS treats `{ ...null }` / `{ ...undefined }` as a no-op, so the common
// `{ ...maybeOpts, override }` merge works when the operand is absent. Sandbox values
// (Date/RegExp/Map/Set) have no own enumerable properties in JS, so they are no-ops too.
// have no own enumerable properties in JS, so they are no-ops too.
if (spread === null || spread === undefined || isSandboxValue(spread)) continue
if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) {
throw new InterpreterRuntimeError(
@ -3655,6 +4018,21 @@ class Interpreter<R> {
if (typeof key === "string" && setMethods.has(key)) return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined)
}
if (objectValue instanceof SandboxURL) {
if (key === "searchParams") {
return new ComputedValue(objectValue.searchParams)
}
if (typeof key === "string" && urlMethods.has(key)) return new IntrinsicReference(objectValue, key)
if (typeof key === "string" && urlProperties.has(key)) return { target: objectValue, key }
return new ComputedValue(undefined)
}
if (objectValue instanceof SandboxURLSearchParams) {
if (key === "size") return new ComputedValue(objectValue.params.size)
if (typeof key === "string" && urlSearchParamsMethods.has(key)) {
return new IntrinsicReference(objectValue, key)
}
return new ComputedValue(undefined)
}
// Any property access on a promise is a confused program (`p.then(...)`, `p.value`);
// reading `undefined` here would hide the missing await, so both paths get an explicit,
@ -3732,6 +4110,9 @@ class Interpreter<R> {
}
return reference.key === "length" ? reference.target.length : reference.target[Number(reference.key)]
}
if (reference.target instanceof SandboxURL) {
return (reference.target.url as unknown as Record<string, unknown>)[String(reference.key)]
}
return reference.target[String(reference.key)]
})
}
@ -3769,7 +4150,10 @@ class Interpreter<R> {
}
}
const key = Array.isArray(reference.target) ? Number(reference.key) : String(reference.key)
const current = (reference.target as Record<PropertyKey, unknown>)[key]
const current =
reference.target instanceof SandboxURL
? (reference.target.url as unknown as Record<string, unknown>)[key]
: (reference.target as Record<PropertyKey, unknown>)[key]
const { write, next, result } = yield* compute(current)
if (write) self.assignToReference(reference, key, next, node)
return result
@ -3809,6 +4193,20 @@ class Interpreter<R> {
target[index] = next
return
}
if (reference.target instanceof SandboxURL) {
const property = key as string
if (!urlWritableProperties.has(property)) {
throw new InterpreterRuntimeError(`URL.${property} is read-only.`, node).as("TypeError")
}
try {
const url = reference.target.url as unknown as Record<string, string>
url[property] = uriArgument(next, `URL.${property} value`)
return
} catch (error) {
if (error instanceof InterpreterRuntimeError || error instanceof ToolRuntimeError) throw error
throw new InterpreterRuntimeError(`URL.${property} received an invalid value.`, node).as("TypeError")
}
}
const target = reference.target as SafeObject
const objectKey = key as string
this.rejectCircularInsertion(target, next, "Object assignment result", node)

View file

@ -9,7 +9,15 @@ import {
outputTypeScript,
} from "./tool-schema.js"
import { isDefinition as isToolDefinition, type Definition } from "./tool.js"
import { SandboxDate, SandboxMap, SandboxPromise, SandboxRegExp, SandboxSet } from "./values.js"
import {
SandboxDate,
SandboxMap,
SandboxPromise,
SandboxRegExp,
SandboxSet,
SandboxURL,
SandboxURLSearchParams,
} from "./values.js"
const estimateTokens = (input: string) => Math.max(0, Math.round(input.length / 4))
@ -152,9 +160,9 @@ export const isBlockedMember = (name: string): boolean => blockedMemberNames.has
* Two modes share the walk:
* - **Boundary** (`preserveSandboxValues` false, the default): the host<->sandbox boundary -
* final results, tool-call arguments, `JSON.stringify`. Sandbox value types serialize
* exactly as JSON.stringify would: Date -> ISO string (invalid -> null), RegExp/Map/Set -> {}.
* exactly as JSON.stringify would: Date/URL -> strings, the remaining value types -> {}.
* - **Intra-sandbox checkpoint** (`preserveSandboxValues` true; see `boundedData` in
* codemode.ts): Date/RegExp/Map/Set instances pass through untouched (treated as leaves,
* codemode.ts): standard-library value instances pass through untouched (treated as leaves,
* contents not walked), so values flowing through `Object.*` helpers, coercion inputs, and
* other in-sandbox checkpoints stay fully usable (`.getTime()`, `.has()`, ...).
*
@ -208,7 +216,9 @@ const copyBounded = (
value instanceof SandboxDate ||
value instanceof SandboxRegExp ||
value instanceof SandboxMap ||
value instanceof SandboxSet
value instanceof SandboxSet ||
value instanceof SandboxURL ||
value instanceof SandboxURLSearchParams
) {
return value
}
@ -228,24 +238,30 @@ const copyBounded = (
for (const item of value.values()) wrapped.set.add(copyBounded(item, label, depth + 1, seen, true))
return wrapped
}
if (value instanceof URL) return new SandboxURL(new URL(value.href))
if (value instanceof URLSearchParams) return new SandboxURLSearchParams(new URLSearchParams(value))
}
// Sandbox value types (and their host counterparts, which a host tool may legitimately
// return) serialize exactly as JSON.stringify would at the data boundary: a Date is its
// toJSON() ISO string (invalid -> null), and RegExp/Map/Set have no JSON form beyond {}.
// return) serialize exactly as JSON.stringify would at the data boundary: Date/URL use
// toJSON(), while RegExp/Map/Set/URLSearchParams have no JSON form beyond {}.
if (value instanceof SandboxDate) {
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null
}
if (value instanceof Date) {
return Number.isFinite(value.getTime()) ? value.toISOString() : null
}
if (value instanceof SandboxURL) return value.url.href
if (value instanceof URL) return value.href
if (
value instanceof SandboxRegExp ||
value instanceof SandboxMap ||
value instanceof SandboxSet ||
value instanceof SandboxURLSearchParams ||
value instanceof RegExp ||
value instanceof Map ||
value instanceof Set
value instanceof Set ||
value instanceof URLSearchParams
) {
return Object.create(null) as SafeObject
}
@ -589,9 +605,9 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
"",
"## Language",
"",
"Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls.",
"Modules/imports, classes, generators, timers, fetch, eval, prototype access, arbitrary methods, and promise chaining are unavailable. Use Code Mode tools for external operations. Use await with try/catch.",
"Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.",
"Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.",
"Modules/imports, classes, generators, timers, fetch, eval, prototype access, unlisted methods, and promise chaining are unavailable. Use Code Mode tools for external operations. Use await with try/catch.",
"Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.",
]
const toolSection: Array<string> = [""]

View file

@ -27,8 +27,23 @@ export class SandboxSet {
readonly set = new Set<unknown>()
}
export const isSandboxValue = (value: unknown): value is SandboxDate | SandboxRegExp | SandboxMap | SandboxSet =>
export class SandboxURLSearchParams {
constructor(readonly params: URLSearchParams) {}
}
export class SandboxURL {
readonly searchParams: SandboxURLSearchParams
constructor(readonly url: URL) {
this.searchParams = new SandboxURLSearchParams(url.searchParams)
}
}
export const isSandboxValue = (
value: unknown,
): value is SandboxDate | SandboxRegExp | SandboxMap | SandboxSet | SandboxURL | SandboxURLSearchParams =>
value instanceof SandboxDate ||
value instanceof SandboxRegExp ||
value instanceof SandboxMap ||
value instanceof SandboxSet
value instanceof SandboxSet ||
value instanceof SandboxURL ||
value instanceof SandboxURLSearchParams

View file

@ -655,9 +655,11 @@ describe("CodeMode public contract", () => {
for (const missing of ["Modules/imports", "classes", "generators", "fetch", "promise chaining"]) {
expect(instructions).toContain(missing)
}
expect(instructions).toContain("URL, URLSearchParams, and URI encoding helpers")
expect(instructions).not.toContain("host globals")
expect(instructions).toContain("Use Code Mode tools for external operations")
expect(instructions).toContain(
"Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.",
"Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.",
)
})

View file

@ -1,12 +1,12 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Effect, Schema } from "effect"
import { CodeMode, Tool } from "../src/index.js"
// Standard-library value types: Date, RegExp, Map, Set. Programs use them as ordinary JS;
// intra-sandbox checkpoints (Object.* helpers, spread, coercion inputs) preserve the live
// values, while at the host boundary (final result, tool arguments, JSON.stringify) they
// serialize exactly as JSON.stringify would: Date -> ISO string (invalid -> null),
// RegExp/Map/Set -> {}.
// URL -> href, and RegExp/Map/Set/URLSearchParams -> {}.
const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const value = async (code: string) => {
const result = await run(code)
@ -149,6 +149,67 @@ describe("RegExp", () => {
expect(await value(`return "hi bob".replace(/b(o)b/, "[$1]")`)).toBe("hi [o]")
})
test("function replacers receive captures, offsets, input, and named groups", async () => {
expect(
await value(`
const seen = []
const output = "a1b22".replace(/(\\d)(\\d)?/g, (match, first, second, offset, input) => {
seen.push([match, first, second === undefined, offset, input])
return Number(match) * 2
})
return { output, seen }
`),
).toEqual({
output: "a2b44",
seen: [
["1", "1", true, 1, "a1b22"],
["22", "2", false, 3, "a1b22"],
],
})
expect(
await value(`
return "red-blue".replace(
/(?<left>[a-z]+)-(?<right>[a-z]+)/,
(match, left, right, offset, input, groups) => groups.right + ":" + groups.left,
)
`),
).toBe("blue:red")
})
test("function replacers support string searches, zero-length matches, and result coercion", async () => {
expect(await value(`return "banana".replace("na", (match, offset, input) => "[" + offset + "]")`)).toBe("ba[2]na")
expect(await value(`return "ab".replaceAll("", (match, offset) => offset)`)).toBe("0a1b2")
expect(await value(`return "😀".replaceAll(/(?:)/gu, (match, offset) => "[" + offset + "]")`)).toBe("[0]😀[2]")
expect(
await value(`return "123".replace(/\\d/g, (match) => match === "1" ? 7 : match === "2" ? null : { n: 3 })`),
).toBe("7null[object Object]")
})
test("function replacers can await effectful tool calls", async () => {
const decorate = Tool.make({
description: "Decorate a string",
input: Schema.String,
output: Schema.String,
run: (input) => Effect.succeed(`[${input}]`),
})
const result = await Effect.runPromise(
CodeMode.execute({
tools: { host: { decorate } },
code: `return "a1b22".replace(/\\d+/g, async (match) => await tools.host.decorate(match))`,
}),
)
expect(result.ok && result.value).toBe("a[1]b[22]")
const missingAwait = await Effect.runPromise(
CodeMode.execute({
tools: { host: { decorate } },
code: `return "a1".replace(/\\d/, (match) => tools.host.decorate(match))`,
}),
)
expect(!missingAwait.ok && missingAwait.error.kind).toBe("InvalidDataValue")
expect(!missingAwait.ok && missingAwait.error.message).toContain("un-awaited Promise")
})
test("replaceAll without the g flag is a catchable error", async () => {
expect(await value(`try { "a".replaceAll(/a/, "b"); return "no" } catch { return "caught" }`)).toBe("caught")
})
@ -208,6 +269,165 @@ describe("RegExp", () => {
})
})
describe("URL and URI helpers", () => {
test("encodes and decodes complete URIs and URI components", async () => {
expect(
await value(`
return [
encodeURI("https://example.test/a b?q=a/b"),
encodeURIComponent("a b/c?"),
decodeURI("https://example.test/a%20b?q=a/b"),
decodeURIComponent("a%20b%2Fc%3F"),
["a b", "c/d"].map(encodeURIComponent),
]
`),
).toEqual([
"https://example.test/a%20b?q=a/b",
"a%20b%2Fc%3F",
"https://example.test/a b?q=a/b",
"a b/c?",
["a%20b", "c%2Fd"],
])
expect(
await value(`try { decodeURIComponent("%zz"); return false } catch (error) { return error instanceof URIError }`),
).toBe(true)
})
test("resolves and mutates URLs with linked search parameters", async () => {
expect(
await value(`
const url = new URL("../users?id=old#top", "https://user:pass@example.com:8443/api/v1/")
url.pathname = "/items/a b"
url.searchParams.set("id", "a b")
url.searchParams.append("tag", "x/y")
url.hash = "part 1"
return {
href: url.href,
origin: url.origin,
host: url.host,
pathname: url.pathname,
search: url.search,
id: url.searchParams.get("id"),
string: String(url),
json: url.toJSON(),
instances: [
url instanceof URL,
url.searchParams instanceof URLSearchParams,
url.searchParams === url.searchParams,
],
}
`),
).toEqual({
href: "https://user:pass@example.com:8443/items/a%20b?id=a+b&tag=x%2Fy#part%201",
origin: "https://example.com:8443",
host: "example.com:8443",
pathname: "/items/a%20b",
search: "?id=a+b&tag=x%2Fy",
id: "a b",
string: "https://user:pass@example.com:8443/items/a%20b?id=a+b&tag=x%2Fy#part%201",
json: "https://user:pass@example.com:8443/items/a%20b?id=a+b&tag=x%2Fy#part%201",
instances: [true, true, true],
})
})
test("URLSearchParams supports records, pairs, mutation, callbacks, and materialization", async () => {
expect(
await value(`
const params = new URLSearchParams([["tag", "b"], ["tag", "a"], ["q", "a b"]])
const seen = []
params.forEach((value, key) => seen.push(key + "=" + value))
params.delete("tag", "b")
params.append("tag", "c")
params.sort()
return {
text: params.toString(),
size: params.size,
tags: params.getAll("tag"),
has: params.has("tag", "c"),
entries: Array.from(params),
object: Object.fromEntries(params),
record: new URLSearchParams({ page: 2, filter: "open" }).toString(),
seen,
}
`),
).toEqual({
text: "q=a+b&tag=a&tag=c",
size: 3,
tags: ["a", "c"],
has: true,
entries: [
["q", "a b"],
["tag", "a"],
["tag", "c"],
],
object: { q: "a b", tag: "c" },
record: "page=2&filter=open",
seen: ["tag=b", "tag=a", "q=a b"],
})
})
test("URL parsing failures are catchable and values use native JSON forms", async () => {
expect(
await value(`
const parsed = URL.parse("/users", "https://example.test/api/")
let invalidIsTypeError = false
try { new URL("not relative without a base") } catch (error) { invalidIsTypeError = error instanceof TypeError }
return {
canParse: URL.canParse("/users", "https://example.test/api/"),
cannotParse: URL.canParse("not relative without a base"),
parsed: parsed.href,
invalidIsTypeError,
boundary: [new URL("https://example.test/a"), new URLSearchParams("q=one")],
json: JSON.stringify({ url: new URL("https://example.test/a"), params: new URLSearchParams("q=one") }),
}
`),
).toEqual({
canParse: true,
cannotParse: false,
parsed: "https://example.test/users",
invalidIsTypeError: true,
boundary: ["https://example.test/a", {}],
json: '{"url":"https://example.test/a","params":{}}',
})
})
test("distinguishes omitted URL arguments from explicit undefined", async () => {
expect(
await value(`
function throwsTypeError(run) {
try { run(); return false } catch (error) { return error instanceof TypeError }
}
const params = new URLSearchParams()
const required = [
() => params.append(),
() => params.delete(),
() => params.get(),
() => params.getAll(),
() => params.has(),
() => params.set(),
() => params.forEach(),
].map(throwsTypeError)
params.append(undefined, undefined)
return {
construct: throwsTypeError(() => new URL()),
canParse: throwsTypeError(() => URL.canParse()),
parse: throwsTypeError(() => URL.parse()),
explicitUndefined: new URL(undefined, "https://example.test/base/").href,
params: params.toString(),
required,
}
`),
).toEqual({
construct: true,
canParse: true,
parse: true,
explicitUndefined: "https://example.test/base/undefined",
params: "undefined=undefined",
required: [true, true, true, true, true, true, true],
})
})
})
describe("Map", () => {
test("get/set/has/size with chaining", async () => {
expect(