From 4085a6d6c307d451307f77013a077de4f14aae2e Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Thu, 16 Jan 2025 11:24:32 -0500 Subject: [PATCH] add node:timers module --- src/node/BUILD.bazel | 1 + src/node/internal/internal_timers.ts | 252 ++++++++++++++++++ src/node/internal/internal_timers_promises.ts | 82 ++++++ src/node/timers.ts | 28 ++ src/node/timers/promises.ts | 28 ++ src/node/tsconfig.json | 1 + src/workerd/api/node/BUILD.bazel | 6 + .../api/node/tests/timers-nodejs-test.js | 138 ++++++++++ .../api/node/tests/timers-nodejs-test.wd-test | 15 ++ 9 files changed, 551 insertions(+) create mode 100644 src/node/internal/internal_timers.ts create mode 100644 src/node/internal/internal_timers_promises.ts create mode 100644 src/node/timers.ts create mode 100644 src/node/timers/promises.ts create mode 100644 src/workerd/api/node/tests/timers-nodejs-test.js create mode 100644 src/workerd/api/node/tests/timers-nodejs-test.wd-test 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..8815cef2665 --- /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; + +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(); + }; + } +} + +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..c20b0d1155f --- /dev/null +++ b/src/node/internal/internal_timers_promises.ts @@ -0,0 +1,82 @@ +// 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. + +/* eslint-disable @typescript-eslint/require-await,@typescript-eslint/no-deprecated,prefer-spread */ + +import * as timers from 'node-internal:internal_timers'; + +export async function setTimeout( + ...args: Parameters +): Promise> { + return timers.setTimeout.apply(timers, args); +} + +export async function clearTimeout( + ...args: Parameters +): Promise { + timers.clearTimeout.apply(timers, args); +} + +export async function setImmediate( + ...args: Parameters +): Promise> { + return timers.setImmediate.apply(timers, args); +} + +export async function clearImmediate( + ...args: Parameters +): Promise { + timers.clearImmediate.apply(timers, args); +} + +export async function setInterval( + ...args: Parameters +): Promise> { + return timers.setInterval.apply(timers, args); +} + +export async function clearInterval( + ...args: Parameters +): Promise { + timers.clearInterval.apply(timers, args); +} + +export async function active( + ...args: Parameters +): Promise> { + timers.active.apply(timers, args); +} + +export async function unenroll( + ...args: Parameters +): Promise { + timers.unenroll.apply(timers, args); +} + +export async function enroll( + ...args: Parameters +): Promise { + timers.enroll.apply(timers, args); +} 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"], + ) + ), + ], +);