From c6bfc007968f8164099bf3182b73b9c328dfc82d Mon Sep 17 00:00:00 2001 From: Yangshun Date: Wed, 21 Oct 2026 20:36:29 +0800 Subject: [PATCH 1/8] perf(void): reuse unchanged source analysis between dev rescans ## Problem Every file event in a source convention directory reran the whole application analysis. Binding inference and the page scanner each read every source file synchronously, and the page scanner parsed every `*.server.ts` with Oxc again. In a 5,000-page app (the many-pages benchmark), one rescan made 20,006 `readFileSync` calls, two per page file, and took about 800 ms on an idle machine. After the rescan, the routing plugin regenerated `virtual:void-routes` even when the save changed nothing the scan reports. That module lists every page, so Vite re-transformed and re-ran it on every save. In a CPU profile of `vp dev` while editing one page of that app, the rescan took 1.0-1.6 s of each 2.0-2.7 s HMR update, and re-transforming and re-running the route table took most of the rest. The benchmark's HMR time grew from 209 ms at 100 pages to 2.21 s at 5,000. The React adapter's pages and islands plugins also scan pages on their own when files are added or removed and at build start, so the same files were read and parsed several times. ## Fix - `deriveFromSource` (`shared/source-analysis-cache.ts`) caches values derived from a file's contents, keyed by path and analysis kind, and reuses them while the file's inode, size, mtime and ctime are unchanged. Binding inference, page server metadata, component `layout` exports and Markdown frontmatter go through it. The cache is module-level, so the core plugin and the framework adapters share it. - Coarse file system timestamps and inode reuse can leave a rewritten file's stat unchanged. As Git does for racily clean index entries, a file that changed within the last 3 s is always read again, and the dev watcher drops a file's entries on every event. - On a `change` event, the routing plugin compares the scan before and after the rescan. When nothing changed, it skips regenerating route types and entries and leaves `virtual:void-routes` alone; Vite still invalidates the saved module. Adds, deletes, auth config, env files and SSR entries keep the previous behavior. ## Impact Measured on the 5,000-page benchmark app: - A warm rescan reads 2 files instead of 20,006 and takes about 100 ms instead of 530-640 ms. - HMR after editing a page (median of five edits, two runs each): 1,166 and 1,216 ms before, 299 and 248 ms after. The benchmark measured 209 ms at 100 pages, so HMR now barely grows with the page count. - Loader and route handler edits also reach the next SSR response sooner: about 1.6 s to 0.5 s for a loader, and 0.8-1.2 s to 0.1 s for a route handler. ## Verification - New `source-analysis-cache.test.ts`. `pages-scan.test.ts` covers metadata edits between cached scans and fails if the cache ignores the file signature. `plugin-routing.test.ts` covers saves that do and do not change scanned facts. - `vp run check` (33/33, after generating playground codegen in a fresh worktree). - `vp run test --base origin/main`: 511 affected files pass, including the Pages HMR integration suites. - On `vp dev` with the 5,000-page app, edits to a page component, its loader and a route handler all reach the next SSR response. Co-Authored-By: Claude Opus 5.5 --- packages/void/AGENTS.md | 3 + packages/void/src/pages/scan.ts | 20 +++-- packages/void/src/plugin-inference.ts | 6 +- packages/void/src/plugin-routing.ts | 37 ++++++--- .../void/src/shared/source-analysis-cache.ts | 54 +++++++++++++ packages/void/test/unit/pages-scan.test.ts | 42 ++++++++++ .../void/test/unit/plugin-routing.test.ts | 44 +++++++++- .../test/unit/source-analysis-cache.test.ts | 80 +++++++++++++++++++ 8 files changed, 266 insertions(+), 20 deletions(-) create mode 100644 packages/void/src/shared/source-analysis-cache.ts create mode 100644 packages/void/test/unit/source-analysis-cache.test.ts diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index 473867ba7..9d3163071 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -16,6 +16,9 @@ and deployment consumers share these snapshots. Apply binding overrides per fiel auth and migrations still require a database. Provider identity and post-build artifact/SQL validation belong to the target. See the [shared analysis record](../../platform/meta/design-docs/0086-shared-application-analysis.md). +Scanners read source through `deriveFromSource` (`shared/source-analysis-cache.ts`), +so dev rescans only re-parse edited files. A derived value must depend only on +that file's contents; anything that reads other files belongs outside the callback. Project settings live in `void.config.ts` through `void/config` and `defineConfig`. `void init` and `void deploy` migrate legacy `void.json` and root Wrangler JSON/JSONC files before using their settings. The `cloudflare` field is authored and checked in; `.void-wrangler.jsonc` is generated and ignored; `void.lock.json` records provisioned IDs and migration history and is checked in. Cloudflare writers must persist every generated change to the lock. Framework adapters that read Cloudflare config during development must point to the generated file. Direct Cloudflare deploys use installed `cf` Build Output upload and activation. Deployment, provisioning and installation callers use the typed `CloudflareOperation` contract from `@void/deploy-cloudflare`. The private `cloudflare-operation-process.ts` entry executes installed `cf` commands or API requests and returns structured process envelopes. Provider presentation is decoded only at the actual CLI boundary; orchestrators consume `data`, codes and process provenance. Secret values travel through stdin or private temporary files. Do not restore historical command arguments, imitation output or a direct Wrangler CLI dependency in `void`. diff --git a/packages/void/src/pages/scan.ts b/packages/void/src/pages/scan.ts index 53fd11823..77e94f4c5 100644 --- a/packages/void/src/pages/scan.ts +++ b/packages/void/src/pages/scan.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync } from 'node:fs'; import { join, dirname, basename, extname } from 'pathslash'; import { glob } from 'tinyglobby'; import { parseSync } from 'vite'; @@ -16,6 +16,7 @@ import { type AstProgram, type AstVariableDeclaration, } from '../shared/ast.ts'; +import { deriveFromSource } from '../shared/source-analysis-cache.ts'; import { staticExportInitializers, staticLiteral } from '../shared/static-exports.ts'; import { scriptBlocks } from './island-imports.ts'; import { resolveNamedLayout } from './layout-resolution.ts'; @@ -298,8 +299,9 @@ export async function scanPages(root: string, options?: PageScanOptions): Promis const candidate = `${serverBase}.server.${serverExt}`; if (existsSync(join(pagesDir, candidate))) { serverPath = candidate; - const content = readFileSync(join(pagesDir, candidate), 'utf-8'); - const metadata = detectPageServerMetadata(content, candidate); + const metadata = deriveFromSource(join(pagesDir, candidate), 'page-server', (source) => + detectPageServerMetadata(source, candidate), + ); methods.push(...metadata.methods); revalidate = metadata.revalidate; prerender = metadata.prerender; @@ -332,12 +334,14 @@ export async function scanPages(root: string, options?: PageScanOptions): Promis let metadata: { title?: string; description?: string } | undefined; let layout: string | false | undefined; if (ext === '.md') { - const mdContent = readFileSync(join(pagesDir, file), 'utf-8'); - metadata = extractFrontmatterMeta(mdContent); - layout = extractFrontmatterLayout(mdContent); + ({ metadata, layout } = deriveFromSource(join(pagesDir, file), 'page-markdown', (source) => ({ + metadata: extractFrontmatterMeta(source), + layout: extractFrontmatterLayout(source), + }))); } else { - const componentContent = readFileSync(join(pagesDir, file), 'utf-8'); - layout = componentLayout(componentContent, file); + layout = deriveFromSource(join(pagesDir, file), 'page-layout', (source) => + componentLayout(source, file), + ); } pages.push({ diff --git a/packages/void/src/plugin-inference.ts b/packages/void/src/plugin-inference.ts index c85a9b1fb..72ae0dbd7 100644 --- a/packages/void/src/plugin-inference.ts +++ b/packages/void/src/plugin-inference.ts @@ -11,6 +11,7 @@ import { type RouteEnvironment, } from './router/parse-filename.ts'; import { isWebSocketPatternFile } from './router/websocket.ts'; +import { deriveFromSource } from './shared/source-analysis-cache.ts'; import { getSsrServerEntryPath } from './ssr/entry.ts'; const SOURCE_GLOB = '**/*.{ts,tsx,mts,js,jsx,mjs,astro}'; @@ -168,8 +169,9 @@ export function inferProjectBindings( return; } } - const code = readFileSync(file, 'utf-8'); - allBindings.push(inferBindingsFromFile(file, code)); + allBindings.push( + deriveFromSource(file, 'bindings', (code) => inferBindingsFromFile(file, code)), + ); }; for (const dir of dirs) { diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index a6f2e6c5f..6d8615807 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -37,6 +37,7 @@ import type { RouteEnvironment } from './router/parse-filename.ts'; import type { ScanResult } from './router/scan.ts'; import type { VoidAuthRuntimeOptions } from './runtime/better-auth-shared.ts'; import { loadLocalEnv } from './shared/env.ts'; +import { invalidateSourceAnalysis } from './shared/source-analysis-cache.ts'; import { validateSsrEntries, SSR_SERVER_ENTRY_RELATIVE_PATHS, @@ -102,6 +103,8 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug let previewing = false; let ssr: SsrCompileOptions | null = null; let authConfigPath: string | null = null; + // Everything a rescan feeds into the route table and generated entries. + const routeInputsKey = () => JSON.stringify([scan, authConfigPath, ssr]); const paths = readProjectPaths(root); const routesDir = paths.routesDir; const middlewareDir = paths.middlewareDir; @@ -664,6 +667,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug const handleFileChange = async (path: string, event: 'add' | 'change' | 'unlink') => { const normalizedPath = normalizePath(path); + invalidateSourceAnalysis(normalizedPath); const sourceConventionChanged = isInDirectory(normalizedPath, normalizedRoutesDir) || isInDirectory(normalizedPath, normalizedMiddlewareDir) || @@ -680,6 +684,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug // Re-scan and invalidate the virtual module in ALL environments. // The route table virtual module lives in the workerd environment, // not the client — so we must iterate all environments. + const previousRouteInputs = routeInputsKey(); try { await rescan(true); } catch (error) { @@ -700,7 +705,19 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug } return; } - resolveAuthCompileOptions(); + // Saving a page body or handler usually changes nothing the scan reports. + // Vite already invalidates the saved module, so the route table only needs + // regenerating when the scan moved: it re-transforms and re-runs a module + // that grows with the number of pages. + const routeInputsChanged = + event !== 'change' || + isPotentialAuthConfigPath(normalizedPath) || + isPotentialEnvFilePath(normalizedPath) || + ssrEntryAbsPaths.has(normalizedPath) || + routeInputsKey() !== previousRouteInputs; + if (routeInputsChanged) { + resolveAuthCompileOptions(); + } const shouldRestartForAssetRouting = event !== 'change' && options.onDevAssetRoutingPolicyChange?.(resolveCurrentAssetRoutingPolicy()) === true; @@ -708,13 +725,15 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug await devServer.restart(); return; } - await writeAuthDatabaseFiles(); - writeRouteTypes(); - writeQueueTypes(); - if (options.target) { - writeNodeEntries(options.target); - } else { - writeWorkerEntry(undefined); + if (routeInputsChanged) { + await writeAuthDatabaseFiles(); + writeRouteTypes(); + writeQueueTypes(); + if (options.target) { + writeNodeEntries(options.target); + } else { + writeWorkerEntry(undefined); + } } // For structural changes (file add/remove), send full-reload to trigger @@ -738,7 +757,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug } } const mod = env.moduleGraph.getModuleById(VIRTUAL_ROUTES_ID); - if (mod) { + if (mod && routeInputsChanged) { env.moduleGraph.invalidateModule(mod); if (structural) { env.hot.send({ type: 'full-reload' }); diff --git a/packages/void/src/shared/source-analysis-cache.ts b/packages/void/src/shared/source-analysis-cache.ts new file mode 100644 index 000000000..58806bcec --- /dev/null +++ b/packages/void/src/shared/source-analysis-cache.ts @@ -0,0 +1,54 @@ +import { readFileSync, statSync } from 'node:fs'; +import { toSlash } from 'pathslash'; + +type SourceEntry = { signature: string; derived: Map }; + +/** + * How long after a file changes its stat is trusted to identify its contents. + * Some file systems keep coarse timestamps (FAT: 2 s, HFS+: 1 s, ext4: one + * kernel tick) and Linux reuses inodes, so a second write inside that window + * can leave the signature unchanged. Like Git's racily clean index entries, + * values derived from recently changed files are recomputed on the next read. + */ +const RACY_WINDOW_MS = 3000; + +const entries = new Map(); + +/** + * Derive a value from a source file, reusing it while the file is unchanged. + * A dev server rescans every source file on each file event, and several + * plugins scan the same pages, but only edited files need to be read and + * parsed again. `kind` separates analyses of the same file. `derive` must be a + * pure function of the source, and callers must not mutate its result. + */ +export function deriveFromSource(path: string, kind: string, derive: (source: string) => T): T { + const key = toSlash(path); + const stat = statSync(key, { bigint: true, throwIfNoEntry: false }); + if (!stat) { + // Keep the read error callers already handle. + return derive(readFileSync(key, 'utf-8')); + } + const signature = `${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`; + const changedAt = Number( + (stat.mtimeNs > stat.ctimeNs ? stat.mtimeNs : stat.ctimeNs) / 1_000_000n, + ); + const trusted = Date.now() - changedAt > RACY_WINDOW_MS; + const entry = entries.get(key); + if (trusted && entry?.signature === signature && entry.derived.has(kind)) { + return entry.derived.get(kind) as T; + } + const value = derive(readFileSync(key, 'utf-8')); + if (!trusted) { + entries.delete(key); + } else if (entry?.signature === signature) { + entry.derived.set(kind, value); + } else { + entries.set(key, { signature, derived: new Map([[kind, value]]) }); + } + return value; +} + +/** Forget values derived from a file. Dev servers call this for watcher events. */ +export function invalidateSourceAnalysis(path: string): void { + entries.delete(toSlash(path)); +} diff --git a/packages/void/test/unit/pages-scan.test.ts b/packages/void/test/unit/pages-scan.test.ts index 61c11a5b3..3444f5f74 100644 --- a/packages/void/test/unit/pages-scan.test.ts +++ b/packages/void/test/unit/pages-scan.test.ts @@ -1080,3 +1080,45 @@ describe('resolveLayoutIds', () => { ).toEqual(['_layouts/home']); }); }); + +describe('scanPages between saves', () => { + beforeEach(() => { + mkdirSync(join(TMP, 'pages'), { recursive: true }); + // Files written by a test are too recent to reuse; move the clock past that window. + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(Date.now() + 60_000); + }); + + afterEach(() => { + vi.useRealTimers(); + rmSync(TMP, { recursive: true, force: true }); + }); + + it('picks up page metadata changed since the previous scan', async () => { + writeFile('cached/post.tsx', 'export default function Post() { return null; }'); + writeFile('cached/post.server.ts', 'export const revalidate = 60;'); + writeFile('cached/notes.md', '---\ntitle: Draft\n---\n# Notes'); + + const before = await scanPages(TMP); + expect(before.pages.find((page) => page.componentId === 'cached/post')).toMatchObject({ + methods: [], + revalidate: 60, + }); + expect(before.pages.find((page) => page.componentId === 'cached/notes')?.metadata).toEqual({ + title: 'Draft', + }); + + writeFile('cached/post.tsx', 'export const layout = false;\nexport default function Post() {}'); + writeFile('cached/post.server.ts', 'export const loader = () => ({});'); + writeFile('cached/notes.md', '---\ntitle: Published\n---\n# Notes'); + + const after = await scanPages(TMP); + expect(after.pages.find((page) => page.componentId === 'cached/post')).toMatchObject({ + methods: ['loader'], + layout: false, + }); + expect(after.pages.find((page) => page.componentId === 'cached/notes')?.metadata).toEqual({ + title: 'Published', + }); + }); +}); diff --git a/packages/void/test/unit/plugin-routing.test.ts b/packages/void/test/unit/plugin-routing.test.ts index e5a920b80..ac2176c18 100644 --- a/packages/void/test/unit/plugin-routing.test.ts +++ b/packages/void/test/unit/plugin-routing.test.ts @@ -335,19 +335,61 @@ describe('routingPlugin', () => { }, ); - it('does not invalidate page renderer modules for component-only page saves', async () => { + it('keeps page and route modules for saves that change no scanned facts', async () => { const root = createTempRoot(); mkdirSync(join(root, 'pages'), { recursive: true }); const pagePath = join(root, 'pages', 'index.tsx'); + const serverPath = join(root, 'pages', 'index.server.ts'); + const routePath = join(root, 'routes', 'health.ts'); writeFileSync(pagePath, 'export default function Index() { return null; }'); + writeFileSync(serverPath, 'export const loader = () => ({ count: 1 });'); + writeFileSync(routePath, 'export const GET = () => new Response("ok");'); const plugin = createRoutingPlugin(root, { isAuthEnabled: () => false }); + resolveConfig(plugin, 'serve'); + await runBuildStart(plugin); const { emit, invalidated } = startDevServer(plugin, PAGE_MODULE_IDS); writeFileSync(pagePath, 'export default function Index() { return

Updated

; }'); await emit('change', pagePath); + writeFileSync(serverPath, 'export const loader = () => ({ count: 2 });'); + await emit('change', serverPath); + writeFileSync(routePath, 'export const GET = () => new Response("still ok");'); + await emit('change', routePath); + + expect(invalidated).toEqual([]); + }); + + it('regenerates the route table when a save changes scanned facts', async () => { + const root = createTempRoot(); + mkdirSync(join(root, 'pages'), { recursive: true }); + const serverPath = join(root, 'pages', 'index.server.ts'); + const routePath = join(root, 'routes', 'health.ts'); + writeFileSync( + join(root, 'pages', 'index.tsx'), + 'export default function Index() { return null; }', + ); + writeFileSync(serverPath, 'export const loader = () => ({});'); + writeFileSync(routePath, 'export const GET = () => new Response("ok");'); + const plugin = createRoutingPlugin(root, { isAuthEnabled: () => false }); + resolveConfig(plugin, 'serve'); + await runBuildStart(plugin); + const { emit, invalidated } = startDevServer(plugin, PAGE_MODULE_IDS); + + writeFileSync( + serverPath, + 'export const loader = () => ({});\nexport const action = () => ({});', + ); + await emit('change', serverPath); expect(invalidated).toEqual(['virtual:void-routes']); + + writeFileSync( + routePath, + 'export const GET = () => new Response("ok");\nexport const POST = () => new Response("ok");', + ); + await emit('change', routePath); + expect(invalidated).toEqual(['virtual:void-routes', 'virtual:void-routes']); }); it('rescans sourceDir route changes during dev', async () => { diff --git a/packages/void/test/unit/source-analysis-cache.test.ts b/packages/void/test/unit/source-analysis-cache.test.ts new file mode 100644 index 000000000..d9039c528 --- /dev/null +++ b/packages/void/test/unit/source-analysis-cache.test.ts @@ -0,0 +1,80 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { + deriveFromSource, + invalidateSourceAnalysis, +} from '../../src/shared/source-analysis-cache.ts'; + +describe('deriveFromSource', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'void-source-cache-')); + }); + + afterEach(() => { + vi.useRealTimers(); + rmSync(dir, { recursive: true, force: true }); + }); + + /** Move the clock past the window in which a file's stat is not trusted. */ + function settle(): void { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(Date.now() + 60_000); + } + + it('reuses a derived value until the file changes', () => { + const file = join(dir, 'page.ts'); + writeFileSync(file, 'export const loader = 1;'); + settle(); + const derive = vi.fn((source: string) => source.length); + + expect(deriveFromSource(file, 'length', derive)).toBe(24); + expect(deriveFromSource(file, 'length', derive)).toBe(24); + expect(derive).toHaveBeenCalledTimes(1); + + writeFileSync(file, 'export const action = 2;;'); + expect(deriveFromSource(file, 'length', derive)).toBe(25); + expect(derive).toHaveBeenCalledTimes(2); + }); + + it('derives again from files that changed too recently to trust their stat', () => { + const file = join(dir, 'page.ts'); + writeFileSync(file, 'one'); + const derive = vi.fn((source: string) => source); + + expect(deriveFromSource(file, 'source', derive)).toBe('one'); + expect(deriveFromSource(file, 'source', derive)).toBe('one'); + expect(derive).toHaveBeenCalledTimes(2); + }); + + it('keeps analyses of one file separate', () => { + const file = join(dir, 'page.ts'); + writeFileSync(file, 'abc'); + settle(); + + expect(deriveFromSource(file, 'upper', (source) => source.toUpperCase())).toBe('ABC'); + expect(deriveFromSource(file, 'length', (source) => source.length)).toBe(3); + expect(deriveFromSource(file, 'upper', () => 'unused')).toBe('ABC'); + }); + + it('derives again after a watcher invalidates the file', () => { + const file = join(dir, 'page.ts'); + writeFileSync(file, 'abc'); + settle(); + const derive = vi.fn((source: string) => source); + + deriveFromSource(file, 'source', derive); + invalidateSourceAnalysis(file); + deriveFromSource(file, 'source', derive); + expect(derive).toHaveBeenCalledTimes(2); + }); + + it('reports a missing file as a read error', () => { + expect(() => deriveFromSource(join(dir, 'missing.ts'), 'source', (source) => source)).toThrow( + /ENOENT/, + ); + }); +}); From c0eaa452d22af266f03efd26a7f81bc485b1e13b Mon Sep 17 00:00:00 2001 From: Yangshun Date: Thu, 22 Oct 2026 09:44:10 +0800 Subject: [PATCH 2/8] style(void): add spacing around source analysis blocks Remove redundant cache guidance from AGENTS.md while keeping the implementation unchanged. --- packages/void/AGENTS.md | 3 --- packages/void/src/pages/scan.ts | 3 +++ packages/void/src/plugin-inference.ts | 1 + packages/void/src/plugin-routing.ts | 8 ++++++++ packages/void/src/shared/source-analysis-cache.ts | 6 ++++++ packages/void/test/unit/pages-scan.test.ts | 3 +++ packages/void/test/unit/plugin-routing.test.ts | 4 ++++ packages/void/test/unit/source-analysis-cache.test.ts | 9 +++++++++ 8 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index 9d3163071..473867ba7 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -16,9 +16,6 @@ and deployment consumers share these snapshots. Apply binding overrides per fiel auth and migrations still require a database. Provider identity and post-build artifact/SQL validation belong to the target. See the [shared analysis record](../../platform/meta/design-docs/0086-shared-application-analysis.md). -Scanners read source through `deriveFromSource` (`shared/source-analysis-cache.ts`), -so dev rescans only re-parse edited files. A derived value must depend only on -that file's contents; anything that reads other files belongs outside the callback. Project settings live in `void.config.ts` through `void/config` and `defineConfig`. `void init` and `void deploy` migrate legacy `void.json` and root Wrangler JSON/JSONC files before using their settings. The `cloudflare` field is authored and checked in; `.void-wrangler.jsonc` is generated and ignored; `void.lock.json` records provisioned IDs and migration history and is checked in. Cloudflare writers must persist every generated change to the lock. Framework adapters that read Cloudflare config during development must point to the generated file. Direct Cloudflare deploys use installed `cf` Build Output upload and activation. Deployment, provisioning and installation callers use the typed `CloudflareOperation` contract from `@void/deploy-cloudflare`. The private `cloudflare-operation-process.ts` entry executes installed `cf` commands or API requests and returns structured process envelopes. Provider presentation is decoded only at the actual CLI boundary; orchestrators consume `data`, codes and process provenance. Secret values travel through stdin or private temporary files. Do not restore historical command arguments, imitation output or a direct Wrangler CLI dependency in `void`. diff --git a/packages/void/src/pages/scan.ts b/packages/void/src/pages/scan.ts index 77e94f4c5..ea9cbc4a7 100644 --- a/packages/void/src/pages/scan.ts +++ b/packages/void/src/pages/scan.ts @@ -299,9 +299,11 @@ export async function scanPages(root: string, options?: PageScanOptions): Promis const candidate = `${serverBase}.server.${serverExt}`; if (existsSync(join(pagesDir, candidate))) { serverPath = candidate; + const metadata = deriveFromSource(join(pagesDir, candidate), 'page-server', (source) => detectPageServerMetadata(source, candidate), ); + methods.push(...metadata.methods); revalidate = metadata.revalidate; prerender = metadata.prerender; @@ -333,6 +335,7 @@ export async function scanPages(root: string, options?: PageScanOptions): Promis // Extract frontmatter metadata and layout from .md files, or layout from component files let metadata: { title?: string; description?: string } | undefined; let layout: string | false | undefined; + if (ext === '.md') { ({ metadata, layout } = deriveFromSource(join(pagesDir, file), 'page-markdown', (source) => ({ metadata: extractFrontmatterMeta(source), diff --git a/packages/void/src/plugin-inference.ts b/packages/void/src/plugin-inference.ts index 72ae0dbd7..f3a84b3df 100644 --- a/packages/void/src/plugin-inference.ts +++ b/packages/void/src/plugin-inference.ts @@ -169,6 +169,7 @@ export function inferProjectBindings( return; } } + allBindings.push( deriveFromSource(file, 'bindings', (code) => inferBindingsFromFile(file, code)), ); diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index 6d8615807..98b0289d1 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -103,8 +103,10 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug let previewing = false; let ssr: SsrCompileOptions | null = null; let authConfigPath: string | null = null; + // Everything a rescan feeds into the route table and generated entries. const routeInputsKey = () => JSON.stringify([scan, authConfigPath, ssr]); + const paths = readProjectPaths(root); const routesDir = paths.routesDir; const middlewareDir = paths.middlewareDir; @@ -668,6 +670,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug const handleFileChange = async (path: string, event: 'add' | 'change' | 'unlink') => { const normalizedPath = normalizePath(path); invalidateSourceAnalysis(normalizedPath); + const sourceConventionChanged = isInDirectory(normalizedPath, normalizedRoutesDir) || isInDirectory(normalizedPath, normalizedMiddlewareDir) || @@ -705,6 +708,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug } return; } + // Saving a page body or handler usually changes nothing the scan reports. // Vite already invalidates the saved module, so the route table only needs // regenerating when the scan moved: it re-transforms and re-runs a module @@ -715,9 +719,11 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug isPotentialEnvFilePath(normalizedPath) || ssrEntryAbsPaths.has(normalizedPath) || routeInputsKey() !== previousRouteInputs; + if (routeInputsChanged) { resolveAuthCompileOptions(); } + const shouldRestartForAssetRouting = event !== 'change' && options.onDevAssetRoutingPolicyChange?.(resolveCurrentAssetRoutingPolicy()) === true; @@ -725,10 +731,12 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug await devServer.restart(); return; } + if (routeInputsChanged) { await writeAuthDatabaseFiles(); writeRouteTypes(); writeQueueTypes(); + if (options.target) { writeNodeEntries(options.target); } else { diff --git a/packages/void/src/shared/source-analysis-cache.ts b/packages/void/src/shared/source-analysis-cache.ts index 58806bcec..2a7e76ce8 100644 --- a/packages/void/src/shared/source-analysis-cache.ts +++ b/packages/void/src/shared/source-analysis-cache.ts @@ -24,20 +24,25 @@ const entries = new Map(); export function deriveFromSource(path: string, kind: string, derive: (source: string) => T): T { const key = toSlash(path); const stat = statSync(key, { bigint: true, throwIfNoEntry: false }); + if (!stat) { // Keep the read error callers already handle. return derive(readFileSync(key, 'utf-8')); } + const signature = `${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`; const changedAt = Number( (stat.mtimeNs > stat.ctimeNs ? stat.mtimeNs : stat.ctimeNs) / 1_000_000n, ); const trusted = Date.now() - changedAt > RACY_WINDOW_MS; const entry = entries.get(key); + if (trusted && entry?.signature === signature && entry.derived.has(kind)) { return entry.derived.get(kind) as T; } + const value = derive(readFileSync(key, 'utf-8')); + if (!trusted) { entries.delete(key); } else if (entry?.signature === signature) { @@ -45,6 +50,7 @@ export function deriveFromSource(path: string, kind: string, derive: (source: } else { entries.set(key, { signature, derived: new Map([[kind, value]]) }); } + return value; } diff --git a/packages/void/test/unit/pages-scan.test.ts b/packages/void/test/unit/pages-scan.test.ts index 3444f5f74..2d30c878a 100644 --- a/packages/void/test/unit/pages-scan.test.ts +++ b/packages/void/test/unit/pages-scan.test.ts @@ -1084,6 +1084,7 @@ describe('resolveLayoutIds', () => { describe('scanPages between saves', () => { beforeEach(() => { mkdirSync(join(TMP, 'pages'), { recursive: true }); + // Files written by a test are too recent to reuse; move the clock past that window. vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(Date.now() + 60_000); @@ -1100,6 +1101,7 @@ describe('scanPages between saves', () => { writeFile('cached/notes.md', '---\ntitle: Draft\n---\n# Notes'); const before = await scanPages(TMP); + expect(before.pages.find((page) => page.componentId === 'cached/post')).toMatchObject({ methods: [], revalidate: 60, @@ -1113,6 +1115,7 @@ describe('scanPages between saves', () => { writeFile('cached/notes.md', '---\ntitle: Published\n---\n# Notes'); const after = await scanPages(TMP); + expect(after.pages.find((page) => page.componentId === 'cached/post')).toMatchObject({ methods: ['loader'], layout: false, diff --git a/packages/void/test/unit/plugin-routing.test.ts b/packages/void/test/unit/plugin-routing.test.ts index ac2176c18..abb42a4d7 100644 --- a/packages/void/test/unit/plugin-routing.test.ts +++ b/packages/void/test/unit/plugin-routing.test.ts @@ -352,8 +352,10 @@ describe('routingPlugin', () => { writeFileSync(pagePath, 'export default function Index() { return

Updated

; }'); await emit('change', pagePath); + writeFileSync(serverPath, 'export const loader = () => ({ count: 2 });'); await emit('change', serverPath); + writeFileSync(routePath, 'export const GET = () => new Response("still ok");'); await emit('change', routePath); @@ -382,6 +384,7 @@ describe('routingPlugin', () => { 'export const loader = () => ({});\nexport const action = () => ({});', ); await emit('change', serverPath); + expect(invalidated).toEqual(['virtual:void-routes']); writeFileSync( @@ -389,6 +392,7 @@ describe('routingPlugin', () => { 'export const GET = () => new Response("ok");\nexport const POST = () => new Response("ok");', ); await emit('change', routePath); + expect(invalidated).toEqual(['virtual:void-routes', 'virtual:void-routes']); }); diff --git a/packages/void/test/unit/source-analysis-cache.test.ts b/packages/void/test/unit/source-analysis-cache.test.ts index d9039c528..56458c11d 100644 --- a/packages/void/test/unit/source-analysis-cache.test.ts +++ b/packages/void/test/unit/source-analysis-cache.test.ts @@ -27,8 +27,10 @@ describe('deriveFromSource', () => { it('reuses a derived value until the file changes', () => { const file = join(dir, 'page.ts'); + writeFileSync(file, 'export const loader = 1;'); settle(); + const derive = vi.fn((source: string) => source.length); expect(deriveFromSource(file, 'length', derive)).toBe(24); @@ -36,13 +38,16 @@ describe('deriveFromSource', () => { expect(derive).toHaveBeenCalledTimes(1); writeFileSync(file, 'export const action = 2;;'); + expect(deriveFromSource(file, 'length', derive)).toBe(25); expect(derive).toHaveBeenCalledTimes(2); }); it('derives again from files that changed too recently to trust their stat', () => { const file = join(dir, 'page.ts'); + writeFileSync(file, 'one'); + const derive = vi.fn((source: string) => source); expect(deriveFromSource(file, 'source', derive)).toBe('one'); @@ -52,6 +57,7 @@ describe('deriveFromSource', () => { it('keeps analyses of one file separate', () => { const file = join(dir, 'page.ts'); + writeFileSync(file, 'abc'); settle(); @@ -62,13 +68,16 @@ describe('deriveFromSource', () => { it('derives again after a watcher invalidates the file', () => { const file = join(dir, 'page.ts'); + writeFileSync(file, 'abc'); settle(); + const derive = vi.fn((source: string) => source); deriveFromSource(file, 'source', derive); invalidateSourceAnalysis(file); deriveFromSource(file, 'source', derive); + expect(derive).toHaveBeenCalledTimes(2); }); From d531ec54fc0b3a91282341ea220b784ac774db3c Mon Sep 17 00:00:00 2001 From: Yangshun Date: Thu, 22 Oct 2026 11:01:08 +0800 Subject: [PATCH 3/8] fix(void): retry failed route regeneration Keep the generated routing checkpoint pending until writes and module invalidation succeed, including forced updates with unchanged scan facts. Cover write failures, forced updates, invalidation failures, and recovery to the body-only save fast path. --- packages/void/src/plugin-routing.ts | 30 ++++++-- .../void/test/unit/plugin-routing.test.ts | 68 +++++++++++++++++++ 2 files changed, 91 insertions(+), 7 deletions(-) diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index 98b0289d1..587e575d8 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -106,6 +106,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug // Everything a rescan feeds into the route table and generated entries. const routeInputsKey = () => JSON.stringify([scan, authConfigPath, ssr]); + let generatedRouteInputs: string | undefined; const paths = readProjectPaths(root); const routesDir = paths.routesDir; @@ -535,7 +536,9 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug }, async buildStart() { + generatedRouteInputs = undefined; await rescan(); + const nextRouteInputs = routeInputsKey(); if (options.target && (scan?.websockets?.length ?? 0) > 0) { throw new Error('routing: WebSocket routes require the default Cloudflare target.'); } @@ -594,6 +597,8 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug : undefined; writeWorkerEntry(isr); } + + generatedRouteInputs = nextRouteInputs; }, resolveId: { @@ -687,7 +692,16 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug // Re-scan and invalidate the virtual module in ALL environments. // The route table virtual module lives in the workerd environment, // not the client — so we must iterate all environments. - const previousRouteInputs = routeInputsKey(); + const forceRegeneration = + event !== 'change' || + isPotentialAuthConfigPath(normalizedPath) || + isPotentialEnvFilePath(normalizedPath) || + ssrEntryAbsPaths.has(normalizedPath); + + if (forceRegeneration) { + generatedRouteInputs = undefined; + } + try { await rescan(true); } catch (error) { @@ -708,19 +722,17 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug } return; } + const nextRouteInputs = routeInputsKey(); // Saving a page body or handler usually changes nothing the scan reports. // Vite already invalidates the saved module, so the route table only needs // regenerating when the scan moved: it re-transforms and re-runs a module // that grows with the number of pages. - const routeInputsChanged = - event !== 'change' || - isPotentialAuthConfigPath(normalizedPath) || - isPotentialEnvFilePath(normalizedPath) || - ssrEntryAbsPaths.has(normalizedPath) || - routeInputsKey() !== previousRouteInputs; + const routeInputsChanged = nextRouteInputs !== generatedRouteInputs; if (routeInputsChanged) { + // A failed forced update must also retry when the scan itself is unchanged. + generatedRouteInputs = undefined; resolveAuthCompileOptions(); } @@ -779,6 +791,10 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug if (/\.island\./.test(normalizedPath) || normalizedPath.endsWith('.md')) { devServer.environments.client?.hot.send({ type: 'full-reload' }); } + + if (routeInputsChanged) { + generatedRouteInputs = nextRouteInputs; + } }; watcher.on('add', (path) => handleFileChange(path, 'add')); diff --git a/packages/void/test/unit/plugin-routing.test.ts b/packages/void/test/unit/plugin-routing.test.ts index abb42a4d7..fac844afa 100644 --- a/packages/void/test/unit/plugin-routing.test.ts +++ b/packages/void/test/unit/plugin-routing.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { analyzeApplicationSync } from '../../src/application-analysis.ts'; import { readConfig } from '../../src/config.ts'; import { routingPlugin } from '../../src/plugin-routing.ts'; +import { readProjectPaths } from '../../src/project-paths.ts'; import { getHookHandler } from '../helpers/vite-hooks.ts'; const tempDirs: Array = []; @@ -396,6 +397,73 @@ describe('routingPlugin', () => { expect(invalidated).toEqual(['virtual:void-routes', 'virtual:void-routes']); }); + it.each([ + { change: 'route exports', failure: 'generated-file write' }, + { change: 'an environment file', failure: 'generated-file write' }, + { change: 'route exports', failure: 'module invalidation' }, + ])('retries after a failed $failure for $change', async ({ change, failure }) => { + const root = createTempRoot(); + const routePath = join(root, 'routes', 'health.ts'); + const envPath = readProjectPaths(root).envFileCandidates[0]!; + const changesExports = change === 'route exports'; + const failsWrite = failure === 'generated-file write'; + + writeFileSync(routePath, 'export const GET = () => new Response("ok");'); + writeFileSync(envPath, 'export default {};'); + + const plugin = createRoutingPlugin(root, { isAuthEnabled: () => false }); + resolveConfig(plugin, 'serve'); + await runBuildStart(plugin); + + const typesPath = join(root, '.void', 'routes.d.ts'); + const previousTypes = readFileSync(typesPath, 'utf-8'); + const { server, emit, invalidated } = startDevServer(plugin, ['virtual:void-routes']); + + expect(previousTypes).not.toContain('POST: {'); + + if (failsWrite) { + rmSync(typesPath); + mkdirSync(typesPath); + } else { + vi.spyOn( + server.environments.void_worker.moduleGraph, + 'invalidateModule', + ).mockImplementationOnce(() => { + throw new Error('Transient module invalidation failure'); + }); + } + + const postExport = changesExports ? '\nexport const POST = () => new Response("created");' : ''; + + if (changesExports) { + writeFileSync(routePath, 'export const GET = () => new Response("ok");' + postExport); + } else { + writeFileSync(envPath, 'export default { example: true };'); + } + + await expect(emit('change', changesExports ? routePath : envPath)).rejects.toThrow(); + expect(invalidated).toEqual([]); + + if (failsWrite) { + rmSync(typesPath, { recursive: true }); + writeFileSync(typesPath, previousTypes); + } + + writeFileSync(routePath, 'export const GET = () => new Response("updated");' + postExport); + await emit('change', routePath); + + expect(invalidated).toEqual(['virtual:void-routes']); + expect(readFileSync(typesPath, 'utf-8').includes('POST: {')).toBe(changesExports); + + writeFileSync( + routePath, + 'export const GET = () => new Response("updated again");' + postExport, + ); + await emit('change', routePath); + + expect(invalidated).toEqual(['virtual:void-routes']); + }); + it('rescans sourceDir route changes during dev', async () => { const root = createBareTempRoot(); writeFileSync(join(root, 'void.json'), JSON.stringify({ sourceDir: 'src' })); From 3ca64b3c29300e18a6f9080c742ed21498ad4b2c Mon Sep 17 00:00:00 2001 From: Yangshun Date: Thu, 22 Oct 2026 11:01:24 +0800 Subject: [PATCH 4/8] test(void): bound optional runtime availability probes Stop an unresponsive runtime version probe after ten seconds so test discovery can skip unavailable optional runtimes instead of hanging before Vitest timeouts apply. --- packages/void/test/integration/pages-target-assets.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/void/test/integration/pages-target-assets.test.ts b/packages/void/test/integration/pages-target-assets.test.ts index 3e432badc..d7bdba537 100644 --- a/packages/void/test/integration/pages-target-assets.test.ts +++ b/packages/void/test/integration/pages-target-assets.test.ts @@ -21,7 +21,8 @@ const targets = [ ]; for (const { target, command, args } of targets) { - let available = spawn.sync(command, ['--version']).status === 0; + let available = + spawn.sync(command, ['--version'], { timeout: 10_000, killSignal: 'SIGKILL' }).status === 0; if (target === 'deno' && available) { // Deno resolves the user's npm configuration before evaluating even a // no-import script. Check that optional runtime without altering registries; From ced95b342ec2bc604b7f38040e16a79bbf5715bb Mon Sep 17 00:00:00 2001 From: Yangshun Date: Thu, 22 Oct 2026 17:48:29 +0800 Subject: [PATCH 5/8] fix(void): keep incremental route generation consistent --- packages/void/src/plugin-routing.ts | 363 ++++++++++-------- .../void/test/unit/plugin-routing.test.ts | 141 ++++++- 2 files changed, 340 insertions(+), 164 deletions(-) diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index 587e575d8..0c4af61d7 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync } from 'node:fs'; +import { existsSync, mkdirSync, statSync } from 'node:fs'; import { homedir } from 'node:os'; import { fileURLToPath } from 'node:url'; import { dirname, join, relative } from 'pathslash'; @@ -104,9 +104,22 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug let ssr: SsrCompileOptions | null = null; let authConfigPath: string | null = null; - // Everything a rescan feeds into the route table and generated entries. - const routeInputsKey = () => JSON.stringify([scan, authConfigPath, ssr]); + // Inputs to the last successful route table and generated entries, including + // authored auth settings that can change without changing the source scan. + const routeInputsKey = () => JSON.stringify([scan, authConfigPath, ssr, compileOptions.auth]); let generatedRouteInputs: string | undefined; + let pendingRegeneration = Promise.resolve(); + + // Startup and watcher callbacks share the scan and compile options. Keep their + // writes and successful-input checkpoint together, even across async auth work. + function regenerateInOrder(regenerate: () => Promise): Promise { + const result = pendingRegeneration.then(regenerate); + pendingRegeneration = result.then( + () => {}, + () => {}, + ); + return result; + } const paths = readProjectPaths(root); const routesDir = paths.routesDir; @@ -157,9 +170,10 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug async function rescan(refresh = false) { const analysis = options.getApplicationAnalysis(routeEnvironment, refresh); - scan = (await completeApplicationAnalysis(analysis)).scan; - authConfigPath = analysis.authConfigPath; + const nextScan = (await completeApplicationAnalysis(analysis)).scan; const entries = validateSsrEntries(root, paths); + scan = nextScan; + authConfigPath = analysis.authConfigPath; ssr = entries ? { serverEntryPath: entries.server.absolutePath, @@ -267,6 +281,16 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug }; } + function generatedFilesExist(): boolean { + const files = [ + 'routes.d.ts', + 'queues.d.ts', + ...(options.target ? ['app.ts', 'index.ts'] : ['entry.ts']), + ...(compileOptions.auth ? ['better-auth-db.ts', 'better-auth-schema.ts'] : []), + ]; + return files.every((file) => statSync(join(outDir, file), { throwIfNoEntry: false })?.isFile()); + } + async function writeAuthDatabaseFiles(): Promise { if (!compileOptions.auth) { return; @@ -535,70 +559,72 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug }, }, - async buildStart() { - generatedRouteInputs = undefined; - await rescan(); - const nextRouteInputs = routeInputsKey(); - if (options.target && (scan?.websockets?.length ?? 0) > 0) { - throw new Error('routing: WebSocket routes require the default Cloudflare target.'); - } - resolveAuthCompileOptions(); - await writeAuthDatabaseFiles(); - writeRouteTypes(); - writeQueueTypes(); - - // Bundle migrations into the worker for CF targets (in-worker migration runner) - if (!options.target) { - const migrationsDir = paths.migrationsDir; - if (existsSync(migrationsDir) || compileOptions.auth) { - const { collectMigrations } = await import('./migrations/collect.ts'); - const { buildMigrationPlanBundle } = await import('./migrations/plan.ts'); - const migrations = existsSync(migrationsDir) ? collectMigrations(migrationsDir) : []; - const dialect = getDatabaseDialect(readConfig(root)); - compileOptions.migrationHandlerPath = resolveFile( - dialect === 'postgresql' - ? '#self/runtime/migration-handler-pg' - : dialect === 'mysql' - ? '#self/runtime/migration-handler-mysql' - : '#self/runtime/migration-handler', - ); - compileOptions.migrations = migrations; - compileOptions.migrationPlanBundle = - dialect === 'sqlite' ? buildMigrationPlanBundle(migrations) : undefined; + buildStart() { + return regenerateInOrder(async () => { + generatedRouteInputs = undefined; + await rescan(); + if (options.target && (scan?.websockets?.length ?? 0) > 0) { + throw new Error('routing: WebSocket routes require the default Cloudflare target.'); } - } - // Re-write entries from the completed scan. - if (options.target) { - writeNodeEntries(options.target); - } else { - // Only a non-managed production build bakes ISR in; managed deploys leave it to the platform. - const buildConfig = readConfig(root); - const merged = collectRevalidate(buildConfig, scan!.pages); - const configuredRevalidate = buildConfig.routing?.revalidate; - const emitIsr = - resolvedConfig?.command === 'build' && - !process.env.VOID_DEPLOY_PROJECT_ID && - hasRevalidate(merged); - // Route table + worker entry must agree within one build. All constants - // derive from `emitIsr` so they are present or absent together. - isrRevalidate = emitIsr ? merged : undefined; - isrPages = emitIsr ? collectPages(scan!.pages, configuredRevalidate) : undefined; - isrPathRevalidate = - emitIsr && configuredRevalidate && typeof configuredRevalidate === 'object' - ? configuredRevalidate + resolveAuthCompileOptions(); + const nextRouteInputs = routeInputsKey(); + await writeAuthDatabaseFiles(); + writeRouteTypes(); + writeQueueTypes(); + + // Bundle migrations into the worker for CF targets (in-worker migration runner) + if (!options.target) { + const migrationsDir = paths.migrationsDir; + if (existsSync(migrationsDir) || compileOptions.auth) { + const { collectMigrations } = await import('./migrations/collect.ts'); + const { buildMigrationPlanBundle } = await import('./migrations/plan.ts'); + const migrations = existsSync(migrationsDir) ? collectMigrations(migrationsDir) : []; + const dialect = getDatabaseDialect(readConfig(root)); + compileOptions.migrationHandlerPath = resolveFile( + dialect === 'postgresql' + ? '#self/runtime/migration-handler-pg' + : dialect === 'mysql' + ? '#self/runtime/migration-handler-mysql' + : '#self/runtime/migration-handler', + ); + compileOptions.migrations = migrations; + compileOptions.migrationPlanBundle = + dialect === 'sqlite' ? buildMigrationPlanBundle(migrations) : undefined; + } + } + // Re-write entries from the completed scan. + if (options.target) { + writeNodeEntries(options.target); + } else { + // Only a non-managed production build bakes ISR in; managed deploys leave it to the platform. + const buildConfig = readConfig(root); + const merged = collectRevalidate(buildConfig, scan!.pages); + const configuredRevalidate = buildConfig.routing?.revalidate; + const emitIsr = + resolvedConfig?.command === 'build' && + !process.env.VOID_DEPLOY_PROJECT_ID && + hasRevalidate(merged); + // Route table + worker entry must agree within one build. All constants + // derive from `emitIsr` so they are present or absent together. + isrRevalidate = emitIsr ? merged : undefined; + isrPages = emitIsr ? collectPages(scan!.pages, configuredRevalidate) : undefined; + isrPathRevalidate = + emitIsr && configuredRevalidate && typeof configuredRevalidate === 'object' + ? configuredRevalidate + : undefined; + // Resolve the ISR runtime path LAZILY — only when ISR is actually emitted + // — so non-ISR/managed/dev builds never depend on it. + const isr = emitIsr + ? { + runtimePath: resolveFile('@void/isr/runtime'), + queryAllowlist: buildConfig.routing?.revalidateQueryAllowlist, + } : undefined; - // Resolve the ISR runtime path LAZILY — only when ISR is actually emitted - // — so non-ISR/managed/dev builds never depend on it. - const isr = emitIsr - ? { - runtimePath: resolveFile('@void/isr/runtime'), - queryAllowlist: buildConfig.routing?.revalidateQueryAllowlist, - } - : undefined; - writeWorkerEntry(isr); - } + writeWorkerEntry(isr); + } - generatedRouteInputs = nextRouteInputs; + generatedRouteInputs = nextRouteInputs; + }); }, resolveId: { @@ -650,7 +676,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug ); } if (!scan) { - await rescan(); + await regenerateInOrder(() => rescan()); } return compileRouteTable( scan!, @@ -689,117 +715,132 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug return; } - // Re-scan and invalidate the virtual module in ALL environments. - // The route table virtual module lives in the workerd environment, - // not the client — so we must iterate all environments. - const forceRegeneration = - event !== 'change' || - isPotentialAuthConfigPath(normalizedPath) || - isPotentialEnvFilePath(normalizedPath) || - ssrEntryAbsPaths.has(normalizedPath); - - if (forceRegeneration) { - generatedRouteInputs = undefined; - } - - try { - await rescan(true); - } catch (error) { - // Keep the last good route table; the next change rescans. - const message = error instanceof Error ? error.message : String(error); - // An adapter's pages plugin rescans page adds, removals and .md saves - // and logs the same page error, so log it once. - const adapterLogs = - message.startsWith('pages:') && - isInDirectory(normalizedPath, normalizedPagesDir) && - (event !== 'change' || normalizedPath.endsWith('.md')) && - resolvedConfig?.plugins?.some((plugin) => plugin.name.endsWith(':pages')) === true; - if (!adapterLogs) { - devServer.config.logger.error(message, { - error: error instanceof Error ? error : undefined, - timestamp: true, - }); + const shouldRestart = await regenerateInOrder(async () => { + invalidateSourceAnalysis(normalizedPath); + // Re-scan and invalidate the virtual module in ALL environments. + // The route table virtual module lives in the workerd environment, + // not the client — so we must iterate all environments. + const forceRegeneration = + event !== 'change' || + isPotentialAuthConfigPath(normalizedPath) || + isPotentialEnvFilePath(normalizedPath) || + ssrEntryAbsPaths.has(normalizedPath); + + if (forceRegeneration) { + generatedRouteInputs = undefined; } - return; - } - const nextRouteInputs = routeInputsKey(); - - // Saving a page body or handler usually changes nothing the scan reports. - // Vite already invalidates the saved module, so the route table only needs - // regenerating when the scan moved: it re-transforms and re-runs a module - // that grows with the number of pages. - const routeInputsChanged = nextRouteInputs !== generatedRouteInputs; - if (routeInputsChanged) { - // A failed forced update must also retry when the scan itself is unchanged. - generatedRouteInputs = undefined; + try { + await rescan(true); + } catch (error) { + // Keep the last good route table; the next change rescans. + const message = error instanceof Error ? error.message : String(error); + // An adapter's pages plugin rescans page adds, removals and .md saves + // and logs the same page error, so log it once. + const adapterLogs = + message.startsWith('pages:') && + isInDirectory(normalizedPath, normalizedPagesDir) && + (event !== 'change' || normalizedPath.endsWith('.md')) && + resolvedConfig?.plugins?.some((plugin) => plugin.name.endsWith(':pages')) === true; + if (!adapterLogs) { + devServer.config.logger.error(message, { + error: error instanceof Error ? error : undefined, + timestamp: true, + }); + } + return; + } resolveAuthCompileOptions(); - } + const nextRouteInputs = routeInputsKey(); + + // Saving a page body or handler usually changes nothing the scan reports. + // Vite already invalidates the saved module, so the route table only needs + // regenerating when the scan moved: it re-transforms and re-runs a module + // that grows with the number of pages. + const routeInputsChanged = + nextRouteInputs !== generatedRouteInputs || !generatedFilesExist(); + + if (routeInputsChanged) { + // A failed forced update must also retry when the scan itself is unchanged. + generatedRouteInputs = undefined; + } - const shouldRestartForAssetRouting = - event !== 'change' && - options.onDevAssetRoutingPolicyChange?.(resolveCurrentAssetRoutingPolicy()) === true; - if (shouldRestartForAssetRouting) { - await devServer.restart(); - return; - } + const shouldRestartForAssetRouting = + event !== 'change' && + options.onDevAssetRoutingPolicyChange?.(resolveCurrentAssetRoutingPolicy()) === true; + if (shouldRestartForAssetRouting) { + return true; + } - if (routeInputsChanged) { - await writeAuthDatabaseFiles(); - writeRouteTypes(); - writeQueueTypes(); + if (routeInputsChanged) { + await writeAuthDatabaseFiles(); + writeRouteTypes(); + writeQueueTypes(); - if (options.target) { - writeNodeEntries(options.target); - } else { - writeWorkerEntry(undefined); + if (options.target) { + writeNodeEntries(options.target); + } else { + writeWorkerEntry(undefined); + } } - } - // For structural changes (file add/remove), send full-reload to trigger - // a program reload so the workerd picks up new/removed routes. - // For content-only changes, module invalidation alone is sufficient — - // the workerd re-evaluates stale modules on the next request. Sending - // full-reload for content changes triggers an unnecessary program reload - // that can break React SSR (dual module instances). - const structural = event !== 'change'; - - const pageManifestChanged = - isInDirectory(normalizedPath, normalizedPagesDir) && - (structural || normalizedPath.endsWith('.md')); - for (const env of Object.values(devServer.environments)) { - if (pageManifestChanged) { - for (const id of PAGE_VIRTUAL_MODULE_IDS) { - const pageMod = env.moduleGraph.getModuleById(id); - if (pageMod) { - env.moduleGraph.invalidateModule(pageMod); + // For structural changes (file add/remove), send full-reload to trigger + // a program reload so the workerd picks up new/removed routes. + // For content-only changes, module invalidation alone is sufficient — + // the workerd re-evaluates stale modules on the next request. Sending + // full-reload for content changes triggers an unnecessary program reload + // that can break React SSR (dual module instances). + const structural = event !== 'change'; + + const pageManifestChanged = + isInDirectory(normalizedPath, normalizedPagesDir) && + (structural || normalizedPath.endsWith('.md')); + for (const env of Object.values(devServer.environments)) { + if (pageManifestChanged) { + for (const id of PAGE_VIRTUAL_MODULE_IDS) { + const pageMod = env.moduleGraph.getModuleById(id); + if (pageMod) { + env.moduleGraph.invalidateModule(pageMod); + } } } - } - const mod = env.moduleGraph.getModuleById(VIRTUAL_ROUTES_ID); - if (mod && routeInputsChanged) { - env.moduleGraph.invalidateModule(mod); - if (structural) { - env.hot.send({ type: 'full-reload' }); + const mod = env.moduleGraph.getModuleById(VIRTUAL_ROUTES_ID); + if (mod && routeInputsChanged) { + env.moduleGraph.invalidateModule(mod); + if (structural) { + env.hot.send({ type: 'full-reload' }); + } } } - } - // Reload the browser for island page changes — islands are - // server-rendered only, so there's no client HMR to handle updates. - // .md pages are implicit islands (no client JS), so reload those too. - if (/\.island\./.test(normalizedPath) || normalizedPath.endsWith('.md')) { - devServer.environments.client?.hot.send({ type: 'full-reload' }); - } + // Reload the browser for island page changes — islands are + // server-rendered only, so there's no client HMR to handle updates. + // .md pages are implicit islands (no client JS), so reload those too. + if (/\.island\./.test(normalizedPath) || normalizedPath.endsWith('.md')) { + devServer.environments.client?.hot.send({ type: 'full-reload' }); + } + + if (routeInputsChanged) { + generatedRouteInputs = nextRouteInputs; + } + }); - if (routeInputsChanged) { - generatedRouteInputs = nextRouteInputs; + // Restart runs buildStart again, so release the generation queue first. + if (shouldRestart) { + await devServer.restart(); } }; - watcher.on('add', (path) => handleFileChange(path, 'add')); - watcher.on('change', (path) => handleFileChange(path, 'change')); - watcher.on('unlink', (path) => handleFileChange(path, 'unlink')); + const onFileChange = (path: string, event: 'add' | 'change' | 'unlink') => + handleFileChange(path, event).catch((error: unknown) => { + devServer.config.logger.error(error instanceof Error ? error.message : String(error), { + error: error instanceof Error ? error : undefined, + timestamp: true, + }); + }); + watcher.on('add', (path) => onFileChange(path, 'add')); + watcher.on('change', (path) => onFileChange(path, 'change')); + watcher.on('unlink', (path) => onFileChange(path, 'unlink')); // File-based routing rule HMR: `public/_redirects` / `public/_headers` // changes re-merge + invalidate the route module so rules take effect diff --git a/packages/void/test/unit/plugin-routing.test.ts b/packages/void/test/unit/plugin-routing.test.ts index fac844afa..ac63915ad 100644 --- a/packages/void/test/unit/plugin-routing.test.ts +++ b/packages/void/test/unit/plugin-routing.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import type { ConfigEnv, ResolvedConfig, UserConfig } from 'vite'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { analyzeApplicationSync } from '../../src/application-analysis.ts'; +import * as authConfig from '../../src/auth/config.ts'; import { readConfig } from '../../src/config.ts'; import { routingPlugin } from '../../src/plugin-routing.ts'; import { readProjectPaths } from '../../src/project-paths.ts'; @@ -109,6 +110,7 @@ const PAGE_MODULE_IDS = [ describe('routingPlugin', () => { afterEach(() => { + vi.restoreAllMocks(); while (tempDirs.length > 0) { const dir = tempDirs.pop()!; rmSync(dir, { recursive: true, force: true }); @@ -270,6 +272,7 @@ describe('routingPlugin', () => { resolveConfig(plugin, 'serve'); await runBuildStart(plugin); const { emit, restart } = startDevServer(plugin); + restart.mockImplementation(() => runBuildStart(plugin)); const addedPath = join(root, 'routes', addedRoute); writeFileSync(addedPath, routeSource); @@ -401,6 +404,7 @@ describe('routingPlugin', () => { { change: 'route exports', failure: 'generated-file write' }, { change: 'an environment file', failure: 'generated-file write' }, { change: 'route exports', failure: 'module invalidation' }, + { change: 'route exports', failure: 'auth generation' }, ])('retries after a failed $failure for $change', async ({ change, failure }) => { const root = createTempRoot(); const routePath = join(root, 'routes', 'health.ts'); @@ -411,19 +415,25 @@ describe('routingPlugin', () => { writeFileSync(routePath, 'export const GET = () => new Response("ok");'); writeFileSync(envPath, 'export default {};'); - const plugin = createRoutingPlugin(root, { isAuthEnabled: () => false }); + const plugin = createRoutingPlugin(root, { + isAuthEnabled: () => failure === 'auth generation', + }); resolveConfig(plugin, 'serve'); await runBuildStart(plugin); const typesPath = join(root, '.void', 'routes.d.ts'); const previousTypes = readFileSync(typesPath, 'utf-8'); - const { server, emit, invalidated } = startDevServer(plugin, ['virtual:void-routes']); + const { server, emit, invalidated, logError } = startDevServer(plugin, ['virtual:void-routes']); expect(previousTypes).not.toContain('POST: {'); if (failsWrite) { rmSync(typesPath); mkdirSync(typesPath); + } else if (failure === 'auth generation') { + vi.spyOn(authConfig, 'loadVoidAuthConfig').mockRejectedValueOnce( + new Error('Transient auth generation failure'), + ); } else { vi.spyOn( server.environments.void_worker.moduleGraph, @@ -441,7 +451,8 @@ describe('routingPlugin', () => { writeFileSync(envPath, 'export default { example: true };'); } - await expect(emit('change', changesExports ? routePath : envPath)).rejects.toThrow(); + await expect(emit('change', changesExports ? routePath : envPath)).resolves.toBeUndefined(); + expect(logError).toHaveBeenCalledTimes(1); expect(invalidated).toEqual([]); if (failsWrite) { @@ -464,6 +475,130 @@ describe('routingPlugin', () => { expect(invalidated).toEqual(['virtual:void-routes']); }); + it.each(['startup', 'another save'])('serializes regeneration with %s', async (overlap) => { + const root = createTempRoot(); + const routePath = join(root, 'routes', 'health.ts'); + const get = 'export const GET = () => new Response();'; + const post = get + '\nexport const POST = () => new Response();'; + const remove = get + '\nexport const DELETE = () => new Response();'; + writeFileSync(routePath, get); + + const getApplicationAnalysis = vi.fn((environment: 'dev' | 'prod') => + analyzeApplicationSync(root, { config: readConfig(root), environment }), + ); + const plugin = routingPlugin(root, { isAuthEnabled: () => true, getApplicationAnalysis }); + resolveConfig(plugin, 'serve'); + const { emit, invalidated } = startDevServer(plugin, ['virtual:void-routes']); + if (overlap === 'another save') { + await runBuildStart(plugin); + } + getApplicationAnalysis.mockClear(); + + let started!: () => void; + const entered = new Promise((resolve) => { + started = resolve; + }); + let resume!: () => void; + const pause = new Promise((resolve) => { + resume = () => resolve(undefined); + }); + vi.spyOn(authConfig, 'loadVoidAuthConfig').mockImplementationOnce(() => { + started(); + return pause; + }); + writeFileSync(routePath, post); + const first = overlap === 'startup' ? runBuildStart(plugin) : emit('change', routePath); + await entered; + writeFileSync(routePath, remove); + const second = emit('change', routePath); + try { + // The second event must not replace the shared scan while the first writer awaits auth. + expect(getApplicationAnalysis).toHaveBeenCalledTimes(1); + } finally { + resume(); + await Promise.all([first, second]); + } + + const typesPath = join(root, '.void', 'routes.d.ts'); + expect(readFileSync(typesPath, 'utf8')).toContain('DELETE: {'); + invalidated.length = 0; + writeFileSync(routePath, post); + await emit('change', routePath); + expect(readFileSync(typesPath, 'utf8')).toContain('POST: {'); + expect(readFileSync(typesPath, 'utf8')).not.toContain('DELETE: {'); + expect(invalidated).toEqual(['virtual:void-routes']); + }); + + it.each([ + { + change: 'providers', + config: { auth: { providers: ['google'] } }, + expected: 'providers: ["google"]', + }, + { + change: 'database dialect', + config: { database: 'pg', auth: { providers: ['email'] } }, + expected: 'better-auth-pg.mjs', + }, + ])('refreshes auth $change on a body save', async ({ config, expected }) => { + const root = createTempRoot(); + const routePath = join(root, 'routes', 'health.ts'); + const configPath = join(root, 'void.config.ts'); + writeFileSync( + configPath, + 'export default ' + JSON.stringify({ auth: { providers: ['email'] } }), + ); + writeFileSync(routePath, 'export const GET = () => new Response();'); + const plugin = createRoutingPlugin(root, { isAuthEnabled: () => true }); + resolveConfig(plugin, 'serve'); + await runBuildStart(plugin); + const { emit, invalidated } = startDevServer(plugin, ['virtual:void-routes']); + writeFileSync(configPath, 'export default ' + JSON.stringify(config)); + writeFileSync(routePath, 'export const GET = () => new Response("updated");'); + await emit('change', routePath); + + const load = getHookHandler(plugin.load) as (id: string) => Promise; + expect(await load('virtual:void-routes')).toContain(expected); + expect(invalidated).toEqual(['virtual:void-routes']); + await emit('change', routePath); + expect(invalidated).toEqual(['virtual:void-routes']); + }); + + it.each([ + { target: undefined, auth: false, files: ['routes.d.ts', 'queues.d.ts', 'entry.ts'] }, + { + target: 'node' as const, + auth: false, + files: ['routes.d.ts', 'queues.d.ts', 'app.ts', 'index.ts'], + }, + { target: undefined, auth: true, files: ['better-auth-db.ts', 'better-auth-schema.ts'] }, + ])( + 'repairs deleted generated files for target $target with auth $auth', + async ({ target, auth, files }) => { + const root = createTempRoot(); + const routePath = join(root, 'routes', 'health.ts'); + writeFileSync(routePath, 'export const GET = () => new Response();'); + const plugin = createRoutingPlugin(root, { target, isAuthEnabled: () => auth }); + resolveConfig(plugin, 'serve'); + await runBuildStart(plugin); + const { emit, invalidated } = startDevServer(plugin, ['virtual:void-routes']); + const outputs = files.map((file) => { + const path = join(root, '.void', file); + const content = readFileSync(path, 'utf8'); + rmSync(path); + return { path, content }; + }); + writeFileSync(routePath, 'export const GET = () => new Response("updated");'); + await emit('change', routePath); + for (const { path, content } of outputs) { + expect(readFileSync(path, 'utf8')).toBe(content); + } + expect(invalidated).toEqual(['virtual:void-routes']); + await emit('change', routePath); + expect(invalidated).toEqual(['virtual:void-routes']); + }, + ); + it('rescans sourceDir route changes during dev', async () => { const root = createBareTempRoot(); writeFileSync(join(root, 'void.json'), JSON.stringify({ sourceDir: 'src' })); From a7f1502768f407b55bace06043ab0f2db9a4c6b8 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Thu, 22 Oct 2026 17:48:30 +0800 Subject: [PATCH 6/8] fix(void): keep cached page scans deterministic and isolated --- packages/void/src/pages/scan.ts | 5 ++-- packages/void/test/unit/pages-scan.test.ts | 30 ++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/void/src/pages/scan.ts b/packages/void/src/pages/scan.ts index ea9cbc4a7..46d10aa59 100644 --- a/packages/void/src/pages/scan.ts +++ b/packages/void/src/pages/scan.ts @@ -223,7 +223,8 @@ export async function scanPages(root: string, options?: PageScanOptions): Promis const layouts: Array = []; const namedLayouts: Array = []; - for (const file of files) { + // Glob traversal order can vary between identical scans. Keep layout inventories stable too. + for (const file of files.sort()) { const segments = file.split('/'); // Skip files starting with _ in any segment (except _layouts/) if (segments.some((s) => s.startsWith('_') && s !== '_layouts')) { @@ -361,7 +362,7 @@ export async function scanPages(root: string, options?: PageScanOptions): Promis ...rendering, hasGetPrerenderPaths, island, - metadata, + metadata: metadata ? { ...metadata } : undefined, layout, }); } diff --git a/packages/void/test/unit/pages-scan.test.ts b/packages/void/test/unit/pages-scan.test.ts index 2d30c878a..76e30a374 100644 --- a/packages/void/test/unit/pages-scan.test.ts +++ b/packages/void/test/unit/pages-scan.test.ts @@ -1,5 +1,6 @@ import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; +import * as tinyglobby from 'tinyglobby'; import { describe, expect, it, beforeEach, afterEach, vi } from 'vite-plus/test'; import { generateRouteTypes } from '../../src/codegen/route-types.ts'; import { resolveLayoutIds } from '../../src/pages/layout-resolution.ts'; @@ -11,6 +12,8 @@ import { MAX_REVALIDATE, } from '../../src/pages/scan.ts'; +vi.mock('tinyglobby', { spy: true }); + const TMP = join(import.meta.dirname, '__tmp_pages_scan__'); function writeFile(relativePath: string, content: string): void { @@ -1095,6 +1098,33 @@ describe('scanPages between saves', () => { rmSync(TMP, { recursive: true, force: true }); }); + it('returns stable layout inventories regardless of glob enumeration order', async () => { + const files = ['z/layout.tsx', 'a/layout.tsx', '_layouts/z.tsx', '_layouts/a.tsx', 'index.tsx']; + for (const file of files) { + writeFile(file, 'export default function Component() { return null; }'); + } + const glob = vi.mocked(tinyglobby.glob); + try { + glob.mockResolvedValueOnce([...files]); + const before = await scanPages(TMP); + glob.mockResolvedValueOnce([...files].reverse()); + const after = await scanPages(TMP); + expect(after.layouts).toEqual(before.layouts); + expect(after.namedLayouts).toEqual(before.namedLayouts); + } finally { + glob.mockRestore(); + } + }); + + it('does not expose cached markdown metadata for mutation between scans', async () => { + writeFile('notes.md', '---\ntitle: Original\ndescription: Unchanged\n---\n# Notes'); + const first = await scanPages(TMP); + first.pages[0]!.metadata!.title = 'Mutated'; + first.pages[0]!.metadata!.description = 'Also mutated'; + const second = await scanPages(TMP); + expect(second.pages[0]!.metadata).toEqual({ title: 'Original', description: 'Unchanged' }); + }); + it('picks up page metadata changed since the previous scan', async () => { writeFile('cached/post.tsx', 'export default function Post() { return null; }'); writeFile('cached/post.server.ts', 'export const revalidate = 60;'); From 18a47331560be4aee2d6aea2d8007e4d5c88ebb3 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Thu, 22 Oct 2026 17:48:30 +0800 Subject: [PATCH 7/8] test(void): keep Node Pages coverage mandatory --- packages/void/test/integration/pages-target-assets.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/void/test/integration/pages-target-assets.test.ts b/packages/void/test/integration/pages-target-assets.test.ts index d7bdba537..d640e9361 100644 --- a/packages/void/test/integration/pages-target-assets.test.ts +++ b/packages/void/test/integration/pages-target-assets.test.ts @@ -21,7 +21,10 @@ const targets = [ ]; for (const { target, command, args } of targets) { + // Node is the runtime executing this suite: its coverage is required, even if + // an availability subprocess would time out. Only Bun and Deno are optional. let available = + target === 'node' || spawn.sync(command, ['--version'], { timeout: 10_000, killSignal: 'SIGKILL' }).status === 0; if (target === 'deno' && available) { // Deno resolves the user's npm configuration before evaluating even a From 1aec80875cf7fc6302a9c489bea41323fcf021b6 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 22 Oct 2026 23:03:38 +0800 Subject: [PATCH 8/8] fix(void): repair generated files other writers changed, and clear analysis on watch rebuilds - The fast path compared only that the generated files existed. Another void process in the same root (`void build`, `void db generate`, or the previous server during a restart) can rewrite them, and the dev watcher ignores `.void/`, so they stayed stale until the scan changed. Record their size and mtime at each checkpoint and regenerate when they differ. - `vite build --watch` reruns buildStart without the dev watcher, so the `watchChange` hook now forgets the analysis of each changed file too. - Tests: an environment-file save with an unchanged scan, a forced update whose invalidation failed, a reverted edit after a failed update, a generated file rewritten by another process, and a stronger queue check that fails for a queue that only defers by a microtask. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/plugin-routing.ts | 32 +++++++- .../void/test/unit/plugin-routing.test.ts | 78 +++++++++++++++++++ 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index 01633c3d0..f7050ce46 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -120,6 +120,8 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug // authored auth settings that can change without changing the source scan. const routeInputsKey = () => JSON.stringify([scan, authConfigPath, ssr, compileOptions.auth]); let generatedRouteInputs: string | undefined; + // Size and mtime of the generated files that checkpoint wrote. + let generatedFiles: string | undefined; let pendingRegeneration = Promise.resolve(); // Startup and watcher callbacks share the scan and compile options. Keep their @@ -293,14 +295,27 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug }; } - function generatedFilesExist(): boolean { + /** + * Size and mtime of every generated file, or undefined when one is missing. + * Another void process in the same root (a build, `void db generate`) can + * rewrite them, and the dev watcher ignores `.void/`. + */ + function generatedFilesSignature(): string | undefined { const files = [ 'routes.d.ts', 'queues.d.ts', ...(options.target ? ['app.ts', 'index.ts'] : ['entry.ts']), ...(compileOptions.auth ? ['better-auth-db.ts', 'better-auth-schema.ts'] : []), ]; - return files.every((file) => statSync(join(outDir, file), { throwIfNoEntry: false })?.isFile()); + const parts: Array = []; + for (const file of files) { + const stat = statSync(join(outDir, file), { throwIfNoEntry: false }); + if (!stat?.isFile()) { + return undefined; + } + parts.push(`${file}:${stat.size}:${stat.mtimeMs}`); + } + return parts.join('|'); } async function writeAuthDatabaseFiles(): Promise { @@ -749,9 +764,16 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug } generatedRouteInputs = nextRouteInputs; + generatedFiles = generatedFilesSignature(); }); }, + // `vite build --watch` reruns buildStart without the dev watcher below, so + // forget the analysis of each changed file here as well. + watchChange(id) { + invalidateSourceAnalysis(id); + }, + resolveId: { filter: { id: RESOLVE_ID_FILTER }, handler(id) { @@ -882,8 +904,11 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug // Vite already invalidates the saved module, so the route table only needs // regenerating when the scan moved: it re-transforms and re-runs a module // that grows with the number of pages. + const currentFiles = generatedFilesSignature(); const routeInputsChanged = - nextRouteInputs !== generatedRouteInputs || !generatedFilesExist(); + nextRouteInputs !== generatedRouteInputs || + currentFiles === undefined || + currentFiles !== generatedFiles; if (routeInputsChanged) { // A failed forced update must also retry when the scan itself is unchanged. @@ -947,6 +972,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug if (routeInputsChanged) { generatedRouteInputs = nextRouteInputs; + generatedFiles = generatedFilesSignature(); } }); diff --git a/packages/void/test/unit/plugin-routing.test.ts b/packages/void/test/unit/plugin-routing.test.ts index 8d72e6a61..975b73bfd 100644 --- a/packages/void/test/unit/plugin-routing.test.ts +++ b/packages/void/test/unit/plugin-routing.test.ts @@ -405,6 +405,7 @@ describe('routingPlugin', () => { { change: 'route exports', failure: 'generated-file write' }, { change: 'an environment file', failure: 'generated-file write' }, { change: 'route exports', failure: 'module invalidation' }, + { change: 'an environment file', failure: 'module invalidation' }, { change: 'route exports', failure: 'auth generation' }, ])('retries after a failed $failure for $change', async ({ change, failure }) => { const root = createTempRoot(); @@ -476,6 +477,80 @@ describe('routingPlugin', () => { expect(invalidated).toEqual(['virtual:void-routes']); }); + it('regenerates for an environment file save that leaves the scan unchanged', async () => { + const root = createTempRoot(); + const routePath = join(root, 'routes', 'health.ts'); + const envPath = readProjectPaths(root).envFileCandidates[0]!; + writeFileSync(routePath, 'export const GET = () => new Response("ok");'); + writeFileSync(envPath, 'export default {};'); + + const plugin = createRoutingPlugin(root, { isAuthEnabled: () => false }); + resolveConfig(plugin, 'serve'); + await runBuildStart(plugin); + const { emit, invalidated } = startDevServer(plugin, ['virtual:void-routes']); + + writeFileSync(envPath, 'export default { example: true };'); + await emit('change', envPath); + expect(invalidated).toEqual(['virtual:void-routes']); + + // A later body save is back on the fast path. + writeFileSync(routePath, 'export const GET = () => new Response("updated");'); + await emit('change', routePath); + expect(invalidated).toEqual(['virtual:void-routes']); + }); + + it('regenerates a reverted edit after the update before it failed', async () => { + const root = createTempRoot(); + const routePath = join(root, 'routes', 'health.ts'); + const get = 'export const GET = () => new Response("ok");'; + writeFileSync(routePath, get); + + const plugin = createRoutingPlugin(root, { isAuthEnabled: () => false }); + resolveConfig(plugin, 'serve'); + await runBuildStart(plugin); + const { server, emit, invalidated, logError } = startDevServer(plugin, ['virtual:void-routes']); + vi.spyOn( + server.environments.void_worker.moduleGraph, + 'invalidateModule', + ).mockImplementationOnce(() => { + throw new Error('Transient module invalidation failure'); + }); + + writeFileSync(routePath, get + '\nexport const POST = () => new Response("created");'); + await emit('change', routePath); + expect(logError).toHaveBeenCalledTimes(1); + + // Back to the scan of the last good checkpoint: the files written for POST + // must still be replaced. + writeFileSync(routePath, get); + await emit('change', routePath); + expect(readFileSync(join(root, '.void', 'routes.d.ts'), 'utf8')).not.toContain('POST: {'); + expect(invalidated).toEqual(['virtual:void-routes']); + }); + + it('repairs a generated file another process rewrote', async () => { + const root = createTempRoot(); + const routePath = join(root, 'routes', 'health.ts'); + writeFileSync(routePath, 'export const GET = () => new Response("ok");'); + + const plugin = createRoutingPlugin(root, { isAuthEnabled: () => false }); + resolveConfig(plugin, 'serve'); + await runBuildStart(plugin); + const { emit, invalidated } = startDevServer(plugin, ['virtual:void-routes']); + const typesPath = join(root, '.void', 'routes.d.ts'); + const types = readFileSync(typesPath, 'utf8'); + + // For example `void build` in another terminal, which writes production routes. + writeFileSync(typesPath, '// written by another process\n'); + writeFileSync(routePath, 'export const GET = () => new Response("updated");'); + await emit('change', routePath); + + expect(readFileSync(typesPath, 'utf8')).toBe(types); + expect(invalidated).toEqual(['virtual:void-routes']); + await emit('change', routePath); + expect(invalidated).toEqual(['virtual:void-routes']); + }); + it.each(['startup', 'another save'])('serializes regeneration with %s', async (overlap) => { const root = createTempRoot(); const routePath = join(root, 'routes', 'health.ts'); @@ -513,6 +588,9 @@ describe('routingPlugin', () => { writeFileSync(routePath, remove); const second = emit('change', routePath); try { + // Give the second event every chance to start: a queue that only defers + // by a microtask would rescan here. + await new Promise((resolve) => setImmediate(resolve)); // The second event must not replace the shared scan while the first writer awaits auth. expect(getApplicationAnalysis).toHaveBeenCalledTimes(1); } finally {