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