Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions packages/plugins/apps/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,14 @@
"dependencies": {
"@dd/core": "workspace:*",
"chalk": "2.3.1",
"estree-walker": "2.0.2",
"glob": "11.1.0",
"jszip": "3.10.1",
"pretty-bytes": "5.6.0"
"pretty-bytes": "5.6.0",
"vite": "6.3.5"
},
"devDependencies": {
"@types/estree": "1.0.8",
"typescript": "5.4.3",
"vite": "6.3.5"
"typescript": "5.4.3"
}
}
208 changes: 169 additions & 39 deletions packages/plugins/apps/src/backend/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2019-Present Datadog, Inc.

import type { Declaration, Expression, Program } from 'estree';
import type { Declaration, Expression, Node, Program } from 'estree';
import type { AstNode } from 'rollup';

export interface BackendFunction {
Expand All @@ -12,34 +12,60 @@ export interface BackendFunction {
name: string;
/** Absolute path to the .backend.ts source file */
absolutePath: string;
/** Connection IDs statically extracted from `request({ connectionId })` call sites. */
allowedConnectionIds: string[];
}

/**
* Extract exported value (non-type) symbols from an ESTree AST.
* Expects plain JavaScript — TypeScript types must already be stripped
* (e.g. by Vite's built-in esbuild transform that runs before our hook).
* Describes a single named export from a backend file, with enough information
* to locate the function body (for static analysis) or identify a re-exported
* import (whose body lives in another module).
*
* Throws on invalid exports (e.g. default exports) and unexpected AST shapes.
* Returns an empty array when the file has no named exports.
*
* @param ast - AstNode from `this.parse()` in unplugin's transform hook
* @param filePath - Path to the source file (used in error messages)
* The two `body`-carrying variants differ only in origin — both resolve to a
* function body we can scan in this file — so they share a single kind.
* `imported` has no locally-visible body; callers must handle that case.
*/
function isProgramNode(node: AstNode): node is AstNode & Program {
return node.type === 'Program';
}
export type ExportedBinding =
| {
/** `export function foo() {}` or `function foo() {}; export { foo }` or arrow-const equivalents */
kind: 'local';
name: string;
body: Node;
}
| {
/** `import { foo } from './x'; export { foo }` — body is in another module */
kind: 'imported';
name: string;
/** Module specifier, e.g. `'./handlers'` */
source: string;
/** The remote name being imported (may differ from `name` when aliased) */
imported: string;
};

export function extractExportedFunctions(ast: AstNode, filePath: string): string[] {
/**
* Enumerate every named export in a backend file along with the information
* needed to locate its implementation. Validates that each export is function-like
* and rejects unsupported shapes (default exports, `export *`, class exports,
* non-callable variable exports, destructured exports).
*
* Expects plain JavaScript — TypeScript types must already be stripped.
*
* This is the single source of truth for "what backend export shapes are
* supported." Both name discovery and connection-id extraction consume it so
* support for a new shape only has to be added here.
*/
export function enumerateBackendExports(ast: AstNode, filePath: string): ExportedBinding[] {
if (!isProgramNode(ast)) {
throw new Error(
`Expected a Program node from this.parse() for ${filePath}, got ${ast.type}`,
);
}

// Build a map of top-level declarations so we can validate export specifiers.
// Map of top-level declarations keyed by local name, used both to validate
// specifier exports and to locate bodies for `function foo(){}; export { foo }`.
const declarations = buildDeclarationMap(ast);

const names: string[] = [];
const bindings: ExportedBinding[] = [];
for (const node of ast.body) {
// handles: export default ...
if (node.type === 'ExportDefaultDeclaration') {
Expand All @@ -59,7 +85,7 @@ export function extractExportedFunctions(ast: AstNode, filePath: string): string

// handles: export function add() {} / export const add = ...
if (node.declaration) {
names.push(...namesFromDeclaration(node.declaration, filePath));
bindings.push(...bindingsFromDeclaration(node.declaration, filePath));
}

for (const spec of node.specifiers) {
Expand All @@ -72,17 +98,39 @@ export function extractExportedFunctions(ast: AstNode, filePath: string): string
`Default exports are not supported in .backend.ts files. Use a named export instead: ${filePath}`,
);
}
// Validate specifier binding is callable when we can resolve it.
// e.g. `const VERSION = '1.0'; export { VERSION };` — rejected
// e.g. `function add() {}; export { add };` — allowed
if (spec.local.type === 'Identifier') {
validateSpecifierBinding(spec.local.name, declarations, filePath);
if (spec.local.type !== 'Identifier') {
continue;
}
// Re-export from another module: `export { X } from './foo'` / `export { Y as X } from './foo'`.
// Not currently surfaced as a separate kind — treat as imported so consumers can log
// and skip. If we ever need to distinguish, add a new kind here.
if (node.source && typeof node.source.value === 'string') {
bindings.push({
kind: 'imported',
name: spec.exported.name,
source: node.source.value,
imported: spec.local.name,
});
continue;
}
// handles: export { add, multiply }
names.push(spec.exported.name);
const info = declarations.get(spec.local.name);
validateSpecifierBinding(spec.local.name, info, filePath);
bindings.push(bindingFromDeclInfo(spec.exported.name, spec.local.name, info));
}
}
return names;
return bindings;
}

/**
* Back-compat name-only view used by callers that just want the list of
* exported names (e.g. proxy codegen, logging).
*/
export function extractExportedFunctions(ast: AstNode, filePath: string): string[] {
return enumerateBackendExports(ast, filePath).map((b) => b.name);
}

function isProgramNode(node: AstNode): node is AstNode & Program {
return node.type === 'Program';
}

/** Init types that are definitively non-callable at runtime. */
Expand All @@ -104,14 +152,14 @@ function isNonCallableInit(init: Expression | null | undefined): boolean {
}

/**
* Extract identifier names from an exported declaration node.
* Extract bindings from an exported declaration node.
* Handles `export function foo()` and `export const foo = ...` forms.
* Throws when a variable export has a non-callable initializer.
*/
function namesFromDeclaration(decl: Declaration, filePath: string): string[] {
function bindingsFromDeclaration(decl: Declaration, filePath: string): ExportedBinding[] {
// export function add(a, b) { return a + b; }
if (decl.type === 'FunctionDeclaration' && decl.id) {
return [decl.id.name];
return [{ kind: 'local', name: decl.id.name, body: decl.body }];
}
// export class MyClass {} — classes are not callable as RPC endpoints
if (decl.type === 'ClassDeclaration') {
Expand All @@ -120,7 +168,7 @@ function namesFromDeclaration(decl: Declaration, filePath: string): string[] {
);
}
if (decl.type === 'VariableDeclaration') {
return decl.declarations.flatMap((d) => {
return decl.declarations.flatMap((d): ExportedBinding[] => {
// export const { a, b } = obj;
// export const [a, b] = arr;
if (d.id.type !== 'Identifier') {
Expand All @@ -136,8 +184,34 @@ function namesFromDeclaration(decl: Declaration, filePath: string): string[] {
);
}
// export const add = (a, b) => a + b;
// export const handler = importedFn; — ambiguous, allowed
return [d.id.name];
if (
d.init &&
(d.init.type === 'ArrowFunctionExpression' || d.init.type === 'FunctionExpression')
) {
return [{ kind: 'local', name: d.id.name, body: d.init.body }];
}
// export const handler = importedFn; — ambiguous, conservatively treat as imported
// so connection-id extraction can skip/log without failing the build.
if (d.init && d.init.type === 'Identifier') {
return [
{
kind: 'imported',
name: d.id.name,
// We don't know the original source here; leaving blank signals "local relay".
source: '<local-alias>',
imported: d.init.name,
},
];
}
// Other ambiguous forms (CallExpression, etc.): treat as imported/opaque.
return [
{
kind: 'imported',
name: d.id.name,
source: '<opaque>',
imported: d.id.name,
},
];
});
}
throw new Error(
Expand All @@ -151,19 +225,22 @@ function namesFromDeclaration(decl: Declaration, filePath: string): string[] {
* 'class' is rejected. 'variable' is checked via its initializer.
*/
type DeclInfo =
| { kind: 'function' | 'import' | 'class' }
| { kind: 'variable'; init: Expression | null | undefined };
| { kind: 'function'; body: Node }
| { kind: 'class' }
| { kind: 'variable'; init: Expression | null | undefined }
| { kind: 'import'; source: string; imported: string };

/**
* Build a map from identifier name → declaration info for all top-level
* statements. Used to validate `export { name }` specifiers.
* statements. Used both to validate `export { name }` specifiers and to
* locate function bodies for specifier-form exports.
*/
function buildDeclarationMap(ast: Program): Map<string, DeclInfo> {
const map = new Map<string, DeclInfo>();
for (const node of ast.body) {
if (node.type === 'FunctionDeclaration' && node.id) {
// handles: function add(a, b) { return a + b; }
map.set(node.id.name, { kind: 'function' });
map.set(node.id.name, { kind: 'function', body: node.body });
} else if (node.type === 'ClassDeclaration' && node.id) {
// handles: class MyService {}
map.set(node.id.name, { kind: 'class' });
Expand All @@ -174,11 +251,25 @@ function buildDeclarationMap(ast: Program): Map<string, DeclInfo> {
map.set(d.id.name, { kind: 'variable', init: d.init });
}
}
} else if (node.type === 'ImportDeclaration') {
} else if (node.type === 'ImportDeclaration' && typeof node.source.value === 'string') {
// handles: import { handler } from './other';
// For this case, we allow exporting handler and accept that it may not be a function.
const source = node.source.value;
for (const spec of node.specifiers) {
map.set(spec.local.name, { kind: 'import' });
if (spec.type === 'ImportSpecifier') {
const imported =
spec.imported.type === 'Identifier'
? spec.imported.name
: String(spec.imported.value);
map.set(spec.local.name, { kind: 'import', source, imported });
} else {
// Default / namespace imports: we allow exporting them but can't
// statically locate a function body.
map.set(spec.local.name, {
kind: 'import',
source,
imported: spec.local.name,
});
}
}
}
}
Expand All @@ -192,10 +283,9 @@ function buildDeclarationMap(ast: Program): Map<string, DeclInfo> {
*/
function validateSpecifierBinding(
localName: string,
declarations: Map<string, DeclInfo>,
info: DeclInfo | undefined,
filePath: string,
): void {
const info = declarations.get(localName);
if (!info) {
// Unresolved — could come from a pattern we don't track. Allow it.
return;
Expand All @@ -211,3 +301,43 @@ function validateSpecifierBinding(
);
}
}

/**
* Turn a resolved specifier binding into an {@link ExportedBinding}. Called
* after {@link validateSpecifierBinding}, so opaque/rejected shapes won't
* reach this point.
*/
function bindingFromDeclInfo(
exportedName: string,
localName: string,
info: DeclInfo | undefined,
): ExportedBinding {
if (info?.kind === 'function') {
return { kind: 'local', name: exportedName, body: info.body };
}
if (info?.kind === 'variable' && info.init) {
if (
info.init.type === 'ArrowFunctionExpression' ||
info.init.type === 'FunctionExpression'
) {
return { kind: 'local', name: exportedName, body: info.init.body };
}
}
if (info?.kind === 'import') {
return {
kind: 'imported',
name: exportedName,
source: info.source,
imported: info.imported,
};
}
// Ambiguous (e.g. `const handler = someCall(); export { handler }`) or a
// binding we can't resolve — treat as imported/opaque so connection-id
// extraction skips it rather than failing the build.
return {
kind: 'imported',
name: exportedName,
source: '<opaque>',
imported: localName,
};
}
Loading
Loading