Compare commits

...
Author SHA1 Message Date
Aiden Cline 97a47cc022 feat(codemode): add structuredClone 2026-09-10 01:26:45 -05:00
5 changed files with 309 additions and 4 deletions
+3
View File
@@ -369,6 +369,9 @@ ultimate source of truth.
- [x] `atob` and `btoa` with forgiving-base64 decoding and WebIDL string conversion; invalid input throws an Error
named `InvalidCharacterError`, since there is no `DOMException`.
- [x] `crypto.randomUUID()`.
- [x] `structuredClone` over the data model: objects, arrays with holes, Date, RegExp (`lastIndex` reset), Map, Set,
URL, URLSearchParams, and Errors (name, message, and cause only); shared references stay shared within one
clone; functions, promises, and tool references throw an Error named `DataCloneError`.
- [ ] `crypto.getRandomValues` and `crypto.subtle`, `TextEncoder`/`TextDecoder`, and `Blob`: these need a binary
value type, which the JSON-like data model does not have yet.
+1 -1
View File
@@ -138,6 +138,6 @@ const copy = (value: unknown, label: string, mode: Mode, depth: number, seen: Se
// Own data property regardless of the target's prototype, so a "__proto__" key on a host object or
// array never reaches the Object.prototype setter.
const define = (target: object, key: string, value: unknown): void => {
export const define = (target: object, key: string, value: unknown): void => {
Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true })
}
+2 -1
View File
@@ -11,7 +11,7 @@ import { regexpGlobal } from "../stdlib/regexp.js"
import { stringGlobal } from "../stdlib/string.js"
import { uriGlobal, urlGlobal, urlSearchParamsGlobal } from "../stdlib/url.js"
import { coercion, errorConstructors } from "../stdlib/value.js"
import { atobGlobal, btoaGlobal, cryptoGlobal } from "../stdlib/web.js"
import { atobGlobal, btoaGlobal, cryptoGlobal, structuredCloneGlobal } from "../stdlib/web.js"
import { ToolReference } from "../tool-runtime.js"
import { errorGlobal } from "./errors.js"
import { HostFunction } from "./host.js"
@@ -75,5 +75,6 @@ export const globals = <R>(host: Host<R>): ReadonlyArray<readonly [string, unkno
["atob", atobGlobal],
["btoa", btoaGlobal],
["crypto", cryptoGlobal],
["structuredClone", structuredCloneGlobal],
...[...errorConstructors].map((name) => [name, errorGlobal(name, host.runner)] as const),
]
+58 -2
View File
@@ -1,6 +1,9 @@
import { define, type SafeObject } from "../data.js"
import { HostNamespace, sync } from "../interpreter/host.js"
import { InterpreterRuntimeError } from "../interpreter/model.js"
import { coerceToString } from "./value.js"
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { describeValue, isRuntimeReference } from "../interpreter/references.js"
import { Values } from "../values.js"
import { coerceToString, createErrorValue, errorBrandName } from "./value.js"
// WebIDL DOMString conversion: a missing argument is a TypeError, anything else stringifies.
const base64 = (name: "atob" | "btoa") =>
@@ -22,3 +25,56 @@ export const btoaGlobal = base64("btoa")
export const cryptoGlobal = new HostNamespace("crypto", {
randomUUID: sync("crypto.randomUUID", () => crypto.randomUUID()),
})
// HTML structured clone over the data model: wrappers are copied, shared references stay shared within
// one clone, Errors keep only name, message, and cause, and RegExp lastIndex resets like the spec.
const cloneValue = (value: unknown, seen: Map<object, unknown>, node: AstNode): unknown => {
if (value === null || typeof value !== "object") return value
if (value instanceof Values.Promise || (isRuntimeReference(value) && !Values.isValue(value))) {
throw new InterpreterRuntimeError(`${describeValue(value)} could not be cloned.`, node).as("DataCloneError")
}
const existing = seen.get(value)
if (existing !== undefined) return existing
const remember = <T extends object>(copied: T): T => {
seen.set(value, copied)
return copied
}
if (value instanceof Values.Date) return remember(new Values.Date(value.time))
if (value instanceof Values.RegExp) return remember(new Values.RegExp(value.regex.source, value.regex.flags))
if (value instanceof Values.URL) return remember(new Values.URL(new URL(value.url.href)))
if (value instanceof Values.URLSearchParams) {
return remember(new Values.URLSearchParams(new URLSearchParams(value.params)))
}
if (value instanceof Values.Map) {
const copied = remember(new Values.Map())
for (const [key, item] of value.map) copied.map.set(cloneValue(key, seen, node), cloneValue(item, seen, node))
return copied
}
if (value instanceof Values.Set) {
const copied = remember(new Values.Set())
for (const item of value.set) copied.set.add(cloneValue(item, seen, node))
return copied
}
if (Array.isArray(value)) {
const copied = remember(new Array<unknown>(value.length))
for (const [key, item] of Object.entries(value)) define(copied, key, cloneValue(item, seen, node))
return copied
}
const brand = errorBrandName(value)
if (brand !== undefined) {
const error = value as { name?: unknown; message?: unknown; cause?: unknown }
const copied = remember(createErrorValue(brand, coerceToString(error.message)))
if (Object.hasOwn(value, "cause")) copied.cause = cloneValue(error.cause, seen, node)
return copied
}
const copied = remember(Object.create(null) as SafeObject)
for (const [key, item] of Object.entries(value)) define(copied, key, cloneValue(item, seen, node))
return copied
}
export const structuredCloneGlobal = sync("structuredClone", (args, node) => {
if (args.length === 0) {
throw new InterpreterRuntimeError("structuredClone requires a value to clone.", node).as("TypeError")
}
return cloneValue(args[0], new Map(), node)
})
@@ -0,0 +1,245 @@
/**
* Portions adapted from web-platform-tests at revision 863077959ca8c1a7ceecfbe2534b75d2527b9013:
* - html/webappapis/structured-clone/structured-clone-battery-of-tests.js
*
* Copyright © web-platform-tests contributors. Governed by the 3-Clause BSD license in LICENSE.wpt.
*
* The battery's `check(description, input, compare)` shape and its `compare_*` helpers are kept, run
* inside the interpreter. Ported: primitives, Array/Object of primitives, Date, RegExp, Error, sparse
* arrays, identical (shared) property values, and the index-property-plus-length object. Not portable:
* boxed primitives, BigInt, Blob/File/ImageData/ArrayBuffer/typed arrays (no binary values), circular
* references (rejected at insertion here), property descriptors and prototype properties (no
* defineProperty or prototypes), and the throwing-getter case (no getters). `assert_throws_dom` for
* `DataCloneError` becomes an `error.name` check.
*/
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
const value = async (code: string) => {
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
// The WPT harness, minus async: assertions push a failure description instead of throwing so one run
// reports every failing check.
const harness = `
const failures = []
const assert_equals = (a, b, m) => { if (!Object.is(a, b) && !(a !== a && b !== b)) failures.push((m ?? "") + ": " + String(a) + " !== " + String(b)) }
const assert_not_equals = (a, b, m) => { if (a === b) failures.push((m ?? "") + ": unexpectedly identical") }
const assert_true = (a, m) => { if (a !== true) failures.push((m ?? "") + ": not true") }
const assert_false = (a, m) => { if (a !== false) failures.push((m ?? "") + ": not false") }
let current = ""
function check(description, input, callback) {
current = description
const newInput = typeof input === "function" ? input() : input
const copy = structuredClone(newInput)
const before = failures.length
callback(copy, newInput)
for (let i = before; i < failures.length; i++) failures[i] = description + " — " + failures[i]
}
function compare_primitive(actual, input) { assert_equals(actual, input) }
function compare_Array(callback) {
return function (actual, input) {
assert_true(Array.isArray(actual), "instanceof Array")
assert_not_equals(actual, input)
assert_equals(actual.length, input.length, "length")
callback(actual, input)
}
}
function compare_Object(callback) {
return function (actual, input) {
assert_true(actual instanceof Object, "instanceof Object")
assert_false(Array.isArray(actual), "instanceof Array")
assert_not_equals(actual, input)
callback(actual, input)
}
}
function enumerate_props(compare_func) {
return function (actual, input) { for (const x in input) compare_func(actual[x], input[x]) }
}
`
describe("structuredClone WPT battery", () => {
test("primitives, and arrays and objects of primitives", async () => {
expect(
await value(`
${harness}
check('primitive undefined', undefined, compare_primitive)
check('primitive null', null, compare_primitive)
check('primitive true', true, compare_primitive)
check('primitive false', false, compare_primitive)
check('primitive string, empty string', '', compare_primitive)
check('primitive string, lone high surrogate', '\\uD800', compare_primitive)
check('primitive string, lone low surrogate', '\\uDC00', compare_primitive)
check('primitive string, NUL', '\\u0000', compare_primitive)
check('primitive string, astral character', '\\uDBFF\\uDFFD', compare_primitive)
check('primitive number, 0.2', 0.2, compare_primitive)
check('primitive number, 0', 0, compare_primitive)
check('primitive number, -0', -0, compare_primitive)
check('primitive number, NaN', NaN, compare_primitive)
check('primitive number, Infinity', Infinity, compare_primitive)
check('primitive number, -Infinity', -Infinity, compare_primitive)
check('primitive number, 9007199254740992', 9007199254740992, compare_primitive)
check('primitive number, -9007199254740992', -9007199254740992, compare_primitive)
check('primitive number, 9007199254740994', 9007199254740994, compare_primitive)
check('primitive number, -9007199254740994', -9007199254740994, compare_primitive)
check('Array primitives', [undefined, null, true, false, '', '\\uD800', '\\uDC00', '\\u0000', '\\uDBFF\\uDFFD',
0.2, 0, -0, NaN, Infinity, -Infinity, 9007199254740992, -9007199254740992, 9007199254740994, -9007199254740994],
compare_Array(enumerate_props(compare_primitive)))
check('Object primitives', { 'undefined': undefined, 'null': null, 'true': true, 'false': false, 'empty': '',
'high surrogate': '\\uD800', 'low surrogate': '\\uDC00', 'nul': '\\u0000', 'astral': '\\uDBFF\\uDFFD',
'0.2': 0.2, '0': 0, '-0': -0, 'NaN': NaN, 'Infinity': Infinity, '-Infinity': -Infinity,
'9007199254740992': 9007199254740992, '-9007199254740992': -9007199254740992,
'9007199254740994': 9007199254740994, '-9007199254740994': -9007199254740994 },
compare_Object(enumerate_props(compare_primitive)))
return failures
`),
).toEqual([])
})
test("Date", async () => {
expect(
await value(`
${harness}
function compare_Date(actual, input) {
assert_true(actual instanceof Date, 'instanceof Date')
assert_equals(Number(actual), Number(input), 'converted to primitive')
assert_not_equals(actual, input)
}
check('Date 0', new Date(0), compare_Date)
check('Date -0', new Date(-0), compare_Date)
check('Date -8.64e15', new Date(-8.64e15), compare_Date)
check('Date 8.64e15', new Date(8.64e15), compare_Date)
check('Array Date objects', [new Date(0), new Date(-0), new Date(-8.64e15), new Date(8.64e15)],
compare_Array(enumerate_props(compare_Date)))
check('Object Date objects', { '0': new Date(0), '-0': new Date(-0), '-8.64e15': new Date(-8.64e15), '8.64e15': new Date(8.64e15) },
compare_Object(enumerate_props(compare_Date)))
return failures
`),
).toEqual([])
})
test("RegExp: flags copied, lastIndex reset, source escaped", async () => {
expect(
await value(`
${harness}
function compare_RegExp(expected_source) {
return function (actual, input) {
assert_true(actual instanceof RegExp, 'instanceof RegExp')
assert_equals(actual.global, input.global, 'global')
assert_equals(actual.ignoreCase, input.ignoreCase, 'ignoreCase')
assert_equals(actual.multiline, input.multiline, 'multiline')
assert_equals(actual.source, expected_source, 'source')
assert_equals(actual.sticky, input.sticky, 'sticky')
assert_equals(actual.unicode, input.unicode, 'unicode')
assert_equals(actual.lastIndex, 0, 'lastIndex')
assert_not_equals(actual, input)
}
}
function func_RegExp_flags_lastIndex() {
const r = /foo/gim
r.lastIndex = 2
return r
}
function func_RegExp_sticky() { return new RegExp('foo', 'y') }
function func_RegExp_unicode() { return new RegExp('foo', 'u') }
check('RegExp flags and lastIndex', func_RegExp_flags_lastIndex, compare_RegExp('foo'))
check('RegExp sticky flag', func_RegExp_sticky, compare_RegExp('foo'))
check('RegExp unicode flag', func_RegExp_unicode, compare_RegExp('foo'))
check('RegExp empty', new RegExp(''), compare_RegExp('(?:)'))
check('RegExp slash', new RegExp('/'), compare_RegExp('\\\\/'))
check('RegExp new line', new RegExp('\\n'), compare_RegExp('\\\\n'))
check('Array RegExp object, RegExp flags and lastIndex', [func_RegExp_flags_lastIndex()], compare_Array(enumerate_props(compare_RegExp('foo'))))
check('Array RegExp object, RegExp sticky flag', function () { return [func_RegExp_sticky()] }, compare_Array(enumerate_props(compare_RegExp('foo'))))
check('Array RegExp object, RegExp unicode flag', function () { return [func_RegExp_unicode()] }, compare_Array(enumerate_props(compare_RegExp('foo'))))
check('Array RegExp object, RegExp empty', [new RegExp('')], compare_Array(enumerate_props(compare_RegExp('(?:)'))))
check('Array RegExp object, RegExp slash', [new RegExp('/')], compare_Array(enumerate_props(compare_RegExp('\\\\/'))))
check('Array RegExp object, RegExp new line', [new RegExp('\\n')], compare_Array(enumerate_props(compare_RegExp('\\\\n'))))
check('Object RegExp object, RegExp flags and lastIndex', { 'x': func_RegExp_flags_lastIndex() }, compare_Object(enumerate_props(compare_RegExp('foo'))))
check('Object RegExp object, RegExp sticky flag', function () { return { 'x': func_RegExp_sticky() } }, compare_Object(enumerate_props(compare_RegExp('foo'))))
check('Object RegExp object, RegExp unicode flag', function () { return { 'x': func_RegExp_unicode() } }, compare_Object(enumerate_props(compare_RegExp('foo'))))
check('Object RegExp object, RegExp empty', { 'x': new RegExp('') }, compare_Object(enumerate_props(compare_RegExp('(?:)'))))
check('Object RegExp object, RegExp slash', { 'x': new RegExp('/') }, compare_Object(enumerate_props(compare_RegExp('\\\\/'))))
check('Object RegExp object, RegExp new line', { 'x': new RegExp('\\n') }, compare_Object(enumerate_props(compare_RegExp('\\\\n'))))
return failures
`),
).toEqual([])
})
test("Error: name and message kept, custom properties dropped", async () => {
expect(
await value(`
${harness}
function compare_Error(actual, input) {
assert_true(actual instanceof Error, "Checking instanceof")
assert_equals(actual.name, input.name, "Checking name")
assert_equals(Object.hasOwn(actual, "message"), Object.hasOwn(input, "message"), "Checking message existence")
assert_equals(actual.message, input.message, "Checking message")
assert_equals(actual.foo, undefined, "Checking for absence of custom property")
}
check('Empty Error object', new Error(), compare_Error)
for (const constructor of [Error, RangeError, ReferenceError, SyntaxError, TypeError, URIError]) {
check(constructor.name, () => {
const error = new constructor("Error message here")
error.foo = "testing"
return error
}, compare_Error)
}
return failures
`),
).toEqual([])
})
test("sparse arrays, index-property objects, and identical property values", async () => {
expect(
await value(`
${harness}
check('Array sparse', new Array(10), compare_Array(enumerate_props(compare_primitive)))
check('Object with index property and length', { '0': 'foo', 'length': 1 }, compare_Object(enumerate_props(compare_primitive)))
function check_identical_property_values(prop1, prop2) {
return function (actual) { assert_equals(actual[prop1], actual[prop2]) }
}
check('Array with identical property values', function () {
const obj = {}
return [obj, obj]
}, compare_Array(check_identical_property_values('0', '1')))
check('Object with identical property values', function () {
const obj = {}
return { 'x': obj, 'y': obj }
}, compare_Object(check_identical_property_values('x', 'y')))
return failures
`),
).toEqual([])
})
})
describe("structuredClone beyond the WPT battery", () => {
test("Map, Set, URL, and URLSearchParams are copied, with shared references preserved across containers", async () => {
expect(
await value(`
const shared = { n: 1 }
const input = { m: new Map([[shared, shared]]), s: new Set([shared]), u: new URL("https://a.b/c?d=1") }
const copy = structuredClone(input)
const [[key, item]] = [...copy.m]
copy.u.searchParams.set("d", "2")
return [
copy.m !== input.m, key !== shared, key === item, key === [...copy.s][0],
copy.u !== input.u, input.u.href, copy.u.href,
]
`),
).toEqual([true, true, true, true, true, "https://a.b/c?d=1", "https://a.b/c?d=2"])
})
test("functions, promises, and tool references throw DataCloneError", async () => {
expect(
await value(`
return [() => 1, Promise.resolve(1), tools, Math, { nested: [() => 1] }].map((input) => {
try { structuredClone(input); return "cloned" } catch (error) { return error.name }
})
`),
).toEqual(Array(5).fill("DataCloneError"))
expect(await value(`try { structuredClone() } catch (error) { return error.name }`)).toBe("TypeError")
})
})