Repository: endojs/endo
Pull request #2353 (2024-07-13 05:45:33)

feat(bundle-source): Add `tag` command-line flag
Refs: #400

## Description

We’ve discovered that, to create an Endo script bundle that captures our `ModuleSource` constructor shim, it will need to entrain Babel. With the default build conditions, Babel entrains a `debug` package, which entrains `node:tty`. So, it is necessary to add the `"browser"` condition, which instead entrains a package with a JSON module (#2352). This change threads arbitrary conditions (build tags) through `bundle-source` to conveniently generate Endo script bundles with different tags like `browser` or `xs`.

### Security Considerations

None.

### Scaling Considerations

None.

### Documentation Considerations

The new flags are advertised in the command’s usage display.

### Testing Considerations

- [ ] test scaffold and manual testing instructions

### Compatibility Considerations

None.

### Upgrade Considerations

None.

erights: > With the default build conditions, Babel entrains a debug package, which entrains node:tty.

Are there better behaved non-default build conditions?





erights: nevermind. I get it now.
dckc: > ... it will need to entrain Babel.

it = ?

I'm not sure I'm following.
kriskowal: > an Endo script bundle that captures our `ModuleSource` constructor shim,

An Endo script bundle that captures our `ModuleSource` constructor shim (née `@endo/static-module-record`) such that a test262 “prelude” (shim script) can arrange for the same environment as XS, so we can write test262 tests that expect the same Hardened JavaScript environment.

Patch:
diff --git a/packages/bundle-source/NEWS.md b/packages/bundle-source/NEWS.md
index 1212a2cc3..a110edc50 100644
--- a/packages/bundle-source/NEWS.md
+++ b/packages/bundle-source/NEWS.md
@@ -11,6 +11,8 @@
 - Adds a `-f,--format` command flag to specify other module formats.
 - Adds a new `endoScript` module format.
 - Adds a no-cache, bundle-to-stdout mode.
+- Adds a `-t,--tag` command flag to specify export/import conditions like
+  `"browser"`.
 
 # v3.2.1 (2024-03-20)
 
diff --git a/packages/bundle-source/cache.js b/packages/bundle-source/cache.js
index 073b194f4..6506dbfc7 100644
--- a/packages/bundle-source/cache.js
+++ b/packages/bundle-source/cache.js
@@ -20,6 +20,7 @@ const { Fail, quote: q } = assert;
  * @property {number} bundleSize
  * @property {boolean} noTransforms
  * @property {ModuleFormat} format
+ * @property {string[]} tags
  * @property {{ relative: string, absolute: string }} moduleSource
  * @property {Array<{ relativePath: string, mtime: string, size: number }>} contents
  */
@@ -51,12 +52,19 @@ export const makeBundleCache = (wr, cwd, readPowers, opts) => {
    * @param {Logger} [log]
    * @param {object} [options]
    * @param {boolean} [options.noTransforms]
+   * @param {string[]} [options.tags]
    * @param {ModuleFormat} [options.format]
    */
   const add = async (rootPath, targetName, log = defaultLog, options = {}) => {
     const srcRd = cwd.neighbor(rootPath);
 
-    const { noTransforms = false, format = 'endoZipBase64' } = options;
+    const {
+      noTransforms = false,
+      format = 'endoZipBase64',
+      tags = [],
+    } = options;
+
+    tags.sort();
 
     const statsByPath = new Map();
 
@@ -115,6 +123,7 @@ export const makeBundleCache = (wr, cwd, readPowers, opts) => {
       ),
       noTransforms,
       format,
+      tags,
     };
 
     await metaWr.atomicWriteText(JSON.stringify(meta, null, 2));
@@ -144,6 +153,7 @@ export const makeBundleCache = (wr, cwd, readPowers, opts) => {
    * @param {object} [options]
    * @param {boolean} [options.noTransforms]
    * @param {ModuleFormat} [options.format]
+   * @param {string[]} [options.tags]
    * @returns {Promise<BundleMeta>}
    */
   const validate = async (
@@ -154,8 +164,12 @@ export const makeBundleCache = (wr, cwd, readPowers, opts) => {
     options = {},
   ) => {
     await null;
-    const { noTransforms: expectedNoTransforms, format: expectedFormat } =
-      options;
+    const {
+      noTransforms: expectedNoTransforms,
+      format: expectedFormat,
+      tags: expectedTags = [],
+    } = options;
+    expectedTags.sort();
     if (!meta) {
       const metaJson = await loadMetaText(targetName, log);
       if (metaJson) {
@@ -180,10 +194,16 @@ export const makeBundleCache = (wr, cwd, readPowers, opts) => {
       moduleSource: { absolute: moduleSource },
       format = 'endoZipBase64',
       noTransforms = false,
+      tags = [],
     } = meta;
+    tags.sort();
     assert.equal(bundleFileName, toBundleName(targetName));
     assert.equal(format, expectedFormat);
     assert.equal(noTransforms, expectedNoTransforms);
+    assert.equal(tags.length, expectedTags.length);
+    tags.forEach((tag, index) => {
+      assert.equal(tag, expectedTags[index]);
+    });
     if (rootOpt) {
       moduleSource === cwd.neighbor(rootOpt).absolute() ||
         Fail`bundle ${targetName} was for ${moduleSource}, not ${rootOpt}`;
@@ -230,6 +250,7 @@ export const makeBundleCache = (wr, cwd, readPowers, opts) => {
    * @param {object} [options]
    * @param {boolean} [options.noTransforms]
    * @param {ModuleFormat} [options.format]
+   * @param {string[]} [options.tags]
    * @returns {Promise<BundleMeta>}
    */
   const validateOrAdd = async (
@@ -248,6 +269,7 @@ export const makeBundleCache = (wr, cwd, readPowers, opts) => {
         meta = await validate(targetName, rootPath, log, meta, {
           format: options.format,
           noTransforms: options.noTransforms,
+          tags: options.tags,
         });
         const {
           bundleTime,
@@ -255,6 +277,7 @@ export const makeBundleCache = (wr, cwd, readPowers, opts) => {
           contents,
           noTransforms,
           format = 'endoZipBase64',
+          tags = [],
         } = meta;
         log(
           `${wr}`,
@@ -268,6 +291,8 @@ export const makeBundleCache = (wr, cwd, readPowers, opts) => {
           noTransforms ? 'w/o transforms' : 'with transforms',
           'with format',
           format,
+          'and tags',
+          JSON.stringify(tags),
         );
       } catch (invalid) {
         meta = undefined;
@@ -284,6 +309,7 @@ export const makeBundleCache = (wr, cwd, readPowers, opts) => {
         contents,
         noTransforms,
         format = 'endoZipBase64',
+        tags = [],
       } = meta;
       log(
         `${wr}`,
@@ -296,6 +322,8 @@ export const makeBundleCache = (wr, cwd, readPowers, opts) => {
         noTransforms ? 'w/o transforms' : 'with transforms',
         'with format',
         format,
+        'and tags',
+        JSON.stringify(tags),
       );
     }
 
@@ -310,6 +338,7 @@ export const makeBundleCache = (wr, cwd, readPowers, opts) => {
    * @param {object} [options]
    * @param {boolean} [options.noTransforms]
    * @param {ModuleFormat} [options.format]
+   * @param {string[]} [options.tags]
    */
   const load = async (
     rootPath,
diff --git a/packages/bundle-source/demo/node_modules/conditional-exports/a.js b/packages/bundle-source/demo/node_modules/conditional-exports/a.js
new file mode 100644
index 000000000..0ed5c3008
--- /dev/null
+++ b/packages/bundle-source/demo/node_modules/conditional-exports/a.js
@@ -0,0 +1,1 @@
+export default 'a';
diff --git a/packages/bundle-source/demo/node_modules/conditional-exports/b.js b/packages/bundle-source/demo/node_modules/conditional-exports/b.js
new file mode 100644
index 000000000..a68ac2819
--- /dev/null
+++ b/packages/bundle-source/demo/node_modules/conditional-exports/b.js
@@ -0,0 +1,1 @@
+export default 'b';
diff --git a/packages/bundle-source/demo/node_modules/conditional-exports/package.json b/packages/bundle-source/demo/node_modules/conditional-exports/package.json
new file mode 100644
index 000000000..8e9974e42
--- /dev/null
+++ b/packages/bundle-source/demo/node_modules/conditional-exports/package.json
@@ -0,0 +1,9 @@
+{
+  "name": "conditional-exports",
+  "version": "0.0.0",
+  "type": "module",
+  "exports": {
+    "a": "./a.js",
+    "b": "./b.js"
+  }
+}
diff --git a/packages/bundle-source/demo/node_modules/conditional-reexports/entry.js b/packages/bundle-source/demo/node_modules/conditional-reexports/entry.js
new file mode 100644
index 000000000..65a337fe8
--- /dev/null
+++ b/packages/bundle-source/demo/node_modules/conditional-reexports/entry.js
@@ -0,0 +1,2 @@
+import value from 'conditional-exports';
+export default value;
diff --git a/packages/bundle-source/demo/node_modules/conditional-reexports/package.json b/packages/bundle-source/demo/node_modules/conditional-reexports/package.json
new file mode 100644
index 000000000..d43ce8f30
--- /dev/null
+++ b/packages/bundle-source/demo/node_modules/conditional-reexports/package.json
@@ -0,0 +1,8 @@
+{
+  "name": "conditional-reexports",
+  "version": "0.0.0",
+  "type": "module",
+  "dependencies": {
+    "conditional-exports": "*"
+  }
+}
diff --git a/packages/bundle-source/src/main.js b/packages/bundle-source/src/main.js
index 136c42ae2..4f31983c1 100644
--- a/packages/bundle-source/src/main.js
+++ b/packages/bundle-source/src/main.js
@@ -8,9 +8,10 @@ import { jsOpts, jsonOpts, makeNodeBundleCache } from '../cache.js';
 /** @import {ModuleFormat} from './types.js' */
 
 const USAGE = `\
-bundle-source [-Tf] <entry.js>
-bundle-source [-Tf] --cache-js|--cache-json <cache/> (<entry.js> <bundle-name>)*
+bundle-source [-Tft] <entry.js>
+bundle-source [-Tft] --cache-js|--cache-json <cache/> (<entry.js> <bundle-name>)*
   -f,--format endoZipBase64*|nestedEvaluate|getExport
+  -t,--tag <tag> (browser, node, &c)
   -T,--no-transforms`;
 
 const options = /** @type {const} */ ({
@@ -32,6 +33,11 @@ const options = /** @type {const} */ ({
     short: 'f',
     multiple: false,
   },
+  tag: {
+    type: 'string',
+    short: 't',
+    multiple: true,
+  },
   // deprecated
   to: {
     type: 'string',
@@ -52,6 +58,7 @@ export const main = async (args, { loadModule, pid, log }) => {
   const {
     values: {
       format: moduleFormat = 'endoZipBase64',
+      tag: tags = [],
       'no-transforms': noTransforms,
       'cache-json': cacheJson,
       'cache-js': cacheJs,
@@ -84,7 +91,11 @@ export const main = async (args, { loadModule, pid, log }) => {
       throw new Error(USAGE);
     }
     const [entryPath] = positionals;
-    const bundle = await bundleSource(entryPath, { noTransforms, format });
+    const bundle = await bundleSource(entryPath, {
+      noTransforms,
+      format,
+      tags,
+    });
     process.stdout.write(JSON.stringify(bundle));
     process.stdout.write('\n');
     return;
@@ -114,6 +125,7 @@ export const main = async (args, { loadModule, pid, log }) => {
     await cache.validateOrAdd(bundleRoot, bundleName, undefined, {
       noTransforms,
       format,
+      tags,
     });
   }
 };
diff --git a/packages/bundle-source/src/script.js b/packages/bundle-source/src/script.js
index f2761b3ba..2e430927b 100644
--- a/packages/bundle-source/src/script.js
+++ b/packages/bundle-source/src/script.js
@@ -37,6 +37,7 @@ export async function bundleScript(
     dev = false,
     cacheSourceMaps = false,
     noTransforms = false,
+    tags = [],
     commonDependencies,
   } = options;
   const powers = { ...readPowers, ...grantedPowers };
@@ -69,6 +70,7 @@ export async function bundleScript(
 
   const source = await makeBundle(powers, entry, {
     dev,
+    tags,
     commonDependencies,
     parserForLanguage,
     moduleTransforms,
diff --git a/packages/bundle-source/src/types.js b/packages/bundle-source/src/types.js
index b527bea65..836f1b432 100644
--- a/packages/bundle-source/src/types.js
+++ b/packages/bundle-source/src/types.js
@@ -70,6 +70,8 @@ export {};
  * @property {boolean} [noTransforms] - when true, generates a bundle with the
  * original sources instead of SES-shim specific ESM and CJS. This may become
  * default in a future major version.
+ * @property {string[]} [tags] - conditions for package.json conditional
+ * exports and imports.
  */
 
 /**
diff --git a/packages/bundle-source/src/zip-base64.js b/packages/bundle-source/src/zip-base64.js
index 7eb9f130b..e7bd1e29d 100644
--- a/packages/bundle-source/src/zip-base64.js
+++ b/packages/bundle-source/src/zip-base64.js
@@ -22,6 +22,7 @@ const readPowers = makeReadPowers({ fs, url, crypto });
  * @param {boolean} [options.dev]
  * @param {boolean} [options.cacheSourceMaps]
  * @param {boolean} [options.noTransforms]
+ * @param {string[]} [options.tags]
  * @param {Record<string, string>} [options.commonDependencies]
  * @param {object} [grantedPowers]
  * @param {(bytes: string | Uint8Array) => string} [grantedPowers.computeSha512]
@@ -39,6 +40,7 @@ export async function bundleZipBase64(
     dev = false,
     cacheSourceMaps = false,
     noTransforms = false,
+    tags = [],
     commonDependencies,
   } = options;
   const powers = { ...readPowers, ...grantedPowers };
@@ -71,6 +73,7 @@ export async function bundleZipBase64(
 
   const compartmentMap = await mapNodeModules(powers, entry, {
     dev,
+    tags,
     commonDependencies,
   });
 


Test patch:
diff --git a/packages/bundle-source/test/tags-command.test.js b/packages/bundle-source/test/tags-command.test.js
new file mode 100644
index 000000000..ead4ccd88
--- /dev/null
+++ b/packages/bundle-source/test/tags-command.test.js
@@ -0,0 +1,67 @@
+/* global Buffer */
+import test from '@endo/ses-ava/prepare-endo.js';
+
+import { spawn } from 'child_process';
+import url from 'url';
+
+const textDecoder = new TextDecoder();
+
+const cwd = url.fileURLToPath(new URL('..', import.meta.url));
+
+const bundleSource = async (...args) => {
+  const bundleBytes = await new Promise((resolve, reject) => {
+    const errorChunks = [];
+    const outputChunks = [];
+    const child = spawn('node', ['bin/bundle-source', ...args], {
+      cwd,
+      stdio: ['inherit', 'pipe', 'pipe'],
+    });
+    child.on('close', code => {
+      if (code !== 0) {
+        reject(
+          new Error(
+            `Exit code: ${code}\nError output: ${new TextDecoder().decode(
+              Buffer.concat(errorChunks),
+            )}`,
+          ),
+        );
+      } else {
+        resolve(Buffer.concat(outputChunks));
+      }
+    });
+    child.stdout.on('data', chunk => {
+      outputChunks.push(chunk);
+    });
+    child.stderr.on('data', chunk => {
+      errorChunks.push(chunk);
+    });
+  });
+  const bundleText = textDecoder.decode(bundleBytes);
+  return JSON.parse(bundleText);
+};
+
+test('bundle-source with --format and --tag', async t => {
+  const compartment = new Compartment();
+  {
+    const bundle = await bundleSource(
+      '--tag',
+      'b',
+      '--format',
+      'endoScript',
+      'demo/node_modules/conditional-reexports/entry.js',
+    );
+    const namespace = compartment.evaluate(bundle.source);
+    t.is(namespace.default, 'b');
+  }
+  {
+    const bundle = await bundleSource(
+      '--tag',
+      'a',
+      '--format',
+      'endoScript',
+      'demo/node_modules/conditional-reexports/entry.js',
+    );
+    const namespace = compartment.evaluate(bundle.source);
+    t.is(namespace.default, 'a');
+  }
+});