diff --git a/package.json b/package.json index 4b7e75f548..ddddca2993 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,6 @@ "atLeast": 0 }, "resolutions": { - "@babel/types": "7.24.0", "@lerna/version@npm:5.6.2": "patch:@lerna/version@npm%3A5.6.2#~/.yarn/patches/@lerna-version-npm-5.6.2-ce2d9cb2f5.patch", "@lerna/conventional-commits@npm:5.6.2": "patch:@lerna/conventional-commits@npm%3A5.6.2#~/.yarn/patches/@lerna-conventional-commits-npm-5.6.2-a373ba4bc0.patch" } diff --git a/packages/bundle-source/NEWS.md b/packages/bundle-source/NEWS.md index e31d34ef8a..4e12a7a447 100644 --- a/packages/bundle-source/NEWS.md +++ b/packages/bundle-source/NEWS.md @@ -1,5 +1,15 @@ User-visible changes to `@endo/bundle-source`: +# Next release + +- Replaces the implementation for the `nestedEvaluate` and `getExport` + formats with one based on Endo's Compartment Mapper instead of Rollup, + in order to obviate the need to reconcile source map transforms between + Rollup and the underlying Babel generator. + As a consequence, we no longer generate a source map for the bundle, but + Babel ensures that we preserve line and column numbers between the original + source and the bundled source. + # v3.5.0 (2024-11-13) - Adds support for TypeScript type erasure using diff --git a/packages/bundle-source/README.md b/packages/bundle-source/README.md index ae3269325e..7725520723 100644 --- a/packages/bundle-source/README.md +++ b/packages/bundle-source/README.md @@ -121,20 +121,31 @@ not exist. ## getExport moduleFormat The most primitive `moduleFormat` is the `"getExport"` format. -It generates source like: +It generates a script where the completion value (last expression evaluated) +is a function that accepts an optional `sourceUrlPrefix`. ```js -function getExport() { - let exports = {}; - const module = { exports }; - // CommonJS source translated from the inputs. - ... - return module.exports; -} +cosnt { source } = await bundleSource('program.js', { format: 'getExport' }); +const exports = eval(source)(); ``` -To evaluate it and obtain the resulting module namespace, you need to endow -a `require` function to resolve external imports. +The `getExport` format can import host modules with a CommonJS `require` +function, if there is one in scope. +One can be endowed using a Hardened JavaScript `Compartment`. + +```js +const compartment = new Comaprtment({ + globals: { require }, + __options__: true, // until SES and XS implementations converge +}); +const exports = compartment.evaluate(source); +``` + +> :warning: The `getExport` format was previously implemented using +> [Rollup](https://rollupjs.org/) and is implemented with +> `@endo/compartment-mapper/bundle.js` starting with version 4 of +> `@endo/bundle-source`. +> See `nestedEvaluate` below for compatibility caveats. ## nestedEvaluate moduleFormat @@ -145,9 +156,63 @@ to evaluate submodules in the same context as the parent function. The advantage of this format is that it helps preserve the filenames within the bundle in the event of any stack traces. -Also, the toplevel `getExport(filePrefix = "/bundled-source")` accepts an -optional `filePrefix` argument (which is prepended to relative paths for the -bundled files) in order to help give context to stack traces. +The completion value of a `nestedEvaluate` bundle is a function that accepts +the `sourceUrlPrefix` for every module in the bundle, which will appear in stack +traces and assist debuggers to find a matching source file. + +```js +cosnt { source } = await bundleSource('program.js', { format: 'nestedEvaluate' }); +const compartment = new Comaprtment({ + globals: { + require, + nestedEvaluate: source => compartment.evaluate(source), + }, + __options__: true, // until SES and XS implementations converge +}); +const exports = compartment.evaluate(source)('bundled-sources/.../'); +``` + +> :warning: The `nestedEvaluate` format was previously implemented using +> [Rollup](https://rollupjs.org/) and is implemented with +> `@endo/compartment-mapper/bundle.js` starting with version 4 of +> `@endo/bundle-source`. +> Their behaviors are not identical. +> +> 1. Version 3 used different heuristics than Node.js 18 for inferring whether +> a module was in CommonJS format or ESM format. Version 4 does not guess, +> but relies on the `"type": "module"` directive in `package.json` to indicate +> that a `.js` extension implies ESM format, or respects the explicit `.cjs` +> and `.mjs` extensions. +> 2. Version 3 supports [live +> bindings](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import#imported_values_can_only_be_modified_by_the_exporter) +> and Version 4 does not. +> 3. Version 3 can import any package that is discoverable by walking parent directories +> until the dependency or devDependeny is found in a `node_modules` directory. +> Version 4 requires that the dependent package explicitly note the dependency +> in `package.json`. +> 4. Version 3 and 4 generate different text. +> Any treatment of that text that is sensitive to the exact shape of the +> text is fragile and may break even between minor and patch versions. + +## endoScript moduleFormat + +The `ses` shim uses the `endoScript` format to generate its distribution bundles, +suitable for injecting in a web page with a ` +``` + +Evaluation of `script` returns the emulated exports namespace of the entry +module. + +```js +// This one weird trick evaluates your script in global scope instead of +// lexical scope. +const globalEval = eval; +const namespace = globalEval(script); +``` + +Scripts can include ESM, CJS, and JSON modules, but no other module languages +like bytes or text. + +> [!WARNING] +> Scripts do not support [live +> bindings](https://developer.mozilla.org/en-US/docs/Glossary/Binding), dynamic +> `import`, or `import.meta`. +> Scripts do not isolate modules to a compartment. + +`makeScripts` accepts all the options of `makeArchive` and: + +- `sourceUrlPrefix` (string, default `""`):: + Specifies a prefix to occur on each module's `sourceURL` comment, as injected + at runtime. + Should generally end with `/` if non-empty. + This can improve stack traces. +- `format` (`"cjs"` or `undefined`, default `undefined`): + By default, `makeBundle` generates a bundle that can be evaluated in any + context. + By specifying `"cjs"`, the bundle can assume there is a host CommonJS + `require` function available for resolving modules that exit the bundle. + The default is `require` on `globalThis`. + The `require` function can be overridden with a curried runtime option. +- `useEvaluate` (boolean, default `false`): + Disabled by default, for bundles that may be embedded on a web page with a + `no-unsafe-eval` Content Security Policy. + Enable for any environment that can use `eval` or other suitable evaluator + (like a Hardened JavaScript `Compartment`). + + By default and when `useEvaluate` is explicitly `false`, the text of a module + includes an array of module evaluator functions. + + > [!WARNING] + > Example is illustrative and neither a compatibility guarantee nor even + > precise.) + + ```js + (modules => options => { + /* ...linker runtime... */ + for (const module of modules) { + module(/* linking convention */); + } + )([ + // 1. bundle ./dependency.js + function () { /* ... */ }, + // 2. bundle ./dependent.js + function () { /* ... */ }, + ])(/* runtime options */) + ``` + + Each of these functions is generated by [Endo's emulation of a JavaScript + `ModuleSource` + constructor](https://github.com/endojs/endo/blob/master/packages/module-source/DESIGN.md), + which we use elsewhere in the Compartment Mapper to emulate Compartment + module systems at runtime, as in the Compartment Mapper's own `importArchive`. + + With `useEvaluate`, the script instead embeds the text for each module as a + string, along with a package-relative source URL, and uses an `eval` function + to produce the corresponding `function`. + + ```js + (modules => options => { + /* ...linker runtime... */ + for (const [module, sourceURL] of modules) { + evalWithSourceURL(module, sourceURL)(/* linking convention */); + } + )([ + // 1. bundle ./dependency.js + ["(function () { /* ... */ })", "bundle/dependency.js"], + // 2. bundle ./dependent.js + ["(function () { /* ... */ })", "bundle/dependent.js"], + ])(/* runtime options */) + ``` + + With `useEvaluate`, the bundle will instead capture a string for + each module function and use an evaluator function to rehydrate them. + This can make the file locations and line numbers in stack traces more + useful. + +From `@endo/compartment-mapper/script-lite.js`, the `makeScriptFromMap` takes +a compartment map, like that generated by `mapNodeModules` in +`@endo/compartment-mapper/node-modules.js` instead of the entry module's +location. +The `-lite.js` modules, in general, do not entrain a specific compartment +mapper. + +# Functor bundles + +From `@endo/compartment-mapper/functor.js`, the `makeFunctor` function is similar +to `makeScript` but generates a string of JavaScript suitable for `eval` but *not* +suitable for embedding as a script. But, the completion value of the script +is a function that accepts runtime options and returns the entry module's emulated +module exports namespace. + +In this example, we use a Hardened JavaScript `Compartment` to confine the +execution of the functor and its modules. + +```js +const compartment = new Compartment(); +const namespace = compartment.evaluate(functor)({ + require, + evaluate: compartment.evaluate, + sourceUrlPrefix: 'file://Users/you/project/', +}); +``` + +The functor runtime options include: + +- `evaluate`: for functors made with `useEvaluate`, + specifies a function to use to evaluate each module. + The default evaluator is indirect `eval`. +- `require`: for functors made with `format` of `"cjs"`, provides the behavior + for `require` calls that exit the bundle to the host environment. + Defaults to the `require` in lexical scope. +- `sourceUrlPrefix`: specifies a prefix to occur on each module's `sourceURL` comment, + as injected at runtime. + Overrides the `sourceUrlPrefix` provided to `makeFunctor`, if any. + +From `@endo/compartment-mapper/functor-lite.js`, the `makeFunctorFromMap` takes +a compartment map, like that generated by `mapNodeModules` in +`@endo/compartment-mapper/node-modules.js` instead of the entry module's +location. +The `-lite.js` modules, in general, do not entrain a specific compartment +mapper. + # Package Descriptors The compartment mapper uses [Compartments], one for each Node.js package your @@ -599,7 +763,6 @@ The shape of the `policy` object is based on `policy.json` from LavaMoat. MetaMa > Endo policy support is intended to reach parity with LavaMoat's policy.json. > Policy generation may be ported to Endo. - [LavaMoat]: https://github.com/LavaMoat/lavamoat [Compartments]: ../ses/README.md#compartment [Policy Demo]: ./demo/policy/README.md diff --git a/packages/compartment-mapper/bundle.js b/packages/compartment-mapper/bundle.js index bfeca289b7..0e26a2f1ac 100644 --- a/packages/compartment-mapper/bundle.js +++ b/packages/compartment-mapper/bundle.js @@ -1,4 +1,7 @@ // eslint-disable-next-line import/export -- just types export * from './src/types-external.js'; -export { makeBundle, writeBundle } from './src/bundle.js'; +export { + makeScript as makeBundle, + writeScript as writeBundle, +} from './src/bundle.js'; diff --git a/packages/compartment-mapper/functor-lite.js b/packages/compartment-mapper/functor-lite.js new file mode 100644 index 0000000000..665b70d9e1 --- /dev/null +++ b/packages/compartment-mapper/functor-lite.js @@ -0,0 +1,4 @@ +// eslint-disable-next-line import/export -- just types +export * from './src/types-external.js'; + +export { makeFunctorFromMap } from './src/bundle-lite.js'; diff --git a/packages/compartment-mapper/functor.js b/packages/compartment-mapper/functor.js new file mode 100644 index 0000000000..62ef5fad62 --- /dev/null +++ b/packages/compartment-mapper/functor.js @@ -0,0 +1,4 @@ +// eslint-disable-next-line import/export -- just types +export * from './src/types-external.js'; + +export { makeFunctor } from './src/bundle.js'; diff --git a/packages/compartment-mapper/index.js b/packages/compartment-mapper/index.js index 7e1b39200c..a7c9a31919 100644 --- a/packages/compartment-mapper/index.js +++ b/packages/compartment-mapper/index.js @@ -16,4 +16,7 @@ export { } from './src/import-archive.js'; export { search } from './src/search.js'; export { compartmentMapForNodeModules } from './src/node-modules.js'; -export { makeBundle, writeBundle } from './src/bundle.js'; +export { + makeScript as makeBundle, + writeScript as writeBundle, +} from './src/bundle.js'; diff --git a/packages/compartment-mapper/package.json b/packages/compartment-mapper/package.json index c2c17b0c48..cd087b42f7 100644 --- a/packages/compartment-mapper/package.json +++ b/packages/compartment-mapper/package.json @@ -39,6 +39,10 @@ }, "./import-archive-all-parsers.js": "./import-archive-all-parsers.js", "./bundle.js": "./bundle.js", + "./functor.js": "./functor.js", + "./functor-lite.js": "./functor-lite.js", + "./script.js": "./script.js", + "./script-lite.js": "./script-lite.js", "./node-powers.js": "./node-powers.js", "./node-modules.js": "./node-modules.js", "./package.json": "./package.json" diff --git a/packages/compartment-mapper/script-lite.js b/packages/compartment-mapper/script-lite.js new file mode 100644 index 0000000000..cccbd6da44 --- /dev/null +++ b/packages/compartment-mapper/script-lite.js @@ -0,0 +1,4 @@ +// eslint-disable-next-line import/export -- just types +export * from './src/types-external.js'; + +export { makeScriptFromMap } from './src/bundle-lite.js'; diff --git a/packages/compartment-mapper/script.js b/packages/compartment-mapper/script.js new file mode 100644 index 0000000000..f2c8635e1a --- /dev/null +++ b/packages/compartment-mapper/script.js @@ -0,0 +1,4 @@ +// eslint-disable-next-line import/export -- just types +export * from './src/types-external.js'; + +export { makeScript } from './src/bundle.js'; diff --git a/packages/compartment-mapper/src/archive-lite.js b/packages/compartment-mapper/src/archive-lite.js index f8ffddb17c..088c90ba99 100644 --- a/packages/compartment-mapper/src/archive-lite.js +++ b/packages/compartment-mapper/src/archive-lite.js @@ -176,7 +176,7 @@ const digestFromMap = async (powers, compartmentMap, options = {}) => { searchSuffixes, entryCompartmentName, entryModuleSpecifier, - exitModuleImportHook: consolidatedExitModuleImportHook, + importHook: consolidatedExitModuleImportHook, sourceMapHook, }); // Induce importHook to record all the necessary modules to import the given module specifier. diff --git a/packages/compartment-mapper/src/bundle-cjs.js b/packages/compartment-mapper/src/bundle-cjs.js index 22decc7fb7..922a98b1cc 100644 --- a/packages/compartment-mapper/src/bundle-cjs.js +++ b/packages/compartment-mapper/src/bundle-cjs.js @@ -1,10 +1,12 @@ /* Provides CommonJS support for `bundle.js`. */ /** @import {VirtualModuleSource} from 'ses' */ -/** @import {BundlerSupport} from './bundle.js' */ +/** @import {BundlerSupport} from './bundle-lite.js' */ /** @typedef {VirtualModuleSource & {cjsFunctor: string}} CjsModuleSource */ +import { join } from './node-module-specifier.js'; + /** quotes strings */ const q = JSON.stringify; @@ -21,16 +23,16 @@ const exportsCellRecord = exportsList => // This function is serialized and references variables from its destination scope. const runtime = `\ -function wrapCjsFunctor(num) { +function wrapCjsFunctor(index, functor) { /* eslint-disable no-undef */ return ({ imports = {} }) => { - const moduleCells = cells[num]; + const moduleCells = cells[index]; const cModule = Object.freeze( Object.defineProperty({}, 'exports', moduleCells.default), ); // TODO: specifier not found handling const requireImpl = specifier => cells[imports[specifier]].default.get(); - functors[num](Object.freeze(requireImpl), cModule.exports, cModule); + functor(Object.freeze(requireImpl), cModule.exports, cModule); // Update all named cells from module.exports. Object.keys(moduleCells) .filter(k => k !== 'default' && k !== '*') @@ -61,17 +63,27 @@ function wrapCjsFunctor(num) { /** @type {BundlerSupport} */ export default { runtime, - getBundlerKit({ - index, - indexedImports, - record: { cjsFunctor, exports: exportsList = {} }, - }) { + getBundlerKit( + { + index, + indexedImports, + moduleSpecifier, + sourceDirname, + record: { cjsFunctor, exports: exportsList = {} }, + }, + { useEvaluate = false }, + ) { const importsMap = JSON.stringify(indexedImports); + let functor = cjsFunctor; + if (useEvaluate) { + const sourceUrl = join(sourceDirname, moduleSpecifier); + functor = JSON.stringify([functor, sourceUrl]); + } + return { getFunctor: () => `\ -// === functors[${index}] === -${cjsFunctor}, +${functor}, `, getCells: () => `\ { @@ -79,9 +91,15 @@ ${exportsCellRecord(exportsList)}\ }, `, getReexportsWiring: () => '', - getFunctorCall: () => `\ - wrapCjsFunctor(${index})({imports: ${importsMap}}); -`, + getFunctorCall: () => { + let functorExpression = `functors[${index}]`; + if (useEvaluate) { + functorExpression = `evaluateSource(...${functorExpression})`; + } + return `\ + wrapCjsFunctor(${index}, ${functorExpression})({imports: ${importsMap}}); +`; + }, }; }, }; diff --git a/packages/compartment-mapper/src/bundle-json.js b/packages/compartment-mapper/src/bundle-json.js index 72ea3dadb5..b33db67cf7 100644 --- a/packages/compartment-mapper/src/bundle-json.js +++ b/packages/compartment-mapper/src/bundle-json.js @@ -9,15 +9,14 @@ export default { const json = JSON.stringify(JSON.parse(textDecoder.decode(bytes))); return { getFunctor: () => `\ -// === functors[${index}] === -(set) => set(${json}), +${json}, `, getCells: () => `\ { default: cell('default') }, `, getReexportsWiring: () => '', getFunctorCall: () => `\ - functors[${index}](cells[${index}].default.set); + cells[${index}].default.set(functors[${index}]); `, }; }, diff --git a/packages/compartment-mapper/src/bundle-lite.js b/packages/compartment-mapper/src/bundle-lite.js new file mode 100644 index 0000000000..431a8b2307 --- /dev/null +++ b/packages/compartment-mapper/src/bundle-lite.js @@ -0,0 +1,612 @@ +/* eslint no-shadow: 0 */ + +/** + * @import { + * StaticModuleType, + * PrecompiledStaticModuleInterface + * } from 'ses' + * @import { + * BundleOptions, + * CompartmentDescriptor, + * CompartmentMapDescriptor, + * CompartmentSources, + * MaybeReadPowers, + * ReadFn, + * ReadPowers, + * Sources, + * } from './types.js' + */ + +/** + * @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 a lexical context with a `cell` maker, the `cells` + * array of every module's internal environment record. + * @property {() => string} getFunctorCall Produces a JavaScript string may + * be a statement that calls this module's functor with the calling convention + * appropriate for its language, injecting whatever cells it needs to link to + * other module namespaces. + * @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} resolvedImports + * @property {Record} indexedImports + * @property {Uint8Array} bytes + * @property {number} index + * @property {BundlerKit} bundlerKit + */ + +/** + * @typedef {object} BundleExit + * @property {string} exit + * @property {number} index + * @property {BundlerKit} bundlerKit + * @property {Record} indexedImports + * @property {Record} resolvedImports + */ + +/** + * @template {unknown} SpecificModuleSource + * @callback GetBundlerKit + * @param {BundleModule} 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} getBundlerKit + */ + +import { resolve } from './node-module-specifier.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'; + +const { quote: q } = assert; + +/** + * @param {BundleExit} source + * @returns {BundlerKit} + */ +const makeCjsExitBundlerKit = ({ exit }) => ({ + getFunctor: () => `\ +null, +`, + getCells: () => `\ + namespaceCells(tryRequire(${JSON.stringify(exit)})), +`, + getReexportsWiring: () => '', + getFunctorCall: () => ``, +}); + +/** + * @param {Record} compartmentDescriptors + * @param {Record} compartmentSources + * @param {string} entryCompartmentName + * @param {string} entryModuleSpecifier + * @param {Array} exitModuleSpecifiers + */ +const sortedModules = ( + compartmentDescriptors, + compartmentSources, + entryCompartmentName, + entryModuleSpecifier, + exitModuleSpecifiers, +) => { + /** @type {BundleModule[]} */ + const modules = []; + /** @type {Map} aliaes */ + const aliases = new Map(); + /** @type {Set} 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) { + const { record, parser, deferredError, bytes, sourceDirname, exit } = + source; + if (exit !== undefined) { + return exit; + } + assert( + bytes !== undefined, + `No bytes for ${moduleSpecifier} in ${compartmentName}`, + ); + assert( + parser !== undefined, + `No parser for ${moduleSpecifier} in ${compartmentName}`, + ); + assert( + sourceDirname !== undefined, + `No sourceDirname for ${moduleSpecifier} in ${compartmentName}`, + ); + if (deferredError) { + throw Error( + `Cannot bundle: encountered deferredError ${deferredError}`, + ); + } + 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>} */ +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} 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 {CompartmentMapDescriptor} compartmentMap + * @param {BundleOptions} [options] + * @returns {Promise} + */ +export const makeFunctorFromMap = async ( + readPowers, + compartmentMap, + options, +) => { + const { + moduleTransforms, + 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, + 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]) => { + // UNTIL https://github.com/endojs/endo/issues/1514 + // Prefer: key = aliases.get(key) ?? key; + const alias = aliases.get(key); + if (alias != null) { + key = alias; + } + const module = modulesByKey[key]; + if (module === undefined) { + throw new Error( + `Unable to locate module for key ${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 => Object.fromEntries( + Object.getOwnPropertyNames(namespace) + .map(name => [name, { + get() { + return Reflect.get(namespace, name); + }, + set() { + throw new TypeError('Non-writable export'); + }, + observe(observer) { + observer(Reflect.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 Object.freeze({ + get: Object.freeze(() => { + return value; + }), + set: Object.freeze((newValue) => { + value = newValue; + for (const observe of observers) { + observe(value); + } + }), + observe: Object.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 => Object.freeze(Object.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