@endo/compartment-mapper
Version:
The compartment mapper assembles Node applications in a sandbox
721 lines (663 loc) • 22.4 kB
JavaScript
/* eslint no-shadow: 0 */
/**
* @import {
* StaticModuleType,
* PrecompiledStaticModuleInterface
* } from 'ses'
* @import {
* BundleOptions,
* CompartmentSources,
* PackageCompartmentDescriptors,
* PackageCompartmentMapDescriptor,
* MaybeReadPowers,
* ReadFn,
* ReadPowers,
* Sources,
* WriteFn,
* } from './types.js'
*/
/**
* The bundler kit defines a language-specific behavior for injecting a module
* into a bundle.
* Each module must allocate cells for its imports and exports, link those cells
* to the cells of dependencies, and provide both the linker and evaluation behavior
* for the module.
* The linker behavior gets injected in a lexical scope with the linker runtime
* and has access to the cells of all modules, whereas the evaluation behavior
* gets injected in the generated script's top level lexical scope, so has
* no accidental visibility into the linkage runtime.
*
* For example, JSON modules produce a single "default" cell ("getCells"):
*
* { default: cell('default') },
*
* Then, the JSON gets injected verbatim for the evaluation behavior ("getFunctor").
* The linker simply sets the cell to the value.
*
* functors[0]['default'].set(modules[0]);
*
* For an ECMAScript or CommonJS module, the evaluation behavior is a function
* that the linker runtime can call to inject it with the cells it needs by
* the names it sees for them.
*
* @typedef {object} BundlerKit
* @property {() => string} getFunctor Produces a JavaScript string consisting of
* a function expression followed by a comma delimiter that will be evaluated in
* a lexical scope with no free variables except the globals.
* In the generated bundle runtime, the function will receive an environment
* record: a record mapping every name of the corresponding module's internal
* namespace to a "cell" it can use to get, set, or observe the linked
* variable.
* @property {() => string} getCells Produces a JavaScript string consisting of
* a JavaScript object and a trailing comma.
* The string is evaluated in the linker runtime's lexical context.
* @property {() => string} getFunctorCall Produces a JavaScript string,
* a statement that effects the module's evaluation behavior using the cells
* it imports and exports and the evaluated "functor".
* @property {() => string} getReexportsWiring Produces a JavaScript string
* that may include statements that bind the cells reexported by this module.
*/
/**
* @template {unknown} SpecificModuleSource
* @typedef {object} BundleModule
* @property {string} key
* @property {string} exit
* @property {string} compartmentName
* @property {string} moduleSpecifier
* @property {string} sourceDirname
* @property {string} parser
* @property {StaticModuleType & SpecificModuleSource} record
* @property {Record<string, string>} resolvedImports
* @property {Record<string, number>} indexedImports
* @property {Uint8Array} bytes
* @property {number} index
* @property {BundlerKit} bundlerKit
*/
/**
* @typedef {object} BundleExit
* @property {string} exit
* @property {number} index
* @property {BundlerKit} bundlerKit
* @property {Record<string, number>} indexedImports
* @property {Record<string, string>} resolvedImports
*/
/**
* @template {unknown} SpecificModuleSource
* @callback GetBundlerKit
* @param {BundleModule<SpecificModuleSource>} module
* @param {object} params
* @param {boolean} [params.useEvaluate]
* @param {string} [params.sourceUrlPrefix]
* @returns {BundlerKit}
*/
/**
* @template {unknown} SpecificModuleSource
* @typedef {object} BundlerSupport
* @property {string} runtime
* @property {GetBundlerKit<SpecificModuleSource>} getBundlerKit
*/
import { resolve } from './node-module-specifier.js';
import { mapNodeModules } from './node-modules.js';
import { link } from './link.js';
import { makeImportHookMaker } from './import-hook.js';
import { defaultParserForLanguage } from './archive-parsers.js';
import mjsSupport from './bundle-mjs.js';
import cjsSupport from './bundle-cjs.js';
import jsonSupport from './bundle-json.js';
import {
isErrorModuleSource,
isExitModuleSource,
isLocalModuleSource,
} from './guards.js';
const textEncoder = new TextEncoder();
const { quote: q } = assert;
/**
* @param {BundleExit} source
* @returns {BundlerKit}
*/
const makeCjsExitBundlerKit = ({ exit, index }) => ({
getFunctor: () => `\
null,
`,
getCells: () => `\
namespaceCells(tryRequire(${JSON.stringify(exit)})),
`,
getReexportsWiring: () => '',
getFunctorCall: () => ``,
});
/**
* Produces a list of modules in the order they should be evaluated, and
* a side-table for following aliases.
* The modules are produce in topological postorder, such that the entry
* module appears last.
* The post-order traversal does not revisit modules that appear in cycles.
*
* Synthesizes a unique key for each module and translates
* each module's imports to their corresponding keys.
* Some import keys are aliased to other keys, such that walking from
* key to value in the aliases side table will eventually arrive at
* the key of a module that is present in the modules list.
*
* The first modules are place-holders for the modules that exit
* the compartment map to the host's module system.
*
* @param {PackageCompartmentDescriptors} compartmentDescriptors
* @param {Record<string, CompartmentSources>} compartmentSources
* @param {string} entryCompartmentName
* @param {string} entryModuleSpecifier
* @param {Array<string>} exitModuleSpecifiers
*/
const sortedModules = (
compartmentDescriptors,
compartmentSources,
entryCompartmentName,
entryModuleSpecifier,
exitModuleSpecifiers,
) => {
/** @type {BundleModule<unknown>[]} */
const modules = [];
/** @type {Map<string, string>} aliaes */
const aliases = new Map();
/** @type {Set<string>} seen */
const seen = new Set();
for (const exit of exitModuleSpecifiers) {
modules.push({
key: exit,
exit,
// @ts-expect-error
index: undefined,
// @ts-expect-error
bundlerKit: null,
});
}
/**
* @param {string} compartmentName
* @param {string} moduleSpecifier
*/
const recur = (compartmentName, moduleSpecifier) => {
const key = `${compartmentName}#${moduleSpecifier}`;
if (seen.has(key)) {
return key;
}
seen.add(key);
const source = compartmentSources[compartmentName][moduleSpecifier];
if (source !== undefined) {
if (isErrorModuleSource(source)) {
throw Error(
`Cannot bundle: encountered deferredError ${source.deferredError}`,
);
}
if (isExitModuleSource(source)) {
return source.exit;
}
if (!isLocalModuleSource(source)) {
throw new TypeError(`Unexpected source type ${JSON.stringify(source)}`);
}
const { record, parser, bytes, sourceDirname } = source;
if (record) {
const { imports = [], reexports = [] } =
/** @type {PrecompiledStaticModuleInterface} */ (record);
const resolvedImports = Object.create(null);
for (const importSpecifier of [...imports, ...reexports]) {
// If we ever support another module resolution algorithm, that
// should be indicated in the compartment descriptor by name and the
// corresponding behavior selected here.
const resolvedSpecifier = resolve(importSpecifier, moduleSpecifier);
resolvedImports[importSpecifier] = recur(
compartmentName,
resolvedSpecifier,
);
}
modules.push({
key,
compartmentName,
moduleSpecifier,
sourceDirname,
parser,
record,
resolvedImports,
bytes,
// @ts-expect-error
index: undefined,
// @ts-expect-error
bundlerKit: null,
});
return key;
}
} else {
const descriptor =
compartmentDescriptors[compartmentName].modules[moduleSpecifier];
if (descriptor) {
const {
compartment: aliasCompartmentName,
module: aliasModuleSpecifier,
} = descriptor;
if (
aliasCompartmentName !== undefined &&
aliasModuleSpecifier !== undefined
) {
const aliasKey = recur(aliasCompartmentName, aliasModuleSpecifier);
aliases.set(key, aliasKey);
return aliasKey;
}
}
}
throw Error(
`Cannot bundle: cannot follow module import ${moduleSpecifier} in compartment ${compartmentName}`,
);
};
recur(entryCompartmentName, entryModuleSpecifier);
return { modules, aliases };
};
/** @type {Record<string, BundlerSupport<any>>} */
const bundlerSupportForLanguage = {
'pre-mjs-json': mjsSupport,
'pre-cjs-json': cjsSupport,
json: jsonSupport,
};
/** @param {string} language */
const getRuntime = language =>
bundlerSupportForLanguage[language]
? bundlerSupportForLanguage[language].runtime
: `/*unknown language:${language}*/`;
/**
* @param {BundleModule<unknown>} module
* @param {object} params
* @param {boolean} [params.useEvaluate]
* @param {string} [params.sourceUrlPrefix]
*/
const getBundlerKitForModule = (module, params) => {
const language = module.parser;
assert(language !== undefined);
if (bundlerSupportForLanguage[language] === undefined) {
const warning = `/*unknown language:${language}*/`;
// each item is a function to avoid creating more in-memory copies of the source text etc.
/** @type {BundlerKit} */
return {
getFunctor: () => `(()=>{${warning}}),`,
getCells: () => `{${warning}},`,
getFunctorCall: () => warning,
getReexportsWiring: () => '',
};
}
const { getBundlerKit } = bundlerSupportForLanguage[language];
return getBundlerKit(module, params);
};
/**
* @param {ReadFn | ReadPowers | MaybeReadPowers} readPowers
* @param {PackageCompartmentMapDescriptor} compartmentMap
* @param {BundleOptions} [options]
* @returns {Promise<string>}
*/
export const makeFunctorFromMap = async (
readPowers,
compartmentMap,
options,
) => {
const {
moduleTransforms,
syncModuleTransforms,
searchSuffixes,
sourceMapHook = undefined,
useEvaluate = false,
sourceUrlPrefix = undefined,
format = undefined,
parserForLanguage: parserForLanguageOption = {},
} = options || {};
/** @type {((module: BundleExit) => BundlerKit) | undefined} */
let makeExitBundlerKit;
if (format === 'cjs') {
makeExitBundlerKit = makeCjsExitBundlerKit;
}
const parserForLanguage = Object.freeze(
Object.assign(
Object.create(null),
defaultParserForLanguage,
parserForLanguageOption,
),
);
const bundlerKitParams = {
useEvaluate,
sourceUrlPrefix,
};
const {
compartments,
entry: { compartment: entryCompartmentName, module: entryModuleSpecifier },
} = compartmentMap;
/** @type {string[]} */
const exitModuleSpecifiers = [];
/** @type {Sources} */
const sources = Object.create(null);
/**
* @param {string} moduleSpecifier
* @param {string} compartmentName
*/
const exitModuleImportHook =
format !== undefined
? /**
* @param {string} moduleSpecifier
* @param {string} compartmentName
*/
async (moduleSpecifier, compartmentName) => {
const compartmentSources =
sources[compartmentName] || Object.create(null);
sources[compartmentName] = compartmentSources;
compartmentSources[moduleSpecifier] = {
exit: moduleSpecifier,
};
exitModuleSpecifiers.push(moduleSpecifier);
return { imports: [], exports: [], execute() {} };
}
: undefined;
const makeImportHook = makeImportHookMaker(readPowers, entryCompartmentName, {
archiveOnly: true,
sources,
compartmentDescriptors: compartments,
searchSuffixes,
entryCompartmentName,
entryModuleSpecifier,
sourceMapHook,
importHook: exitModuleImportHook,
});
// Induce importHook to record all the necessary modules to import the given module specifier.
const { compartment } = link(compartmentMap, {
resolve,
makeImportHook,
moduleTransforms,
syncModuleTransforms,
parserForLanguage,
});
await compartment.load(entryModuleSpecifier);
const { modules, aliases } = sortedModules(
compartmentMap.compartments,
sources,
entryCompartmentName,
entryModuleSpecifier,
exitModuleSpecifiers,
);
// Create an index of modules so we can resolve import specifiers to the
// index of the corresponding functor.
const modulesByKey = Object.create(null);
for (let index = 0; index < modules.length; index += 1) {
const module = modules[index];
module.index = index;
modulesByKey[module.key] = module;
}
const parsersInUse = new Set();
for (const module of modules) {
if (module.exit !== undefined) {
if (makeExitBundlerKit === undefined) {
// makeExitBundlerKit must have been provided to makeImportHookMaker for any modules with an exit property to have been created.
throw TypeError('Unreachable');
}
module.bundlerKit = makeExitBundlerKit(module);
} else {
module.indexedImports = Object.fromEntries(
Object.entries(module.resolvedImports).map(([importSpecifier, key]) => {
const alias = aliases.get(key);
const module = modulesByKey[alias ?? key];
if (module === undefined) {
if (alias === undefined) {
throw new Error(
`Unable to locate module for key ${q(key)} import specifier ${q(
importSpecifier,
)}`,
);
} else {
throw new Error(
`Unable to locate module for key ${q(alias)} (via alias ${q(key)}) import specifier ${q(
importSpecifier,
)}`,
);
}
}
const { index } = module;
return [importSpecifier, index];
}),
);
parsersInUse.add(module.parser);
module.bundlerKit = getBundlerKitForModule(module, bundlerKitParams);
}
}
// Some bundles appeal to the host module system appropriate to their format
// like `require` for bundles used as CommonJS modules.
// Each module in the modules array is constructed by a language-specific bundler kit,
// and in the case of an exit module, is a bundler kit made with
// makeExitBundlerKit, like makeCjsExitBundlerKit.
// This will generate a module initialization runtime that in turn needs this
// namespaceCells utility function to take a host module exports namespace
// and turn it into a bank of cells for importing and exporting the
// properties of the module exports namespace object.
const exitNamespaces =
exitModuleSpecifiers.length === 0
? ''
: `\
const namespaceCells = namespace => fromEntries(
getOwnPropertyNames(namespace)
.map(name => [name, {
get() {
return get(namespace, name);
},
set() {
throw new TypeError('Non-writable export');
},
observe(observer) {
observer(get(namespace, name));
},
enumerable: true,
}])
);
`;
// The linkage runtime creates a cell for every value exported by any of the
// bundled modules.
// The interface of a cell is very much like a getter/setter property
// deescriptor, and additionally has a method for registering an observer to
// notice when a variable is changed in its originating module, to support
// live bindings.
// Each module language defines its own behavior for the generation of its
// exported cells.
// After all cells are allocated, each language gets a second opportunity
// to introduce bindings for cells that the module re-exports from another
// module, but does not itself own.
const runtimeLinkageCells = `\
const cell = (name, value = undefined) => {
const observers = [];
return freeze({
get: freeze(() => {
return value;
}),
set: freeze((newValue) => {
value = newValue;
for (const observe of observers) {
observe(value);
}
}),
observe: freeze((observe) => {
observers.push(observe);
observe(value);
}),
enumerable: true,
});
};
const cells = [
${''.concat(...modules.map(m => m.bundlerKit.getCells()))}\
];
${''.concat(...modules.map(m => m.bundlerKit.getReexportsWiring()))}\
`;
// The linker runtime includes a parallel array of module exports namespace
// objects for each bundled module, for each respective index of the module
// functors array.
// Each namespace has a special '*' property for the namespace object itself,
// which is what modules obtain with `import * as x from 'x'` notation.
const moduleNamespaces = `\
const namespaces = cells.map(cells => freeze(create(null, {
...cells,
// Make this appear like an ESM module namespace object.
[Symbol.toStringTag]: {
value: 'Module',
writable: false,
enumerable: false,
configurable: false,
},
})));
for (let index = 0; index < namespaces.length; index += 1) {
cells[index]['*'] = cell('*', namespaces[index]);
}
`;
// Each language in use within the bundle has an opportunity to inject
// utilities into the bundle runtime that it can use in the shared lexical
// scope of module execution.
// CommonJS in particular injects a utility function here, if the script
// entrains any CommonJS modules.
const languageRuntimeExtensions = `\
${''.concat(...Array.from(parsersInUse).map(parser => getRuntime(parser)))}\
`;
// This section of the linker runtime causes each of the modules to execute
// in topological order, using a language-specific calling convention to
// link its imports and exports to other modules.
const moduleExecutionRuntime = `\
${''.concat(...modules.map(m => m.bundlerKit.getFunctorCall()))}\
`;
// The linker runtime receives an array of language-specific representations
// of each module, which in the simplest case is just a function and a
// runtime initialization calling convention (a functor).
// Then, in the style of partial application, it receives runtime options.
// When driven by makeScript, the script will statically apply the options,
// but with makeFunctor, the runtime must evaluate and apply runtime options.
// Scripts are suitable for injection with <script> tags on the web, whereas
// functors require use of an evaluator at runtime.
const linkerRuntime = `functors => options => {
'use strict';
const {
Map,
Object,
ReferenceError,
Reflect,
TypeError,
} = globalThis;
const {
create,
defineProperties,
defineProperty,
freeze,
fromEntries,
getOwnPropertyDescriptors,
getOwnPropertyNames,
keys,
} = Object;
const { get, set } = Reflect;
const {
${
!useEvaluate
? ''
: `\
evaluate = eval,
sourceUrlPrefix = ${JSON.stringify(sourceUrlPrefix)},
`
}\
${
format !== 'cjs'
? ''
: `\
require: tryRequire = typeof require === 'function' ? require : specifier => {
throw new TypeError('Cannot import host module: ' + specifier);
},
`
}\
} = options || {};
${
!useEvaluate
? ''
: `\
const evaluateSource = (source, sourceUrl) => {
return evaluate(source + '\\n//# sourceURL=' + sourceUrlPrefix + sourceUrl + '\\n');
};`
}\
${exitNamespaces}\
${runtimeLinkageCells}\
${moduleNamespaces}\
${languageRuntimeExtensions}\
${moduleExecutionRuntime}\
return cells[cells.length - 1]['*'].get();
}`;
// An array of language-specific representations of each bundled module,
// which in the simplest case is a function that must be initialized by the
// linkage runtime using a calling convention.
// We pass this array into the linkage runtime rather than embedding it in
// the linkage runtime in order to assure that the runtime's lexical context
// doesn't overshadow each module's lexical scope.
const moduleFunctors = `[
${''.concat(
...modules.map(
(m, index) => `\
// === ${index}. ${m.sourceDirname} ${m.moduleSpecifier} ===
${m.bundlerKit.getFunctor()}`,
),
)}\
]`;
// Functors partially apply the linker runtime.
// Scripts go on to apply static options and execute immediately.
return `(${linkerRuntime})(${moduleFunctors})`;
};
/**
* @param {ReadFn | ReadPowers | MaybeReadPowers} readPowers
* @param {PackageCompartmentMapDescriptor} compartmentMap
* @param {BundleOptions} [options]
* @returns {Promise<string>}
*/
export const makeScriptFromMap = async (
readPowers,
compartmentMap,
options,
) => {
// Functors partially apply the linker runtime.
// Scripts go on to apply static options and execute immediately.
const functor = await makeFunctorFromMap(readPowers, compartmentMap, options);
return `${functor}()`;
};
/**
* @param {ReadFn | ReadPowers | MaybeReadPowers} readPowers
* @param {string} moduleLocation
* @param {BundleOptions} [options]
* @returns {Promise<string>}
*/
export const makeFunctor = async (readPowers, moduleLocation, options) => {
const compartmentMap = await mapNodeModules(
readPowers,
moduleLocation,
options,
);
return makeFunctorFromMap(readPowers, compartmentMap, options);
};
/**
* @param {ReadFn | ReadPowers | MaybeReadPowers} readPowers
* @param {string} moduleLocation
* @param {BundleOptions} [options]
* @returns {Promise<string>}
*/
export const makeScript = async (readPowers, moduleLocation, options) => {
const compartmentMap = await mapNodeModules(
readPowers,
moduleLocation,
options,
);
return makeScriptFromMap(readPowers, compartmentMap, options);
};
/**
* @param {WriteFn} write
* @param {ReadFn} read
* @param {string} bundleLocation
* @param {string} moduleLocation
* @param {BundleOptions} [options]
*/
export const writeScript = async (
write,
read,
bundleLocation,
moduleLocation,
options,
) => {
const bundleString = await makeScript(read, moduleLocation, options);
const bundleBytes = textEncoder.encode(bundleString);
await write(bundleLocation, bundleBytes);
};