From 7ba1aa873a414c6ee576d93011dd070eac25e045 Mon Sep 17 00:00:00 2001 From: Sebastian Wessel Date: Fri, 20 Jun 2025 13:52:45 +0100 Subject: [PATCH] feat: improve node timers compatibility --- src/modules/nodeCompatibility/headers.js | 6 +- src/modules/nodeCompatibility/request.js | 4 +- src/modules/nodeCompatibility/response.js | 4 +- src/modules/timers.js | 4 +- src/modules/timers_promises.js | 41 +++++++++- src/sandbox/provide/provideTimingFunctions.ts | 81 +++++++++++++++---- src/test/async/core-timers.test.ts | 18 ++++- src/test/async/timers-promises.test.ts | 39 +++++++++ src/test/sync/core-timers.test.ts | 18 ++++- src/test/sync/timers-promises.test.ts | 39 +++++++++ .../module-resolution/node-compatibility.md | 3 +- 11 files changed, 231 insertions(+), 26 deletions(-) create mode 100644 src/test/async/timers-promises.test.ts create mode 100644 src/test/sync/timers-promises.test.ts diff --git a/src/modules/nodeCompatibility/headers.js b/src/modules/nodeCompatibility/headers.js index b79b474..5bfe333 100644 --- a/src/modules/nodeCompatibility/headers.js +++ b/src/modules/nodeCompatibility/headers.js @@ -1,4 +1,5 @@ -export default `class Headers { +export default `if (typeof globalThis.Headers === 'undefined') { +class Headers { constructor(init) { this.map = {}; @@ -51,6 +52,7 @@ export default `class Headers { }); } } - globalThis.Headers = Headers; +} +export default globalThis.Headers; ` diff --git a/src/modules/nodeCompatibility/request.js b/src/modules/nodeCompatibility/request.js index 77e80ee..e165268 100644 --- a/src/modules/nodeCompatibility/request.js +++ b/src/modules/nodeCompatibility/request.js @@ -1,3 +1,5 @@ export default ` -export default {} +const RequestClass = typeof Request !== 'undefined' ? Request : class Request {}; +globalThis.Request = RequestClass; +export default RequestClass; ` diff --git a/src/modules/nodeCompatibility/response.js b/src/modules/nodeCompatibility/response.js index 77e80ee..ac3255b 100644 --- a/src/modules/nodeCompatibility/response.js +++ b/src/modules/nodeCompatibility/response.js @@ -1,3 +1,5 @@ export default ` -export default {} +const ResponseClass = typeof Response !== 'undefined' ? Response : class Response {}; +globalThis.Response = ResponseClass; +export default ResponseClass; ` diff --git a/src/modules/timers.js b/src/modules/timers.js index bca413a..e484089 100644 --- a/src/modules/timers.js +++ b/src/modules/timers.js @@ -3,6 +3,8 @@ export setTimeout export clearTimeout export setInterval export clearInterval +export setImmediate +export clearImmediate -export default {setTimeout, clearTimeout, setInterval, clearInterval } +export default {setTimeout, clearTimeout, setInterval, clearInterval, setImmediate, clearImmediate } ` diff --git a/src/modules/timers_promises.js b/src/modules/timers_promises.js index 37146ae..2e6bead 100644 --- a/src/modules/timers_promises.js +++ b/src/modules/timers_promises.js @@ -1 +1,40 @@ -export default '' +export default ` +export const setTimeout = (delay, value, options) => new Promise((resolve, reject) => { + const id = globalThis.setTimeout(() => resolve(value), delay); + if (options && options.signal) { + const abortHandler = () => { + globalThis.clearTimeout(id); + reject(options.signal.reason ?? new Error('AbortError')); + }; + if (options.signal.aborted) { + abortHandler(); + return; + } + options.signal.addEventListener('abort', abortHandler, { once: true }); + } +}); + +export const setImmediate = (value, options) => new Promise((resolve, reject) => { + const id = globalThis.setTimeout(() => resolve(value), 0); + if (options && options.signal) { + const abortHandler = () => { + globalThis.clearTimeout(id); + reject(options.signal.reason ?? new Error('AbortError')); + }; + if (options.signal.aborted) { + abortHandler(); + return; + } + options.signal.addEventListener('abort', abortHandler, { once: true }); + } +}); + +export async function* setInterval(delay, value) { + while (true) { + await new Promise((resolve) => globalThis.setTimeout(resolve, delay)); + yield value; + } +} + +export default { setTimeout, setImmediate, setInterval } +` diff --git a/src/sandbox/provide/provideTimingFunctions.ts b/src/sandbox/provide/provideTimingFunctions.ts index 19c633d..91fb462 100644 --- a/src/sandbox/provide/provideTimingFunctions.ts +++ b/src/sandbox/provide/provideTimingFunctions.ts @@ -7,10 +7,13 @@ export const provideTimingFunctions = ( maxIntervalCount: number }, ) => { - const scope = new Scope() + const scope = new Scope() - const timeouts = new Map>() - let timeoutCounter = 0 + const timeouts = new Map>() + let timeoutCounter = 0 + + const immediates = new Map>() + let immediateCounter = 0 const intervals = new Map>() let intervalCounter = 0 @@ -56,7 +59,49 @@ export const provideTimingFunctions = ( }) scope.manage(_clearTimeout) - ctx.setProp(ctx.global, 'clearTimeout', _clearTimeout) + ctx.setProp(ctx.global, 'clearTimeout', _clearTimeout) + + const _setImmediate = ctx.newFunction('setImmediate', vmFnHandle => { + const currentCounter = immediateCounter++ + if (timeouts.size + 1 > max.maxTimeoutCount) { + throw new Error( + `Client tries to use setImmediate, which exceeds the limit of max ${max.maxTimeoutCount} concurrent running timeout functions`, + ) + } + + const vmFnHandleCopy = vmFnHandle.dup() + scope.manage(vmFnHandleCopy) + + const timeoutID = setTimeout(() => { + const t = immediates.get(currentCounter) + if (t) { + clearTimeout(t) + immediates.delete(currentCounter) + } + ctx.callFunction(vmFnHandleCopy, ctx.undefined) + }, 0) + + immediates.set(currentCounter, timeoutID) + + return ctx.newNumber(currentCounter) + }) + + scope.manage(_setImmediate) + ctx.setProp(ctx.global, 'setImmediate', _setImmediate) + + const _clearImmediate = ctx.newFunction('clearImmediate', idHandle => { + const id: number = ctx.dump(idHandle) + idHandle.dispose() + + const t = immediates.get(id) + if (t) { + clearTimeout(t) + immediates.delete(id) + } + }) + + scope.manage(_clearImmediate) + ctx.setProp(ctx.global, 'clearImmediate', _clearImmediate) const _setInterval = ctx.newFunction('setInterval', (vmFnHandle, intervalHandle) => { const currentCounter = intervalCounter++ @@ -95,17 +140,23 @@ export const provideTimingFunctions = ( scope.manage(_clearInterval) ctx.setProp(ctx.global, 'clearInterval', _clearInterval) - const dispose = () => { - for (const [_key, value] of timeouts) { - clearTimeout(value) - } - timeouts.clear() - timeoutCounter = 0 - - for (const [_key, value] of intervals) { - clearInterval(value) - } - intervals.clear() + const dispose = () => { + for (const [_key, value] of timeouts) { + clearTimeout(value) + } + timeouts.clear() + timeoutCounter = 0 + + for (const [_key, value] of immediates) { + clearTimeout(value) + } + immediates.clear() + immediateCounter = 0 + + for (const [_key, value] of intervals) { + clearInterval(value) + } + intervals.clear() intervalCounter = 0 scope.dispose() diff --git a/src/test/async/core-timers.test.ts b/src/test/async/core-timers.test.ts index d15595c..e73d4c9 100644 --- a/src/test/async/core-timers.test.ts +++ b/src/test/async/core-timers.test.ts @@ -67,7 +67,7 @@ describe('async - core - timers', () => { expect((result as OkResponse).data).toBe('interval reached') }) - it('clearInterval works correctly', async () => { + it('clearInterval works correctly', async () => { const code = ` export default await new Promise((resolve) => { let count = 0 @@ -87,5 +87,19 @@ describe('async - core - timers', () => { // but it should be around 3 if intervals are 100ms and we clear after 500ms. expect((result as OkResponse).data).toBeGreaterThanOrEqual(3) expect((result as OkResponse).data).toBeLessThanOrEqual(5) - }) + }) + + it('setImmediate works correctly', async () => { + const code = ` + export default await new Promise((resolve) => { + setImmediate(() => { + resolve('immediate reached') + }) + }) + ` + + const result = await runCode(code) + expect(result.ok).toBeTrue() + expect((result as OkResponse).data).toBe('immediate reached') + }) }) diff --git a/src/test/async/timers-promises.test.ts b/src/test/async/timers-promises.test.ts new file mode 100644 index 0000000..83605ef --- /dev/null +++ b/src/test/async/timers-promises.test.ts @@ -0,0 +1,39 @@ +import { beforeAll, describe, expect, it } from 'bun:test' +import { loadAsyncQuickJs } from '../../loadAsyncQuickJs.js' +import type { OkResponse } from '../../types/OkResponse.js' + +describe('async - node:timers/promises', () => { + let runtime: Awaited> + + beforeAll(async () => { + runtime = await loadAsyncQuickJs() + }) + + const runCode = async (code: string) => { + return await runtime.runSandboxed(async ({ evalCode }) => { + return await evalCode(code) + }) + } + + it('setTimeout resolves', async () => { + const code = ` + import { setTimeout } from 'node:timers/promises' + export default await setTimeout(100, 'done') + ` + + const result = await runCode(code) + expect(result.ok).toBeTrue() + expect((result as OkResponse).data).toBe('done') + }) + + it('setImmediate resolves', async () => { + const code = ` + import { setImmediate } from 'node:timers/promises' + export default await setImmediate('immediate') + ` + + const result = await runCode(code) + expect(result.ok).toBeTrue() + expect((result as OkResponse).data).toBe('immediate') + }) +}) diff --git a/src/test/sync/core-timers.test.ts b/src/test/sync/core-timers.test.ts index cbd8726..9724b0d 100644 --- a/src/test/sync/core-timers.test.ts +++ b/src/test/sync/core-timers.test.ts @@ -67,7 +67,7 @@ describe('sync - core - timers', () => { expect((result as OkResponse).data).toBe('interval reached') }) - it('clearInterval works correctly', async () => { + it('clearInterval works correctly', async () => { const code = ` export default await new Promise((resolve) => { let count = 0 @@ -87,5 +87,19 @@ describe('sync - core - timers', () => { // but it should be around 3 if intervals are 100ms and we clear after 500ms. expect((result as OkResponse).data).toBeGreaterThanOrEqual(3) expect((result as OkResponse).data).toBeLessThanOrEqual(5) - }) + }) + + it('setImmediate works correctly', async () => { + const code = ` + export default await new Promise((resolve) => { + setImmediate(() => { + resolve('immediate reached') + }) + }) + ` + + const result = await runCode(code) + expect(result.ok).toBeTrue() + expect((result as OkResponse).data).toBe('immediate reached') + }) }) diff --git a/src/test/sync/timers-promises.test.ts b/src/test/sync/timers-promises.test.ts new file mode 100644 index 0000000..be214dc --- /dev/null +++ b/src/test/sync/timers-promises.test.ts @@ -0,0 +1,39 @@ +import { beforeAll, describe, expect, it } from 'bun:test' +import { loadQuickJs } from '../../loadQuickJs.js' +import type { OkResponse } from '../../types/OkResponse.js' + +describe('sync - node:timers/promises', () => { + let runtime: Awaited> + + beforeAll(async () => { + runtime = await loadQuickJs() + }) + + const runCode = async (code: string) => { + return await runtime.runSandboxed(async ({ evalCode }) => { + return await evalCode(code) + }) + } + + it('setTimeout resolves', async () => { + const code = ` + import { setTimeout } from 'node:timers/promises' + export default await setTimeout(100, 'done') + ` + + const result = await runCode(code) + expect(result.ok).toBeTrue() + expect((result as OkResponse).data).toBe('done') + }) + + it('setImmediate resolves', async () => { + const code = ` + import { setImmediate } from 'node:timers/promises' + export default await setImmediate('immediate') + ` + + const result = await runCode(code) + expect(result.ok).toBeTrue() + expect((result as OkResponse).data).toBe('immediate') + }) +}) diff --git a/website/docs/module-resolution/node-compatibility.md b/website/docs/module-resolution/node-compatibility.md index 07a70b7..9ec5a50 100644 --- a/website/docs/module-resolution/node-compatibility.md +++ b/website/docs/module-resolution/node-compatibility.md @@ -38,7 +38,8 @@ This library tries to provide basic support for most common used Node.js modules | `repl` | ❌ | Provides a Read-Eval-Print Loop (REPL) interface | | `stream` | ❌ | Provides an API for implementing stream-based I/O | | `string_decoder` | ✅ | Provides utilities for decoding buffer objects into strings | -| `timers` | ❌ | Provides timer functions similar to those in JavaScript | +| `timers` | ✅ | Provides timer functions similar to those in JavaScript | +| `timers/promises`| ✅ | Promise-based timer functions | | `tls` | ❌ | Provides an implementation of TLS and SSL protocols | | `trace_events` | ❌ | Provides a mechanism to centralize tracing information | | `tty` | ❌ | Provides utilities for working with TTYs (terminals) |