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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/plugins/apps/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,16 @@
"dependencies": {
"@dd/core": "workspace:*",
"chalk": "2.3.1",
"esbuild": "0.25.8",
"eslint-scope": "7.2.2",
"glob": "11.1.0",
"jszip": "3.10.1",
"pretty-bytes": "5.6.0"
"pretty-bytes": "5.6.0",
"rollup": "4.45.1"
},
"devDependencies": {
"@types/eslint-scope": "3.7.7",
"@types/estree": "1.0.8",
"rollup": "4.45.1",
"typescript": "5.4.3",
"vite": "6.3.5"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,8 @@ function collectStaticModuleDependencies(
}));
}

function getStaticModuleSources(ast: Program): string[] {
// Exported so callers without build-time Rollup ModuleInfo (the dev server) can resolve each static specifier individually against the exact same list this module zips against, rather than a second AST walk that could drift from it.
export function getStaticModuleSources(ast: Program): string[] {
return ast.body.flatMap((node) => {
if (
(node.type === 'ImportDeclaration' ||
Expand Down
124 changes: 124 additions & 0 deletions packages/plugins/apps/src/vite/dev-server-module-graph.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// 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 { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import type { ViteDevServer } from 'vite';

import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants';

import { collectModuleGraphFromServer } from './dev-server-module-graph';

const FIXTURE_ROOT = path.resolve(
__dirname,
'../../../../tests/src/_jest/fixtures/apps_backend_project',
);
const ENTRY_ID = path.join(FIXTURE_ROOT, 'helper.ts');
const SUFFIXED_ENTRY_ID = ENTRY_ID + LOCAL_EXECUTION_LOAD_SUFFIX;

/** A minimal fake ModuleNode shape, matching only the fields collectModuleGraphFromServer reads. */
interface FakeModuleNode {
id: string;
file?: string;
importedModules: Set<FakeModuleNode>;
}

function makeFakeServer(
resolveId: (specifier: string) => Promise<{ id: string } | null>,
entryNode: FakeModuleNode = {
id: SUFFIXED_ENTRY_ID,
file: ENTRY_ID,
importedModules: new Set(),
},
) {
return {
moduleGraph: {
getModuleById: (id: string) => (id === SUFFIXED_ENTRY_ID ? entryNode : undefined),
},
pluginContainer: {
resolveId: (specifier: string) => resolveId(specifier),
},
} as unknown as ViteDevServer;
}

describe('dev-server-module-graph — collectModuleGraphFromServer', () => {
test('Should fail closed, not fall back to the raw specifier, when resolveId fails to resolve a static import', async () => {
const server = makeFakeServer(async () => null);

await expect(collectModuleGraphFromServer(server, ENTRY_ID, FIXTURE_ROOT)).rejects.toThrow(
/unresolvable import specifier ".\/getRuntimeUsers\.backend"/,
);
});

test('Should use the resolved id when resolveId succeeds', async () => {
const resolvedPath = path.join(FIXTURE_ROOT, 'getRuntimeUsers.backend.ts');
const server = makeFakeServer(async () => ({ id: resolvedPath }));

const records = await collectModuleGraphFromServer(server, ENTRY_ID, FIXTURE_ROOT);

expect(records.has(ENTRY_ID)).toBe(true);
});

test('Should throw a clear error when a module file cannot be read from disk', async () => {
const missingFile = path.join(FIXTURE_ROOT, 'does-not-exist.ts');
const entryNode: FakeModuleNode = {
id: SUFFIXED_ENTRY_ID,
file: missingFile,
importedModules: new Set(),
};
const server = makeFakeServer(async () => null, entryNode);

await expect(collectModuleGraphFromServer(server, ENTRY_ID, FIXTURE_ROOT)).rejects.toThrow(
/unreadable module source/,
);
});

describe('when a module file fails to parse', () => {
let tempDir: string;
let badFile: string;

beforeAll(() => {
tempDir = mkdtempSync(path.join(tmpdir(), 'dev-server-module-graph-test-'));
badFile = path.join(tempDir, 'broken.ts');
writeFileSync(badFile, 'export function broken( {{{ this is not valid syntax');
});

afterAll(() => {
rmSync(tempDir, { recursive: true, force: true });
});

test('Should throw a clear error instead of propagating the raw parser exception', async () => {
const entryNode: FakeModuleNode = {
id: SUFFIXED_ENTRY_ID,
file: badFile,
importedModules: new Set(),
};
const server = makeFakeServer(async () => null, entryNode);

await expect(
collectModuleGraphFromServer(server, ENTRY_ID, FIXTURE_ROOT),
).rejects.toThrow(/unparseable module source/);
});
});

test('Should not infinite-loop or double-process a module reached through a cycle in the import graph', async () => {
const resolvedPath = path.join(FIXTURE_ROOT, 'getRuntimeUsers.backend.ts');
const entryNode: FakeModuleNode = {
id: SUFFIXED_ENTRY_ID,
file: ENTRY_ID,
importedModules: new Set(),
};
// A self-referential cycle: the entry "imports" itself via node.importedModules, the
// same shape a real circular backend-to-backend import produces in Vite's own module
// graph. The `visited` Set must stop this from being processed a second time.
entryNode.importedModules.add(entryNode);
const server = makeFakeServer(async () => ({ id: resolvedPath }), entryNode);

const records = await collectModuleGraphFromServer(server, ENTRY_ID, FIXTURE_ROOT);

expect(records.size).toBe(1);
expect(records.has(ENTRY_ID)).toBe(true);
});
});
148 changes: 148 additions & 0 deletions packages/plugins/apps/src/vite/dev-server-module-graph.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// 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.

/* eslint-disable no-await-in-loop */

import { readFile } from '@dd/core/helpers/fs';
import { transform } from 'esbuild';
import { parseAst } from 'rollup/parseAst';
Comment thread
tyffical marked this conversation as resolved.
import type { ModuleNode, ViteDevServer } from 'vite';

import {
createParsedModuleRecord,
getStaticModuleSources,
type ParsedModuleRecord,
shouldTraverseCollectedModule,
unsupportedModuleGraphDependency,
} from '../backend/ast-parsing/module-graph';
import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants';

/**
* Builds the same `ReadonlyMap<string, ParsedModuleRecord>` shape
* `createBackendModuleGraphCollector`'s `moduleParsed` hook produces during a real Rollup
* build, but for the dev server, where that hook never fires (Vite's plugin container
* doesn't implement it) — instead walking `server.moduleGraph`, which `ssrLoadModule`
* already populates with the entry's full transitive static-import graph by the time it
* resolves.
*
* Parses each module's source fresh from disk (via `ModuleNode.file`), stripped of TS/JSX
* by `esbuild.transform` in isolation, rather than Vite's own transform results — the
* client transform doesn't run for an SSR-only load, and the SSR transform rewrites
* imports into `__vite_ssr_import__(...)` calls that `collectActionCatalogImports`'s
* plain-`ImportDeclaration` parser can't read. `esbuild.transform` alone only strips
* types/JSX, leaving import specifiers untouched, so this sees the same syntax the
* production build path already trusts.
*
* Call only after `loadModule` has resolved for `bareEntryId + LOCAL_EXECUTION_LOAD_SUFFIX`
* in the same request — the graph is a live side effect of that call. The suffix is
* appended internally, since Vite keys the loaded node by its full resolved id, so callers
* only need to pass the bare id `extractConnectionIdsFromModuleGraph` also uses.
*/
export async function collectModuleGraphFromServer(
server: ViteDevServer,
bareEntryId: string,
buildRoot: string,
): Promise<ReadonlyMap<string, ParsedModuleRecord>> {
const records = new Map<string, ParsedModuleRecord>();
const visited = new Set<string>();
const pending: ModuleNode[] = [];

const entryNode = server.moduleGraph.getModuleById(bareEntryId + LOCAL_EXECUTION_LOAD_SUFFIX);
if (entryNode) {
pending.push(entryNode);
}

while (pending.length > 0) {
const node = pending.shift()!;
const moduleId = node.id ? normalizeViteModuleId(node.id) : undefined;
if (!moduleId || visited.has(moduleId) || !node.file) {
continue;
}
visited.add(moduleId);

if (!shouldTraverseCollectedModule(moduleId, buildRoot)) {
continue;
}

let source: string;
try {
source = await readFile(node.file);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw unsupportedModuleGraphDependency(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Analyze the source produced by Vite plugins

When a custom Vite load or transform hook rewrites an app-local TypeScript module, ssrLoadModule executes that rewritten source, but this collector analyzes the original file from disk. For example, an action-catalog call or import inserted by a transform is absent from the resulting connection allowlist and is then rejected during local execution; a load hook serving a synthetic filesystem ID can instead fail here as unreadable. The production collector avoids this mismatch by analyzing post-transform moduleInfo.code, so the dev collector also needs to consume source from the Vite plugin pipeline rather than readFile(node.file).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed real and customer-reachable — a customer's own vite.config.ts is a real, hand-editable file that can add any Vite plugin. It fails safe though: an undercollected allowlist causes a runtime rejection, not a leak, and it requires a customer's own plugin to specifically rewrite a backend-reachable file with new action-catalog-relevant code. A proper fix means teaching collectActionCatalogImports to also parse Vite's SSR-rewritten __vite_ssr_import__ call syntax (confirmed via server.transformRequest's actual output), not just plain ImportDeclaration — real parser work, not a mechanical change. Tracked as a deferred follow-up in the PR description's Out of Scope table rather than folded into this pass.

moduleId,
`unreadable module source (${reason})`,
);
}

let ast;
try {
const stripped = await transform(source, {
loader: loaderForModuleId(moduleId),
format: 'esm',
});
ast = parseAst(stripped.code);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw unsupportedModuleGraphDependency(
moduleId,
`unparseable module source (${reason})`,
);
}

// `node.importedModules` mixes static and dynamic imports with no ordering guarantee,
// but `createParsedModuleRecord` zips dependency ids positionally against the AST's
// static import/export declarations, so this list must be static-only, same order. The
// dev server has no Rollup-style `ModuleInfo.importedIds` outside a real build, so each
// static specifier is resolved individually via Vite's own resolution instead — a
// correct 1:1 correspondence by construction, since both sides derive from the same AST.
const staticModuleSources = getStaticModuleSources(ast);
const importerFile = node.file;
const resolutions = await Promise.all(
staticModuleSources.map((moduleSource) =>
server.pluginContainer.resolveId(moduleSource, importerFile ?? undefined, {
ssr: true,
}),
),
);
const staticDependencyIds = resolutions.map((resolved, index) => {
if (!resolved) {
// Fail closed rather than trusting an incomplete allowlist — falling back to the raw specifier text would let a connectionId-scoped call behind an unresolvable import silently drop out of extractConnectionIdsFromModuleGraph's allowlist instead of the whole request failing loudly.
throw unsupportedModuleGraphDependency(
moduleId,
`unresolvable import specifier "${staticModuleSources[index]}"`,
);
}
return normalizeViteModuleId(resolved.id);
});

const record = createParsedModuleRecord(moduleId, buildRoot, ast, staticDependencyIds);
if (record) {
records.set(record.id, record);
}

for (const dependencyNode of node.importedModules) {
pending.push(dependencyNode);
}
}

return records;
}

function loaderForModuleId(moduleId: string): 'ts' | 'tsx' | 'jsx' | 'js' {
if (moduleId.endsWith('.tsx')) {
return 'tsx';
}
if (moduleId.endsWith('.ts') || moduleId.endsWith('.mts') || moduleId.endsWith('.cts')) {
return 'ts';
}
if (moduleId.endsWith('.jsx')) {
return 'jsx';
}
return 'js';
}

function normalizeViteModuleId(id: string): string {
return id.split('?')[0];
}
Loading
Loading