diff --git a/docs/src/interop/nodejs-builtins.md b/docs/src/interop/nodejs-builtins.md index be2766821..88648396e 100644 --- a/docs/src/interop/nodejs-builtins.md +++ b/docs/src/interop/nodejs-builtins.md @@ -113,6 +113,7 @@ is planned. | `node:perf_hooks` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/perf-hooks` | Portable timing and observers; native telemetry throws. Runtime requirements are described below. | | `node:readline`, `node:readline/promises` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/readline` and `/readline/promises` | Node 24.20 line parsing, questions, terminal editing and cursor actions over supplied streams. No WIT capability. | | `node:repl` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/repl` | Node 24.20 REPL over the readline port and supplied streams; `useGlobal: true` only, bundles acorn -- see below. No WIT capability. | +| `node:timers`, `node:timers/promises` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/timers` and `/timers/promises` | Node 24 timer handles and promise timers over engine task scheduling; see runtime limits below. | | `node:string_decoder` | `@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/string-decoder` | Guest-local streaming decoder for Node 24. Requires no WIT capability. | | `node:domain` | _(refused)_ | Deprecated upstream in its entirety. Resolves so the failure explains itself; every use throws `ERR_JCO_UNSUPPORTED_DEPRECATED_NODE_API`. | | `node:ffi` | `@bytecodealliance/jco-std/wasi/0.2.x/node/26.x.x/ffi` | **Node 26 only.** Native calls and host memory over an explicit host capability; denied by default. Callbacks and guest-buffer addresses are refused -- see below. | @@ -1596,7 +1597,6 @@ the module or upstream project. | Modules | Why they are not enabled yet | | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `node:timers/promises` | A component-aware timer/event-loop integration is needed for delays, cancellation, and abort signals. | | `node:trace_events` | The fallbacks preserve useful shapes, but tracing is synthetic or no-op without runtime integration. | | `node:url` | There is substantial Node-derived code, but its eager `node:path` dependency adds a WASI environment requirement even for global-only URL use, and its namespace combines modern and legacy APIs that need separate policy. | @@ -1608,7 +1608,7 @@ set of coordinated shims: `node:crypto`, `node:dgram`, `node:http2`, `node:perf_hooks`, `node:repl`, `node:stream`, -`node:stream/promises`, `node:stream/web`, `node:timers`, +`node:stream/promises`, `node:stream/web`, `node:tls`, `node:util`, `node:util/types`, `node:v8`, `node:vm`, `node:wasi`, `node:worker_threads`, and `node:zlib`. @@ -1706,3 +1706,69 @@ Marks, measures, resource entries within the buffer limit, and synchronous funct timing work there. Observer subscription, event listeners and resource buffer overflow fail explicitly; `PerformanceObserver.supportedEntryTypes` is empty. StarlingMonkey supports these runtime facilities and the observer APIs. + +## Timers + +`node:timers` and `node:timers/promises` target Node.js 24.20.0. Keep ordinary +imports in application code and bundle them with `jco componentize --bundle`: + +```js +import { setTimeout, clearTimeout } from "node:timers"; +import { setTimeout as delay, scheduler } from "node:timers/promises"; + +const pending = setTimeout(() => console.log("later"), 100); +pending.refresh(); +clearTimeout(pending); +await delay(10, "ready"); +await scheduler.yield(); +``` + +### Timer handles and promises + +The callback module supplies timeout, interval and immediate scheduling and +cancellation. Timeouts support `refresh()`, numeric/string cancellation IDs, +`close()` and `Symbol.dispose`. Immediates support cancellation and disposal. +`close()` remains functional because Node 24 marks it legacy, not deprecated. +Removed exports such as `enroll` and `active` are not reintroduced. + +The promise module supplies delays, immediates, interval async iterators and the +scheduler singleton. It shares identity with `timers.promises` and the callback +functions' custom promisify hooks. Abort rejects with `AbortError`, `ABORT_ERR` +and the signal's reason as `cause`; interval iterators retain ticks while the +consumer is busy and release the timer when the loop breaks. + +### Engine requirements + +No additional WIT import or host mapping is required by the adapter. The component +engine supplies the task scheduler and its underlying clocks. StarlingMonkey +supports scheduling; the current QuickJS backend lacks task timers, so scheduling +throws `ERR_JCO_UNSUPPORTED_NODE_API` (or rejects for promise APIs). Imports and +argument validation remain usable without timers. + +`setImmediate` uses the runtime's native implementation when present, otherwise +a zero-delay timer task. Nested immediates run in later tasks, but a Web engine +cannot reproduce libuv's I/O/check phase ordering. The adapter does not replace Web +globals: imported functions return Node-style handles while the engine's global +timer functions retain their native identities and return types. Cancel imported +timers with the imported cancellation functions or their handle methods. + +`ref()`, `unref()` and `hasRef()` track handle state and forward liveness changes +when runtime handles support them. Active `unref()` and `{ ref: false }` throw or +reject explicitly on engines with numeric Web timer handles, including +StarlingMonkey. A failed promise setup cancels its timer. Native Node timer +handles support these operations when using jco-std directly in Node. + +Node's private async-hook instrumentation, delay warnings, native inspection and +private abort-listener protection against `stopImmediatePropagation()` are not +ported. Abort handling uses the engine's public event API. Direct jco-std imports +can coexist with native Node builtins, but their timer handles and cancellation +registries are separate. + +### Implementation source + +The TypeScript adaptation follows Node's `lib/timers.js`, `lib/internal/timers.js` +and `lib/timers/promises.js` at commit +`71b8b174857e25106d39b61a9e6f30d927da8b01`, with retained MIT notices. Engine timers +replace Node's native queue. The audited unenv 2.0.0-rc.24 implementation was not +selected: its promise delays resolve immediately, interval promises yield only +once, and fallback handles lack the required lifecycle semantics. diff --git a/packages/jco-std/README.md b/packages/jco-std/README.md index f8b1fe177..f959b33b1 100644 --- a/packages/jco-std/README.md +++ b/packages/jco-std/README.md @@ -42,6 +42,8 @@ build NodeJS programs as components. | `wasi/0.2.x/node/24.x.x/os` | `node:os` guest adapter over an explicit host capability | | `wasi/0.2.x/node/24.x.x/path` | `node:path` adapter, Node 24 on WASI p2 | | `wasi/0.2.x/node/24.x.x/string-decoder` | Guest-local `node:string_decoder` implementation for Node 24 | +| `wasi/0.2.x/node/24.x.x/timers` | Node 24 callback timers over engine scheduling | +| `wasi/0.2.x/node/24.x.x/timers/promises` | Promise timers, abortable interval iterators and scheduler | | `wasi/0.2.x/node/24.x.x/domain` | `node:domain`, deprecated upstream: every use throws | | `wasi/0.2.x/node/24.x.x/async-hooks` | `node:async_hooks` guest adapter, Node 24, synchronous scopes only | | `wasi/0.2.x/node/24.x.x/diagnostics-channel` | `node:diagnostics_channel` guest adapter, Node 24 | @@ -849,6 +851,15 @@ Unix-domain listeners, arbitrary custom transports, and HTTP/1.1 `Upgrade: h2c`. represents individual requests, not observable Node HTTP/2 sessions or arbitrary inbound servers. +### Node timers + +Jco bundles `node:timers` and `node:timers/promises` imports using this package's +Node 24 adaptation. See the [timer compatibility documentation](../../docs/src/interop/nodejs-builtins.md#timers) +for usage and engine requirements. StarlingMonkey supplies task timers; QuickJS +currently rejects scheduling. Active `unref()` and `{ ref: false }` require runtime +handles with liveness control. Direct adapters can coexist with native Node +builtins, with separate timer handles and cancellation registries. + # License This project is licensed under the Apache 2.0 license with the LLVM exception. diff --git a/packages/jco-std/package.json b/packages/jco-std/package.json index 9fdba2b8d..cb6fd1290 100644 --- a/packages/jco-std/package.json +++ b/packages/jco-std/package.json @@ -458,6 +458,16 @@ "./wasi/0.2.x/node/24.x.x/tty/host/node": { "types": "./dist/wasi/0.2.x/node/24.x.x/tty-host-node.d.ts", "node": "./dist/wasi/0.2.x/node/24.x.x/tty-host-node.js" + }, + "./wasi/0.2.x/node/24.x.x/timers": { + "types": "./dist/wasi/0.2.x/node/24.x.x/timers.d.ts", + "browser": "./dist/wasi/0.2.x/node/24.x.x/timers.js", + "default": "./dist/wasi/0.2.x/node/24.x.x/timers.js" + }, + "./wasi/0.2.x/node/24.x.x/timers/promises": { + "types": "./dist/wasi/0.2.x/node/24.x.x/timers-promises.d.ts", + "browser": "./dist/wasi/0.2.x/node/24.x.x/timers-promises.js", + "default": "./dist/wasi/0.2.x/node/24.x.x/timers-promises.js" } }, "scripts": { diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/timers-promises.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/timers-promises.ts new file mode 100644 index 000000000..13b0fe9de --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/timers-promises.ts @@ -0,0 +1,3 @@ +/** Promise timers share the callback timer core and its runtime requirements. */ +export * from "./timers/promises.js"; +export { default } from "./timers/promises.js"; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/timers.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/timers.ts new file mode 100644 index 000000000..8ae397571 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/timers.ts @@ -0,0 +1,3 @@ +/** Node 24 timers over the component engine's task scheduler. */ +export * from "./timers/index.js"; +export { default } from "./timers/index.js"; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/timers/callbacks.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/timers/callbacks.ts new file mode 100644 index 000000000..dfc9b58e6 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/timers/callbacks.ts @@ -0,0 +1,81 @@ +/** + * Adapted from nodejs/node lib/timers.js, v24.20.0, + * commit 71b8b174857e25106d39b61a9e6f30d927da8b01 (MIT). + * TypeScript and engine task timers replace primordials and native scheduling. + * No native async hooks, process warnings, or private abort event flags. + */ +// Copyright Joyent, Inc. and other Node contributors. +// +// 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 { validateFunction } from "../errors/core.js"; +import { Timeout, Immediate, cancelTimeout } from "./handles.js"; + +export function setTimeout( + callback: (...args: T) => void, + delay?: number, + ...args: T +): Timeout { + validateFunction(callback, "callback"); + return new Timeout( + function () { + Reflect.apply(callback, this, args); + }, + delay, + false, + ); +} + +export function setInterval( + callback: (...args: T) => void, + delay?: number, + ...args: T +): Timeout { + validateFunction(callback, "callback"); + return new Timeout( + function () { + Reflect.apply(callback, this, args); + }, + delay, + true, + ); +} + +export function setImmediate( + callback: (...args: T) => void, + ...args: T +): Immediate { + validateFunction(callback, "callback"); + return new Immediate(function () { + Reflect.apply(callback, this, args); + }); +} + +export function clearTimeout(timer: Timeout | number | string | undefined): void { + cancelTimeout(timer); +} +export function clearInterval(timer: Timeout | number | string | undefined): void { + cancelTimeout(timer); +} +export function clearImmediate(immediate: Immediate | undefined): void { + if (immediate instanceof Immediate) { + immediate[Symbol.dispose](); + } +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/timers/handles.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/timers/handles.ts new file mode 100644 index 000000000..8c2b96c55 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/timers/handles.ts @@ -0,0 +1,201 @@ +/** + * Adapted from nodejs/node lib/internal/timers.js and lib/timers.js, v24.20.0, + * commit 71b8b174857e25106d39b61a9e6f30d927da8b01 (MIT). + * TypeScript and engine task timers replace primordials and native scheduling. + * No native async hooks, process warnings, or private abort event flags. + */ +// Copyright Joyent, Inc. and other Node contributors. +// +// 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 { schedule, type RuntimeTimer } from "./runtime.js"; + +let nextId = 1; +const knownTimersById = new Map(); + +/** Node delay coercion, with warning emission omitted (no implicit process capability). */ +export function normalizeDelay(after: number | undefined): number { + const value = after === undefined ? 1 : after * 1; + return value >= 1 && value <= 2 ** 31 - 1 ? Math.trunc(value) : 1; +} + +export class Timeout implements Disposable { + #timer?: RuntimeTimer; + #callback?: (this: Timeout) => void; + #delay: number; + #repeat: boolean; + #refed = true; + #id = nextId++; + #primitive = false; + #fired = false; + + constructor(callback: (this: Timeout) => void, delay: number | undefined, repeat: boolean) { + this.#callback = callback; + this.#delay = normalizeDelay(delay); + this.#repeat = repeat; + this.#start(); + } + + #start(): void { + this.#timer = schedule( + () => { + if (!this.#repeat) { + this.#timer = undefined; + this.#fired = true; + knownTimersById.delete(this.#id); + } + this.#callback?.call(this); + }, + this.#delay, + this.#repeat ? "interval" : "timeout", + ); + try { + this.#timer.setRef(this.#refed); + } catch (error) { + this.#timer.cancel(); + this.#timer = undefined; + throw error; + } + } + + refresh(): this { + if (!this.#callback) { + return this; + } + this.#timer?.cancel(); + if (this.#fired) { + this.#id = nextId++; + this.#fired = false; + if (this.#primitive) { + knownTimersById.set(this.#id, this); + } + } + this.#start(); + return this; + } + + unref(): this { + this.#timer?.setRef(false); + this.#refed = false; + return this; + } + + ref(): this { + this.#timer?.setRef(true); + this.#refed = true; + return this; + } + + hasRef(): boolean { + return this.#refed; + } + + // close is legacy, not deprecated in Node 24. + close(): this { + this.#timer?.cancel(); + this.#timer = undefined; + this.#callback = undefined; + knownTimersById.delete(this.#id); + return this; + } + + [Symbol.dispose](): void { + this.close(); + } + + [Symbol.toPrimitive](): number { + if (!this.#primitive && this.#callback && !this.#fired) { + this.#primitive = true; + knownTimersById.set(this.#id, this); + } + return this.#id; + } +} + +export function cancelTimeout(timer: unknown): void { + if (timer instanceof Timeout) { + timer.close(); + } else if (typeof timer === "number" || typeof timer === "string") { + // Node's ID table uses exact property keys, not general numeric coercion. + const id = typeof timer === "number" ? timer : Number(timer); + if (typeof timer === "string" && String(id) !== timer) { + return; + } + knownTimersById.get(id)?.close(); + } +} + +export class Immediate implements Disposable { + #timer?: RuntimeTimer; + #callback?: (this: Immediate) => void; + #refed = true; + + constructor(callback: (this: Immediate) => void) { + this.#callback = callback; + this.#timer = schedule( + () => { + const fn = this.#callback; + this.#callback = undefined; + this.#timer = undefined; + this.#refed = false; + fn?.call(this); + }, + 0, + "immediate", + ); + } + + ref(): this { + if (this.#timer) { + this.#timer.setRef(true); + this.#refed = true; + } + return this; + } + + unref(): this { + if (this.#timer) { + this.#timer.setRef(false); + this.#refed = false; + } + return this; + } + + hasRef(): boolean { + return this.#refed; + } + + [Symbol.dispose](): void { + this.#timer?.cancel(); + this.#timer = undefined; + this.#callback = undefined; + this.#refed = false; + } +} + +// Node installs these members by assignment, unlike the class-defined methods. +for (const [prototype, keys] of [ + [Timeout.prototype, ["close", Symbol.dispose, Symbol.toPrimitive]], + [Immediate.prototype, [Symbol.dispose]], +] as const) { + for (const key of keys) { + Object.defineProperty(prototype, key, { enumerable: true }); + } +} diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/timers/index.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/timers/index.ts new file mode 100644 index 000000000..0f4e02a0e --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/timers/index.ts @@ -0,0 +1,71 @@ +/** + * Adapted from nodejs/node lib/timers.js, v24.20.0, + * commit 71b8b174857e25106d39b61a9e6f30d927da8b01 (MIT). + * TypeScript and engine task timers replace primordials and native scheduling. + * No native async hooks, process warnings, or private abort event flags. + */ +// Copyright Joyent, Inc. and other Node contributors. +// +// 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 { + setTimeout, + clearTimeout, + setImmediate, + clearImmediate, + setInterval, + clearInterval, +} from "./callbacks.js"; +import promises from "./promises.js"; + +export { + setTimeout, + clearTimeout, + setImmediate, + clearImmediate, + setInterval, + clearInterval, + promises, +}; +export type { Timeout, Immediate } from "./handles.js"; +export type { TimerOptions } from "./promises.js"; + +const customPromisify = Symbol.for("nodejs.util.promisify.custom"); +Object.defineProperty(setTimeout, customPromisify, { + enumerable: true, + get: () => promises.setTimeout, +}); +Object.defineProperty(setImmediate, customPromisify, { + enumerable: true, + get: () => promises.setImmediate, +}); + +const timers = { + setTimeout, + clearTimeout, + setImmediate, + clearImmediate, + setInterval, + clearInterval, + get promises() { + return promises; + }, +}; +export default timers; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/timers/promises.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/timers/promises.ts new file mode 100644 index 000000000..59e6e3431 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/timers/promises.ts @@ -0,0 +1,193 @@ +/** + * Adapted from nodejs/node lib/timers/promises.js, v24.20.0, + * commit 71b8b174857e25106d39b61a9e6f30d927da8b01 (MIT). + * TypeScript and engine task timers replace primordials and native scheduling. + * No native async hooks, process warnings, or private abort event flags. + */ +// Copyright Joyent, Inc. and other Node contributors. +// +// 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 { + AbortError, + illegalConstructor, + invalidArgType, + invalidThis, + validateObject, +} from "../errors/core.js"; +import * as timers from "./callbacks.js"; +import type { Timeout, Immediate } from "./handles.js"; + +export interface TimerOptions { + signal?: AbortSignal; + ref?: boolean; +} + +function validateOptions(options: TimerOptions, delay?: number): void { + if (delay !== undefined && typeof delay !== "number") { + throw invalidArgType("delay", "number", delay); + } + validateObject(options, "options"); + const signal = options.signal; + // Like Node's validator, accept cross-realm signals by their aborted property. + if ( + signal !== undefined && + (signal === null || typeof signal !== "object" || !("aborted" in signal)) + ) { + throw invalidArgType("options.signal", "AbortSignal", signal); + } + if (options.ref !== undefined && typeof options.ref !== "boolean") { + throw invalidArgType("options.ref", "boolean", options.ref); + } +} + +async function wait( + kind: "timeout" | "immediate", + value: T, + options: TimerOptions, + delay?: number, +): Promise { + validateOptions(options, delay); + const { signal, ref = true } = options; + if (signal?.aborted) { + throw new AbortError(undefined, { cause: signal.reason }); + } + let timer: Timeout | Immediate | undefined; + let oncancel: (() => void) | undefined; + try { + return await new Promise((resolve, reject) => { + timer = + kind === "timeout" + ? timers.setTimeout(resolve, delay, value) + : timers.setImmediate(resolve, value); + if (!ref) { + timer.unref(); + } + if (signal) { + oncancel = () => { + timer?.[Symbol.dispose](); + reject(new AbortError(undefined, { cause: signal.reason })); + }; + signal.addEventListener("abort", oncancel); + } + }); + } finally { + timer?.[Symbol.dispose](); + if (oncancel) { + signal!.removeEventListener("abort", oncancel); + } + } +} + +export function setTimeout( + delay?: number, + value?: T, + options: TimerOptions = {}, +): Promise { + return wait("timeout", value as T, options, delay); +} + +export function setImmediate(value?: T, options: TimerOptions = {}): Promise { + return wait("immediate", value as T, options); +} + +export async function* setInterval( + delay?: number, + value?: T, + options: TimerOptions = {}, +): AsyncGenerator { + validateOptions(options, delay); + const { signal, ref = true } = options; + if (signal?.aborted) { + throw new AbortError(undefined, { cause: signal.reason }); + } + let onCancel: (() => void) | undefined; + let interval: Timeout | undefined; + try { + let notYielded = 0; + let callback: ((value?: PromiseLike) => void) | undefined; + interval = timers.setInterval(() => { + notYielded++; + if (callback) { + callback(); + callback = undefined; + } + }, delay); + if (!ref) { + interval.unref(); + } + if (signal) { + onCancel = () => { + timers.clearInterval(interval); + if (callback) { + callback(Promise.reject(new AbortError(undefined, { cause: signal.reason }))); + callback = undefined; + } + }; + signal.addEventListener("abort", onCancel, { once: true }); + } + while (!signal?.aborted) { + if (notYielded === 0) { + await new Promise((resolve) => { + callback = resolve; + }); + } + for (; notYielded > 0; notYielded--) { + yield value as T; + } + } + throw new AbortError(undefined, { cause: signal?.reason }); + } finally { + timers.clearInterval(interval); + if (onCancel) { + signal!.removeEventListener("abort", onCancel); + } + } +} + +const kScheduler = Symbol("kScheduler"); +class Scheduler { + constructor() { + throw illegalConstructor(); + } + yield(): Promise { + if (!this[kScheduler]) { + throw invalidThis("Scheduler"); + } + return setImmediate(); + } + wait(delay: number, options?: TimerOptions): Promise { + if (!this[kScheduler]) { + throw invalidThis("Scheduler"); + } + return setTimeout(delay, undefined, options); + } + declare [kScheduler]: boolean; +} +export interface TimerScheduler { + yield(): Promise; + wait(delay: number, options?: TimerOptions): Promise; +} +export const scheduler: TimerScheduler = Object.assign(Object.create(Scheduler.prototype), { + [kScheduler]: true, +}); + +const promises = { setTimeout, setImmediate, setInterval, scheduler }; +export default promises; diff --git a/packages/jco-std/src/wasi/0.2.x/node/24.x.x/timers/runtime.ts b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/timers/runtime.ts new file mode 100644 index 000000000..c24e21b09 --- /dev/null +++ b/packages/jco-std/src/wasi/0.2.x/node/24.x.x/timers/runtime.ts @@ -0,0 +1,55 @@ +/** Runtime bridge: engine timers own scheduling; no Node host capability is assumed. */ +import { unsupportedNodeApi } from "../errors/core.js"; + +export interface RuntimeTimer { + cancel(): void; + setRef(ref: boolean): void; +} + +export function schedule( + callback: () => void, + delay: number, + kind: "timeout" | "interval" | "immediate", +): RuntimeTimer { + const immediate = kind === "immediate" && typeof globalThis.setImmediate === "function"; + const name = immediate ? "setImmediate" : kind === "interval" ? "setInterval" : "setTimeout"; + const clearName = immediate + ? "clearImmediate" + : kind === "interval" + ? "clearInterval" + : "clearTimeout"; + const start: unknown = globalThis[name]; + const clear: unknown = globalThis[clearName]; + if (typeof start !== "function" || typeof clear !== "function") { + throw unsupportedNodeApi( + `timers.${kind === "immediate" ? "setImmediate" : name}`, + "the engine must supply task timers", + ); + } + const handle: unknown = Reflect.apply( + start, + globalThis, + immediate ? [callback] : [callback, delay], + ); + return { + cancel(): void { + Reflect.apply(clear, globalThis, [handle]); + }, + setRef(ref: boolean): void { + const method = ref ? "ref" : "unref"; + if (typeof handle === "object" && handle !== null) { + const fn: unknown = Reflect.get(handle, method); + if (typeof fn === "function") { + Reflect.apply(fn, handle, []); + return; + } + } + if (!ref) { + throw unsupportedNodeApi( + "timers.unref", + "the engine timer handle does not support event-loop reference control", + ); + } + }, + }; +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/timers/immediate.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/timers/immediate.ts new file mode 100644 index 000000000..0348cadd2 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/timers/immediate.ts @@ -0,0 +1,39 @@ +import native from "node:timers"; +import { expect, test, vi } from "vitest"; +import shim from "../../../../../../src/wasi/0.2.x/node/24.x.x/timers.js"; + +for (const [name, timers] of [ + ["Node", native], + ["shim", shim], +] as const) { + test(`${name}: immediate ordering, receiver, ref and cancellation`, async () => { + const order: string[] = []; + const cancelled = timers.setImmediate(() => order.push("cancelled")); + expect(cancelled.unref()).toBe(cancelled); + expect(cancelled.hasRef()).toBe(false); + expect(cancelled.ref()).toBe(cancelled); + cancelled[Symbol.dispose](); + expect(cancelled.hasRef()).toBe(false); + await new Promise((resolve) => { + const first = timers.setImmediate(function (arg: string) { + expect(this).toBe(first); + order.push(arg); + timers.setImmediate(() => { + order.push("nested"); + resolve(); + }); + }, "first"); + timers.setImmediate(() => order.push("second")); + queueMicrotask(() => order.push("microtask")); + }); + expect(order).toEqual(["microtask", "first", "second", "nested"]); + const callback = vi.fn(); + const cleared = timers.setImmediate(callback); + timers.clearImmediate(cleared); + await new Promise((resolve) => native.setImmediate(resolve)); + expect(callback).not.toHaveBeenCalled(); + expect(() => Reflect.apply(timers.setImmediate, null, ["bad"])).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/timers/interval.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/timers/interval.ts new file mode 100644 index 000000000..78dca2bc3 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/timers/interval.ts @@ -0,0 +1,45 @@ +import native from "node:timers"; +import { afterEach, expect, test, vi } from "vitest"; +import shim from "../../../../../../src/wasi/0.2.x/node/24.x.x/timers.js"; + +afterEach(() => vi.useRealTimers()); +for (const [name, timers] of [ + ["Node", native], + ["shim", shim], +] as const) { + test(`${name}: intervals repeat, preserve arguments/receiver and cancel inside callback`, async () => { + let count = 0; + await new Promise((resolve) => { + const handle = timers.setInterval( + function (value: string) { + expect(this).toBe(handle); + expect(value).toBe("tick"); + if (++count === 3) { + timers.clearTimeout(handle); + resolve(); + } + }, + 1, + "tick", + ); + }); + await new Promise((resolve) => native.setTimeout(resolve, 5)); + expect(count).toBe(3); + expect(() => Reflect.apply(timers.setInterval, null, [null])).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + }); +} + +test("refresh restarts an interval; dispose removes it", () => { + vi.useFakeTimers(); + const callback = vi.fn(); + const timer = shim.setInterval(callback, 10); + vi.advanceTimersByTime(8); + timer.refresh(); + vi.advanceTimersByTime(22); + expect(callback).toHaveBeenCalledTimes(2); + timer[Symbol.dispose](); + vi.advanceTimersByTime(20); + expect(callback).toHaveBeenCalledTimes(2); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/timers/module.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/timers/module.ts new file mode 100644 index 000000000..355480b68 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/timers/module.ts @@ -0,0 +1,48 @@ +import native from "node:timers"; +import { promisify } from "node:util"; +import { expect, test } from "vitest"; +import timers, * as namespace from "../../../../../../src/wasi/0.2.x/node/24.x.x/timers.js"; +import promises from "../../../../../../src/wasi/0.2.x/node/24.x.x/timers-promises.js"; + +test("matches Node 24 exports, descriptors and shared promise identities", () => { + expect(process.versions.node.split(".")[0]).toBe("24"); + expect(Object.keys(timers)).toEqual(Object.keys(native)); + expect(Object.keys(namespace).sort()).toEqual([...Object.keys(native), "default"].sort()); + expect(timers.promises).toBe(promises); + expect(promisify(timers.setTimeout)).toBe(promises.setTimeout); + expect(promisify(timers.setImmediate)).toBe(promises.setImmediate); + expect(Object.getOwnPropertyDescriptor(timers, "promises")).toMatchObject({ + enumerable: true, + configurable: true, + set: undefined, + }); + for (const key of [ + "setTimeout", + "setImmediate", + "setInterval", + "clearTimeout", + "clearImmediate", + "clearInterval", + ] as const) { + expect(timers[key]).toBe(namespace[key]); + expect(timers[key].length).toBe(native[key].length); + } +}); + +test("matches handle prototype public descriptors", () => { + for (const key of ["setTimeout", "setImmediate"] as const) { + const handle = timers[key](() => {}); + const reference = native[key](() => {}); + try { + const prototype = Object.getPrototypeOf(handle); + const expected = Object.getPrototypeOf(reference); + for (const name of Reflect.ownKeys(prototype)) { + const actual = Object.getOwnPropertyDescriptor(prototype, name)!; + expect(actual.enumerable).toBe(Object.getOwnPropertyDescriptor(expected, name)!.enumerable); + } + } finally { + handle[Symbol.dispose](); + reference[Symbol.dispose](); + } + } +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/timers/promises-immediate.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/timers/promises-immediate.ts new file mode 100644 index 000000000..96cc27984 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/timers/promises-immediate.ts @@ -0,0 +1,26 @@ +import native from "node:timers/promises"; +import { expect, test, vi } from "vitest"; +import shim from "../../../../../../src/wasi/0.2.x/node/24.x.x/timers-promises.js"; + +for (const [name, timers] of [ + ["Node", native], + ["shim", shim], +] as const) { + test(`${name}: promise immediate is a task and cleans up abort listeners`, async () => { + const order: string[] = []; + const controller = new AbortController(); + const remove = vi.spyOn(controller.signal, "removeEventListener"); + const pending = timers.setImmediate("value", { signal: controller.signal }); + pending.then(() => order.push("task")); + await Promise.resolve(); + expect(order).toEqual([]); + expect(await pending).toBe("value"); + expect(remove).toHaveBeenCalled(); + const aborted = timers.setImmediate(undefined, { signal: controller.signal }); + controller.abort("stop"); + await expect(aborted).rejects.toMatchObject({ code: "ABORT_ERR", cause: "stop" }); + await expect( + Reflect.apply(timers.setImmediate, null, [undefined, { ref: "no" }]), + ).rejects.toHaveProperty("code", "ERR_INVALID_ARG_TYPE"); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/timers/promises-interval.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/timers/promises-interval.ts new file mode 100644 index 000000000..7f93b9fbd --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/timers/promises-interval.ts @@ -0,0 +1,40 @@ +import native from "node:timers/promises"; +import { afterEach, expect, test, vi } from "vitest"; +import shim from "../../../../../../src/wasi/0.2.x/node/24.x.x/timers-promises.js"; + +afterEach(() => vi.useRealTimers()); +for (const [name, timers] of [ + ["Node", native], + ["shim", shim], +] as const) { + test(`${name}: promise interval repeats, aborts pending next and closes on break`, async () => { + const controller = new AbortController(); + const remove = vi.spyOn(controller.signal, "removeEventListener"); + const iterator = timers.setInterval(1, "tick", { signal: controller.signal }); + expect(await iterator.next()).toMatchObject({ done: false, value: "tick" }); + expect(await iterator.next()).toMatchObject({ done: false, value: "tick" }); + const pending = iterator.next(); + controller.abort("stop"); + await expect(pending).rejects.toMatchObject({ code: "ABORT_ERR", cause: "stop" }); + expect(remove).toHaveBeenCalled(); + expect(await iterator.next()).toMatchObject({ done: true }); + for await (const value of timers.setInterval(1, 7)) { + expect(value).toBe(7); + break; + } + const invalid = Reflect.apply(timers.setInterval, null, ["bad"]); + await expect(invalid.next()).rejects.toHaveProperty("code", "ERR_INVALID_ARG_TYPE"); + }); +} + +test("slow consumers retain every tick and return clears the timer", async () => { + vi.useFakeTimers(); + const iterator = shim.setInterval(10, 5); + const first = iterator.next(); + await vi.advanceTimersByTimeAsync(30); + expect(await first).toEqual({ value: 5, done: false }); + expect(await iterator.next()).toEqual({ value: 5, done: false }); + expect(await iterator.next()).toEqual({ value: 5, done: false }); + await iterator.return(); + expect(vi.getTimerCount()).toBe(0); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/timers/promises-timeout.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/timers/promises-timeout.ts new file mode 100644 index 000000000..271eb4359 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/timers/promises-timeout.ts @@ -0,0 +1,54 @@ +import native from "node:timers/promises"; +import { expect, test, vi } from "vitest"; +import shim from "../../../../../../src/wasi/0.2.x/node/24.x.x/timers-promises.js"; + +for (const [name, timers] of [ + ["Node", native], + ["shim", shim], +] as const) { + test(`${name}: promise timeout resolves values and rejects abort with cause`, async () => { + const value = {}; + expect(await timers.setTimeout(1, value)).toBe(value); + expect(await timers.setTimeout()).toBeUndefined(); + const controller = new AbortController(); + const remove = vi.spyOn(controller.signal, "removeEventListener"); + const pending = timers.setTimeout(1000, value, { signal: controller.signal }); + const reason = { stopped: true }; + controller.abort(reason); + await expect(pending).rejects.toMatchObject({ + name: "AbortError", + code: "ABORT_ERR", + message: "The operation was aborted", + cause: reason, + }); + expect(remove).toHaveBeenCalled(); + await expect(timers.setTimeout(1, value, { signal: controller.signal })).rejects.toHaveProperty( + "cause", + reason, + ); + }); + + test(`${name}: options and delay errors reject, including validation before abort`, async () => { + for (const [delay, options] of [ + ["1", {}], + [1n, {}], + [1, null], + [1, []], + [1, { ref: 0 }], + [1, { signal: null }], + [1, { signal: {} }], + ]) { + const pending = Reflect.apply(timers.setTimeout, null, [delay, undefined, options]); + await expect(pending).rejects.toMatchObject({ + name: "TypeError", + code: "ERR_INVALID_ARG_TYPE", + }); + } + await expect( + timers.setTimeout(1, undefined, { + signal: AbortSignal.abort(), + ref: "bad" as unknown as boolean, + }), + ).rejects.toHaveProperty("code", "ERR_INVALID_ARG_TYPE"); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/timers/ref.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/timers/ref.ts new file mode 100644 index 000000000..ecd66185f --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/timers/ref.ts @@ -0,0 +1,30 @@ +import { execFileSync } from "node:child_process"; +import { expect, test } from "vitest"; + +const moduleUrl = new URL( + "../../../../../../dist/wasi/0.2.x/node/24.x.x/timers.js", + import.meta.url, +).href; + +test("unref and ref:false actually let a Node host exit with pending work", () => { + const result = execFileSync( + process.execPath, + [ + "--input-type=module", + "-e", + ` + import timers from ${JSON.stringify(moduleUrl)}; + timers.setTimeout(() => { throw new Error('timeout fired'); }, 60000).unref(); + timers.setInterval(() => { throw new Error('interval fired'); }, 60000).unref(); + timers.setImmediate(() => {}).unref(); + timers.promises.setTimeout(60000, undefined, { ref: false }); + timers.promises.setImmediate(undefined, { ref: false }); + timers.promises.setInterval(60000, undefined, { ref: false }).next(); + timers.promises.scheduler.wait(60000, { ref: false }); + console.log('ready'); + `, + ], + { encoding: "utf8", timeout: 5000 }, + ); + expect(result.trim()).toBe("ready"); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/timers/runtime.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/timers/runtime.ts new file mode 100644 index 000000000..617091d20 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/timers/runtime.ts @@ -0,0 +1,141 @@ +import { afterEach, expect, test, vi } from "vitest"; +import timers from "../../../../../../src/wasi/0.2.x/node/24.x.x/timers.js"; + +// Deterministic browser-style scheduler: real numeric handles and no ref methods. +function browserTimers(): { + tasks: Map void; delay: number }>; + tick(): void; +} { + const tasks = new Map void; delay: number }>(); + let nextId = 1; + const start = (callback: () => void, delay: number): number => { + const id = nextId++; + tasks.set(id, { callback, delay }); + return id; + }; + vi.stubGlobal("setTimeout", start); + vi.stubGlobal("setInterval", start); + vi.stubGlobal("clearTimeout", (id: number) => tasks.delete(id)); + vi.stubGlobal("clearInterval", (id: number) => tasks.delete(id)); + vi.stubGlobal("setImmediate", undefined); + vi.stubGlobal("clearImmediate", undefined); + return { + tasks, + tick(): void { + const ready = [...tasks.keys()]; + for (const id of ready) { + const task = tasks.get(id); + tasks.delete(id); + task?.callback(); + } + }, + }; +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +test("normalizes callback delays before passing them to Web timers", () => { + const { tasks } = browserTimers(); + for (const [value, expected] of [ + [undefined, 1], + [0, 1], + [-1, 1], + [NaN, 1], + [Infinity, 1], + [2 ** 31, 1], + [2.9, 2], + ["4", 4], + [null, 1], + ] as const) { + const timer = Reflect.apply(timers.setTimeout, null, [() => {}, value]); + expect([...tasks.values()][0].delay).toBe(expected); + timer[Symbol.dispose](); + expect(tasks.size).toBe(0); + } +}); + +test("numeric handles support cancellation, refresh and honest liveness errors", async () => { + const { tasks, tick } = browserTimers(); + const callback = vi.fn(); + const timer = timers.setTimeout(callback, 5); + expect(timer.ref()).toBe(timer); + expect(timer.hasRef()).toBe(true); + expect(() => timer.unref()).toThrow( + expect.objectContaining({ code: "ERR_JCO_UNSUPPORTED_NODE_API" }), + ); + expect(timer.hasRef()).toBe(true); + timer.refresh(); + expect(tasks.size).toBe(1); + tick(); + expect(callback).toHaveBeenCalledTimes(1); + timer.refresh(); + timers.clearTimeout(String(+timer)); + expect(tasks.size).toBe(0); + for (const pending of [ + timers.promises.setTimeout(5, 1, { ref: false }), + timers.promises.setImmediate(1, { ref: false }), + timers.promises.setInterval(5, 1, { ref: false }).next(), + ]) { + await expect(pending).rejects.toHaveProperty("code", "ERR_JCO_UNSUPPORTED_NODE_API"); + } + expect(tasks.size).toBe(0); +}); + +test("immediate fallback uses task turns with cancellation and nested ordering", () => { + const { tick } = browserTimers(); + const order: number[] = []; + timers.setImmediate(() => { + order.push(1); + timers.setImmediate(() => order.push(3)); + }); + timers.setImmediate(() => order.push(2)); + timers.clearImmediate(timers.setImmediate(() => order.push(99))); + expect(order).toEqual([]); + tick(); + expect(order).toEqual([1, 2]); + tick(); + expect(order).toEqual([1, 2, 3]); +}); + +test("no engine timers: fail lazily, preserve validation and pre-abort", async () => { + vi.stubGlobal("setTimeout", undefined); + vi.stubGlobal("setInterval", undefined); + vi.stubGlobal("setImmediate", undefined); + expect(Object.keys(timers)).toHaveLength(7); + for (const name of ["setTimeout", "setInterval", "setImmediate"] as const) { + expect(() => timers[name](() => {})).toThrow( + expect.objectContaining({ code: "ERR_JCO_UNSUPPORTED_NODE_API" }), + ); + expect(() => Reflect.apply(timers[name], null, [null])).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + } + await expect(timers.promises.setTimeout()).rejects.toHaveProperty( + "code", + "ERR_JCO_UNSUPPORTED_NODE_API", + ); + await expect(timers.promises.setImmediate()).rejects.toHaveProperty( + "code", + "ERR_JCO_UNSUPPORTED_NODE_API", + ); + await expect(timers.promises.setInterval().next()).rejects.toHaveProperty( + "code", + "ERR_JCO_UNSUPPORTED_NODE_API", + ); + await expect( + timers.promises.setTimeout(1, undefined, { signal: AbortSignal.abort("stop") }), + ).rejects.toMatchObject({ code: "ABORT_ERR", cause: "stop" }); +}); + +test("abort after a timer fired cannot change its fulfilled value", async () => { + const { tick, tasks } = browserTimers(); + const controller = new AbortController(); + const pending = timers.promises.setTimeout(1, "done", { signal: controller.signal }); + tick(); + controller.abort("late"); + expect(await pending).toBe("done"); + expect(tasks.size).toBe(0); +}); diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/timers/scheduler.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/timers/scheduler.ts new file mode 100644 index 000000000..a6f568b23 --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/timers/scheduler.ts @@ -0,0 +1,24 @@ +import native from "node:timers/promises"; +import { expect, test } from "vitest"; +import shim from "../../../../../../src/wasi/0.2.x/node/24.x.x/timers-promises.js"; + +for (const [name, { scheduler }] of [ + ["Node", native], + ["shim", shim], +] as const) { + test(`${name}: scheduler methods, brand checks and illegal constructor`, async () => { + expect(await scheduler.wait(1)).toBeUndefined(); + expect(await scheduler.yield()).toBeUndefined(); + expect(() => Reflect.apply(scheduler.yield, {}, [])).toThrow( + expect.objectContaining({ code: "ERR_INVALID_THIS" }), + ); + expect(() => Reflect.apply(scheduler.wait, undefined, [1])).toThrow(TypeError); + expect(() => Reflect.construct(scheduler.constructor, [])).toThrow( + expect.objectContaining({ code: "ERR_ILLEGAL_CONSTRUCTOR" }), + ); + await expect(scheduler.wait(1, { signal: AbortSignal.abort("stop") })).rejects.toHaveProperty( + "cause", + "stop", + ); + }); +} diff --git a/packages/jco-std/test/wasi/0.2.x/node/24.x.x/timers/timeout.ts b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/timers/timeout.ts new file mode 100644 index 000000000..b7e55d6eb --- /dev/null +++ b/packages/jco-std/test/wasi/0.2.x/node/24.x.x/timers/timeout.ts @@ -0,0 +1,76 @@ +import native from "node:timers"; +import { expect, test, vi, afterEach } from "vitest"; +import shim from "../../../../../../src/wasi/0.2.x/node/24.x.x/timers.js"; + +afterEach(() => vi.useRealTimers()); + +for (const [name, timers] of [ + ["Node", native], + ["shim", shim], +] as const) { + test(`${name}: timeout callback arguments, receiver, ref and close`, async () => { + await new Promise((resolve) => { + const timer = timers.setTimeout( + function (a: number, b: string) { + expect(this).toBe(timer); + expect([a, b]).toEqual([7, "ok"]); + resolve(); + }, + 1, + 7, + "ok", + ); + expect(timer.hasRef()).toBe(true); + expect(timer.unref()).toBe(timer); + expect(timer.hasRef()).toBe(false); + expect(timer.ref()).toBe(timer); + }); + const callback = vi.fn(); + const timer = timers.setTimeout(callback, 1); + expect(timer.close()).toBe(timer); + timer.refresh(); + await new Promise((resolve) => native.setTimeout(resolve, 10)); + expect(callback).not.toHaveBeenCalled(); + expect(timer.hasRef()).toBe(true); + }); + + test(`${name}: numeric and string IDs cancel interchangeably`, async () => { + const callback = vi.fn(); + const a = timers.setTimeout(callback, 1); + const b = timers.setInterval(callback, 1); + expect(+a).toBe(+a); + timers.clearInterval(String(+a)); + timers.clearTimeout(+b); + await new Promise((resolve) => native.setTimeout(resolve, 10)); + expect(callback).not.toHaveBeenCalled(); + }); + + test(`${name}: validation precedes delay coercion`, () => { + const coerce = vi.fn(); + for (const callback of [undefined, null, false, 1, "fn", {}, Symbol()]) { + expect(() => Reflect.apply(timers.setTimeout, null, [callback, { valueOf: coerce }])).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + } + expect(coerce).not.toHaveBeenCalled(); + expect(() => Reflect.apply(timers.setTimeout, null, [() => {}, 1n])).toThrow( + "Cannot mix BigInt and other types, use explicit conversions", + ); + }); +} + +test("refresh resets the deadline and reactivates fired timeouts", () => { + vi.useFakeTimers(); + const callback = vi.fn(); + const timer = shim.setTimeout(callback, 10); + vi.advanceTimersByTime(8); + expect(timer.refresh()).toBe(timer); + vi.advanceTimersByTime(8); + expect(callback).not.toHaveBeenCalled(); + vi.advanceTimersByTime(2); + expect(callback).toHaveBeenCalledTimes(1); + timer.refresh(); + vi.advanceTimersByTime(10); + expect(callback).toHaveBeenCalledTimes(2); + timer[Symbol.dispose](); +}); diff --git a/packages/jco/src/node-builtins/index.ts b/packages/jco/src/node-builtins/index.ts index 073edeaf9..c8fa32eb8 100644 --- a/packages/jco/src/node-builtins/index.ts +++ b/packages/jco/src/node-builtins/index.ts @@ -6,6 +6,7 @@ import { createModuleBuiltin } from "./module.js"; import { createFfiBuiltin } from "./ffi.js"; import { createInspectorBuiltin } from "./inspector.js"; import { createDomainBuiltin } from "./domain.js"; +import { createTimersBuiltin } from "./timers.js"; import { createPerfHooksBuiltin } from "./perf-hooks.js"; import { createDiagnosticsChannelBuiltin } from "./diagnostics-channel.js"; import { createAsyncHooksBuiltin } from "./async-hooks.js"; @@ -60,6 +61,7 @@ export function nodeBuiltinPlugin(worldMetadata: WorldMetadata, options: NodeBui createInspectorBuiltin, createDomainBuiltin, createPerfHooksBuiltin, + createTimersBuiltin, createDiagnosticsChannelBuiltin, createAsyncHooksBuiltin, createEventsBuiltin, diff --git a/packages/jco/src/node-builtins/timers.ts b/packages/jco/src/node-builtins/timers.ts new file mode 100644 index 000000000..0d5b885b7 --- /dev/null +++ b/packages/jco/src/node-builtins/timers.ts @@ -0,0 +1,15 @@ +import { type BuiltinContext, type BuiltinAdapter, builtin, stdModule, starReexportAdapter } from "./shared.js"; + +/** Both timer specifiers share an engine-backed core and request no additional WIT. */ +export function createTimersBuiltin({ options }: BuiltinContext): BuiltinAdapter { + return builtin(["node:timers", "node:timers/promises"], (specifier) => { + const promises = specifier === "node:timers/promises"; + return starReexportAdapter( + stdModule( + promises ? options.timersPromisesModule : options.timersModule, + promises ? "timers/promises" : "timers", + ), + promises ? "timersPromises" : "timers", + ); + }); +} diff --git a/packages/jco/src/node-builtins/types.ts b/packages/jco/src/node-builtins/types.ts index 6913ca19b..cff259285 100644 --- a/packages/jco/src/node-builtins/types.ts +++ b/packages/jco/src/node-builtins/types.ts @@ -39,6 +39,9 @@ export interface NodeBuiltinOptions { inspectorModule?: string; /** Path to the versioned perf_hooks implementation (overridable for tests). */ perfHooksModule?: string; + /** Paths to the versioned timer modules (overridable for tests). */ + timersModule?: string; + timersPromisesModule?: string; /** Path to jco-std's versioned `node:inspector/promises` module (overridable for tests) */ inspectorPromisesModule?: string; /** Path to jco-std's versioned `node:module` module (overridable for tests) */ diff --git a/packages/jco/test/fixtures/componentize/node-timers/source.js b/packages/jco/test/fixtures/componentize/node-timers/source.js new file mode 100644 index 000000000..52ed8bbfc --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-timers/source.js @@ -0,0 +1,202 @@ +import timers, { + setTimeout, + clearTimeout, + setInterval, + clearInterval, + setImmediate, + clearImmediate, + promises, +} from "node:timers"; +import promiseTimers, { scheduler } from "node:timers/promises"; +import * as namespace from "node:timers"; + +function check(condition, message) { + if (!condition) { + throw new Error(message); + } +} +function code(fn) { + try { + fn(); + } catch (error) { + return error.code; + } + throw new Error("expected synchronous error"); +} +async function rejection(pending, expected, cause) { + try { + await pending; + } catch (error) { + check(error.code === expected, `unexpected rejection: ${error}`); + if (expected === "ABORT_ERR") { + check(error.name === "AbortError" && error.message === "The operation was aborted", "abort shape"); + check(error.cause === cause, "abort cause"); + } + return; + } + throw new Error("expected rejection"); +} + +async function runAsync() { + check(timers === namespace.default && timers.setTimeout === setTimeout, "module identity"); + check(timers.promises === promises && promises === promiseTimers, "subpath identity"); + check(setTimeout[Symbol.for("nodejs.util.promisify.custom")] === promises.setTimeout, "promisify timeout"); + check(setImmediate[Symbol.for("nodejs.util.promisify.custom")] === promises.setImmediate, "promisify immediate"); + check(Object.keys(timers).length === 7 && Object.keys(promises).length === 4, "export keys"); + for (const fn of [setTimeout, setInterval, setImmediate]) { + for (const value of [null, 1, "function", {}, false]) { + check(code(() => fn(value)) === "ERR_INVALID_ARG_TYPE", "callback validation"); + } + } + for (const options of [null, [], { signal: null }, { ref: 1 }]) { + await rejection(promises.setTimeout(1, undefined, options), "ERR_INVALID_ARG_TYPE"); + await rejection(promises.setImmediate(undefined, options), "ERR_INVALID_ARG_TYPE"); + await rejection(promises.setInterval(1, undefined, options).next(), "ERR_INVALID_ARG_TYPE"); + } + await rejection(promises.setTimeout("1"), "ERR_INVALID_ARG_TYPE"); + await rejection(promises.setInterval("1").next(), "ERR_INVALID_ARG_TYPE"); + check(code(() => scheduler.yield.call({})) === "ERR_INVALID_THIS", "scheduler receiver"); + check(code(() => new scheduler.constructor()) === "ERR_ILLEGAL_CONSTRUCTOR", "scheduler constructor"); + clearTimeout(undefined); + clearInterval(undefined); + clearImmediate(undefined); + + let cancelled = false; + const a = setTimeout(() => { + cancelled = true; + }, 1); + const b = setInterval(() => { + cancelled = true; + }, 1); + check(a.hasRef() && a.ref() === a, "ref"); + clearInterval(String(+a)); + clearTimeout(+b); + const closed = setTimeout(() => { + cancelled = true; + }, 1); + check(closed.close() === closed, "close identity"); + closed.refresh(); + const disposed = setTimeout(() => { + cancelled = true; + }, 1); + disposed[Symbol.dispose](); + const immediate = setImmediate(() => { + cancelled = true; + }); + clearImmediate(immediate); + check(!immediate.hasRef(), "cleared immediate ref"); + const disposedImmediate = setImmediate(() => { + cancelled = true; + }); + disposedImmediate[Symbol.dispose](); + + await new Promise((resolve) => { + const timer = setTimeout( + function (x, y) { + check(this === timer && x === 2 && y === "ok", "timeout arguments/receiver"); + resolve(); + }, + 1, + 2, + "ok", + ); + check(timer.refresh() === timer, "refresh identity"); + }); + await new Promise((resolve) => { + let count = 0; + const timer = setTimeout(() => { + if (++count === 1) { + timer.refresh(); + } else { + resolve(); + } + }, 1); + }); + await new Promise((resolve) => { + let count = 0; + const timer = setInterval( + function (arg) { + check(this === timer && arg === "tick", "interval receiver/args"); + if (++count === 3) { + clearInterval(timer); + resolve(); + } + }, + 1, + "tick", + ); + }); + const order = []; + await new Promise((resolve) => { + const first = setImmediate(function (arg) { + check(this === first && arg === "first", "immediate receiver/args"); + order.push(arg); + setImmediate(() => { + order.push("nested"); + resolve(); + }); + }, "first"); + setImmediate(() => order.push("second")); + queueMicrotask(() => order.push("microtask")); + }); + check(order.join() === "microtask,first,second,nested", "immediate order"); + const value = {}; + check((await promises.setTimeout(1, value)) === value, "timeout value"); + check((await promises.setImmediate(value)) === value, "immediate value"); + check((await scheduler.wait(1)) === undefined && (await scheduler.yield()) === undefined, "scheduler"); + + for (const start of [ + (signal) => promises.setTimeout(100, value, { signal }), + (signal) => promises.setImmediate(value, { signal }), + (signal) => promises.setInterval(100, value, { signal }).next(), + ]) { + const controller = new AbortController(); + const pending = start(controller.signal); + controller.abort(value); + await rejection(pending, "ABORT_ERR", value); + await rejection(start(controller.signal), "ABORT_ERR", value); + } + let ticks = 0; + for await (const item of promises.setInterval(1, value)) { + check(item === value, "interval value"); + if (++ticks === 3) { + break; + } + } + const controller = new AbortController(); + const iterator = promises.setInterval(1, value, { signal: controller.signal }); + check(!(await iterator.next()).done, "interval initial tick"); + const next = iterator.next(); + controller.abort("stop"); + await rejection(next, "ABORT_ERR", "stop"); + check((await iterator.next()).done, "interval closed"); + + const unrefTimer = setTimeout(() => {}, 100); + check(code(() => unrefTimer.unref()) === "ERR_JCO_UNSUPPORTED_NODE_API", "unsupported unref"); + clearTimeout(unrefTimer); + await rejection(promises.setTimeout(100, undefined, { ref: false }), "ERR_JCO_UNSUPPORTED_NODE_API"); + await rejection(promises.setImmediate(undefined, { ref: false }), "ERR_JCO_UNSUPPORTED_NODE_API"); + await rejection(promises.setInterval(100, undefined, { ref: false }).next(), "ERR_JCO_UNSUPPORTED_NODE_API"); + check(!cancelled, "cancelled callback ran"); + return JSON.stringify({ module: true, validation: true, scheduling: true }); +} + +// QuickJS cannot return a Promise through a synchronous WIT export. Its missing +// scheduler contract is synchronous; StarlingMonkey executes the async cases. +export function run() { + if (typeof globalThis.setTimeout !== "function") { + check(timers === namespace.default && timers.setTimeout === setTimeout, "module identity"); + check(promises === promiseTimers && timers.promises === promises, "promise identity"); + check(Object.keys(timers).length === 7 && Object.keys(promises).length === 4, "exports"); + for (const fn of [setTimeout, setInterval, setImmediate]) { + check(code(() => fn(null)) === "ERR_INVALID_ARG_TYPE", "validation before scheduling"); + check(code(() => fn(() => {})) === "ERR_JCO_UNSUPPORTED_NODE_API", "missing timer"); + } + check(code(() => scheduler.yield.call({})) === "ERR_INVALID_THIS", "scheduler receiver"); + clearTimeout(undefined); + clearInterval(undefined); + clearImmediate(undefined); + return JSON.stringify({ module: true, validation: true, scheduling: "unavailable" }); + } + return runAsync(); +} diff --git a/packages/jco/test/fixtures/componentize/node-timers/source.wit b/packages/jco/test/fixtures/componentize/node-timers/source.wit new file mode 100644 index 000000000..6adde84b1 --- /dev/null +++ b/packages/jco/test/fixtures/componentize/node-timers/source.wit @@ -0,0 +1,2 @@ +package test:timers; +world test { export run: func() -> string; } diff --git a/packages/jco/test/node/timers.js b/packages/jco/test/node/timers.js new file mode 100644 index 000000000..77e4bbcbf --- /dev/null +++ b/packages/jco/test/node/timers.js @@ -0,0 +1,67 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test, vi } from "vitest"; +import { bundleComponentSource } from "../../src/bundle.js"; +import { nodeBuiltinPlugin } from "../../src/node-builtins/index.js"; +import { COMPONENT_JS_FIXTURES_DIR } from "../common.js"; +import { exec, getTmpDir, jcoPath, transpileComponent } from "../helpers.js"; + +const fixtureDir = join(COMPONENT_JS_FIXTURES_DIR, "node-timers"); +// The installed jco-std predates timers, so the fixture is bundled here with the source plugin +// pointed at the local build rather than with the CLI's `--bundle`. +const timersModule = fileURLToPath(new URL("../../../jco-std/dist/wasi/0.2.x/node/24.x.x/timers.js", import.meta.url)); + +const timersPromisesModule = fileURLToPath( + new URL("../../../jco-std/dist/wasi/0.2.x/node/24.x.x/timers-promises.js", import.meta.url), +); + +test("resolves timers without unrelated capabilities and leaves bare imports alone", () => { + const onWitRequirement = vi.fn(); + const plugin = nodeBuiltinPlugin( + { imports: [], exports: [] }, + { timersModule, timersPromisesModule, onWitRequirement }, + ); + expect(plugin.resolveId("node:timers")).toBe("\0jco-node-builtin:node:timers"); + expect(plugin.resolveId("timers")).toBeNull(); + expect(plugin.resolveId("timers/promises")).toBeNull(); + expect(plugin.resolveId("node:timers/promises")).toBe("\0jco-node-builtin:node:timers/promises"); + expect(plugin.load(plugin.resolveId("node:timers"))).toContain(timersModule); + expect(plugin.load(plugin.resolveId("node:timers/promises"))).toContain(timersPromisesModule); + expect(plugin.resolveId("node:timers/unknown")).toBeNull(); + expect(onWitRequirement).not.toHaveBeenCalled(); +}); + +test.each(["starlingmonkey", "quickjs"])( + "runs ordinary node:timers imports in %s", + async (backend) => { + const outputDir = await getTmpDir(); + const entry = join(outputDir, "source.js"); + const componentPath = join(outputDir, "component.wasm"); + const source = await bundleComponentSource(join(fixtureDir, "source.js"), { + plugins: [nodeBuiltinPlugin({ imports: [], exports: [] }, { timersModule, timersPromisesModule })], + }); + await writeFile(entry, source); + await exec( + jcoPath, + "componentize", + entry, + "--backend", + backend, + "-w", + join(fixtureDir, "source.wit"), + "-n", + "test", + "-o", + componentPath, + ); + const { modulePath } = await transpileComponent({ componentPath, name: "timers" }); + const component = await import(modulePath); + expect(JSON.parse(component.run())).toEqual({ + module: true, + validation: true, + scheduling: backend === "quickjs" ? "unavailable" : true, + }); + }, + 600_000, +);