Skip to content

Commit e3d487d

Browse files
committed
Refactor backend discovery AST parsing
1 parent c692b4f commit e3d487d

12 files changed

Lines changed: 268 additions & 219 deletions

File tree

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
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 { Declaration, Expression, Program } from 'estree';
6+
import type { AstNode } from 'rollup';
7+
8+
import type { BackendExport } from './types';
9+
import { isProgramNode } from './type-guards';
10+
11+
/**
12+
* Extract exported value (non-type) symbols from an ESTree AST.
13+
* Expects plain JavaScript — TypeScript types must already be stripped
14+
* (e.g. by Vite's built-in esbuild transform that runs before our hook).
15+
*
16+
* Throws on invalid exports (e.g. default exports) and unexpected AST shapes.
17+
* Returns an empty array when the file has no named exports.
18+
*
19+
* @param ast - AstNode from `this.parse()` in unplugin's transform hook
20+
* @param filePath - Path to the source file (used in error messages)
21+
*/
22+
export function extractExportedFunctions(ast: AstNode, filePath: string): string[] {
23+
return enumerateBackendExports(ast, filePath).map((backendExport) => backendExport.name);
24+
}
25+
26+
export function enumerateBackendExports(ast: AstNode, filePath: string): BackendExport[] {
27+
if (!isProgramNode(ast)) {
28+
throw new Error(
29+
`Expected a Program node from this.parse() for ${filePath}, got ${ast.type}`,
30+
);
31+
}
32+
33+
// Build a map of top-level declarations so we can validate export specifiers.
34+
const declarations = buildDeclarationMap(ast);
35+
36+
const backendExports: BackendExport[] = [];
37+
for (const node of ast.body) {
38+
// handles: export default ...
39+
if (node.type === 'ExportDefaultDeclaration') {
40+
throw new Error(
41+
`Default exports are not supported in .backend.ts files. Use a named export instead: ${filePath}`,
42+
);
43+
}
44+
// handles: export * from '...'
45+
if (node.type === 'ExportAllDeclaration') {
46+
throw new Error(
47+
`"export *" is not supported in .backend.ts files. Use explicit named exports instead: ${filePath}`,
48+
);
49+
}
50+
if (node.type !== 'ExportNamedDeclaration') {
51+
continue;
52+
}
53+
54+
// handles: export function add() {} / export const add = ...
55+
if (node.declaration) {
56+
backendExports.push(
57+
...namesFromDeclaration(node.declaration, filePath).map((name) => ({
58+
name,
59+
localName: name,
60+
})),
61+
);
62+
}
63+
64+
const source = typeof node.source?.value === 'string' ? node.source.value : undefined;
65+
for (const spec of node.specifiers) {
66+
if (spec.exported.type !== 'Identifier') {
67+
continue;
68+
}
69+
// handles: export { add as default }
70+
if (spec.exported.name === 'default') {
71+
throw new Error(
72+
`Default exports are not supported in .backend.ts files. Use a named export instead: ${filePath}`,
73+
);
74+
}
75+
// Validate specifier binding is callable when we can resolve it.
76+
// e.g. `const VERSION = '1.0'; export { VERSION };` — rejected
77+
// e.g. `function add() {}; export { add };` — allowed
78+
if (spec.local.type === 'Identifier') {
79+
validateSpecifierBinding(spec.local.name, declarations, filePath);
80+
backendExports.push({
81+
name: spec.exported.name,
82+
localName: spec.local.name,
83+
source,
84+
});
85+
} else {
86+
backendExports.push({
87+
name: spec.exported.name,
88+
source,
89+
});
90+
}
91+
}
92+
}
93+
return backendExports;
94+
}
95+
96+
/** Init types that are definitively non-callable at runtime. */
97+
const NON_CALLABLE_INIT_TYPES = new Set([
98+
'ArrayExpression',
99+
'Literal',
100+
'ObjectExpression',
101+
'TemplateLiteral',
102+
]);
103+
104+
/**
105+
* Return `true` when the initializer is known to be non-callable.
106+
* `ArrowFunctionExpression` / `FunctionExpression` are clearly callable.
107+
* Ambiguous forms (`Identifier`, `CallExpression`, …) are allowed — the
108+
* user may legitimately re-export an imported function or a factory result.
109+
*/
110+
function isNonCallableInit(init: Expression | null | undefined): boolean {
111+
return init === null || init === undefined || NON_CALLABLE_INIT_TYPES.has(init.type);
112+
}
113+
114+
/**
115+
* Extract identifier names from an exported declaration node.
116+
* Handles `export function foo()` and `export const foo = ...` forms.
117+
* Throws when a variable export has a non-callable initializer.
118+
*/
119+
function namesFromDeclaration(decl: Declaration, filePath: string): string[] {
120+
// export function add(a, b) { return a + b; }
121+
if (decl.type === 'FunctionDeclaration' && decl.id) {
122+
return [decl.id.name];
123+
}
124+
// export class MyClass {} — classes are not callable as RPC endpoints
125+
if (decl.type === 'ClassDeclaration') {
126+
throw new Error(
127+
`Class exports are not supported in .backend.ts files. Only function exports are allowed: ${filePath}`,
128+
);
129+
}
130+
if (decl.type === 'VariableDeclaration') {
131+
return decl.declarations.flatMap((d) => {
132+
// export const { a, b } = obj;
133+
// export const [a, b] = arr;
134+
if (d.id.type !== 'Identifier') {
135+
throw new Error(
136+
`Destructured exports are not supported in backend files. Use individual named exports instead: ${filePath}`,
137+
);
138+
}
139+
// export const VERSION = '1.0'; — non-callable, throws
140+
// export const config = { ... }; — non-callable, throws
141+
if (isNonCallableInit(d.init)) {
142+
throw new Error(
143+
`Non-function export "${d.id.name}" in backend file ${filePath}. Only function exports are supported — use "export function ${d.id.name}(…) { }" instead.`,
144+
);
145+
}
146+
// export const add = (a, b) => a + b;
147+
// export const handler = importedFn; — ambiguous, allowed
148+
return [d.id.name];
149+
});
150+
}
151+
throw new Error(
152+
`Unsupported export declaration type "${decl.type}" in backend file ${filePath}. Only function and variable exports are allowed.`,
153+
);
154+
}
155+
156+
/**
157+
* Describes a top-level declaration for specifier validation.
158+
* 'function' and 'import' are always allowed (callable or ambiguous).
159+
* 'class' is rejected. 'variable' is checked via its initializer.
160+
*/
161+
type DeclInfo =
162+
| { kind: 'function' | 'import' | 'class' }
163+
| { kind: 'variable'; init: Expression | null | undefined };
164+
165+
/**
166+
* Build a map from identifier name → declaration info for all top-level
167+
* statements. Used to validate `export { name }` specifiers.
168+
*/
169+
function buildDeclarationMap(ast: Program): Map<string, DeclInfo> {
170+
const map = new Map<string, DeclInfo>();
171+
for (const node of ast.body) {
172+
if (node.type === 'FunctionDeclaration' && node.id) {
173+
// handles: function add(a, b) { return a + b; }
174+
map.set(node.id.name, { kind: 'function' });
175+
} else if (node.type === 'ClassDeclaration' && node.id) {
176+
// handles: class MyService {}
177+
map.set(node.id.name, { kind: 'class' });
178+
} else if (node.type === 'VariableDeclaration') {
179+
// handles: const add = (a, b) => a + b; / const VERSION = '1.0';
180+
for (const d of node.declarations) {
181+
if (d.id.type === 'Identifier') {
182+
map.set(d.id.name, { kind: 'variable', init: d.init });
183+
}
184+
}
185+
} else if (node.type === 'ImportDeclaration') {
186+
// handles: import { handler } from './other';
187+
// For this case, we allow exporting handler and accept that it may not be a function.
188+
for (const spec of node.specifiers) {
189+
map.set(spec.local.name, { kind: 'import' });
190+
}
191+
}
192+
}
193+
return map;
194+
}
195+
196+
/**
197+
* Validate that an export specifier's local binding is callable.
198+
* Throws for known non-callable bindings (classes, non-callable variables).
199+
* Allows unresolved bindings (e.g. from other export patterns) and imports.
200+
*/
201+
function validateSpecifierBinding(
202+
localName: string,
203+
declarations: Map<string, DeclInfo>,
204+
filePath: string,
205+
): void {
206+
const info = declarations.get(localName);
207+
if (!info) {
208+
// Unresolved — could come from a pattern we don't track. Allow it.
209+
return;
210+
}
211+
if (info.kind === 'class') {
212+
throw new Error(
213+
`Class exports are not supported in .backend.ts files. Only function exports are allowed: ${filePath}`,
214+
);
215+
}
216+
if (info.kind === 'variable' && isNonCallableInit(info.init)) {
217+
throw new Error(
218+
`Non-function export "${localName}" in backend file ${filePath}. Only function exports are supported — use "export function ${localName}(…) { }" instead.`,
219+
);
220+
}
221+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
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+
import type { AstNode } from 'rollup';
7+
8+
export function isProgramNode(node: AstNode): node is AstNode & Program {
9+
return node.type === 'Program';
10+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
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+
export interface BackendExport {
6+
name: string;
7+
localName?: string;
8+
source?: string;
9+
}

0 commit comments

Comments
 (0)