diff --git a/doc/api/esm.md b/doc/api/esm.md index 3215cc100df651..fb1989b46eb0d0 100644 --- a/doc/api/esm.md +++ b/doc/api/esm.md @@ -1188,6 +1188,39 @@ export async function transformSource(source, } ``` +#### getGlobalPreloadCode hook + +> Note: The loaders API is being redesigned. This hook may disappear or its +> signature may change. Do not rely on the API described below. + +Sometimes it can be necessary to run some code inside of the same global scope +that the application will run in. This hook allows to return a string that will +be ran as sloppy-mode script on startup. + +Similar to how CommonJS wrappers work, the code runs in an implicit function +scope. The only argument is a `require`-like function that can be used to load +builtins like "fs": `getBuiltin(request: string)`. + +If the code needs more advanced `require` features, it will have to construct +its own `require` using `module.createRequire()`. + +```js +/** + * @returns {string} Code to run before application startup + */ +export function getGlobalPreloadCode() { + return `\ +globalThis.someInjectedProperty = 42; +console.log('I just set some globals!'); + +const { createRequire } = getBuiltin('module'); + +const require = createRequire(process.cwd + '/'); +// [...] +`; +} +``` + #### dynamicInstantiate hook > Note: The loaders API is being redesigned. This hook may disappear or its diff --git a/lib/internal/modules/esm/loader.js b/lib/internal/modules/esm/loader.js index 5a27f4be7c9d1a..285f656fa99b11 100644 --- a/lib/internal/modules/esm/loader.js +++ b/lib/internal/modules/esm/loader.js @@ -7,6 +7,7 @@ const { } = primordials; const { + ERR_INVALID_ARG_VALUE, ERR_INVALID_RETURN_PROPERTY, ERR_INVALID_RETURN_PROPERTY_VALUE, ERR_INVALID_RETURN_VALUE, @@ -47,6 +48,14 @@ class Loader { // Map of already-loaded CJS modules to use this.cjsCache = new SafeMap(); + // This hook is called before the first root module is imported. It's a + // function that returns a piece of code that runs as a sloppy-mode script. + // The script may evaluate to a function that can be called with a + // `getBuiltin` helper that can be used to retrieve builtins. + // If the hook returns `null` instead of a source string, it opts out of + // running any preload code. + // The preload code runs as soon as the hook module has finished evaluating. + this._getGlobalPreloadCode = null; // The resolver has the signature // (specifier : string, parentURL : string, defaultResolve) // -> Promise<{ url : string }> @@ -168,7 +177,16 @@ class Loader { return module.getNamespace(); } - hook({ resolve, dynamicInstantiate, getFormat, getSource, transformSource }) { + hook(hooks) { + const { + resolve, + dynamicInstantiate, + getFormat, + getSource, + transformSource, + getGlobalPreloadCode, + } = hooks; + // Use .bind() to avoid giving access to the Loader instance when called. if (resolve !== undefined) this._resolve = FunctionPrototypeBind(resolve, null); @@ -185,6 +203,37 @@ class Loader { if (transformSource !== undefined) { this._transformSource = FunctionPrototypeBind(transformSource, null); } + if (getGlobalPreloadCode !== undefined) { + this._getGlobalPreloadCode = + FunctionPrototypeBind(getGlobalPreloadCode, null); + } + } + + runGlobalPreloadCode() { + if (!this._getGlobalPreloadCode) { + return; + } + const preloadCode = this._getGlobalPreloadCode(); + if (preloadCode === null) { + return; + } + + if (typeof preloadCode !== 'string') { + throw new ERR_INVALID_RETURN_VALUE( + 'string', 'loader getGlobalPreloadCode', preloadCode); + } + const { compileFunction } = require('vm'); + const preloadInit = compileFunction(preloadCode, ['getBuiltin'], { + filename: '', + }); + const { NativeModule } = require('internal/bootstrap/loaders'); + + preloadInit.call(globalThis, (builtinName) => { + if (NativeModule.canBeRequiredByUsers(builtinName)) { + return require(builtinName); + } + throw new ERR_INVALID_ARG_VALUE('builtinName', builtinName); + }); } async getModuleJob(specifier, parentURL) { diff --git a/lib/internal/process/esm_loader.js b/lib/internal/process/esm_loader.js index 404be77338bfe1..cca1e3e07956a1 100644 --- a/lib/internal/process/esm_loader.js +++ b/lib/internal/process/esm_loader.js @@ -69,6 +69,7 @@ async function initializeLoader() { await ESMLoader.import(userLoader, pathToFileURL(cwd).href); ESMLoader = new Loader(); ESMLoader.hook(hooks); + ESMLoader.runGlobalPreloadCode(); return exports.ESMLoader = ESMLoader; })(); } diff --git a/test/es-module/test-esm-loader-side-effect.mjs b/test/es-module/test-esm-loader-side-effect.mjs new file mode 100644 index 00000000000000..f76b10700ddc8f --- /dev/null +++ b/test/es-module/test-esm-loader-side-effect.mjs @@ -0,0 +1,32 @@ +// Flags: --experimental-loader ./test/fixtures/es-module-loaders/loader-side-effect.mjs --require ./test/fixtures/es-module-loaders/loader-side-effect-require-preload.js +import { allowGlobals, mustCall } from '../common/index.mjs'; +import assert from 'assert'; +import { fileURLToPath } from 'url'; +import { Worker, isMainThread, parentPort } from 'worker_threads'; + +/* global implicitGlobalProperty */ +assert.strictEqual(globalThis.implicitGlobalProperty, 42); +allowGlobals(implicitGlobalProperty); + +/* global implicitGlobalConst */ +assert.strictEqual(implicitGlobalConst, 42 * 42); +allowGlobals(implicitGlobalConst); + +/* global explicitGlobalProperty */ +assert.strictEqual(globalThis.explicitGlobalProperty, 42 * 42 * 42); +allowGlobals(explicitGlobalProperty); + +/* global preloadOrder */ +assert.deepStrictEqual(globalThis.preloadOrder, ['--require', 'loader']); +allowGlobals(preloadOrder); + +if (isMainThread) { + const worker = new Worker(fileURLToPath(import.meta.url)); + const promise = new Promise((resolve, reject) => { + worker.on('message', resolve); + worker.on('error', reject); + }); + promise.then(mustCall()); +} else { + parentPort.postMessage('worker done'); +} diff --git a/test/fixtures/es-module-loaders/loader-side-effect-require-preload.js b/test/fixtures/es-module-loaders/loader-side-effect-require-preload.js new file mode 100644 index 00000000000000..98820b9379748e --- /dev/null +++ b/test/fixtures/es-module-loaders/loader-side-effect-require-preload.js @@ -0,0 +1,6 @@ +/** + * This file is combined with `loader-side-effect.mjs` via `--require`. Its + * purpose is to test execution order of the two kinds of preload code. + */ + +(globalThis.preloadOrder || (globalThis.preloadOrder = [])).push('--require'); diff --git a/test/fixtures/es-module-loaders/loader-side-effect.mjs b/test/fixtures/es-module-loaders/loader-side-effect.mjs new file mode 100644 index 00000000000000..5c80724fbb95f6 --- /dev/null +++ b/test/fixtures/es-module-loaders/loader-side-effect.mjs @@ -0,0 +1,32 @@ +// Arrow function so it closes over the this-value of the preload scope. +const globalPreload = () => { + /* global getBuiltin */ + const assert = getBuiltin('assert'); + const vm = getBuiltin('vm'); + + assert.strictEqual(typeof require, 'undefined'); + assert.strictEqual(typeof module, 'undefined'); + assert.strictEqual(typeof exports, 'undefined'); + assert.strictEqual(typeof __filename, 'undefined'); + assert.strictEqual(typeof __dirname, 'undefined'); + + assert.strictEqual(this, globalThis); + (globalThis.preloadOrder || (globalThis.preloadOrder = [])).push('loader'); + + vm.runInThisContext(`\ +var implicitGlobalProperty = 42; +const implicitGlobalConst = 42 * 42; +`); + + assert.strictEqual(globalThis.implicitGlobalProperty, 42); + (implicitGlobalProperty).foo = 'bar'; // assert: not strict mode + + globalThis.explicitGlobalProperty = 42 * 42 * 42; +} + +export function getGlobalPreloadCode() { + return `\ + +(${globalPreload.toString()})(); +`; +}