Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(compartment-mapper): Make script or functor bundles #2707

Merged
merged 1 commit into from
Feb 15, 2025

Conversation

kriskowal
Copy link
Member

@kriskowal kriskowal commented Jan 30, 2025

Refs: #2444

Description

Toward better preserving the format of original source through the ModuleSource and censorship evasion transforms, we upgraded Babel and use a new code generator. The new code generator does not compose with Rollup. However, in the intervening years, we have reïmplemented every part of Rollup we find desirable and better in keeping with our integrity requirements. So, we are poised to reïmplement the nestedEvaluate and getExport formats using our own implementation. To that end, this change closes the feature parity gap needed to undertake that refactor.

We refactor makeBundle into makeScript and makeFuctor. We effectively rename makeBundle to makeScript for clarity. The script form surfaces as the endoScript bundler format and we expect functor to surface in an endoFunctor format, in addition to replacing the implementation of nestedEvaluate and getExport.

Both of these functions are extended to accept compile time useEvaluate, sourceUrlPrefix, and format, to exit to CommonJS require. We leave open the possibility of an esm or mjs format that would exit to import for host modules.

The new makeFunctor is analogous but isn’t suitable for <script> tags and instead allows the user to supply runtime options, including require for CommonJS format, evaluate if compiled with useEvaluate to override indirect eval, and sourceUrlPrefix which also in combination with useEvaluate overrides the compile time option by the same name.

Captures sourceDirname for each packaged compartment for improved sourceURL generation. So, if the directory name does not match the package name, the source URL will be more likely to united with the original sources if they are open in the developer’s IDE.

Moves bundle use strict pragma into evaluated function expression bodies for better composition with eval.

Improve error message for misconfigured require option.

Security Considerations

No impact.

Scaling Considerations

No impact.

Documentation Considerations

The new bundling features necessitate new API reference documentation, both those generated from TypeScript, and our hand-crafted README.

Testing Considerations

This change includes coverage for all essential combinations of the new bundler options, including compile time and runtime options and cases where the latter overrides the former.

Compatibility Considerations

Documentation included for was that the new features do not have parity with Rollup. This change is purely additive, but the upcoming changes to @endo/bundle-source will require a major version bump for not being comfortably equivalent to former behavior. We have confirmed that the new behavior produces a passing CI run in composition with usage patterns in Agoric SDK, with a couple preparatory changes to make the transition.

Upgrade Considerations

These changes will impact the formation of the Agoric SwingSet XSnap Supervisor Lockdown (bootstrap) script. They should not produce observable differences in behavior.

@kriskowal kriskowal requested a review from gibson042 January 30, 2025 23:40
@@ -6,7 +6,7 @@
* PrecompiledStaticModuleInterface
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewers, expand this large diff.

Comment on lines +12 to +19
${json},
`,
getCells: () => `\
{ default: cell('default') },
`,
getReexportsWiring: () => '',
getFunctorCall: () => `\
functors[${index}](cells[${index}].default.set);
cells[${index}].default.set(functors[${index}]);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a simplification, made obvious by changes to the other formats. The respective entry in the functors array is a contract between the caller and callee on a per-language basis. So, for JSON, there is no need to complicate things by making the JSON “functor” a functor. The caller just needs to set the value for the default export cell.

@@ -176,7 +176,7 @@ const digestFromMap = async (powers, compartmentMap, options = {}) => {
searchSuffixes,
entryCompartmentName,
entryModuleSpecifier,
exitModuleImportHook: consolidatedExitModuleImportHook,
importHook: consolidatedExitModuleImportHook,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a simplification of an internal type signature that is better aligned with reusable type definitions.

sourceDirname,
record: { cjsFunctor, exports: exportsList = {} },
},
{ useEvaluate = false, sourceUrlPrefix = undefined },
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whether to embed a sourceURL at all hinges on whether we useEvaluate, and in that case, we now defer the construction of the sourceURL to runtime to account for a runtime sourceUrlPrefix option. We could conceivably continue in this direction and inject computeSourceLocation at runtime, using the same option signature we use for importArchive.

@@ -41,7 +43,7 @@ function observeImports(map, importName, importIndex) {
for (const [name, observers] of map.get(importName)) {
const cell = cells[importIndex][name];
if (cell === undefined) {
throw new ReferenceError(\`Cannot import name \${name}\`);
throw new ReferenceError(\`Cannot import name \${name} (has \${Object.getOwnPropertyNames(cells[importIndex]).join(', ')})\`);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This proved useful in debugging in integration with Agoric SDK and might again one day.

Comment on lines 546 to 559
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,
}])
);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It’s not possible to perfectly lift a JavaScript object to a virtual module exports namespace object. This is a good place to question.

Comment on lines 601 to 611
${
curryRuntimeOptions
? ''
: `({
${
format !== 'cjs'
? ''
: `\
require: typeof require === 'function' ? require : undefined,
`
}\
})`
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There’s a design choice here, whether to curry both functors and options in the case where the options will be a fixed shape. I went with this approach, where we apply options in the generated code if curryRuntimeOptions is not specified, rather that inlining the options higher up. It could be argued.

Comment on lines -403 to -418
* @param {object} options
* @param {Sources} [options.sources]
* @param {Record<string, CompartmentDescriptor>} [options.compartmentDescriptors]
* @param {boolean} [options.archiveOnly]
* @param {HashFn} [options.computeSha512]
* @param {Array<string>} [options.searchSuffixes] - Suffixes to search if the
* unmodified specifier is not found.
* Pass [] to emulate Node.js' strict behavior.
* The default handles Node.js' CommonJS behavior.
* Unlike Node.js, the Compartment Mapper lifts CommonJS up, more like a
* bundler, and does not attempt to vary the behavior of resolution depending
* on the language of the importing module.
* @param {string} options.entryCompartmentName
* @param {string} options.entryModuleSpecifier
* @param {ExitModuleImportHook} [options.exitModuleImportHook]
* @param {SourceMapHook} [options.sourceMapHook]
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drive-by refactor from unfinished typescript consolidation.

@@ -720,6 +712,7 @@ export function makeImportNowHookMaker(
packageLocation,
packageSources,
readPowers,
archiveOnly,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not need to reveal a new option to makeBundle in order to indicate whether a sourceURL comment should be generated at the time a module is parsed off disk, because we already had archiveOnly, which is not orthogonal. I did get caught surprised that we do need to inject a sourceURL at parse time in order to preserve stack traces for importLocation, since that doesn’t go through the archiver or bundler.

@@ -41,7 +41,7 @@ export const parseCjs = (
*/
const execute = (moduleEnvironmentRecord, compartment, resolvedImports) => {
const functor = compartment.evaluate(
`(function (require, exports, module, __filename, __dirname) { ${source} //*/\n})\n//# sourceURL=${location}`,
`(function (require, exports, module, __filename, __dirname) { 'use strict'; ${source} })\n`,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving the pragma into each individual functor and the generated linker was needed to make bundles suitable inlined anywhere an expression is accepted, like (${source}).

Copy link
Contributor

@gibson042 gibson042 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Initial feedback on the documentation. I have not yet reviewed any code changes.

Comment on lines 147 to 152
import url from 'url';
import fs from 'fs';
import { makeBundle } from "@endo/compartment-mapper/bundle.js";
import { makeReadPowers } from '../node-powers.js';
const readPowers = makeReadPowers({ fs, url });
const bundle = await makeBundle(readPowers, moduleLocation, options);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the origin and purpose of options in this snippet?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving a stub:

const bundleOptions = {}; // if any

that subsumes `ses`, `@endo/compartment-mapper/import-archive.js`, and other
parts of Endo, but is not as feature-complete as `importArchive`.

```
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
```
```js

Comment on lines 155 to 157
It should be sufficient to evaluate `bundle`.
The completion value of evaluation is the emulated exports namespace of the
entry module.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
It should be sufficient to evaluate `bundle`.
The completion value of evaluation is the emulated exports namespace of the
entry module.
Evaluation of `bundle` returns the emulated exports namespace of the entry
module (e.g., `const namespace = eval(bundle);`).

Comment on lines 162 to 165
:warning: Bundles do not support [live
bindings](https://developer.mozilla.org/en-US/docs/Glossary/Binding), dynamic
`import`, or `import.meta`.
Bundles do not isolate modules to a compartment.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
:warning: Bundles do not support [live
bindings](https://developer.mozilla.org/en-US/docs/Glossary/Binding), dynamic
`import`, or `import.meta`.
Bundles do not isolate modules to a compartment.
> [!WARNING]
> Bundles do not support [live
> bindings](https://developer.mozilla.org/en-US/docs/Glossary/Binding), dynamic
> `import`, or `import.meta`.
> Bundles do not isolate modules to a compartment.

(GFM Alerts)

The default is `require` on `globalThis`.
The `require` function can be overridden with a curried runtime option.
- `useEvaluate` (`boolean`, default `false`):
By default, a bundle has an array of functions for each bundled module.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure how to interpret this sentence... I assume "array of functions" refers to module exports, but if a bundle is "a string of JavaScript suitable for eval" then what does it mean for a bundle to have such an array?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewrote.

packages/compartment-mapper/README.md Outdated Show resolved Hide resolved
packages/compartment-mapper/README.md Outdated Show resolved Hide resolved
Comment on lines 190 to 197
- `curryRuntimeOptions` (`boolean`, default `false`):
By default, the completion value of a bundle is the exports namespace of
the entry module after all the modules have executed.
This is suitable for shims like `ses`, where the completion value is
discarded and any runtime options must be communicated through the
global environment, like `process.env` or the presence of a `require` function.
Currying runtime options changes the completion value to a function that receives
runtime options and then returns the exports namespace of the entry module.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think "currying" is technically correct here, nor does it communicate to me what's really going on (the indirection from "bundle represents a [non-function] module namespace" to "bundle represents a function that returns a module namespace"). I don't have a better name suggestion, but I wonder if the indirection might be better represented by a sibling function distinct from makeBundle rather than an option for it (and I really an wondering; the answer might be "no").

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per out-of-band conversation, we found that we can avoid the option name by splitting makeBundle into makeScript and makeFunctor, where the latter provides an opportunity to apply runtime options.

packages/compartment-mapper/README.md Outdated Show resolved Hide resolved
Copy link
Contributor

@gibson042 gibson042 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's always difficult to review code like this, and I don't think I'm sufficiently familiar with compartment-mapper to actually detect problems. If you'd like a more substantial review, we should probably pair up for a walkthrough.

packages/compartment-mapper/src/types/external.ts Outdated Show resolved Hide resolved
packages/compartment-mapper/src/types/external.ts Outdated Show resolved Hide resolved
packages/compartment-mapper/src/types/external.ts Outdated Show resolved Hide resolved
packages/compartment-mapper/src/types/external.ts Outdated Show resolved Hide resolved
packages/compartment-mapper/src/parse-archive-cjs.js Outdated Show resolved Hide resolved
packages/compartment-mapper/test/bundle.test.js Outdated Show resolved Hide resolved
Comment on lines 147 to 152
import url from 'url';
import fs from 'fs';
import { makeBundle } from "@endo/compartment-mapper/bundle.js";
import { makeReadPowers } from '../node-powers.js';
const readPowers = makeReadPowers({ fs, url });
const bundle = await makeBundle(readPowers, moduleLocation, options);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving a stub:

const bundleOptions = {}; // if any

packages/compartment-mapper/NEWS.md Show resolved Hide resolved
packages/compartment-mapper/NEWS.md Outdated Show resolved Hide resolved
packages/compartment-mapper/NEWS.md Outdated Show resolved Hide resolved
packages/compartment-mapper/src/bundle-cjs.js Outdated Show resolved Hide resolved
@kriskowal kriskowal changed the title feat(compartment-mapper): Curried bundles feat(compartment-mapper): Make script or functor bundles Feb 6, 2025
@kriskowal kriskowal force-pushed the kriskowal-curry-bundle branch from 3805a35 to e9c7eef Compare February 6, 2025 05:03
@kriskowal kriskowal requested a review from gibson042 February 6, 2025 05:04
@kriskowal kriskowal force-pushed the kriskowal-curry-bundle branch from e9c7eef to b5907d6 Compare February 6, 2025 05:07
Copy link
Contributor

@gibson042 gibson042 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❤️
LGTM after minor tweaks.

packages/compartment-mapper/README.md Outdated Show resolved Hide resolved
packages/compartment-mapper/README.md Outdated Show resolved Hide resolved
packages/compartment-mapper/README.md Outdated Show resolved Hide resolved
packages/compartment-mapper/README.md Outdated Show resolved Hide resolved
packages/compartment-mapper/README.md Outdated Show resolved Hide resolved
packages/compartment-mapper/src/bundle-lite.js Outdated Show resolved Hide resolved
packages/compartment-mapper/src/bundle-lite.js Outdated Show resolved Hide resolved
packages/compartment-mapper/src/bundle-lite.js Outdated Show resolved Hide resolved
@kriskowal kriskowal force-pushed the kriskowal-curry-bundle branch from b5907d6 to 90f7846 Compare February 15, 2025 01:12
…Bundle

In service to reimplementing the internals of the `nestedEvaluate` and
`getExport` bundle formats in `@endo/bundle-source`:

Add `makeBundle` options for `useEvaluate`, `sourceUrlPrefix`, exit to
CommonJS `require`, and `curryRuntimeOptions`, with curried runtime
options for `evaluate`, `require`, and `sourceUrlPrefix`.

Capture `sourceDirname` for each packaged compartment for improved
`sourceURL` generation.

Move bundle `use strict` pragma into evaluated function expression
bodies for better composition with `eval`.

Improve error message for misconfigured `require` option.
@kriskowal kriskowal force-pushed the kriskowal-curry-bundle branch from 90f7846 to ee87476 Compare February 15, 2025 01:20
@kriskowal kriskowal enabled auto-merge February 15, 2025 01:24
@kriskowal kriskowal merged commit bc34d40 into master Feb 15, 2025
15 checks passed
@kriskowal kriskowal deleted the kriskowal-curry-bundle branch February 15, 2025 01:31
gibson042 added a commit that referenced this pull request Feb 15, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants