Skip to content

Commit

Permalink
esm: add back globalPreload tests and fix failing ones
Browse files Browse the repository at this point in the history
PR-URL: nodejs/node#48779
Backport-PR-URL: nodejs/node#50669
Fixes: nodejs/node#48778
Fixes: nodejs/node#48516
Refs: nodejs/node#46402
Reviewed-By: Geoffrey Booth <webadmin@geoffreybooth.com>
Reviewed-By: Jacob Smith <jacob@frende.me>
  • Loading branch information
sercher committed Apr 25, 2024
1 parent cebab12 commit 29f9d1c
Show file tree
Hide file tree
Showing 7 changed files with 475 additions and 31 deletions.
2 changes: 1 addition & 1 deletion graal-nodejs/lib/internal/main/eval_string.js
Expand Up @@ -22,7 +22,7 @@ markBootstrapComplete();

const source = getOptionValue('--eval');
const print = getOptionValue('--print');
const loadESM = getOptionValue('--import').length > 0;
const loadESM = getOptionValue('--import').length > 0 || getOptionValue('--experimental-loader').length > 0;
if (getOptionValue('--input-type') === 'module')
evalModule(source, print);
else
Expand Down
71 changes: 52 additions & 19 deletions graal-nodejs/lib/internal/modules/esm/hooks.js
Expand Up @@ -10,6 +10,7 @@ const {
Promise,
SafeSet,
StringPrototypeSlice,
StringPrototypeStartsWith,
StringPrototypeToUpperCase,
globalThis,
} = primordials;
Expand All @@ -30,6 +31,7 @@ const {
ERR_INVALID_RETURN_PROPERTY_VALUE,
ERR_INVALID_RETURN_VALUE,
ERR_LOADER_CHAIN_INCOMPLETE,
ERR_UNKNOWN_BUILTIN_MODULE,
ERR_WORKER_UNSERIALIZABLE_ERROR,
} = require('internal/errors').codes;
const { URL } = require('internal/url');
Expand Down Expand Up @@ -520,14 +522,14 @@ class HooksProxy {
this.#worker.on('exit', process.exit);
}

#waitForWorker() {
waitForWorker() {
if (!this.#isReady) {
const { kIsOnline } = require('internal/worker');
if (!this.#worker[kIsOnline]) {
debug('wait for signal from worker');
AtomicsWait(this.#lock, WORKER_TO_MAIN_THREAD_NOTIFICATION, 0);
const response = this.#worker.receiveMessageSync();
if (response.message.status === 'exit') { return; }
if (response == null || response.message.status === 'exit') { return; }
const { preloadScripts } = this.#unwrapMessage(response);
this.#executePreloadScripts(preloadScripts);
}
Expand All @@ -537,7 +539,7 @@ class HooksProxy {
}

async makeAsyncRequest(method, ...args) {
this.#waitForWorker();
this.waitForWorker();

MessageChannel ??= require('internal/worker/io').MessageChannel;
const asyncCommChannel = new MessageChannel();
Expand Down Expand Up @@ -577,7 +579,7 @@ class HooksProxy {
}

makeSyncRequest(method, ...args) {
this.#waitForWorker();
this.waitForWorker();

// Pass work to the worker.
debug('post sync message to worker', { method, args });
Expand Down Expand Up @@ -619,35 +621,66 @@ class HooksProxy {
}
}

#importMetaInitializer = require('internal/modules/esm/initialize_import_meta').initializeImportMeta;

importMetaInitialize(meta, context, loader) {
this.#importMetaInitializer(meta, context, loader);
}

#executePreloadScripts(preloadScripts) {
for (let i = 0; i < preloadScripts.length; i++) {
const { code, port } = preloadScripts[i];
const { compileFunction } = require('vm');
const preloadInit = compileFunction(
code,
['getBuiltin', 'port'],
['getBuiltin', 'port', 'setImportMetaCallback'],
{
filename: '<preload>',
},
);
let finished = false;
let replacedImportMetaInitializer = false;
let next = this.#importMetaInitializer;
const { BuiltinModule } = require('internal/bootstrap/realm');
// Calls the compiled preload source text gotten from the hook
// Since the parameters are named we use positional parameters
// see compileFunction above to cross reference the names
FunctionPrototypeCall(
preloadInit,
globalThis,
// Param getBuiltin
(builtinName) => {
if (BuiltinModule.canBeRequiredByUsers(builtinName) &&
BuiltinModule.canBeRequiredWithoutScheme(builtinName)) {
return require(builtinName);
}
throw new ERR_INVALID_ARG_VALUE('builtinName', builtinName);
},
// Param port
port,
);
try {
FunctionPrototypeCall(
preloadInit,
globalThis,
// Param getBuiltin
(builtinName) => {
if (StringPrototypeStartsWith(builtinName, 'node:')) {
builtinName = StringPrototypeSlice(builtinName, 5);
} else if (!BuiltinModule.canBeRequiredWithoutScheme(builtinName)) {
throw new ERR_UNKNOWN_BUILTIN_MODULE(builtinName);
}
if (BuiltinModule.canBeRequiredByUsers(builtinName)) {
return require(builtinName);
}
throw new ERR_UNKNOWN_BUILTIN_MODULE(builtinName);
},
// Param port
port,
// setImportMetaCallback
(fn) => {
if (finished || typeof fn !== 'function') {
throw new ERR_INVALID_ARG_TYPE('fn', fn);
}
replacedImportMetaInitializer = true;
const parent = next;
next = (meta, context) => {
return fn(meta, context, parent);
};
},
);
} finally {
finished = true;
if (replacedImportMetaInitializer) {
this.#importMetaInitializer = next;
}
}
}
}
}
Expand Down
13 changes: 13 additions & 0 deletions graal-nodejs/lib/internal/modules/esm/loader.js
Expand Up @@ -287,6 +287,11 @@ class DefaultModuleLoader {
meta = importMetaInitializer(meta, context, this);
return meta;
}

/**
* No-op when no hooks have been supplied.
*/
forceLoadHooks() {}
}
ObjectSetPrototypeOf(DefaultModuleLoader.prototype, null);

Expand Down Expand Up @@ -359,6 +364,14 @@ class CustomizedModuleLoader extends DefaultModuleLoader {

return result;
}

importMetaInitialize(meta, context) {
hooksProxy.importMetaInitialize(meta, context, this);
}

forceLoadHooks() {
hooksProxy.waitForWorker();
}
}


Expand Down
2 changes: 2 additions & 0 deletions graal-nodejs/lib/internal/process/esm_loader.js
Expand Up @@ -36,6 +36,8 @@ module.exports = {
parentURL,
kEmptyObject,
));
} else {
esmLoader.forceLoadHooks();
}
await callback(esmLoader);
} catch (err) {
Expand Down
124 changes: 113 additions & 11 deletions graal-nodejs/test/es-module/test-esm-loader-hooks.mjs
@@ -1,6 +1,7 @@
import { spawnPromisified } from '../common/index.mjs';
import * as fixtures from '../common/fixtures.mjs';
import assert from 'node:assert';
import os from 'node:os';
import { execPath } from 'node:process';
import { describe, it } from 'node:test';

Expand Down Expand Up @@ -370,18 +371,119 @@ describe('Loader hooks', { concurrency: true }, () => {
});
});

it('should handle globalPreload returning undefined', async () => {
const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
'--no-warnings',
'--experimental-loader',
'data:text/javascript,export function globalPreload(){}',
fixtures.path('empty.js'),
]);
describe('globalPreload', () => {
it('should handle globalPreload returning undefined', async () => {
const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
'--no-warnings',
'--experimental-loader',
'data:text/javascript,export function globalPreload(){}',
fixtures.path('empty.js'),
]);

assert.strictEqual(stderr, '');
assert.strictEqual(stdout, '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
assert.strictEqual(stderr, '');
assert.strictEqual(stdout, '');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
});

it('should handle loading node:test', async () => {
const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
'--no-warnings',
'--experimental-loader',
'data:text/javascript,export function globalPreload(){return `getBuiltin("node:test")()`}',
fixtures.path('empty.js'),
]);

assert.strictEqual(stderr, '');
assert.match(stdout, /\n# pass 1\r?\n/);
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
});

it('should handle loading node:os with node: prefix', async () => {
const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
'--no-warnings',
'--experimental-loader',
'data:text/javascript,export function globalPreload(){return `console.log(getBuiltin("node:os").arch())`}',
fixtures.path('empty.js'),
]);

assert.strictEqual(stderr, '');
assert.strictEqual(stdout.trim(), os.arch());
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
});

// `os` is used here because it's simple and not mocked (the builtin module otherwise doesn't matter).
it('should handle loading builtin module without node: prefix', async () => {
const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
'--no-warnings',
'--experimental-loader',
'data:text/javascript,export function globalPreload(){return `console.log(getBuiltin("os").arch())`}',
fixtures.path('empty.js'),
]);

assert.strictEqual(stderr, '');
assert.strictEqual(stdout.trim(), os.arch());
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
});

it('should throw when loading node:test without node: prefix', async () => {
const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
'--no-warnings',
'--experimental-loader',
'data:text/javascript,export function globalPreload(){return `getBuiltin("test")()`}',
fixtures.path('empty.js'),
]);

assert.match(stderr, /ERR_UNKNOWN_BUILTIN_MODULE/);
assert.strictEqual(stdout, '');
assert.strictEqual(code, 1);
assert.strictEqual(signal, null);
});

it('should register globals set from globalPreload', async () => {
const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
'--no-warnings',
'--experimental-loader',
'data:text/javascript,export function globalPreload(){return "this.myGlobal=4"}',
'--print', 'myGlobal',
]);

assert.strictEqual(stderr, '');
assert.strictEqual(stdout.trim(), '4');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
});

it('should log console.log calls returned from globalPreload', async () => {
const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
'--no-warnings',
'--experimental-loader',
'data:text/javascript,export function globalPreload(){return `console.log("Hello from globalPreload")`}',
fixtures.path('empty.js'),
]);

assert.strictEqual(stderr, '');
assert.strictEqual(stdout.trim(), 'Hello from globalPreload');
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
});

it('should crash if globalPreload returns code that throws', async () => {
const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [
'--no-warnings',
'--experimental-loader',
'data:text/javascript,export function globalPreload(){return `throw new Error("error from globalPreload")`}',
fixtures.path('empty.js'),
]);

assert.match(stderr, /error from globalPreload/);
assert.strictEqual(stdout, '');
assert.strictEqual(code, 1);
assert.strictEqual(signal, null);
});
});

it('should be fine to call `process.removeAllListeners("beforeExit")` from the main thread', async () => {
Expand Down
45 changes: 45 additions & 0 deletions graal-nodejs/test/es-module/test-esm-loader-mock.mjs
@@ -0,0 +1,45 @@
// Flags: --loader ./test/fixtures/es-module-loaders/mock-loader.mjs
import '../common/index.mjs';
import assert from 'assert/strict';

// This is provided by test/fixtures/es-module-loaders/mock-loader.mjs
import mock from 'node:mock';

mock('node:events', {
EventEmitter: 'This is mocked!'
});

// This resolves to node:events
// It is intercepted by mock-loader and doesn't return the normal value
assert.deepStrictEqual(await import('events'), Object.defineProperty({
__proto__: null,
EventEmitter: 'This is mocked!'
}, Symbol.toStringTag, {
enumerable: false,
value: 'Module'
}));

const mutator = mock('node:events', {
EventEmitter: 'This is mocked v2!'
});

// It is intercepted by mock-loader and doesn't return the normal value.
// This is resolved separately from the import above since the specifiers
// are different.
const mockedV2 = await import('node:events');
assert.deepStrictEqual(mockedV2, Object.defineProperty({
__proto__: null,
EventEmitter: 'This is mocked v2!'
}, Symbol.toStringTag, {
enumerable: false,
value: 'Module'
}));

mutator.EventEmitter = 'This is mocked v3!';
assert.deepStrictEqual(mockedV2, Object.defineProperty({
__proto__: null,
EventEmitter: 'This is mocked v3!'
}, Symbol.toStringTag, {
enumerable: false,
value: 'Module'
}));

0 comments on commit 29f9d1c

Please sign in to comment.