Skip to content

Commit ffc0a88

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`. Rolldown — the bundler Vite 8 uses by default — 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`, after the client bundle had already succeeded. Parse `moduleInfo.code` with the plugin context's own `parse` instead. Rolldown withholds only the pre-built AST; the source and the resolved dependency IDs are both still available. Using the context's parser rather than bundling one keeps this correct by construction: the bundler already parsed this exact source to compute `importedIds`, so whatever it accepted parses here too. It also means no TypeScript-capable parser is needed, since `moduleParsed` runs after `transform` — 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. No new dependency, and `this.parse` returns a node that is directly assignable, so the previous `as BaseNode` cast goes away. Filtering with `shouldTraverseCollectedModule` moves ahead of the parse. `createParsedModuleRecord` applies the same predicate, but only once an AST exists — checking first avoids parsing every `node_modules` module just to discard the result, which reading the pre-built AST never cost. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 1d131e2 commit ffc0a88

5 files changed

Lines changed: 131 additions & 52 deletions

File tree

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

packages/plugins/apps/src/vite/backend-module-graph-collector.test.ts

Lines changed: 68 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,40 +6,52 @@ import { parseAst } from 'rollup/parseAst';
66

77
import { createBackendModuleGraphCollector } from './backend-module-graph-collector';
88

9+
type FakeModuleInfo = { id: string; code?: string | null };
10+
11+
/**
12+
* Invokes the hook with a plugin context exposing `parse`, which is where it gets
13+
* its parser. `rollup/parseAst` is what Rollup's real context uses.
14+
*/
15+
const getEmit = (collector: ReturnType<typeof createBackendModuleGraphCollector>) => {
16+
const moduleParsed = collector.plugin.moduleParsed as (
17+
this: { parse: typeof parseAst },
18+
moduleInfo: unknown,
19+
) => void;
20+
21+
// Fills the remaining fields in place rather than spreading into a new
22+
// object: a spread would drop (or trigger) an `ast` getter, and one case
23+
// below depends on that getter surviving intact.
24+
return (moduleInfo: FakeModuleInfo, importedIds: string[] = []) =>
25+
moduleParsed.call(
26+
{ parse: parseAst },
27+
Object.assign(moduleInfo, {
28+
importedIds,
29+
importedIdResolutions: importedIds.map((id) => ({ id })),
30+
}),
31+
);
32+
};
33+
934
describe('Backend Functions - backend module graph collector', () => {
10-
test('Should collect parsed local module records from Rollup moduleParsed hooks', () => {
35+
test('Should collect parsed local module records from moduleParsed hooks', () => {
1136
const collector = createBackendModuleGraphCollector('/project');
12-
const moduleParsed = collector.plugin.moduleParsed as (moduleInfo: unknown) => void;
13-
14-
moduleParsed({
15-
id: '/project/src/backend/actions.backend.js?import',
16-
ast: parseAst(`
17-
import { getEcho } from './helpers/http.js';
18-
export function run() {
19-
return getEcho();
20-
}
21-
`),
22-
importedIds: ['/project/src/backend/helpers/http.js?import'],
23-
importedIdResolutions: [{ id: '/project/src/backend/helpers/http.js?import' }],
24-
});
25-
moduleParsed({
26-
id: '/project/node_modules/package/index.js',
27-
ast: parseAst('export const value = true;'),
28-
importedIds: [],
29-
importedIdResolutions: [],
30-
});
31-
moduleParsed({
32-
id: '\0virtual-helper.js',
33-
ast: parseAst('export const value = true;'),
34-
importedIds: [],
35-
importedIdResolutions: [],
36-
});
37-
moduleParsed({
38-
id: 'virtual:dd-backend-dev:example.js',
39-
ast: parseAst('export const value = true;'),
40-
importedIds: [],
41-
importedIdResolutions: [],
42-
});
37+
const emit = getEmit(collector);
38+
39+
emit(
40+
{
41+
id: '/project/src/backend/actions.backend.js?import',
42+
code: `
43+
import { getEcho } from './helpers/http.js';
44+
export function run() {
45+
return getEcho();
46+
}
47+
`,
48+
},
49+
['/project/src/backend/helpers/http.js?import'],
50+
);
51+
emit({ id: '/project/node_modules/package/index.js', code: 'export const value = true;' });
52+
emit({ id: '\0virtual-helper.js', code: 'export const value = true;' });
53+
emit({ id: 'virtual:dd-backend-dev:example.js', code: 'export const value = true;' });
54+
emit({ id: '/project/src/backend/external.js', code: null });
4355

4456
expect([...collector.getModuleRecords().keys()]).toEqual([
4557
'/project/src/backend/actions.backend.js',
@@ -55,4 +67,28 @@ describe('Backend Functions - backend module graph collector', () => {
5567
],
5668
});
5769
});
70+
71+
test('Should collect records under a bundler that does not support ModuleInfo#ast', () => {
72+
const collector = createBackendModuleGraphCollector('/project');
73+
const emit = getEmit(collector);
74+
75+
// Rolldown, the bundler Vite 8 uses by default, keeps `ast` on its
76+
// Rollup-compat object but stubs the getter to throw. Reading the
77+
// property at all is the failure, so it must throw rather than be absent.
78+
const moduleInfo: FakeModuleInfo = Object.defineProperty(
79+
{ id: '/project/src/backend/actions.backend.ts', code: 'export const id = "conn-1";' },
80+
'ast',
81+
{
82+
get() {
83+
throw new Error('UNSUPPORTED: ModuleInfo#ast');
84+
},
85+
enumerable: true,
86+
},
87+
);
88+
89+
expect(() => emit(moduleInfo)).not.toThrow();
90+
expect([...collector.getModuleRecords().keys()]).toEqual([
91+
'/project/src/backend/actions.backend.ts',
92+
]);
93+
});
5894
});

packages/plugins/apps/src/vite/backend-module-graph-collector.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22
// This product includes software developed at Datadog (https://www.datadoghq.com/).
33
// Copyright 2019-Present Datadog, Inc.
44

5-
import type { BaseNode } from 'estree';
65
import type { ModuleInfo } from 'rollup';
76
import type { Plugin } from 'vite';
87

98
import {
109
createParsedModuleRecord,
1110
type ParsedModuleRecord,
11+
shouldTraverseCollectedModule,
1212
} from '../backend/ast-parsing/module-graph';
1313

1414
const VIRTUAL_MODULE_ID_RE = /^(?:\0|virtual:)/;
@@ -30,10 +30,32 @@ export function createBackendModuleGraphCollector(buildRoot: string): BackendMod
3030
return;
3131
}
3232

33+
// `createParsedModuleRecord` applies this same predicate, but
34+
// only after the AST exists. Checking it here keeps us from
35+
// parsing every `node_modules` module just to discard it.
36+
if (!shouldTraverseCollectedModule(moduleId, buildRoot)) {
37+
return;
38+
}
39+
40+
// Parse the source instead of reading `moduleInfo.ast`: Rolldown,
41+
// the bundler Vite 8 uses by default, stubs that getter to throw
42+
// `UNSUPPORTED: ModuleInfo#ast`. `code` is null for external and
43+
// synthetic modules.
44+
//
45+
// `this.parse` is the bundler's own parser, which already parsed
46+
// this exact source to compute `importedIds` — so whatever the
47+
// bundler accepted parses here too, and no TypeScript-capable
48+
// parser is needed (`moduleParsed` runs after `transform`, so
49+
// types and JSX are already gone).
50+
if (typeof moduleInfo.code !== 'string') {
51+
return;
52+
}
53+
54+
const parsed = this.parse(moduleInfo.code);
3355
const record = createParsedModuleRecord(
3456
moduleId,
3557
buildRoot,
36-
moduleInfo.ast as BaseNode,
58+
parsed,
3759
getStaticDependencyIds(moduleInfo).map(normalizeViteModuleId),
3860
);
3961
if (!record) {

packages/plugins/apps/src/vite/dev-server.test.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -113,17 +113,24 @@ function mockBuildResult(code: string) {
113113
}
114114

115115
function emitModuleParsed(
116-
config: { plugins?: Array<{ moduleParsed?: (moduleInfo: unknown) => void }> },
116+
config: {
117+
plugins?: Array<{
118+
moduleParsed?: (this: { parse: typeof parseAst }, moduleInfo: unknown) => void;
119+
}>;
120+
},
117121
id: string,
118122
code: string,
119123
importedIds: string[] = [],
120124
) {
121125
for (const plugin of config.plugins ?? []) {
122-
plugin.moduleParsed?.({
123-
id,
124-
ast: parseAst(code),
125-
importedIds,
126-
});
126+
plugin.moduleParsed?.call(
127+
{ parse: parseAst },
128+
{
129+
id,
130+
code,
131+
importedIds,
132+
},
133+
);
127134
}
128135
}
129136

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

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,16 +48,23 @@ function mockBuildResult() {
4848
}
4949

5050
function emitModuleParsed(
51-
config: { plugins?: Array<{ moduleParsed?: (moduleInfo: unknown) => void }> },
51+
config: {
52+
plugins?: Array<{
53+
moduleParsed?: (this: { parse: typeof parseAst }, moduleInfo: unknown) => void;
54+
}>;
55+
},
5256
id: string,
5357
code: string,
5458
) {
5559
for (const plugin of config.plugins ?? []) {
56-
plugin.moduleParsed?.({
57-
id,
58-
ast: parseAst(code),
59-
importedIds: [],
60-
});
60+
plugin.moduleParsed?.call(
61+
{ parse: parseAst },
62+
{
63+
id,
64+
code,
65+
importedIds: [],
66+
},
67+
);
6168
}
6269
}
6370

0 commit comments

Comments
 (0)