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(import-bundle): Test bundle format #2719 #2735

Merged
merged 1 commit into from
Mar 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
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
7 changes: 7 additions & 0 deletions packages/import-bundle/NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
User-visible changes to `@endo/import-bundle`:

# Next release

- Adds support for `test` format bundles, which simply return a promise for an
object that resembles a module exports namespace with the objects specified
Copy link
Contributor

Choose a reason for hiding this comment

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

Oh interesting in French it's "ressemble"

on the symbol-named property @exports, which is deliberately not JSON
serializable or passable.

# v1.3.0 (2024-10-10)

- Adds support for `endoScript` format bundles.
Expand Down
26 changes: 26 additions & 0 deletions packages/import-bundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,28 @@ evaluated, to enforce ocap rules.

The source can be bundled in a variety of "formats".

### endoZipBase64

By default, `bundleSource` uses a format named `endoZipBase64`, in which the
source modules and a "compartment map" are captured in a Zip file and base-64
encoded.
The compartment map describes how to construct a set of [Hardened
JavaScript](https://hardenedjs.org) compartments and how to load and link the
source modules between them.

### endoScript

The `endoScript` format captures the sources as a single JavaScript program
that completes with the entry module's namespace object.

### getExport

The `getExport` format captures the sources as a single CommonJS-style string,
and wrapped in a callable function that provides the `exports` and
`module.exports` context to which the exports can be attached.

### nestedEvaluate

More sophisticated than `getExport` is named `nestedEvaluate`.
In this mode, the source tree is converted into a table of evaluable strings,
one for each original module.
Expand All @@ -59,6 +67,24 @@ Note that the `nestedEvaluate` format receives a global endowment named
`require`, although it will only be called if the source tree imported one of
the few modules on the `bundle-source` "external" list.

### test

The `test` format is useful for mocking a bundle locally for a test and is
deliberately not serializable or passable.
Use this format in tests to avoid the need to generate a bundle from source,
providing instead just the exports you need returned by `importBundle`.

```js
import { importBundle, bundleTestExports } from '@endo/import-bundle';

test('who tests the tests', async t => {
const bundle = bundleTestExports({ a: 10 });
const namespace = await importBundle(bundle);
t.is(namespace.a, 10);
t.is(Object.prototype.toString.call(ns), '[object Module]');
});
```

## Options

`importBundle()` takes an options bag and optional additional powers.
Expand Down
70 changes: 70 additions & 0 deletions packages/import-bundle/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,59 @@ export async function importBundle(bundle, options = {}, powers = {}) {
return namespace;
}

// The 'test' format is not generated by bundleSource and is not
// serializable as JSON and is not passable because copy-records cannot have symbol keys.
if (moduleFormat === 'test') {
const exports = bundle[Symbol.for('exports')];
if (exports === undefined) {
throw new Error(
'Cannot import bundle with moduleFormat "test" that lacks an symbol-named property @exports and has likely been partially transported via JSON or eventual-send',
);
}

// We emulate a module exports namespace object, which has certain invariants:
// Property names are only strings, so we will ignore symbol-named properties.
// All properties are enumerable, so we will ignore non-enumerable properties.
// All properties should be writable, but we deliberately deviate rather than
// emulate the exotic behavior of standard module exports namespace objects.
// The namespace object is sealed.
// Because we deviate from the standard behavior, the namespace object is
// frozen by implication.
// We capture the value for each property now and never again consult the given exports object.
return Object.seal(
Object.create(
null,
Object.fromEntries([
...Object.entries(Object.getOwnPropertyDescriptors(exports))
// eslint-disable-next-line no-nested-ternary
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.filter(
([name, descriptor]) =>
typeof name === 'string' && descriptor.enumerable,
)
.map(([name]) => [
name,
{
value: exports[name],
writable: false,
enumerable: true,
configurable: false,
},
]),
[
Symbol.toStringTag,
{
value: 'Module',
writable: false,
enumerable: false,
configurable: false,
},
],
]),
),
);
}

let { source } = bundle;
const { sourceMap } = bundle;
if (moduleFormat === 'getExport') {
Expand Down Expand Up @@ -124,6 +177,23 @@ export async function importBundle(bundle, options = {}, powers = {}) {
}
}

/**
* A utility function for producing test bundles, which are not serializable
* as JSON or passable.
* @param {Record<PropertyKey, unknown>} exports
*/
export const bundleTestExports = exports => {
const symbols = Object.getOwnPropertySymbols(exports).filter(
name => name !== Symbol.toStringTag,
);
symbols.length > 0 &&
Fail`exports must not have symbol-named properties, got: ${symbols.map(String).join(', ')}`;
return {
moduleFormat: 'test',
[Symbol.for('exports')]: exports,
};
Comment on lines +191 to +194
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we freeze the object (not harden it since we don't care about the stability of exports at this point)

Copy link
Member Author

Choose a reason for hiding this comment

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

That would be a departure from the norm for bundleSource. I think it’s reasonable to defer that choice to the caller. But, if we at some point reörganize the bundler infrastructure and create a new entry-point, I’d be open to changing the contract. Other reasons to consider reorganizing around an @endo/bundler module maybe someday would be to consolidate @endo/import-bundle, @endo/bundle-source, and @endo/check-bundle into a single package so they can more easily cross-test, do a better job of isolating powers and decoupling them from the underlying platform (like node-powers.js in the Compartment Mapper), remove deprecated powers like the ones taken by Rollup, one README for all the bundle formats and how to read and write them, &c. The list is growing but not pressing.

};

/*
importBundle(bundle, { metering: { getMeter, meteringOptions } });
importBundle(bundle, { transforms: [ meterTransform ], lexicals: { getMeter } });
Expand Down
3 changes: 3 additions & 0 deletions packages/import-bundle/test/_namespace.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// A fixture for the sole purpose of verifying that a module exports namespace
// is a suitable argument for bundleTestExports.
export {};
48 changes: 47 additions & 1 deletion packages/import-bundle/test/import-bundle.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import crypto from 'crypto';
import { makeArchive } from '@endo/compartment-mapper/archive.js';
import bundleSource from '@endo/bundle-source';
import { makeReadPowers } from '@endo/compartment-mapper/node-powers.js';
import { importBundle } from '../src/index.js';
import { importBundle, bundleTestExports } from '../src/index.js';

import * as namespace from './_namespace.js';

const { read } = makeReadPowers({ fs, url, crypto });

Expand Down Expand Up @@ -188,3 +190,47 @@ test('inescapable global properties, zip base64 format', async t => {
});
t.is(ns.default, 42);
});

test('test the test format', async t => {
const bundle = {
moduleFormat: 'test',
[Symbol.for('exports')]: {
z: 43,
default: 42,
a: 41,
},
};
const ns = await importBundle(bundle);
t.is(ns.default, 42);
t.deepEqual(Object.keys(ns), ['a', 'default', 'z']);
t.is(Object.prototype.toString.call(ns), '[object Module]');
});

test('test format must not round-trip via JSON', async t => {
const bundle = JSON.parse(
JSON.stringify({
moduleFormat: 'test',
[Symbol.for('exports')]: {
default: 42,
},
}),
);
await t.throwsAsync(importBundle(bundle), {
message: /Cannot import bundle with moduleFormat "test" that lacks/,
});
});

test('test bundle utility should fail early for symbol keys', t => {
t.throws(() =>
bundleTestExports({
[Symbol.for('iterator')]: 1,
[Symbol.iterator]: 'a',
}),
);
});

test('bundleTestExports should accept a genuine module exports namespace', t => {
// Taking into account that it will have a Symbol.toStringTag.
bundleTestExports(namespace);
t.pass();
});
Loading