From 4780d0083bd30a9d52e833e67a00d996bab9365e Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Mon, 11 May 2026 11:49:34 -0400 Subject: [PATCH 1/2] Build backend module graph during backend builds --- .../ast-parsing/action-catalog-imports.ts | 10 +- .../ast-parsing/extract-backend-functions.ts | 12 +- .../ast-parsing/extract-connection-ids.ts | 22 +-- .../backend/ast-parsing/module-graph.test.ts | 84 ++++++++ .../src/backend/ast-parsing/module-graph.ts | 184 ++++++++++++++++++ .../src/backend/ast-parsing/type-guards.ts | 30 ++- .../backend-module-graph-collector.test.ts | 49 +++++ .../vite/backend-module-graph-collector.ts | 58 ++++++ .../apps/src/vite/build-backend-functions.ts | 6 +- .../plugins/apps/src/vite/build-config.ts | 3 +- packages/plugins/apps/src/vite/dev-server.ts | 9 +- 11 files changed, 431 insertions(+), 36 deletions(-) create mode 100644 packages/plugins/apps/src/backend/ast-parsing/module-graph.test.ts create mode 100644 packages/plugins/apps/src/backend/ast-parsing/module-graph.ts create mode 100644 packages/plugins/apps/src/vite/backend-module-graph-collector.test.ts create mode 100644 packages/plugins/apps/src/vite/backend-module-graph-collector.ts diff --git a/packages/plugins/apps/src/backend/ast-parsing/action-catalog-imports.ts b/packages/plugins/apps/src/backend/ast-parsing/action-catalog-imports.ts index 83f994b39..57c2ff837 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/action-catalog-imports.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/action-catalog-imports.ts @@ -2,7 +2,9 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import type { BaseNode, Program } from 'estree'; +import type { Program } from 'estree'; + +import { isTypeOnly } from './type-guards'; const ACTION_CATALOG_PACKAGE = '@datadog/action-catalog'; @@ -11,8 +13,6 @@ export interface ActionCatalogImports { namespaces: Set; } -type NodeWithOptionalImportKind = BaseNode & { importKind?: string }; - export function collectActionCatalogImports(ast: Program): ActionCatalogImports { const functions = new Set(); const namespaces = new Set(); @@ -51,7 +51,3 @@ function isActionCatalogSource(source: unknown): boolean { (source === ACTION_CATALOG_PACKAGE || source.startsWith(`${ACTION_CATALOG_PACKAGE}/`)) ); } - -function isTypeOnly(node: NodeWithOptionalImportKind): boolean { - return node.importKind === 'type'; -} diff --git a/packages/plugins/apps/src/backend/ast-parsing/extract-backend-functions.ts b/packages/plugins/apps/src/backend/ast-parsing/extract-backend-functions.ts index f77263f93..3ebbc6408 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/extract-backend-functions.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/extract-backend-functions.ts @@ -4,7 +4,7 @@ import type { BaseNode, Declaration, Expression, Program } from 'estree'; -import { isProgramNode } from './type-guards'; +import { ensureProgram } from './type-guards'; import type { BackendExport } from './types'; /** @@ -23,17 +23,13 @@ export function extractExportedFunctions(ast: BaseNode, filePath: string): strin } export function enumerateBackendExports(ast: BaseNode, filePath: string): BackendExport[] { - if (!isProgramNode(ast)) { - throw new Error( - `Expected a Program node from this.parse() for ${filePath}, got ${ast.type}`, - ); - } + const program = ensureProgram(ast, filePath); // Build a map of top-level declarations so we can validate export specifiers. - const declarations = buildDeclarationMap(ast); + const declarations = buildDeclarationMap(program); const backendExports: BackendExport[] = []; - for (const node of ast.body) { + for (const node of program.body) { // handles: export default ... if (node.type === 'ExportDefaultDeclaration') { throw new Error( diff --git a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts index 5c7954fba..3fe90ef22 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids.ts @@ -8,30 +8,22 @@ import { analyzeActionCatalogScopes, findActionCatalogCallSites, } from './action-catalog-call-sites'; -import { collectActionCatalogImports, hasActionCatalogImports } from './action-catalog-imports'; +import { collectActionCatalogImports } from './action-catalog-imports'; import { collectSameModuleConnectionIdBindings, extractConnectionIdFromActionCall, } from './connection-id-values'; -import { isProgramNode } from './type-guards'; +import { ensureProgram } from './type-guards'; export function extractConnectionIds(ast: BaseNode, filePath: string): string[] { - if (!isProgramNode(ast)) { - throw new Error( - `Expected a Program node from this.parse() for ${filePath}, got ${ast.type}`, - ); - } - - const imports = collectActionCatalogImports(ast); - if (!hasActionCatalogImports(imports)) { - return []; - } + const program = ensureProgram(ast, filePath); - const scopeAnalysis = analyzeActionCatalogScopes(ast, imports); - const bindings = collectSameModuleConnectionIdBindings(ast, scopeAnalysis); + const imports = collectActionCatalogImports(program); + const scopeAnalysis = analyzeActionCatalogScopes(program, imports); + const bindings = collectSameModuleConnectionIdBindings(program, scopeAnalysis); const connectionIds = new Set(); - for (const callSite of findActionCatalogCallSites(ast, scopeAnalysis, filePath)) { + for (const callSite of findActionCatalogCallSites(program, scopeAnalysis, filePath)) { const connectionId = extractConnectionIdFromActionCall( callSite, bindings, diff --git a/packages/plugins/apps/src/backend/ast-parsing/module-graph.test.ts b/packages/plugins/apps/src/backend/ast-parsing/module-graph.test.ts new file mode 100644 index 000000000..6a95201eb --- /dev/null +++ b/packages/plugins/apps/src/backend/ast-parsing/module-graph.test.ts @@ -0,0 +1,84 @@ +// 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 { parseAst } from 'rollup/parseAst'; + +import { createParsedModuleRecord } from './module-graph'; + +const buildRoot = '/project'; + +describe('Backend Functions - module graph records', () => { + test('Should create graph records for app-local backend modules', () => { + const record = createParsedModuleRecord( + '/project/src/backend/actions.backend.js', + buildRoot, + parseAst(` + import { getEcho } from './helpers/http.js'; + export function run() { + return getEcho(); + } + `), + ['/project/src/backend/helpers/http.js'], + ); + + expect(record).toMatchObject({ + id: '/project/src/backend/actions.backend.js', + staticDependencies: ['/project/src/backend/helpers/http.js'], + unsupportedDependencies: [], + }); + expect(record?.ast.type).toBe('Program'); + }); + + test.each([ + { description: 'package modules', id: '/project/node_modules/package/index.js' }, + { description: 'files outside buildRoot', id: '/external/helper.js' }, + { description: 'dist output', id: '/project/dist/helper.js' }, + { description: 'build output', id: '/project/build/helper.js' }, + { description: 'Vite cache output', id: '/project/.vite/helper.js' }, + { description: 'non-JavaScript files', id: '/project/src/backend/data.json' }, + ])('Should skip $description', ({ id }) => { + expect( + createParsedModuleRecord(id, buildRoot, parseAst('export const value = true;')), + ).toBeNull(); + }); + + test.each([ + { + description: 'dynamic local imports', + code: "import('./helper.js');", + expected: { kind: 'dynamic-import', specifier: './helper.js' }, + }, + { + description: 'non-literal dynamic imports', + code: 'import(helperPath);', + expected: { kind: 'dynamic-import', specifier: 'non-literal dynamic import' }, + }, + { + description: 'local require calls', + code: "require('./helper.js');", + expected: { kind: 'require', specifier: './helper.js' }, + }, + ])('Should record unsupported $description', ({ code, expected }) => { + const record = createParsedModuleRecord( + '/project/src/backend/actions.backend.js', + buildRoot, + parseAst(code), + ); + + expect(record?.unsupportedDependencies).toEqual([expected]); + }); + + test('Should ignore package dynamic imports and require calls', () => { + const record = createParsedModuleRecord( + '/project/src/backend/actions.backend.js', + buildRoot, + parseAst(` + import('package'); + require('package'); + `), + ); + + expect(record?.unsupportedDependencies).toEqual([]); + }); +}); diff --git a/packages/plugins/apps/src/backend/ast-parsing/module-graph.ts b/packages/plugins/apps/src/backend/ast-parsing/module-graph.ts new file mode 100644 index 000000000..9d5595484 --- /dev/null +++ b/packages/plugins/apps/src/backend/ast-parsing/module-graph.ts @@ -0,0 +1,184 @@ +// 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 { BaseNode, ImportExpression, Program, SimpleCallExpression } from 'estree'; +import path from 'path'; + +import { ensureProgram, isStringLiteral } from './type-guards'; +import { walkAst } from './walk-ast'; + +export interface ParsedModuleRecord { + id: string; + ast: Program; + staticDependencies: string[]; + unsupportedDependencies: ModuleDependency[]; +} + +export interface ModuleDependency { + specifier: string; + kind: 'dynamic-import' | 'require'; +} + +type ImportCallExpression = SimpleCallExpression & { callee: { type: 'Import' } }; + +const DISALLOWED_GRAPH_DIRS = new Set(['node_modules', 'dist', 'build', '.vite']); +const PARSEABLE_FILE_RE = /\.(mjs|cjs|js|jsx|mts|cts|ts|tsx)$/; + +/** + * Creates the per-module analysis record consumed by backend-entry reachability + * analysis. The caller supplies canonical module IDs and already-resolved + * static dependency IDs instead of asking this module to resolve/load files. + * + * Returns null when the module is outside the analyzable app-local backend + * graph, allowing build collectors to skip package/generated modules without + * duplicating graph filtering rules. + */ +export function createParsedModuleRecord( + moduleId: string, + buildRoot: string, + ast: BaseNode, + staticDependencies: string[] = [], +): ParsedModuleRecord | null { + if (!shouldTraverseCollectedModule(moduleId, buildRoot)) { + return null; + } + + const program = ensureProgram(ast, moduleId); + + return { + id: moduleId, + ast: program, + staticDependencies, + unsupportedDependencies: collectUnsupportedModuleDependencies(program), + }; +} + +/** + * Finds dependency forms that cannot be represented by the static dependency + * IDs supplied by the backend build collector. + */ +function collectUnsupportedModuleDependencies(ast: Program): ModuleDependency[] { + const dependencies: ModuleDependency[] = []; + + walkAst(ast, dependencies, { + ImportExpression(node, { state }) { + const specifier = getImportExpressionSpecifier(node); + if (shouldFailDynamicImport(specifier)) { + state.push({ specifier, kind: 'dynamic-import' }); + } + }, + CallExpression(node, { state }) { + if (isImportCallExpression(node)) { + const specifier = getImportCallSpecifier(node); + if (shouldFailDynamicImport(specifier)) { + state.push({ specifier, kind: 'dynamic-import' }); + } + return; + } + + if (isLocalRequireCall(node)) { + state.push({ + specifier: getRequireSpecifier(node), + kind: 'require', + }); + } + }, + }); + + return dependencies; +} + +/** + * Dynamic package imports are skipped, but local or non-literal dynamic imports + * could hide app-local action-catalog calls and must fail closed. + */ +function shouldFailDynamicImport(specifier: string): boolean { + return specifier === 'non-literal dynamic import' || isLocalSpecifier(specifier); +} + +/** + * Keeps reachability traversal scoped to app-local JavaScript/TypeScript source + * modules that the backend build collector can safely analyze. + */ +export function shouldTraverseCollectedModule(moduleId: string, buildRoot: string): boolean { + if (!PARSEABLE_FILE_RE.test(moduleId)) { + return false; + } + + const relativePath = path.relative(path.resolve(buildRoot), moduleId); + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + return false; + } + + return !relativePath.split(path.sep).some((segment) => DISALLOWED_GRAPH_DIRS.has(segment)); +} + +/** + * Reads the static string specifier from an ESTree dynamic import expression. + */ +function getImportExpressionSpecifier(node: ImportExpression): string { + return getLiteralSpecifier(node.source, 'non-literal dynamic import'); +} + +/** + * Reads the static string specifier from Rollup's call-expression form for + * dynamic import. + */ +function getImportCallSpecifier(node: ImportCallExpression): string { + return getLiteralSpecifier(node.arguments[0], 'non-literal dynamic import'); +} + +/** + * Reads the static string specifier from a CommonJS require call. + */ +function getRequireSpecifier(node: SimpleCallExpression): string { + return getLiteralSpecifier(node.arguments[0], 'local require'); +} + +/** + * Returns a literal string value when available, otherwise a diagnostic label + * used in fail-closed error messages. + */ +function getLiteralSpecifier(node: unknown, fallback: string): string { + if (isStringLiteral(node)) { + return node.value; + } + return fallback; +} + +/** + * Narrows Rollup's dynamic import call-expression representation. + */ +function isImportCallExpression(node: SimpleCallExpression): node is ImportCallExpression { + return (node.callee as { type: string }).type === 'Import'; +} + +/** + * Detects local CommonJS require calls. Package require calls are ignored + * because package modules are outside the app-local backend graph. + */ +function isLocalRequireCall(node: SimpleCallExpression): boolean { + if (node.callee.type !== 'Identifier' || node.callee.name !== 'require') { + return false; + } + const [source] = node.arguments; + return !source || !isStringLiteral(source) || isLocalSpecifier(source.value); +} + +/** + * Returns whether an import specifier points at an app-local path. + */ +function isLocalSpecifier(specifier: string): boolean { + return specifier.startsWith('.') || specifier.startsWith('/'); +} + +/** + * Builds the common fail-closed error for graph shapes that could hide an + * action-catalog connection ID. + */ +export function unsupportedModuleGraphDependency(filePath: string, unsupported: string): Error { + return new Error( + `Unsupported local module graph for ${filePath}: ${unsupported} could hide an action-catalog connectionId.`, + ); +} diff --git a/packages/plugins/apps/src/backend/ast-parsing/type-guards.ts b/packages/plugins/apps/src/backend/ast-parsing/type-guards.ts index 5f1776c75..94db8d362 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/type-guards.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/type-guards.ts @@ -2,8 +2,36 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import type { BaseNode, Program } from 'estree'; +import type { BaseNode, Program, SimpleLiteral } from 'estree'; + +export type StringLiteral = SimpleLiteral & { value: string }; + +// Rollup's parser preserves TypeScript import/export kind metadata on otherwise +// ESTree-shaped import/export nodes. +type TypeOnlyAwareNode = BaseNode & { importKind?: string; exportKind?: string }; + +export function ensureProgram(node: BaseNode, filePath: string): Program { + if (!isProgramNode(node)) { + throw new Error( + `Expected a Program node from this.parse() for ${filePath}, got ${node.type}`, + ); + } + return node; +} export function isProgramNode(node: BaseNode): node is Program { return node.type === 'Program'; } + +export function isStringLiteral(node: unknown): node is StringLiteral { + return ( + typeof node === 'object' && + node !== null && + (node as { type?: string }).type === 'Literal' && + typeof (node as { value?: unknown }).value === 'string' + ); +} + +export function isTypeOnly(node: TypeOnlyAwareNode): boolean { + return node.importKind === 'type' || node.exportKind === 'type'; +} diff --git a/packages/plugins/apps/src/vite/backend-module-graph-collector.test.ts b/packages/plugins/apps/src/vite/backend-module-graph-collector.test.ts new file mode 100644 index 000000000..f8f9ef823 --- /dev/null +++ b/packages/plugins/apps/src/vite/backend-module-graph-collector.test.ts @@ -0,0 +1,49 @@ +// 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 { parseAst } from 'rollup/parseAst'; + +import { createBackendModuleGraphCollector } from './backend-module-graph-collector'; + +describe('Backend Functions - backend module graph collector', () => { + test('Should collect parsed local module records from Rollup moduleParsed hooks', () => { + const collector = createBackendModuleGraphCollector('/project'); + const moduleParsed = collector.plugin.moduleParsed as (moduleInfo: unknown) => void; + + moduleParsed({ + id: '/project/src/backend/actions.backend.js?import', + ast: parseAst(` + import { getEcho } from './helpers/http.js'; + export function run() { + return getEcho(); + } + `), + importedIds: ['/project/src/backend/helpers/http.js?import'], + }); + moduleParsed({ + id: '/project/node_modules/package/index.js', + ast: parseAst('export const value = true;'), + importedIds: [], + }); + moduleParsed({ + id: '\0virtual-helper.js', + ast: parseAst('export const value = true;'), + importedIds: [], + }); + moduleParsed({ + id: 'virtual:dd-backend-dev:example.js', + ast: parseAst('export const value = true;'), + importedIds: [], + }); + + expect([...collector.getModuleRecords().keys()]).toEqual([ + '/project/src/backend/actions.backend.js', + ]); + expect( + collector.getModuleRecords().get('/project/src/backend/actions.backend.js'), + ).toMatchObject({ + staticDependencies: ['/project/src/backend/helpers/http.js'], + }); + }); +}); diff --git a/packages/plugins/apps/src/vite/backend-module-graph-collector.ts b/packages/plugins/apps/src/vite/backend-module-graph-collector.ts new file mode 100644 index 000000000..27acdf524 --- /dev/null +++ b/packages/plugins/apps/src/vite/backend-module-graph-collector.ts @@ -0,0 +1,58 @@ +// 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 { BaseNode } from 'estree'; +import type { ModuleInfo } from 'rollup'; +import type { Plugin } from 'vite'; + +import { + createParsedModuleRecord, + type ParsedModuleRecord, +} from '../backend/ast-parsing/module-graph'; + +const VIRTUAL_MODULE_ID_RE = /^(?:\0|virtual:)/; + +export interface BackendModuleGraphCollector { + plugin: Plugin; + getModuleRecords: () => ReadonlyMap; +} + +export function createBackendModuleGraphCollector(buildRoot: string): BackendModuleGraphCollector { + const records = new Map(); + + return { + plugin: { + name: 'dd-backend-module-graph-collector', + moduleParsed(moduleInfo: ModuleInfo) { + const moduleId = normalizeViteModuleId(moduleInfo.id); + if (isViteVirtualModuleId(moduleId)) { + return; + } + + const record = createParsedModuleRecord( + moduleId, + buildRoot, + moduleInfo.ast as BaseNode, + moduleInfo.importedIds.map(normalizeViteModuleId), + ); + if (!record) { + return; + } + + records.set(record.id, record); + }, + }, + getModuleRecords() { + return records; + }, + }; +} + +function normalizeViteModuleId(id: string): string { + return id.split('?')[0]; +} + +function isViteVirtualModuleId(id: string): boolean { + return VIRTUAL_MODULE_ID_RE.test(id); +} diff --git a/packages/plugins/apps/src/vite/build-backend-functions.ts b/packages/plugins/apps/src/vite/build-backend-functions.ts index 1fc36ab4d..835bebf62 100644 --- a/packages/plugins/apps/src/vite/build-backend-functions.ts +++ b/packages/plugins/apps/src/vite/build-backend-functions.ts @@ -12,6 +12,7 @@ import { encodeQueryName } from '../backend/encodeQueryName'; import type { BackendFunction } from '../backend/types'; import { generateVirtualEntryContent } from '../backend/virtual-entry'; +import { createBackendModuleGraphCollector } from './backend-module-graph-collector'; import { getBaseBackendBuildConfig } from './build-config'; const VIRTUAL_PREFIX = '\0dd-backend:'; @@ -40,8 +41,11 @@ export async function buildBackendFunctions( const bundleName = encodeQueryName(func); const virtualId = `${VIRTUAL_PREFIX}${bundleName}`; const virtualContent = generateVirtualEntryContent(func.name, func.absolutePath, buildRoot); + const moduleGraphCollector = createBackendModuleGraphCollector(buildRoot); - const baseConfig = getBaseBackendBuildConfig(buildRoot, { [virtualId]: virtualContent }); + const baseConfig = getBaseBackendBuildConfig(buildRoot, { [virtualId]: virtualContent }, [ + moduleGraphCollector.plugin, + ]); // eslint-disable-next-line no-await-in-loop const result = await viteBuild({ diff --git a/packages/plugins/apps/src/vite/build-config.ts b/packages/plugins/apps/src/vite/build-config.ts index 43f4b0eeb..8878424e0 100644 --- a/packages/plugins/apps/src/vite/build-config.ts +++ b/packages/plugins/apps/src/vite/build-config.ts @@ -35,6 +35,7 @@ export function createVirtualPlugin(name: string, virtualEntries: Record, + plugins: Plugin[] = [], ): InlineConfig & { build: BuildOptions & { rollupOptions: NonNullable }; } { @@ -60,6 +61,6 @@ export function getBaseBackendBuildConfig( resolve: { extensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json'], }, - plugins: [createVirtualPlugin('dd-backend-resolve', virtualEntries)], + plugins: [createVirtualPlugin('dd-backend-resolve', virtualEntries), ...plugins], }; } diff --git a/packages/plugins/apps/src/vite/dev-server.ts b/packages/plugins/apps/src/vite/dev-server.ts index 8d033d306..c736b2254 100644 --- a/packages/plugins/apps/src/vite/dev-server.ts +++ b/packages/plugins/apps/src/vite/dev-server.ts @@ -15,6 +15,7 @@ import type { ExecuteActionRequest, ExecuteActionResponse } from '../backend/pro import type { BackendFunction } from '../backend/types'; import { generateDevVirtualEntryContent } from '../backend/virtual-entry'; +import { createBackendModuleGraphCollector } from './backend-module-graph-collector'; import { getBaseBackendBuildConfig } from './build-config'; type BundleFn = (func: BackendFunction, args: unknown[]) => Promise; @@ -74,10 +75,13 @@ async function bundleBackendFunction( args, projectRoot, ); + const moduleGraphCollector = createBackendModuleGraphCollector(projectRoot); log.debug(`Bundling backend function "${displayName}" from ${func.absolutePath}`); - const baseConfig = getBaseBackendBuildConfig(projectRoot, { [virtualId]: virtualContent }); + const baseConfig = getBaseBackendBuildConfig(projectRoot, { [virtualId]: virtualContent }, [ + moduleGraphCollector.plugin, + ]); // Dev: build a single function in-memory per request so we can send the // bundled script to the Datadog API without writing temp files. @@ -264,8 +268,7 @@ async function validateAndBundle( throw new HttpError(404, `Backend function "${functionName}" not found`); } - const code = await bundle(func, args); - return { func, code }; + return { func, code: await bundle(func, args) }; } /** From 31f9122764df8de343bbbec73eb699850172fc0d Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Wed, 13 May 2026 16:25:22 -0400 Subject: [PATCH 2/2] Address backend module graph review feedback --- .../src/backend/ast-parsing/module-graph.test.ts | 16 +++++++++++++--- .../apps/src/backend/ast-parsing/module-graph.ts | 9 +++++---- .../apps/src/backend/ast-parsing/type-guards.ts | 9 ++++++--- packages/plugins/apps/src/constants.ts | 10 ++++++++++ packages/plugins/apps/src/vite/build-config.ts | 4 +++- 5 files changed, 37 insertions(+), 11 deletions(-) diff --git a/packages/plugins/apps/src/backend/ast-parsing/module-graph.test.ts b/packages/plugins/apps/src/backend/ast-parsing/module-graph.test.ts index 6a95201eb..f62114795 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/module-graph.test.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/module-graph.test.ts @@ -32,10 +32,8 @@ describe('Backend Functions - module graph records', () => { test.each([ { description: 'package modules', id: '/project/node_modules/package/index.js' }, + { description: 'Yarn package cache modules', id: '/project/.yarn/cache/package/index.js' }, { description: 'files outside buildRoot', id: '/external/helper.js' }, - { description: 'dist output', id: '/project/dist/helper.js' }, - { description: 'build output', id: '/project/build/helper.js' }, - { description: 'Vite cache output', id: '/project/.vite/helper.js' }, { description: 'non-JavaScript files', id: '/project/src/backend/data.json' }, ])('Should skip $description', ({ id }) => { expect( @@ -43,6 +41,18 @@ describe('Backend Functions - module graph records', () => { ).toBeNull(); }); + test.each([ + '/project/src/backend/helper.mts', + '/project/src/backend/helper.cts', + '/project/build/helper.js', + '/project/dist/helper.js', + '/project/.vite/helper.js', + ])('Should parse supported app-local module path %s', (id) => { + expect( + createParsedModuleRecord(id, buildRoot, parseAst('export const value = true;')), + ).toEqual(expect.objectContaining({ id })); + }); + test.each([ { description: 'dynamic local imports', diff --git a/packages/plugins/apps/src/backend/ast-parsing/module-graph.ts b/packages/plugins/apps/src/backend/ast-parsing/module-graph.ts index 9d5595484..1b4df6738 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/module-graph.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/module-graph.ts @@ -5,6 +5,8 @@ import type { BaseNode, ImportExpression, Program, SimpleCallExpression } from 'estree'; import path from 'path'; +import { BACKEND_CODE_EXTENSIONS } from '../../constants'; + import { ensureProgram, isStringLiteral } from './type-guards'; import { walkAst } from './walk-ast'; @@ -22,8 +24,7 @@ export interface ModuleDependency { type ImportCallExpression = SimpleCallExpression & { callee: { type: 'Import' } }; -const DISALLOWED_GRAPH_DIRS = new Set(['node_modules', 'dist', 'build', '.vite']); -const PARSEABLE_FILE_RE = /\.(mjs|cjs|js|jsx|mts|cts|ts|tsx)$/; +const PACKAGE_MANAGER_DIRS = new Set(['node_modules', '.yarn']); /** * Creates the per-module analysis record consumed by backend-entry reachability @@ -102,7 +103,7 @@ function shouldFailDynamicImport(specifier: string): boolean { * modules that the backend build collector can safely analyze. */ export function shouldTraverseCollectedModule(moduleId: string, buildRoot: string): boolean { - if (!PARSEABLE_FILE_RE.test(moduleId)) { + if (!BACKEND_CODE_EXTENSIONS.some((extension) => moduleId.endsWith(extension))) { return false; } @@ -111,7 +112,7 @@ export function shouldTraverseCollectedModule(moduleId: string, buildRoot: strin return false; } - return !relativePath.split(path.sep).some((segment) => DISALLOWED_GRAPH_DIRS.has(segment)); + return !relativePath.split(path.sep).some((segment) => PACKAGE_MANAGER_DIRS.has(segment)); } /** diff --git a/packages/plugins/apps/src/backend/ast-parsing/type-guards.ts b/packages/plugins/apps/src/backend/ast-parsing/type-guards.ts index 94db8d362..369a9aca1 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/type-guards.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/type-guards.ts @@ -6,9 +6,12 @@ import type { BaseNode, Program, SimpleLiteral } from 'estree'; export type StringLiteral = SimpleLiteral & { value: string }; -// Rollup's parser preserves TypeScript import/export kind metadata on otherwise -// ESTree-shaped import/export nodes. -type TypeOnlyAwareNode = BaseNode & { importKind?: string; exportKind?: string }; +interface TypeScriptImportExportMetadata { + importKind?: 'type' | 'value'; + exportKind?: 'type' | 'value'; +} + +type TypeOnlyAwareNode = BaseNode & TypeScriptImportExportMetadata; export function ensureProgram(node: BaseNode, filePath: string): Program { if (!isProgramNode(node)) { diff --git a/packages/plugins/apps/src/constants.ts b/packages/plugins/apps/src/constants.ts index 441f86148..db612df45 100644 --- a/packages/plugins/apps/src/constants.ts +++ b/packages/plugins/apps/src/constants.ts @@ -10,3 +10,13 @@ export const PLUGIN_NAME: PluginName = 'datadog-apps-plugin' as const; export const APPS_API_PATH = 'api/unstable/app-builder-code/apps'; export const ARCHIVE_FILENAME = 'datadog-apps-assets.zip'; export const BACKEND_FILE_RE = /\.backend\.(ts|tsx|js|jsx)$/; +export const BACKEND_CODE_EXTENSIONS = [ + '.ts', + '.tsx', + '.js', + '.jsx', + '.mjs', + '.cjs', + '.mts', + '.cts', +]; diff --git a/packages/plugins/apps/src/vite/build-config.ts b/packages/plugins/apps/src/vite/build-config.ts index 8878424e0..114526623 100644 --- a/packages/plugins/apps/src/vite/build-config.ts +++ b/packages/plugins/apps/src/vite/build-config.ts @@ -4,6 +4,8 @@ import type { BuildOptions, InlineConfig, Plugin } from 'vite'; +import { BACKEND_CODE_EXTENSIONS } from '../constants'; + /** * Create the virtual module resolver plugin used by both production and dev builds. * Maps virtual IDs to their generated source content. @@ -59,7 +61,7 @@ export function getBaseBackendBuildConfig( }, }, resolve: { - extensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json'], + extensions: [...BACKEND_CODE_EXTENSIONS, '.json'], }, plugins: [createVirtualPlugin('dd-backend-resolve', virtualEntries), ...plugins], };