From fec74c9a86fefb5c1573d749517b42a8838cdce6 Mon Sep 17 00:00:00 2001 From: osztenkurden Date: Thu, 17 Sep 2026 14:09:40 +0200 Subject: [PATCH] worker: strip types in Web Worker module entries Strip TypeScript from file-backed module worker entries before evaluating the fetched source. Add regression coverage and document the supported entry types. Assisted-by: Codex Signed-off-by: Hubert Walczak --- doc/api/globals.md | 9 ++ doc/api/typescript.md | 5 ++ lib/internal/webworker.js | 25 +++++- .../web-worker/typescript/dependency.ts | 1 + test/fixtures/web-worker/typescript/entry.ts | 2 + test/fixtures/web-worker/typescript/module.ts | 10 +++ .../test-webworker-typescript-disabled.js | 28 ++++++ test/parallel/test-webworker-typescript.js | 85 +++++++++++++++++++ 8 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 test/fixtures/web-worker/typescript/dependency.ts create mode 100644 test/fixtures/web-worker/typescript/entry.ts create mode 100644 test/fixtures/web-worker/typescript/module.ts create mode 100644 test/parallel/test-webworker-typescript-disabled.js create mode 100644 test/parallel/test-webworker-typescript.js diff --git a/doc/api/globals.md b/doc/api/globals.md index a5998d14885d..b1080902e8e3 100644 --- a/doc/api/globals.md +++ b/doc/api/globals.md @@ -1373,6 +1373,15 @@ accepted and how failures are reported: * For `blob:` URLs, the script must be held in memory, so blobs backed by a file, such as those returned by [`fs.openAsBlob()`][], cannot be used. +Module workers (`{ type: 'module' }`) loaded from `file:` URLs support +[TypeScript type stripping](typescript.md#type-stripping) for `.ts`, `.mts`, and +`.cts` entries, unless `--no-strip-types` is enabled. The worker's `type` option +determines how the entry is executed, regardless of its extension or the +surrounding `package.json`: even a `.cts` module-worker entry is evaluated as an +ES module. Imported modules follow the usual Node.js module-loading rules. +Classic worker entries, `importScripts()`, and `data:` or `blob:` entry sources +do not support type stripping. + ### Differences from the HTML Standard Besides script loading, mentioned above: diff --git a/doc/api/typescript.md b/doc/api/typescript.md index 3959fd58b56c..0018d06b1d1a 100644 --- a/doc/api/typescript.md +++ b/doc/api/typescript.md @@ -127,6 +127,11 @@ if you want your code to run as CommonJS you must use `require` and * `.cts` files will always be run as CommonJS modules, similar to `.cjs` files. * `.tsx` files are unsupported. +For [Web Worker module entries](globals.md#loading-worker-scripts), the worker's +`type` option takes precedence over these module-system rules. A TypeScript +file used as a module-worker entry is evaluated as an ES module, including when +it has a `.cts` extension. Its imports still follow the rules above. + As in JavaScript files, [file extensions are mandatory][] in `import` statements and `import()` expressions: `import './file.ts'`, not `import './file'`. Because of backward compatibility, file extensions are also mandatory in `require()` diff --git a/lib/internal/webworker.js b/lib/internal/webworker.js index e2a2086f9039..a82ea6623e74 100644 --- a/lib/internal/webworker.js +++ b/lib/internal/webworker.js @@ -29,6 +29,7 @@ const { ERR_ILLEGAL_CONSTRUCTOR, ERR_INVALID_STATE, ERR_NO_CRYPTO, + ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING, } = require('internal/errors').codes; const { @@ -43,6 +44,7 @@ const { exposeInterface, getCWDURL, getLazy, + isUnderNodeModules, kEmptyObject, kEnumerableProperty, lazyDOMException, @@ -89,6 +91,7 @@ const { const { URL, URLParse, + fileURLToPath, } = require('internal/url'); const { @@ -253,10 +256,26 @@ function runClassicScriptSource(source, url) { * @returns {Promise} */ function runModuleScriptSource(source, url) { - // Necessary to reset RegExp statics before user code runs. - RegExpPrototypeExec(/^/, ''); return require('internal/modules/run_main').runEntryPointWithESMLoader( - (loader) => loader.eval(source, url, true), + (loader) => { + if (require('internal/options').getOptionValue('--strip-types')) { + const parsedURL = new URL(url); + if (parsedURL.protocol === 'file:') { + const filename = fileURLToPath(parsedURL); + const extension = require('path').extname(filename); + if (extension === '.ts' || extension === '.mts' || extension === '.cts') { + if (isUnderNodeModules(filename)) { + throw new ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING(filename); + } + const { stripTypeScriptModuleTypes } = require('internal/modules/typescript'); + source = stripTypeScriptModuleTypes(source, url); + } + } + } + // Necessary to reset RegExp statics before user code runs. + RegExpPrototypeExec(/^/, ''); + return loader.eval(source, url, true); + }, ); } diff --git a/test/fixtures/web-worker/typescript/dependency.ts b/test/fixtures/web-worker/typescript/dependency.ts new file mode 100644 index 000000000000..1b55ac618d95 --- /dev/null +++ b/test/fixtures/web-worker/typescript/dependency.ts @@ -0,0 +1 @@ +export const value: number = 42; diff --git a/test/fixtures/web-worker/typescript/entry.ts b/test/fixtures/web-worker/typescript/entry.ts new file mode 100644 index 000000000000..99961c3cd8ea --- /dev/null +++ b/test/fixtures/web-worker/typescript/entry.ts @@ -0,0 +1,2 @@ +const value: number = 1; +postMessage(value); diff --git a/test/fixtures/web-worker/typescript/module.ts b/test/fixtures/web-worker/typescript/module.ts new file mode 100644 index 000000000000..42ee6113f4c4 --- /dev/null +++ b/test/fixtures/web-worker/typescript/module.ts @@ -0,0 +1,10 @@ +import { value } from './dependency.ts'; + +const result: number = await Promise.resolve(value); +postMessage({ + value: result, + url: import.meta.url, + main: import.meta.main, + requireType: typeof require, + thisIsUndefined: this === undefined, +}); diff --git a/test/parallel/test-webworker-typescript-disabled.js b/test/parallel/test-webworker-typescript-disabled.js new file mode 100644 index 000000000000..423394e9bd93 --- /dev/null +++ b/test/parallel/test-webworker-typescript-disabled.js @@ -0,0 +1,28 @@ +// Flags: --experimental-web-worker --no-strip-types +'use strict'; + +const common = require('../common'); +// Disabling stripping must reject annotations, not JavaScript-compatible .ts files. +const assert = require('node:assert'); +const { writeFileSync } = require('node:fs'); +const { join } = require('node:path'); +const { pathToFileURL } = require('node:url'); +const fixtures = require('../common/fixtures'); +const tmpdir = require('../common/tmpdir'); + +tmpdir.refresh(); + +const worker = new Worker(fixtures.fileURL('web-worker', 'typescript', 'entry.ts'), { type: 'module' }); +worker.onmessage = common.mustNotCall('types must not be stripped'); +worker.onerror = common.mustCall(({ error }) => { + assert.strictEqual(error.name, 'SyntaxError'); +}); + +const path = join(tmpdir.path, 'entry.ts'); +writeFileSync(path, 'postMessage(1);'); +const untyped = new Worker(pathToFileURL(path), { type: 'module' }); +untyped.onerror = common.mustNotCall('JavaScript-compatible entries must still work'); +untyped.onmessage = common.mustCall(({ data }) => { + assert.strictEqual(data, 1); + untyped.terminate(); +}); diff --git a/test/parallel/test-webworker-typescript.js b/test/parallel/test-webworker-typescript.js new file mode 100644 index 000000000000..6e91d07657a1 --- /dev/null +++ b/test/parallel/test-webworker-typescript.js @@ -0,0 +1,85 @@ +// Flags: --experimental-web-worker +'use strict'; + +const common = require('../common'); +if (!process.config.variables.node_use_amaro) { + common.skip('Requires Amaro'); +} + +// Strip file entry types without changing the worker's module semantics. +const assert = require('node:assert'); +const { mkdirSync, writeFileSync } = require('node:fs'); +const { join } = require('node:path'); +const { pathToFileURL } = require('node:url'); +const fixtures = require('../common/fixtures'); +const tmpdir = require('../common/tmpdir'); + +tmpdir.refresh(); + +function createEntry(name, source) { + const path = join(tmpdir.path, name); + writeFileSync(path, source); + return pathToFileURL(path); +} + +function expectMessage(url, expected) { + const worker = new Worker(url, { type: 'module' }); + worker.onerror = common.mustNotCall('worker failed'); + worker.onmessage = common.mustCall(({ data }) => { + assert.deepStrictEqual(data, expected); + worker.terminate(); + }); +} + +function expectError(url, code, type = 'module') { + const worker = new Worker(url, { type }); + worker.onmessage = common.mustNotCall('worker unexpectedly succeeded'); + worker.onerror = common.mustCall(({ error }) => { + assert.strictEqual(error.code ?? error.name, code); + }); +} + +const entry = fixtures.fileURL('web-worker', 'typescript', 'entry.ts'); +expectMessage(entry, 1); +expectError(entry, 'SyntaxError', 'classic'); + +// Worker type takes precedence over both the extension and package type. +writeFileSync(join(tmpdir.path, 'package.json'), '{ "type": "commonjs" }'); +for (const extension of ['ts', 'mts', 'cts']) { + const url = createEntry(`entry.${extension}`, ` + const value: number = 1; + postMessage([value, typeof require, this === undefined, import.meta.main]); + `); + expectMessage(url, [1, 'undefined', true, true]); +} + +// The stripped entry can still import TypeScript and keeps its original URL. +{ + const url = fixtures.fileURL('web-worker', 'typescript', 'module.ts'); + url.search = '?version=1'; + url.hash = '#entry'; + expectMessage(url, { + value: 42, + url: url.href, + main: true, + requireType: 'undefined', + thisIsUndefined: true, + }); +} + +// Extension detection and the node_modules restriction use the decoded path. +{ + const url = createEntry('space \u00e9.ts', 'const value: number = 1; postMessage(value);'); + expectMessage(url.href.replace('.ts', '.%74s') + '?version=2#entry', 1); +} +mkdirSync(join(tmpdir.path, 'node_modules')); +const dependency = createEntry('node_modules/entry.ts', 'postMessage(1);'); +for (const url of [dependency.href, dependency.href.replace('node_modules', '%6eode_modules')]) { + expectError(url, 'ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING'); +} + +// Type-stripping errors must reach the parent's error handler. JavaScript +// entries must not acquire TypeScript support. +expectError(createEntry('invalid.ts', 'const value: = 1;'), 'ERR_INVALID_TYPESCRIPT_SYNTAX'); +expectError(createEntry('enum.ts', 'enum Value { A }'), 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX'); +expectError(createEntry('entry.js', 'const value: number = 1;'), 'SyntaxError');