Skip to content

Commit 13ab19e

Browse files
committed
esm: working mock test
1 parent e55ab89 commit 13ab19e

File tree

7 files changed

+279
-33
lines changed

7 files changed

+279
-33
lines changed

doc/api/esm.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -806,6 +806,30 @@ const require = createRequire(cwd() + '/<preload>');
806806
}
807807
```
808808
809+
In order to allow communication between the application and the loader another
810+
argument is provided to the preload code `port`. This is available as a
811+
parameter to the loader hook and inside of the source text returned by the hook.
812+
Some care must be taken in order to properly `ref()` and `unref()` the
813+
`MessagePort` to prevent a process from being in a state where it won't close
814+
normally.
815+
816+
```js
817+
/**
818+
* This example causes
819+
* @param {object} utilities
820+
* @param {MessagePort} utilities.port
821+
*/
822+
export function getGlobalPreloadCode({ port }) {
823+
port.onmessage = (evt) => {
824+
// ...
825+
};
826+
return `\
827+
port.postMessage('I went to the Loader and back');
828+
port.onmessage = eval;
829+
`;
830+
}
831+
```
832+
809833
### Examples
810834
811835
The various loader hooks can be used together to accomplish wide-ranging
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
'use strict';
2+
3+
const { getOptionValue } = require('internal/options');
4+
const experimentalImportMetaResolve =
5+
getOptionValue('--experimental-import-meta-resolve');
6+
const { PromisePrototypeThen, PromiseReject } = primordials;
7+
const asyncESM = require('internal/process/esm_loader');
8+
9+
function createImportMetaResolve(defaultParentUrl) {
10+
return async function resolve(specifier, parentUrl = defaultParentUrl) {
11+
return PromisePrototypeThen(
12+
asyncESM.esmLoader.resolve(specifier, parentUrl),
13+
({ url }) => url,
14+
(error) => (
15+
error.code === 'ERR_UNSUPPORTED_DIR_IMPORT' ?
16+
error.url : PromiseReject(error))
17+
);
18+
};
19+
}
20+
21+
function initializeImportMeta(meta, context) {
22+
const url = context.url;
23+
24+
// Alphabetical
25+
if (experimentalImportMetaResolve)
26+
meta.resolve = createImportMetaResolve(url);
27+
meta.url = url;
28+
}
29+
30+
module.exports = {
31+
initializeImportMeta
32+
};

lib/internal/modules/esm/loader.js

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const {
1818
SafeWeakMap,
1919
globalThis,
2020
} = primordials;
21+
const { MessageChannel } = require('internal/worker/io');
2122

2223
const {
2324
ERR_INVALID_ARG_TYPE,
@@ -39,6 +40,9 @@ const {
3940
defaultResolve,
4041
DEFAULT_CONDITIONS,
4142
} = require('internal/modules/esm/resolve');
43+
const {
44+
initializeImportMeta
45+
} = require('internal/modules/esm/initialize_import_meta');
4246
const { defaultLoad } = require('internal/modules/esm/load');
4347
const { translators } = require(
4448
'internal/modules/esm/translators');
@@ -76,6 +80,8 @@ class ESMLoader {
7680
defaultResolve,
7781
];
7882

83+
#importMetaInitializer = initializeImportMeta;
84+
7985
/**
8086
* Map of already-loaded CJS modules to use
8187
*/
@@ -359,7 +365,15 @@ class ESMLoader {
359365
if (!count) return;
360366

361367
for (let i = 0; i < count; i++) {
362-
const preload = this.#globalPreloaders[i]();
368+
const channel = new MessageChannel();
369+
const insidePreload = channel.port1;
370+
insidePreload.unref();
371+
const insideLoader = channel.port2;
372+
insideLoader.unref();
373+
374+
const preload = this.#globalPreloaders[i]({
375+
port: insideLoader
376+
});
363377

364378
if (preload == null) return;
365379

@@ -373,22 +387,44 @@ class ESMLoader {
373387
const { compileFunction } = require('vm');
374388
const preloadInit = compileFunction(
375389
preload,
376-
['getBuiltin'],
390+
['getBuiltin', 'port', 'setImportMetaCallback'],
377391
{
378392
filename: '<preload>',
379393
}
380394
);
381395
const { NativeModule } = require('internal/bootstrap/loaders');
382-
383-
FunctionPrototypeCall(preloadInit, globalThis, (builtinName) => {
384-
if (NativeModule.canBeRequiredByUsers(builtinName)) {
385-
return require(builtinName);
396+
let finished = false;
397+
let replacedImportMetaInitializer = false;
398+
let next = this.#importMetaInitializer;
399+
try {
400+
FunctionPrototypeCall(preloadInit, globalThis, (builtinName) => {
401+
if (NativeModule.canBeRequiredByUsers(builtinName)) {
402+
return require(builtinName);
403+
}
404+
throw new ERR_INVALID_ARG_VALUE('builtinName', builtinName);
405+
}, insidePreload, (fn) => {
406+
if (finished || typeof fn !== 'function') {
407+
throw new ERR_INVALID_ARG_TYPE('fn', fn);
408+
}
409+
replacedImportMetaInitializer = true;
410+
const parent = next;
411+
next = (meta, context) => {
412+
return fn(meta, context, parent);
413+
};
414+
});
415+
} finally {
416+
finished = true;
417+
if (replacedImportMetaInitializer) {
418+
this.#importMetaInitializer = next;
386419
}
387-
throw new ERR_INVALID_ARG_VALUE('builtinName', builtinName);
388-
});
420+
}
389421
}
390422
}
391423

424+
importMetaInitialize(meta, context) {
425+
this.#importMetaInitializer(meta, context);
426+
}
427+
392428
/**
393429
* Resolve the location of the module.
394430
*

lib/internal/modules/esm/translators.js

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ const {
88
ObjectGetPrototypeOf,
99
ObjectPrototypeHasOwnProperty,
1010
ObjectKeys,
11-
PromisePrototypeThen,
12-
PromiseReject,
1311
SafeArrayIterator,
1412
SafeMap,
1513
SafeSet,
@@ -52,9 +50,6 @@ const {
5250
const { maybeCacheSourceMap } = require('internal/source_map/source_map_cache');
5351
const moduleWrap = internalBinding('module_wrap');
5452
const { ModuleWrap } = moduleWrap;
55-
const { getOptionValue } = require('internal/options');
56-
const experimentalImportMetaResolve =
57-
getOptionValue('--experimental-import-meta-resolve');
5853
const asyncESM = require('internal/process/esm_loader');
5954
const { emitWarningSync } = require('internal/process/warning');
6055
const { TextDecoder } = require('internal/encoding');
@@ -111,25 +106,6 @@ async function importModuleDynamically(specifier, { url }) {
111106
return asyncESM.esmLoader.import(specifier, url);
112107
}
113108

114-
function createImportMetaResolve(defaultParentUrl) {
115-
return async function resolve(specifier, parentUrl = defaultParentUrl) {
116-
return PromisePrototypeThen(
117-
asyncESM.esmLoader.resolve(specifier, parentUrl),
118-
({ url }) => url,
119-
(error) => (
120-
error.code === 'ERR_UNSUPPORTED_DIR_IMPORT' ?
121-
error.url : PromiseReject(error))
122-
);
123-
};
124-
}
125-
126-
function initializeImportMeta(meta, { url }) {
127-
// Alphabetical
128-
if (experimentalImportMetaResolve)
129-
meta.resolve = createImportMetaResolve(url);
130-
meta.url = url;
131-
}
132-
133109
// Strategy for loading a standard JavaScript module.
134110
translators.set('module', async function moduleStrategy(url, source, isMain) {
135111
assertBufferSource(source, true, 'load');
@@ -138,7 +114,9 @@ translators.set('module', async function moduleStrategy(url, source, isMain) {
138114
debug(`Translating StandardModule ${url}`);
139115
const module = new ModuleWrap(url, undefined, source, 0, 0);
140116
moduleWrap.callbackMap.set(module, {
141-
initializeImportMeta,
117+
initializeImportMeta: (meta, wrap) => this.importMetaInitialize(meta, {
118+
url: wrap.url
119+
}),
142120
importModuleDynamically,
143121
});
144122
return module;
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Flags: --loader ./test/fixtures/es-module-loaders/mock-loader.mjs
2+
import '../common/index.mjs';
3+
import assert from 'assert/strict';
4+
import mock from 'node:mock';
5+
6+
mock('node:events', {
7+
EventEmitter: 'This is mocked!'
8+
});
9+
10+
// This resolves to node:events
11+
assert.deepStrictEqual(await import('events'), Object.defineProperty({
12+
__proto__: null,
13+
EventEmitter: 'This is mocked!'
14+
}, Symbol.toStringTag, {
15+
enumerable: false,
16+
value: 'Module'
17+
}));
18+
19+
const mutator = mock('node:events', {
20+
EventEmitter: 'This is mocked v2!'
21+
});
22+
23+
const mockedV2 = await import('node:events');
24+
assert.deepStrictEqual(mockedV2, Object.defineProperty({
25+
__proto__: null,
26+
EventEmitter: 'This is mocked v2!'
27+
}, Symbol.toStringTag, {
28+
enumerable: false,
29+
value: 'Module'
30+
}));
31+
32+
mutator.EventEmitter = 'This is mocked v3!';
33+
assert.deepStrictEqual(mockedV2, Object.defineProperty({
34+
__proto__: null,
35+
EventEmitter: 'This is mocked v3!'
36+
}, Symbol.toStringTag, {
37+
enumerable: false,
38+
value: 'Module'
39+
}));
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import {receiveMessageOnPort} from 'worker_threads';
2+
const mockedModuleExports = new Map();
3+
let currentMockVersion = 0;
4+
5+
/**
6+
* FIXME: this is a hack to workaround loaders being
7+
* single threaded for now
8+
*/
9+
function doDrainPort() {
10+
let msg;
11+
while (msg = receiveMessageOnPort(preloadPort)) {
12+
onPreloadPortMessage(msg.message);
13+
}
14+
}
15+
function onPreloadPortMessage({
16+
mockVersion, resolved, exports
17+
}) {
18+
currentMockVersion = mockVersion;
19+
mockedModuleExports.set(resolved, exports);
20+
}
21+
let preloadPort;
22+
export function globalPreload({port}) {
23+
preloadPort = port;
24+
port.on('message', onPreloadPortMessage);
25+
port.unref();
26+
return `(${()=>{
27+
let mockedModules = new Map();
28+
let mockVersion = 0;
29+
const doMock = (resolved, replacementProperties) => {
30+
let exports = Object.keys(replacementProperties);
31+
let namespace = Object.create(null);
32+
let listeners = [];
33+
for (const name of exports) {
34+
let currentValue = replacementProperties[name];
35+
Object.defineProperty(namespace, name, {
36+
enumerable: true,
37+
get() {
38+
return currentValue;
39+
},
40+
set(v) {
41+
currentValue = v;
42+
for (let fn of listeners) {
43+
try {
44+
fn(name);
45+
} catch {
46+
}
47+
}
48+
}
49+
});
50+
}
51+
mockedModules.set(resolved, {
52+
namespace,
53+
listeners
54+
});
55+
mockVersion++;
56+
port.postMessage({mockVersion, resolved, exports });
57+
return namespace;
58+
}
59+
setImportMetaCallback((meta, context, parent) => {
60+
if (context.url === 'node:mock') {
61+
meta.doMock = doMock;
62+
return;
63+
}
64+
if (context.url.startsWith('mock:')) {
65+
let [proto, version, encodedTargetURL] = context.url.split(':');
66+
let decodedTargetURL = decodeURIComponent(encodedTargetURL);
67+
if (mockedModules.has(decodedTargetURL)) {
68+
meta.mock = mockedModules.get(decodedTargetURL);
69+
return;
70+
}
71+
}
72+
parent(meta, context);
73+
});
74+
}})()`;
75+
}
76+
77+
78+
// rewrites node: loading to mock: so that it can be intercepted
79+
export function resolve(specifier, context, defaultResolve) {
80+
if (specifier === 'node:mock') {
81+
return {
82+
url: specifier
83+
};
84+
}
85+
doDrainPort();
86+
const def = defaultResolve(specifier, context);
87+
if (context.parentURL?.startsWith('mock:')) {
88+
// do nothing, let it get the "real" module
89+
} else if (mockedModuleExports.has(def.url)) {
90+
return {
91+
url: `mock:${currentMockVersion}:${encodeURIComponent(def.url)}`
92+
};
93+
};
94+
return {
95+
url: `${def.url}`
96+
};
97+
}
98+
99+
export function load(url, context, defaultLoad) {
100+
doDrainPort();
101+
if (url === 'node:mock') {
102+
return {
103+
source: 'export default import.meta.doMock',
104+
format: 'module'
105+
};
106+
}
107+
if (url.startsWith('mock:')) {
108+
let [proto, version, encodedTargetURL] = url.split(':');
109+
let ret = generateModule(mockedModuleExports.get(
110+
decodeURIComponent(encodedTargetURL)
111+
));
112+
return {
113+
source: ret,
114+
format: 'module'
115+
};
116+
}
117+
return defaultLoad(url, context);
118+
}
119+
120+
function generateModule(exports) {
121+
let body = 'export {};let mapping = {__proto__: null};'
122+
for (const [i, name] of Object.entries(exports)) {
123+
let key = JSON.stringify(name);
124+
body += `var _${i} = import.meta.mock.namespace[${key}];`
125+
body += `Object.defineProperty(mapping, ${key}, {enumerable: true,set(v) {_${i} = v;}});`
126+
body += `export {_${i} as ${name}};`;
127+
}
128+
body += `import.meta.mock.listeners.push(${
129+
() => {
130+
for (var k in mapping) {
131+
mapping[k] = import.meta.mock.namespace[k];
132+
}
133+
}
134+
});`
135+
return body;
136+
}

0 commit comments

Comments
 (0)