Skip to content

Commit 7cb8dba

Browse files
sdkennedy2claude
andcommitted
fix(apps): parse backend module source instead of reading ModuleInfo#ast
The Apps backend module-graph collector read `moduleInfo.ast` in `moduleParsed`, coupling it to whatever AST the active bundler exposes. Rolldown — the bundler Vite 8 uses by default — deliberately stubs that getter to throw `UNSUPPORTED: ModuleInfo#ast`, so any app with a `.backend.ts` built under Vite 8 (or `rolldown-vite` on Vite 7) failed in `closeBundle` with an opaque error, after the client bundle had already succeeded. Parse `moduleInfo.code` instead, using the plugin context's own `parse`. A bundler then only has to supply the module's source and its resolved dependency IDs, neither of which Rolldown withholds. Using the context's parser rather than bundling one keeps this correct by construction: the bundler must already have parsed this exact source to compute `importedIds`, so anything it accepted, its own parser accepts here. A third-party parser could disagree with the bundler; its own cannot. Verified `this.parse` is available in `moduleParsed` under both Rollup and Rolldown 1.1.5. It also means no TypeScript-capable parser is needed. `moduleParsed` runs after `transform`, so types and JSX are already gone — verified against a real Vite 8.1.5 + Rolldown 1.1.5 build, where `'x' as string` arrives as `"x"` and `<div/>` arrives as a `_jsx(...)` call. Two alternatives were measured and rejected: - Bundling `@typescript-eslint/typescript-estree`. It eagerly requires `typescript`, which it declares only as an optional peer, so it would have needed a deferred require to avoid dragging `typescript` into every consumer of every published plugin — and it added a dependency to all five of them for capability this path never exercises. - Injecting Vite's `parseAst` export. Vite maps its `require` condition to a reduced CJS entry that omits `parseAst` entirely, so it is unavailable to consumers of the plugin's CJS build. Also filters before parsing, so `node_modules` files never reach the parser, and skips modules whose `code` is absent rather than passing a non-string to it. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 9797d6e commit 7cb8dba

8 files changed

Lines changed: 420 additions & 53 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License.
2+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
3+
// Copyright 2019-Present Datadog, Inc.
4+
5+
import { parseAst } from 'rollup/parseAst';
6+
7+
import { parseBackendModule } from './parse-module';
8+
9+
describe('Backend Functions - parseBackendModule', () => {
10+
// `rollup/parseAst` is what Vite 5-7 re-exports as `parseAst`, so it is also
11+
// the realistic injected parser here.
12+
const cases = [
13+
{
14+
description: 'parse an ES module',
15+
code: `import { a } from './a'; export const b = a;`,
16+
expectedBodyTypes: ['ImportDeclaration', 'ExportNamedDeclaration'],
17+
},
18+
{
19+
description: 'parse transpiled JSX, which arrives as plain calls',
20+
code: `import { jsx as _jsx } from "react/jsx-runtime"; export const el = _jsx("div", {});`,
21+
expectedBodyTypes: ['ImportDeclaration', 'ExportNamedDeclaration'],
22+
},
23+
];
24+
25+
test.each(cases)('Should $description', ({ code, expectedBodyTypes }) => {
26+
const program = parseBackendModule(parseAst, code, '/project/a.js');
27+
28+
expect(program.type).toBe('Program');
29+
expect(program.body.map((node) => node.type)).toEqual(expectedBodyTypes);
30+
});
31+
32+
test('Should surface the parser error when the source cannot be parsed', () => {
33+
expect(() => parseBackendModule(parseAst, 'const = ;', '/project/broken.js')).toThrow();
34+
});
35+
36+
test('Should reject parser output that is not a Program', () => {
37+
const notAProgram = () => ({ type: 'ExpressionStatement' });
38+
39+
expect(() => parseBackendModule(notAProgram, 'a;', '/project/a.js')).toThrow(
40+
'Expected a Program node for /project/a.js, got ExpressionStatement',
41+
);
42+
});
43+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License.
2+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
3+
// Copyright 2019-Present Datadog, Inc.
4+
5+
import type { Program } from 'estree';
6+
7+
import { ensureProgram } from './type-guards';
8+
9+
/**
10+
* Parses a module's source into an ESTree `Program`.
11+
*
12+
* Structurally compatible with the plugin context's `parse`, which is what gets
13+
* passed in. Declared here rather than imported from Rollup so this stays usable
14+
* from the bundler-agnostic `backend/` layer, and so the same type covers
15+
* Rollup's and Rolldown's separately-declared context types.
16+
*/
17+
export type ParseAst = (code: string) => unknown;
18+
19+
/**
20+
* Parses a backend module's source, instead of reading the bundler's
21+
* `ModuleInfo#ast`.
22+
*
23+
* Rolldown — the bundler Vite 8 uses by default — stubs that getter to throw
24+
* `UNSUPPORTED: ModuleInfo#ast`. Parsing `moduleInfo.code` instead means a
25+
* bundler only has to supply the module's source and its resolved dependency
26+
* IDs, neither of which Rolldown withholds.
27+
*
28+
* The parser is a parameter rather than a bundled dependency, and the collector
29+
* supplies the plugin context's own `parse`. That keeps this correct by
30+
* construction: the bundler must already have parsed this exact source to
31+
* compute `importedIds`, so anything it accepted, its own parser accepts here.
32+
* A third-party parser could disagree with the bundler; its own cannot.
33+
*
34+
* It also means no TypeScript-capable parser is needed. `moduleParsed` runs
35+
* after `transform`, so types and JSX are already gone — verified on a real
36+
* Vite 8 + Rolldown build, where `'x' as string` arrives as `"x"` and `<div/>`
37+
* arrives as a `_jsx(...)` call.
38+
*/
39+
export function parseBackendModule(parseAst: ParseAst, code: string, moduleId: string): Program {
40+
const program = parseAst(code);
41+
return ensureProgram(program, moduleId);
42+
}

packages/plugins/apps/src/backend/ast-parsing/type-guards.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,29 @@ interface TypeScriptImportExportMetadata {
1313

1414
type TypeOnlyAwareNode = BaseNode & TypeScriptImportExportMetadata;
1515

16-
export function ensureProgram(node: BaseNode, filePath: string): Program {
16+
/**
17+
* Accepts `unknown` rather than `BaseNode` so it can also narrow the output of
18+
* parsers whose node types are declared separately from `estree` (the
19+
* TypeScript-ESTree `Program` is structurally an `estree` `Program`, but is not
20+
* the same declared type). The runtime check is what establishes the shape, so
21+
* callers get a validated `estree.Program` without a type assertion.
22+
*/
23+
export function ensureProgram(node: unknown, filePath: string): Program {
1724
if (!isProgramNode(node)) {
18-
throw new Error(
19-
`Expected a Program node from this.parse() for ${filePath}, got ${node.type}`,
20-
);
25+
throw new Error(`Expected a Program node for ${filePath}, got ${describeNodeType(node)}`);
2126
}
2227
return node;
2328
}
2429

25-
export function isProgramNode(node: BaseNode): node is Program {
26-
return node.type === 'Program';
30+
export function isProgramNode(node: unknown): node is Program {
31+
return typeof node === 'object' && node !== null && 'type' in node && node.type === 'Program';
32+
}
33+
34+
function describeNodeType(node: unknown): string {
35+
if (typeof node === 'object' && node !== null && 'type' in node) {
36+
return String(node.type);
37+
}
38+
return String(node);
2739
}
2840

2941
export function isStringLiteral(node: unknown): node is StringLiteral {

packages/plugins/apps/src/index.test.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,17 +41,24 @@ function extractViteTransform(plugins: PluginOptions[]) {
4141
}
4242

4343
function emitModuleParsed(
44-
config: { plugins?: Array<{ moduleParsed?: (moduleInfo: unknown) => void }> },
44+
config: {
45+
plugins?: Array<{
46+
moduleParsed?: (this: { parse: typeof parseAst }, moduleInfo: unknown) => void;
47+
}>;
48+
},
4549
id: string,
4650
code: string,
4751
importedIds: string[] = [],
4852
) {
4953
for (const plugin of config.plugins ?? []) {
50-
plugin.moduleParsed?.({
51-
id,
52-
ast: parseAst(code),
53-
importedIds,
54-
});
54+
plugin.moduleParsed?.call(
55+
{ parse: parseAst },
56+
{
57+
id,
58+
code,
59+
importedIds,
60+
},
61+
);
5562
}
5663
}
5764

0 commit comments

Comments
 (0)