Skip to content
Closed
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fixup: doc the mock
  • Loading branch information
bmeck committed Nov 22, 2021
commit 5ad4c1b6a1a88a56ced729ba4cd6d402d34c5056
116 changes: 105 additions & 11 deletions test/fixtures/es-module-loaders/mock-loader.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,47 @@ import { receiveMessageOnPort } from 'node:worker_threads';
const mockedModuleExports = new Map();
let currentMockVersion = 0;

// This loader causes import.meta.mock to become available as a way to swap
// module resolution.
// This loader causes a new module `node:mock` to become available as a way to
// swap module resolution results for mocking purposes. It uses this instead
// of import.meta so that CJS can still use the functionality
Comment thread
bmeck marked this conversation as resolved.
Outdated
//
// It does so by allowing non-mocked modules to live in normal URL cache
// locations but creates 'mock-facade:' URL cache location for every time a
// module location is mocked. Since a single URL can be mocked multiple
// times but it cannot be removed from the cache `mock-facade:` URLs have a
Comment thread
bmeck marked this conversation as resolved.
Outdated
// form of mock-facade:$VERSION:$REPLACING_URL with the parameters being URL
Comment thread
bmeck marked this conversation as resolved.
// percent encoded every time a module is resolved
Comment thread
bmeck marked this conversation as resolved.
Outdated
//
// NOTE: due to ESM spec, once a specifier has been resolved in a source text
// it cannot be changed. so things like the following DO NOT WORK
Comment thread
bmeck marked this conversation as resolved.
Outdated
//
// ```mjs
// import mock from 'node:mock';
// mock('file:///app.js', {x:1});
// const namespace1 = await import('file:///app.js');
// namespace1.x; // 1
// mock('file:///app.js', {x:2});
// const namespace2 = await import('file:///app.js');
// namespace2.x; // STILL 1, because this source text already set the specifier
// // for 'file:///app.js', a different specifier that resolves
// // to that could still get a new namespace though
// assert(namespace1 === namespace2);
// ```

/**
* FIXME: this is a hack to workaround loaders being
* single threaded for now
* single threaded for now, just ensures that the MessagePort drains
*/
function doDrainPort() {
let msg;
while (msg = receiveMessageOnPort(preloadPort)) {
onPreloadPortMessage(msg.message);
}
}

/**
* @param param0 message from the application context
*/
function onPreloadPortMessage({
mockVersion, resolved, exports
}) {
Expand All @@ -25,25 +51,60 @@ function onPreloadPortMessage({
}
let preloadPort;
export function globalPreload({port}) {
// Save the communication port to the application context to send messages
// to it later
preloadPort = port;
// Every time the application context sends a message over the port
port.on('message', onPreloadPortMessage);
// This prevents the port that the Loader/application talk over
// from keeping the process alive, without this, an application would be kept
// alive just because a loader is waiting for messages
port.unref();

const insideAppContext = (getBuiltin, port, setImportMetaCallback) => {
/**
* This is the Map that saves *all* the mocked URL -> replacement Module
* mappings
* @type {Map<string, {namespace, listeners}>}
*/
let mockedModules = new Map();
let mockVersion = 0;
/**
* This is the value that is placed into the `node:mock` default export
*
* @example
* ```mjs
* import mock from 'node:mock';
* const mutator = mock('file:///app.js', {x:1});
* const namespace = await import('file:///app.js');
* namespace.x; // 1;
* mutator.x = 2;
* namespace.x; // 2;
* ```
*
* @param {string} resolved an absolute URL HREF string
* @param {object} replacementProperties an object to pick properties from
* to act as a module namespace
* @returns {object} a mutator object that can update the module namespace
Comment thread
DerekNonGeneric marked this conversation as resolved.
* since we can't do something like old Object.observe
*/
const doMock = (resolved, replacementProperties) => {
let exports = Object.keys(replacementProperties);
let exportNames = Object.keys(replacementProperties);
let namespace = Object.create(null);
/**
* @type {Array<(name: string)=>void>} functions to call whenever an
* export name is updated
*/
let listeners = [];
for (const name of exports) {
let currentValue = replacementProperties[name];
for (const name of exportNames) {
let currentValueForPropertyName = replacementProperties[name];
Object.defineProperty(namespace, name, {
enumerable: true,
get() {
return currentValue;
return currentValueForPropertyName;
},
set(v) {
currentValue = v;
currentValueForPropertyName = v;
for (let fn of listeners) {
try {
fn(name);
Expand All @@ -58,14 +119,31 @@ export function globalPreload({port}) {
listeners
});
mockVersion++;
port.postMessage({ mockVersion, resolved, exports });
// Inform the loader that the `resolved` URL should now use the specific
// `mockVersion` and has export names of `exportNames`
//
// This allows the loader to generate a fake module for that version
// and names the next time it resolves a specifier to equal `resolved`
port.postMessage({ mockVersion, resolved, exports: exportNames });
return namespace;
}
// Sets the import.meta properties up
// has the normal chaining workflow with `defaultImportMetaInitializer`
setImportMetaCallback((meta, context, defaultImportMetaInitializer) => {
/**
* 'node:mock' creates its default export by plucking off of import.meta
* and must do so in order to get the communications channel from inside
* preloadCode
*/
if (context.url === 'node:mock') {
meta.doMock = doMock;
return;
}
/**
* Fake modules created by `node:mock` get their meta.mock utility set
* to the corresponding value keyed off `mockedModules` and use this
* to setup their exports/listeners properly
*/
if (context.url.startsWith('mock-facade:')) {
let [proto, version, encodedTargetURL] = context.url.split(':');
let decodedTargetURL = decodeURIComponent(encodedTargetURL);
Expand All @@ -74,14 +152,17 @@ export function globalPreload({port}) {
return;
}
}
/**
* Ensure we still get things like `import.meta.url`
*/
defaultImportMetaInitializer(meta, context);
});
};
return `(${insideAppContext})(getBuiltin, port, setImportMetaCallback)`
Comment thread
bmeck marked this conversation as resolved.
}
Comment thread
GeoffreyBooth marked this conversation as resolved.


// rewrites node: loading to mock-facade: so that it can be intercepted
// Rewrites node: loading to mock-facade: so that it can be intercepted
export function resolve(specifier, context, defaultResolve) {
if (specifier === 'node:mock') {
return {
Expand All @@ -91,7 +172,7 @@ export function resolve(specifier, context, defaultResolve) {
doDrainPort();
const def = defaultResolve(specifier, context);
if (context.parentURL?.startsWith('mock-facade:')) {
// do nothing, let it get the "real" module
// Do nothing, let it get the "real" module
} else if (mockedModuleExports.has(def.url)) {
return {
url: `mock-facade:${currentMockVersion}:${encodeURIComponent(def.url)}`
Expand All @@ -105,11 +186,19 @@ export function resolve(specifier, context, defaultResolve) {
export function load(url, context, defaultLoad) {
doDrainPort();
if (url === 'node:mock') {
/**
* Simply grab the import.meta.doMock to establish the communication
* channel with preloadCode
*/
return {
source: 'export default import.meta.doMock',
format: 'module'
};
}
/**
* Mocked fake module, not going to be handled in default way so it
* generates the source text, then short circuits
*/
if (url.startsWith('mock-facade:')) {
let [proto, version, encodedTargetURL] = url.split(':');
let ret = generateModule(mockedModuleExports.get(
Expand All @@ -123,6 +212,11 @@ export function load(url, context, defaultLoad) {
return defaultLoad(url, context);
}

/**
*
* @param {Array<string>} exports name of the exports of the module
* @returns {string}
*/
function generateModule(exports) {
let body = [
'export {};',
Expand Down