Skip to content

Commit 9bcbfc1

Browse files
committed
[APPS] Extract inline backend connection IDs
1 parent a91c48b commit 9bcbfc1

6 files changed

Lines changed: 768 additions & 31 deletions

File tree

packages/plugins/apps/src/backend/discovery.test.ts

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

5-
import { extractExportedFunctions } from '@dd/apps-plugin/backend/discovery';
5+
import {
6+
enumerateBackendExports,
7+
extractExportedFunctions,
8+
} from '@dd/apps-plugin/backend/discovery';
69
import type { Program } from 'estree';
710
import type { AstNode } from 'rollup';
811

@@ -460,4 +463,139 @@ describe('Backend Functions - extractExportedFunctions', () => {
460463
]);
461464
expect(extractExportedFunctions(ast, filePath)).toEqual(['add']);
462465
});
466+
467+
test('Should expose supported backend exports through enumerateBackendExports', () => {
468+
const ast = program([
469+
{
470+
type: 'FunctionDeclaration',
471+
id: { type: 'Identifier', name: 'localAdd' },
472+
params: [],
473+
body: { type: 'BlockStatement', body: [] },
474+
},
475+
{
476+
type: 'ImportDeclaration',
477+
specifiers: [
478+
{
479+
type: 'ImportSpecifier',
480+
local: { type: 'Identifier', name: 'importedHandler' },
481+
imported: { type: 'Identifier', name: 'handler' },
482+
},
483+
],
484+
source: { type: 'Literal', value: './handler' },
485+
attributes: [],
486+
},
487+
{
488+
type: 'ExportNamedDeclaration',
489+
declaration: {
490+
type: 'FunctionDeclaration',
491+
id: { type: 'Identifier', name: 'direct' },
492+
params: [],
493+
body: { type: 'BlockStatement', body: [] },
494+
},
495+
specifiers: [],
496+
source: null,
497+
attributes: [],
498+
},
499+
{
500+
type: 'ExportNamedDeclaration',
501+
declaration: {
502+
type: 'VariableDeclaration',
503+
kind: 'const' as const,
504+
declarations: [
505+
{
506+
type: 'VariableDeclarator',
507+
id: { type: 'Identifier', name: 'arrowHandler' },
508+
init: {
509+
type: 'ArrowFunctionExpression',
510+
params: [],
511+
body: { type: 'BlockStatement', body: [] },
512+
expression: false,
513+
},
514+
},
515+
],
516+
},
517+
specifiers: [],
518+
source: null,
519+
attributes: [],
520+
},
521+
{
522+
type: 'ExportNamedDeclaration',
523+
declaration: null,
524+
specifiers: [
525+
{
526+
type: 'ExportSpecifier',
527+
local: { type: 'Identifier', name: 'localAdd' },
528+
exported: { type: 'Identifier', name: 'aliasedAdd' },
529+
},
530+
{
531+
type: 'ExportSpecifier',
532+
local: { type: 'Identifier', name: 'importedHandler' },
533+
exported: { type: 'Identifier', name: 'runImported' },
534+
},
535+
],
536+
source: null,
537+
attributes: [],
538+
},
539+
{
540+
type: 'ExportNamedDeclaration',
541+
declaration: null,
542+
specifiers: [
543+
{
544+
type: 'ExportSpecifier',
545+
local: { type: 'Identifier', name: 'remoteHandler' },
546+
exported: { type: 'Identifier', name: 'remoteRun' },
547+
},
548+
],
549+
source: { type: 'Literal', value: './remote' },
550+
attributes: [],
551+
},
552+
]);
553+
554+
expect(enumerateBackendExports(ast, filePath)).toEqual([
555+
{ name: 'direct', localName: 'direct' },
556+
{ name: 'arrowHandler', localName: 'arrowHandler' },
557+
{ name: 'aliasedAdd', localName: 'localAdd' },
558+
{ name: 'runImported', localName: 'importedHandler' },
559+
{ name: 'remoteRun', localName: 'remoteHandler', source: './remote' },
560+
]);
561+
});
562+
563+
test('Should allow opaque callable export specifiers', () => {
564+
const ast = program([
565+
{
566+
type: 'VariableDeclaration',
567+
kind: 'const' as const,
568+
declarations: [
569+
{
570+
type: 'VariableDeclarator',
571+
id: { type: 'Identifier', name: 'handler' },
572+
init: {
573+
type: 'CallExpression',
574+
callee: { type: 'Identifier', name: 'createHandler' },
575+
arguments: [],
576+
optional: false,
577+
},
578+
},
579+
],
580+
},
581+
{
582+
type: 'ExportNamedDeclaration',
583+
declaration: null,
584+
specifiers: [
585+
{
586+
type: 'ExportSpecifier',
587+
local: { type: 'Identifier', name: 'handler' },
588+
exported: { type: 'Identifier', name: 'handler' },
589+
},
590+
],
591+
source: null,
592+
attributes: [],
593+
},
594+
]);
595+
596+
expect(enumerateBackendExports(ast, filePath)).toEqual([
597+
{ name: 'handler', localName: 'handler' },
598+
]);
599+
expect(extractExportedFunctions(ast, filePath)).toEqual(['handler']);
600+
});
463601
});

packages/plugins/apps/src/backend/discovery.ts

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,23 @@ export interface BackendFunction {
1616
allowedConnectionIds: string[];
1717
}
1818

19+
export interface BackendExport {
20+
/** Exported backend function name. */
21+
name: string;
22+
/** Local binding name when the export points at a local/imported binding. */
23+
localName?: string;
24+
/** Source specifier for re-exported bindings. */
25+
source?: string;
26+
}
27+
1928
/**
20-
* Extract exported value (non-type) symbols from an ESTree AST.
29+
* Enumerate exported value (non-type) symbols from an ESTree AST.
2130
* Expects plain JavaScript — TypeScript types must already be stripped
2231
* (e.g. by Vite's built-in esbuild transform that runs before our hook).
2332
*
2433
* Throws on invalid exports (e.g. default exports) and unexpected AST shapes.
2534
* Returns an empty array when the file has no named exports.
35+
* This is the shared source of truth for supported backend export shapes.
2636
*
2737
* @param ast - AstNode from `this.parse()` in unplugin's transform hook
2838
* @param filePath - Path to the source file (used in error messages)
@@ -31,7 +41,7 @@ function isProgramNode(node: AstNode): node is AstNode & Program {
3141
return node.type === 'Program';
3242
}
3343

34-
export function extractExportedFunctions(ast: AstNode, filePath: string): string[] {
44+
export function enumerateBackendExports(ast: AstNode, filePath: string): BackendExport[] {
3545
if (!isProgramNode(ast)) {
3646
throw new Error(
3747
`Expected a Program node from this.parse() for ${filePath}, got ${ast.type}`,
@@ -41,7 +51,7 @@ export function extractExportedFunctions(ast: AstNode, filePath: string): string
4151
// Build a map of top-level declarations so we can validate export specifiers.
4252
const declarations = buildDeclarationMap(ast);
4353

44-
const names: string[] = [];
54+
const exports: BackendExport[] = [];
4555
for (const node of ast.body) {
4656
// handles: export default ...
4757
if (node.type === 'ExportDefaultDeclaration') {
@@ -61,7 +71,7 @@ export function extractExportedFunctions(ast: AstNode, filePath: string): string
6171

6272
// handles: export function add() {} / export const add = ...
6373
if (node.declaration) {
64-
names.push(...namesFromDeclaration(node.declaration, filePath));
74+
exports.push(...exportsFromDeclaration(node.declaration, filePath));
6575
}
6676

6777
for (const spec of node.specifiers) {
@@ -74,17 +84,29 @@ export function extractExportedFunctions(ast: AstNode, filePath: string): string
7484
`Default exports are not supported in .backend.ts files. Use a named export instead: ${filePath}`,
7585
);
7686
}
77-
// Validate specifier binding is callable when we can resolve it.
78-
// e.g. `const VERSION = '1.0'; export { VERSION };` — rejected
79-
// e.g. `function add() {}; export { add };` — allowed
8087
if (spec.local.type === 'Identifier') {
88+
if (node.source && typeof node.source.value === 'string') {
89+
exports.push({
90+
name: spec.exported.name,
91+
localName: spec.local.name,
92+
source: node.source.value,
93+
});
94+
continue;
95+
}
96+
// Validate specifier binding is callable when we can resolve it.
97+
// e.g. `const VERSION = '1.0'; export { VERSION };` — rejected
98+
// e.g. `function add() {}; export { add };` — allowed
8199
validateSpecifierBinding(spec.local.name, declarations, filePath);
100+
// handles: export { add, multiply } and aliases
101+
exports.push({ name: spec.exported.name, localName: spec.local.name });
82102
}
83-
// handles: export { add, multiply }
84-
names.push(spec.exported.name);
85103
}
86104
}
87-
return names;
105+
return exports;
106+
}
107+
108+
export function extractExportedFunctions(ast: AstNode, filePath: string): string[] {
109+
return enumerateBackendExports(ast, filePath).map((backendExport) => backendExport.name);
88110
}
89111

90112
/** Init types that are definitively non-callable at runtime. */
@@ -110,10 +132,10 @@ function isNonCallableInit(init: Expression | null | undefined): boolean {
110132
* Handles `export function foo()` and `export const foo = ...` forms.
111133
* Throws when a variable export has a non-callable initializer.
112134
*/
113-
function namesFromDeclaration(decl: Declaration, filePath: string): string[] {
135+
function exportsFromDeclaration(decl: Declaration, filePath: string): BackendExport[] {
114136
// export function add(a, b) { return a + b; }
115137
if (decl.type === 'FunctionDeclaration' && decl.id) {
116-
return [decl.id.name];
138+
return [{ name: decl.id.name, localName: decl.id.name }];
117139
}
118140
// export class MyClass {} — classes are not callable as RPC endpoints
119141
if (decl.type === 'ClassDeclaration') {
@@ -122,7 +144,7 @@ function namesFromDeclaration(decl: Declaration, filePath: string): string[] {
122144
);
123145
}
124146
if (decl.type === 'VariableDeclaration') {
125-
return decl.declarations.flatMap((d) => {
147+
return decl.declarations.flatMap((d): BackendExport[] => {
126148
// export const { a, b } = obj;
127149
// export const [a, b] = arr;
128150
if (d.id.type !== 'Identifier') {
@@ -139,7 +161,7 @@ function namesFromDeclaration(decl: Declaration, filePath: string): string[] {
139161
}
140162
// export const add = (a, b) => a + b;
141163
// export const handler = importedFn; — ambiguous, allowed
142-
return [d.id.name];
164+
return [{ name: d.id.name, localName: d.id.name }];
143165
});
144166
}
145167
throw new Error(

0 commit comments

Comments
 (0)