|
| 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 | +} |
0 commit comments