Skip to content

Commit 564f98d

Browse files
committed
feat(compartment-mapper): expose AST-based CJS parser
This creates a `parse-cjs-babel` parser which uses `CjsModuleSource` from `@endo/module-source` instead of the lexer, supporting `import()` in CJS sources. Exposed within `parserForLanguageWithCjsBabel` as exported from `import-parsers.js`. Refactored compat tests to run across both parsers.
1 parent b10d38b commit 564f98d

11 files changed

Lines changed: 278 additions & 50 deletions

File tree

.changeset/olive-bats-push.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@endo/compartment-mapper': minor
3+
'@endo/module-source': minor
4+
'@endo/parser-pipeline': patch
5+
---
6+
7+
Exposes a Babel-based AST parser for CJS in `@endo/compartment-mapper` which supports dynamic `import()`. `@endo/module-source` exposes `CjsModuleSource` and `createCjsModuleSourcePasses()` (for use with `@endo/parser-pipeline`) and contains the implementation of the Babel-based parser for CJS. `@endo/parser-pipeline` now accepts records created by `CjsModuleSource`.

packages/compartment-mapper/cjs.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './src/cjs.js';
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
// eslint-disable-next-line import/export -- just types
22
export * from './src/types-external.js';
33

4-
export { defaultParserForLanguage } from './src/import-parsers.js';
4+
export {
5+
defaultParserForLanguage,
6+
parserForLanguageWithCjsBabel,
7+
} from './src/import-parsers.js';

packages/compartment-mapper/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"main": "./index.js",
2424
"exports": {
2525
".": "./index.js",
26+
"./cjs.js": "./cjs.js",
2627
"./import.js": "./import.js",
2728
"./import-lite.js": "./import-lite.js",
2829
"./import-parsers.js": "./import-parsers.js",
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/* eslint-disable no-underscore-dangle */
2+
/**
3+
* Provides {@link buildCjsExecuteRecord}, a function that converts a
4+
* {@link CjsModuleSourceRecord} into a {@link FinalStaticModuleType}.
5+
*
6+
* For use with `@endo/parser-pipeline`'s `createComposedParser()`.
7+
*
8+
* @module
9+
*/
10+
11+
import { getModulePaths, wrap } from './parse-cjs-shared-export-wrapper.js';
12+
13+
/**
14+
* @import {CjsModuleSourceRecord} from '@endo/module-source'
15+
* @import {ReadFn, ReadPowers} from './types.js'
16+
* @import {FinalStaticModuleType} from 'ses'
17+
*/
18+
19+
const { freeze } = Object;
20+
21+
/**
22+
* Converts a {@link CjsModuleSourceRecord} (which has a `cjsFunctor` string)
23+
* into a `FinalStaticModuleType`-compatible record (which has an `execute`
24+
* function). This is the bridge between the composed-pipeline CJS analysis and
25+
* the compartment-mapper execution model.
26+
*
27+
* Used by both {@link parseCjsBabel} (single-shot parser) and
28+
* {@link createCjsExecParser} (composed-pipeline parser) so the execution
29+
* logic lives in exactly one place.
30+
*
31+
* @param {CjsModuleSourceRecord} cjsRecord
32+
* @param {string} location
33+
* @param {ReadFn | ReadPowers | undefined} readPowers
34+
* @returns {FinalStaticModuleType}
35+
*/
36+
37+
export const buildCjsExecuteRecord = (cjsRecord, location, readPowers) => {
38+
const { filename, dirname } = getModulePaths(readPowers, location);
39+
40+
/**
41+
* @param {object} moduleEnvironmentRecord
42+
* @param {Compartment} compartment
43+
* @param {Record<string, string>} resolvedImports
44+
*/
45+
const execute = (moduleEnvironmentRecord, compartment, resolvedImports) => {
46+
const functor = compartment.evaluate(cjsRecord.cjsFunctor);
47+
48+
const wrapResult = wrap({
49+
moduleEnvironmentRecord,
50+
compartment,
51+
resolvedImports,
52+
location,
53+
readPowers,
54+
});
55+
56+
const args = [
57+
wrapResult.require,
58+
wrapResult.moduleExports,
59+
wrapResult.module,
60+
filename,
61+
dirname,
62+
];
63+
64+
if (cjsRecord.__needsImport__ && wrapResult.importFn) {
65+
args.push(wrapResult.importFn);
66+
}
67+
68+
functor.call(wrapResult.moduleExports, ...args);
69+
70+
wrapResult.afterExecute();
71+
};
72+
73+
return freeze({
74+
imports: cjsRecord.imports,
75+
exports: cjsRecord.exports,
76+
reexports: cjsRecord.reexports,
77+
execute,
78+
});
79+
};

packages/compartment-mapper/src/import-parsers.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@ import parserText from './parse-text.js';
1111
import parserBytes from './parse-bytes.js';
1212
import parserCjs from './parse-cjs.js';
1313
import parserMjs from './parse-mjs.js';
14+
import parserCjsBabel from './parse-cjs-babel.js';
15+
16+
const { freeze } = Object;
1417

1518
/** @satisfies {Readonly<ParserForLanguage>} */
16-
export const defaultParserForLanguage = Object.freeze(
19+
export const defaultParserForLanguage = freeze(
1720
/** @type {const} */ ({
1821
mjs: parserMjs,
1922
cjs: parserCjs,
@@ -22,3 +25,11 @@ export const defaultParserForLanguage = Object.freeze(
2225
bytes: parserBytes,
2326
}),
2427
);
28+
29+
/** @satisfies {Readonly<ParserForLanguage>} */
30+
export const parserForLanguageWithCjsBabel = freeze(
31+
/** @type {const} */ ({
32+
...defaultParserForLanguage,
33+
cjs: parserCjsBabel,
34+
}),
35+
);
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/* eslint-disable no-underscore-dangle */
2+
/**
3+
* Provides language behavior (parser) for importing CommonJS as a virtual
4+
* module source, using Babel AST analysis instead of the character-level lexer.
5+
*
6+
* Drop-in replacement for {@link parse-cjs.js}. Consumers opt in via the
7+
* pre-built parser map:
8+
*
9+
* ```js
10+
* import { parserForLanguageWithCjsBabel } from '@endo/compartment-mapper/import-parsers.js';
11+
*
12+
* await importLocation(readPowers, entryUrl, {
13+
* parserForLanguage: parserForLanguageWithCjsBabel,
14+
* });
15+
* ```
16+
*
17+
* @module
18+
*/
19+
20+
/**
21+
* @import {ParseFn, ParserImplementation} from './types.js'
22+
*/
23+
24+
import { CjsModuleSource } from '@endo/module-source';
25+
import { buildCjsExecuteRecord } from './cjs.js';
26+
27+
const textDecoder = new TextDecoder();
28+
29+
/** @type {ParseFn} */
30+
export const parseCjsBabel = (
31+
bytes,
32+
_specifier,
33+
location,
34+
_packageLocation,
35+
{ readPowers } = {},
36+
) => {
37+
const source = textDecoder.decode(bytes);
38+
const cjsRecord = new CjsModuleSource(source, { sourceUrl: location });
39+
40+
return {
41+
parser: 'cjs',
42+
bytes,
43+
record: buildCjsExecuteRecord(cjsRecord, location, readPowers),
44+
};
45+
};
46+
47+
/** @type {ParserImplementation} */
48+
export default {
49+
parse: parseCjsBabel,
50+
heuristicImports: true,
51+
synchronous: true,
52+
};

packages/compartment-mapper/src/parse-cjs-shared-export-wrapper.js

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,11 @@ export const getModulePaths = (readPowers, location) => {
8383
* @param {string} in.location
8484
* @param {ReadFn | ReadPowers | undefined} in.readPowers
8585
* @returns {{
86-
* module: { exports: any },
87-
* moduleExports: any,
88-
* afterExecute: Function,
89-
* require: Function,
86+
* module: { exports: unknown },
87+
* moduleExports: unknown,
88+
* afterExecute: () => void,
89+
* require: (specifier: string) => unknown,
90+
* importFn: (specifier: string) => Promise<unknown>,
9091
* }}
9192
*/
9293
export const wrap = ({
@@ -204,6 +205,15 @@ export const wrap = ({
204205

205206
freeze(require);
206207

208+
/** @param {string} importSpecifier */
209+
const importFn = async importSpecifier => {
210+
const specifier = has(resolvedImports, importSpecifier)
211+
? resolvedImports[importSpecifier]
212+
: importSpecifier;
213+
return compartment.import(specifier);
214+
};
215+
freeze(importFn);
216+
207217
const afterExecute = () => {
208218
const finalExports = module.exports; // in case it's a getter, only call it once
209219
const exportsHaveBeenOverwritten = finalExports !== originalExports;
@@ -231,5 +241,6 @@ export const wrap = ({
231241
moduleExports: originalExports,
232242
afterExecute,
233243
require,
244+
importFn,
234245
};
235246
};

packages/compartment-mapper/test/cjs-compat.test.js

Lines changed: 97 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,14 @@ import 'ses';
66
import test from 'ava';
77
import path from 'path';
88
import { scaffold } from './scaffold.js';
9+
import {
10+
defaultParserForLanguage,
11+
parserForLanguageWithCjsBabel,
12+
} from '../src/import-parsers.js';
913

1014
/**
1115
* @import {FixtureAssertionFn} from './test.types.js';
16+
* @import {ThirdPartyStaticModuleInterface} from 'ses'
1217
*/
1318

1419
const fixture = new URL(
@@ -19,9 +24,13 @@ const fixtureDirname = new URL(
1924
'fixtures-cjs-compat/node_modules/app/dirname.js',
2025
import.meta.url,
2126
).toString();
27+
const fixtureDynamicImport = new URL(
28+
'fixtures-cjs-compat/node_modules/dynamic-import/index.js',
29+
import.meta.url,
30+
).toString();
2231

2332
const q = JSON.stringify;
24-
33+
const { freeze } = Object;
2534
/**
2635
* @type {FixtureAssertionFn<{requireResolvePaths: string[]}>}
2736
*/
@@ -70,50 +79,94 @@ const assertFixture = (t, { namespace, testCategoryHint }) => {
7079

7180
const fixtureAssertionCount = 2;
7281

73-
scaffold(
74-
'fixtures-cjs-compat',
75-
test,
76-
fixture,
77-
assertFixture,
78-
fixtureAssertionCount,
79-
);
82+
const parsersForLanguage = {
83+
default: defaultParserForLanguage,
84+
babel: parserForLanguageWithCjsBabel,
85+
};
86+
87+
for (const [name, parserForLanguage] of Object.entries(parsersForLanguage)) {
88+
scaffold(
89+
`fixtures-cjs-compat-${name}`,
90+
test,
91+
fixture,
92+
assertFixture,
93+
fixtureAssertionCount,
94+
{
95+
parserForLanguage,
96+
},
97+
);
8098

81-
// Exit module errors are also deferred
82-
scaffold(
83-
'fixtures-cjs-compat-exit-module',
84-
test,
85-
fixture,
86-
assertFixture,
87-
fixtureAssertionCount,
88-
{
89-
additionalOptions: {
90-
importHook: async specifier => {
91-
throw Error(`${q(specifier)} is NOT an exit module.`);
99+
// Exit module errors are also deferred
100+
scaffold(
101+
`fixtures-cjs-compat-exit-module-${name}`,
102+
test,
103+
fixture,
104+
assertFixture,
105+
fixtureAssertionCount,
106+
{
107+
additionalOptions: {
108+
importHook: async specifier => {
109+
throw Error(`${q(specifier)} is NOT an exit module.`);
110+
},
92111
},
112+
parserForLanguage,
93113
},
94-
},
95-
);
114+
);
96115

97-
scaffold(
98-
'fixtures-cjs-compat-__dirname',
99-
test,
100-
fixtureDirname,
101-
(t, { namespace, testCategoryHint }) => {
102-
if (testCategoryHint === 'Location') {
103-
const { __filename, __dirname } = namespace;
104-
t.is(__filename, path.join(__dirname, '/dirname.js'));
105-
t.assert(!__dirname.startsWith('file://'));
106-
t.notRegex(
107-
__dirname,
108-
/[\\/]$/,
109-
'Expected __dirname to NOT have a trailing slash',
110-
);
111-
} else {
112-
const { __filename, __dirname } = namespace;
113-
t.is(__dirname, null);
114-
t.is(__filename, null);
115-
t.pass();
116-
}
117-
},
118-
3,
119-
);
116+
scaffold(
117+
`fixtures-cjs-compat-__dirname-${name}`,
118+
test,
119+
fixtureDirname,
120+
(t, { namespace, testCategoryHint }) => {
121+
if (testCategoryHint === 'Location') {
122+
const { __filename, __dirname } = namespace;
123+
t.is(__filename, path.join(__dirname, '/dirname.js'));
124+
t.assert(!__dirname.startsWith('file://'));
125+
t.notRegex(
126+
__dirname,
127+
/[\\/]$/,
128+
'Expected __dirname to NOT have a trailing slash',
129+
);
130+
} else {
131+
const { __filename, __dirname } = namespace;
132+
t.is(__dirname, null);
133+
t.is(__filename, null);
134+
t.pass();
135+
}
136+
},
137+
3,
138+
{
139+
parserForLanguage,
140+
},
141+
);
142+
143+
scaffold(
144+
`fixtures-cjs-compat-dynamic-import-${name}`,
145+
test,
146+
fixtureDynamicImport,
147+
async (t, { namespace }) => {
148+
const { namespace: dynamicNamespace } =
149+
// @ts-expect-error - untyped
150+
await namespace.dynamicImport('a');
151+
t.is(dynamicNamespace.foo, 'foo');
152+
},
153+
1,
154+
{
155+
// NOTE: this should fail with parse-cjs, but not parse-cjs-babel
156+
knownFailure: name === 'default',
157+
parserForLanguage,
158+
additionalOptions: {
159+
importHook: async () => {
160+
/** @type {ThirdPartyStaticModuleInterface} */
161+
return freeze({
162+
imports: [],
163+
exports: ['foo'],
164+
execute: moduleExports => {
165+
moduleExports.foo = 'foo';
166+
},
167+
});
168+
},
169+
},
170+
},
171+
);
172+
}

packages/compartment-mapper/test/fixtures-cjs-compat/node_modules/dynamic-import/index.js

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)