diff --git a/doc/api/globals.md b/doc/api/globals.md index e717b9a05ac1..00383dcfb7dc 100644 --- a/doc/api/globals.md +++ b/doc/api/globals.md @@ -1378,6 +1378,12 @@ accepted and how failures are reported: `file:` URLs. The `type` option, not the file extension, decides how an entry is run, so a `.cts` entry is still evaluated as an ES module. +Package resolution within a Web Worker, including the entry point, its +dependencies, `import()`, `require()`, and `import.meta.resolve()`, applies +the `"worker"` [package condition][] in addition to the usual conditions. +Threads created through [`node:worker_threads`][] from within a Web Worker do +not apply it. + ### Differences from the HTML Standard Besides script loading, mentioned above: @@ -1545,6 +1551,7 @@ A browser-compatible implementation of [`WritableStreamDefaultWriter`][]. [browser `LockManager`]: https://developer.mozilla.org/en-US/docs/Web/API/LockManager [buffer section]: buffer.md [built-in objects]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects +[package condition]: packages.md#conditional-exports [timers]: timers.md [type stripping]: typescript.md#type-stripping [webassembly-mdn]: https://developer.mozilla.org/en-US/docs/WebAssembly diff --git a/doc/api/packages.md b/doc/api/packages.md index 8e1f5038f96b..a389f27d11c0 100644 --- a/doc/api/packages.md +++ b/doc/api/packages.md @@ -710,6 +710,11 @@ specific to least specific as conditions should be defined: `import()` or `require()`. The format is expected to be ES modules that does not contain top-level await in its module graph - if it does, `ERR_REQUIRE_ASYNC_MODULE` will be thrown when the module is `require()`-ed. +* `"worker"` - matches when the package is loaded within a [Web Worker][] + thread, including the worker entry point and all of its dependencies, + `import()`, `require()`, and `import.meta.resolve()`. Does not apply to + threads created with [`node:worker_threads`][], including those created + from within a Web Worker. * `"default"` - the generic fallback that always matches. Can be a CommonJS or ES module file. _This condition should always come last._ @@ -1356,6 +1361,7 @@ This field defines [subpath imports][] for the current package. [Runtime Keys]: https://runtime-keys.proposal.wintercg.org/ [Syntax detection]: #syntax-detection [TypeScript]: typescript.md +[Web Worker]: globals.md#class-worker [WebAssembly modules]: esm.md#wasm-modules [WinterCG]: https://wintercg.org/ [`"exports"`]: #exports @@ -1371,6 +1377,7 @@ This field defines [subpath imports][] for the current package. [`ERR_PACKAGE_PATH_NOT_EXPORTED`]: errors.md#err_package_path_not_exported [`ERR_UNKNOWN_FILE_EXTENSION`]: errors.md#err_unknown_file_extension [`URL`]: url.md#the-whatwg-url-api +[`node:worker_threads`]: worker_threads.md [`package.json`]: #nodejs-packagejson-field-definitions [customization hooks]: module.md#customization-hooks [entry points]: #package-entry-points diff --git a/lib/internal/main/worker_thread.js b/lib/internal/main/worker_thread.js index e2b30de754a2..864b776925fd 100644 --- a/lib/internal/main/worker_thread.js +++ b/lib/internal/main/worker_thread.js @@ -119,6 +119,7 @@ port.on('message', (message) => { setupMainThreadPort(mainThreadPort); if (webWorkerData !== undefined) { + require('internal/modules/helpers').enableWorkerCondition(); require('internal/webworker') .installDedicatedWorkerGlobalScope(webWorkerData.url, webWorkerData); } diff --git a/lib/internal/modules/esm/hooks.js b/lib/internal/modules/esm/hooks.js index 4ced5521aca2..220dcd33754e 100644 --- a/lib/internal/modules/esm/hooks.js +++ b/lib/internal/modules/esm/hooks.js @@ -50,6 +50,7 @@ const { const { getDefaultConditions, } = require('internal/modules/esm/utils'); +const { hasWorkerCondition } = require('internal/modules/helpers'); const { deserializeError } = require('internal/error_serdes'); const { SHARED_MEMORY_BYTE_LENGTH, @@ -531,6 +532,7 @@ class AsyncLoaderHookWorker { trackUnmanagedFds: false, workerData: { lock, + workerCondition: hasWorkerCondition(), }, }); this.#worker.unref(); // ! Allows the process to eventually exit. diff --git a/lib/internal/modules/esm/utils.js b/lib/internal/modules/esm/utils.js index 9e27a2db1ebc..c6b101fb9070 100644 --- a/lib/internal/modules/esm/utils.js +++ b/lib/internal/modules/esm/utils.js @@ -44,6 +44,7 @@ const { } = require('internal/util'); const assert = require('internal/assert'); const { + hasWorkerCondition, normalizeReferrerURL, loadBuiltinModuleForEmbedder, } = require('internal/modules/helpers'); @@ -84,6 +85,7 @@ function initializeDefaultConditions() { 'import', ...moduleConditions, ...addonConditions, + ...(hasWorkerCondition() ? ['worker'] : []), ...userConditions, ]); defaultConditionsSet = new SafeSet(defaultConditions); diff --git a/lib/internal/modules/esm/worker.js b/lib/internal/modules/esm/worker.js index e2553b253db8..7d023059a4b0 100644 --- a/lib/internal/modules/esm/worker.js +++ b/lib/internal/modules/esm/worker.js @@ -35,6 +35,7 @@ const { isMarkedAsUntransferable } = require('internal/buffer'); const { getCWDURL } = require('internal/util'); const { isCascadedLoaderInitialized, getOrInitializeCascadedLoader } = require('internal/modules/esm/loader'); const { AsyncLoaderHooksOnLoaderHookWorker } = require('internal/modules/esm/hooks'); +const { enableWorkerCondition } = require('internal/modules/helpers'); /** * Register asynchronous module loader customization hooks. This should only be run in the loader @@ -285,12 +286,16 @@ async function customizedModuleWorker(lock, syncCommPort, errorHandler) { /** * Initializes a worker thread for a module with customized hooks. * ! Run everything possible within this function so errors get reported. - * @param {{lock: SharedArrayBuffer}} workerData - The lock used to synchronize with the main thread. + * @param {{lock: SharedArrayBuffer, workerCondition: boolean}} workerData - The lock used to + * synchronize with the main thread, and whether the spawning thread is a Web Worker. * @param {MessagePort} syncCommPort - The communication port used to communicate with the main thread. * @returns {object} */ module.exports = function setupModuleWorker(workerData, syncCommPort) { const lock = new Int32Array(workerData.lock); + if (workerData.workerCondition) { + enableWorkerCondition(); + } /** * Handles errors that occur in the worker thread. diff --git a/lib/internal/modules/helpers.js b/lib/internal/modules/helpers.js index 98d9e04d0c9a..dc19e30e8f9f 100644 --- a/lib/internal/modules/helpers.js +++ b/lib/internal/modules/helpers.js @@ -194,6 +194,17 @@ function purgeRealpathCacheForPrefix(mountPoint) { cleanForVfsPrefix(realpathCache, mountPoint); } +/** Whether the `worker` condition applies, i.e. this is a Web Worker thread. */ +let workerCondition = false; + +function enableWorkerCondition() { + workerCondition = true; +} + +function hasWorkerCondition() { + return workerCondition; +} + /** @type {Set} */ let cjsConditions; /** @type {string[]} */ @@ -212,6 +223,7 @@ function initializeCjsConditions() { 'require', 'node', ...addonConditions, + ...(workerCondition ? ['worker'] : []), ...userConditions, ]; if (getOptionValue('--require-module')) { @@ -691,11 +703,13 @@ module.exports = { purgeRealpathCacheForPrefix, constants, enableCompileCache, + enableWorkerCondition, flushCompileCache, getBuiltinModule, getCjsConditions, getCjsConditionsArray, getCompileCacheDir, + hasWorkerCondition, initializeCjsConditions, legacyMainResolveExtensions, legacyMainResolveExtensionsIndexes, diff --git a/test/fixtures/es-modules/worker-condition/classic-worker.js b/test/fixtures/es-modules/worker-condition/classic-worker.js new file mode 100644 index 000000000000..d1f5f4e8d416 --- /dev/null +++ b/test/fixtures/es-modules/worker-condition/classic-worker.js @@ -0,0 +1,5 @@ +'use strict'; +const required = require('pkg'); +import('pkg').then(({ default: imported }) => { + postMessage({ require: required, import: imported, resolve: require.resolve('pkg') }); +}); diff --git a/test/fixtures/es-modules/worker-condition/hooks-worker.mjs b/test/fixtures/es-modules/worker-condition/hooks-worker.mjs new file mode 100644 index 000000000000..ee9cd45bd6c5 --- /dev/null +++ b/test/fixtures/es-modules/worker-condition/hooks-worker.mjs @@ -0,0 +1,6 @@ +import { register } from 'node:module'; + +register('./hooks.mjs', import.meta.url); + +const { default: value } = await import('pkg'); +postMessage(value); diff --git a/test/fixtures/es-modules/worker-condition/hooks.mjs b/test/fixtures/es-modules/worker-condition/hooks.mjs new file mode 100644 index 000000000000..8505bafc98db --- /dev/null +++ b/test/fixtures/es-modules/worker-condition/hooks.mjs @@ -0,0 +1,6 @@ +export async function resolve(specifier, context, nextResolve) { + if (specifier === 'pkg' && !context.conditions.includes('worker')) { + throw new Error(`missing worker condition: ${context.conditions}`); + } + return nextResolve(specifier, context); +} diff --git a/test/fixtures/es-modules/worker-condition/main.mjs b/test/fixtures/es-modules/worker-condition/main.mjs new file mode 100644 index 000000000000..2565f04c4206 --- /dev/null +++ b/test/fixtures/es-modules/worker-condition/main.mjs @@ -0,0 +1,13 @@ +import { createRequire } from 'node:module'; +import value from 'pkg'; +import custom from 'pkg/custom'; + +const require = createRequire(import.meta.url); + +export default { + import: value, + custom, + require: require('pkg'), + resolve: import.meta.resolve('pkg'), + workerOnly: await import('pkg/worker-only').then(() => 'resolved', (err) => err.code), +}; diff --git a/test/fixtures/es-modules/worker-condition/module-worker.mjs b/test/fixtures/es-modules/worker-condition/module-worker.mjs new file mode 100644 index 000000000000..fb069c692138 --- /dev/null +++ b/test/fixtures/es-modules/worker-condition/module-worker.mjs @@ -0,0 +1,31 @@ +import { once } from 'node:events'; +import { createRequire } from 'node:module'; +import { Worker as ThreadWorker } from 'node:worker_threads'; +import staticImport from 'pkg'; +import custom from 'pkg/custom'; +import workerOnly from 'pkg/worker-only'; + +const require = createRequire(import.meta.url); + +const { default: dynamicImport } = await import('pkg'); + +const thread = new ThreadWorker(new URL('./thread.mjs', import.meta.url)); +const [threadResult] = await once(thread, 'message'); + +const nested = new Worker(new URL('./nested-worker.mjs', import.meta.url), { type: 'module' }); +const nestedResult = await new Promise((resolve, reject) => { + nested.onmessage = ({ data }) => resolve(data); + nested.onerror = reject; +}); +nested.terminate(); + +postMessage({ + staticImport, + dynamicImport, + custom, + workerOnly, + resolve: import.meta.resolve('pkg'), + require: require('pkg'), + thread: threadResult, + nested: nestedResult, +}); diff --git a/test/fixtures/es-modules/worker-condition/nested-worker.mjs b/test/fixtures/es-modules/worker-condition/nested-worker.mjs new file mode 100644 index 000000000000..48a03e4fe41d --- /dev/null +++ b/test/fixtures/es-modules/worker-condition/nested-worker.mjs @@ -0,0 +1,2 @@ +import value from 'pkg'; +postMessage(value); diff --git a/test/fixtures/es-modules/worker-condition/node_modules/pkg/custom-worker.mjs b/test/fixtures/es-modules/worker-condition/node_modules/pkg/custom-worker.mjs new file mode 100644 index 000000000000..e80a870ddabb --- /dev/null +++ b/test/fixtures/es-modules/worker-condition/node_modules/pkg/custom-worker.mjs @@ -0,0 +1 @@ +export default 'custom-worker'; diff --git a/test/fixtures/es-modules/worker-condition/node_modules/pkg/custom.mjs b/test/fixtures/es-modules/worker-condition/node_modules/pkg/custom.mjs new file mode 100644 index 000000000000..bcd83d82be02 --- /dev/null +++ b/test/fixtures/es-modules/worker-condition/node_modules/pkg/custom.mjs @@ -0,0 +1 @@ +export default 'custom'; diff --git a/test/fixtures/es-modules/worker-condition/node_modules/pkg/default.cjs b/test/fixtures/es-modules/worker-condition/node_modules/pkg/default.cjs new file mode 100644 index 000000000000..024b12f73910 --- /dev/null +++ b/test/fixtures/es-modules/worker-condition/node_modules/pkg/default.cjs @@ -0,0 +1 @@ +module.exports = 'default'; diff --git a/test/fixtures/es-modules/worker-condition/node_modules/pkg/default.mjs b/test/fixtures/es-modules/worker-condition/node_modules/pkg/default.mjs new file mode 100644 index 000000000000..413f601a377a --- /dev/null +++ b/test/fixtures/es-modules/worker-condition/node_modules/pkg/default.mjs @@ -0,0 +1 @@ +export default 'default'; diff --git a/test/fixtures/es-modules/worker-condition/node_modules/pkg/package.json b/test/fixtures/es-modules/worker-condition/node_modules/pkg/package.json new file mode 100644 index 000000000000..b50fb3299d03 --- /dev/null +++ b/test/fixtures/es-modules/worker-condition/node_modules/pkg/package.json @@ -0,0 +1,23 @@ +{ + "name": "pkg", + "exports": { + ".": { + "worker": { + "import": "./worker.mjs", + "require": "./worker.cjs" + }, + "import": "./default.mjs", + "require": "./default.cjs" + }, + "./custom": { + "custom-condition": { + "worker": "./custom-worker.mjs", + "default": "./custom.mjs" + }, + "default": "./default.mjs" + }, + "./worker-only": { + "worker": "./worker.mjs" + } + } +} diff --git a/test/fixtures/es-modules/worker-condition/node_modules/pkg/worker.cjs b/test/fixtures/es-modules/worker-condition/node_modules/pkg/worker.cjs new file mode 100644 index 000000000000..a8baa369c928 --- /dev/null +++ b/test/fixtures/es-modules/worker-condition/node_modules/pkg/worker.cjs @@ -0,0 +1 @@ +module.exports = 'worker'; diff --git a/test/fixtures/es-modules/worker-condition/node_modules/pkg/worker.mjs b/test/fixtures/es-modules/worker-condition/node_modules/pkg/worker.mjs new file mode 100644 index 000000000000..314cb87d0695 --- /dev/null +++ b/test/fixtures/es-modules/worker-condition/node_modules/pkg/worker.mjs @@ -0,0 +1 @@ +export default 'worker'; diff --git a/test/fixtures/es-modules/worker-condition/thread-spawns-webworker.mjs b/test/fixtures/es-modules/worker-condition/thread-spawns-webworker.mjs new file mode 100644 index 000000000000..c76a741d6cc3 --- /dev/null +++ b/test/fixtures/es-modules/worker-condition/thread-spawns-webworker.mjs @@ -0,0 +1,8 @@ +import { parentPort } from 'node:worker_threads'; +import value from 'pkg'; + +const worker = new Worker(new URL('./nested-worker.mjs', import.meta.url), { type: 'module' }); +worker.onmessage = ({ data }) => { + worker.terminate(); + parentPort.postMessage({ thread: value, webWorker: data }); +}; diff --git a/test/fixtures/es-modules/worker-condition/thread.mjs b/test/fixtures/es-modules/worker-condition/thread.mjs new file mode 100644 index 000000000000..d5e0b615cc06 --- /dev/null +++ b/test/fixtures/es-modules/worker-condition/thread.mjs @@ -0,0 +1,13 @@ +import { parentPort } from 'node:worker_threads'; +import { createRequire } from 'node:module'; +import value from 'pkg'; +import custom from 'pkg/custom'; + +const require = createRequire(import.meta.url); + +parentPort.postMessage({ + import: value, + custom, + require: require('pkg'), + resolve: import.meta.resolve('pkg'), +}); diff --git a/test/parallel/test-webworker-conditions.js b/test/parallel/test-webworker-conditions.js new file mode 100644 index 000000000000..313e3f3d643d --- /dev/null +++ b/test/parallel/test-webworker-conditions.js @@ -0,0 +1,77 @@ +// Flags: --experimental-web-worker --conditions=custom-condition +'use strict'; + +// The `worker` package condition applies only within Web Worker threads, +// not on the main thread or in `worker_threads` threads. + +const common = require('../common'); +const fixtures = require('../common/fixtures'); +const assert = require('node:assert'); +const { once } = require('node:events'); +const { Worker: ThreadWorker } = require('node:worker_threads'); + +const base = fixtures.fileURL('es-modules/worker-condition/'); +const pkg = new URL('node_modules/pkg/', base); + +function runWebWorker(url, options) { + const worker = new Worker(url, options); + worker.onerror = common.mustNotCall('worker failed'); + return new Promise((resolve) => { + worker.onmessage = common.mustCall(({ data }) => { + worker.terminate(); + resolve(data); + }); + }); +} + +(async () => { + // Main thread: no worker condition. + assert.deepStrictEqual((await import(new URL('main.mjs', base))).default, { + import: 'default', + custom: 'custom', + require: 'default', + resolve: new URL('default.mjs', pkg).href, + workerOnly: 'ERR_PACKAGE_PATH_NOT_EXPORTED', + }); + + // Module Web Worker: entry point dependencies, dynamic import, + // import.meta.resolve, require, nested threads. + assert.deepStrictEqual( + await runWebWorker(new URL('module-worker.mjs', base), { type: 'module' }), + { + staticImport: 'worker', + dynamicImport: 'worker', + custom: 'custom-worker', + workerOnly: 'worker', + resolve: new URL('worker.mjs', pkg).href, + require: 'worker', + // worker_threads created from a Web Worker revert to no worker condition. + thread: { + import: 'default', + custom: 'custom', + require: 'default', + resolve: new URL('default.mjs', pkg).href, + }, + // Nested Web Workers retain it. + nested: 'worker', + }); + + // Classic Web Worker. + assert.deepStrictEqual( + await runWebWorker(new URL('classic-worker.js', base)), + { + require: 'worker', + import: 'worker', + resolve: fixtures.path('es-modules/worker-condition/node_modules/pkg/worker.cjs'), + }); + + // Web Worker created from a worker_threads thread. + const thread = new ThreadWorker(new URL('thread-spawns-webworker.mjs', base)); + const [threadResult] = await once(thread, 'message'); + assert.deepStrictEqual(threadResult, { thread: 'default', webWorker: 'worker' }); + + // Async module customization hooks registered from a Web Worker. + assert.strictEqual( + await runWebWorker(new URL('hooks-worker.mjs', base), { type: 'module' }), + 'worker'); +})().then(common.mustCall());