1import { receiveMessageOnPort } from 'node:worker_threads';
2const mockedModuleExports = new Map();
3let currentMockVersion = 0;
4
5// These hooks enable code running on the application thread to
6// swap module resolution results for mocking purposes. It uses this instead
7// of import.meta so that CommonJS can still use the functionality.
8//
9// It does so by allowing non-mocked modules to live in normal URL cache
10// locations but creates 'mock-facade:' URL cache location for every time a
11// module location is mocked. Since a single URL can be mocked multiple
12// times but it cannot be removed from the cache, `mock-facade:` URLs have a
13// form of mock-facade:$VERSION:$REPLACING_URL with the parameters being URL
14// percent encoded every time a module is resolved. So if a module for
15// 'file:///app.js' is mocked it might look like
16// 'mock-facade:12:file%3A%2F%2F%2Fapp.js'. This encoding is done to prevent
17// problems like mocking URLs with special URL characters like '#' or '?' from
18// accidentally being picked up as part of the 'mock-facade:' URL containing
19// the mocked URL.
20//
21// NOTE: due to ESM spec, once a specifier has been resolved in a source text
22//       it cannot be changed. So things like the following DO NOT WORK:
23//
24// ```mjs
25// import mock from 'test-esm-loader-mock'; // See test-esm-loader-mock.mjs
26// mock('file:///app.js', {x:1});
27// const namespace1 = await import('file:///app.js');
28// namespace1.x; // 1
29// mock('file:///app.js', {x:2});
30// const namespace2 = await import('file:///app.js');
31// namespace2.x; // STILL 1, because this source text already set the specifier
32//               // for 'file:///app.js', a different specifier that resolves
33//               // to that could still get a new namespace though
34// assert(namespace1 === namespace2);
35// ```
36
37/**
38 * @param param0 message from the application context
39 */
40function onPreloadPortMessage({
41  mockVersion, resolved, exports
42}) {
43  currentMockVersion = mockVersion;
44  mockedModuleExports.set(resolved, exports);
45}
46
47/** @type {URL['href']} */
48let mainImportURL;
49/** @type {MessagePort} */
50let preloadPort;
51export async function initialize(data) {
52  ({ mainImportURL, port: preloadPort } = data);
53  
54  data.port.on('message', onPreloadPortMessage);
55}
56
57/**
58 * Because Node.js internals use a separate MessagePort for cross-thread
59 * communication, there could be some messages pending that we should handle
60 * before continuing.
61 */
62function doDrainPort() {
63  let msg;
64  while (msg = receiveMessageOnPort(preloadPort)) {
65    onPreloadPortMessage(msg.message);
66  }
67}
68
69// Rewrites node: loading to mock-facade: so that it can be intercepted
70export async function resolve(specifier, context, defaultResolve) {
71  doDrainPort();
72  const def = await defaultResolve(specifier, context);
73  if (context.parentURL?.startsWith('mock-facade:')) {
74    // Do nothing, let it get the "real" module
75  } else if (mockedModuleExports.has(def.url)) {
76    return {
77      shortCircuit: true,
78      url: `mock-facade:${currentMockVersion}:${encodeURIComponent(def.url)}`
79    };
80  };
81  return {
82    shortCircuit: true,
83    url: def.url,
84  };
85}
86
87export async function load(url, context, defaultLoad) {
88  doDrainPort();
89  /**
90   * Mocked fake module, not going to be handled in default way so it
91   * generates the source text, then short circuits
92   */
93  if (url.startsWith('mock-facade:')) {
94    const encodedTargetURL = url.slice(url.lastIndexOf(':') + 1);
95    return {
96      shortCircuit: true,
97      source: generateModule(encodedTargetURL),
98      format: 'module',
99    };
100  }
101  return defaultLoad(url, context);
102}
103
104/**
105 * Generate the source code for a mocked module.
106 * @param {string} encodedTargetURL the module being mocked
107 * @returns {string}
108 */
109function generateModule(encodedTargetURL) {
110  const exports = mockedModuleExports.get(
111    decodeURIComponent(encodedTargetURL)
112  );
113  let body = [
114    `import { mockedModules } from ${JSON.stringify(mainImportURL)};`,
115    'export {};',
116    'let mapping = {__proto__: null};',
117    `const mock = mockedModules.get(${JSON.stringify(encodedTargetURL)});`,
118  ];
119  for (const [i, name] of Object.entries(exports)) {
120    let key = JSON.stringify(name);
121    body.push(`var _${i} = mock.namespace[${key}];`);
122    body.push(`Object.defineProperty(mapping, ${key}, { enumerable: true, set(v) {_${i} = v;}, get() {return _${i};} });`);
123    body.push(`export {_${i} as ${name}};`);
124  }
125  body.push(`mock.listeners.push(${
126    () => {
127      for (var k in mapping) {
128        mapping[k] = mock.namespace[k];
129      }
130    }
131  });`);
132  return body.join('\n');
133}
134