From e5b03129c3753f1550023294bbec23e4812ae755 Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Thu, 23 Apr 2026 15:01:23 -0400 Subject: [PATCH 1/5] [APPS] Emit backend/manifest.json with per-export allowed connection IDs --- .../plugins/apps/src/backend/discovery.ts | 2 + .../backend/extract-connection-ids.test.ts | 412 ++++++++++++++++++ .../src/backend/extract-connection-ids.ts | 411 +++++++++++++++++ packages/plugins/apps/src/index.ts | 52 ++- .../plugins/apps/src/vite/dev-server.test.ts | 3 + packages/plugins/apps/src/vite/index.test.ts | 2 + 6 files changed, 878 insertions(+), 4 deletions(-) create mode 100644 packages/plugins/apps/src/backend/extract-connection-ids.test.ts create mode 100644 packages/plugins/apps/src/backend/extract-connection-ids.ts diff --git a/packages/plugins/apps/src/backend/discovery.ts b/packages/plugins/apps/src/backend/discovery.ts index f489d1480..15534076b 100644 --- a/packages/plugins/apps/src/backend/discovery.ts +++ b/packages/plugins/apps/src/backend/discovery.ts @@ -12,6 +12,8 @@ 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[]; } /** diff --git a/packages/plugins/apps/src/backend/extract-connection-ids.test.ts b/packages/plugins/apps/src/backend/extract-connection-ids.test.ts new file mode 100644 index 000000000..6d8da7cff --- /dev/null +++ b/packages/plugins/apps/src/backend/extract-connection-ids.test.ts @@ -0,0 +1,412 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { extractConnectionIds } from '@dd/apps-plugin/backend/extract-connection-ids'; +import { parse } from 'acorn'; +import type { AstNode, PluginContext } from 'rollup'; + +/** + * Build a mock PluginContext that can parse code and resolve/load files from + * an in-memory file map. Enough surface to exercise the extractor without + * starting a real Rollup build. + */ +function createCtx(files: Record): PluginContext { + const ctx = { + parse: (code: string): AstNode => + parse(code, { ecmaVersion: 2022, sourceType: 'module', locations: true }) as AstNode, + resolve: async (source: string, importer?: string) => { + const resolvedId = resolveSimple(source, importer, files); + if (!resolvedId) { + return null; + } + return { id: resolvedId, external: false }; + }, + load: async ({ id }: { id: string }) => { + if (!(id in files)) { + throw new Error(`mock load: no file ${id}`); + } + return { id, code: files[id], ast: null }; + }, + }; + return ctx as unknown as PluginContext; +} + +function resolveSimple( + source: string, + importer: string | undefined, + files: Record, +): string | null { + if (!importer) { + return source in files ? source : null; + } + if (source.startsWith('/') && source in files) { + return source; + } + + const base = importer.replace(/\/[^/]+$/, ''); + const joined = source.startsWith('.') ? `${base}/${source.replace(/^\.\//, '')}` : source; + // Normalise `../` segments. + const parts = joined.split('/'); + const out: string[] = []; + for (const p of parts) { + if (p === '..') { + out.pop(); + } else if (p !== '.' && p !== '') { + out.push(p); + } + } + const candidate = `/${out.join('/')}`; + if (candidate in files) { + return candidate; + } + for (const ext of ['.ts', '.js', '/index.ts', '/index.js']) { + if (`${candidate}${ext}` in files) { + return `${candidate}${ext}`; + } + } + return null; +} + +function run(files: Record, entry: string, exports: string[]) { + const ctx = createCtx(files); + const ast = ctx.parse(files[entry]); + return extractConnectionIds(ctx, ast, entry, exports); +} + +describe('extractConnectionIds', () => { + describe('inline literals', () => { + test('extracts a string-literal connectionId', async () => { + const result = await run( + { + '/app/foo.backend.ts': ` + export function foo() { + request({ connectionId: 'abc-123', url: '/x' }); + } + `, + }, + '/app/foo.backend.ts', + ['foo'], + ); + expect(result.get('foo')).toEqual(['abc-123']); + }); + + test('extracts a plain template literal connectionId', async () => { + const result = await run( + { + '/app/foo.backend.ts': ` + export function foo() { + request({ connectionId: \`abc-123\`, url: '/x' }); + } + `, + }, + '/app/foo.backend.ts', + ['foo'], + ); + expect(result.get('foo')).toEqual(['abc-123']); + }); + + test('dedupes repeated IDs and sorts', async () => { + const result = await run( + { + '/app/foo.backend.ts': ` + export function foo() { + request({ connectionId: 'b', url: '/x' }); + request({ connectionId: 'a', url: '/y' }); + request({ connectionId: 'a', url: '/z' }); + } + `, + }, + '/app/foo.backend.ts', + ['foo'], + ); + expect(result.get('foo')).toEqual(['a', 'b']); + }); + }); + + describe('same-file consts', () => { + test('resolves const to a string literal', async () => { + const result = await run( + { + '/app/foo.backend.ts': ` + const CONNECTION_ID = 'xyz-1'; + export function foo() { + request({ connectionId: CONNECTION_ID, url: '/x' }); + } + `, + }, + '/app/foo.backend.ts', + ['foo'], + ); + expect(result.get('foo')).toEqual(['xyz-1']); + }); + + test('resolves const to a plain template literal', async () => { + const result = await run( + { + '/app/foo.backend.ts': ` + const CONNECTION_ID = \`xyz-1\`; + export function foo() { + request({ connectionId: CONNECTION_ID, url: '/x' }); + } + `, + }, + '/app/foo.backend.ts', + ['foo'], + ); + expect(result.get('foo')).toEqual(['xyz-1']); + }); + + test('resolves const-through-const chain', async () => { + const result = await run( + { + '/app/foo.backend.ts': ` + const A = 'deep'; + const B = A; + export function foo() { request({ connectionId: B }); } + `, + }, + '/app/foo.backend.ts', + ['foo'], + ); + expect(result.get('foo')).toEqual(['deep']); + }); + }); + + describe('imported consts — transitive', () => { + test('resolves `export const` in a sibling file', async () => { + const result = await run( + { + '/app/foo.backend.ts': ` + import { CONN } from './constants'; + export function foo() { request({ connectionId: CONN }); } + `, + '/app/constants.ts': `export const CONN = 'imported-1';`, + }, + '/app/foo.backend.ts', + ['foo'], + ); + expect(result.get('foo')).toEqual(['imported-1']); + }); + + test('resolves through a barrel re-export', async () => { + const result = await run( + { + '/app/foo.backend.ts': ` + import { CONN } from './barrel'; + export function foo() { request({ connectionId: CONN }); } + `, + '/app/barrel.ts': `export { CONN } from './real';`, + '/app/real.ts': `export const CONN = 'barrelled';`, + }, + '/app/foo.backend.ts', + ['foo'], + ); + expect(result.get('foo')).toEqual(['barrelled']); + }); + + test('resolves through `export { X as Y } from`', async () => { + const result = await run( + { + '/app/foo.backend.ts': ` + import { CONN } from './barrel'; + export function foo() { request({ connectionId: CONN }); } + `, + '/app/barrel.ts': `export { INNER as CONN } from './real';`, + '/app/real.ts': `export const INNER = 'renamed';`, + }, + '/app/foo.backend.ts', + ['foo'], + ); + expect(result.get('foo')).toEqual(['renamed']); + }); + + test('resolves through `import { X } from; export { X }`', async () => { + const result = await run( + { + '/app/foo.backend.ts': ` + import { CONN } from './mid'; + export function foo() { request({ connectionId: CONN }); } + `, + '/app/mid.ts': ` + import { CONN } from './real'; + export { CONN }; + `, + '/app/real.ts': `export const CONN = 'relayed';`, + }, + '/app/foo.backend.ts', + ['foo'], + ); + expect(result.get('foo')).toEqual(['relayed']); + }); + + test('resolves via `export * from`', async () => { + const result = await run( + { + '/app/foo.backend.ts': ` + import { CONN } from './barrel'; + export function foo() { request({ connectionId: CONN }); } + `, + '/app/barrel.ts': ` + export * from './other'; + export * from './real'; + `, + '/app/other.ts': `export const UNUSED = 'u';`, + '/app/real.ts': `export const CONN = 'star';`, + }, + '/app/foo.backend.ts', + ['foo'], + ); + expect(result.get('foo')).toEqual(['star']); + }); + + test('throws on cyclic re-export chain', async () => { + await expect( + run( + { + '/app/foo.backend.ts': ` + import { CONN } from './a'; + export function foo() { request({ connectionId: CONN }); } + `, + '/app/a.ts': `export { CONN } from './b';`, + '/app/b.ts': `export { CONN } from './a';`, + }, + '/app/foo.backend.ts', + ['foo'], + ), + ).rejects.toThrow(/cyclic re-export chain/); + }); + + test('throws with clear message when export not found', async () => { + await expect( + run( + { + '/app/foo.backend.ts': ` + import { CONN } from './constants'; + export function foo() { request({ connectionId: CONN }); } + `, + '/app/constants.ts': `export const OTHER = 'x';`, + }, + '/app/foo.backend.ts', + ['foo'], + ), + ).rejects.toThrow(/export 'CONN' not found/); + }); + }); + + describe('unresolvable forms throw', () => { + test('dynamic template literal', async () => { + await expect( + run( + { + '/app/foo.backend.ts': ` + const prefix = 'a'; + export function foo() { + request({ connectionId: \`\${prefix}-b\` }); + } + `, + }, + '/app/foo.backend.ts', + ['foo'], + ), + ).rejects.toThrow(/must not contain interpolations/); + }); + + test('concatenation', async () => { + await expect( + run( + { + '/app/foo.backend.ts': ` + export function foo() { + request({ connectionId: 'a' + 'b' }); + } + `, + }, + '/app/foo.backend.ts', + ['foo'], + ), + ).rejects.toThrow(/must be a string literal/); + }); + + test('env var (member expression)', async () => { + await expect( + run( + { + '/app/foo.backend.ts': ` + export function foo() { + request({ connectionId: process.env.CONN }); + } + `, + }, + '/app/foo.backend.ts', + ['foo'], + ), + ).rejects.toThrow(/must be a string literal/); + }); + + test('function call', async () => { + await expect( + run( + { + '/app/foo.backend.ts': ` + export function foo() { + request({ connectionId: getConn() }); + } + `, + }, + '/app/foo.backend.ts', + ['foo'], + ), + ).rejects.toThrow(/must be a string literal/); + }); + + test('undefined identifier', async () => { + await expect( + run( + { + '/app/foo.backend.ts': ` + export function foo() { + request({ connectionId: MYSTERY }); + } + `, + }, + '/app/foo.backend.ts', + ['foo'], + ), + ).rejects.toThrow(/not defined .* and is not imported/); + }); + }); + + describe('multiple exports', () => { + test('extracts IDs per export independently', async () => { + const result = await run( + { + '/app/foo.backend.ts': ` + const A = 'aaa'; + export function foo() { request({ connectionId: A }); } + export function bar() { request({ connectionId: 'bbb' }); } + `, + }, + '/app/foo.backend.ts', + ['foo', 'bar'], + ); + expect(result.get('foo')).toEqual(['aaa']); + expect(result.get('bar')).toEqual(['bbb']); + }); + }); + + describe('no connectionId', () => { + test('returns empty list when the export never mentions connectionId', async () => { + const result = await run( + { + '/app/foo.backend.ts': ` + export function foo() { request({ url: '/x' }); } + `, + }, + '/app/foo.backend.ts', + ['foo'], + ); + expect(result.get('foo')).toEqual([]); + }); + }); +}); diff --git a/packages/plugins/apps/src/backend/extract-connection-ids.ts b/packages/plugins/apps/src/backend/extract-connection-ids.ts new file mode 100644 index 000000000..9a2a425ed --- /dev/null +++ b/packages/plugins/apps/src/backend/extract-connection-ids.ts @@ -0,0 +1,411 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { + Expression, + Node, + ObjectExpression, + Program, + Property, + TemplateLiteral, +} from 'estree'; +import type { AstNode, PluginContext } from 'rollup'; + +/** Hard safety cap — genuine cycles are caught by `visited`, this guards runaway chains. */ +const MAX_HOPS = 16; + +/** + * Statically extract every `connectionId` value used inside each exported + * backend function. Values may be: + * - inline string literal (`'abc'`) + * - plain template literal with no interpolation (\`abc\`) + * - identifier that resolves to a same-file `const` of those forms + * - identifier that resolves via an import chain to a `const` of those forms + * + * Any other form (dynamic template, call, concatenation, env var, …) throws + * with the source location so Vite surfaces a framed build error. + * + * Returns a map keyed by export name → sorted, deduplicated connection IDs. + */ +export async function extractConnectionIds( + ctx: PluginContext, + ast: AstNode, + filePath: string, + exportedNames: string[], +): Promise> { + if (!isProgram(ast)) { + throw new Error( + `Expected a Program node from this.parse() for ${filePath}, got ${ast.type}`, + ); + } + + const symbols = buildSymbolTable(ast); + const bodyByExport = findExportedFunctionBodies(ast, exportedNames); + const result = new Map(); + + for (const name of exportedNames) { + const body = bodyByExport.get(name); + if (!body) { + // Export was declared but we couldn't locate its function body. + // This is a shape discovery already rejects — defensive empty entry. + result.set(name, []); + continue; + } + + const callSites = findConnectionIdValues(body); + const ids = new Set(); + for (const { valueNode, keyLoc } of callSites) { + const id = await resolveValue(ctx, valueNode, symbols, filePath, { + visited: new Set(), + hops: 0, + exportName: name, + originFile: filePath, + keyLoc, + }); + ids.add(id); + } + result.set(name, [...ids].sort()); + } + + return result; +} + +// ---------- AST helpers ---------- + +function isProgram(node: AstNode): node is AstNode & Program { + return node.type === 'Program'; +} + +type LocalSymbols = { + /** Top-level `const X = ` bindings (and `let`/`var`). */ + localConsts: Map; + /** Import specifiers: local name → `{ source, imported }`. `imported` is the remote name. */ + importBindings: Map; +}; + +function buildSymbolTable(ast: Program): LocalSymbols { + const localConsts = new Map(); + const importBindings = new Map(); + + for (const node of ast.body) { + if (node.type === 'VariableDeclaration') { + for (const d of node.declarations) { + if (d.id.type === 'Identifier' && d.init) { + localConsts.set(d.id.name, d.init); + } + } + } else if (node.type === 'ImportDeclaration' && typeof node.source.value === 'string') { + const source = node.source.value; + for (const spec of node.specifiers) { + if (spec.type === 'ImportSpecifier') { + const imported = + spec.imported.type === 'Identifier' + ? spec.imported.name + : String(spec.imported.value); + importBindings.set(spec.local.name, { source, imported }); + } + // ImportDefaultSpecifier / ImportNamespaceSpecifier intentionally skipped: + // they can't resolve to a statically-known string constant we'd accept. + } + } else if (node.type === 'ExportNamedDeclaration' && node.declaration) { + // `export const X = ` — also track so same-file exported consts resolve. + if (node.declaration.type === 'VariableDeclaration') { + for (const d of node.declaration.declarations) { + if (d.id.type === 'Identifier' && d.init) { + localConsts.set(d.id.name, d.init); + } + } + } + } + } + + return { localConsts, importBindings }; +} + +/** Map export name → function body node. Supports `export function f(){}` and `export const f = () => {}`. */ +function findExportedFunctionBodies(ast: Program, names: string[]): Map { + const wanted = new Set(names); + const out = new Map(); + + for (const node of ast.body) { + if (node.type !== 'ExportNamedDeclaration' || !node.declaration) { + continue; + } + const decl = node.declaration; + if (decl.type === 'FunctionDeclaration' && decl.id && wanted.has(decl.id.name)) { + out.set(decl.id.name, decl.body); + } else if (decl.type === 'VariableDeclaration') { + for (const d of decl.declarations) { + if (d.id.type !== 'Identifier' || !wanted.has(d.id.name) || !d.init) { + continue; + } + if ( + d.init.type === 'ArrowFunctionExpression' || + d.init.type === 'FunctionExpression' + ) { + out.set(d.id.name, d.init.body); + } + } + } + } + return out; +} + +type ConnectionIdCallSite = { + valueNode: Expression; + keyLoc: Node['loc']; +}; + +/** + * Walk a function body for every CallExpression whose first argument is an + * ObjectExpression containing a `connectionId` property — record the value node. + * Nested functions are walked too (we don't restrict to the top scope). + */ +function findConnectionIdValues(root: Node): ConnectionIdCallSite[] { + const out: ConnectionIdCallSite[] = []; + + const visit = (node: Node | null | undefined): void => { + if (!node || typeof node !== 'object') { + return; + } + if (Array.isArray(node)) { + for (const c of node as unknown as Node[]) { + visit(c); + } + return; + } + if (!('type' in node)) { + return; + } + + if (node.type === 'CallExpression') { + const firstArg = node.arguments[0]; + if (firstArg && firstArg.type === 'ObjectExpression') { + const prop = findConnectionIdProp(firstArg); + if (prop) { + out.push({ + valueNode: prop.value as Expression, + keyLoc: prop.loc ?? null, + }); + } + } + } + + for (const key of Object.keys(node)) { + if (key === 'loc' || key === 'range' || key === 'parent') { + continue; + } + visit((node as unknown as Record)[key]); + } + }; + + visit(root); + return out; +} + +function findConnectionIdProp(obj: ObjectExpression): Property | undefined { + for (const p of obj.properties) { + if (p.type !== 'Property') { + continue; + } + if (p.computed) { + continue; + } + const key = p.key; + if (key.type === 'Identifier' && key.name === 'connectionId') { + return p; + } + if (key.type === 'Literal' && key.value === 'connectionId') { + return p; + } + } + return undefined; +} + +// ---------- Resolution ---------- + +type ResolutionState = { + visited: Set; + hops: number; + exportName: string; + originFile: string; + keyLoc: Node['loc']; +}; + +class ExtractionError extends Error {} + +function fail(state: ResolutionState, reason: string, loc?: Node['loc']): never { + const where = loc?.start + ? `${state.originFile}:${loc.start.line}:${loc.start.column + 1}` + : state.originFile; + throw new ExtractionError( + `[connectionId manifest] ${reason} (export '${state.exportName}' at ${where})`, + ); +} + +async function resolveValue( + ctx: PluginContext, + node: Expression, + symbols: LocalSymbols, + currentFile: string, + state: ResolutionState, +): Promise { + if (node.type === 'Literal' && typeof node.value === 'string') { + return node.value; + } + if (node.type === 'TemplateLiteral') { + return requireStaticTemplate(node, state); + } + if (node.type === 'Identifier') { + return resolveIdentifier(ctx, node.name, symbols, currentFile, state, node.loc); + } + fail( + state, + `'connectionId' must be a string literal, a plain template literal, or an identifier that resolves to one; got ${node.type}`, + node.loc, + ); +} + +function requireStaticTemplate(node: TemplateLiteral, state: ResolutionState): string { + if (node.expressions.length > 0) { + fail(state, `'connectionId' template literals must not contain interpolations`, node.loc); + } + const q = node.quasis[0]; + return q.value.cooked ?? q.value.raw; +} + +async function resolveIdentifier( + ctx: PluginContext, + name: string, + symbols: LocalSymbols, + currentFile: string, + state: ResolutionState, + loc: Node['loc'], +): Promise { + const localInit = symbols.localConsts.get(name); + if (localInit) { + return resolveValue(ctx, localInit, symbols, currentFile, state); + } + const binding = symbols.importBindings.get(name); + if (binding) { + return resolveCrossFile(ctx, currentFile, binding.source, binding.imported, state); + } + fail(state, `identifier '${name}' is not defined in ${currentFile} and is not imported`, loc); +} + +async function resolveCrossFile( + ctx: PluginContext, + importer: string, + source: string, + importedName: string, + state: ResolutionState, +): Promise { + const nextHops = state.hops + 1; + if (nextHops > MAX_HOPS) { + fail( + state, + `import tracing depth exceeded (${MAX_HOPS} hops) while resolving '${importedName}'`, + ); + } + + const resolved = await ctx.resolve(source, importer, { skipSelf: false }); + if (!resolved || resolved.external) { + fail( + state, + `could not resolve module '${source}' (imported from ${importer}) while following 'connectionId' — external modules are not supported`, + ); + } + const targetId = resolved.id; + + const key = `${targetId}::${importedName}`; + if (state.visited.has(key)) { + fail(state, `cyclic re-export chain detected at ${targetId}::${importedName}`); + } + state.visited.add(key); + + const info = await ctx.load({ id: targetId }); + const code = info.code; + if (code === null || code === undefined) { + fail(state, `module '${targetId}' produced no code when loaded for connectionId tracing`); + } + const targetAst = ctx.parse(code) as unknown as Program; + const targetSymbols = buildSymbolTable(targetAst); + const nextState: ResolutionState = { ...state, hops: nextHops }; + + // Look for the exported binding in the target file. + for (const node of targetAst.body) { + if (node.type === 'ExportNamedDeclaration') { + // Re-export: `export { X } from './foo'` / `export { Y as X } from './foo'` + if (node.source && typeof node.source.value === 'string') { + for (const spec of node.specifiers) { + if ( + spec.exported.type === 'Identifier' && + spec.exported.name === importedName + ) { + const reSource = node.source.value; + const reName = + spec.local.type === 'Identifier' + ? spec.local.name + : String((spec.local as { value: string }).value); + return resolveCrossFile(ctx, targetId, reSource, reName, nextState); + } + } + continue; + } + + // Local re-export: `const X = …; export { X }` / `export { X as Y }` + for (const spec of node.specifiers) { + if (spec.exported.type === 'Identifier' && spec.exported.name === importedName) { + const localName = + spec.local.type === 'Identifier' + ? spec.local.name + : String((spec.local as { value: string }).value); + return resolveIdentifier( + ctx, + localName, + targetSymbols, + targetId, + nextState, + spec.loc, + ); + } + } + + // `export const X = ` / `export function X(){}` + if (node.declaration?.type === 'VariableDeclaration') { + for (const d of node.declaration.declarations) { + if (d.id.type === 'Identifier' && d.id.name === importedName && d.init) { + return resolveValue(ctx, d.init, targetSymbols, targetId, nextState); + } + } + } + } else if (node.type === 'ExportAllDeclaration') { + // `export * from './bar'` (no namespace) / `export * as NS from './bar'`. + // Only the plain `export *` form can re-export our name. + if (node.exported) { + continue; + } + if (typeof node.source.value !== 'string') { + continue; + } + try { + return await resolveCrossFile( + ctx, + targetId, + node.source.value, + importedName, + nextState, + ); + } catch (e) { + if (e instanceof ExtractionError && /not found/.test(e.message)) { + // Try the next `export *` — normal. + continue; + } + throw e; + } + } + } + + fail(state, `export '${importedName}' not found in '${targetId}' while resolving connectionId`); +} diff --git a/packages/plugins/apps/src/index.ts b/packages/plugins/apps/src/index.ts index 13af8ff23..f954268eb 100644 --- a/packages/plugins/apps/src/index.ts +++ b/packages/plugins/apps/src/index.ts @@ -6,7 +6,10 @@ import { rm } from '@dd/core/helpers/fs'; import type { GetPlugins } from '@dd/core/types'; import { InjectPosition } from '@dd/core/types'; import chalk from 'chalk'; +import fsp from 'fs/promises'; +import os from 'os'; import path from 'path'; +import type { PluginContext } from 'rollup'; import { createArchive } from './archive'; import type { Asset } from './assets'; @@ -14,6 +17,7 @@ import { collectAssets } from './assets'; import type { BackendFunction } from './backend/discovery'; import { extractExportedFunctions } from './backend/discovery'; import { encodeQueryName } from './backend/encodeQueryName'; +import { extractConnectionIds } from './backend/extract-connection-ids'; import { generateProxyModule } from './backend/proxy-codegen'; import { BACKEND_FILE_RE, CONFIG_KEY, PLUGIN_NAME } from './constants'; import { resolveIdentifier } from './identifier'; @@ -32,6 +36,7 @@ function buildProxyModule( exportNames: string[], id: string, buildRoot: string, + connectionIdsByExport: Map, ): { functions: BackendFunction[]; proxyCode: string } { const relativePath = path.relative(buildRoot, id); const refPath = relativePath.replace(BACKEND_FILE_RE, ''); @@ -40,7 +45,12 @@ function buildProxyModule( const proxyExports: Array<{ exportName: string; queryName: string }> = []; for (const exportName of exportNames) { - const func = { relativePath: refPath, name: exportName, absolutePath: id }; + const func: BackendFunction = { + relativePath: refPath, + name: exportName, + absolutePath: id, + allowedConnectionIds: connectionIdsByExport.get(exportName) ?? [], + }; functions.push(func); proxyExports.push({ exportName, queryName: encodeQueryName(func) }); } @@ -109,6 +119,7 @@ export const getPlugins: GetPlugins = ({ options, context, bundler }) => { const handleUpload = async (backendOutputs: Map) => { const handleTimer = log.time('handle assets'); let archiveDir: string | undefined; + let manifestDir: string | undefined; try { const identifierTimer = log.time('resolve identifier'); @@ -158,6 +169,27 @@ Either: }); } + // Emit the connection-ID manifest alongside the backend bundles so + // the server can allowlist the connections each function uses. + const backendFunctions = getBackendFunctions(); + if (backendFunctions.length > 0) { + const manifest: Record = {}; + for (const fn of backendFunctions) { + manifest[encodeQueryName(fn)] = { + allowedConnectionIds: fn.allowedConnectionIds, + }; + } + const manifestJson = JSON.stringify(manifest, null, 2); + log.debug(`Backend connectionId manifest:\n${manifestJson}`); + manifestDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'dd-apps-manifest-')); + const manifestPath = path.join(manifestDir, 'manifest.json'); + await fsp.writeFile(manifestPath, manifestJson); + allAssets.push({ + absolutePath: manifestPath, + relativePath: 'backend/manifest.json', + }); + } + const archiveTimer = log.time('archive assets'); const archive = await createArchive(allAssets); archiveTimer.end(); @@ -198,10 +230,13 @@ Either: log.error(`${red('Failed to upload assets:')}\n${error?.message || error}`); } - // Clean temporary directory + // Clean temporary directories if (archiveDir) { await rm(archiveDir); } + if (manifestDir) { + await rm(manifestDir); + } handleTimer.end(); if (toThrow) { @@ -226,8 +261,9 @@ Either: // For each .backend.* file, parse its named exports, register // them as backend functions, and replace the module with a // frontend proxy that calls executeBackendFunction at runtime. - handler(code, id) { - const exportNames = extractExportedFunctions(this.parse(code), id); + async handler(code, id) { + const ast = this.parse(code); + const exportNames = extractExportedFunctions(ast, id); if (exportNames.length === 0) { log.warn( `Backend file ${id} has no exported functions. ` + @@ -239,10 +275,18 @@ Either: return { code: '', map: null }; } + const connectionIdsByExport = await extractConnectionIds( + this as unknown as PluginContext, + ast, + id, + exportNames, + ); + const { functions, proxyCode } = buildProxyModule( exportNames, id, context.buildRoot, + connectionIdsByExport, ); setBackendFunctions(id, functions); log.debug(`Generated proxy for ${id} with ${functions.length} export(s)`); diff --git a/packages/plugins/apps/src/vite/dev-server.test.ts b/packages/plugins/apps/src/vite/dev-server.test.ts index 2c7b203a0..d15431a53 100644 --- a/packages/plugins/apps/src/vite/dev-server.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.test.ts @@ -20,11 +20,13 @@ const mockFunctions: BackendFunction[] = [ relativePath: 'backend/greet', name: 'greet', absolutePath: '/project/backend/greet.backend.ts', + allowedConnectionIds: [], }, { relativePath: 'backend/compute', name: 'compute', absolutePath: '/project/backend/compute.backend.ts', + allowedConnectionIds: [], }, ]; @@ -422,6 +424,7 @@ describe('Dev Server Middleware', () => { relativePath: 'backend/greet', name: 'greetV2', absolutePath: '/project/backend/greet.backend.ts', + allowedConnectionIds: [], }, mockFunctions[1], ]; diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index 5d923f8a7..b39465640 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -15,11 +15,13 @@ const functions: BackendFunction[] = [ relativePath: 'src/backend/myHandler', name: 'myHandler', absolutePath: '/src/backend/myHandler.backend.ts', + allowedConnectionIds: [], }, { relativePath: 'src/backend/otherFunc', name: 'otherFunc', absolutePath: '/src/backend/otherFunc.backend.ts', + allowedConnectionIds: [], }, ]; From 664ba9b53d135da45decbdab096b9736d0071460 Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Thu, 23 Apr 2026 17:40:18 -0400 Subject: [PATCH 2/5] Add JSDocs and inline examples to connectionId resolution helpers --- .../src/backend/extract-connection-ids.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/packages/plugins/apps/src/backend/extract-connection-ids.ts b/packages/plugins/apps/src/backend/extract-connection-ids.ts index 9a2a425ed..4af3bd0eb 100644 --- a/packages/plugins/apps/src/backend/extract-connection-ids.ts +++ b/packages/plugins/apps/src/backend/extract-connection-ids.ts @@ -84,18 +84,32 @@ type LocalSymbols = { importBindings: Map; }; +/** + * Build a table of top-level symbols from a module's AST so later passes can + * resolve identifiers to their originating declaration. + * + * Two kinds of bindings are tracked: + * - `localConsts`: same-file `const`/`let`/`var` and `export const` declarations, + * mapped to their initializer expression (used to resolve local string constants). + * - `importBindings`: named imports, mapped to `{ source, imported }` so callers + * can follow the binding to another module. Default and namespace imports are + * intentionally skipped since they can't resolve to a statically-known value. + */ function buildSymbolTable(ast: Program): LocalSymbols { const localConsts = new Map(); const importBindings = new Map(); for (const node of ast.body) { if (node.type === 'VariableDeclaration') { + // e.g. `const MY_ID = 'abc-123';` + // or multi-declarator: `const A = 'x', B = 'y';` — one VariableDeclaration, two declarators. for (const d of node.declarations) { if (d.id.type === 'Identifier' && d.init) { localConsts.set(d.id.name, d.init); } } } else if (node.type === 'ImportDeclaration' && typeof node.source.value === 'string') { + // e.g. `import { MY_ID } from './constants';` const source = node.source.value; for (const spec of node.specifiers) { if (spec.type === 'ImportSpecifier') { @@ -109,6 +123,7 @@ function buildSymbolTable(ast: Program): LocalSymbols { // they can't resolve to a statically-known string constant we'd accept. } } else if (node.type === 'ExportNamedDeclaration' && node.declaration) { + // e.g. `export const MY_ID = 'abc-123';` // `export const X = ` — also track so same-file exported consts resolve. if (node.declaration.type === 'VariableDeclaration') { for (const d of node.declaration.declarations) { @@ -244,6 +259,18 @@ function fail(state: ResolutionState, reason: string, loc?: Node['loc']): never ); } +/** + * Resolve an arbitrary expression node to its static string value, recursing + * through identifier bindings until we land on a literal. + * + * `node` may be the original `connectionId` property value, but during + * recursion it's whatever we've followed to — a `const` initializer, a + * re-exported binding's target, etc. + * + * Accepts: string `Literal`, interpolation-free `TemplateLiteral`, and + * `Identifier` that resolves (locally or via imports) to one of those forms. + * Anything else calls {@link fail} with a source location. + */ async function resolveValue( ctx: PluginContext, node: Expression, @@ -252,12 +279,15 @@ async function resolveValue( state: ResolutionState, ): Promise { if (node.type === 'Literal' && typeof node.value === 'string') { + // e.g. the `'abc-123'` in `connectionId: 'abc-123'` or `const MY_ID = 'abc-123'` return node.value; } if (node.type === 'TemplateLiteral') { + // e.g. a plain `abc-123` template, as in connectionId: `abc-123` or const MY_ID = `abc-123` return requireStaticTemplate(node, state); } if (node.type === 'Identifier') { + // e.g. the `MY_ID` in `connectionId: MY_ID` or `const ALIAS = MY_ID` return resolveIdentifier(ctx, node.name, symbols, currentFile, state, node.loc); } fail( @@ -267,6 +297,14 @@ async function resolveValue( ); } +/** + * Return the cooked text of a template literal, but only if it has no + * interpolations. + * + * Accepts: `` `abc-123` `` → `'abc-123'`. + * Rejects: `` `abc-${suffix}` ``, `` `${prefix}-123` ``, etc. — these fail + * because we can't statically know the resulting string. + */ function requireStaticTemplate(node: TemplateLiteral, state: ResolutionState): string { if (node.expressions.length > 0) { fail(state, `'connectionId' template literals must not contain interpolations`, node.loc); @@ -275,6 +313,17 @@ function requireStaticTemplate(node: TemplateLiteral, state: ResolutionState): s return q.value.cooked ?? q.value.raw; } +/** + * Resolve an identifier `name` to its static string value by following its + * binding in the current file's symbol table. + * + * Three outcomes: + * - Bound to a same-file `const`/`let`/`var` (or `export const`): recurse into + * {@link resolveValue} on that initializer, staying in this file. + * - Bound to a named import: hand off to {@link resolveCrossFile} to load and + * trace the source module. + * - Not bound anywhere: fail with the identifier's source location. + */ async function resolveIdentifier( ctx: PluginContext, name: string, @@ -294,6 +343,24 @@ async function resolveIdentifier( fail(state, `identifier '${name}' is not defined in ${currentFile} and is not imported`, loc); } +/** + * Follow a named import across module boundaries: resolve `source` relative to + * `importer`, load and parse the target module, then find the binding exported + * as `importedName` and continue resolution from there. + * + * Handles these export forms in the target module: + * - `export { X } from './foo'` (and `export { Y as X } from './foo'`) — recurses into + * the onward module. + * - `export { X }` / `export { X as Y }` — recurses into the target's own + * symbol table via {@link resolveIdentifier}. + * - `export const X = ` — recurses into {@link resolveValue} on the initializer. + * - `export * from './bar'` — tries each barrel in order, swallowing only + * "not found" errors so the search continues. + * + * Fails on: unresolved or external modules, modules that produce no code, + * cyclic re-export chains (caught via `state.visited`), and chains deeper + * than `MAX_HOPS`. + */ async function resolveCrossFile( ctx: PluginContext, importer: string, From 18f2002685b9cbae89b50553807348aecd677d61 Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Sat, 25 Apr 2026 10:31:28 -0400 Subject: [PATCH 3/5] Refactor discovery to expose enumerateBackendExports as the single source of truth for export shapes; let extract-connection-ids reuse it --- .../plugins/apps/src/backend/discovery.ts | 206 ++++++++++++--- .../backend/extract-connection-ids.test.ts | 238 ++++++++++++++---- .../src/backend/extract-connection-ids.ts | 227 ++++++++++------- packages/plugins/apps/src/index.ts | 1 - 4 files changed, 499 insertions(+), 173 deletions(-) diff --git a/packages/plugins/apps/src/backend/discovery.ts b/packages/plugins/apps/src/backend/discovery.ts index 15534076b..e776911e1 100644 --- a/packages/plugins/apps/src/backend/discovery.ts +++ b/packages/plugins/apps/src/backend/discovery.ts @@ -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 { @@ -17,31 +17,55 @@ export interface BackendFunction { } /** - * 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') { @@ -61,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) { @@ -74,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. */ @@ -106,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') { @@ -122,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') { @@ -138,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: '', + imported: d.init.name, + }, + ]; + } + // Other ambiguous forms (CallExpression, etc.): treat as imported/opaque. + return [ + { + kind: 'imported', + name: d.id.name, + source: '', + imported: d.id.name, + }, + ]; }); } throw new Error( @@ -153,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 { const map = new Map(); 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' }); @@ -176,11 +251,25 @@ function buildDeclarationMap(ast: Program): Map { 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, + }); + } } } } @@ -194,10 +283,9 @@ function buildDeclarationMap(ast: Program): Map { */ function validateSpecifierBinding( localName: string, - declarations: Map, + 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; @@ -213,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: '', + imported: localName, + }; +} diff --git a/packages/plugins/apps/src/backend/extract-connection-ids.test.ts b/packages/plugins/apps/src/backend/extract-connection-ids.test.ts index 6d8da7cff..0e4ae2a6d 100644 --- a/packages/plugins/apps/src/backend/extract-connection-ids.test.ts +++ b/packages/plugins/apps/src/backend/extract-connection-ids.test.ts @@ -28,6 +28,9 @@ function createCtx(files: Record): PluginContext { } return { id, code: files[id], ast: null }; }, + debug: (_msg: string) => { + /* no-op for tests */ + }, }; return ctx as unknown as PluginContext; } @@ -68,10 +71,13 @@ function resolveSimple( return null; } -function run(files: Record, entry: string, exports: string[]) { +/** Standard action-catalog import prepended to fixtures so `request(…)` is recognised. */ +const CATALOG_IMPORT = `import { request } from '@datadog/action-catalog/http/http';\n`; + +function run(files: Record, entry: string) { const ctx = createCtx(files); const ast = ctx.parse(files[entry]); - return extractConnectionIds(ctx, ast, entry, exports); + return extractConnectionIds(ctx, ast, entry); } describe('extractConnectionIds', () => { @@ -79,14 +85,13 @@ describe('extractConnectionIds', () => { test('extracts a string-literal connectionId', async () => { const result = await run( { - '/app/foo.backend.ts': ` + '/app/foo.backend.ts': `${CATALOG_IMPORT} export function foo() { request({ connectionId: 'abc-123', url: '/x' }); } `, }, '/app/foo.backend.ts', - ['foo'], ); expect(result.get('foo')).toEqual(['abc-123']); }); @@ -94,14 +99,13 @@ describe('extractConnectionIds', () => { test('extracts a plain template literal connectionId', async () => { const result = await run( { - '/app/foo.backend.ts': ` + '/app/foo.backend.ts': `${CATALOG_IMPORT} export function foo() { request({ connectionId: \`abc-123\`, url: '/x' }); } `, }, '/app/foo.backend.ts', - ['foo'], ); expect(result.get('foo')).toEqual(['abc-123']); }); @@ -109,7 +113,7 @@ describe('extractConnectionIds', () => { test('dedupes repeated IDs and sorts', async () => { const result = await run( { - '/app/foo.backend.ts': ` + '/app/foo.backend.ts': `${CATALOG_IMPORT} export function foo() { request({ connectionId: 'b', url: '/x' }); request({ connectionId: 'a', url: '/y' }); @@ -118,7 +122,6 @@ describe('extractConnectionIds', () => { `, }, '/app/foo.backend.ts', - ['foo'], ); expect(result.get('foo')).toEqual(['a', 'b']); }); @@ -128,7 +131,7 @@ describe('extractConnectionIds', () => { test('resolves const to a string literal', async () => { const result = await run( { - '/app/foo.backend.ts': ` + '/app/foo.backend.ts': `${CATALOG_IMPORT} const CONNECTION_ID = 'xyz-1'; export function foo() { request({ connectionId: CONNECTION_ID, url: '/x' }); @@ -136,7 +139,6 @@ describe('extractConnectionIds', () => { `, }, '/app/foo.backend.ts', - ['foo'], ); expect(result.get('foo')).toEqual(['xyz-1']); }); @@ -144,7 +146,7 @@ describe('extractConnectionIds', () => { test('resolves const to a plain template literal', async () => { const result = await run( { - '/app/foo.backend.ts': ` + '/app/foo.backend.ts': `${CATALOG_IMPORT} const CONNECTION_ID = \`xyz-1\`; export function foo() { request({ connectionId: CONNECTION_ID, url: '/x' }); @@ -152,7 +154,6 @@ describe('extractConnectionIds', () => { `, }, '/app/foo.backend.ts', - ['foo'], ); expect(result.get('foo')).toEqual(['xyz-1']); }); @@ -160,31 +161,101 @@ describe('extractConnectionIds', () => { test('resolves const-through-const chain', async () => { const result = await run( { - '/app/foo.backend.ts': ` + '/app/foo.backend.ts': `${CATALOG_IMPORT} const A = 'deep'; const B = A; export function foo() { request({ connectionId: B }); } `, }, '/app/foo.backend.ts', - ['foo'], ); expect(result.get('foo')).toEqual(['deep']); }); }); + describe('specifier exports', () => { + test('resolves `function foo(){}; export { foo }`', async () => { + const result = await run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + function foo() { request({ connectionId: 'abc-123' }); } + export { foo }; + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual(['abc-123']); + }); + + test('resolves `const foo = () => {}; export { foo }`', async () => { + const result = await run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + const foo = () => { request({ connectionId: 'abc-123' }); }; + export { foo }; + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual(['abc-123']); + }); + + test('resolves `export { foo as bar }` (alias)', async () => { + const result = await run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + function foo() { request({ connectionId: 'abc-123' }); } + export { foo as bar }; + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('bar')).toEqual(['abc-123']); + }); + + test('emits [] for `import { handler } from "./x"; export { handler }`', async () => { + const result = await run( + { + '/app/foo.backend.ts': ` + import { handler } from './handlers'; + export { handler }; + `, + '/app/handlers.ts': `${CATALOG_IMPORT} + export function handler() { request({ connectionId: 'abc-123' }); } + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('handler')).toEqual([]); + }); + + test('emits [] for `export { X } from "./x"` re-exports', async () => { + const result = await run( + { + '/app/foo.backend.ts': ` + export { handler } from './handlers'; + `, + '/app/handlers.ts': `${CATALOG_IMPORT} + export function handler() { request({ connectionId: 'abc-123' }); } + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('handler')).toEqual([]); + }); + }); + describe('imported consts — transitive', () => { test('resolves `export const` in a sibling file', async () => { const result = await run( { - '/app/foo.backend.ts': ` + '/app/foo.backend.ts': `${CATALOG_IMPORT} import { CONN } from './constants'; export function foo() { request({ connectionId: CONN }); } `, '/app/constants.ts': `export const CONN = 'imported-1';`, }, '/app/foo.backend.ts', - ['foo'], ); expect(result.get('foo')).toEqual(['imported-1']); }); @@ -192,7 +263,7 @@ describe('extractConnectionIds', () => { test('resolves through a barrel re-export', async () => { const result = await run( { - '/app/foo.backend.ts': ` + '/app/foo.backend.ts': `${CATALOG_IMPORT} import { CONN } from './barrel'; export function foo() { request({ connectionId: CONN }); } `, @@ -200,7 +271,6 @@ describe('extractConnectionIds', () => { '/app/real.ts': `export const CONN = 'barrelled';`, }, '/app/foo.backend.ts', - ['foo'], ); expect(result.get('foo')).toEqual(['barrelled']); }); @@ -208,7 +278,7 @@ describe('extractConnectionIds', () => { test('resolves through `export { X as Y } from`', async () => { const result = await run( { - '/app/foo.backend.ts': ` + '/app/foo.backend.ts': `${CATALOG_IMPORT} import { CONN } from './barrel'; export function foo() { request({ connectionId: CONN }); } `, @@ -216,7 +286,6 @@ describe('extractConnectionIds', () => { '/app/real.ts': `export const INNER = 'renamed';`, }, '/app/foo.backend.ts', - ['foo'], ); expect(result.get('foo')).toEqual(['renamed']); }); @@ -224,7 +293,7 @@ describe('extractConnectionIds', () => { test('resolves through `import { X } from; export { X }`', async () => { const result = await run( { - '/app/foo.backend.ts': ` + '/app/foo.backend.ts': `${CATALOG_IMPORT} import { CONN } from './mid'; export function foo() { request({ connectionId: CONN }); } `, @@ -235,7 +304,6 @@ describe('extractConnectionIds', () => { '/app/real.ts': `export const CONN = 'relayed';`, }, '/app/foo.backend.ts', - ['foo'], ); expect(result.get('foo')).toEqual(['relayed']); }); @@ -243,7 +311,7 @@ describe('extractConnectionIds', () => { test('resolves via `export * from`', async () => { const result = await run( { - '/app/foo.backend.ts': ` + '/app/foo.backend.ts': `${CATALOG_IMPORT} import { CONN } from './barrel'; export function foo() { request({ connectionId: CONN }); } `, @@ -255,7 +323,6 @@ describe('extractConnectionIds', () => { '/app/real.ts': `export const CONN = 'star';`, }, '/app/foo.backend.ts', - ['foo'], ); expect(result.get('foo')).toEqual(['star']); }); @@ -264,7 +331,7 @@ describe('extractConnectionIds', () => { await expect( run( { - '/app/foo.backend.ts': ` + '/app/foo.backend.ts': `${CATALOG_IMPORT} import { CONN } from './a'; export function foo() { request({ connectionId: CONN }); } `, @@ -272,7 +339,6 @@ describe('extractConnectionIds', () => { '/app/b.ts': `export { CONN } from './a';`, }, '/app/foo.backend.ts', - ['foo'], ), ).rejects.toThrow(/cyclic re-export chain/); }); @@ -281,25 +347,73 @@ describe('extractConnectionIds', () => { await expect( run( { - '/app/foo.backend.ts': ` + '/app/foo.backend.ts': `${CATALOG_IMPORT} import { CONN } from './constants'; export function foo() { request({ connectionId: CONN }); } `, '/app/constants.ts': `export const OTHER = 'x';`, }, '/app/foo.backend.ts', - ['foo'], ), ).rejects.toThrow(/export 'CONN' not found/); }); }); + describe('callee scoping', () => { + test('ignores `connectionId` passed to non-action-catalog callees', async () => { + const result = await run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + import { logger } from './logger'; + export function foo() { + logger.info({ connectionId: process.env.WHATEVER }); + request({ connectionId: 'abc-123' }); + } + `, + '/app/logger.ts': `export const logger = { info: () => {} };`, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual(['abc-123']); + }); + + test('recognises namespace-imported action-catalog calls', async () => { + const result = await run( + { + '/app/foo.backend.ts': ` + import * as http from '@datadog/action-catalog/http/http'; + export function foo() { + http.request({ connectionId: 'abc-123' }); + } + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual(['abc-123']); + }); + + test('ignores a locally-defined function with the same name as a catalog call', async () => { + const result = await run( + { + '/app/foo.backend.ts': ` + function request(_opts) {} + export function foo() { + request({ connectionId: process.env.WHATEVER }); + } + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual([]); + }); + }); + describe('unresolvable forms throw', () => { test('dynamic template literal', async () => { await expect( run( { - '/app/foo.backend.ts': ` + '/app/foo.backend.ts': `${CATALOG_IMPORT} const prefix = 'a'; export function foo() { request({ connectionId: \`\${prefix}-b\` }); @@ -307,7 +421,6 @@ describe('extractConnectionIds', () => { `, }, '/app/foo.backend.ts', - ['foo'], ), ).rejects.toThrow(/must not contain interpolations/); }); @@ -316,14 +429,13 @@ describe('extractConnectionIds', () => { await expect( run( { - '/app/foo.backend.ts': ` + '/app/foo.backend.ts': `${CATALOG_IMPORT} export function foo() { request({ connectionId: 'a' + 'b' }); } `, }, '/app/foo.backend.ts', - ['foo'], ), ).rejects.toThrow(/must be a string literal/); }); @@ -332,14 +444,13 @@ describe('extractConnectionIds', () => { await expect( run( { - '/app/foo.backend.ts': ` + '/app/foo.backend.ts': `${CATALOG_IMPORT} export function foo() { request({ connectionId: process.env.CONN }); } `, }, '/app/foo.backend.ts', - ['foo'], ), ).rejects.toThrow(/must be a string literal/); }); @@ -348,14 +459,13 @@ describe('extractConnectionIds', () => { await expect( run( { - '/app/foo.backend.ts': ` + '/app/foo.backend.ts': `${CATALOG_IMPORT} export function foo() { request({ connectionId: getConn() }); } `, }, '/app/foo.backend.ts', - ['foo'], ), ).rejects.toThrow(/must be a string literal/); }); @@ -364,31 +474,76 @@ describe('extractConnectionIds', () => { await expect( run( { - '/app/foo.backend.ts': ` + '/app/foo.backend.ts': `${CATALOG_IMPORT} export function foo() { request({ connectionId: MYSTERY }); } `, }, '/app/foo.backend.ts', - ['foo'], ), ).rejects.toThrow(/not defined .* and is not imported/); }); + + test('let binding (reassignable)', async () => { + await expect( + run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + let CONN = 'initial'; + export function foo() { + request({ connectionId: CONN }); + } + `, + }, + '/app/foo.backend.ts', + ), + ).rejects.toThrow(/must resolve to a 'const' binding.*'let'/); + }); + + test('var binding (reassignable)', async () => { + await expect( + run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + var CONN = 'initial'; + export function foo() { + request({ connectionId: CONN }); + } + `, + }, + '/app/foo.backend.ts', + ), + ).rejects.toThrow(/must resolve to a 'const' binding.*'var'/); + }); + + test('imported let binding (from another file)', async () => { + await expect( + run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + import { CONN } from './mutable'; + export function foo() { request({ connectionId: CONN }); } + `, + '/app/mutable.ts': `export let CONN = 'initial';`, + }, + '/app/foo.backend.ts', + ), + ).rejects.toThrow(/must resolve to a 'const' binding.*'let'/); + }); }); describe('multiple exports', () => { test('extracts IDs per export independently', async () => { const result = await run( { - '/app/foo.backend.ts': ` + '/app/foo.backend.ts': `${CATALOG_IMPORT} const A = 'aaa'; export function foo() { request({ connectionId: A }); } export function bar() { request({ connectionId: 'bbb' }); } `, }, '/app/foo.backend.ts', - ['foo', 'bar'], ); expect(result.get('foo')).toEqual(['aaa']); expect(result.get('bar')).toEqual(['bbb']); @@ -399,12 +554,11 @@ describe('extractConnectionIds', () => { test('returns empty list when the export never mentions connectionId', async () => { const result = await run( { - '/app/foo.backend.ts': ` + '/app/foo.backend.ts': `${CATALOG_IMPORT} export function foo() { request({ url: '/x' }); } `, }, '/app/foo.backend.ts', - ['foo'], ); expect(result.get('foo')).toEqual([]); }); diff --git a/packages/plugins/apps/src/backend/extract-connection-ids.ts b/packages/plugins/apps/src/backend/extract-connection-ids.ts index 4af3bd0eb..fc1aa5451 100644 --- a/packages/plugins/apps/src/backend/extract-connection-ids.ts +++ b/packages/plugins/apps/src/backend/extract-connection-ids.ts @@ -12,19 +12,35 @@ import type { } from 'estree'; import type { AstNode, PluginContext } from 'rollup'; +import { enumerateBackendExports } from './discovery'; +import type { ExportedBinding } from './discovery'; + /** Hard safety cap — genuine cycles are caught by `visited`, this guards runaway chains. */ const MAX_HOPS = 16; /** - * Statically extract every `connectionId` value used inside each exported - * backend function. Values may be: + * Callees we scan for a static `connectionId` argument. We only care about + * calls into the action catalog (e.g. `@datadog/action-catalog/http/http`) + * because that's the only API whose server-side allowlist our manifest feeds. + * A prefix match covers every submodule path. + */ +const ACTION_CATALOG_PACKAGE = '@datadog/action-catalog'; + +/** + * Statically extract every `connectionId` value passed to an action-catalog + * call site inside each exported backend function. Values may be: * - inline string literal (`'abc'`) * - plain template literal with no interpolation (\`abc\`) * - identifier that resolves to a same-file `const` of those forms * - identifier that resolves via an import chain to a `const` of those forms * - * Any other form (dynamic template, call, concatenation, env var, …) throws - * with the source location so Vite surfaces a framed build error. + * Any other form (dynamic template, call, concatenation, env var, mutable + * binding, …) throws with the source location so Vite surfaces a framed + * build error. + * + * Exports whose body lives in another module (`import { x } from './y'; export { x }`) + * are emitted with an empty allowlist and a debug log; the server allowlist + * will still reject any mismatched calls at runtime. * * Returns a map keyed by export name → sorted, deduplicated connection IDs. */ @@ -32,139 +48,139 @@ export async function extractConnectionIds( ctx: PluginContext, ast: AstNode, filePath: string, - exportedNames: string[], ): Promise> { + const bindings = enumerateBackendExports(ast, filePath); + if (!isProgram(ast)) { - throw new Error( - `Expected a Program node from this.parse() for ${filePath}, got ${ast.type}`, - ); + // enumerateBackendExports already validated this, but narrow the type for the rest. + throw new Error(`Expected a Program node from this.parse() for ${filePath}`); } const symbols = buildSymbolTable(ast); - const bodyByExport = findExportedFunctionBodies(ast, exportedNames); const result = new Map(); - for (const name of exportedNames) { - const body = bodyByExport.get(name); - if (!body) { - // Export was declared but we couldn't locate its function body. - // This is a shape discovery already rejects — defensive empty entry. - result.set(name, []); + for (const binding of bindings) { + if (binding.kind === 'imported') { + result.set(binding.name, []); + logImportedSkip(ctx, filePath, binding); continue; } - const callSites = findConnectionIdValues(body); + const callSites = findConnectionIdValues(binding.body, symbols); const ids = new Set(); for (const { valueNode, keyLoc } of callSites) { const id = await resolveValue(ctx, valueNode, symbols, filePath, { visited: new Set(), hops: 0, - exportName: name, + exportName: binding.name, originFile: filePath, keyLoc, }); ids.add(id); } - result.set(name, [...ids].sort()); + result.set(binding.name, [...ids].sort()); } return result; } +function logImportedSkip(ctx: PluginContext, filePath: string, binding: ExportedBinding): void { + if (binding.kind !== 'imported') { + return; + } + const where = + binding.source === '' || binding.source === '' + ? `(${binding.source})` + : `from '${binding.source}'`; + const msg = + `[connectionId manifest] Export '${binding.name}' in ${filePath} is re-exported ${where} — ` + + `connection IDs cannot be statically traced across files. Manifest will allowlist no ` + + `connections for this export; the server will reject any mismatched calls at runtime.`; + if (typeof ctx.debug === 'function') { + ctx.debug(msg); + } +} + // ---------- AST helpers ---------- function isProgram(node: AstNode): node is AstNode & Program { return node.type === 'Program'; } +type MutableKind = 'let' | 'var'; + type LocalSymbols = { - /** Top-level `const X = ` bindings (and `let`/`var`). */ + /** Top-level `const X = ` bindings. Mutable (`let`/`var`) bindings are tracked + * separately so resolution can reject them. */ localConsts: Map; - /** Import specifiers: local name → `{ source, imported }`. `imported` is the remote name. */ + /** Top-level `let`/`var` bindings — carried so we can fail with a targeted error. */ + localMutables: Map; + /** Named imports: local name → `{ source, imported }`. */ importBindings: Map; + /** Namespace imports: local name → source module, e.g. `import * as http from '…'`. */ + namespaceImports: Map; }; +function isMutableKind(kind: string): kind is MutableKind { + return kind === 'let' || kind === 'var'; +} + /** - * Build a table of top-level symbols from a module's AST so later passes can - * resolve identifiers to their originating declaration. - * - * Two kinds of bindings are tracked: - * - `localConsts`: same-file `const`/`let`/`var` and `export const` declarations, - * mapped to their initializer expression (used to resolve local string constants). - * - `importBindings`: named imports, mapped to `{ source, imported }` so callers - * can follow the binding to another module. Default and namespace imports are - * intentionally skipped since they can't resolve to a statically-known value. + * Build a table of top-level symbols so later passes can resolve identifiers + * to their originating declaration and gate call-site scanning by callee origin. */ function buildSymbolTable(ast: Program): LocalSymbols { const localConsts = new Map(); + const localMutables = new Map(); const importBindings = new Map(); + const namespaceImports = new Map(); + + const recordVariableDeclaration = (decl: { + kind: string; + declarations: { id: { type: string; name?: string; loc?: Node['loc'] }; init?: unknown }[]; + }): void => { + // e.g. `const MY_ID = 'abc-123';` / `let X = …;` / `using y = …;` (unsupported — skipped) + for (const d of decl.declarations) { + if (d.id.type !== 'Identifier' || !d.id.name) { + continue; + } + if (decl.kind === 'const' && d.init) { + localConsts.set(d.id.name, d.init as Expression); + } else if (isMutableKind(decl.kind)) { + localMutables.set(d.id.name, { kind: decl.kind, loc: d.id.loc ?? null }); + } + // `using` / `await using` — ignored; they can't hold a static string we'd accept. + } + }; for (const node of ast.body) { if (node.type === 'VariableDeclaration') { - // e.g. `const MY_ID = 'abc-123';` - // or multi-declarator: `const A = 'x', B = 'y';` — one VariableDeclaration, two declarators. - for (const d of node.declarations) { - if (d.id.type === 'Identifier' && d.init) { - localConsts.set(d.id.name, d.init); - } - } + recordVariableDeclaration(node); } else if (node.type === 'ImportDeclaration' && typeof node.source.value === 'string') { - // e.g. `import { MY_ID } from './constants';` const source = node.source.value; for (const spec of node.specifiers) { if (spec.type === 'ImportSpecifier') { + // e.g. `import { MY_ID } from './constants';` const imported = spec.imported.type === 'Identifier' ? spec.imported.name : String(spec.imported.value); importBindings.set(spec.local.name, { source, imported }); + } else if (spec.type === 'ImportNamespaceSpecifier') { + // e.g. `import * as http from '@datadog/action-catalog/http/http';` + namespaceImports.set(spec.local.name, source); } - // ImportDefaultSpecifier / ImportNamespaceSpecifier intentionally skipped: - // they can't resolve to a statically-known string constant we'd accept. + // ImportDefaultSpecifier intentionally skipped — no statically-known value. } } else if (node.type === 'ExportNamedDeclaration' && node.declaration) { - // e.g. `export const MY_ID = 'abc-123';` - // `export const X = ` — also track so same-file exported consts resolve. + // e.g. `export const MY_ID = 'abc-123';` / `export let …` if (node.declaration.type === 'VariableDeclaration') { - for (const d of node.declaration.declarations) { - if (d.id.type === 'Identifier' && d.init) { - localConsts.set(d.id.name, d.init); - } - } + recordVariableDeclaration(node.declaration); } } } - return { localConsts, importBindings }; -} - -/** Map export name → function body node. Supports `export function f(){}` and `export const f = () => {}`. */ -function findExportedFunctionBodies(ast: Program, names: string[]): Map { - const wanted = new Set(names); - const out = new Map(); - - for (const node of ast.body) { - if (node.type !== 'ExportNamedDeclaration' || !node.declaration) { - continue; - } - const decl = node.declaration; - if (decl.type === 'FunctionDeclaration' && decl.id && wanted.has(decl.id.name)) { - out.set(decl.id.name, decl.body); - } else if (decl.type === 'VariableDeclaration') { - for (const d of decl.declarations) { - if (d.id.type !== 'Identifier' || !wanted.has(d.id.name) || !d.init) { - continue; - } - if ( - d.init.type === 'ArrowFunctionExpression' || - d.init.type === 'FunctionExpression' - ) { - out.set(d.id.name, d.init.body); - } - } - } - } - return out; + return { localConsts, localMutables, importBindings, namespaceImports }; } type ConnectionIdCallSite = { @@ -173,11 +189,17 @@ type ConnectionIdCallSite = { }; /** - * Walk a function body for every CallExpression whose first argument is an - * ObjectExpression containing a `connectionId` property — record the value node. - * Nested functions are walked too (we don't restrict to the top scope). + * Walk a function body collecting every action-catalog call site whose first + * argument object contains a `connectionId` property, recording the value node. + * + * Callees are considered "action catalog" when: + * - a direct identifier is bound to a named import from `@datadog/action-catalog`, or + * - a member expression's object is a namespace import from `@datadog/action-catalog`. + * + * Unrelated calls (e.g. `logger.info({ connectionId })`) are ignored so users + * can legitimately use the `connectionId` key in their own code. */ -function findConnectionIdValues(root: Node): ConnectionIdCallSite[] { +function findConnectionIdValues(root: Node, symbols: LocalSymbols): ConnectionIdCallSite[] { const out: ConnectionIdCallSite[] = []; const visit = (node: Node | null | undefined): void => { @@ -194,7 +216,7 @@ function findConnectionIdValues(root: Node): ConnectionIdCallSite[] { return; } - if (node.type === 'CallExpression') { + if (node.type === 'CallExpression' && isActionCatalogCallee(node.callee, symbols)) { const firstArg = node.arguments[0]; if (firstArg && firstArg.type === 'ObjectExpression') { const prop = findConnectionIdProp(firstArg); @@ -219,6 +241,20 @@ function findConnectionIdValues(root: Node): ConnectionIdCallSite[] { return out; } +function isActionCatalogCallee(callee: Node, symbols: LocalSymbols): boolean { + // e.g. `request({ … })` where `request` is imported from `@datadog/action-catalog/*` + if (callee.type === 'Identifier') { + const imp = symbols.importBindings.get(callee.name); + return imp !== undefined && imp.source.startsWith(ACTION_CATALOG_PACKAGE); + } + // e.g. `http.request({ … })` where `http` is `import * as http from '@datadog/action-catalog/…'` + if (callee.type === 'MemberExpression' && callee.object.type === 'Identifier') { + const ns = symbols.namespaceImports.get(callee.object.name); + return ns !== undefined && ns.startsWith(ACTION_CATALOG_PACKAGE); + } + return false; +} + function findConnectionIdProp(obj: ObjectExpression): Property | undefined { for (const p of obj.properties) { if (p.type !== 'Property') { @@ -314,15 +350,10 @@ function requireStaticTemplate(node: TemplateLiteral, state: ResolutionState): s } /** - * Resolve an identifier `name` to its static string value by following its - * binding in the current file's symbol table. - * - * Three outcomes: - * - Bound to a same-file `const`/`let`/`var` (or `export const`): recurse into - * {@link resolveValue} on that initializer, staying in this file. - * - Bound to a named import: hand off to {@link resolveCrossFile} to load and - * trace the source module. - * - Not bound anywhere: fail with the identifier's source location. + * Resolve an identifier by following its binding. `const` bindings recurse; + * `let`/`var` bindings fail because their runtime value can drift from the + * initializer we'd read; imports hand off to {@link resolveCrossFile}; + * unresolved names fail. */ async function resolveIdentifier( ctx: PluginContext, @@ -332,6 +363,14 @@ async function resolveIdentifier( state: ResolutionState, loc: Node['loc'], ): Promise { + const mutable = symbols.localMutables.get(name); + if (mutable) { + fail( + state, + `'connectionId' must resolve to a 'const' binding; '${name}' is declared with '${mutable.kind}' and can be reassigned`, + loc, + ); + } const localInit = symbols.localConsts.get(name); if (localInit) { return resolveValue(ctx, localInit, symbols, currentFile, state); @@ -439,8 +478,14 @@ async function resolveCrossFile( } } - // `export const X = ` / `export function X(){}` + // `export const X = ` if (node.declaration?.type === 'VariableDeclaration') { + if (node.declaration.kind !== 'const') { + fail( + state, + `'connectionId' must resolve to a 'const' binding; '${importedName}' in '${targetId}' is declared with '${node.declaration.kind}'`, + ); + } for (const d of node.declaration.declarations) { if (d.id.type === 'Identifier' && d.id.name === importedName && d.init) { return resolveValue(ctx, d.init, targetSymbols, targetId, nextState); diff --git a/packages/plugins/apps/src/index.ts b/packages/plugins/apps/src/index.ts index f954268eb..c74c2dd3a 100644 --- a/packages/plugins/apps/src/index.ts +++ b/packages/plugins/apps/src/index.ts @@ -279,7 +279,6 @@ Either: this as unknown as PluginContext, ast, id, - exportNames, ); const { functions, proxyCode } = buildProxyModule( From d304370c625cf085fb0a737492357a45bb668764 Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Tue, 5 May 2026 12:49:37 -0400 Subject: [PATCH 4/5] Add module graph connection ID analysis --- packages/plugins/apps/package.json | 7 +- .../backend/extract-connection-ids.test.ts | 175 +++- .../src/backend/extract-connection-ids.ts | 915 ++++++++++++------ packages/plugins/apps/src/estree-walker.d.ts | 17 + packages/plugins/apps/src/index.ts | 1 + .../plugins/apps/src/vite/dev-server.test.ts | 25 +- packages/plugins/apps/src/vite/dev-server.ts | 17 +- yarn.lock | 3 +- 8 files changed, 821 insertions(+), 339 deletions(-) create mode 100644 packages/plugins/apps/src/estree-walker.d.ts diff --git a/packages/plugins/apps/package.json b/packages/plugins/apps/package.json index 2a737b1e2..2b520d481 100644 --- a/packages/plugins/apps/package.json +++ b/packages/plugins/apps/package.json @@ -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" } } diff --git a/packages/plugins/apps/src/backend/extract-connection-ids.test.ts b/packages/plugins/apps/src/backend/extract-connection-ids.test.ts index 0e4ae2a6d..a668abd4b 100644 --- a/packages/plugins/apps/src/backend/extract-connection-ids.test.ts +++ b/packages/plugins/apps/src/backend/extract-connection-ids.test.ts @@ -213,7 +213,7 @@ describe('extractConnectionIds', () => { expect(result.get('bar')).toEqual(['abc-123']); }); - test('emits [] for `import { handler } from "./x"; export { handler }`', async () => { + test('traverses `import { handler } from "./x"; export { handler }`', async () => { const result = await run( { '/app/foo.backend.ts': ` @@ -226,10 +226,10 @@ describe('extractConnectionIds', () => { }, '/app/foo.backend.ts', ); - expect(result.get('handler')).toEqual([]); + expect(result.get('handler')).toEqual(['abc-123']); }); - test('emits [] for `export { X } from "./x"` re-exports', async () => { + test('traverses `export { X } from "./x"` re-exports', async () => { const result = await run( { '/app/foo.backend.ts': ` @@ -241,7 +241,7 @@ describe('extractConnectionIds', () => { }, '/app/foo.backend.ts', ); - expect(result.get('handler')).toEqual([]); + expect(result.get('handler')).toEqual(['abc-123']); }); }); @@ -340,7 +340,7 @@ describe('extractConnectionIds', () => { }, '/app/foo.backend.ts', ), - ).rejects.toThrow(/cyclic re-export chain/); + ).rejects.toThrow(/cyclic re-export or import chain/); }); test('throws with clear message when export not found', async () => { @@ -392,6 +392,21 @@ describe('extractConnectionIds', () => { expect(result.get('foo')).toEqual(['abc-123']); }); + test('recognises default-imported action-catalog calls', async () => { + const result = await run( + { + '/app/foo.backend.ts': ` + import request from '@datadog/action-catalog/http/http'; + export function foo() { + request({ connectionId: 'abc-123' }); + } + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual(['abc-123']); + }); + test('ignores a locally-defined function with the same name as a catalog call', async () => { const result = await run( { @@ -437,7 +452,7 @@ describe('extractConnectionIds', () => { }, '/app/foo.backend.ts', ), - ).rejects.toThrow(/must be a string literal/); + ).rejects.toThrow(/must be a static string/); }); test('env var (member expression)', async () => { @@ -452,7 +467,7 @@ describe('extractConnectionIds', () => { }, '/app/foo.backend.ts', ), - ).rejects.toThrow(/must be a string literal/); + ).rejects.toThrow(/member expressions must read from a const object/); }); test('function call', async () => { @@ -467,7 +482,7 @@ describe('extractConnectionIds', () => { }, '/app/foo.backend.ts', ), - ).rejects.toThrow(/must be a string literal/); + ).rejects.toThrow(/must be a static string/); }); test('undefined identifier', async () => { @@ -534,7 +549,7 @@ describe('extractConnectionIds', () => { }); describe('multiple exports', () => { - test('extracts IDs per export independently', async () => { + test('applies the file-level module graph allowlist to every export', async () => { const result = await run( { '/app/foo.backend.ts': `${CATALOG_IMPORT} @@ -545,8 +560,146 @@ describe('extractConnectionIds', () => { }, '/app/foo.backend.ts', ); - expect(result.get('foo')).toEqual(['aaa']); - expect(result.get('bar')).toEqual(['bbb']); + expect(result.get('foo')).toEqual(['aaa', 'bbb']); + expect(result.get('bar')).toEqual(['aaa', 'bbb']); + }); + }); + + describe('reachable module graph', () => { + test('includes action calls inside same-file helpers', async () => { + const result = await run( + { + '/app/foo.backend.ts': `${CATALOG_IMPORT} + function helper() { request({ connectionId: 'helper-id' }); } + export function foo() { return helper(); } + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual(['helper-id']); + }); + + test('includes action calls inside imported helpers', async () => { + const result = await run( + { + '/app/foo.backend.ts': ` + import { getHosts } from './helpers/hosts'; + export function foo() { return getHosts(); } + `, + '/app/helpers/hosts.ts': `${CATALOG_IMPORT} + export function getHosts() { + return request({ connectionId: 'helper-id' }); + } + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual(['helper-id']); + }); + + test('includes action calls from static re-export sources', async () => { + const result = await run( + { + '/app/foo.backend.ts': ` + export { getHosts } from './helpers'; + export function foo() {} + `, + '/app/helpers.ts': ` + export { getHosts } from './real-helper'; + `, + '/app/real-helper.ts': `${CATALOG_IMPORT} + export function getHosts() { + return request({ connectionId: 'reexport-helper-id' }); + } + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual(['reexport-helper-id']); + expect(result.get('getHosts')).toEqual(['reexport-helper-id']); + }); + + test('resolves imported CONNECTIONS object member access', async () => { + const result = await run( + { + '/app/foo.backend.ts': ` + import { getHosts } from './helpers/hosts'; + export function foo() { return getHosts(); } + `, + '/app/helpers/hosts.ts': `${CATALOG_IMPORT} + import { CONNECTIONS } from '../connections'; + export function getHosts() { + return request({ connectionId: CONNECTIONS.DD }); + } + `, + '/app/connections.ts': ` + export const CONNECTIONS = { + DD: 'dd-connection', + OTHER: 'other', + }; + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual(['dd-connection']); + }); + + test('handles cycles in the reachable module graph', async () => { + const result = await run( + { + '/app/foo.backend.ts': ` + import { helper } from './a'; + export function foo() { return helper(); } + `, + '/app/a.ts': ` + import './b'; + export function helper() {} + `, + '/app/b.ts': ` + import './a'; + ${CATALOG_IMPORT} + request({ connectionId: 'cycle-id' }); + `, + }, + '/app/foo.backend.ts', + ); + expect(result.get('foo')).toEqual(['cycle-id']); + }); + + test('rejects dynamic local imports', async () => { + await expect( + run( + { + '/app/foo.backend.ts': ` + export async function foo() { + await import('./helpers'); + } + `, + '/app/helpers.ts': `${CATALOG_IMPORT} + request({ connectionId: 'hidden' }); + `, + }, + '/app/foo.backend.ts', + ), + ).rejects.toThrow(/dynamic import of local module/); + }); + + test('rejects local require calls', async () => { + await expect( + run( + { + '/app/foo.backend.ts': ` + export function foo() { + require('./helpers'); + } + `, + '/app/helpers.ts': `${CATALOG_IMPORT} + request({ connectionId: 'hidden' }); + `, + }, + '/app/foo.backend.ts', + ), + ).rejects.toThrow(/require of local module/); }); }); diff --git a/packages/plugins/apps/src/backend/extract-connection-ids.ts b/packages/plugins/apps/src/backend/extract-connection-ids.ts index fc1aa5451..8b61c20ab 100644 --- a/packages/plugins/apps/src/backend/extract-connection-ids.ts +++ b/packages/plugins/apps/src/backend/extract-connection-ids.ts @@ -2,368 +2,556 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +import { walk } from 'estree-walker'; import type { + CallExpression, + ExportNamedDeclaration, Expression, + ImportDeclaration, + ImportSpecifier, + MemberExpression, Node, ObjectExpression, Program, Property, TemplateLiteral, } from 'estree'; +import fsp from 'fs/promises'; +import path from 'path'; import type { AstNode, PluginContext } from 'rollup'; +import { transformWithEsbuild } from 'vite'; import { enumerateBackendExports } from './discovery'; -import type { ExportedBinding } from './discovery'; -/** Hard safety cap — genuine cycles are caught by `visited`, this guards runaway chains. */ -const MAX_HOPS = 16; - -/** - * Callees we scan for a static `connectionId` argument. We only care about - * calls into the action catalog (e.g. `@datadog/action-catalog/http/http`) - * because that's the only API whose server-side allowlist our manifest feeds. - * A prefix match covers every submodule path. - */ +const MAX_HOPS = 32; const ACTION_CATALOG_PACKAGE = '@datadog/action-catalog'; +const GENERATED_SEGMENT_RE = /[/\\](?:dist|build|\.vite)(?:[/\\]|$)/; + +type MutableKind = 'let' | 'var'; + +type ImportBinding = { + source: string; + imported: string; +}; + +type LocalSymbols = { + localConsts: Map; + localMutables: Map; + importBindings: Map; + namespaceImports: Map; + actionFunctions: Set; + actionNamespaces: Set; +}; + +type ParsedModule = { + id: string; + ast: Program; + symbols: LocalSymbols; +}; + +type ResolutionState = { + visited: Set; + hops: number; + originFile: string; + label: string; +}; + +type ConnectionIdCallSite = { + module: ParsedModule; + valueNode: Expression; + loc: Node['loc']; +}; + +class ExtractionError extends Error {} + +class ExportNotFoundError extends ExtractionError {} /** - * Statically extract every `connectionId` value passed to an action-catalog - * call site inside each exported backend function. Values may be: - * - inline string literal (`'abc'`) - * - plain template literal with no interpolation (\`abc\`) - * - identifier that resolves to a same-file `const` of those forms - * - identifier that resolves via an import chain to a `const` of those forms - * - * Any other form (dynamic template, call, concatenation, env var, mutable - * binding, …) throws with the source location so Vite surfaces a framed - * build error. + * Statically extract every action-catalog `connectionId` used by the local + * module graph reachable from one `*.backend.*` entry file. * - * Exports whose body lives in another module (`import { x } from './y'; export { x }`) - * are emitted with an empty allowlist and a debug log; the server allowlist - * will still reject any mismatched calls at runtime. - * - * Returns a map keyed by export name → sorted, deduplicated connection IDs. + * The result is intentionally file-level: every supported backend export in the + * entry file receives the same sorted allowlist. This mirrors the conservative + * module-graph design: if a reachable helper module contains an action call, any + * export from the backend entry may be able to reach it at runtime. */ export async function extractConnectionIds( ctx: PluginContext, ast: AstNode, filePath: string, + buildRoot = path.dirname(filePath), ): Promise> { const bindings = enumerateBackendExports(ast, filePath); - if (!isProgram(ast)) { - // enumerateBackendExports already validated this, but narrow the type for the rest. throw new Error(`Expected a Program node from this.parse() for ${filePath}`); } - const symbols = buildSymbolTable(ast); - const result = new Map(); - - for (const binding of bindings) { - if (binding.kind === 'imported') { - result.set(binding.name, []); - logImportedSkip(ctx, filePath, binding); - continue; + const modules = await buildReachableModuleGraph(ctx, ast, filePath, buildRoot); + const ids = new Set(); + + for (const mod of modules) { + for (const callSite of findConnectionIdCallSites(mod)) { + ids.add( + await resolveValue(ctx, callSite.valueNode, callSite.module, { + visited: new Set(), + hops: 0, + originFile: filePath, + label: 'module graph', + }), + ); } - - const callSites = findConnectionIdValues(binding.body, symbols); - const ids = new Set(); - for (const { valueNode, keyLoc } of callSites) { - const id = await resolveValue(ctx, valueNode, symbols, filePath, { - visited: new Set(), - hops: 0, - exportName: binding.name, - originFile: filePath, - keyLoc, - }); - ids.add(id); - } - result.set(binding.name, [...ids].sort()); } - return result; + const sortedIds = [...ids].sort(); + return new Map(bindings.map((binding) => [binding.name, sortedIds])); } -function logImportedSkip(ctx: PluginContext, filePath: string, binding: ExportedBinding): void { - if (binding.kind !== 'imported') { - return; - } - const where = - binding.source === '' || binding.source === '' - ? `(${binding.source})` - : `from '${binding.source}'`; - const msg = - `[connectionId manifest] Export '${binding.name}' in ${filePath} is re-exported ${where} — ` + - `connection IDs cannot be statically traced across files. Manifest will allowlist no ` + - `connections for this export; the server will reject any mismatched calls at runtime.`; - if (typeof ctx.debug === 'function') { - ctx.debug(msg); - } +function isProgram(node: AstNode): node is AstNode & Program { + return node.type === 'Program'; } -// ---------- AST helpers ---------- +function isMutableKind(kind: string): kind is MutableKind { + return kind === 'let' || kind === 'var'; +} -function isProgram(node: AstNode): node is AstNode & Program { - return node.type === 'Program'; +function isTypeOnlyImport(node: ImportDeclaration): boolean { + return (node as ImportDeclaration & { importKind?: string }).importKind === 'type'; } -type MutableKind = 'let' | 'var'; +function isTypeOnlyImportSpecifier(node: ImportSpecifier): boolean { + return (node as ImportSpecifier & { importKind?: string }).importKind === 'type'; +} -type LocalSymbols = { - /** Top-level `const X = ` bindings. Mutable (`let`/`var`) bindings are tracked - * separately so resolution can reject them. */ - localConsts: Map; - /** Top-level `let`/`var` bindings — carried so we can fail with a targeted error. */ - localMutables: Map; - /** Named imports: local name → `{ source, imported }`. */ - importBindings: Map; - /** Namespace imports: local name → source module, e.g. `import * as http from '…'`. */ - namespaceImports: Map; -}; +function isTypeOnlyExport(node: ExportNamedDeclaration): boolean { + return (node as ExportNamedDeclaration & { exportKind?: string }).exportKind === 'type'; +} -function isMutableKind(kind: string): kind is MutableKind { - return kind === 'let' || kind === 'var'; +function isActionCatalogSource(source: string): boolean { + return source === ACTION_CATALOG_PACKAGE || source.startsWith(`${ACTION_CATALOG_PACKAGE}/`); +} + +function isLocalSourceSpecifier(source: string): boolean { + return source.startsWith('.') || source.startsWith('/'); +} + +function stripQuery(id: string): string { + return id.replace(/\?.*$/, ''); +} + +function toPosix(id: string): string { + return id.split(path.sep).join('/'); +} + +function isInsideBuildRoot(id: string, buildRoot: string): boolean { + const rel = path.relative(buildRoot, stripQuery(id)); + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); +} + +function isGeneratedOutput(id: string): boolean { + return GENERATED_SEGMENT_RE.test(stripQuery(id)); +} + +function shouldTraverseResolvedId(id: string, buildRoot: string): boolean { + const cleanId = stripQuery(id); + if (cleanId.includes('/node_modules/') || cleanId.includes('\\node_modules\\')) { + return false; + } + if (cleanId.startsWith('\0')) { + return false; + } + if (!isInsideBuildRoot(cleanId, buildRoot)) { + return false; + } + return !isGeneratedOutput(cleanId); } -/** - * Build a table of top-level symbols so later passes can resolve identifiers - * to their originating declaration and gate call-site scanning by callee origin. - */ function buildSymbolTable(ast: Program): LocalSymbols { const localConsts = new Map(); const localMutables = new Map(); - const importBindings = new Map(); + const importBindings = new Map(); const namespaceImports = new Map(); + const actionFunctions = new Set(); + const actionNamespaces = new Set(); const recordVariableDeclaration = (decl: { kind: string; - declarations: { id: { type: string; name?: string; loc?: Node['loc'] }; init?: unknown }[]; + declarations: Array<{ + id: Node; + init?: Expression | null; + }>; }): void => { - // e.g. `const MY_ID = 'abc-123';` / `let X = …;` / `using y = …;` (unsupported — skipped) for (const d of decl.declarations) { - if (d.id.type !== 'Identifier' || !d.id.name) { + if (d.id.type !== 'Identifier') { continue; } if (decl.kind === 'const' && d.init) { - localConsts.set(d.id.name, d.init as Expression); + localConsts.set(d.id.name, d.init); } else if (isMutableKind(decl.kind)) { localMutables.set(d.id.name, { kind: decl.kind, loc: d.id.loc ?? null }); } - // `using` / `await using` — ignored; they can't hold a static string we'd accept. } }; for (const node of ast.body) { if (node.type === 'VariableDeclaration') { recordVariableDeclaration(node); - } else if (node.type === 'ImportDeclaration' && typeof node.source.value === 'string') { + } else if ( + node.type === 'ExportNamedDeclaration' && + node.declaration?.type === 'VariableDeclaration' + ) { + recordVariableDeclaration(node.declaration); + } else if ( + node.type === 'ImportDeclaration' && + !isTypeOnlyImport(node) && + typeof node.source.value === 'string' + ) { const source = node.source.value; for (const spec of node.specifiers) { if (spec.type === 'ImportSpecifier') { - // e.g. `import { MY_ID } from './constants';` + if (isTypeOnlyImportSpecifier(spec)) { + continue; + } const imported = spec.imported.type === 'Identifier' ? spec.imported.name : String(spec.imported.value); - importBindings.set(spec.local.name, { source, imported }); + if (isActionCatalogSource(source)) { + actionFunctions.add(spec.local.name); + } else { + importBindings.set(spec.local.name, { source, imported }); + } + } else if (spec.type === 'ImportDefaultSpecifier') { + if (isActionCatalogSource(source)) { + actionFunctions.add(spec.local.name); + } else { + importBindings.set(spec.local.name, { source, imported: 'default' }); + } } else if (spec.type === 'ImportNamespaceSpecifier') { - // e.g. `import * as http from '@datadog/action-catalog/http/http';` - namespaceImports.set(spec.local.name, source); + if (isActionCatalogSource(source)) { + actionNamespaces.add(spec.local.name); + } else { + namespaceImports.set(spec.local.name, source); + } } - // ImportDefaultSpecifier intentionally skipped — no statically-known value. } - } else if (node.type === 'ExportNamedDeclaration' && node.declaration) { - // e.g. `export const MY_ID = 'abc-123';` / `export let …` - if (node.declaration.type === 'VariableDeclaration') { - recordVariableDeclaration(node.declaration); + } + } + + return { + localConsts, + localMutables, + importBindings, + namespaceImports, + actionFunctions, + actionNamespaces, + }; +} + +async function buildReachableModuleGraph( + ctx: PluginContext, + entryAst: Program, + entryId: string, + buildRoot: string, +): Promise { + const normalizedBuildRoot = stripQuery(buildRoot); + const cache = new Map(); + const ordered: ParsedModule[] = []; + const queue: ParsedModule[] = []; + + const entry = makeParsedModule(entryId, entryAst); + cache.set(entryId, entry); + ordered.push(entry); + queue.push(entry); + + for (let i = 0; i < queue.length; i += 1) { + const mod = queue[i]; + assertNoUnsupportedDynamicLocalDependencies(mod); + + for (const source of collectStaticDependencySpecifiers(mod.ast)) { + if (isActionCatalogSource(source)) { + continue; + } + const resolvedId = await resolveModuleId(ctx, mod.id, source, { + required: isLocalSourceSpecifier(source), + }); + if (!resolvedId || !shouldTraverseResolvedId(resolvedId, normalizedBuildRoot)) { + continue; + } + if (cache.has(resolvedId)) { + continue; } + const loaded = await loadParsedModule(ctx, resolvedId); + cache.set(resolvedId, loaded); + ordered.push(loaded); + queue.push(loaded); } } - return { localConsts, localMutables, importBindings, namespaceImports }; + return ordered; } -type ConnectionIdCallSite = { - valueNode: Expression; - keyLoc: Node['loc']; -}; +function makeParsedModule(id: string, ast: Program): ParsedModule { + return { id: toPosix(id), ast, symbols: buildSymbolTable(ast) }; +} -/** - * Walk a function body collecting every action-catalog call site whose first - * argument object contains a `connectionId` property, recording the value node. - * - * Callees are considered "action catalog" when: - * - a direct identifier is bound to a named import from `@datadog/action-catalog`, or - * - a member expression's object is a namespace import from `@datadog/action-catalog`. - * - * Unrelated calls (e.g. `logger.info({ connectionId })`) are ignored so users - * can legitimately use the `connectionId` key in their own code. - */ -function findConnectionIdValues(root: Node, symbols: LocalSymbols): ConnectionIdCallSite[] { - const out: ConnectionIdCallSite[] = []; +function collectStaticDependencySpecifiers(ast: Program): string[] { + const sources: string[] = []; + for (const node of ast.body) { + if ( + node.type === 'ImportDeclaration' && + !isTypeOnlyImport(node) && + typeof node.source.value === 'string' + ) { + sources.push(node.source.value); + } else if ( + node.type === 'ExportNamedDeclaration' && + !isTypeOnlyExport(node) && + node.source && + typeof node.source.value === 'string' + ) { + sources.push(node.source.value); + } else if (node.type === 'ExportAllDeclaration' && typeof node.source.value === 'string') { + sources.push(node.source.value); + } + } + return sources; +} - const visit = (node: Node | null | undefined): void => { - if (!node || typeof node !== 'object') { - return; +async function resolveModuleId( + ctx: PluginContext, + importer: string, + source: string, + opts: { required: boolean }, +): Promise { + const resolved = await ctx.resolve(source, importer, { skipSelf: false }); + if (!resolved || resolved.external) { + if (opts.required) { + throw new ExtractionError( + `[connectionId manifest] could not resolve local module '${source}' imported from ${importer}`, + ); } - if (Array.isArray(node)) { - for (const c of node as unknown as Node[]) { - visit(c); - } - return; + return undefined; + } + return toPosix(resolved.id); +} + +async function loadParsedModule(ctx: PluginContext, id: string): Promise { + const { code, ast: loadedAst } = await loadModule(ctx, id); + if (code === null || code === undefined) { + throw new ExtractionError( + `[connectionId manifest] module '${id}' produced no code during module graph analysis`, + ); + } + const ast = (loadedAst || ctx.parse(code)) as unknown as Program; + return makeParsedModule(id, ast); +} + +async function loadModule( + ctx: PluginContext, + id: string, +): Promise<{ code: string | null | undefined; ast?: AstNode | null }> { + try { + const loaded = await ctx.load({ id }); + if (typeof loaded === 'string') { + return { code: loaded, ast: null }; } - if (!('type' in node)) { - return; + return { code: loaded.code, ast: loaded.ast }; + } catch (error) { + if (!isUnsupportedModuleInfoCodeError(error)) { + throw error; } + } - if (node.type === 'CallExpression' && isActionCatalogCallee(node.callee, symbols)) { - const firstArg = node.arguments[0]; - if (firstArg && firstArg.type === 'ObjectExpression') { - const prop = findConnectionIdProp(firstArg); - if (prop) { - out.push({ - valueNode: prop.value as Expression, - keyLoc: prop.loc ?? null, - }); + const source = await fsp.readFile(stripQuery(id), 'utf8'); + const transformed = await transformWithEsbuild(source, stripQuery(id), { + loader: getEsbuildLoader(id), + sourcemap: false, + target: 'esnext', + }); + return { code: transformed.code, ast: null }; +} + +function isUnsupportedModuleInfoCodeError(error: unknown): boolean { + return ( + error instanceof Error && + error.message.includes('The "code" property of ModuleInfo is not supported') + ); +} + +function getEsbuildLoader(id: string): 'js' | 'jsx' | 'ts' | 'tsx' { + const ext = path.extname(stripQuery(id)); + if (ext === '.tsx') { + return 'tsx'; + } + if (ext === '.ts' || ext === '.mts' || ext === '.cts') { + return 'ts'; + } + if (ext === '.jsx') { + return 'jsx'; + } + return 'js'; +} + +function assertNoUnsupportedDynamicLocalDependencies(mod: ParsedModule): void { + walk(mod.ast, { + enter(node) { + if (isDynamicImportExpression(node)) { + const source = node.source; + if (!source || source.type !== 'Literal' || typeof source.value !== 'string') { + failBase( + `dynamic import in ${mod.id} cannot be statically analyzed for backend connection IDs`, + node.loc, + mod.id, + ); + } + if (isLocalSourceSpecifier(source.value)) { + failBase( + `dynamic import of local module '${source.value}' in ${mod.id} cannot be statically analyzed for backend connection IDs`, + node.loc, + mod.id, + ); } } - } - - for (const key of Object.keys(node)) { - if (key === 'loc' || key === 'range' || key === 'parent') { - continue; + if (node.type === 'CallExpression' && isRequireCall(node)) { + const source = node.arguments[0]; + if (!source || source.type !== 'Literal' || typeof source.value !== 'string') { + failBase( + `dynamic require in ${mod.id} cannot be statically analyzed for backend connection IDs`, + node.loc, + mod.id, + ); + } + if (isLocalSourceSpecifier(source.value)) { + failBase( + `require of local module '${source.value}' in ${mod.id} cannot be statically analyzed for backend connection IDs`, + node.loc, + mod.id, + ); + } } - visit((node as unknown as Record)[key]); - } - }; + }, + }); +} + +function isDynamicImportExpression(node: Node): node is Node & { source?: Expression } { + return (node as { type: string }).type === 'ImportExpression'; +} - visit(root); - return out; +function isRequireCall(node: CallExpression): boolean { + return ( + node.callee.type === 'Identifier' && + node.callee.name === 'require' && + node.arguments.length > 0 + ); +} + +function findConnectionIdCallSites(mod: ParsedModule): ConnectionIdCallSite[] { + const callSites: ConnectionIdCallSite[] = []; + walk(mod.ast, { + enter(node) { + if ( + node.type !== 'CallExpression' || + !isActionCatalogCallee(node.callee, mod.symbols) + ) { + return; + } + const firstArg = node.arguments[0]; + if (!firstArg || firstArg.type !== 'ObjectExpression') { + return; + } + const prop = findConnectionIdProp(firstArg); + if (!prop) { + return; + } + callSites.push({ + module: mod, + valueNode: prop.value as Expression, + loc: prop.loc ?? null, + }); + }, + }); + return callSites; } function isActionCatalogCallee(callee: Node, symbols: LocalSymbols): boolean { - // e.g. `request({ … })` where `request` is imported from `@datadog/action-catalog/*` if (callee.type === 'Identifier') { - const imp = symbols.importBindings.get(callee.name); - return imp !== undefined && imp.source.startsWith(ACTION_CATALOG_PACKAGE); + return symbols.actionFunctions.has(callee.name); } - // e.g. `http.request({ … })` where `http` is `import * as http from '@datadog/action-catalog/…'` - if (callee.type === 'MemberExpression' && callee.object.type === 'Identifier') { - const ns = symbols.namespaceImports.get(callee.object.name); - return ns !== undefined && ns.startsWith(ACTION_CATALOG_PACKAGE); + if (callee.type !== 'MemberExpression') { + return false; } - return false; + const root = getMemberRoot(callee); + return root ? symbols.actionNamespaces.has(root.name) : false; +} + +function getMemberRoot(member: MemberExpression): { name: string } | undefined { + let current = member.object; + while (current.type === 'MemberExpression') { + current = current.object; + } + return current.type === 'Identifier' ? current : undefined; } function findConnectionIdProp(obj: ObjectExpression): Property | undefined { - for (const p of obj.properties) { - if (p.type !== 'Property') { - continue; - } - if (p.computed) { + for (const prop of obj.properties) { + if (prop.type !== 'Property' || prop.computed) { continue; } - const key = p.key; - if (key.type === 'Identifier' && key.name === 'connectionId') { - return p; + if (prop.key.type === 'Identifier' && prop.key.name === 'connectionId') { + return prop; } - if (key.type === 'Literal' && key.value === 'connectionId') { - return p; + if (prop.key.type === 'Literal' && prop.key.value === 'connectionId') { + return prop; } } return undefined; } -// ---------- Resolution ---------- - -type ResolutionState = { - visited: Set; - hops: number; - exportName: string; - originFile: string; - keyLoc: Node['loc']; -}; - -class ExtractionError extends Error {} - -function fail(state: ResolutionState, reason: string, loc?: Node['loc']): never { - const where = loc?.start - ? `${state.originFile}:${loc.start.line}:${loc.start.column + 1}` - : state.originFile; - throw new ExtractionError( - `[connectionId manifest] ${reason} (export '${state.exportName}' at ${where})`, - ); -} - -/** - * Resolve an arbitrary expression node to its static string value, recursing - * through identifier bindings until we land on a literal. - * - * `node` may be the original `connectionId` property value, but during - * recursion it's whatever we've followed to — a `const` initializer, a - * re-exported binding's target, etc. - * - * Accepts: string `Literal`, interpolation-free `TemplateLiteral`, and - * `Identifier` that resolves (locally or via imports) to one of those forms. - * Anything else calls {@link fail} with a source location. - */ async function resolveValue( ctx: PluginContext, node: Expression, - symbols: LocalSymbols, - currentFile: string, + mod: ParsedModule, state: ResolutionState, ): Promise { if (node.type === 'Literal' && typeof node.value === 'string') { - // e.g. the `'abc-123'` in `connectionId: 'abc-123'` or `const MY_ID = 'abc-123'` return node.value; } if (node.type === 'TemplateLiteral') { - // e.g. a plain `abc-123` template, as in connectionId: `abc-123` or const MY_ID = `abc-123` - return requireStaticTemplate(node, state); + return requireStaticTemplate(node, state, node.loc); } if (node.type === 'Identifier') { - // e.g. the `MY_ID` in `connectionId: MY_ID` or `const ALIAS = MY_ID` - return resolveIdentifier(ctx, node.name, symbols, currentFile, state, node.loc); + return resolveIdentifier(ctx, node.name, mod, state, node.loc); + } + if (node.type === 'MemberExpression') { + return resolveMemberExpression(ctx, node, mod, state); } fail( state, - `'connectionId' must be a string literal, a plain template literal, or an identifier that resolves to one; got ${node.type}`, + `'connectionId' must be a static string, static template, const identifier, or object member; got ${node.type}`, node.loc, ); } -/** - * Return the cooked text of a template literal, but only if it has no - * interpolations. - * - * Accepts: `` `abc-123` `` → `'abc-123'`. - * Rejects: `` `abc-${suffix}` ``, `` `${prefix}-123` ``, etc. — these fail - * because we can't statically know the resulting string. - */ -function requireStaticTemplate(node: TemplateLiteral, state: ResolutionState): string { +function requireStaticTemplate( + node: TemplateLiteral, + state: ResolutionState, + loc: Node['loc'], +): string { if (node.expressions.length > 0) { - fail(state, `'connectionId' template literals must not contain interpolations`, node.loc); + fail(state, `'connectionId' template literals must not contain interpolations`, loc); } - const q = node.quasis[0]; - return q.value.cooked ?? q.value.raw; + const quasi = node.quasis[0]; + return quasi.value.cooked ?? quasi.value.raw; } -/** - * Resolve an identifier by following its binding. `const` bindings recurse; - * `let`/`var` bindings fail because their runtime value can drift from the - * initializer we'd read; imports hand off to {@link resolveCrossFile}; - * unresolved names fail. - */ async function resolveIdentifier( ctx: PluginContext, name: string, - symbols: LocalSymbols, - currentFile: string, + mod: ParsedModule, state: ResolutionState, loc: Node['loc'], ): Promise { - const mutable = symbols.localMutables.get(name); + const mutable = mod.symbols.localMutables.get(name); if (mutable) { fail( state, @@ -371,153 +559,248 @@ async function resolveIdentifier( loc, ); } - const localInit = symbols.localConsts.get(name); + const localInit = mod.symbols.localConsts.get(name); if (localInit) { - return resolveValue(ctx, localInit, symbols, currentFile, state); + return resolveValue(ctx, localInit, mod, state); } - const binding = symbols.importBindings.get(name); + const binding = mod.symbols.importBindings.get(name); if (binding) { - return resolveCrossFile(ctx, currentFile, binding.source, binding.imported, state); + return resolveExportedValue(ctx, mod.id, binding.source, binding.imported, state); } - fail(state, `identifier '${name}' is not defined in ${currentFile} and is not imported`, loc); + fail(state, `identifier '${name}' is not defined in ${mod.id} and is not imported`, loc); } -/** - * Follow a named import across module boundaries: resolve `source` relative to - * `importer`, load and parse the target module, then find the binding exported - * as `importedName` and continue resolution from there. - * - * Handles these export forms in the target module: - * - `export { X } from './foo'` (and `export { Y as X } from './foo'`) — recurses into - * the onward module. - * - `export { X }` / `export { X as Y }` — recurses into the target's own - * symbol table via {@link resolveIdentifier}. - * - `export const X = ` — recurses into {@link resolveValue} on the initializer. - * - `export * from './bar'` — tries each barrel in order, swallowing only - * "not found" errors so the search continues. - * - * Fails on: unresolved or external modules, modules that produce no code, - * cyclic re-export chains (caught via `state.visited`), and chains deeper - * than `MAX_HOPS`. - */ -async function resolveCrossFile( +async function resolveMemberExpression( ctx: PluginContext, - importer: string, - source: string, - importedName: string, + node: MemberExpression, + mod: ParsedModule, state: ResolutionState, ): Promise { - const nextHops = state.hops + 1; - if (nextHops > MAX_HOPS) { + if (node.computed) { + fail(state, `'connectionId' computed member expressions are not supported`, node.loc); + } + if (node.object.type !== 'Identifier') { + fail(state, `'connectionId' member expressions must read from a const object`, node.loc); + } + const propertyName = readPropertyName(node.property); + if (!propertyName) { + fail(state, `'connectionId' member property must be static`, node.property.loc); + } + + const objectName = node.object.name; + const mutable = mod.symbols.localMutables.get(objectName); + if (mutable) { fail( state, - `import tracing depth exceeded (${MAX_HOPS} hops) while resolving '${importedName}'`, + `'connectionId' object '${objectName}' must resolve to a 'const' binding; it is declared with '${mutable.kind}'`, + node.object.loc, ); } - - const resolved = await ctx.resolve(source, importer, { skipSelf: false }); - if (!resolved || resolved.external) { - fail( + const localInit = mod.symbols.localConsts.get(objectName); + if (localInit) { + return resolveObjectMember(ctx, localInit, mod, propertyName, state, node.loc); + } + const binding = mod.symbols.importBindings.get(objectName); + if (binding) { + const exported = await resolveExportedExpression( + ctx, + mod.id, + binding.source, + binding.imported, + state, + ); + return resolveObjectMember( + ctx, + exported.expression, + exported.module, + propertyName, state, - `could not resolve module '${source}' (imported from ${importer}) while following 'connectionId' — external modules are not supported`, + node.loc, ); } - const targetId = resolved.id; + fail(state, `connectionId object '${objectName}' is not defined in ${mod.id}`, node.object.loc); +} - const key = `${targetId}::${importedName}`; - if (state.visited.has(key)) { - fail(state, `cyclic re-export chain detected at ${targetId}::${importedName}`); +function readPropertyName(node: Node): string | undefined { + if (node.type === 'Identifier') { + return node.name; + } + if (node.type === 'Literal' && typeof node.value === 'string') { + return node.value; + } + return undefined; +} + +async function resolveObjectMember( + ctx: PluginContext, + expression: Expression, + mod: ParsedModule, + propertyName: string, + state: ResolutionState, + loc: Node['loc'], +): Promise { + if (expression.type === 'Identifier') { + const binding = mod.symbols.importBindings.get(expression.name); + if (binding) { + const exported = await resolveExportedExpression( + ctx, + mod.id, + binding.source, + binding.imported, + state, + ); + return resolveObjectMember( + ctx, + exported.expression, + exported.module, + propertyName, + state, + loc, + ); + } + const localInit = mod.symbols.localConsts.get(expression.name); + if (localInit) { + return resolveObjectMember(ctx, localInit, mod, propertyName, state, loc); + } + } + if (expression.type !== 'ObjectExpression') { + fail(state, `'connectionId' object member must resolve to an object literal`, loc); } - state.visited.add(key); - const info = await ctx.load({ id: targetId }); - const code = info.code; - if (code === null || code === undefined) { - fail(state, `module '${targetId}' produced no code when loaded for connectionId tracing`); + for (const prop of expression.properties) { + if (prop.type === 'SpreadElement') { + fail(state, `'connectionId' object spreads are not supported`, prop.loc); + } + if (prop.computed) { + fail(state, `'connectionId' object computed properties are not supported`, prop.loc); + } + const key = readPropertyName(prop.key); + if (key === propertyName) { + return resolveValue(ctx, prop.value as Expression, mod, state); + } + } + + fail(state, `connectionId object has no '${propertyName}' property`, loc); +} + +async function resolveExportedValue( + ctx: PluginContext, + importer: string, + source: string, + exportName: string, + state: ResolutionState, +): Promise { + const resolved = await resolveExportedExpression(ctx, importer, source, exportName, state); + return resolveValue(ctx, resolved.expression, resolved.module, state); +} + +async function resolveExportedExpression( + ctx: PluginContext, + importer: string, + source: string, + exportName: string, + state: ResolutionState, +): Promise<{ module: ParsedModule; expression: Expression }> { + const nextState = nextResolutionState(state, `${importer}::${source}::${exportName}`); + const resolvedId = await resolveModuleId(ctx, importer, source, { required: true }); + if (!resolvedId) { + fail(nextState, `could not resolve module '${source}' imported from ${importer}`); } - const targetAst = ctx.parse(code) as unknown as Program; - const targetSymbols = buildSymbolTable(targetAst); - const nextState: ResolutionState = { ...state, hops: nextHops }; + const target = await loadParsedModule(ctx, resolvedId); - // Look for the exported binding in the target file. - for (const node of targetAst.body) { + for (const node of target.ast.body) { if (node.type === 'ExportNamedDeclaration') { - // Re-export: `export { X } from './foo'` / `export { Y as X } from './foo'` if (node.source && typeof node.source.value === 'string') { for (const spec of node.specifiers) { - if ( - spec.exported.type === 'Identifier' && - spec.exported.name === importedName - ) { - const reSource = node.source.value; + if (spec.exported.type === 'Identifier' && spec.exported.name === exportName) { const reName = spec.local.type === 'Identifier' ? spec.local.name - : String((spec.local as { value: string }).value); - return resolveCrossFile(ctx, targetId, reSource, reName, nextState); + : String(spec.local.value); + return resolveExportedExpression( + ctx, + target.id, + node.source.value, + reName, + nextState, + ); } } continue; } - // Local re-export: `const X = …; export { X }` / `export { X as Y }` for (const spec of node.specifiers) { - if (spec.exported.type === 'Identifier' && spec.exported.name === importedName) { + if (spec.exported.type === 'Identifier' && spec.exported.name === exportName) { const localName = spec.local.type === 'Identifier' ? spec.local.name - : String((spec.local as { value: string }).value); - return resolveIdentifier( - ctx, - localName, - targetSymbols, - targetId, - nextState, - spec.loc, - ); + : String(spec.local.value); + return { + module: target, + expression: { type: 'Identifier', name: localName } as Expression, + }; } } - // `export const X = ` if (node.declaration?.type === 'VariableDeclaration') { if (node.declaration.kind !== 'const') { fail( - state, - `'connectionId' must resolve to a 'const' binding; '${importedName}' in '${targetId}' is declared with '${node.declaration.kind}'`, + nextState, + `'connectionId' must resolve to a 'const' binding; '${exportName}' in '${target.id}' is declared with '${node.declaration.kind}'`, ); } for (const d of node.declaration.declarations) { - if (d.id.type === 'Identifier' && d.id.name === importedName && d.init) { - return resolveValue(ctx, d.init, targetSymbols, targetId, nextState); + if (d.id.type === 'Identifier' && d.id.name === exportName && d.init) { + return { module: target, expression: d.init }; } } } } else if (node.type === 'ExportAllDeclaration') { - // `export * from './bar'` (no namespace) / `export * as NS from './bar'`. - // Only the plain `export *` form can re-export our name. - if (node.exported) { - continue; - } - if (typeof node.source.value !== 'string') { + if (node.exported || typeof node.source.value !== 'string') { continue; } try { - return await resolveCrossFile( + return await resolveExportedExpression( ctx, - targetId, + target.id, node.source.value, - importedName, + exportName, nextState, ); - } catch (e) { - if (e instanceof ExtractionError && /not found/.test(e.message)) { - // Try the next `export *` — normal. + } catch (error) { + if (error instanceof ExportNotFoundError) { continue; } - throw e; + throw error; } } } - fail(state, `export '${importedName}' not found in '${targetId}' while resolving connectionId`); + throw new ExportNotFoundError( + `[connectionId manifest] export '${exportName}' not found in '${target.id}' while resolving connectionId`, + ); +} + +function nextResolutionState(state: ResolutionState, key: string): ResolutionState { + if (state.hops + 1 > MAX_HOPS) { + fail(state, `import tracing depth exceeded (${MAX_HOPS} hops)`); + } + if (state.visited.has(key)) { + fail(state, `cyclic re-export or import chain detected at ${key}`); + } + const visited = new Set(state.visited); + visited.add(key); + return { ...state, visited, hops: state.hops + 1 }; +} + +function fail(state: ResolutionState, reason: string, loc?: Node['loc']): never { + const where = loc?.start + ? `${state.originFile}:${loc.start.line}:${loc.start.column + 1}` + : state.originFile; + throw new ExtractionError(`[connectionId manifest] ${reason} (${state.label} at ${where})`); +} + +function failBase(reason: string, loc: Node['loc'], filePath: string): never { + const where = loc?.start ? `${filePath}:${loc.start.line}:${loc.start.column + 1}` : filePath; + throw new ExtractionError(`[connectionId manifest] ${reason} (${where})`); } diff --git a/packages/plugins/apps/src/estree-walker.d.ts b/packages/plugins/apps/src/estree-walker.d.ts new file mode 100644 index 000000000..8cc482fe2 --- /dev/null +++ b/packages/plugins/apps/src/estree-walker.d.ts @@ -0,0 +1,17 @@ +declare module 'estree-walker' { + import type { Node } from 'estree'; + + type WalkerContext = { + skip(): void; + remove(): void; + replace(node: Node): void; + }; + + export function walk( + ast: T, + walker: { + enter?(this: WalkerContext, node: Node, parent: Node, key: string, index: number): void; + leave?(this: WalkerContext, node: Node, parent: Node, key: string, index: number): void; + }, + ): T; +} diff --git a/packages/plugins/apps/src/index.ts b/packages/plugins/apps/src/index.ts index c74c2dd3a..f5be6eead 100644 --- a/packages/plugins/apps/src/index.ts +++ b/packages/plugins/apps/src/index.ts @@ -279,6 +279,7 @@ Either: this as unknown as PluginContext, ast, id, + context.buildRoot, ); const { functions, proxyCode } = buildProxyModule( diff --git a/packages/plugins/apps/src/vite/dev-server.test.ts b/packages/plugins/apps/src/vite/dev-server.test.ts index d15431a53..ba408e58b 100644 --- a/packages/plugins/apps/src/vite/dev-server.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.test.ts @@ -147,10 +147,25 @@ describe('Dev Server Middleware', () => { test('Should handle /__dd/executeAction POST', async () => { mockViteBuild.mockResolvedValue(mockBuildResult('// bundled code')); + let capturedPreviewBody: any; + const functionWithConnectionIds = { + ...mockFunctions[0], + allowedConnectionIds: ['77c14b8b-27e1-4901-985d-8817908b9706'], + }; + const middlewareWithConnectionIds = createDevServerMiddleware( + mockViteBuild, + () => [functionWithConnectionIds], + mockAuth, + '/project', + mockLog, + ); // Mock the Datadog API via nock. const apiScope = nock(`https://${DD_SITE}`) - .post('/api/v2/app-builder/queries/preview-async') + .post('/api/v2/app-builder/queries/preview-async', (body) => { + capturedPreviewBody = body; + return true; + }) .reply(200, { data: { id: 'receipt-123' } }) .get('/api/v2/app-builder/queries/execution-long-polling/receipt-123') .reply(200, { @@ -163,13 +178,13 @@ describe('Dev Server Middleware', () => { }); const req = createMockRequest('/__dd/executeAction', { - functionName: encodeQueryName(mockFunctions[0]), + functionName: encodeQueryName(functionWithConnectionIds), args: ['world'], }); const res = createMockResponse(); const next = jest.fn(); - middleware(req, res, next); + middlewareWithConnectionIds(req, res, next); expect(next).not.toHaveBeenCalled(); await res.done; @@ -178,6 +193,10 @@ describe('Dev Server Middleware', () => { const body = JSON.parse(res.getBody()); expect(body.success).toBe(true); expect(body.result).toEqual({ data: { result: 'hello' } }); + expect( + capturedPreviewBody?.data.attributes.query.properties.spec.inputs + .allowedConnectionIds, + ).toEqual(['77c14b8b-27e1-4901-985d-8817908b9706']); expect(apiScope.isDone()).toBe(true); }); }); diff --git a/packages/plugins/apps/src/vite/dev-server.ts b/packages/plugins/apps/src/vite/dev-server.ts index f2ecbb43b..66dbd9224 100644 --- a/packages/plugins/apps/src/vite/dev-server.ts +++ b/packages/plugins/apps/src/vite/dev-server.ts @@ -115,6 +115,7 @@ async function bundleBackendFunction( async function executeScriptViaDatadog( scriptBody: string, displayName: string, + allowedConnectionIds: string[], auth: AuthConfig, log: Logger, ): Promise { @@ -133,7 +134,7 @@ async function executeScriptViaDatadog( properties: { spec: { fqn: 'com.datadoghq.datatransformation.jsFunctionWithActions', - inputs: { script: scriptBody }, + inputs: { script: scriptBody, allowedConnectionIds }, }, onlyTriggerManually: true, }, @@ -248,7 +249,7 @@ async function validateAndBundle( req: IncomingMessage, functionsByName: Map, bundle: BundleFn, -): Promise<{ displayName: string; code: string }> { +): Promise<{ displayName: string; code: string; func: BackendFunction }> { const { functionName, args = [] } = await parseRequestBody(req); if (!functionName || typeof functionName !== 'string') { @@ -261,7 +262,7 @@ async function validateAndBundle( } const code = await bundle(func, args); - return { displayName: formatRef(func), code }; + return { displayName: formatRef(func), code, func }; } /** @@ -298,11 +299,17 @@ async function handleExecuteAction( log: Logger, ): Promise { try { - const { displayName, code } = await validateAndBundle(req, functionsByName, bundle); + const { displayName, code, func } = await validateAndBundle(req, functionsByName, bundle); log.debug(`Executing action: ${displayName} with args`); - const result = await executeScriptViaDatadog(code, displayName, auth, log); + const result = await executeScriptViaDatadog( + code, + displayName, + func.allowedConnectionIds, + auth, + log, + ); res.statusCode = 200; res.setHeader('Content-Type', 'application/json'); diff --git a/yarn.lock b/yarn.lock index 329b10d2e..f82c9e596 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1947,6 +1947,7 @@ __metadata: "@dd/core": "workspace:*" "@types/estree": "npm:1.0.8" chalk: "npm:2.3.1" + estree-walker: "npm:2.0.2" glob: "npm:11.1.0" jszip: "npm:3.10.1" pretty-bytes: "npm:5.6.0" @@ -6641,7 +6642,7 @@ __metadata: languageName: node linkType: hard -"estree-walker@npm:^2.0.2": +"estree-walker@npm:2.0.2, estree-walker@npm:^2.0.2": version: 2.0.2 resolution: "estree-walker@npm:2.0.2" checksum: 10/b02109c5d46bc2ed47de4990eef770f7457b1159a229f0999a09224d2b85ffeed2d7679cffcff90aeb4448e94b0168feb5265b209cdec29aad50a3d6e93d21e2 From 90922148f429d56ec16b58769cfbcf51873810ba Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Wed, 6 May 2026 09:19:18 -0400 Subject: [PATCH 5/5] Centralize backend manifest writing --- packages/plugins/apps/src/index.test.ts | 21 +++++++++--- packages/plugins/apps/src/index.ts | 43 ++++++++++++++----------- packages/plugins/apps/src/types.ts | 11 +++++++ 3 files changed, 53 insertions(+), 22 deletions(-) diff --git a/packages/plugins/apps/src/index.test.ts b/packages/plugins/apps/src/index.test.ts index 553f29c2a..88bcd8854 100644 --- a/packages/plugins/apps/src/index.test.ts +++ b/packages/plugins/apps/src/index.test.ts @@ -17,6 +17,7 @@ import { mockLogFn, } from '@dd/tests/_jest/helpers/mocks'; import { runBundlers } from '@dd/tests/_jest/helpers/runBundlers'; +import fsp from 'fs/promises'; import nock from 'nock'; import path from 'path'; @@ -149,10 +150,18 @@ describe('Apps Plugin - getPlugins', () => { ]; jest.spyOn(assets, 'collectAssets').mockResolvedValue(mockedAssets); jest.spyOn(fsHelpers, 'rm').mockResolvedValue(undefined); - jest.spyOn(archive, 'createArchive').mockResolvedValue({ - archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip', - assets: mockedAssets, - size: 10, + let manifest: unknown; + jest.spyOn(archive, 'createArchive').mockImplementation(async (archiveAssets) => { + const manifestAsset = archiveAssets.find( + (asset) => asset.relativePath === 'manifest.json', + ); + expect(manifestAsset).toBeDefined(); + manifest = JSON.parse(await fsp.readFile(manifestAsset!.absolutePath, 'utf8')); + return { + archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip', + assets: archiveAssets, + size: 10, + }; }); jest.spyOn(uploader, 'uploadArchive').mockResolvedValue({ errors: [], @@ -168,7 +177,11 @@ describe('Apps Plugin - getPlugins', () => { absolutePath: '/project/dist/index.js', relativePath: path.join('frontend', 'dist/index.js'), }, + expect.objectContaining({ + relativePath: 'manifest.json', + }), ]); + expect(manifest).toEqual({ backend: { functions: {} } }); expect(uploader.uploadArchive).toHaveBeenCalledWith( expect.objectContaining({ archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip' }), { diff --git a/packages/plugins/apps/src/index.ts b/packages/plugins/apps/src/index.ts index f5be6eead..fb585fe62 100644 --- a/packages/plugins/apps/src/index.ts +++ b/packages/plugins/apps/src/index.ts @@ -21,7 +21,7 @@ import { extractConnectionIds } from './backend/extract-connection-ids'; import { generateProxyModule } from './backend/proxy-codegen'; import { BACKEND_FILE_RE, CONFIG_KEY, PLUGIN_NAME } from './constants'; import { resolveIdentifier } from './identifier'; -import type { AppsOptions } from './types'; +import type { AppsManifest, AppsOptions } from './types'; import { uploadArchive } from './upload'; import { validateOptions } from './validate'; import { getVitePlugin } from './vite/index'; @@ -81,9 +81,27 @@ function createBackendFunctionRegistry() { }; } +function buildManifest(backendFunctions: BackendFunction[]): AppsManifest { + const functions: AppsManifest['backend']['functions'] = {}; + for (const fn of backendFunctions) { + functions[encodeQueryName(fn)] = { + allowedConnectionIds: fn.allowedConnectionIds, + }; + } + return { backend: { functions } }; +} + +async function writeManifestFile(backendFunctions: BackendFunction[]): Promise { + const manifestDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'dd-apps-manifest-')); + const manifestPath = path.join(manifestDir, 'manifest.json'); + await fsp.writeFile(manifestPath, JSON.stringify(buildManifest(backendFunctions), null, 2)); + return manifestPath; +} + export type types = { // Add the types you'd like to expose here. AppsOptions: AppsOptions; + AppsManifest: AppsManifest; }; export const getPlugins: GetPlugins = ({ options, context, bundler }) => { @@ -172,23 +190,12 @@ Either: // Emit the connection-ID manifest alongside the backend bundles so // the server can allowlist the connections each function uses. const backendFunctions = getBackendFunctions(); - if (backendFunctions.length > 0) { - const manifest: Record = {}; - for (const fn of backendFunctions) { - manifest[encodeQueryName(fn)] = { - allowedConnectionIds: fn.allowedConnectionIds, - }; - } - const manifestJson = JSON.stringify(manifest, null, 2); - log.debug(`Backend connectionId manifest:\n${manifestJson}`); - manifestDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'dd-apps-manifest-')); - const manifestPath = path.join(manifestDir, 'manifest.json'); - await fsp.writeFile(manifestPath, manifestJson); - allAssets.push({ - absolutePath: manifestPath, - relativePath: 'backend/manifest.json', - }); - } + const manifestPath = await writeManifestFile(backendFunctions); + manifestDir = path.dirname(manifestPath); + allAssets.push({ + absolutePath: manifestPath, + relativePath: 'manifest.json', + }); const archiveTimer = log.time('archive assets'); const archive = await createArchive(allAssets); diff --git a/packages/plugins/apps/src/types.ts b/packages/plugins/apps/src/types.ts index f62ffd83b..55ed247b5 100644 --- a/packages/plugins/apps/src/types.ts +++ b/packages/plugins/apps/src/types.ts @@ -12,5 +12,16 @@ export type AppsOptions = { name?: string; }; +export type AppsManifest = { + backend: { + functions: Record< + string, + { + allowedConnectionIds: string[]; + } + >; + }; +}; + // We don't enforce identifier, as it needs to be dynamically computed if absent. export type AppsOptionsWithDefaults = WithRequired;