diff --git a/packages/module-source/.gitignore b/packages/module-source/.gitignore new file mode 100644 index 0000000000..a9a5aecf42 --- /dev/null +++ b/packages/module-source/.gitignore @@ -0,0 +1 @@ +tmp diff --git a/packages/module-source/NEWS.md b/packages/module-source/NEWS.md index 3e981409b0..68e2ce077d 100644 --- a/packages/module-source/NEWS.md +++ b/packages/module-source/NEWS.md @@ -1,5 +1,10 @@ User-visible changes in `@endo/module-source`: +# Next release + +- Provides an XS-specific variant of `@endo/module-source` that adapts the + native `ModuleSource` instead of entraining Babel. + # v1.1.0 (2024-10-10) - Adds `@endo/module-source/shim.js` to shim `globalThis.ModuleSource`. diff --git a/packages/module-source/README.md b/packages/module-source/README.md index 8edfea979d..37e2c276ba 100644 --- a/packages/module-source/README.md +++ b/packages/module-source/README.md @@ -74,6 +74,15 @@ the hook returns a promise, it will be dropped and rejections will go uncaught. If the hook must do async work, these should be queued up as a job that the caller can later await. +## XS Specific Variant + +With the `xs` condition, `@endo/module-source` will not entrain Babel and will +just adapt the native `ModuleSource` to the older interface presented by this +package. +That is, the XS native `bindings` will be translated to `imports`, `exports`, +and `reexports` getters. +This form of `ModuleSource` ignores all options. + ## Bug Disclosure Please help us practice coordinated security bug disclosure, by using the diff --git a/packages/module-source/package.json b/packages/module-source/package.json index d3625cf092..0058b247a0 100644 --- a/packages/module-source/package.json +++ b/packages/module-source/package.json @@ -22,7 +22,10 @@ "type": "module", "main": "./index.js", "exports": { - ".": "./index.js", + ".": { + "xs": "./src-xs/index.js", + "default": "./index.js" + }, "./shim.js": "./shim.js", "./package.json": "./package.json" }, @@ -35,7 +38,8 @@ "lint:types": "tsc", "lint:eslint": "eslint .", "lint-fix": "eslint --fix .", - "test": "ava" + "test": "ava", + "test:xs": "node scripts/generate-test-xs.js && xst tmp/test-xs.js" }, "dependencies": { "@agoric/babel-generator": "^7.17.6", diff --git a/packages/module-source/scripts/generate-test-xs.js b/packages/module-source/scripts/generate-test-xs.js new file mode 100644 index 0000000000..e74e88577a --- /dev/null +++ b/packages/module-source/scripts/generate-test-xs.js @@ -0,0 +1,41 @@ +/* global process */ +import 'ses'; +import { promises as fs } from 'fs'; +// Lerna does not like dependency cycles. +// With an explicit devDependency from module-source to compartment-mapper, +// the build script stalls before running every package's build script. +// yarn lerna run build +// Omitting the dependency from package.json solves the problem and works +// by dint of shared workspace node_modules. +// eslint-disable-next-line import/no-extraneous-dependencies +import { makeBundle } from '@endo/compartment-mapper/bundle.js'; +import { fileURLToPath } from 'url'; + +const read = async location => { + const path = fileURLToPath(location); + return fs.readFile(path); +}; +const write = async (location, content) => { + const path = fileURLToPath(location); + await fs.writeFile(path, content); +}; + +const main = async () => { + const xsPrelude = await makeBundle( + read, + new URL('../test/_xs.js', import.meta.url).href, + { + tags: new Set(['xs']), + }, + ); + + await fs.mkdir(fileURLToPath(new URL('../tmp', import.meta.url)), { + recursive: true, + }); + await write(new URL('../tmp/test-xs.js', import.meta.url).href, xsPrelude); +}; + +main().catch(err => { + console.error('Error running main:', err); + process.exitCode = 1; +}); diff --git a/packages/module-source/shim.js b/packages/module-source/shim.js index e1d8c5b98e..f3357fc276 100644 --- a/packages/module-source/shim.js +++ b/packages/module-source/shim.js @@ -1,6 +1,10 @@ /* global globalThis */ -import { ModuleSource } from './index.js'; +// We are using a reflexive import to make sure we pass through the conditional +// export in package.json. +// Eslint does not yet seem to have a carve-out for package-reflexive imports. +// eslint-disable-next-line import/no-extraneous-dependencies +import { ModuleSource } from '@endo/module-source'; Object.defineProperty(globalThis, 'ModuleSource', { value: ModuleSource, diff --git a/packages/module-source/src-xs/index.js b/packages/module-source/src-xs/index.js new file mode 100644 index 0000000000..f293de226d --- /dev/null +++ b/packages/module-source/src-xs/index.js @@ -0,0 +1,67 @@ +// @ts-check +/* global globalThis */ +/* eslint-disable @endo/no-nullish-coalescing */ + +/** + * @typedef {| + * { import: string, as?: string, from: string } | + * { importAllFrom: string, as: string, from: string } | + * { export: string, as?: string, from?: string } | + * { exportAllFrom: string, as?: string } | + * {importFrom: string } + * } Binding + */ + +/** @param {Binding[]} bindings */ +function* getImports(bindings) { + for (const binding of bindings) { + if (binding.import !== undefined) { + yield binding.from; + } else if (binding.importFrom !== undefined) { + yield binding.importFrom; + } else if (binding.importAllFrom !== undefined) { + yield binding.importAllFrom; + } else if (binding.exportAllFrom !== undefined) { + yield binding.exportAllFrom; + } + } +} + +/** @param {Binding[]} bindings */ +function* getExports(bindings) { + for (const binding of bindings) { + if (binding.export !== undefined) { + yield binding.as ?? binding.export; + } + } +} + +/** @param {Binding[]} bindings */ +function* getReexports(bindings) { + for (const binding of bindings) { + if (binding.exportAllFrom !== undefined) { + yield binding.exportAllFrom; + } + } +} + +const ModuleSource = globalThis.ModuleSource; + +Object.defineProperties( + ModuleSource.prototype, + Object.getOwnPropertyDescriptors({ + get imports() { + return Array.from(new Set(getImports(this.bindings))); + }, + + get exports() { + return Array.from(new Set(getExports(this.bindings))); + }, + + get reexports() { + return Array.from(new Set(getReexports(this.bindings))); + }, + }), +); + +export { ModuleSource }; diff --git a/packages/module-source/test/_native.js b/packages/module-source/test/_native.js new file mode 100644 index 0000000000..85ff89518a --- /dev/null +++ b/packages/module-source/test/_native.js @@ -0,0 +1,3 @@ +/* global globalThis */ +export const NativeModuleSource = globalThis.ModuleSource; +export const NativeCompartment = globalThis.Compartment; diff --git a/packages/module-source/test/_xs.js b/packages/module-source/test/_xs.js new file mode 100644 index 0000000000..47573b12f5 --- /dev/null +++ b/packages/module-source/test/_xs.js @@ -0,0 +1,41 @@ +// This is a test fixture for XS validation (yarn test:xs with Moddable's xst +// on the PATH) +// This must be bundled with the -C xs condition to produce an artifact +// (tmp/test-xs.js) suitable for running with xst. +import { NativeModuleSource, NativeCompartment } from './_native.js'; +// Eslint does not know about package reflexive imports (importing your own +// package), which in this case is necessary to go through the conditional +// export in package.json. +// eslint-disable-next-line import/no-extraneous-dependencies +import '@endo/module-source/shim.js'; +import 'ses'; + +lockdown(); + +// spot checks +assert(Object.isFrozen(Object)); + +const source = new ModuleSource(` + import name from 'imported'; + export * from 'reexported'; + export default 42; + throw new Error('unreached'); +`); +assert(source.imports[0] === 'imported'); +assert(source.exports[0] === 'default'); +assert(source.reexports[0] === 'reexported'); + +assert(source instanceof NativeModuleSource); + +const compartment = new NativeCompartment({ + modules: { + '.': { + source: new ModuleSource(` + export default 42; + `), + }, + }, +}); +assert(compartment.importNow('.').default === 42); + +// to be continued with XS-specific adapters for Compartment in SES...