-
Notifications
You must be signed in to change notification settings - Fork 12
[APPS-2792] Add: wire local execution into the real dev server #481
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3b70398
6c11054
361607b
92cb344
714e448
a50a7f6
568967e
95f4532
fe24fa1
8b69b17
65a987a
a0f37c5
414cab9
fbddcb4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
| }); | ||
| }); |
| 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'; | ||
| 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( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a custom Vite Useful? React with 👍 / 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed real and customer-reachable — a customer's own |
||
| 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]; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.