diff --git a/packages/void/src/pages/scan.ts b/packages/void/src/pages/scan.ts index 53fd11823..46d10aa59 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'; @@ -222,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')) { @@ -298,8 +300,11 @@ 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; @@ -331,13 +336,16 @@ 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') { - 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({ @@ -354,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/src/plugin-inference.ts b/packages/void/src/plugin-inference.ts index c85a9b1fb..f3a84b3df 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,10 @@ 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 99d0db701..f7050ce46 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { fileURLToPath } from 'node:url'; import { dirname, join, relative, resolve } from 'pathslash'; @@ -43,6 +43,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, @@ -114,6 +115,26 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug let previewing = false; let ssr: SsrCompileOptions | null = null; let authConfigPath: string | null = null; + + // 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; + // 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 + // 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; const middlewareDir = paths.middlewareDir; @@ -163,9 +184,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, @@ -273,6 +295,29 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug }; } + /** + * 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'] : []), + ]; + 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 { if (!compileOptions.auth) { return; @@ -654,66 +699,79 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug } }, - async buildStart() { - await rescan(); - 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; + 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: { @@ -765,7 +823,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug ); } if (!scan) { - await rescan(); + await regenerateInOrder(() => rescan()); } return compileRouteTable( scan!, @@ -789,6 +847,8 @@ 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) || @@ -802,86 +862,136 @@ 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. - 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; - } - resolveAuthCompileOptions(); - const shouldRestartForAssetRouting = - event !== 'change' && - options.onDevAssetRoutingPolicyChange?.(resolveCurrentAssetRoutingPolicy()) === true; - if (shouldRestartForAssetRouting) { - await devServer.restart(); - return; - } - await writeAuthDatabaseFiles(); - writeRouteTypes(); - writeQueueTypes(); - 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); - } + 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; } - const mod = env.moduleGraph.getModuleById(VIRTUAL_ROUTES_ID); - if (mod) { - env.moduleGraph.invalidateModule(mod); - if (structural) { - env.hot.send({ type: 'full-reload' }); + 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 currentFiles = generatedFilesSignature(); + const routeInputsChanged = + nextRouteInputs !== generatedRouteInputs || + currentFiles === undefined || + currentFiles !== generatedFiles; + + 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) { + return true; + } + + if (routeInputsChanged) { + await writeAuthDatabaseFiles(); + writeRouteTypes(); + writeQueueTypes(); + + if (options.target) { + writeNodeEntries(options.target); + } else { + writeWorkerEntry(undefined); } } - } - // 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' }); + // 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' }); + } + } + } + + // 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; + generatedFiles = generatedFilesSignature(); + } + }); + + // 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/src/shared/source-analysis-cache.ts b/packages/void/src/shared/source-analysis-cache.ts new file mode 100644 index 000000000..2a7e76ce8 --- /dev/null +++ b/packages/void/src/shared/source-analysis-cache.ts @@ -0,0 +1,60 @@ +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/integration/pages-target-assets.test.ts b/packages/void/test/integration/pages-target-assets.test.ts index 3e432badc..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,11 @@ const targets = [ ]; for (const { target, command, args } of targets) { - let available = spawn.sync(command, ['--version']).status === 0; + // 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 // no-import script. Check that optional runtime without altering registries; diff --git a/packages/void/test/unit/pages-scan.test.ts b/packages/void/test/unit/pages-scan.test.ts index 61c11a5b3..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 { @@ -1080,3 +1083,75 @@ 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('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;'); + 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 6ad9a723a..975b73bfd 100644 --- a/packages/void/test/unit/plugin-routing.test.ts +++ b/packages/void/test/unit/plugin-routing.test.ts @@ -5,8 +5,10 @@ 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 { GENERATED_ASSET_HEADERS_MARKER } from '../../src/asset-worker-bypass.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'; import { getHookHandler } from '../helpers/vite-hooks.ts'; const tempDirs: Array = []; @@ -109,6 +111,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 +273,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); @@ -336,21 +340,344 @@ 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.each([ + { 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(); + 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: () => 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, 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, + '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)).resolves.toBeUndefined(); + expect(logError).toHaveBeenCalledTimes(1); + 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('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'); + 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 { + // 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 { + 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' })); 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..56458c11d --- /dev/null +++ b/packages/void/test/unit/source-analysis-cache.test.ts @@ -0,0 +1,89 @@ +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/, + ); + }); +});