From c38a75bcedcd867647bb54d475a9575136381880 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Sat, 12 Sep 2026 12:57:56 +0000 Subject: [PATCH 01/12] refactor(std): share structured value transport with V8 --- .../node/24.x.x/internal/structured-value.ts | 48 ++++ .../internal/structured-value/decode.ts | 110 +++++++++ .../internal/structured-value/encode.ts | 202 +++++++++++++++ .../24.x.x/internal/structured-value/types.ts | 87 +++++++ .../0.2.x/node/24.x.x/worker-threads/codec.ts | 232 +----------------- 5 files changed, 449 insertions(+), 230 deletions(-) create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/structured-value.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/structured-value/decode.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/structured-value/encode.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/structured-value/types.ts diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/structured-value.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/structured-value.ts new file mode 100644 index 000000000..9ef49cd99 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/structured-value.ts @@ -0,0 +1,48 @@ +/** + * Shared bounded graph transport extracted from worker-threads/codec.ts. + * Worker defaults preserve its clone-mark policy and Uint8Array treatment of Buffer. + * V8 opts into Buffer branding and persistent identities between native serdes calls. + * The graph is transport only; it is never returned as a V8 serialization buffer. + */ +import { createEncoder } from "./structured-value/encode.js"; +import { objectLike } from "./structured-value/types.js"; +import type { CodecOptions, MessageCodec } from "./structured-value/types.js"; + +export { decodeMessage } from "./structured-value/decode.js"; + +export type { CodecOptions, MessageCodec, DecodeSession } from "./structured-value/types.js"; + +export function createMessageCodec(options: CodecOptions = {}): MessageCodec { + const uncloneable = new WeakSet(); + const untransferable = new WeakSet(); + + return { + encode: createEncoder(options, uncloneable), + + markAsUncloneable(value: unknown): void { + // Node's clone flag applies to ordinary objects, not built-in value serializers. + if ( + objectLike(value) && + !Array.isArray(value) && + !ArrayBuffer.isView(value) && + !(value instanceof ArrayBuffer) && + !(value instanceof Map) && + !(value instanceof Set) && + !(value instanceof Date) && + !(value instanceof RegExp) + ) { + uncloneable.add(value); + } + }, + + markAsUntransferable(value: unknown): void { + if (objectLike(value)) { + untransferable.add(value); + } + }, + + isMarkedAsUntransferable(value: unknown): boolean { + return objectLike(value) && untransferable.has(value); + }, + }; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/structured-value/decode.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/structured-value/decode.ts new file mode 100644 index 000000000..f8e4a458a --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/structured-value/decode.ts @@ -0,0 +1,110 @@ +import { Buffer } from "node:buffer"; +import { viewConstructors } from "./types.js"; +import type { EncodedValue, Graph, DecodeSession } from "./types.js"; + +/** Decode only the private graph representation emitted by the paired adapter. */ +export function decodeMessage(source: string, session?: DecodeSession): unknown { + const graph: EncodedValue[] | Graph = JSON.parse(source); + const nodes = Array.isArray(graph) ? graph : graph.nodes; + const root = Array.isArray(graph) ? 0 : graph.root; + const values = session?.values ?? []; + const start = values.length; + + for (let index = start; index < nodes.length; index++) { + values.push(allocate(nodes[index])); + } + + // Buffers must exist before constructing views; object references are filled last. + for (let index = start; index < nodes.length; index++) { + const node = nodes[index]; + + if (node.type === "view") { + values[index] = restoreView(node, values); + } + } + + for (let index = start; index < nodes.length; index++) { + fillReferences(nodes[index], values[index], values); + } + + return values[root]; +} + +function allocate(node: EncodedValue): unknown { + switch (node.type) { + case "value": + return node.value; + + case "undefined": + case "view": + return undefined; + + case "number": + return Number(node.value); + + case "bigint": + return BigInt(node.value); + + case "date": + return new Date(Number(node.value)); + + case "array": + return new Array(node.length); + + case "object": + return {}; + + case "map": + return new Map(); + + case "set": + return new Set(); + + case "buffer": + return Uint8Array.from(node.bytes).buffer; + + case "node-buffer": + return Buffer.from(node.bytes); + + case "regexp": + return new RegExp(node.source, node.flags); + } +} + +function restoreView( + node: Extract, + values: unknown[], +): ArrayBufferView { + // The paired encoder references an ArrayBuffer node, allocated in the first pass. + const buffer = values[node.buffer] as ArrayBuffer; + + if (node.name === "DataView") { + return new DataView(buffer, node.offset, node.length); + } + + const Constructor = viewConstructors[node.name as keyof typeof viewConstructors]; + + return new Constructor(buffer, node.offset, node.length / Constructor.BYTES_PER_ELEMENT); +} + +function fillReferences(node: EncodedValue, target: unknown, values: unknown[]): void { + if (node.type === "object" || node.type === "array") { + for (const [key, reference] of node.entries) { + Object.defineProperty(target, key, { + value: values[reference], + enumerable: true, + configurable: true, + writable: true, + }); + } + } else if (node.type === "map") { + // The allocation pass creates the collection corresponding to this node tag. + for (const [key, value] of node.entries) { + (target as Map).set(values[key], values[value]); + } + } else if (node.type === "set") { + for (const value of node.entries) { + (target as Set).add(values[value]); + } + } +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/structured-value/encode.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/structured-value/encode.ts new file mode 100644 index 000000000..3b8a47032 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/structured-value/encode.ts @@ -0,0 +1,202 @@ +import { Buffer } from "node:buffer"; +import { DOMException } from "../../errors.js"; +import { unsupportedNodeApi } from "../../errors/core.js"; +import { objectLike, viewConstructors } from "./types.js"; +import type { CodecOptions, EncodedValue } from "./types.js"; + +type Visit = (value: unknown) => number; + +type Unsupported = (detail: string) => never; + +/** Reuse the worker transport, adding persistent IDs only for native V8 sessions. */ +export function createEncoder( + options: CodecOptions, + uncloneable: WeakSet, +): (value: unknown) => string { + let nodes: EncodedValue[] = []; + let seen = new Map(); + + const unsupported: Unsupported = (detail) => { + throw unsupportedNodeApi(options.api ?? "worker_threads structured messages", detail); + }; + + return function encode(value: unknown): string { + if (!options.persistent) { + nodes = []; + seen = new Map(); + } + + const checkpoint = nodes.length; + const added: object[] = []; + + function visit(value: unknown): number { + if (objectLike(value)) { + if (uncloneable.has(value)) { + throw new DOMException("Cannot clone object marked as uncloneable", "DataCloneError"); + } + + const previous = seen.get(value); + + if (previous !== undefined) { + return previous; + } + } + + const id = nodes.length; + + // Register before walking children, so aliases and cycles resolve to this node. + nodes.push({ type: "undefined" }); + + if (objectLike(value)) { + seen.set(value, id); + added.push(value); + } + + nodes[id] = encodeValue(value, visit, options, unsupported); + + return id; + } + + try { + const root = visit(value); + + return JSON.stringify(options.persistent ? { root, nodes } : nodes); + } catch (error) { + // Failed writes must not leave dangling IDs in the next session message. + nodes.length = checkpoint; + + for (const object of added) { + seen.delete(object); + } + + throw error; + } finally { + if (!options.persistent) { + // Worker messages must not retain their last graph between sends. + nodes = []; + seen.clear(); + } + } + }; +} + +function encodeValue( + value: unknown, + visit: Visit, + options: CodecOptions, + unsupported: Unsupported, +): EncodedValue { + if (value === undefined) { + return { type: "undefined" }; + } + + if (value === null || typeof value === "string" || typeof value === "boolean") { + return { type: "value", value }; + } + + if (typeof value === "number") { + return { type: "number", value: Object.is(value, -0) ? "-0" : String(value) }; + } + + if (typeof value === "bigint") { + return { type: "bigint", value: String(value) }; + } + + if (typeof value !== "object") { + throw ( + options.cloneError?.("Value cannot be cloned") ?? + new DOMException("Value cannot be cloned", "DataCloneError") + ); + } + + return encodeObject(value, visit, options, unsupported); +} + +function encodeObject( + value: object, + visit: Visit, + options: CodecOptions, + unsupported: Unsupported, +): EncodedValue { + if (value instanceof Map) { + return { + type: "map", + entries: [...Map.prototype.entries.call(value)].map(([key, item]) => [ + visit(key), + visit(item), + ]), + }; + } + + if (value instanceof Set) { + return { type: "set", entries: [...Set.prototype.values.call(value)].map(visit) }; + } + + if (value instanceof Date) { + return { type: "date", value: String(Date.prototype.getTime.call(value)) }; + } + + if (value instanceof RegExp) { + return { type: "regexp", source: value.source, flags: value.flags }; + } + + if (options.preserveBuffers && Buffer.isBuffer(value)) { + // Native V8's default serializer copies only a Buffer's visible bytes. + return { type: "node-buffer", bytes: [...value] }; + } + + if (ArrayBuffer.isView(value)) { + return encodeView(value, visit, unsupported); + } + + if (value instanceof ArrayBuffer) { + return { type: "buffer", bytes: [...new Uint8Array(value)] }; + } + + return encodeRecord(value, visit, unsupported); +} + +function encodeView(value: ArrayBufferView, visit: Visit, unsupported: Unsupported): EncodedValue { + // Worker messages clone Buffer as Uint8Array; V8 opts into the separate branch above. + const name = + value instanceof DataView + ? "DataView" + : Object.entries(viewConstructors).find(([, ctor]) => value instanceof ctor)?.[0]; + + if (!name) { + return unsupported("Unknown typed array view"); + } + + return { + type: "view", + name, + buffer: visit(value.buffer), + offset: value.byteOffset, + length: value.byteLength, + }; +} + +function encodeRecord(value: object, visit: Visit, unsupported: Unsupported): EncodedValue { + const array = Array.isArray(value); + const prototype = Object.getPrototypeOf(value); + + if (!array && prototype !== Object.prototype && prototype !== null) { + return unsupported( + "Only plain records, arrays, Map, Set, Date, RegExp, ArrayBuffer and typed views can cross this boundary", + ); + } + + const entries: [string, number][] = []; + + for (const key of Object.keys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key)!; + + if (!("value" in descriptor)) { + return unsupported("Accessor properties are not supported"); + } + + entries.push([key, visit(descriptor.value)]); + } + + return array ? { type: "array", length: value.length, entries } : { type: "object", entries }; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/structured-value/types.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/structured-value/types.ts new file mode 100644 index 000000000..18ec7f336 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/internal/structured-value/types.ts @@ -0,0 +1,87 @@ +export type EncodedValue = + | { + type: "value"; + value: string | boolean | null; + } + | { type: "undefined" } + | { + type: "number" | "bigint" | "date"; + value: string; + } + | { + type: "array"; + length: number; + entries: [string, number][]; + } + | { + type: "object"; + entries: [string, number][]; + } + | { + type: "map"; + entries: [number, number][]; + } + | { + type: "set"; + entries: number[]; + } + | { + type: "buffer" | "node-buffer"; + bytes: number[]; + } + | { + type: "regexp"; + source: string; + flags: string; + } + | { + type: "view"; + name: string; + buffer: number; + offset: number; + length: number; + }; + +export const viewConstructors = { + Int8Array, + Uint8Array, + Uint8ClampedArray, + Int16Array, + Uint16Array, + Int32Array, + Uint32Array, + Float32Array, + Float64Array, + BigInt64Array, + BigUint64Array, +}; + +export interface CodecOptions { + api?: string; + preserveBuffers?: boolean; + persistent?: boolean; + cloneError?: (message: string) => Error; +} + +export interface MessageCodec { + encode(value: unknown): string; + + markAsUncloneable(value: unknown): void; + + markAsUntransferable(value: unknown): void; + + isMarkedAsUntransferable(value: unknown): boolean; +} + +export interface DecodeSession { + values: unknown[]; +} + +export interface Graph { + root: number; + nodes: EncodedValue[]; +} + +export function objectLike(value: unknown): value is object { + return (typeof value === "object" && value !== null) || typeof value === "function"; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/worker-threads/codec.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/worker-threads/codec.ts index 79385642e..46f20281f 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/worker-threads/codec.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/worker-threads/codec.ts @@ -1,230 +1,2 @@ -import { DOMException } from "../errors.js"; -import { unsupportedNodeApi } from "../errors/core.js"; - -/** - * A deliberately bounded structured-value transport. A graph, rather than raw - * JSON, preserves undefined, bigint, cycles, aliases, Map, Set and special numbers. - * Native handles, shared memory, accessors and custom prototypes are refused. - * No user toJSON hook runs and no transfer silently becomes a copy. - */ -type EncodedValue = - | { type: "value"; value: string | boolean | null } - | { type: "undefined" } - | { type: "number" | "bigint" | "date"; value: string } - | { type: "array"; length: number; entries: [string, number][] } - | { type: "object"; entries: [string, number][] } - | { type: "map"; entries: [number, number][] } - | { type: "set"; entries: number[] } - | { type: "buffer"; bytes: number[] } - | { type: "regexp"; source: string; flags: string } - | { type: "view"; name: string; buffer: number; offset: number; length: number }; - -const viewConstructors = { - Int8Array, - Uint8Array, - Uint8ClampedArray, - Int16Array, - Uint16Array, - Int32Array, - Uint32Array, - Float32Array, - Float64Array, - BigInt64Array, - BigUint64Array, -}; - -function unsupported(detail: string): never { - throw unsupportedNodeApi("worker_threads structured messages", detail); -} - -function objectLike(value: unknown): value is object { - return (typeof value === "object" && value !== null) || typeof value === "function"; -} - -export function createMessageCodec() { - const uncloneable = new WeakSet(); - const untransferable = new WeakSet(); - - function encode(value: unknown): string { - const nodes: EncodedValue[] = []; - const seen = new Map(); - function visit(value: unknown): number { - if (objectLike(value)) { - if (uncloneable.has(value)) { - throw new DOMException("Cannot clone object marked as uncloneable", "DataCloneError"); - } - const previous = seen.get(value); - if (previous !== undefined) { - return previous; - } - } - const id = nodes.length; - nodes.push({ type: "undefined" }); - if (objectLike(value)) { - seen.set(value, id); - } - if (value === undefined) { - return id; - } - if (value === null || typeof value === "string" || typeof value === "boolean") { - nodes[id] = { type: "value", value }; - } else if (typeof value === "number") { - nodes[id] = { type: "number", value: Object.is(value, -0) ? "-0" : String(value) }; - } else if (typeof value === "bigint") { - nodes[id] = { type: "bigint", value: String(value) }; - } else if (typeof value !== "object") { - throw new DOMException("Value cannot be cloned", "DataCloneError"); - } else if (value instanceof Map) { - nodes[id] = { - type: "map", - entries: [...Map.prototype.entries.call(value)].map(([key, item]) => [ - visit(key), - visit(item), - ]), - }; - } else if (value instanceof Set) { - nodes[id] = { type: "set", entries: [...Set.prototype.values.call(value)].map(visit) }; - } else if (value instanceof Date) { - nodes[id] = { type: "date", value: String(Date.prototype.getTime.call(value)) }; - } else if (value instanceof RegExp) { - nodes[id] = { type: "regexp", source: value.source, flags: value.flags }; - } else if (ArrayBuffer.isView(value)) { - // Buffer is cloned as Uint8Array, matching Node structured cloning. - const name = - value instanceof DataView - ? "DataView" - : Object.entries(viewConstructors).find(([, ctor]) => value instanceof ctor)?.[0]; - if (!name) { - unsupported("Unknown typed array view"); - } - nodes[id] = { - type: "view", - name, - buffer: visit(value.buffer), - offset: value.byteOffset, - length: value.byteLength, - }; - } else if (value instanceof ArrayBuffer) { - nodes[id] = { type: "buffer", bytes: [...new Uint8Array(value)] }; - } else { - const array = Array.isArray(value); - const prototype = Object.getPrototypeOf(value); - if (!array && prototype !== Object.prototype && prototype !== null) { - unsupported( - "Only plain records, arrays, Map, Set, Date, RegExp, ArrayBuffer and typed views can cross this boundary", - ); - } - const entries: [string, number][] = []; - for (const key of Object.keys(value)) { - const descriptor = Object.getOwnPropertyDescriptor(value, key)!; - if (!("value" in descriptor)) { - unsupported("Accessor properties are not supported"); - } - entries.push([key, visit(descriptor.value)]); - } - nodes[id] = array - ? { type: "array", length: value.length, entries } - : { type: "object", entries }; - } - return id; - } - visit(value); - return JSON.stringify(nodes); - } - - return { - encode, - markAsUncloneable(value: unknown): void { - // Node's private clone flag applies to ordinary objects. Built-in value - // serializers (including arrays) ignore it; nested ordinary objects do not. - if ( - objectLike(value) && - !Array.isArray(value) && - !ArrayBuffer.isView(value) && - !(value instanceof ArrayBuffer) && - !(value instanceof Map) && - !(value instanceof Set) && - !(value instanceof Date) && - !(value instanceof RegExp) - ) { - uncloneable.add(value); - } - }, - markAsUntransferable(value: unknown): void { - if (objectLike(value)) { - untransferable.add(value); - } - }, - isMarkedAsUntransferable(value: unknown): boolean { - return objectLike(value) && untransferable.has(value); - }, - }; -} - -/** Decode only the private graph representation emitted by the paired adapter. */ -export function decodeMessage(source: string): unknown { - const nodes: EncodedValue[] = JSON.parse(source); - const values: unknown[] = nodes.map((node) => { - switch (node.type) { - case "value": - return node.value; - case "undefined": - return undefined; - case "number": - return Number(node.value); - case "bigint": - return BigInt(node.value); - case "date": - return new Date(Number(node.value)); - case "array": - return new Array(node.length); - case "object": - return {}; - case "map": - return new Map(); - case "set": - return new Set(); - case "buffer": - return Uint8Array.from(node.bytes).buffer; - case "regexp": - return new RegExp(node.source, node.flags); - case "view": - return undefined; - } - }); - // Buffers must exist before constructing views; object references are filled last. - nodes.forEach((node, index) => { - if (node.type !== "view") { - return; - } - const buffer = values[node.buffer] as ArrayBuffer; - if (node.name === "DataView") { - values[index] = new DataView(buffer, node.offset, node.length); - } else { - const ctor = viewConstructors[node.name as keyof typeof viewConstructors]; - values[index] = new ctor(buffer, node.offset, node.length / ctor.BYTES_PER_ELEMENT); - } - }); - nodes.forEach((node, index) => { - const target = values[index]; - if (node.type === "object" || node.type === "array") { - for (const [key, reference] of node.entries) { - Object.defineProperty(target, key, { - value: values[reference], - enumerable: true, - configurable: true, - writable: true, - }); - } - } else if (node.type === "map") { - for (const [key, value] of node.entries) { - (target as Map).set(values[key], values[value]); - } - } else if (node.type === "set") { - for (const value of node.entries) { - (target as Set).add(values[value]); - } - } - }); - return values[0]; -} +/** Worker messages keep their original bounded cloning policy. */ +export { createMessageCodec, decodeMessage } from "../internal/structured-value.js"; From 8b001f7900bebbffa89c72a13f967ef1862c8cb5 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Sat, 12 Sep 2026 12:58:13 +0000 Subject: [PATCH 02/12] test(std): cover shared structured value transport --- .../node/24.x.x/internal/structured-value.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/internal/structured-value.ts diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/internal/structured-value.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/internal/structured-value.ts new file mode 100644 index 000000000..ea9a9fcf8 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/internal/structured-value.ts @@ -0,0 +1,46 @@ +import { expect, test } from "vitest"; +import { Buffer } from "node:buffer"; +import { + createMessageCodec, + decodeMessage, +} from "../../../../../../src/wasi/0.2.x/node/24.x.x/internal/structured-value.js"; + +test("worker defaults retain buffer sharing, sparse arrays, safe keys and special values", () => { + const backing = new ArrayBuffer(16); + const array = new Array(4); + array[2] = "present"; + const source = { + backing, + first: new Uint16Array(backing, 2, 2), + second: new DataView(backing, 3, 3), + buffer: Buffer.from("hello"), + array, + numbers: [-0, NaN, Infinity, -Infinity], + record: JSON.parse('{"__proto__":{"value":1}}'), + }; + const result = decodeMessage(createMessageCodec().encode(source)) as typeof source; + + expect(result.first.buffer).toBe(result.backing); + expect(result.second.buffer).toBe(result.backing); + expect(result.first.byteOffset).toBe(2); + expect(result.second.byteOffset).toBe(3); + expect(result.buffer).toBeInstanceOf(Uint8Array); + expect(Buffer.isBuffer(result.buffer)).toBe(false); + expect(result.array).toEqual(array); + expect(0 in result.array).toBe(false); + expect(result.numbers).toEqual(source.numbers); + expect(Object.getPrototypeOf(result.record)).toBe(Object.prototype); + expect(Object.hasOwn(result.record, "__proto__")).toBe(true); +}); + +test("persistent graphs keep identities and recover from an unsupported nested value", () => { + const codec = createMessageCodec({ persistent: true, preserveBuffers: true }); + const session = { values: [] as unknown[] }; + const item = { buffer: Buffer.from("hi") }; + const first = decodeMessage(codec.encode(item), session); + + expect(() => codec.encode({ nested: { invalid: Symbol() } })).toThrow(); + expect(decodeMessage(codec.encode(item), session)).toBe(first); + expect(decodeMessage(codec.encode({ item }), session)).toEqual({ item: first }); + expect(Buffer.isBuffer((first as typeof item).buffer)).toBe(true); +}); From 41537dca5022346cd64b9be5ce6d22b4a1732d8c Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Sat, 12 Sep 2026 12:59:26 +0000 Subject: [PATCH 03/12] feat(std): add V8 API with explicit Node host capability --- packages/jco-std/package.json | 19 ++ .../wasi/0.2.x/node/24.x.x/v8-host-node.ts | 153 +++++++++ .../src/wasi/0.2.x/node/24.x.x/v8-host.ts | 143 +++++++++ .../wasi/0.2.x/node/24.x.x/v8-interface.d.ts | 29 ++ .../jco-std/src/wasi/0.2.x/node/24.x.x/v8.ts | 44 +++ .../src/wasi/0.2.x/node/24.x.x/v8/bytes.ts | 9 + .../src/wasi/0.2.x/node/24.x.x/v8/core.ts | 124 ++++++++ .../wasi/0.2.x/node/24.x.x/v8/deserializer.ts | 109 +++++++ .../src/wasi/0.2.x/node/24.x.x/v8/errors.ts | 35 ++ .../node/24.x.x/v8/host-serialization.ts | 130 ++++++++ .../src/wasi/0.2.x/node/24.x.x/v8/profiles.ts | 67 ++++ .../0.2.x/node/24.x.x/v8/serialization.ts | 42 +++ .../wasi/0.2.x/node/24.x.x/v8/serializer.ts | 158 ++++++++++ .../src/wasi/0.2.x/node/24.x.x/v8/types.ts | 298 ++++++++++++++++++ .../wasi/0.2.x/node/24.x.x/v8/unsupported.ts | 73 +++++ packages/jco-std/tsconfig.json | 1 + packages/jco-std/wit/node-0.1.0/v8.wit | 102 ++++++ 17 files changed, 1536 insertions(+) create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8-host-node.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8-host.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8-interface.d.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/bytes.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/core.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/deserializer.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/errors.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/host-serialization.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/profiles.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/serialization.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/serializer.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/types.ts create mode 100644 packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/unsupported.ts create mode 100644 packages/jco-std/wit/node-0.1.0/v8.wit diff --git a/packages/jco-std/package.json b/packages/jco-std/package.json index 653408476..cfa04b4f6 100644 --- a/packages/jco-std/package.json +++ b/packages/jco-std/package.json @@ -622,6 +622,25 @@ "types": "./dist/wasi/0.2.x/node/24.x.x/wasi-host-node.d.ts", "node": "./dist/wasi/0.2.x/node/24.x.x/wasi-host-node.js" }, + "./wasi/0.2.x/node/24.x.x/v8": { + "types": "./dist/wasi/0.2.x/node/24.x.x/v8.d.ts", + "browser": "./dist/wasi/0.2.x/node/24.x.x/v8.js", + "default": "./dist/wasi/0.2.x/node/24.x.x/v8.js" + }, + "./wasi/0.2.x/node/24.x.x/v8/core": { + "types": "./dist/wasi/0.2.x/node/24.x.x/v8/core.d.ts", + "browser": "./dist/wasi/0.2.x/node/24.x.x/v8/core.js", + "default": "./dist/wasi/0.2.x/node/24.x.x/v8/core.js" + }, + "./wasi/0.2.x/node/24.x.x/v8/host": { + "types": "./dist/wasi/0.2.x/node/24.x.x/v8-host.d.ts", + "browser": "./dist/wasi/0.2.x/node/24.x.x/v8-host.js", + "default": "./dist/wasi/0.2.x/node/24.x.x/v8-host.js" + }, + "./wasi/0.2.x/node/24.x.x/v8/host/node": { + "types": "./dist/wasi/0.2.x/node/24.x.x/v8-host-node.d.ts", + "node": "./dist/wasi/0.2.x/node/24.x.x/v8-host-node.js" + }, "./wasi/0.2.x/node/26.x.x/vfs": { "types": "./dist/wasi/0.2.x/node/26.x.x/vfs.d.ts", "browser": "./dist/wasi/0.2.x/node/26.x.x/vfs.js", diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8-host-node.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8-host-node.ts new file mode 100644 index 000000000..e119ca8c6 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8-host-node.ts @@ -0,0 +1,153 @@ +/** Explicit access to the host Node V8 isolate through public node:v8 APIs. */ +import * as node from "node:v8"; +import { Buffer } from "node:buffer"; +import { nativeCall, Writer, Reader } from "./v8/host-serialization.js"; +import type { + StatisticsKind, + HeapSnapshotOptions, + Profile as ProfileContract, +} from "./v8/types.js"; + +export { Writer, Reader }; + +export function cachedDataVersionTag(): number { + return nativeCall(() => node.cachedDataVersionTag()); +} + +export function statistics(kind: StatisticsKind): string { + return nativeCall(() => { + switch (kind) { + case "heap": + return JSON.stringify(node.getHeapStatistics()); + + case "spaces": + return JSON.stringify(node.getHeapSpaceStatistics()); + + case "code": + return JSON.stringify(node.getHeapCodeStatistics()); + + case "cpp-brief": + return JSON.stringify(node.getCppHeapStatistics("brief")); + + case "cpp-detailed": + return JSON.stringify(node.getCppHeapStatistics("detailed")); + } + }); +} + +/** Node produces the native snapshot synchronously; bytes are copied across WIT. */ +export function getHeapSnapshot(options: HeapSnapshotOptions): Uint8Array { + return nativeCall(() => { + const stream = node.getHeapSnapshot(options); + const chunks: Uint8Array[] = []; + + try { + for (let chunk: unknown = stream.read(); chunk !== null; chunk = stream.read()) { + if (!(chunk instanceof Uint8Array)) { + throw new TypeError("V8 snapshot stream returned a non-byte chunk"); + } + + chunks.push(chunk); + } + + return Buffer.concat(chunks); + } finally { + stream.destroy(); + } + }); +} + +export function writeHeapSnapshot( + filename: string | undefined, + options: HeapSnapshotOptions, +): string { + return nativeCall(() => node.writeHeapSnapshot(filename, options)); +} + +export function setFlagsFromString(flags: string): void { + nativeCall(() => node.setFlagsFromString(flags)); +} + +export function takeCoverage(): void { + nativeCall(() => node.takeCoverage()); +} + +export function stopCoverage(): void { + nativeCall(() => node.stopCoverage()); +} + +export function setHeapSnapshotNearHeapLimit(limit: number): void { + nativeCall(() => node.setHeapSnapshotNearHeapLimit(limit)); +} + +export function openWriter(defaults: boolean): Writer { + return nativeCall(() => new Writer(defaults)); +} + +export function openReader(data: Uint8Array, defaults: boolean): Reader { + return nativeCall(() => new Reader(data, defaults)); +} + +export class Profile implements ProfileContract { + #profile: node.GCProfiler | node.SyncCPUProfileHandle | undefined; + + constructor(cpu: boolean) { + if (cpu) { + this.#profile = node.startCpuProfile(); + } else { + const profiler = new node.GCProfiler(); + + profiler.start(); + this.#profile = profiler; + } + } + + stop(): string | undefined { + return nativeCall(() => { + const profile = this.#profile; + + this.#profile = undefined; + + const result: unknown = profile?.stop(); + + return typeof result === "string" ? result : JSON.stringify(result); + }); + } + + [Symbol.dispose](): void { + this.stop(); + } +} + +export function startProfile(cpu: boolean): Profile { + return nativeCall(() => new Profile(cpu)); +} + +export function releaseWriter(writer: Writer): void { + writer[Symbol.dispose](); +} + +export function releaseReader(reader: Reader): void { + reader[Symbol.dispose](); +} + +export function releaseProfile(profile: Profile): void { + profile[Symbol.dispose](); +} + +export default { + cachedDataVersionTag, + statistics, + getHeapSnapshot, + writeHeapSnapshot, + setFlagsFromString, + takeCoverage, + stopCoverage, + setHeapSnapshotNearHeapLimit, + openWriter, + openReader, + startProfile, + releaseWriter, + releaseReader, + releaseProfile, +}; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8-host.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8-host.ts new file mode 100644 index 000000000..157021fbd --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8-host.ts @@ -0,0 +1,143 @@ +import { adapterRequiredMessage } from "./internal/deny-host.js"; +import type { HostErrorBase } from "./internal/wit-types.js"; +import type { + V8Host, + Writer as WriterContract, + Reader as ReaderContract, + Profile as ProfileContract, +} from "./v8/types.js"; + +function denied(): never { + throw { + name: "Error", + message: adapterRequiredMessage("node:v8"), + code: "ERR_JCO_V8_ADAPTER_REQUIRED", + } satisfies HostErrorBase; +} + +export const cachedDataVersionTag: V8Host["cachedDataVersionTag"] = denied; + +export const statistics: V8Host["statistics"] = denied; + +export const getHeapSnapshot: V8Host["getHeapSnapshot"] = denied; + +export const writeHeapSnapshot: V8Host["writeHeapSnapshot"] = denied; + +export const setFlagsFromString: V8Host["setFlagsFromString"] = denied; + +export const takeCoverage: V8Host["takeCoverage"] = denied; + +export const stopCoverage: V8Host["stopCoverage"] = denied; + +export const setHeapSnapshotNearHeapLimit: V8Host["setHeapSnapshotNearHeapLimit"] = denied; + +export const openWriter: V8Host["openWriter"] = denied; + +export const openReader: V8Host["openReader"] = denied; + +export const startProfile: V8Host["startProfile"] = denied; + +export const releaseWriter: V8Host["releaseWriter"] = denied; + +export const releaseReader: V8Host["releaseReader"] = denied; + +export const releaseProfile: V8Host["releaseProfile"] = denied; + +export default { + cachedDataVersionTag, + statistics, + getHeapSnapshot, + writeHeapSnapshot, + setFlagsFromString, + takeCoverage, + stopCoverage, + setHeapSnapshotNearHeapLimit, + openWriter, + openReader, + startProfile, + releaseWriter, + releaseReader, + releaseProfile, +}; + +export class Writer implements WriterContract { + constructor() { + denied(); + } + + writeHeader(): void { + return denied(); + } + + writeValue(_graph: string): boolean { + return denied(); + } + + releaseBuffer(): Uint8Array { + return denied(); + } + + writeUint32(_value: number): void { + return denied(); + } + + writeUint64(_hi: number, _lo: number): void { + return denied(); + } + + writeDouble(_value: number): void { + return denied(); + } + + writeRawBytes(_data: Uint8Array): void { + return denied(); + } + + setTreatViewsAsHostObjects(_flag: boolean): void { + return denied(); + } +} + +export class Reader implements ReaderContract { + constructor() { + denied(); + } + + readHeader(): boolean { + return denied(); + } + + readValue(): string { + return denied(); + } + + getWireFormatVersion(): number { + return denied(); + } + + readUint32(): number { + return denied(); + } + + readUint64(): [number, number] { + return denied(); + } + + readDouble(): number { + return denied(); + } + + readRawBytes(_length: number): Uint8Array { + return denied(); + } +} + +export class Profile implements ProfileContract { + constructor() { + denied(); + } + + stop(): string | undefined { + return denied(); + } +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8-interface.d.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8-interface.d.ts new file mode 100644 index 000000000..62eb6c46d --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8-interface.d.ts @@ -0,0 +1,29 @@ +import type { V8Host } from "./v8/types.js"; + +export const cachedDataVersionTag: V8Host["cachedDataVersionTag"]; + +export const statistics: V8Host["statistics"]; + +export const getHeapSnapshot: V8Host["getHeapSnapshot"]; + +export const writeHeapSnapshot: V8Host["writeHeapSnapshot"]; + +export const setFlagsFromString: V8Host["setFlagsFromString"]; + +export const takeCoverage: V8Host["takeCoverage"]; + +export const stopCoverage: V8Host["stopCoverage"]; + +export const setHeapSnapshotNearHeapLimit: V8Host["setHeapSnapshotNearHeapLimit"]; + +export const openWriter: V8Host["openWriter"]; + +export const openReader: V8Host["openReader"]; + +export const startProfile: V8Host["startProfile"]; + +export const releaseWriter: V8Host["releaseWriter"]; + +export const releaseReader: V8Host["releaseReader"]; + +export const releaseProfile: V8Host["releaseProfile"]; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8.ts new file mode 100644 index 000000000..746d86fc8 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8.ts @@ -0,0 +1,44 @@ +import * as host from "jco:node/v8@0.1.0"; +import { createV8 } from "./v8/core.js"; + +export type * from "./v8/types.js"; + +const v8 = createV8(host); + +export default v8; + +export const { + cachedDataVersionTag, + getHeapSnapshot, + getHeapStatistics, + getHeapSpaceStatistics, + getHeapCodeStatistics, + getCppHeapStatistics, + setFlagsFromString, + Serializer, + Deserializer, + DefaultSerializer, + DefaultDeserializer, + deserialize, + takeCoverage, + stopCoverage, + serialize, + writeHeapSnapshot, + promiseHooks, + queryObjects, + startupSnapshot, + setHeapSnapshotNearHeapLimit, + GCProfiler, + isStringOneByteRepresentation, + startCpuProfile, +} = v8; + +export type Serializer = InstanceType; + +export type Deserializer = InstanceType; + +export type DefaultSerializer = InstanceType; + +export type DefaultDeserializer = InstanceType; + +export type GCProfiler = InstanceType; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/bytes.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/bytes.ts new file mode 100644 index 000000000..5e0c5fc5e --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/bytes.ts @@ -0,0 +1,9 @@ +import { invalidArgType } from "../errors/core.js"; + +export function bytes(value: unknown, name = "buffer"): Uint8Array { + if (!ArrayBuffer.isView(value)) { + throw invalidArgType(name, ["Buffer", "TypedArray", "DataView"], value); + } + + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/core.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/core.ts new file mode 100644 index 000000000..c3f811a93 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/core.ts @@ -0,0 +1,124 @@ +import { Buffer } from "node:buffer"; +import { Readable } from "../stream/index.js"; +import { + validateBoolean, + validateObject, + validateOneOf, + validateString, + validateUint32, +} from "../internal/validation.js"; +import { createSerialization } from "./serialization.js"; +import { createProfiles } from "./profiles.js"; +import { + promiseHooks, + startupSnapshot, + queryObjects, + isStringOneByteRepresentation, +} from "./unsupported.js"; +import { call } from "./errors.js"; +import type { + V8Host, + HeapSnapshotOptions, + HeapInfo, + HeapSpaceInfo, + HeapCodeStatistics, +} from "./types.js"; + +/** Copy only native snapshot settings, preserving Node's validation before host access. */ +function snapshotOptions(options: HeapSnapshotOptions = {}): HeapSnapshotOptions { + validateObject(options, "options"); + + const { exposeInternals = false, exposeNumericValues = false } = options; + + validateBoolean(exposeInternals, "options.exposeInternals"); + validateBoolean(exposeNumericValues, "options.exposeNumericValues"); + + return { exposeInternals, exposeNumericValues }; +} + +/** Importing the module never inspects or modifies the host V8 isolate. */ +export function createV8(host: V8Host) { + const serialization = createSerialization(host); + const profiles = createProfiles(host); + + function cachedDataVersionTag(): number { + return call(() => host.cachedDataVersionTag()) >>> 0; + } + + function getHeapStatistics(): HeapInfo { + return JSON.parse(call(() => host.statistics("heap"))); + } + + function getHeapSpaceStatistics(): HeapSpaceInfo[] { + return JSON.parse(call(() => host.statistics("spaces"))); + } + + function getHeapCodeStatistics(): HeapCodeStatistics { + return JSON.parse(call(() => host.statistics("code"))); + } + + function getCppHeapStatistics(detailLevel: "brief" | "detailed" = "detailed"): object { + validateOneOf(detailLevel, "type", ["brief", "detailed"]); + + return JSON.parse( + call(() => host.statistics(detailLevel === "brief" ? "cpp-brief" : "cpp-detailed")), + ); + } + + function getHeapSnapshot(options?: HeapSnapshotOptions): Readable { + const settings = snapshotOptions(options); + const data = call(() => host.getHeapSnapshot(settings)); + + return Readable.from([Buffer.from(data)], { objectMode: false }); + } + + function writeHeapSnapshot(filename?: string, options?: HeapSnapshotOptions): string { + if (filename !== undefined) { + validateString(filename, "filename"); + } + + const settings = snapshotOptions(options); + + return call(() => host.writeHeapSnapshot(filename, settings)); + } + + function setFlagsFromString(flags: string): void { + validateString(flags, "flags"); + call(() => host.setFlagsFromString(flags)); + } + + function takeCoverage(): void { + call(() => host.takeCoverage()); + } + + function stopCoverage(): void { + call(() => host.stopCoverage()); + } + + function setHeapSnapshotNearHeapLimit(limit: number): void { + validateUint32(limit, "limit", true); + call(() => host.setHeapSnapshotNearHeapLimit(limit)); + } + + return { + cachedDataVersionTag, + getHeapSnapshot, + getHeapStatistics, + getHeapSpaceStatistics, + getHeapCodeStatistics, + getCppHeapStatistics, + setFlagsFromString, + ...serialization, + takeCoverage, + stopCoverage, + writeHeapSnapshot, + promiseHooks, + queryObjects, + startupSnapshot, + setHeapSnapshotNearHeapLimit, + ...profiles, + isStringOneByteRepresentation, + }; +} + +export type V8Module = ReturnType; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/deserializer.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/deserializer.ts new file mode 100644 index 000000000..fd895fa30 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/deserializer.ts @@ -0,0 +1,109 @@ +import { Buffer } from "node:buffer"; +import { codedError } from "../errors/core.js"; +import { decodeMessage } from "../internal/structured-value.js"; +import { call, unsupported } from "./errors.js"; +import { bytes } from "./bytes.js"; +import type { DecodeSession } from "../internal/structured-value.js"; +import type { Reader, V8Host, V8Buffer, SerializationModule } from "./types.js"; + +/** Read native bytes while retaining guest identities across successive values. */ +export function createDeserializers( + host: V8Host, +): Pick { + class Deserializer { + readonly buffer: ArrayBufferView; + + #reader: Reader | undefined; + + #session: DecodeSession = { values: [] }; + + constructor(buffer: ArrayBufferView) { + if (new.target !== Deserializer && new.target !== DefaultDeserializer) { + unsupported("Deserializer subclasses"); + } + + this.buffer = buffer; + this.#reader = call(() => host.openReader(bytes(buffer), new.target === DefaultDeserializer)); + } + + #handle(): Reader { + if (!this.#reader) { + throw codedError(new Error("Deserializer has been disposed"), "ERR_INVALID_STATE"); + } + + return this.#reader; + } + + readHeader(): boolean { + return call(() => this.#handle().readHeader()); + } + + readValue(): unknown { + if ( + "_readHostObject" in this && + this._readHostObject !== DefaultDeserializer.prototype._readHostObject + ) { + unsupported("Deserializer._readHostObject()"); + } + + return decodeMessage( + call(() => this.#handle().readValue()), + this.#session, + ); + } + + transferArrayBuffer(_id: number, _arrayBuffer: ArrayBuffer): void { + unsupported("Deserializer.transferArrayBuffer()"); + } + + getWireFormatVersion(): number { + return call(() => this.#handle().getWireFormatVersion()) >>> 0; + } + + readUint32(): number { + return call(() => this.#handle().readUint32()) >>> 0; + } + + readUint64(): [number, number] { + const [hi, lo] = call(() => this.#handle().readUint64()); + + return [hi >>> 0, lo >>> 0]; + } + + readDouble(): number { + return call(() => this.#handle().readDouble()); + } + + _readRawBytes(_length: number): number { + return unsupported("Deserializer._readRawBytes() native offsets"); + } + + readRawBytes(length: number): V8Buffer { + return Buffer.from(call(() => this.#handle().readRawBytes(length))); + } + + /** Component extension: release the native reader after consuming its values. */ + [Symbol.dispose](): void { + if (this.#reader) { + host.releaseReader(this.#reader); + this.#reader = undefined; + this.#session.values = []; + } + } + } + + // Node installs its native base methods as enumerable prototype properties. + for (const name of Object.getOwnPropertyNames(Deserializer.prototype)) { + if (name !== "constructor") { + Object.defineProperty(Deserializer.prototype, name, { enumerable: true }); + } + } + + class DefaultDeserializer extends Deserializer { + _readHostObject(): unknown { + return unsupported("DefaultDeserializer._readHostObject() direct calls"); + } + } + + return { Deserializer, DefaultDeserializer }; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/errors.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/errors.ts new file mode 100644 index 000000000..0bb39e018 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/errors.ts @@ -0,0 +1,35 @@ +import { codedError, unsupportedNodeApi } from "../errors/core.js"; +import { callHost, decodeErrno } from "../internal/host-error.js"; +import type { HostErrorBase } from "../internal/wit-types.js"; +import type { Result } from "./types.js"; + +export function call(operation: () => Result): T { + return callHost(operation, restoreError); +} + +function restoreError(record: HostErrorBase): Error { + const constructors = { Error, TypeError, RangeError, SyntaxError }; + const Constructor = Object.hasOwn(constructors, record.name) + ? constructors[record.name as keyof typeof constructors] + : Error; + const error = new Constructor(record.message); + + error.name = record.name; + + if (record.errno !== undefined) { + Object.assign(error, { errno: decodeErrno(record.errno) }); + } + + if (record.syscall !== undefined) { + Object.assign(error, { syscall: record.syscall }); + } + + return record.code === undefined ? error : codedError(error, record.code); +} + +export function unsupported(api: string): never { + throw unsupportedNodeApi( + `v8.${api}`, + "Guest engine objects, callbacks, shared memory and native serialization hooks cannot cross the V8 host boundary", + ); +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/host-serialization.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/host-serialization.ts new file mode 100644 index 000000000..b31171d12 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/host-serialization.ts @@ -0,0 +1,130 @@ +import * as node from "node:v8"; +import { createMessageCodec, decodeMessage } from "../internal/structured-value.js"; +import { serializeHostError } from "../internal/host-error.js"; +import type { DecodeSession } from "../internal/structured-value.js"; +import type { Writer as WriterContract, Reader as ReaderContract } from "./types.js"; + +export function nativeCall(operation: () => T): T { + try { + return operation(); + } catch (error) { + throw serializeHostError(error); + } +} + +export class Writer implements WriterContract { + #writer: node.Serializer | undefined; + + #session: DecodeSession = { values: [] }; + + constructor(defaults: boolean) { + this.#writer = defaults ? new node.DefaultSerializer() : new node.Serializer(); + } + + #handle(): node.Serializer { + if (!this.#writer) { + throw new Error("Serializer has been released"); + } + + return this.#writer; + } + + writeHeader(): void { + nativeCall(() => this.#handle().writeHeader()); + } + + writeValue(graph: string): boolean { + return nativeCall(() => this.#handle().writeValue(decodeMessage(graph, this.#session))); + } + + releaseBuffer(): Uint8Array { + return nativeCall(() => this.#handle().releaseBuffer()); + } + + writeUint32(value: number): void { + nativeCall(() => this.#handle().writeUint32(value)); + } + + writeUint64(hi: number, lo: number): void { + nativeCall(() => this.#handle().writeUint64(hi, lo)); + } + + writeDouble(value: number): void { + nativeCall(() => this.#handle().writeDouble(value)); + } + + writeRawBytes(data: Uint8Array): void { + nativeCall(() => this.#handle().writeRawBytes(data)); + } + + setTreatViewsAsHostObjects(flag: boolean): void { + // This documented method is missing from @types/node's Serializer declaration. + nativeCall(() => + Reflect.apply( + Reflect.get(this.#handle(), "_setTreatArrayBufferViewsAsHostObjects"), + this.#handle(), + [flag], + ), + ); + } + + [Symbol.dispose](): void { + this.#writer = undefined; + this.#session.values = []; + } +} + +export class Reader implements ReaderContract { + #reader: node.Deserializer | undefined; + + #codec = createMessageCodec({ + api: "v8 deserialization", + preserveBuffers: true, + persistent: true, + }); + + constructor(data: Uint8Array, defaults: boolean) { + this.#reader = defaults ? new node.DefaultDeserializer(data) : new node.Deserializer(data); + } + + #handle(): node.Deserializer { + if (!this.#reader) { + throw new Error("Deserializer has been released"); + } + + return this.#reader; + } + + readHeader(): boolean { + return nativeCall(() => this.#handle().readHeader()); + } + + readValue(): string { + return nativeCall(() => this.#codec.encode(this.#handle().readValue())); + } + + getWireFormatVersion(): number { + return nativeCall(() => this.#handle().getWireFormatVersion()); + } + + readUint32(): number { + return nativeCall(() => this.#handle().readUint32()); + } + + readUint64(): [number, number] { + return nativeCall(() => this.#handle().readUint64()); + } + + readDouble(): number { + return nativeCall(() => this.#handle().readDouble()); + } + + readRawBytes(length: number): Uint8Array { + return nativeCall(() => this.#handle().readRawBytes(length)); + } + + [Symbol.dispose](): void { + this.#reader = undefined; + this.#codec = createMessageCodec(); + } +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/profiles.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/profiles.ts new file mode 100644 index 000000000..2bd27b1ab --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/profiles.ts @@ -0,0 +1,67 @@ +import { call } from "./errors.js"; +import type { + V8Host, + Profile, + GCProfilerResult, + SyncCPUProfileHandle, + ProfilesModule, +} from "./types.js"; + +export function createProfiles(host: V8Host): ProfilesModule { + class GCProfiler { + #profile: Profile | undefined; + + start(): void { + this.#profile ??= call(() => host.startProfile(false)); + } + + stop(): GCProfilerResult | undefined { + const profile = this.#profile; + + if (!profile) { + return undefined; + } + + this.#profile = undefined; + + try { + const result = call(() => profile.stop()); + + return result === undefined ? undefined : JSON.parse(result); + } finally { + host.releaseProfile(profile); + } + } + + [Symbol.dispose](): void { + this.stop(); + } + } + + function startCpuProfile(): SyncCPUProfileHandle { + const profile = call(() => host.startProfile(true)); + let stopped = false; + + return { + stop(): string | undefined { + if (stopped) { + return undefined; + } + + stopped = true; + + try { + return call(() => profile.stop()); + } finally { + host.releaseProfile(profile); + } + }, + + [Symbol.dispose](): void { + this.stop(); + }, + }; + } + + return { GCProfiler, startCpuProfile }; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/serialization.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/serialization.ts new file mode 100644 index 000000000..5b9f5d1f9 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/serialization.ts @@ -0,0 +1,42 @@ +import { createSerializers } from "./serializer.js"; +import { createDeserializers } from "./deserializer.js"; +import type { V8Host, V8Buffer, SerializationModule } from "./types.js"; + +export function createSerialization(host: V8Host): SerializationModule { + const { Serializer, DefaultSerializer } = createSerializers(host); + const { Deserializer, DefaultDeserializer } = createDeserializers(host); + + function serialize(value: unknown): V8Buffer { + const serializer = new DefaultSerializer(); + + try { + serializer.writeHeader(); + serializer.writeValue(value); + + return serializer.releaseBuffer(); + } finally { + serializer[Symbol.dispose](); + } + } + + function deserialize(buffer: ArrayBufferView): unknown { + const deserializer = new DefaultDeserializer(buffer); + + try { + deserializer.readHeader(); + + return deserializer.readValue(); + } finally { + deserializer[Symbol.dispose](); + } + } + + return { + Serializer, + Deserializer, + DefaultSerializer, + DefaultDeserializer, + serialize, + deserialize, + }; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/serializer.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/serializer.ts new file mode 100644 index 000000000..362dc5251 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/serializer.ts @@ -0,0 +1,158 @@ +import { Buffer } from "node:buffer"; +import { codedError } from "../errors/core.js"; +import { createMessageCodec } from "../internal/structured-value.js"; +import { call, unsupported } from "./errors.js"; +import { bytes } from "./bytes.js"; +import type { MessageCodec } from "../internal/structured-value.js"; +import type { Writer, V8Host, V8Buffer, SerializationModule } from "./types.js"; + +function codec(): MessageCodec { + return createMessageCodec({ + api: "v8 serialization", + preserveBuffers: true, + persistent: true, + cloneError: (message) => new Error(message), + }); +} + +/** Native resources retain the binary format and serialization identity table. */ +export function createSerializers( + host: V8Host, +): Pick { + class Serializer { + #writer: Writer | undefined; + + #codec = codec(); + + #defaults: boolean; + + #disposed = false; + + #treatViews: boolean; + + declare _getDataCloneError: ErrorConstructor; + + constructor() { + if (new.target !== Serializer && new.target !== DefaultSerializer) { + unsupported("Serializer subclasses"); + } + + this.#defaults = new.target === DefaultSerializer; + this.#treatViews = this.#defaults; + this.#writer = call(() => host.openWriter(this.#defaults)); + } + + #handle(): Writer { + if (this.#disposed) { + throw codedError(new Error("Serializer has been disposed"), "ERR_INVALID_STATE"); + } + + if (!this.#writer) { + const writer = call(() => host.openWriter(this.#defaults)); + + try { + call(() => writer.setTreatViewsAsHostObjects(this.#treatViews)); + this.#writer = writer; + } catch (error) { + host.releaseWriter(writer); + throw error; + } + } + + return this.#writer; + } + + writeHeader(): void { + call(() => this.#handle().writeHeader()); + } + + writeValue(value: unknown): boolean { + if (this._getDataCloneError !== Error || "_getSharedArrayBufferId" in this) { + unsupported("Serializer custom callbacks"); + } + + if ( + "_writeHostObject" in this && + this._writeHostObject !== DefaultSerializer.prototype._writeHostObject + ) { + unsupported("Serializer._writeHostObject()"); + } + + const handle = this.#handle(); + const graph = this.#codec.encode(value); + + return call(() => handle.writeValue(graph)); + } + + releaseBuffer(): V8Buffer { + const handle = this.#handle(); + const output = call(() => handle.releaseBuffer()); + + host.releaseWriter(handle); + this.#writer = undefined; + this.#codec = codec(); + + return Buffer.from(output); + } + + transferArrayBuffer(_id: number, _arrayBuffer: ArrayBuffer): void { + unsupported("Serializer.transferArrayBuffer()"); + } + + writeUint32(value: number): void { + call(() => this.#handle().writeUint32(value)); + } + + writeUint64(hi: number, lo: number): void { + call(() => this.#handle().writeUint64(hi, lo)); + } + + writeDouble(value: number): void { + call(() => this.#handle().writeDouble(value)); + } + + writeRawBytes(buffer: ArrayBufferView): void { + const data = bytes(buffer); + + call(() => this.#handle().writeRawBytes(data)); + } + + _setTreatArrayBufferViewsAsHostObjects(flag: boolean): void { + call(() => this.#handle().setTreatViewsAsHostObjects(flag)); + this.#treatViews = flag; + } + + /** Component extension: release an abandoned native serializer deterministically. */ + [Symbol.dispose](): void { + this.#disposed = true; + this.#codec = codec(); + + if (this.#writer) { + host.releaseWriter(this.#writer); + this.#writer = undefined; + } + } + } + + Object.defineProperty(Serializer.prototype, "_getDataCloneError", { + value: Error, + writable: true, + enumerable: true, + configurable: true, + }); + + // Node installs its native base methods as enumerable prototype properties. + for (const name of Object.getOwnPropertyNames(Serializer.prototype)) { + if (name !== "constructor") { + Object.defineProperty(Serializer.prototype, name, { enumerable: true }); + } + } + + class DefaultSerializer extends Serializer { + _writeHostObject(_object: object): void { + unsupported("DefaultSerializer._writeHostObject() direct calls"); + } + } + + return { Serializer, DefaultSerializer }; +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/types.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/types.ts new file mode 100644 index 000000000..f8343dbad --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/types.ts @@ -0,0 +1,298 @@ +/** + * Public contracts adapted from @types/node 24.13.3 (MIT) and Node v24.20.0 + * lib/v8.js, commit 71b8b174857e25106d39b61a9e6f30d927da8b01. + * Unknown replaces unconstrained values; no Node ambient types are required. + * + * MIT License + * + * Copyright (c) Microsoft Corporation. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE + */ +import type { HostErrorBase, HostResult } from "../internal/wit-types.js"; + +export interface V8Buffer extends Uint8Array { + toString(encoding?: string, start?: number, end?: number): string; + equals(other: Uint8Array): boolean; +} + +export interface HeapSnapshotOptions { + exposeInternals?: boolean; + exposeNumericValues?: boolean; +} + +export interface HeapInfo { + total_heap_size: number; + total_heap_size_executable: number; + total_physical_size: number; + total_available_size: number; + used_heap_size: number; + heap_size_limit: number; + malloced_memory: number; + peak_malloced_memory: number; + does_zap_garbage: 0 | 1; + number_of_native_contexts: number; + number_of_detached_contexts: number; + total_global_handles_size: number; + used_global_handles_size: number; + external_memory: number; +} + +export interface HeapSpaceInfo { + space_name: string; + space_size: number; + space_used_size: number; + space_available_size: number; + physical_space_size: number; +} + +export interface HeapCodeStatistics { + code_and_metadata_size: number; + bytecode_and_metadata_size: number; + external_script_source_size: number; + cpu_profiler_metadata_size: number; +} + +export interface HeapStatistics { + totalHeapSize: number; + totalHeapSizeExecutable: number; + totalPhysicalSize: number; + totalAvailableSize: number; + totalGlobalHandlesSize: number; + usedGlobalHandlesSize: number; + usedHeapSize: number; + heapSizeLimit: number; + mallocedMemory: number; + externalMemory: number; + peakMallocedMemory: number; +} + +export interface HeapSpaceStatistics { + spaceName: string; + spaceSize: number; + spaceUsedSize: number; + spaceAvailableSize: number; + physicalSpaceSize: number; +} + +export interface GCProfilerResult { + version: number; + startTime: number; + endTime: number; + statistics: Array<{ + gcType: string; + cost: number; + beforeGC: { + heapStatistics: HeapStatistics; + heapSpaceStatistics: HeapSpaceStatistics[]; + }; + afterGC: { + heapStatistics: HeapStatistics; + heapSpaceStatistics: HeapSpaceStatistics[]; + }; + }>; +} + +export interface SyncCPUProfileHandle { + stop(): string | undefined; + + [Symbol.dispose](): void; +} + +export interface CPUProfileHandle { + stop(): Promise; + + [Symbol.asyncDispose](): Promise; +} + +export type HeapProfileHandle = CPUProfileHandle; + +export type Init = (promise: Promise, parent: Promise | undefined) => void; + +export type Before = (promise: Promise) => void; + +export type After = Before; + +export type Settled = Before; + +export interface HookCallbacks { + init?: Init; + before?: Before; + after?: After; + settled?: Settled; +} + +export type StartupSnapshotCallbackFn = (data: unknown) => unknown; + +export type Result = T | HostResult; + +export type StatisticsKind = "heap" | "spaces" | "code" | "cpp-brief" | "cpp-detailed"; + +export interface Writer { + writeHeader(): Result; + + writeValue(graph: string): Result; + + releaseBuffer(): Result; + + writeUint32(value: number): Result; + + writeUint64(hi: number, lo: number): Result; + + writeDouble(value: number): Result; + + writeRawBytes(data: Uint8Array): Result; + + setTreatViewsAsHostObjects(flag: boolean): Result; +} + +export interface Reader { + readHeader(): Result; + + readValue(): Result; + + getWireFormatVersion(): Result; + + readUint32(): Result; + + readUint64(): Result<[number, number]>; + + readDouble(): Result; + + readRawBytes(length: number): Result; +} + +export interface Profile { + stop(): Result; +} + +export interface V8Host { + cachedDataVersionTag(): Result; + + statistics(kind: StatisticsKind): Result; + + getHeapSnapshot(options: HeapSnapshotOptions): Result; + + writeHeapSnapshot(filename: string | undefined, options: HeapSnapshotOptions): Result; + + setFlagsFromString(flags: string): Result; + + takeCoverage(): Result; + + stopCoverage(): Result; + + setHeapSnapshotNearHeapLimit(limit: number): Result; + + openWriter(defaults: boolean): Result; + + openReader(data: Uint8Array, defaults: boolean): Result; + + startProfile(cpu: boolean): Result; + + releaseWriter(writer: Writer): void; + + releaseReader(reader: Reader): void; + + releaseProfile(profile: Profile): void; +} + +export interface Serializer { + writeHeader(): void; + + writeValue(value: unknown): boolean; + + releaseBuffer(): V8Buffer; + + transferArrayBuffer(id: number, arrayBuffer: ArrayBuffer): void; + + writeUint32(value: number): void; + + writeUint64(hi: number, lo: number): void; + + writeDouble(value: number): void; + + writeRawBytes(buffer: ArrayBufferView): void; + + _getDataCloneError: ErrorConstructor; + + _setTreatArrayBufferViewsAsHostObjects(flag: boolean): void; + + [Symbol.dispose](): void; +} + +export interface DefaultSerializer extends Serializer { + _writeHostObject(object: object): void; +} + +export interface Deserializer { + readonly buffer: ArrayBufferView; + + readHeader(): boolean; + + readValue(): unknown; + + transferArrayBuffer(id: number, arrayBuffer: ArrayBuffer): void; + + getWireFormatVersion(): number; + + readUint32(): number; + + readUint64(): [number, number]; + + readDouble(): number; + + _readRawBytes(length: number): number; + + readRawBytes(length: number): V8Buffer; + + [Symbol.dispose](): void; +} + +export interface DefaultDeserializer extends Deserializer { + _readHostObject(): unknown; +} + +export interface SerializationModule { + Serializer: new () => Serializer; + Deserializer: new (buffer: ArrayBufferView) => Deserializer; + DefaultSerializer: new () => DefaultSerializer; + DefaultDeserializer: new (buffer: ArrayBufferView) => DefaultDeserializer; + + serialize(value: unknown): V8Buffer; + + deserialize(buffer: ArrayBufferView): unknown; +} + +export interface GCProfiler { + start(): void; + + stop(): GCProfilerResult | undefined; + + [Symbol.dispose](): void; +} + +export interface ProfilesModule { + GCProfiler: new () => GCProfiler; + + startCpuProfile(): SyncCPUProfileHandle; +} + +export type QueryConstructor = + | ((...args: never[]) => unknown) + | (abstract new (...args: never[]) => object); diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/unsupported.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/unsupported.ts new file mode 100644 index 000000000..4f3cb8a8c --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8/unsupported.ts @@ -0,0 +1,73 @@ +import { codedError } from "../errors/core.js"; +import { unsupported } from "./errors.js"; +import type { + QueryConstructor, + Init, + Before, + After, + Settled, + HookCallbacks, + StartupSnapshotCallbackFn, +} from "./types.js"; + +export const promiseHooks = { + createHook(_callbacks: HookCallbacks = {}): () => void { + return unsupported("promiseHooks.createHook()"); + }, + + onInit(_callback: Init): () => void { + return unsupported("promiseHooks.onInit()"); + }, + + onBefore(_callback: Before): () => void { + return unsupported("promiseHooks.onBefore()"); + }, + + onAfter(_callback: After): () => void { + return unsupported("promiseHooks.onAfter()"); + }, + + onSettled(_callback: Settled): () => void { + return unsupported("promiseHooks.onSettled()"); + }, +}; + +function notBuildingSnapshot(): never { + throw codedError( + new Error("Operation cannot be invoked when not building startup snapshot"), + "ERR_NOT_BUILDING_SNAPSHOT", + ); +} + +/** A component is never executing inside Node's startup-snapshot builder. */ +export const startupSnapshot = { + addDeserializeCallback(_callback: StartupSnapshotCallbackFn, _data?: unknown): void { + notBuildingSnapshot(); + }, + + addSerializeCallback(_callback: StartupSnapshotCallbackFn, _data?: unknown): void { + notBuildingSnapshot(); + }, + + setDeserializeMainFunction(_callback: StartupSnapshotCallbackFn, _data?: unknown): void { + notBuildingSnapshot(); + }, + + isBuildingSnapshot(): boolean { + return false; + }, +}; + +export function queryObjects(_ctor: QueryConstructor): number | string[]; +export function queryObjects(_ctor: QueryConstructor, _options: { format: "count" }): number; +export function queryObjects(_ctor: QueryConstructor, _options: { format: "summary" }): string[]; +export function queryObjects( + _ctor: QueryConstructor, + _options?: { format: "count" | "summary" }, +): number | string[] { + return unsupported("queryObjects()"); +} + +export function isStringOneByteRepresentation(_content: string): boolean { + return unsupported("isStringOneByteRepresentation()"); +} diff --git a/packages/jco-std/tsconfig.json b/packages/jco-std/tsconfig.json index 64ed430d9..ae0533b99 100644 --- a/packages/jco-std/tsconfig.json +++ b/packages/jco-std/tsconfig.json @@ -11,6 +11,7 @@ "declarationMap": true, "skipLibCheck": true, "paths": { + "jco:node/v8@0.1.0": ["./src/wasi/0.2.x/node/24.x.x/v8-interface.d.ts"], "jco:node/trace-events@0.1.0": ["./src/wasi/0.2.x/node/24.x.x/trace-events-interface.d.ts"], "jco:node/worker-threads@0.1.0": [ "./src/wasi/0.2.x/node/24.x.x/worker-threads-interface.d.ts" diff --git a/packages/jco-std/wit/node-0.1.0/v8.wit b/packages/jco-std/wit/node-0.1.0/v8.wit new file mode 100644 index 000000000..703c3340a --- /dev/null +++ b/packages/jco-std/wit/node-0.1.0/v8.wit @@ -0,0 +1,102 @@ +package jco:node@0.1.0; + +/// Explicit access to a host V8 isolate. Diagnostics describe the host engine. +/// Structured graphs are private transport; returned serialization bytes use native V8 format. +interface v8 { + variant errno { + number(s64), + symbolic(string), + } + + record error { + name: string, + message: string, + code: option, + errno: option, + syscall: option, + } + + enum statistics-kind { + heap, + spaces, + code, + cpp-brief, + cpp-detailed, + } + + record snapshot-options { + expose-internals: option, + expose-numeric-values: option, + } + + cached-data-version-tag: func() -> result; + + /// JSON retains the version-specific native diagnostic schema. + statistics: func(kind: statistics-kind) -> result; + + get-heap-snapshot: func(options: snapshot-options) -> result, error>; + + write-heap-snapshot: func( + filename: option, + options: snapshot-options, + ) -> result; + + set-flags-from-string: func(%flags: string) -> result<_, error>; + + take-coverage: func() -> result<_, error>; + + stop-coverage: func() -> result<_, error>; + + set-heap-snapshot-near-heap-limit: func(limit: u32) -> result<_, error>; + + open-writer: func(defaults: bool) -> result; + + open-reader: func(data: list, defaults: bool) -> result; + + start-profile: func(cpu: bool) -> result; + + /// Consuming a handle releases the corresponding native state. + release-writer: func(handle: writer); + + release-reader: func(handle: reader); + + release-profile: func(handle: profile); + + resource writer { + write-header: func() -> result<_, error>; + + write-value: func(graph: string) -> result; + + release-buffer: func() -> result, error>; + + write-uint32: func(value: f64) -> result<_, error>; + + write-uint64: func(hi: f64, lo: f64) -> result<_, error>; + + write-double: func(value: f64) -> result<_, error>; + + write-raw-bytes: func(data: list) -> result<_, error>; + + set-treat-views-as-host-objects: func(flag: bool) -> result<_, error>; + } + + resource reader { + read-header: func() -> result; + + read-value: func() -> result; + + get-wire-format-version: func() -> result; + + read-uint32: func() -> result; + + read-uint64: func() -> result, error>; + + read-double: func() -> result; + + read-raw-bytes: func(length: f64) -> result, error>; + } + + resource profile { + stop: func() -> result, error>; + } +} From 6a0f8f2691179da2e5d02a1d60d7b8d4b1475156 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Sat, 12 Sep 2026 13:00:00 +0000 Subject: [PATCH 04/12] test(std): verify V8 APIs against native Node --- .../test/wasi/0.2.x/node/24.x.x/helpers/v8.ts | 32 +++++++++ .../node/24.x.x/v8/DefaultDeserializer.ts | 29 ++++++++ .../0.2.x/node/24.x.x/v8/DefaultSerializer.ts | 34 +++++++++ .../wasi/0.2.x/node/24.x.x/v8/Deserializer.ts | 26 +++++++ .../wasi/0.2.x/node/24.x.x/v8/GCProfiler.ts | 18 +++++ .../wasi/0.2.x/node/24.x.x/v8/Serializer.ts | 54 ++++++++++++++ .../node/24.x.x/v8/cachedDataVersionTag.ts | 8 +++ .../wasi/0.2.x/node/24.x.x/v8/deserialize.ts | 22 ++++++ .../node/24.x.x/v8/getCppHeapStatistics.ts | 16 +++++ .../node/24.x.x/v8/getHeapCodeStatistics.ts | 11 +++ .../0.2.x/node/24.x.x/v8/getHeapSnapshot.ts | 23 ++++++ .../node/24.x.x/v8/getHeapSpaceStatistics.ts | 13 ++++ .../0.2.x/node/24.x.x/v8/getHeapStatistics.ts | 11 +++ .../v8/isStringOneByteRepresentation.ts | 8 +++ .../test/wasi/0.2.x/node/24.x.x/v8/module.ts | 70 +++++++++++++++++++ .../wasi/0.2.x/node/24.x.x/v8/promiseHooks.ts | 21 ++++++ .../wasi/0.2.x/node/24.x.x/v8/queryObjects.ts | 14 ++++ .../wasi/0.2.x/node/24.x.x/v8/serialize.ts | 53 ++++++++++++++ .../node/24.x.x/v8/setFlagsFromString.ts | 17 +++++ .../24.x.x/v8/setHeapSnapshotNearHeapLimit.ts | 19 +++++ .../0.2.x/node/24.x.x/v8/startCpuProfile.ts | 12 ++++ .../0.2.x/node/24.x.x/v8/startupSnapshot.ts | 22 ++++++ .../wasi/0.2.x/node/24.x.x/v8/stopCoverage.ts | 31 ++++++++ .../wasi/0.2.x/node/24.x.x/v8/takeCoverage.ts | 30 ++++++++ .../0.2.x/node/24.x.x/v8/writeHeapSnapshot.ts | 22 ++++++ 25 files changed, 616 insertions(+) create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/helpers/v8.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/DefaultDeserializer.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/DefaultSerializer.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/Deserializer.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/GCProfiler.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/Serializer.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/cachedDataVersionTag.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/deserialize.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/getCppHeapStatistics.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/getHeapCodeStatistics.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/getHeapSnapshot.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/getHeapSpaceStatistics.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/getHeapStatistics.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/isStringOneByteRepresentation.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/module.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/promiseHooks.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/queryObjects.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/serialize.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/setFlagsFromString.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/setHeapSnapshotNearHeapLimit.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/startCpuProfile.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/startupSnapshot.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/stopCoverage.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/takeCoverage.ts create mode 100644 packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/writeHeapSnapshot.ts diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/helpers/v8.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/helpers/v8.ts new file mode 100644 index 000000000..7fccab9a0 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/helpers/v8.ts @@ -0,0 +1,32 @@ +import { createV8 } from "../../../../../../src/wasi/0.2.x/node/24.x.x/v8/core.js"; +import host from "../../../../../../src/wasi/0.2.x/node/24.x.x/v8-host-node.js"; +import denied from "../../../../../../src/wasi/0.2.x/node/24.x.x/v8-host.js"; + +export const v8 = createV8(host); + +export const blocked = createV8(denied); + +export const deniedError = { name: "Error", code: "ERR_JCO_V8_ADAPTER_REQUIRED" }; + +export const unsupportedError = { name: "Error", code: "ERR_JCO_UNSUPPORTED_NODE_API" }; + +/** Isolate native flags and coverage changes from Vitest's own V8 isolate. */ +export async function runNode( + source: string, + environment: Record = {}, +): Promise { + const { execFileSync } = await import("node:child_process"); + const host = new URL( + "../../../../../../dist/wasi/0.2.x/node/24.x.x/v8-host-node.js", + import.meta.url, + ).href; + const core = new URL("../../../../../../dist/wasi/0.2.x/node/24.x.x/v8/core.js", import.meta.url) + .href; + const script = `import host from ${JSON.stringify(host)};\nimport { createV8 } from ${JSON.stringify(core)};\nconst v8 = createV8(host);\n${source}`; + + return execFileSync(process.execPath, ["--input-type=module", "-e", script], { + encoding: "utf8", + env: { ...process.env, ...environment }, + timeout: 30_000, + }).trim(); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/DefaultDeserializer.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/DefaultDeserializer.ts new file mode 100644 index 000000000..18d5209a7 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/DefaultDeserializer.ts @@ -0,0 +1,29 @@ +import { expect, test } from "vitest"; +import native from "node:v8"; +import { Buffer } from "node:buffer"; +import { v8, blocked, deniedError, unsupportedError } from "../helpers/v8.js"; + +test("preserves reference identity across readValue calls", () => { + const writer = new native.DefaultSerializer(); + const value = { buffer: Buffer.from("hi") }; + writer.writeHeader(); + writer.writeValue(value); + writer.writeValue(value); + const reader = new v8.DefaultDeserializer(writer.releaseBuffer()); + try { + expect(reader).toBeInstanceOf(v8.Deserializer); + reader.readHeader(); + const first = reader.readValue(); + expect(reader.readValue()).toBe(first); + expect(first).toEqual(value); + expect(() => reader.transferArrayBuffer(1, new ArrayBuffer(1))).toThrow( + expect.objectContaining(unsupportedError), + ); + } finally { + reader[Symbol.dispose](); + } + + expect(() => new blocked.DefaultDeserializer(Buffer.alloc(0))).toThrow( + expect.objectContaining(deniedError), + ); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/DefaultSerializer.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/DefaultSerializer.ts new file mode 100644 index 000000000..3e77cca41 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/DefaultSerializer.ts @@ -0,0 +1,34 @@ +import { expect, test } from "vitest"; +import native from "node:v8"; +import { Buffer } from "node:buffer"; +import { v8, blocked, deniedError, unsupportedError } from "../helpers/v8.js"; + +test("preserves Buffer branding and keeps persistent object identities", () => { + const writer = new v8.DefaultSerializer(); + const value = { buffer: Buffer.from("hi") }; + expect(writer).toBeInstanceOf(v8.Serializer); + writer.writeHeader(); + writer.writeValue(value); + writer.writeValue(value); + const reader = new native.DefaultDeserializer(writer.releaseBuffer()); + reader.readHeader(); + const first = reader.readValue(); + expect(Buffer.isBuffer(first.buffer)).toBe(true); + expect(reader.readValue()).toBe(first); + expect(() => new blocked.DefaultSerializer()).toThrow(expect.objectContaining(deniedError)); +}); + +test("custom hooks and transfer registrations fail explicitly", () => { + const writer = new v8.DefaultSerializer(); + try { + expect(() => writer.transferArrayBuffer(1, new ArrayBuffer(1))).toThrow( + expect.objectContaining(unsupportedError), + ); + writer._writeHostObject = () => {}; + expect(() => writer.writeValue(Buffer.from("x"))).toThrow( + expect.objectContaining(unsupportedError), + ); + } finally { + writer[Symbol.dispose](); + } +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/Deserializer.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/Deserializer.ts new file mode 100644 index 000000000..3d7d67f57 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/Deserializer.ts @@ -0,0 +1,26 @@ +import { expect, test } from "vitest"; +import native from "node:v8"; +import { Buffer } from "node:buffer"; +import { v8, blocked, deniedError } from "../helpers/v8.js"; + +test("reads native scalar primitives, raw bytes and wire versions", () => { + const writer = new native.Serializer(); + writer.writeHeader(); + writer.writeUint32(0xffffffff); + writer.writeUint64(0xffffffff, 0xfffffffe); + writer.writeDouble(NaN); + writer.writeRawBytes(Buffer.from("abc")); + const reader = new v8.Deserializer(writer.releaseBuffer()); + expect(reader.readHeader()).toBe(true); + expect(reader.getWireFormatVersion()).toBeGreaterThan(0); + expect(reader.readUint32()).toBe(0xffffffff); + expect(reader.readUint64()).toEqual([0xffffffff, 0xfffffffe]); + expect(reader.readDouble()).toBeNaN(); + expect(reader.readRawBytes(3).toString()).toBe("abc"); + expect(() => reader.readRawBytes(1)).toThrow(); + reader[Symbol.dispose](); + expect(() => reader.readHeader()).toThrow(expect.objectContaining({ code: "ERR_INVALID_STATE" })); + expect(() => new blocked.Deserializer(Buffer.alloc(0))).toThrow( + expect.objectContaining(deniedError), + ); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/GCProfiler.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/GCProfiler.ts new file mode 100644 index 000000000..33084ca19 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/GCProfiler.ts @@ -0,0 +1,18 @@ +import { expect, test } from "vitest"; +import { v8, blocked, deniedError } from "../helpers/v8.js"; + +test("starts lazily, stops idempotently, and produces native profile data", () => { + const profiler = new v8.GCProfiler(); + expect(profiler.stop()).toBeUndefined(); + profiler.start(); + profiler.start(); + const result = profiler.stop(); + expect(result).toMatchObject({ version: 1, statistics: expect.any(Array) }); + expect(result!.endTime).toBeGreaterThanOrEqual(result!.startTime); + expect(profiler.stop()).toBeUndefined(); + profiler.start(); + profiler[Symbol.dispose](); + const denied = new blocked.GCProfiler(); + expect(() => denied.start()).toThrow(expect.objectContaining(deniedError)); + expect(denied.stop()).toBeUndefined(); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/Serializer.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/Serializer.ts new file mode 100644 index 000000000..6872e5aab --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/Serializer.ts @@ -0,0 +1,54 @@ +import { expect, test } from "vitest"; +import native from "node:v8"; +import { Buffer } from "node:buffer"; +import { v8, blocked, deniedError } from "../helpers/v8.js"; + +test("writes native headers, scalar primitives and raw view bytes", () => { + const writer = new v8.Serializer(); + writer.writeHeader(); + writer.writeUint32(0xffffffff); + writer.writeUint64(0xffffffff, 0xfffffffe); + writer.writeDouble(-0.25); + writer.writeRawBytes(new DataView(Uint8Array.from([0, 1, 2, 0]).buffer, 1, 2)); + const reader = new native.Deserializer(writer.releaseBuffer()); + expect(reader.readHeader()).toBe(true); + expect(reader.readUint32()).toBe(0xffffffff); + expect(reader.readUint64()).toEqual([0xffffffff, 0xfffffffe]); + expect(reader.readDouble()).toBe(-0.25); + expect(reader.readRawBytes(2)).toEqual(Buffer.from([1, 2])); + + writer.writeHeader(); + writer.writeValue(42); + expect(native.deserialize(writer.releaseBuffer())).toBe(42); + writer[Symbol.dispose](); + expect(() => writer.writeHeader()).toThrow( + expect.objectContaining({ code: "ERR_INVALID_STATE" }), + ); + expect(() => new blocked.Serializer()).toThrow(expect.objectContaining(deniedError)); +}); + +test("preserves native cross-write references and rolls back a failed graph encode", () => { + const writer = new v8.Serializer(); + const item = { value: 1 }; + writer.writeHeader(); + writer.writeValue(item); + item.value = 2; + expect(() => writer.writeValue({ invalid: () => {} })).toThrow(); + writer.writeValue(item); + writer.writeValue({ next: item }); + const reader = new native.Deserializer(writer.releaseBuffer()); + reader.readHeader(); + const first = reader.readValue(); + expect(reader.readValue()).toBe(first); + expect(reader.readValue().next).toBe(first); + expect(first).toEqual({ value: 1 }); +}); + +test("retains view settings when releaseBuffer permits serializer reuse", () => { + const writer = new v8.Serializer(); + writer._setTreatArrayBufferViewsAsHostObjects(true); + writer.releaseBuffer(); + + expect(() => writer.writeValue(new Uint8Array([1]))).toThrow(); + writer[Symbol.dispose](); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/cachedDataVersionTag.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/cachedDataVersionTag.ts new file mode 100644 index 000000000..c9b92b6da --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/cachedDataVersionTag.ts @@ -0,0 +1,8 @@ +import { expect, test } from "vitest"; +import native from "node:v8"; +import { v8, blocked, deniedError } from "../helpers/v8.js"; + +test("reads the real host version tag and denies without a provider", () => { + expect(v8.cachedDataVersionTag()).toBe(native.cachedDataVersionTag()); + expect(() => blocked.cachedDataVersionTag()).toThrow(expect.objectContaining(deniedError)); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/deserialize.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/deserialize.ts new file mode 100644 index 000000000..750b5333c --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/deserialize.ts @@ -0,0 +1,22 @@ +import { expect, test } from "vitest"; +import native from "node:v8"; +import { Buffer } from "node:buffer"; +import { v8, blocked, deniedError } from "../helpers/v8.js"; + +test("reads native buffers, typed views, and the upstream version-13 cyclic fixture", () => { + const encoded = native.serialize({ value: [1, 2, 3], buffer: Buffer.from("hello") }); + const padded = Buffer.concat([Buffer.from([0]), encoded, Buffer.from([0])]); + expect( + v8.deserialize(new DataView(padded.buffer, padded.byteOffset + 1, encoded.length)), + ).toEqual(native.deserialize(encoded)); + + // Fixture from Node v24.20.0 test/parallel/test-v8-serdes.js (MIT), + // commit 71b8b174857e25106d39b61a9e6f30d927da8b01: an older cyclic wire format. + const cycle = v8.deserialize(Buffer.from("ff0d6f2203666f6f5e007b01", "hex")) as { foo: unknown }; + expect(cycle.foo).toBe(cycle); + expect(() => v8.deserialize(Buffer.from("invalid"))).toThrow(/deserialize/); + expect(() => Reflect.apply(v8.deserialize, null, ["invalid"])).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + expect(() => blocked.deserialize(encoded)).toThrow(expect.objectContaining(deniedError)); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/getCppHeapStatistics.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/getCppHeapStatistics.ts new file mode 100644 index 000000000..e827ff24a --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/getCppHeapStatistics.ts @@ -0,0 +1,16 @@ +import { expect, test } from "vitest"; +import native from "node:v8"; +import { v8, blocked, deniedError } from "../helpers/v8.js"; + +test("preserves detailed and brief native schemas and validates detail level", () => { + for (const detail of [undefined, "brief", "detailed"] as const) { + expect(Object.keys(v8.getCppHeapStatistics(detail))).toEqual( + Object.keys(native.getCppHeapStatistics(detail)), + ); + } + + expect(() => Reflect.apply(v8.getCppHeapStatistics, null, ["invalid"])).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_VALUE" }), + ); + expect(() => blocked.getCppHeapStatistics()).toThrow(expect.objectContaining(deniedError)); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/getHeapCodeStatistics.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/getHeapCodeStatistics.ts new file mode 100644 index 000000000..474314bb7 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/getHeapCodeStatistics.ts @@ -0,0 +1,11 @@ +import { expect, test } from "vitest"; +import native from "node:v8"; +import { v8, blocked, deniedError } from "../helpers/v8.js"; + +test("returns native code statistics including profiler metadata", () => { + const stats = v8.getHeapCodeStatistics(); + expect(Object.keys(stats)).toEqual(Object.keys(native.getHeapCodeStatistics())); + expect(stats.code_and_metadata_size).toBeGreaterThan(0); + expect(stats.bytecode_and_metadata_size).toBeGreaterThan(0); + expect(() => blocked.getHeapCodeStatistics()).toThrow(expect.objectContaining(deniedError)); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/getHeapSnapshot.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/getHeapSnapshot.ts new file mode 100644 index 000000000..d2a0f638f --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/getHeapSnapshot.ts @@ -0,0 +1,23 @@ +import { expect, test } from "vitest"; +import { Buffer } from "node:buffer"; +import { v8, blocked, deniedError } from "../helpers/v8.js"; + +test("returns a byte-mode Readable containing an actual heap snapshot", async () => { + const stream = v8.getHeapSnapshot({ exposeNumericValues: true }); + const chunks: Uint8Array[] = []; + + for await (const chunk of stream) { + expect(chunk).toBeInstanceOf(Uint8Array); + chunks.push(chunk as Uint8Array); + } + + const result = JSON.parse(Buffer.concat(chunks).toString()); + + expect(result.snapshot.meta.node_fields).toContain("type"); + expect(result.nodes.length).toBeGreaterThan(0); + expect(result.strings.length).toBeGreaterThan(0); + expect(() => blocked.getHeapSnapshot()).toThrow(expect.objectContaining(deniedError)); + expect(() => Reflect.apply(v8.getHeapSnapshot, null, [{ exposeInternals: "yes" }])).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/getHeapSpaceStatistics.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/getHeapSpaceStatistics.ts new file mode 100644 index 000000000..2b05760df --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/getHeapSpaceStatistics.ts @@ -0,0 +1,13 @@ +import { expect, test } from "vitest"; +import native from "node:v8"; +import { v8, blocked, deniedError } from "../helpers/v8.js"; + +test("returns real heap spaces with native record shapes", () => { + const spaces = v8.getHeapSpaceStatistics(); + expect(spaces.map((space) => space.space_name)).toEqual( + native.getHeapSpaceStatistics().map((space) => space.space_name), + ); + expect(spaces.some((space) => space.space_used_size > 0)).toBe(true); + expect(Object.keys(spaces[0])).toEqual(Object.keys(native.getHeapSpaceStatistics()[0])); + expect(() => blocked.getHeapSpaceStatistics()).toThrow(expect.objectContaining(deniedError)); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/getHeapStatistics.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/getHeapStatistics.ts new file mode 100644 index 000000000..d56b2d028 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/getHeapStatistics.ts @@ -0,0 +1,11 @@ +import { expect, test } from "vitest"; +import native from "node:v8"; +import { v8, blocked, deniedError } from "../helpers/v8.js"; + +test("returns native heap fields and meaningful sizes", () => { + const stats = v8.getHeapStatistics(); + expect(Object.keys(stats)).toEqual(Object.keys(native.getHeapStatistics())); + expect(stats.used_heap_size).toBeGreaterThan(0); + expect(stats.heap_size_limit).toBe(native.getHeapStatistics().heap_size_limit); + expect(() => blocked.getHeapStatistics()).toThrow(expect.objectContaining(deniedError)); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/isStringOneByteRepresentation.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/isStringOneByteRepresentation.ts new file mode 100644 index 000000000..52d4abf12 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/isStringOneByteRepresentation.ts @@ -0,0 +1,8 @@ +import { expect, test } from "vitest"; +import { v8, unsupportedError } from "../helpers/v8.js"; + +test("refuses to misreport the representation of a transported string", () => { + expect(() => v8.isStringOneByteRepresentation("ascii")).toThrow( + expect.objectContaining(unsupportedError), + ); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/module.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/module.ts new file mode 100644 index 000000000..324d2ae95 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/module.ts @@ -0,0 +1,70 @@ +import { expect, test } from "vitest"; +import native from "node:v8"; +import { v8 } from "../helpers/v8.js"; + +test("matches Node 24.20.0 exports and class inheritance", () => { + expect(process.version).toBe("v24.20.0"); + expect(Object.keys(v8).sort()).toEqual(Object.keys(native).sort()); + expect(Object.keys(v8.promiseHooks)).toEqual(Object.keys(native.promiseHooks)); + expect(Object.keys(v8.startupSnapshot)).toEqual(Object.keys(native.startupSnapshot)); + expect(Object.getPrototypeOf(v8.DefaultSerializer)).toBe(v8.Serializer); + expect(Object.getPrototypeOf(v8.DefaultDeserializer)).toBe(v8.Deserializer); + expect(Reflect.apply(v8.startupSnapshot.isBuildingSnapshot, null, [])).toBe(false); +}); + +test("importing and guest-only unsupported APIs never consult the host", async () => { + const { createV8 } = await import("../../../../../../src/wasi/0.2.x/node/24.x.x/v8/core.js"); + const denied = await import("../../../../../../src/wasi/0.2.x/node/24.x.x/v8-host.js"); + let accesses = 0; + const host = new Proxy(denied.default, { + get() { + accesses++; + throw new Error("unexpected host access"); + }, + }); + const local = createV8(host); + + expect(local.startupSnapshot.isBuildingSnapshot()).toBe(false); + expect(new local.GCProfiler().stop()).toBeUndefined(); + expect(() => local.queryObjects(Object)).toThrow(/not supported/); + expect(accesses).toBe(0); + + for (const Constructor of [denied.Writer, denied.Reader, denied.Profile]) { + expect(() => new Constructor()).toThrow( + expect.objectContaining({ code: "ERR_JCO_V8_ADAPTER_REQUIRED" }), + ); + + const resource = Object.create(Constructor.prototype); + + for (const name of Object.getOwnPropertyNames(Constructor.prototype)) { + if (name !== "constructor") { + expect(() => Reflect.apply(resource[name], resource, [])).toThrow( + expect.objectContaining({ code: "ERR_JCO_V8_ADAPTER_REQUIRED" }), + ); + } + } + } +}); + +test("matches native constructor prototype names and descriptors", () => { + for (const name of [ + "Serializer", + "Deserializer", + "DefaultSerializer", + "DefaultDeserializer", + "GCProfiler", + ] as const) { + const actual = Object.getOwnPropertyDescriptors(v8[name].prototype); + const expected = Object.getOwnPropertyDescriptors(native[name].prototype); + + expect(Object.keys(actual).sort()).toEqual(Object.keys(expected).sort()); + + for (const key of Object.keys(expected)) { + expect(actual[key]).toMatchObject({ + enumerable: expected[key].enumerable, + configurable: expected[key].configurable, + writable: expected[key].writable, + }); + } + } +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/promiseHooks.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/promiseHooks.ts new file mode 100644 index 000000000..fb32b0b7f --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/promiseHooks.ts @@ -0,0 +1,21 @@ +import { expect, test } from "vitest"; +import { v8, unsupportedError } from "../helpers/v8.js"; + +test("never installs inert hooks or reads callback getters", () => { + for (const name of ["onInit", "onBefore", "onAfter", "onSettled"] as const) { + expect(() => v8.promiseHooks[name](() => {})).toThrow( + expect.objectContaining(unsupportedError), + ); + } + + let accessed = false; + expect(() => + v8.promiseHooks.createHook({ + get init() { + accessed = true; + return () => {}; + }, + }), + ).toThrow(expect.objectContaining(unsupportedError)); + expect(accessed).toBe(false); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/queryObjects.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/queryObjects.ts new file mode 100644 index 000000000..0f9af8d55 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/queryObjects.ts @@ -0,0 +1,14 @@ +import { expect, test } from "vitest"; +import { v8, unsupportedError } from "../helpers/v8.js"; + +test("does not query a different heap or inspect the constructor", () => { + const ctor = new Proxy(function Example() {}, { + get() { + throw new Error("must not inspect"); + }, + }); + expect(() => v8.queryObjects(ctor)).toThrow(expect.objectContaining(unsupportedError)); + expect(() => v8.queryObjects(ctor, { format: "summary" })).toThrow( + expect.objectContaining(unsupportedError), + ); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/serialize.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/serialize.ts new file mode 100644 index 000000000..c6679b776 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/serialize.ts @@ -0,0 +1,53 @@ +import { expect, test } from "vitest"; +import native from "node:v8"; +import { Buffer } from "node:buffer"; +import { v8, blocked, deniedError, unsupportedError } from "../helpers/v8.js"; + +test("produces native binary data for primitives and structured graphs", () => { + const shared = { value: 42 }; + const cycle: Record = { shared, alias: shared }; + cycle.self = cycle; + const backing = new ArrayBuffer(16); + const values = [ + undefined, + null, + -0, + NaN, + Infinity, + -Infinity, + 123n, + "hello 💚", + cycle, + new Map([[shared, shared]]), + new Set([shared]), + new Date(NaN), + /hello/gi, + new Uint32Array(backing, 4, 2), + new DataView(backing, 3, 4), + Buffer.from([1, 2, 3]), + ]; + + for (const value of values) { + const encoded = v8.serialize(value); + expect(Buffer.isBuffer(encoded)).toBe(true); + expect(native.deserialize(encoded)).toEqual(native.deserialize(native.serialize(value))); + } + + expect(() => blocked.serialize({})).toThrow(expect.objectContaining(deniedError)); +}); + +test("refuses unsupported values without running accessors or toJSON", () => { + let accessed = false; + const value = { + get field() { + accessed = true; + return 1; + }, + }; + expect(() => v8.serialize(value)).toThrow(expect.objectContaining(unsupportedError)); + expect(accessed).toBe(false); + expect(() => v8.serialize(new Error("unportable"))).toThrow( + expect.objectContaining(unsupportedError), + ); + expect(() => v8.serialize(() => 1)).toThrow(); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/setFlagsFromString.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/setFlagsFromString.ts new file mode 100644 index 000000000..650d060ee --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/setFlagsFromString.ts @@ -0,0 +1,17 @@ +import { expect, test } from "vitest"; +import { v8, blocked, deniedError, runNode } from "../helpers/v8.js"; + +test("changes the native host V8 flags in an isolated process", async () => { + expect( + await runNode(` + const before = v8.cachedDataVersionTag(); + v8.setFlagsFromString('--allow_natives_syntax'); + console.log(before !== v8.cachedDataVersionTag()); + `), + ).toBe("true"); + + expect(() => blocked.setFlagsFromString("")).toThrow(expect.objectContaining(deniedError)); + expect(() => Reflect.apply(v8.setFlagsFromString, null, [42])).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/setHeapSnapshotNearHeapLimit.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/setHeapSnapshotNearHeapLimit.ts new file mode 100644 index 000000000..a0d164c2a --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/setHeapSnapshotNearHeapLimit.ts @@ -0,0 +1,19 @@ +import { expect, test } from "vitest"; +import { v8, blocked, deniedError, runNode } from "../helpers/v8.js"; + +test("registers the native near-limit callback and validates its limit", async () => { + expect( + await runNode(` + v8.setHeapSnapshotNearHeapLimit(1); + v8.setHeapSnapshotNearHeapLimit(1); + console.log('registered'); + `), + ).toBe("registered"); + + expect(() => v8.setHeapSnapshotNearHeapLimit(0)).toThrow( + expect.objectContaining({ code: "ERR_OUT_OF_RANGE" }), + ); + expect(() => blocked.setHeapSnapshotNearHeapLimit(1)).toThrow( + expect.objectContaining(deniedError), + ); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/startCpuProfile.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/startCpuProfile.ts new file mode 100644 index 000000000..1ca6d64fb --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/startCpuProfile.ts @@ -0,0 +1,12 @@ +import { expect, test } from "vitest"; +import { v8, blocked, deniedError } from "../helpers/v8.js"; + +test("returns an actual CPU profile and stops only once", () => { + const profile = v8.startCpuProfile(); + const result = JSON.parse(profile.stop()!); + expect(result.nodes.length).toBeGreaterThan(0); + expect(result.endTime).toBeGreaterThanOrEqual(result.startTime); + expect(profile.stop()).toBeUndefined(); + profile[Symbol.dispose](); + expect(() => blocked.startCpuProfile()).toThrow(expect.objectContaining(deniedError)); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/startupSnapshot.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/startupSnapshot.ts new file mode 100644 index 000000000..ebf6520c5 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/startupSnapshot.ts @@ -0,0 +1,22 @@ +import { expect, test } from "vitest"; +import native from "node:v8"; +import { v8 } from "../helpers/v8.js"; + +test("matches ordinary Node outside a startup snapshot build", () => { + expect(v8.startupSnapshot.isBuildingSnapshot()).toBe(false); + for (const name of [ + "addDeserializeCallback", + "addSerializeCallback", + "setDeserializeMainFunction", + ] as const) { + const fn = () => { + throw new Error("must not execute"); + }; + expect(() => native.startupSnapshot[name](fn)).toThrow( + expect.objectContaining({ code: "ERR_NOT_BUILDING_SNAPSHOT" }), + ); + expect(() => v8.startupSnapshot[name](fn)).toThrow( + expect.objectContaining({ code: "ERR_NOT_BUILDING_SNAPSHOT" }), + ); + } +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/stopCoverage.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/stopCoverage.ts new file mode 100644 index 000000000..b03354992 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/stopCoverage.ts @@ -0,0 +1,31 @@ +import { expect, test } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { blocked, deniedError, runNode } from "../helpers/v8.js"; + +test("stopCoverage controls real host coverage in an isolated process", async () => { + const directory = await mkdtemp(join(tmpdir(), "v8-coverage-")); + + try { + expect( + await runNode( + ` + import { readdirSync } from 'node:fs'; + function measured() { return 42; } + measured(); + v8.takeCoverage(); + const before = readdirSync(process.env.NODE_V8_COVERAGE).length; + v8.stopCoverage(); + v8.takeCoverage(); + console.log(before === readdirSync(process.env.NODE_V8_COVERAGE).length); + `, + { NODE_V8_COVERAGE: directory }, + ), + ).toBe("true"); + } finally { + await rm(directory, { recursive: true, force: true }); + } + + expect(() => blocked.stopCoverage()).toThrow(expect.objectContaining(deniedError)); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/takeCoverage.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/takeCoverage.ts new file mode 100644 index 000000000..08b61a839 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/takeCoverage.ts @@ -0,0 +1,30 @@ +import { expect, test } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { blocked, deniedError, runNode } from "../helpers/v8.js"; + +test("takeCoverage controls real host coverage in an isolated process", async () => { + const directory = await mkdtemp(join(tmpdir(), "v8-coverage-")); + + try { + expect( + await runNode( + ` + import { readdirSync } from 'node:fs'; + function measured() { return 42; } + measured(); + v8.takeCoverage(); + const before = readdirSync(process.env.NODE_V8_COVERAGE).length; + console.log(before > 0); + v8.stopCoverage(); + `, + { NODE_V8_COVERAGE: directory }, + ), + ).toBe("true"); + } finally { + await rm(directory, { recursive: true, force: true }); + } + + expect(() => blocked.takeCoverage()).toThrow(expect.objectContaining(deniedError)); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/writeHeapSnapshot.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/writeHeapSnapshot.ts new file mode 100644 index 000000000..571e680f5 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/writeHeapSnapshot.ts @@ -0,0 +1,22 @@ +import { expect, test } from "vitest"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { v8, blocked, deniedError } from "../helpers/v8.js"; + +test("writes a real snapshot to the requested host path", async () => { + const directory = await mkdtemp(join(tmpdir(), "v8-snapshot-")); + const filename = join(directory, "snapshot.heapsnapshot"); + + try { + expect(v8.writeHeapSnapshot(filename)).toBe(filename); + expect(JSON.parse(await readFile(filename, "utf8")).nodes.length).toBeGreaterThan(0); + expect(() => blocked.writeHeapSnapshot(filename)).toThrow(expect.objectContaining(deniedError)); + } finally { + await rm(directory, { recursive: true, force: true }); + } + + expect(() => Reflect.apply(v8.writeHeapSnapshot, null, [42])).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); +}); From 764ac9edf136e506db4e8141e5d5e4cde96d4b5e Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Sat, 12 Sep 2026 13:00:13 +0000 Subject: [PATCH 05/12] feat(jco): support node:v8 component imports --- .../jco/lib/wit/builtin/jco-node-0.1.0/v8.wit | 102 ++++++++++++++++++ packages/jco/src/cmd/transpile.ts | 1 + packages/jco/src/node-builtins/index.ts | 2 + packages/jco/src/node-builtins/types.ts | 2 + packages/jco/src/node-builtins/v8.ts | 10 ++ packages/jco/src/node-wit.ts | 2 + 6 files changed, 119 insertions(+) create mode 100644 packages/jco/lib/wit/builtin/jco-node-0.1.0/v8.wit create mode 100644 packages/jco/src/node-builtins/v8.ts diff --git a/packages/jco/lib/wit/builtin/jco-node-0.1.0/v8.wit b/packages/jco/lib/wit/builtin/jco-node-0.1.0/v8.wit new file mode 100644 index 000000000..703c3340a --- /dev/null +++ b/packages/jco/lib/wit/builtin/jco-node-0.1.0/v8.wit @@ -0,0 +1,102 @@ +package jco:node@0.1.0; + +/// Explicit access to a host V8 isolate. Diagnostics describe the host engine. +/// Structured graphs are private transport; returned serialization bytes use native V8 format. +interface v8 { + variant errno { + number(s64), + symbolic(string), + } + + record error { + name: string, + message: string, + code: option, + errno: option, + syscall: option, + } + + enum statistics-kind { + heap, + spaces, + code, + cpp-brief, + cpp-detailed, + } + + record snapshot-options { + expose-internals: option, + expose-numeric-values: option, + } + + cached-data-version-tag: func() -> result; + + /// JSON retains the version-specific native diagnostic schema. + statistics: func(kind: statistics-kind) -> result; + + get-heap-snapshot: func(options: snapshot-options) -> result, error>; + + write-heap-snapshot: func( + filename: option, + options: snapshot-options, + ) -> result; + + set-flags-from-string: func(%flags: string) -> result<_, error>; + + take-coverage: func() -> result<_, error>; + + stop-coverage: func() -> result<_, error>; + + set-heap-snapshot-near-heap-limit: func(limit: u32) -> result<_, error>; + + open-writer: func(defaults: bool) -> result; + + open-reader: func(data: list, defaults: bool) -> result; + + start-profile: func(cpu: bool) -> result; + + /// Consuming a handle releases the corresponding native state. + release-writer: func(handle: writer); + + release-reader: func(handle: reader); + + release-profile: func(handle: profile); + + resource writer { + write-header: func() -> result<_, error>; + + write-value: func(graph: string) -> result; + + release-buffer: func() -> result, error>; + + write-uint32: func(value: f64) -> result<_, error>; + + write-uint64: func(hi: f64, lo: f64) -> result<_, error>; + + write-double: func(value: f64) -> result<_, error>; + + write-raw-bytes: func(data: list) -> result<_, error>; + + set-treat-views-as-host-objects: func(flag: bool) -> result<_, error>; + } + + resource reader { + read-header: func() -> result; + + read-value: func() -> result; + + get-wire-format-version: func() -> result; + + read-uint32: func() -> result; + + read-uint64: func() -> result, error>; + + read-double: func() -> result; + + read-raw-bytes: func(length: f64) -> result, error>; + } + + resource profile { + stop: func() -> result, error>; + } +} diff --git a/packages/jco/src/cmd/transpile.ts b/packages/jco/src/cmd/transpile.ts index d2c9bef0e..206249b73 100644 --- a/packages/jco/src/cmd/transpile.ts +++ b/packages/jco/src/cmd/transpile.ts @@ -45,6 +45,7 @@ const HTTP2_ASYNC_IMPORTS = [ `${HTTP2_CAPABILITY}#[method]server.close`, ]; const DEFAULT_NODE_CAPABILITY_MAP = { + "jco:node/v8@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/v8/host", "jco:node/tls@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/node-host", "jco:node/dgram@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/dgram/host", "wasi:tls/types@0.2.0-draft": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host", diff --git a/packages/jco/src/node-builtins/index.ts b/packages/jco/src/node-builtins/index.ts index 87d2cf00e..5d33371d9 100644 --- a/packages/jco/src/node-builtins/index.ts +++ b/packages/jco/src/node-builtins/index.ts @@ -25,6 +25,7 @@ import { createStringDecoderBuiltin } from "./string-decoder.js"; import { createZlibBuiltin } from "./zlib.js"; import { createTtyBuiltin } from "./tty.js"; import { createWasiBuiltin } from "./wasi.js"; +import { createV8Builtin } from "./v8.js"; import { createStreamBuiltin } from "./stream.js"; import { createClusterBuiltin } from "./cluster.js"; import { createWorkerThreadsBuiltin } from "./worker-threads.js"; @@ -91,6 +92,7 @@ export function nodeBuiltinPlugin(worldMetadata: WorldMetadata, options: NodeBui createTtyBuiltin, createZlibBuiltin, createWasiBuiltin, + createV8Builtin, createStreamBuiltin, createClusterBuiltin, createWorkerThreadsBuiltin, diff --git a/packages/jco/src/node-builtins/types.ts b/packages/jco/src/node-builtins/types.ts index 557390f21..f6b7611ff 100644 --- a/packages/jco/src/node-builtins/types.ts +++ b/packages/jco/src/node-builtins/types.ts @@ -91,6 +91,8 @@ export interface NodeBuiltinOptions { zlibModule?: string; /** Path to jco-std's versioned `node:wasi` module (overridable for tests) */ wasiModule?: string; + /** Path to the versioned node:v8 module (overridable for tests). */ + v8Module?: string; /** Paths to jco-std's capability-free readline modules (overridable for tests). */ readlineModule?: string; readlinePromisesModule?: string; diff --git a/packages/jco/src/node-builtins/v8.ts b/packages/jco/src/node-builtins/v8.ts new file mode 100644 index 000000000..e698c15f3 --- /dev/null +++ b/packages/jco/src/node-builtins/v8.ts @@ -0,0 +1,10 @@ +import { starReexportAdapter, type BuiltinContext, type BuiltinAdapter, builtin, stdModule } from "./shared.js"; +import { V8_WIT_REQUIREMENT } from "../node-wit.js"; + +export function createV8Builtin({ options }: BuiltinContext): BuiltinAdapter { + return builtin( + "node:v8", + () => starReexportAdapter(stdModule(options.v8Module, "v8"), "v8"), + () => options.onWitRequirement?.(V8_WIT_REQUIREMENT), + ); +} diff --git a/packages/jco/src/node-wit.ts b/packages/jco/src/node-wit.ts index 40a344386..10ffbe043 100644 --- a/packages/jco/src/node-wit.ts +++ b/packages/jco/src/node-wit.ts @@ -164,6 +164,8 @@ export const TLS_WIT_REQUIREMENT: NodeWitRequirement = { ], }; +export const V8_WIT_REQUIREMENT = nodeRequirement("node:v8", "v8"); + export const OS_WIT_REQUIREMENT = nodeRequirement("node:os", "os"); export const ZLIB_WIT_REQUIREMENT = nodeRequirement("node:zlib", "zlib"); From 3bf3fd40add73fa564b39df86a445fd6ab03870f Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Sat, 12 Sep 2026 13:00:26 +0000 Subject: [PATCH 06/12] test(jco): exercise V8 components with native and denied hosts --- .../componentize/node-v8/component.js | 107 ++++++++++++++++++ .../componentize/node-v8/wit/component.wit | 5 + packages/jco/test/node/builtins.js | 2 + packages/jco/test/node/v8.js | 99 ++++++++++++++++ 4 files changed, 213 insertions(+) create mode 100644 packages/jco/test/fixtures/componentize/node-v8/component.js create mode 100644 packages/jco/test/fixtures/componentize/node-v8/wit/component.wit create mode 100644 packages/jco/test/node/v8.js diff --git a/packages/jco/test/fixtures/componentize/node-v8/component.js b/packages/jco/test/fixtures/componentize/node-v8/component.js new file mode 100644 index 000000000..7a4dbae20 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-v8/component.js @@ -0,0 +1,107 @@ +import v8, { serialize, deserialize, Serializer, DefaultSerializer, DefaultDeserializer } from "node:v8"; +import * as namespace from "node:v8"; +import { Buffer } from "node:buffer"; + +export function run(denied) { + if (denied) { + const errors = []; + + for (const operation of [() => serialize(1), () => v8.getHeapStatistics(), () => v8.startCpuProfile()]) { + try { + operation(); + } catch (error) { + errors.push({ name: error.name, code: error.code }); + } + } + + return JSON.stringify({ errors, buildingSnapshot: v8.startupSnapshot.isBuildingSnapshot() }); + } + + const shared = { value: 42 }; + const value = { + shared, + alias: shared, + bigint: 123n, + bytes: Buffer.from("hello 💚"), + numbers: new Uint32Array([0xffffffff, 42]), + map: new Map([[shared, shared]]), + set: new Set([shared]), + special: [-0, Infinity, NaN, undefined], + }; + value.self = value; + + const restored = deserialize(serialize(value)); + const writer = new DefaultSerializer(); + + writer.writeHeader(); + writer.writeUint32(0xffffffff); + writer.writeUint64(0xffffffff, 0xfffffffe); + writer.writeDouble(-0.25); + writer.writeRawBytes(Buffer.from("abc")); + writer.writeValue(value); + writer.writeValue(value); + + const reader = new DefaultDeserializer(writer.releaseBuffer()); + let scalar; + let identity; + + try { + reader.readHeader(); + scalar = [reader.readUint32(), reader.readUint64(), reader.readDouble(), reader.readRawBytes(3).toString()]; + + const first = reader.readValue(); + + identity = reader.readValue() === first && first.self === first && first.map.get(first.shared) === first.shared; + } finally { + reader[Symbol.dispose](); + } + + const profiler = new v8.GCProfiler(); + profiler.start(); + const gc = profiler.stop(); + const cpu = v8.startCpuProfile(); + const cpuData = JSON.parse(cpu.stop()); + const unsupported = []; + + for (const operation of [ + () => v8.promiseHooks.onInit(() => {}), + () => v8.queryObjects(Object), + () => v8.isStringOneByteRepresentation("ascii"), + ]) { + try { + operation(); + } catch (error) { + unsupported.push(error.code); + } + } + + return JSON.stringify({ + module: + namespace.default === v8 && + v8.serialize === serialize && + Object.getPrototypeOf(DefaultSerializer) === Serializer, + graph: + restored.self === restored && + restored.shared === restored.alias && + restored.map.get(restored.shared) === restored.shared && + restored.set.has(restored.shared), + bytes: Buffer.isBuffer(restored.bytes) && restored.bytes.equals(value.bytes), + types: + restored.bigint === 123n && restored.numbers instanceof Uint32Array && restored.numbers[0] === 0xffffffff, + special: + Object.is(restored.special[0], -0) && + restored.special[1] === Infinity && + Number.isNaN(restored.special[2]) && + restored.special[3] === undefined, + scalar, + identity, + tag: v8.cachedDataVersionTag() > 0, + heap: v8.getHeapStatistics().used_heap_size > 0, + spaces: v8.getHeapSpaceStatistics().length > 0, + code: v8.getHeapCodeStatistics().code_and_metadata_size > 0, + cpp: v8.getCppHeapStatistics("brief").detail_level === "brief", + gc: gc.version === 1 && Array.isArray(gc.statistics) && profiler.stop() === undefined, + cpu: cpuData.nodes.length > 0 && cpu.stop() === undefined, + unsupported, + }); +} diff --git a/packages/jco/test/fixtures/componentize/node-v8/wit/component.wit b/packages/jco/test/fixtures/componentize/node-v8/wit/component.wit new file mode 100644 index 000000000..8d54ff7f6 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-v8/wit/component.wit @@ -0,0 +1,5 @@ +package test:v8; + +world component { + export run: func(denied: bool) -> string; +} diff --git a/packages/jco/test/node/builtins.js b/packages/jco/test/node/builtins.js index 9faae381c..20ab39d04 100644 --- a/packages/jco/test/node/builtins.js +++ b/packages/jco/test/node/builtins.js @@ -68,6 +68,7 @@ describe("Node builtin adapters", () => { "jco:node/process@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/process/host", "jco:node/os@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/os/host", "jco:node/zlib@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/zlib/host", + "jco:node/v8@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/v8/host", "jco:node/tty@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tty/host", "jco:node/wasi@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/wasi/host", }); @@ -97,6 +98,7 @@ describe("Node builtin adapters", () => { "jco:node/process@0.1.0": "/application/process-host.js", "jco:node/os@0.1.0": "/application/os-host.js", "jco:node/zlib@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/zlib/host", + "jco:node/v8@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/v8/host", "jco:node/tty@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tty/host", "jco:node/wasi@0.1.0": "@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/wasi/host", }); diff --git a/packages/jco/test/node/v8.js b/packages/jco/test/node/v8.js new file mode 100644 index 000000000..bc2046811 --- /dev/null +++ b/packages/jco/test/node/v8.js @@ -0,0 +1,99 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation"; +import { expect, test, vi } from "vitest"; +import { V8_WIT_REQUIREMENT, injectNodeWitImports } from "../../src/node-wit.js"; +import { nodeBuiltinPlugin } from "../../src/node-builtins/index.js"; +import { withDefaultNodeCapabilities } from "../../src/cmd/transpile.js"; +import { componentizeFixture, getTmpDir, setupAsyncTest } from "../helpers.js"; + +const NODE_HOST = import.meta.resolve("@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/v8/host/node"); +const DENY_HOST = import.meta.resolve("@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/v8/host"); + +test("resolves node:v8 with only its explicit capability", () => { + const onWitRequirement = vi.fn(); + const plugin = nodeBuiltinPlugin({ imports: [], exports: [] }, { v8Module: "/v8.js", onWitRequirement }); + + expect(plugin.resolveId("v8")).toBeNull(); + expect(plugin.resolveId("node:v8/missing")).toBeNull(); + expect(onWitRequirement).not.toHaveBeenCalled(); + + const id = plugin.resolveId("node:v8"); + + expect(plugin.load(id)).toContain('from "/v8.js"'); + expect(onWitRequirement).toHaveBeenCalledExactlyOnceWith(V8_WIT_REQUIREMENT); + expect(withDefaultNodeCapabilities({}).map["jco:node/v8@0.1.0"]).toMatch(/v8\/host$/); + expect(withDefaultNodeCapabilities({ map: { "jco:node/v8@0.1.0": NODE_HOST } }).map["jco:node/v8@0.1.0"]).toBe( + NODE_HOST, + ); +}); + +test("installs the matching V8 WIT contract once", async () => { + const root = await getTmpDir(); + + await writeFile(join(root, "component.wit"), "package test:v8;\nworld component {}\n"); + + const result = await injectNodeWitImports(root, undefined, [V8_WIT_REQUIREMENT]); + + expect(result.imports).toEqual(["jco:node/v8@0.1.0"]); + expect(await readFile(join(root, "deps/jco-node-0.1.0/v8.wit"), "utf8")).toEqual( + await readFile(new URL("../../../jco-std/wit/node-0.1.0/v8.wit", import.meta.url), "utf8"), + ); + expect(await injectNodeWitImports(root, undefined, [V8_WIT_REQUIREMENT])).toBeUndefined(); +}); + +test.each(["quickjs", "starlingmonkey"])( + "node:v8 with native and denied providers in %s", + async (backend) => { + const { componentPath } = await componentizeFixture({ + fixture: "node-v8", + bundle: true, + copy: true, + extraArgs: ["--backend", backend], + }); + + for (const mode of ["node", "denied"]) { + const hostSpecifier = mode === "node" ? NODE_HOST : DENY_HOST; + const { instance, cleanup } = await setupAsyncTest({ + component: { + name: `node-v8-${backend}-${mode}`, + path: componentPath, + imports: { ...new WASIShim().getImportObject(), [hostSpecifier]: await import(hostSpecifier) }, + }, + jco: { transpile: { extraArgs: { map: { "jco:node/v8@0.1.0": hostSpecifier } } } }, + }); + + try { + const report = JSON.parse(instance.run(mode === "denied")); + + if (mode === "denied") { + expect(report).toEqual({ + errors: Array(3).fill({ name: "Error", code: "ERR_JCO_V8_ADAPTER_REQUIRED" }), + buildingSnapshot: false, + }); + } else { + expect(report).toEqual({ + module: true, + graph: true, + bytes: true, + types: true, + special: true, + scalar: [0xffffffff, [0xffffffff, 0xfffffffe], -0.25, "abc"], + identity: true, + tag: true, + heap: true, + spaces: true, + code: true, + cpp: true, + gc: true, + cpu: true, + unsupported: Array(3).fill("ERR_JCO_UNSUPPORTED_NODE_API"), + }); + } + } finally { + await cleanup(); + } + } + }, + 600_000, +); From 6d78b24bf25046ed5882e384bcf0b22656f11630 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Sat, 12 Sep 2026 13:00:55 +0000 Subject: [PATCH 07/12] docs(jco): document V8 capability and transport limits --- docs/src/SUMMARY.md | 1 + .../supported-modules/index.md | 1 + .../nodejs-builtins/supported-modules/v8.md | 110 ++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 docs/src/interop/nodejs-builtins/supported-modules/v8.md diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index de4d8eea9..d8f8eba0b 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -56,6 +56,7 @@ - [`node:tty`](./interop/nodejs-builtins/supported-modules/tty.md) - [`node:url`](./interop/nodejs-builtins/supported-modules/url.md) - [`node:util`](./interop/nodejs-builtins/supported-modules/util.md) + - [`node:v8`](./interop/nodejs-builtins/supported-modules/v8.md) - [`node:vfs`](./interop/nodejs-builtins/supported-modules/vfs.md) - [`node:vm`](./interop/nodejs-builtins/supported-modules/vm.md) - [`node:wasi`](./interop/nodejs-builtins/supported-modules/wasi.md) diff --git a/docs/src/interop/nodejs-builtins/supported-modules/index.md b/docs/src/interop/nodejs-builtins/supported-modules/index.md index ebe2974fc..e2a90cb79 100644 --- a/docs/src/interop/nodejs-builtins/supported-modules/index.md +++ b/docs/src/interop/nodejs-builtins/supported-modules/index.md @@ -72,6 +72,7 @@ compatibility limits. Related submodules share their parent API page. See the | [`node:tty`](./tty.md) | Node 24.20 `isatty`, `ReadStream` and `WriteStream` over the host process's descriptors through an explicit host capability; denied by default. | | [`node:url`](./url.md) | Node 24 URL, URLSearchParams, URLPattern, domain and file conversions; relative file paths use optional WASI environment imports. | | [`node:util`](./util.md), [`node:util/types`](./util.md) | Portable Node 24 utilities, sharing assertion equality and console formatting. No WIT capability; see the API page for engine and process restrictions. | +| [`node:v8`](./v8.md) | Native V8 serialization, host heap diagnostics and profiling through an explicit provider; guest engine hooks are unsupported. | | [`node:vfs`](./vfs.md) | Node 26 memory VFS, default-denied Node passthrough, and WASI filesystem storage with configurable preopen placement. | | [`node:vm`](./vm.md) | Same-context script evaluation and function compilation in the guest. No WIT capability; separate realms, native caches and VM modules are unsupported. | | [`node:wasi`](./wasi.md) | Node 24.19 `WASI` construction over an explicit host capability, denied by default; `start()` and `initialize()` refuse because a component cannot instantiate a nested module. | diff --git a/docs/src/interop/nodejs-builtins/supported-modules/v8.md b/docs/src/interop/nodejs-builtins/supported-modules/v8.md new file mode 100644 index 000000000..97305e213 --- /dev/null +++ b/docs/src/interop/nodejs-builtins/supported-modules/v8.md @@ -0,0 +1,110 @@ +# `node:v8` + +Jco provides the Node 24.20.0 `node:v8` export surface. Native operations use an +explicit `jco:node/v8@0.1.0` capability and are denied by default. + +## Enable the Node provider + +Application source keeps ordinary Node imports: + +```js +import { serialize, deserialize, getHeapStatistics } from 'node:v8'; + +const bytes = serialize({ message: 'hello', count: 42n }); +console.log(deserialize(bytes)); +console.log(getHeapStatistics()); +``` + +Bundle with Jco's Node builtin support, then explicitly grant the host capability: + +```sh +jco componentize app.js --bundle --wit app.wit -o app.wasm +jco transpile app.wasm -o out \ + --map 'jco:node/v8@0.1.0=@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/v8/host/node' +``` + +Importing the module does not access the provider. Without an explicit mapping, +native operations throw `ERR_JCO_V8_ADAPTER_REQUIRED`. The portable adapter can +also be imported directly from jco-std alongside ordinary Node builtins. + +## Host diagnostics and controls + +The provider delegates these operations to public `node:v8` APIs: + +- `cachedDataVersionTag`, heap, heap-space, code and C++ heap statistics; +- `getHeapSnapshot`, `writeHeapSnapshot`, and `setHeapSnapshotNearHeapLimit`; +- `GCProfiler` and `startCpuProfile`; +- `setFlagsFromString`, `takeCoverage`, and `stopCoverage`. + +These operations inspect or affect the **Node host V8 isolate**. They do not +inspect the QuickJS or SpiderMonkey guest heap. Snapshot paths, coverage settings, +and V8 flags belong to the host process. The cached-data tag describes host V8; +it cannot establish compatibility with guest compiled code. + +`getHeapSnapshot` returns a portable byte-mode `Readable`. The provider gathers +the synchronous native snapshot before copying it into the component, so this +requires additional memory and does not provide incremental host streaming. +Profiles stop and release their native resource on `stop()` or disposal. Repeated +stops return `undefined`, matching the pinned runtime. + +## Serialization + +`serialize` and `deserialize` use the native V8 binary format. `Serializer`, +`Deserializer`, `DefaultSerializer`, and `DefaultDeserializer` support headers, +values, unsigned integers, doubles, raw bytes and wire-format inspection. Successive +value writes and reads preserve object identity through the native serializer. + +The shared component transport supports primitives, bigint, special numbers, +cyclic plain records and arrays, Map, Set, Date, RegExp, ArrayBuffer, DataView, +and the integer/float typed arrays available in both engines. The default +serializer also preserves Buffer branding and visible bytes. + +The transport rejects accessors, custom prototypes, Error objects, Float16Array, +shared memory and native objects with explicit errors. Functions and symbols +cannot be serialized. Serializer subclasses, custom native serialization hooks, +and `transferArrayBuffer` registrations are unsupported across the component +boundary. The corresponding entry points throw `ERR_JCO_UNSUPPORTED_NODE_API`. +No JSON substitute is returned as a V8 serialization buffer. + +Buffers cross WIT by value: `readRawBytes` returns a copy rather than a view into +the caller's original input. Deserialization takes a snapshot of the input bytes +at construction. Changes to the input afterwards are not visible to the native +reader. Native format versions are controlled by the selected host Node release. + +`releaseBuffer()` releases a writer's native resource; a subsequent write reopens +it. Serializer and deserializer objects additionally expose `Symbol.dispose` for +deterministic cleanup of abandoned writers and completed readers. The convenience +functions clean up their resources automatically. + +## Guest engine restrictions + +`promiseHooks`, `queryObjects`, and `isStringOneByteRepresentation` throw +`ERR_JCO_UNSUPPORTED_NODE_API`: guest promises, constructors and string storage +cannot be inspected through a host V8 call. + +`startupSnapshot.isBuildingSnapshot()` returns `false`. The three startup callback +registration methods throw `ERR_NOT_BUILDING_SNAPSHOT`, matching ordinary Node +execution outside its snapshot builder. Componentization does not run Node's +startup-snapshot callbacks. + +## Implementation and verification + +The compatibility target is [Node v24.20.0](https://nodejs.org/download/release/v24.20.0/docs/api/v8.html), +source commit `71b8b174857e25106d39b61a9e6f30d927da8b01`. Public declarations are +reconciled with `@types/node` 24.13.3 and do not require consumer Node types. + +Native V8 owns binary serialization and diagnostics. Jco reuses +its shared errors, validation, Buffer, Readable, and the graph transport extracted +from the worker-threads implementation. Workers retain their existing cloning +policy; V8 opts into Buffer preservation and persistent reference sessions. + +The unenv V8 module uses mock statistics and inert serializers, so it is not used. +The codec audit also considered `@ungap/structured-clone`, devalue, flatted and the +existing cluster JSON transport. The shared worker codec already preserves the +required backing-buffer relationships and clone-marking policy; V8 adds session +identity without replacing the worker format. + +Tests compare native wire interoperability and runtime export shape, verify +capability denial and resource cleanup, and execute ordinary `node:v8` imports +through both QuickJS and StarlingMonkey. Global controls are tested in isolated +Node processes. From 5d6ad3aadc50a23ae153f99cd4cb51dbb5f9ef42 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 14 Sep 2026 05:58:57 +0000 Subject: [PATCH 08/12] docs(jco): remove V8 testing notes --- docs/src/interop/nodejs-builtins/supported-modules/v8.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/docs/src/interop/nodejs-builtins/supported-modules/v8.md b/docs/src/interop/nodejs-builtins/supported-modules/v8.md index 97305e213..214f119b4 100644 --- a/docs/src/interop/nodejs-builtins/supported-modules/v8.md +++ b/docs/src/interop/nodejs-builtins/supported-modules/v8.md @@ -87,7 +87,7 @@ registration methods throw `ERR_NOT_BUILDING_SNAPSHOT`, matching ordinary Node execution outside its snapshot builder. Componentization does not run Node's startup-snapshot callbacks. -## Implementation and verification +## Implementation The compatibility target is [Node v24.20.0](https://nodejs.org/download/release/v24.20.0/docs/api/v8.html), source commit `71b8b174857e25106d39b61a9e6f30d927da8b01`. Public declarations are @@ -103,8 +103,3 @@ The codec audit also considered `@ungap/structured-clone`, devalue, flatted and existing cluster JSON transport. The shared worker codec already preserves the required backing-buffer relationships and clone-marking policy; V8 adds session identity without replacing the worker format. - -Tests compare native wire interoperability and runtime export shape, verify -capability denial and resource cleanup, and execute ordinary `node:v8` imports -through both QuickJS and StarlingMonkey. Global controls are tested in isolated -Node processes. From c3e4e6d42191105afc1b76b6a0b5d1bdff0d0750 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 14 Sep 2026 07:53:01 +0000 Subject: [PATCH 09/12] fix(std): report unsupported V8 CPU profiling on older hosts --- packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8-host-node.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8-host-node.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8-host-node.ts index e119ca8c6..45b936e45 100644 --- a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8-host-node.ts +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/v8-host-node.ts @@ -1,6 +1,7 @@ /** Explicit access to the host Node V8 isolate through public node:v8 APIs. */ import * as node from "node:v8"; import { Buffer } from "node:buffer"; +import { unsupportedNodeApi } from "./errors/core.js"; import { nativeCall, Writer, Reader } from "./v8/host-serialization.js"; import type { StatisticsKind, @@ -93,6 +94,10 @@ export class Profile implements ProfileContract { constructor(cpu: boolean) { if (cpu) { + if (typeof node.startCpuProfile !== "function") { + throw unsupportedNodeApi("v8.startCpuProfile()", "The Node host does not provide this API"); + } + this.#profile = node.startCpuProfile(); } else { const profiler = new node.GCProfiler(); From 5d25504b34d911d8cae4a148fd204bbb1b6638c6 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 14 Sep 2026 07:53:11 +0000 Subject: [PATCH 10/12] test(std): exercise V8 profiling across host versions --- .../test/wasi/0.2.x/node/24.x.x/v8/module.ts | 7 ++++-- .../0.2.x/node/24.x.x/v8/startCpuProfile.ts | 22 +++++++++++++++++-- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/module.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/module.ts index 324d2ae95..bd3c1f88d 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/module.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/module.ts @@ -2,9 +2,12 @@ import { expect, test } from "vitest"; import native from "node:v8"; import { v8 } from "../helpers/v8.js"; -test("matches Node 24.20.0 exports and class inheritance", () => { - expect(process.version).toBe("v24.20.0"); +// Exact export comparisons require the pinned oracle; other hosts still run behavioral tests. +test.runIf(process.version === "v24.20.0")("matches the pinned Node 24.20.0 exports", () => { expect(Object.keys(v8).sort()).toEqual(Object.keys(native).sort()); +}); + +test("matches native namespaces and class inheritance", () => { expect(Object.keys(v8.promiseHooks)).toEqual(Object.keys(native.promiseHooks)); expect(Object.keys(v8.startupSnapshot)).toEqual(Object.keys(native.startupSnapshot)); expect(Object.getPrototypeOf(v8.DefaultSerializer)).toBe(v8.Serializer); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/startCpuProfile.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/startCpuProfile.ts index 1ca6d64fb..f343af419 100644 --- a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/startCpuProfile.ts +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/v8/startCpuProfile.ts @@ -1,12 +1,30 @@ import { expect, test } from "vitest"; -import { v8, blocked, deniedError } from "../helpers/v8.js"; +import native from "node:v8"; +import { v8, blocked, deniedError, unsupportedError } from "../helpers/v8.js"; -test("returns an actual CPU profile and stops only once", () => { +const hasCpuProfile = typeof native.startCpuProfile === "function"; + +test.runIf(hasCpuProfile)("returns an actual CPU profile and stops only once", () => { const profile = v8.startCpuProfile(); const result = JSON.parse(profile.stop()!); expect(result.nodes.length).toBeGreaterThan(0); expect(result.endTime).toBeGreaterThanOrEqual(result.startTime); expect(profile.stop()).toBeUndefined(); profile[Symbol.dispose](); +}); + +test.runIf(!hasCpuProfile)( + "reports an unsupported API when the Node host lacks CPU profiling", + () => { + expect(() => v8.startCpuProfile()).toThrow( + expect.objectContaining({ + ...unsupportedError, + message: expect.stringContaining("v8.startCpuProfile()"), + }), + ); + }, +); + +test("denies CPU profiling without an explicit provider on every host version", () => { expect(() => blocked.startCpuProfile()).toThrow(expect.objectContaining(deniedError)); }); From 296d283c94a02445e5420de7496d98e3c2816304 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 14 Sep 2026 07:53:27 +0000 Subject: [PATCH 11/12] test(jco): handle unavailable V8 profiling in Node 22 --- .../componentize/node-v8/component.js | 20 ++++++++++++++++--- packages/jco/test/node/v8.js | 10 +++++++++- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/jco/test/fixtures/componentize/node-v8/component.js b/packages/jco/test/fixtures/componentize/node-v8/component.js index 7a4dbae20..a9e056b42 100644 --- a/packages/jco/test/fixtures/componentize/node-v8/component.js +++ b/packages/jco/test/fixtures/componentize/node-v8/component.js @@ -2,6 +2,22 @@ import v8, { serialize, deserialize, Serializer, DefaultSerializer, DefaultDeser import * as namespace from "node:v8"; import { Buffer } from "node:buffer"; +function cpuProfileReport() { + try { + const profile = v8.startCpuProfile(); + + try { + const data = JSON.parse(profile.stop()); + + return data.nodes.length > 0 && profile.stop() === undefined; + } finally { + profile[Symbol.dispose](); + } + } catch (error) { + return { name: error.name, code: error.code, message: error.message }; + } +} + export function run(denied) { if (denied) { const errors = []; @@ -59,8 +75,6 @@ export function run(denied) { const profiler = new v8.GCProfiler(); profiler.start(); const gc = profiler.stop(); - const cpu = v8.startCpuProfile(); - const cpuData = JSON.parse(cpu.stop()); const unsupported = []; for (const operation of [ @@ -101,7 +115,7 @@ export function run(denied) { code: v8.getHeapCodeStatistics().code_and_metadata_size > 0, cpp: v8.getCppHeapStatistics("brief").detail_level === "brief", gc: gc.version === 1 && Array.isArray(gc.statistics) && profiler.stop() === undefined, - cpu: cpuData.nodes.length > 0 && cpu.stop() === undefined, + cpu: cpuProfileReport(), unsupported, }); } diff --git a/packages/jco/test/node/v8.js b/packages/jco/test/node/v8.js index bc2046811..65cae6fbe 100644 --- a/packages/jco/test/node/v8.js +++ b/packages/jco/test/node/v8.js @@ -1,5 +1,6 @@ import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import * as nativeV8 from "node:v8"; import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation"; import { expect, test, vi } from "vitest"; import { V8_WIT_REQUIREMENT, injectNodeWitImports } from "../../src/node-wit.js"; @@ -86,7 +87,14 @@ test.each(["quickjs", "starlingmonkey"])( code: true, cpp: true, gc: true, - cpu: true, + cpu: + typeof nativeV8.startCpuProfile === "function" + ? true + : { + name: "Error", + code: "ERR_JCO_UNSUPPORTED_NODE_API", + message: expect.stringContaining("v8.startCpuProfile()"), + }, unsupported: Array(3).fill("ERR_JCO_UNSUPPORTED_NODE_API"), }); } From 6330f2f775b0ed7482cc56f28768555a94aecfc2 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 14 Sep 2026 07:53:41 +0000 Subject: [PATCH 12/12] docs(jco): clarify V8 CPU profiling host requirements --- docs/src/interop/nodejs-builtins/supported-modules/v8.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/src/interop/nodejs-builtins/supported-modules/v8.md b/docs/src/interop/nodejs-builtins/supported-modules/v8.md index 214f119b4..b5e47ab11 100644 --- a/docs/src/interop/nodejs-builtins/supported-modules/v8.md +++ b/docs/src/interop/nodejs-builtins/supported-modules/v8.md @@ -47,6 +47,10 @@ requires additional memory and does not provide incremental host streaming. Profiles stop and release their native resource on `stop()` or disposal. Repeated stops return `undefined`, matching the pinned runtime. +`startCpuProfile` requires a Node host that provides that API. On older hosts, +including Node 22, it throws `ERR_JCO_UNSUPPORTED_NODE_API`. Other V8 operations +remain available when supported by the host. + ## Serialization `serialize` and `deserialize` use the native V8 binary format. `Serializer`,