diff --git a/src/node/BUILD.bazel b/src/node/BUILD.bazel index c9da6ef4736..80eba20636d 100644 --- a/src/node/BUILD.bazel +++ b/src/node/BUILD.bazel @@ -17,6 +17,7 @@ wd_ts_bundle( "stream/*.js", "path/*.ts", "util/*.ts", + "timers/*.ts", ]), schema_id = "0xbcc8f57c63814005", tsconfig_json = "tsconfig.json", diff --git a/src/node/internal/internal_timers.ts b/src/node/internal/internal_timers.ts new file mode 100644 index 00000000000..3d7f25fdf8a --- /dev/null +++ b/src/node/internal/internal_timers.ts @@ -0,0 +1,252 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +// +// Adapted from Node.js. 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 'node-internal:validators'; + +let clearTimeoutImpl: (obj: Timeout) => void; +let clearImmediateImpl: (obj: Immediate) => void; + +export class Timeout { + // @ts-expect-error TS2564 It has a default value, but TS fails to detect it. + #timer: number; + #callback: (...args: unknown[]) => unknown; + #after: number; + #args: unknown[]; + #isRepeat: boolean; + #isRefed: boolean; + + public constructor( + callback: (...args: unknown[]) => unknown, + after: number = 1, + args: unknown[] = [], + isRepeat: boolean = false, + isRefed: boolean = false + ) { + this.#callback = callback; + // Left it as multiply by 1 due to make the behavior as similar to Node.js + // as possible. + this.#after = after * 1; + this.#args = args; + this.#isRepeat = isRepeat; + this.#isRefed = isRefed; + this.#constructTimer(); + } + + #constructTimer(): void { + if (this.#isRepeat) { + // @ts-expect-error TS2322 Due to difference between Node.js and globals + this.#timer = globalThis.setInterval( + this.#callback, + this.#after, + ...this.#args + ); + } else { + // @ts-expect-error TS2322 Due to difference between Node.js and globals + this.#timer = globalThis.setTimeout( + this.#callback, + this.#after, + ...this.#args + ); + } + } + + #clearTimeout(): void { + if (this.#isRepeat) { + globalThis.clearInterval(this.#timer); + } else { + globalThis.clearTimeout(this.#timer); + } + } + + public refresh(): this { + this.#clearTimeout(); + this.#constructTimer(); + return this; + } + + public unref(): this { + // Intentionally left as no-op. + this.#isRefed = false; + return this; + } + + public ref(): this { + // Intentionally left as no-op. + this.#isRefed = true; + return this; + } + + public hasRef(): boolean { + return this.#isRefed; + } + + public close(): this { + this.#clearTimeout(); + return this; + } + + public [Symbol.dispose](): void { + this.#clearTimeout(); + } + + public [Symbol.toPrimitive](): number { + return this.#timer; + } + + static { + clearTimeoutImpl = (obj: Timeout): void => { + obj.#clearTimeout(); + }; + } +} + +export class Immediate { + // @ts-expect-error TS2724 Node.js and global difference. + #timer: globalThis.Immediate; + #hasRef: boolean = false; + + public constructor( + callback: (...args: unknown[]) => void, + args: unknown[] = [] + ) { + this.#timer = globalThis.setImmediate(callback, ...args); + } + + public ref(): this { + // Intentionally left as no-op. + this.#hasRef = true; + return this; + } + + public unref(): this { + // Intentionally left as no-op. + this.#hasRef = false; + return this; + } + + public hasRef(): boolean { + return this.#hasRef; + } + + public [Symbol.dispose](): void { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + globalThis.clearImmediate(this.#timer); + } + + static { + clearImmediateImpl = (obj: Immediate): void => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + globalThis.clearImmediate(obj.#timer); + }; + } +} + +export function setTimeout( + callback: (...args: unknown[]) => unknown, + after: number, + ...args: unknown[] +): Timeout { + validateFunction(callback, 'callback'); + + return new Timeout( + callback, + after, + args, + /* isRepeat */ false, + /* isRefed */ true + ); +} + +export function clearTimeout(timer: unknown): void { + if (timer instanceof Timeout) { + clearTimeoutImpl(timer); + } else if (typeof timer === 'number') { + globalThis.clearTimeout(timer); + } +} + +export function setImmediate( + callback: (...args: unknown[]) => void, + ...args: unknown[] +): Immediate { + validateFunction(callback, 'callback'); + return new Immediate(callback, args); +} + +export function clearImmediate(immediate?: Immediate): void { + if (immediate != null) { + clearImmediateImpl(immediate); + } +} + +export function setInterval( + callback: (...args: unknown[]) => void, + repeat: number, + ...args: unknown[] +): Timeout { + validateFunction(callback, 'callback'); + return new Timeout( + callback, + repeat, + args, + /* isRepeat */ true, + /* isRefed */ true + ); +} + +export function clearInterval(timer: unknown): void { + if (timer instanceof Timeout) { + clearTimeoutImpl(timer); + } else if (typeof timer === 'number') { + globalThis.clearInterval(timer); + } +} + +/** + * @deprecated Please use timeout.refresh() instead. + */ +export function active(timer: unknown): void { + if (timer instanceof Timeout) { + timer.refresh(); + } +} + +/** + * @deprecated Please use clearTimeout instead. + */ +export function unenroll(timer: unknown): void { + if (timer instanceof Timeout) { + clearTimeoutImpl(timer); + } else if (typeof timer === 'number') { + globalThis.clearTimeout(timer); + } +} + +/** + * @deprecated Please use setTimeout instead. + */ +export function enroll(_item: unknown, _msecs: number): void { + throw new Error('Not implemented. Please use setTimeout() instead.'); +} diff --git a/src/node/internal/internal_timers_promises.ts b/src/node/internal/internal_timers_promises.ts new file mode 100644 index 00000000000..1a3fac9dfce --- /dev/null +++ b/src/node/internal/internal_timers_promises.ts @@ -0,0 +1,218 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +// +// Adapted from Node.js. 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 * as timers from 'node-internal:internal_timers'; +import { ERR_INVALID_THIS, AbortError } from 'node-internal:internal_errors'; +import { + validateNumber, + validateAbortSignal, + validateBoolean, + validateObject, +} from 'node-internal:validators'; + +const kScheduler = Symbol.for('kScheduler'); + +export async function setTimeout( + after: number | undefined, + value?: unknown, + options: { signal?: AbortSignal; ref?: boolean } = {} +): Promise { + if (after !== undefined) { + validateNumber(after, 'delay'); + } + + validateObject(options, 'options'); + + if (options.signal !== undefined) { + validateAbortSignal(options.signal, 'options.signal'); + } + + if (options.ref !== undefined) { + validateBoolean(options.ref, 'options.ref'); + } + + // Ref options is a no-op. + const { signal } = options; + + if (signal?.aborted) { + throw new AbortError(undefined, { cause: signal.reason }); + } + + const { promise, resolve, reject } = Promise.withResolvers(); + + const timer = timers.setTimeout(() => { + resolve(value); + }, after ?? 0); + + if (signal) { + function onCancel(): void { + timers.clearTimeout(timer); + reject(new AbortError(undefined, { cause: signal?.reason })); + } + signal.addEventListener('abort', onCancel); + } + + return promise; +} + +export async function setImmediate( + value?: unknown, + options: { signal?: AbortSignal; ref?: boolean } = {} +): Promise { + validateObject(options, 'options'); + if (options.signal !== undefined) { + validateAbortSignal(options.signal, 'options.signal'); + } + + if (options.ref !== undefined) { + validateBoolean(options.ref, 'options.ref'); + } + + // Ref options is a no-op. + const { signal } = options; + + if (signal?.aborted) { + throw new AbortError(undefined, { cause: signal.reason }); + } + + const { promise, resolve, reject } = Promise.withResolvers(); + + const timer = timers.setImmediate(() => { + resolve(value); + }); + + if (signal) { + function onCancel(): void { + timers.clearImmediate(timer); + reject(new AbortError(undefined, { cause: signal?.reason })); + } + signal.addEventListener('abort', onCancel); + } + + return promise; +} + +export async function* setInterval( + after?: number, + value?: unknown, + options: { signal?: AbortSignal; ref?: boolean } = {} +): AsyncGenerator { + if (after !== undefined) { + validateNumber(after, 'delay'); + } + + validateObject(options, 'options'); + + if (options.signal !== undefined) { + validateAbortSignal(options.signal, 'options.signal'); + } + + if (options.ref !== undefined) { + validateBoolean(options.ref, 'options.ref'); + } + + // Ref options is a no-op. + const { signal, ref } = options; + + if (signal?.aborted) { + throw new AbortError(undefined, { cause: signal.reason }); + } + + let onCancel: (() => void) | undefined; + let interval: timers.Timeout; + try { + let notYielded = 0; + let callback: ((promise?: Promise) => void) | undefined; + interval = new timers.Timeout( + () => { + notYielded++; + callback?.(); + callback = undefined; + }, + after, + undefined, + true, + ref + ); + + if (signal) { + onCancel = (): void => { + timers.clearInterval(interval); + callback?.( + Promise.reject(new AbortError(undefined, { cause: signal.reason })) + ); + callback = undefined; + }; + signal.addEventListener('abort', onCancel); + } + + while (!signal?.aborted) { + if (notYielded === 0) { + await new Promise((resolve) => (callback = resolve)); + } + for (; notYielded > 0; notYielded--) { + yield value; + } + } + throw new AbortError(undefined, { cause: signal.reason }); + } finally { + // @ts-expect-error TS2454 TS detects invalid use before assignment. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (interval) { + timers.clearInterval(interval); + } + if (onCancel) { + signal?.removeEventListener('abort', onCancel); + } + } +} + +// TODO(@jasnell): Scheduler is an API currently being discussed by WICG +// for Web Platform standardization: https://github.com/WICG/scheduling-apis +// The scheduler.yield() and scheduler.wait() methods correspond roughly to +// the awaitable setTimeout and setImmediate implementations here. This api +// should be considered to be experimental until the spec for these are +// finalized. Note, also, that Scheduler is expected to be defined as a global, +// but while the API is experimental we shouldn't expose it as such. +class Scheduler { + public [kScheduler] = true; + + public yield(): Promise { + if (!this[kScheduler]) throw new ERR_INVALID_THIS('Scheduler'); + // @ts-expect-error TS2555 Following Node.js implementation + return setImmediate(); + } + + public wait( + delay: number, + options?: { signal?: AbortSignal } + ): Promise { + if (!this[kScheduler]) throw new ERR_INVALID_THIS('Scheduler'); + // @ts-expect-error TS2322 Invalid return value detected by TS. + return setTimeout(delay, undefined, options); + } +} + +export const scheduler = new Scheduler(); diff --git a/src/node/timers.ts b/src/node/timers.ts new file mode 100644 index 00000000000..525715c2209 --- /dev/null +++ b/src/node/timers.ts @@ -0,0 +1,28 @@ +import * as _promises from 'node-internal:internal_timers_promises'; +import { + setTimeout, + clearTimeout, + setImmediate, + clearImmediate, + setInterval, + clearInterval, + active, + unenroll, + enroll, +} from 'node-internal:internal_timers'; + +export * from 'node-internal:internal_timers'; +export const promises = _promises; + +export default { + promises: _promises, + setTimeout, + clearTimeout, + setImmediate, + clearImmediate, + setInterval, + clearInterval, + active, + unenroll, + enroll, +}; diff --git a/src/node/timers/promises.ts b/src/node/timers/promises.ts new file mode 100644 index 00000000000..cfc13646072 --- /dev/null +++ b/src/node/timers/promises.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +// +// Adapted from Node.js. 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 * as _default from 'node-internal:internal_timers_promises'; +export * from 'node-internal:internal_timers_promises'; +export default _default; diff --git a/src/node/tsconfig.json b/src/node/tsconfig.json index 0784c82b020..8d9f3a4a197 100644 --- a/src/node/tsconfig.json +++ b/src/node/tsconfig.json @@ -28,6 +28,7 @@ "node:util/*": ["./*"], "node:path/*": ["./*"], "node:stream/*": ["./*"], + "node:timers/*": ["./*"], "node-internal:*": ["./internal/*"], "cloudflare-internal:sockets": ["./internal/sockets.d.ts"], "cloudflare-internal:workers": ["./internal/workers.d.ts"], diff --git a/src/workerd/api/node/BUILD.bazel b/src/workerd/api/node/BUILD.bazel index 1e348768f40..8b838f0d3f9 100644 --- a/src/workerd/api/node/BUILD.bazel +++ b/src/workerd/api/node/BUILD.bazel @@ -272,3 +272,9 @@ wd_test( "//conditions:default": [], }), ) + +wd_test( + src = "tests/timers-nodejs-test.wd-test", + args = ["--experimental"], + data = ["tests/timers-nodejs-test.js"], +) diff --git a/src/workerd/api/node/tests/timers-nodejs-test.js b/src/workerd/api/node/tests/timers-nodejs-test.js new file mode 100644 index 00000000000..97b410c3f58 --- /dev/null +++ b/src/workerd/api/node/tests/timers-nodejs-test.js @@ -0,0 +1,138 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +// +// Adapted from Node.js. 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 timers from 'node:timers'; +import { throws, strictEqual, deepStrictEqual } from 'node:assert'; + +export const testEnroll = { + async test() { + throws(() => timers.enroll(), /Not implemented/); + }, +}; + +// Tests are taken from: +// https://github.com/nodejs/node/blob/b3641fe85d55525127c03be730596154705b798e/test/parallel/test-timers-immediate.js +export const testSetImmediate = { + async test() { + { + const { promise, resolve, reject } = Promise.withResolvers(); + let mainFinished = false; + timers.setImmediate(() => { + strictEqual(mainFinished, true); + timers.clearImmediate(immediateB); + resolve(); + }); + + const immediateB = timers.setImmediate(reject); + mainFinished = true; + + await promise; + } + + { + const { promise, resolve } = Promise.withResolvers(); + globalThis.setImmediate( + (...args) => { + deepStrictEqual(args, [1, 2, 3]); + resolve(); + }, + 1, + 2, + 3 + ); + await promise; + } + }, +}; + +export const testSetTimeout = { + async test() { + { + const { promise, resolve, reject } = Promise.withResolvers(); + let mainFinished = false; + timers.setTimeout(() => { + strictEqual(mainFinished, true); + timers.clearTimeout(timeoutB); + resolve(); + }); + + const timeoutB = timers.setTimeout(reject); + mainFinished = true; + + await promise; + } + + { + const { promise, resolve } = Promise.withResolvers(); + timers.setTimeout( + (...args) => { + deepStrictEqual(args, [1, 2, 3]); + resolve(); + }, + 100, + 1, + 2, + 3 + ); + await promise; + } + }, +}; + +export const testSetInterval = { + async test() { + { + const { promise, resolve, reject } = Promise.withResolvers(); + let mainFinished = false; + const thisInterval = timers.setInterval(() => { + strictEqual(mainFinished, true); + timers.clearInterval(intervalB); + timers.clearInterval(thisInterval); + resolve(); + }, 100); + + const intervalB = timers.setInterval(reject, 100); + mainFinished = true; + + await promise; + } + + { + const { promise, resolve } = Promise.withResolvers(); + const thisInterval = timers.setInterval( + (...args) => { + deepStrictEqual(args, [1, 2, 3]); + timers.clearInterval(thisInterval); + resolve(); + }, + 100, + 1, + 2, + 3 + ); + await promise; + } + }, +}; diff --git a/src/workerd/api/node/tests/timers-nodejs-test.wd-test b/src/workerd/api/node/tests/timers-nodejs-test.wd-test new file mode 100644 index 00000000000..f69236a5326 --- /dev/null +++ b/src/workerd/api/node/tests/timers-nodejs-test.wd-test @@ -0,0 +1,15 @@ +using Workerd = import "/workerd/workerd.capnp"; + +const unitTests :Workerd.Config = ( + services = [ + ( name = "nodejs-timers-test", + worker = ( + modules = [ + (name = "worker", esModule = embed "timers-nodejs-test.js") + ], + compatibilityDate = "2025-01-09", + compatibilityFlags = ["nodejs_compat"], + ) + ), + ], +);