diff --git a/docs/guide/server-routing.md b/docs/guide/server-routing.md index f44b17aa7..389479680 100644 --- a/docs/guide/server-routing.md +++ b/docs/guide/server-routing.md @@ -14,7 +14,27 @@ Create route handlers in `routes/**/*.ts`. Each file maps to a URL path based on Dynamic segments use brackets: `[id]` becomes a route parameter and `[...slug]` becomes a catch-all. Files or directories starting with `_` are ignored. Directories wrapped in parentheses like `(admin)` are route groups. They help organize files without changing the URL. -You can add a `.dev` or `.prod` suffix before the extension (e.g. `debug.dev.ts`) to include a route only in that environment. +### Environment-only routes + +Add a `.dev` or `.prod` suffix before the extension to include a route in only one environment: + +``` +routes/ + api/health.ts → always + api/debug.dev.ts → development only + api/metrics.prod.ts → production only +``` + +`dev` is the Vite dev server. `prod` is every build, `void deploy`, and `vite preview` — preview serves a production build, so it uses the production routes. + +An excluded route is stripped from the worker bundle, from the generated types, and from the generated route and WebSocket config. It is not compiled and it is not deployed. + +Two things do not follow the suffix: + +- `void prepare` boots no Vite, so it has no environment to read. It generates types for every route in both environments. +- [Binding inference](../reference/resource-inference.md) scans your source for imports and does not read the suffix. A `void/storage` import inside `api/debug.dev.ts` still adds an R2 binding to your production config, and `void deploy` still provisions the bucket. Set the binding explicitly with [`inference.bindings`](../reference/config.md#inference-bindings) if you do not want it. + +The suffix applies to files in `routes/` only. It has no effect in `pages/`, `middleware/`, `crons/`, or `queues/`. Each file exports named HTTP method constants to handle specific methods: diff --git a/docs/guide/websockets.md b/docs/guide/websockets.md index 7db400a02..a9b953348 100644 --- a/docs/guide/websockets.md +++ b/docs/guide/websockets.md @@ -28,6 +28,9 @@ Filename rules match regular server routes: - `[id].ws.ts` becomes `:id` - `[...slug].ws.ts` becomes a catch-all - route groups like `(marketing)/chat.ws.ts` are ignored in the URL +- `chat.dev.ws.ts` / `chat.prod.ws.ts` restrict the route to one environment + +The environment suffix goes **before** `.ws`. `chat.ws.dev.ts` is not recognised. ## `defineRoom()` diff --git a/docs/reference/structure.md b/docs/reference/structure.md index 35034013c..d187edc49 100644 --- a/docs/reference/structure.md +++ b/docs/reference/structure.md @@ -80,6 +80,7 @@ File-based HTTP API endpoints built on [Hono](https://hono.dev). Each file expor - **Catch-all**: `[...slug]` matches the remaining path - **Route groups**: `(admin)/` organizes files without affecting URL paths - **Files starting with `_`** are ignored +- **Environment suffix**: `debug.dev.ts` builds in development only, `metrics.prod.ts` in production only See [Server Routing](/guide/server-routing) for the full guide. diff --git a/packages/void/CLAUDE.md b/packages/void/CLAUDE.md index 23d5f5b37..1150ee416 100644 --- a/packages/void/CLAUDE.md +++ b/packages/void/CLAUDE.md @@ -144,7 +144,7 @@ Build output uses `.mjs` / `.d.mts` extensions (`vp pack` / tsdown default). Applied in order to a path relative to `routes/`: 1. Strip extension (`.ts`, `.js`, `.mts`, `.mjs`) -2. Strip env suffix (`.dev`, `.prod`) +2. Strip env suffix (`.dev`, `.prod`) and record it on `RouteDefinition.env` 3. Strip trailing `index` 4. Remove route group dirs `(groupname)` 5. Convert `[param]` → `:param`, `[...param]` → `:param{.+}`, `[...]` → `*` @@ -152,6 +152,8 @@ Applied in order to a path relative to `routes/`: HTTP methods are determined by **named exports** in each file (detected by `detect-exports.ts` via `parseSync`), not by filename suffixes. Files/dirs starting with `_` are ignored by the scanner. +The recorded `env` is enforced by `isRouteEnabledForEnvironment()`. The scanners take an optional `environment` (`'dev' | 'prod'`); when it is omitted no filtering happens. `dev` is the Vite dev server, `prod` is every build, `void deploy`, and `vite preview`. A dev-only route is absent from the production bundle, types, and generated route/WebSocket config. Two paths do not follow it: `void prepare` boots no Vite, passes no `environment`, and so generates types for both environments; and `inferProjectBindings()` (`plugin-inference.ts`) globs source files without parsing route filenames, so a `void/storage` import in a `.dev.ts` route still emits and provisions an R2 binding in production. Over-emitting is the deliberate safe direction — under-emitting would drop a binding the running code needs — and the environment is not knowable at the `inferProjectBindings()` call site anyway (`index.ts:678`, versus `routeEnvironment` at `index.ts:1324`). The suffix is honoured in `routes/` only, not in `pages/`, `middleware/`, `crons/`, or `queues/`. + ## Code Generation (`void gen`) Rails/Laravel-style scaffolding commands for common boilerplate: diff --git a/packages/void/skills/migrate-vite-cloudflare-to-void/SKILL.md b/packages/void/skills/migrate-vite-cloudflare-to-void/SKILL.md index f819b10d8..ee8070705 100644 --- a/packages/void/skills/migrate-vite-cloudflare-to-void/SKILL.md +++ b/packages/void/skills/migrate-vite-cloudflare-to-void/SKILL.md @@ -103,7 +103,7 @@ Apply these filename rules exactly when mapping old handlers to `routes/`: 1. Extension and suffix parsing order - Strip extension (`.ts`, `.js`, `.mts`, `.mjs`). -- Strip env suffix (`.dev`, `.prod`). +- Strip env suffix (`.dev`, `.prod`). The suffix restricts the route to that environment, so only use it for handlers that must not ship to the other one. - Strip HTTP method suffix (`.get`, `.post`, `.put`, `.delete`, `.patch`). - Strip trailing `index` segment. - Remove route group segments `(group-name)` from URL path. diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index bf3029728..28e4e6e11 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -3597,7 +3597,7 @@ export async function runCloudflareDeploy( // instead"). So a websocket app deployed to a fresh own-account hard-fails after the build. // Emitting `new_sqlite_classes` is a build-side follow-up; until then reject those apps. // (sandbox / void/live DOs already emit `new_sqlite_classes` — they stay supported.) - if (scanWebSocketRoutesSync(root, paths).length > 0) { + if (scanWebSocketRoutesSync(root, paths, 'prod').length > 0) { log.error( [ 'deploy: --backend cloudflare does not yet support WebSocket / Durable Object routes.', @@ -6119,8 +6119,12 @@ async function runFullDeploy( cron, })), ); - const routeScan = await scanRoutes(root, { output: deployConfig.output, paths }); - const webSockets = routeScan.websockets ?? scanWebSocketRoutesSync(root, paths); + const routeScan = await scanRoutes(root, { + output: deployConfig.output, + paths, + environment: 'prod', + }); + const webSockets = routeScan.websockets ?? scanWebSocketRoutesSync(root, paths, 'prod'); const liveConfig = await resolveDeployLiveBindingConfig(root, client, config.projectId, { isFrameworkMode, needsLive: effectiveBindings.needsLive, diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index be2a4b884..1af8731a5 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -67,6 +67,7 @@ import { routingPlugin } from './plugin-routing.ts'; import { assertNoRootSourceConventionConflicts, resolveProjectPaths } from './project-paths.ts'; import { scanQueuesSync } from './queues/scan.ts'; import { compileAssetFallbackHelper } from './router/compile.ts'; +import { isRouteEnabledForEnvironment, type RouteEnvironment } from './router/parse-filename.ts'; import { scanMiddlewareSync, scanRoutePatternsSync } from './router/scan.ts'; import { scanWebSocketRoutesSync } from './router/websocket.ts'; import { SANDBOX_MIGRATION_TAG, isSandboxEnabled, resolveSandboxConfig } from './sandbox/config.ts'; @@ -662,6 +663,7 @@ export function voidPlugin(options?: { : getOrCreateDevTriggerToken(root); const config = readConfig(root); const paths = resolveProjectPaths(root, config); + let routeEnvironment: RouteEnvironment = 'prod'; assertNoRootSourceConventionConflicts(paths); const dialect = getDatabaseDialect(config); const detected = detectFramework(root); @@ -756,6 +758,13 @@ export function voidPlugin(options?: { // Scan queues/websocket routes early so both framework-mode and default-mode // branches can safely close over these bindings. const queues = framework ? [] : scanQueuesSync(root, paths.queuesDir); + // Deliberately scanned with NO environment: this list is the superset used to + // RESERVE binding and Durable Object class names below, and reservation must + // not depend on which command is running. A filtered list would let the live + // DO resolve to `VOID_LIVE` under `vite dev` and `VOID_LIVE_2` under + // `vite build` for the same project. Consumers that emit config rather than + // reserve names re-filter by `routeEnvironment` (see `mergeBindings` and + // `resolveConfiguredAssetRoutingPolicy`). const websocketRoutes = framework ? [] : scanWebSocketRoutesSync(root, paths); const liveConfig = !isNodeTarget(config.target) ? bindings.needsLive @@ -941,6 +950,7 @@ export function voidPlugin(options?: { // Core worker config — always set by voidPlugin (main, triggers, assets). // Bindings are handled separately in the config function below. const workerConfig: Record = {}; + let assetRouting: AssetRoutingPolicy; if (config.worker?.vars) { workerConfig.vars = { ...config.worker.vars }; } @@ -990,6 +1000,9 @@ export function voidPlugin(options?: { // When no wrangler.json exists, resolved config has empty bindings and // all inferred bindings are added — matching the previous behavior. function mergeBindings(resolved: WorkerConfig): Partial { + const environmentWebsocketRoutes = websocketRoutes.filter((route) => + isRouteEnabledForEnvironment(route, routeEnvironment), + ); const result: Record = { ...workerConfig }; // Void owns the generated asset routing policy. Replace stale root @@ -1071,7 +1084,7 @@ export function voidPlugin(options?: { }; } - if (websocketRoutes.length > 0) { + if (environmentWebsocketRoutes.length > 0) { type DurableObjectBindingsConfig = { durable_objects?: { bindings?: Array<{ name: string; class_name: string }>; @@ -1081,7 +1094,7 @@ export function voidPlugin(options?: { ((resolved as DurableObjectBindingsConfig).durable_objects?.bindings as | Array<{ name: string; class_name: string }> | undefined) ?? []; - const missingDurableObjectBindings = websocketRoutes + const missingDurableObjectBindings = environmentWebsocketRoutes .filter( (route) => !existingDurableObjectBindings.some((binding) => binding.name === route.bindingName), @@ -1101,7 +1114,7 @@ export function voidPlugin(options?: { (result as Record).migrations = [ { tag: 'void-ws-v1', - new_classes: websocketRoutes.map((route) => route.className), + new_classes: environmentWebsocketRoutes.map((route) => route.className), }, ]; } @@ -1304,8 +1317,25 @@ export function voidPlugin(options?: { getVoidDeployBuildConfig: (): { mode: string; envDir: string | undefined } | undefined => voidDeployBuildConfig, }, - config(userConfig, { mode, command }) { + config(userConfig, { mode, command, isPreview }) { viteCommand = command; + // `dev` is the Vite dev server only. Every build, `void deploy`, and + // `vite preview` (which reports `command: 'serve'`) is `prod`. + routeEnvironment = command === 'serve' && !isPreview ? 'dev' : 'prod'; + // Re-resolve now that the environment is known — the construction-time + // value below always assumes `prod`. + // + // The guard is NOT an optimization. `envPlugin` is also returned by the + // framework and node-target branches, and both of those `return` before + // `ssrEntry` / `hasPages` / `hasMiddleware` are declared. Calling + // `resolveConfiguredAssetRoutingPolicy()` there throws a TDZ + // `ReferenceError`. Those branches also must not write + // `workerConfig.assets` at all — `mergeBindings` keys off it staying + // unset. + if (!framework && !isNodeTarget(config.target)) { + assetRouting = resolveConfiguredAssetRoutingPolicy(); + applyAssetRoutingPolicy(assetRouting); + } // Exclude the .void cache directory from the file watcher so that // miniflare SQLite writes don't trigger infinite HMR refreshes. const ignored = ['**/.void/**']; @@ -1341,7 +1371,7 @@ export function voidPlugin(options?: { // Vite's own module URLs still need to bypass the worker so HMR, // virtual modules, and dev CSS are served by Vite middleware directly. const assets = workerConfig.assets as { run_worker_first?: Array } | undefined; - if (command === 'serve' && assets?.run_worker_first?.includes('/**')) { + if (command === 'serve' && !isPreview && assets?.run_worker_first?.includes('/**')) { assets.run_worker_first = [...DEV_ASSET_ROUTING_EXCLUSIONS, '/**']; } @@ -1721,21 +1751,34 @@ export function voidPlugin(options?: { const ssrEntry = validateSsrEntry(root, paths); const hasPages = existsSync(paths.pagesDir); const hasMiddleware = scanMiddlewareSync(root, paths).length > 0; - const routePatterns = scanRoutePatternsSync(root, paths); - const hasDocumentRoutes = routeScanHasDocumentRoutes(routePatterns); - const hasApiRoutes = - routeScanHasApiRoutes(routePatterns) || routeScanHasApiRoutes(websocketRoutes) || authEnabled; - let assetRouting = resolveAssetRoutingPolicy({ - isSsr: Boolean(ssrEntry), - hasPages, - hasApiRoutes, - hasDocumentRoutes, - hasMiddleware, - hasWebSockets: routeScanHasDocumentRoutes(websocketRoutes), - authEnabled, - liveEnabled: liveConfig !== undefined, - notFound: config.routing?.notFound, - }); + + function resolveConfiguredAssetRoutingPolicy(): AssetRoutingPolicy { + const routePatterns = scanRoutePatternsSync(root, paths, routeEnvironment); + const environmentWebsocketRoutes = websocketRoutes.filter((route) => + isRouteEnabledForEnvironment(route, routeEnvironment), + ); + return resolveAssetRoutingPolicy({ + isSsr: Boolean(ssrEntry), + hasPages, + hasApiRoutes: + routeScanHasApiRoutes(routePatterns) || + routeScanHasApiRoutes(environmentWebsocketRoutes) || + authEnabled, + hasDocumentRoutes: routeScanHasDocumentRoutes(routePatterns), + hasMiddleware, + hasWebSockets: routeScanHasDocumentRoutes(environmentWebsocketRoutes), + authEnabled, + liveEnabled: liveConfig !== undefined, + notFound: config.routing?.notFound, + }); + } + + // Provisional: `routeEnvironment` is still its `prod` default here, because no + // `config` hook has run yet. `envPlugin.config` recomputes this before any + // consumer reads it — `mergeBindings` runs inside the Cloudflare plugin's own + // `config` hook, which Vite sorts after `envPlugin`'s. It is assigned here so + // `assetRouting` is never read while unassigned. + assetRouting = resolveConfiguredAssetRoutingPolicy(); workerConfig.main = join(root, '.void', 'entry.ts'); diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index 1426ec79a..253e8366d 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -31,6 +31,7 @@ import { type SsrCompileOptions, type PagesCompileOptions, } from './router/compile.ts'; +import type { RouteEnvironment } from './router/parse-filename.ts'; import { scanRoutes, type ScanResult } from './router/scan.ts'; import type { VoidAuthRuntimeOptions } from './runtime/better-auth-shared.ts'; import { @@ -97,6 +98,7 @@ type RoutingPluginOptions = { export function routingPlugin(root: string, options: RoutingPluginOptions): Plugin { let scan: ScanResult | null = null; + let routeEnvironment: RouteEnvironment = 'prod'; let ssr: SsrCompileOptions | null = null; let authConfigPath: string | null = null; const paths = readProjectPaths(root); @@ -141,7 +143,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug } async function rescan() { - scan = await scanRoutes(root, { output: options.output, paths }); + scan = await scanRoutes(root, { output: options.output, paths, environment: routeEnvironment }); authConfigPath = findVoidAuthConfig(root, paths); const entries = validateSsrEntries(root, paths); ssr = entries @@ -471,6 +473,8 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug } }, config(_, env) { + routeEnvironment = env.command === 'serve' && !env.isPreview ? 'dev' : 'prod'; + if (env.command === 'serve' && options.isAuthEnabled()) { // Alias better-auth client subpaths to absolute file paths so // Vite can resolve them during SSR without the bare specifiers diff --git a/packages/void/src/router/parse-filename.ts b/packages/void/src/router/parse-filename.ts index 449a06ed2..69f7456bf 100644 --- a/packages/void/src/router/parse-filename.ts +++ b/packages/void/src/router/parse-filename.ts @@ -1,3 +1,5 @@ +export type RouteEnvironment = 'dev' | 'prod'; + export type RouteDefinition = { /** URL pattern, e.g. "/api/users/:id" */ pattern: string; @@ -8,13 +10,13 @@ export type RouteDefinition = { /** Whether this route has a catch-all parameter */ catchAll: boolean; /** Environment restriction: "dev", "prod", or null */ - env: string | null; + env: RouteEnvironment | null; /** Original relative file path */ filePath: string; }; const EXTENSIONS = ['.ts', '.mts', '.js', '.mjs']; -const ENV_SUFFIXES = ['dev', 'prod']; +const ENV_SUFFIXES: Array = ['dev', 'prod']; function parsePath(relativePath: string, isWebSocketRoute: boolean): RouteDefinition { const filePath = relativePath; @@ -40,7 +42,7 @@ function parsePath(relativePath: string, isWebSocketRoute: boolean): RouteDefini let filename = segments.pop()!; // 2. Check for env suffix - let env: string | null = null; + let env: RouteEnvironment | null = null; for (const suffix of ENV_SUFFIXES) { if (filename.endsWith(`.${suffix}`)) { env = suffix; @@ -116,3 +118,10 @@ export function parseRouteFilename(relativePath: string): RouteDefinition { export function parseWebSocketFilename(relativePath: string): RouteDefinition { return parsePath(relativePath, true); } + +export function isRouteEnabledForEnvironment( + route: Pick, + environment?: RouteEnvironment, +): boolean { + return environment === undefined || route.env === null || route.env === environment; +} diff --git a/packages/void/src/router/scan.ts b/packages/void/src/router/scan.ts index 2dbdcd13d..8d245c04b 100644 --- a/packages/void/src/router/scan.ts +++ b/packages/void/src/router/scan.ts @@ -6,7 +6,12 @@ import type { PageScanResult } from '../pages/types.ts'; import { readProjectPaths, type ProjectPaths } from '../project-paths.ts'; import { scanQueues, type QueueDefinition } from '../queues/scan.ts'; import { detectHttpExports } from './detect-exports.ts'; -import { parseRouteFilename, type RouteDefinition } from './parse-filename.ts'; +import { + isRouteEnabledForEnvironment, + parseRouteFilename, + type RouteDefinition, + type RouteEnvironment, +} from './parse-filename.ts'; import { isWebSocketPatternFile, scanWebSocketRoutes, @@ -73,12 +78,16 @@ function compareRouteSpecificity(a: RouteDefinition, b: RouteDefinition): number */ export async function scanRoutes( root: string, - options?: { output?: 'server' | 'static'; paths?: ProjectPaths }, + options?: { + output?: 'server' | 'static'; + paths?: ProjectPaths; + environment?: RouteEnvironment; + }, ): Promise { const paths = options?.paths ?? readProjectPaths(root); const [routeFiles, websockets, middlewareFiles, jobs, queues, pages] = await Promise.all([ glob(ROUTE_PATTERN, { cwd: paths.routesDir, absolute: false }), - scanWebSocketRoutes(root, paths), + scanWebSocketRoutes(root, paths, options?.environment), glob(ROUTE_PATTERN, { cwd: paths.middlewareDir, absolute: false }).catch( () => [] as Array, ), @@ -93,9 +102,10 @@ export async function scanRoutes( const routes = routeFiles .filter((f) => !isWebSocketPatternFile(f)) .filter((f) => !f.split('/').some((s) => s.startsWith('_'))) - .map((f) => { - const route = parseRouteFilename(f); - route.methods = detectHttpExports(join(routesDir, f)); + .map((file) => parseRouteFilename(file)) + .filter((route) => isRouteEnabledForEnvironment(route, options?.environment)) + .map((route) => { + route.methods = detectHttpExports(join(routesDir, route.filePath)); return route; }) // Sort by specificity: static routes before dynamic params, catch-all last. @@ -124,6 +134,7 @@ export function scanMiddlewareSync( export function scanRoutePatternsSync( root: string, paths: ProjectPaths = readProjectPaths(root), + environment?: RouteEnvironment, ): Array { let routeFiles: Array; try { @@ -136,5 +147,6 @@ export function scanRoutePatternsSync( .filter((f) => !isWebSocketPatternFile(f)) .filter((f) => !f.split('/').some((s) => s.startsWith('_'))) .map((f) => parseRouteFilename(f)) + .filter((route) => isRouteEnabledForEnvironment(route, environment)) .sort(compareRouteSpecificity); } diff --git a/packages/void/src/router/websocket.ts b/packages/void/src/router/websocket.ts index fbdd3ee91..7c90d7ade 100644 --- a/packages/void/src/router/websocket.ts +++ b/packages/void/src/router/websocket.ts @@ -14,7 +14,12 @@ import { type AstProperty, type AstVariableDeclaration, } from '../shared/ast.ts'; -import { parseWebSocketFilename, type RouteDefinition } from './parse-filename.ts'; +import { + isRouteEnabledForEnvironment, + parseWebSocketFilename, + type RouteDefinition, + type RouteEnvironment, +} from './parse-filename.ts'; const HANDLER_EXTENSIONS = ['ts', 'mts', 'js', 'mjs']; const WEBSOCKET_ROUTE_RE = /(?:\.(?:dev|prod))?\.ws\.(?:ts|mts|js|mjs)$/; @@ -167,10 +172,14 @@ function parseWebSocketFiles( root: string, files: Array, paths = readProjectPaths(root), + environment?: RouteEnvironment, ): Array { return files .filter((f) => isWebSocketRouteFile(f)) .filter((f) => !f.split('/').some((segment) => segment.startsWith('_'))) + .filter((filePath) => + isRouteEnabledForEnvironment(parseWebSocketFilename(filePath), environment), + ) .map((filePath) => buildWebSocketRoute(root, filePath, paths)) .sort((a, b) => { if (a.catchAll !== b.catchAll) { @@ -183,17 +192,19 @@ function parseWebSocketFiles( export async function scanWebSocketRoutes( root: string, paths: ProjectPaths = readProjectPaths(root), + environment?: RouteEnvironment, ): Promise> { const pattern = `**/*.{${HANDLER_EXTENSIONS.join(',')}}`; const files = await glob(pattern, { cwd: paths.routesDir, absolute: false }).catch( () => [] as Array, ); - return parseWebSocketFiles(root, files, paths); + return parseWebSocketFiles(root, files, paths, environment); } export function scanWebSocketRoutesSync( root: string, paths: ProjectPaths = readProjectPaths(root), + environment?: RouteEnvironment, ): Array { const pattern = `**/*.{${HANDLER_EXTENSIONS.join(',')}}`; let files: Array; @@ -202,7 +213,7 @@ export function scanWebSocketRoutesSync( } catch { files = []; } - return parseWebSocketFiles(root, files, paths); + return parseWebSocketFiles(root, files, paths, environment); } export function isWebSocketPatternFile(filePath: string): boolean { diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index cf3ece3ca..4c9614476 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node import { pathToFileURL } from 'node:url'; import { log } from '@clack/prompts'; import { dirname, join } from 'pathslash'; -import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { assertMigrationsProduceAuthSchema, assertNoConfigOverrideInProjectDotenv, @@ -1084,6 +1084,34 @@ describe('runCloudflareDeploy — app-class guard (narrowed to full Void apps)', expect(exitCalls).toEqual([1]); expect(trace).toEqual([]); }); + + it('does not reject a dev-only WebSocket route (.dev.ws.ts) — it never reaches production', async () => { + // The gate scans with `'prod'`, the same environment the build compiles with, so a + // dev-only room emits no `new_classes` migration and must not block the deploy. Compare + // with the test above: the same project with a suffix-free `.ws.ts` route is rejected + // pre-account with an empty trace. + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'wsapp' }), + 'void.json': JSON.stringify({}), + 'routes/index.ts': `export const GET = () => new Response('ok');`, + 'routes/chat/[room].dev.ws.ts': `import { defineRoom } from 'void/ws';\nexport default defineRoom({});`, + }); + const errors: Array = []; + const errorSpy = vi.spyOn(log, 'error').mockImplementation((message) => { + errors.push(String(message)); + }); + try { + const { deps } = harness(); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps).catch( + () => undefined, + ); + } finally { + errorSpy.mockRestore(); + } + expect(errors.some((message) => message.includes('WebSocket / Durable Object routes'))).toBe( + false, + ); + }); }); describe('runCloudflareDeploy — preflight', () => { diff --git a/packages/void/test/unit/parse-filename.test.ts b/packages/void/test/unit/parse-filename.test.ts index 3a6215e1a..057abae05 100644 --- a/packages/void/test/unit/parse-filename.test.ts +++ b/packages/void/test/unit/parse-filename.test.ts @@ -1,6 +1,10 @@ import { Hono } from 'hono'; import { describe, expect, it } from 'vite-plus/test'; -import { parseRouteFilename } from '../../src/router/parse-filename.ts'; +import { + isRouteEnabledForEnvironment, + parseRouteFilename, + parseWebSocketFilename, +} from '../../src/router/parse-filename.ts'; describe('parseRouteFilename', () => { describe('static routes', () => { @@ -59,6 +63,18 @@ describe('parseRouteFilename', () => { expect(r.pattern).toBe('/health'); expect(r.env).toBe('prod'); }); + + it('sits before the .ws segment on a websocket route', () => { + const r = parseWebSocketFilename('chat.dev.ws.ts'); + expect(r.pattern).toBe('/chat'); + expect(r.env).toBe('dev'); + }); + + it('is not recognised after the .ws segment', () => { + const r = parseWebSocketFilename('chat.ws.ts'); + expect(r.pattern).toBe('/chat'); + expect(r.env).toBeNull(); + }); }); describe('dynamic params', () => { @@ -156,3 +172,27 @@ describe('parseRouteFilename', () => { }); }); }); + +describe('isRouteEnabledForEnvironment', () => { + const cases: Array<{ + env: 'dev' | 'prod' | null; + environment: 'dev' | 'prod' | undefined; + expected: boolean; + }> = [ + { env: null, environment: 'dev', expected: true }, + { env: null, environment: 'prod', expected: true }, + { env: null, environment: undefined, expected: true }, + { env: 'dev', environment: 'dev', expected: true }, + { env: 'dev', environment: 'prod', expected: false }, + { env: 'dev', environment: undefined, expected: true }, + { env: 'prod', environment: 'dev', expected: false }, + { env: 'prod', environment: 'prod', expected: true }, + { env: 'prod', environment: undefined, expected: true }, + ]; + + for (const { env, environment, expected } of cases) { + it(`env=${env} environment=${environment} -> ${expected}`, () => { + expect(isRouteEnabledForEnvironment({ env }, environment)).toBe(expected); + }); + } +}); diff --git a/packages/void/test/unit/plugin-dev-asset-routing.test.ts b/packages/void/test/unit/plugin-dev-asset-routing.test.ts index 88d77b905..f93603aaf 100644 --- a/packages/void/test/unit/plugin-dev-asset-routing.test.ts +++ b/packages/void/test/unit/plugin-dev-asset-routing.test.ts @@ -55,6 +55,16 @@ function createApiOnlyProjectRoot(): string { return root; } +function createDevOnlyApiProjectRoot(): string { + const root = createProjectRoot(); + mkdirSync(join(root, 'routes/api'), { recursive: true }); + writeFileSync( + join(root, 'routes/api/debug.dev.ts'), + 'export const GET = () => Response.json({});', + ); + return root; +} + function createBindingProjectRoot(): string { const root = createProjectRoot(); mkdirSync(join(root, 'routes/api'), { recursive: true }); @@ -137,6 +147,7 @@ describe('voidPlugin dev asset routing', () => { async function resolveCloudflareConfig( root: string, resolved: Record = {}, + configEnv: import('vite').ConfigEnv = { command: 'serve', mode: 'development' }, ): Promise> { vi.spyOn(process, 'cwd').mockReturnValue(root); @@ -148,7 +159,7 @@ describe('voidPlugin dev asset routing', () => { config: import('vite').UserConfig, env: import('vite').ConfigEnv, ) => void; - config({}, { command: 'serve', mode: 'development' }); + config({}, configEnv); const cloudflarePlugin = plugins.find( (plugin): plugin is MockCloudflarePlugin => plugin.name === 'mock:cloudflare', @@ -163,8 +174,9 @@ describe('voidPlugin dev asset routing', () => { async function resolveVoidPluginAssets( root: string, resolved: Record = {}, + configEnv?: import('vite').ConfigEnv, ): Promise<{ not_found_handling?: string; run_worker_first?: Array }> { - const merged = await resolveCloudflareConfig(root, resolved); + const merged = await resolveCloudflareConfig(root, resolved, configEnv); return merged.assets as { not_found_handling?: string; run_worker_first?: Array }; } @@ -199,6 +211,51 @@ describe('voidPlugin dev asset routing', () => { expect(assets?.run_worker_first).toEqual(['/api', '/api/*', '/__void', '/__void/*']); }); + it('routes API requests to the worker in dev when the only route is dev-only', async () => { + const root = createDevOnlyApiProjectRoot(); + const assets = await resolveVoidPluginAssets(root); + + expect(assets?.not_found_handling).toBe('single-page-application'); + expect(assets?.run_worker_first).toEqual(['/api', '/api/*', '/__void', '/__void/*']); + }); + + it('drops the API worker-first rules in a build when the only route is dev-only', async () => { + const root = createDevOnlyApiProjectRoot(); + const assets = await resolveVoidPluginAssets( + root, + {}, + { + command: 'build', + mode: 'production', + }, + ); + + expect(assets?.not_found_handling).toBe('single-page-application'); + expect(assets?.run_worker_first).toEqual(['/__void', '/__void/*']); + }); + + it('keeps the API worker-first rules in a preview run of a prod-only route', async () => { + const root = createProjectRoot(); + mkdirSync(join(root, 'routes/api'), { recursive: true }); + writeFileSync( + join(root, 'routes/api/health.prod.ts'), + 'export const GET = () => Response.json({});', + ); + + const assets = await resolveVoidPluginAssets( + root, + {}, + { + command: 'serve', + mode: 'production', + isPreview: true, + }, + ); + + expect(assets?.not_found_handling).toBe('single-page-application'); + expect(assets?.run_worker_first).toEqual(['/api', '/api/*', '/__void', '/__void/*']); + }); + it('does not return existing D1 or R2 bindings when adding inferred defaults', async () => { const root = createBindingProjectRoot(); const config = await resolveCloudflareConfig(root, { diff --git a/packages/void/test/unit/plugin-inference.test.ts b/packages/void/test/unit/plugin-inference.test.ts index 1995b2cd6..1b833e581 100644 --- a/packages/void/test/unit/plugin-inference.test.ts +++ b/packages/void/test/unit/plugin-inference.test.ts @@ -64,6 +64,27 @@ describe('inferProjectBindings', () => { }); }); + // Binding inference is deliberately blind to the `.dev` / `.prod` route suffix. + // Over-emitting is the safe direction: a missing binding crashes the running + // worker, while a spare one only costs a resource. The environment is also not + // knowable where `inferProjectBindings()` runs — `index.ts` calls it during + // plugin construction, long before the `config` hook assigns `routeEnvironment`. + // Changing this means reordering `voidPlugin()`, so make it a deliberate change. + it('still infers a binding a dev-only route imports', () => { + const root = createTempRoot(); + mkdirSync(join(root, 'routes', 'api'), { recursive: true }); + writeFileSync( + join(root, 'routes', 'api', 'debug.dev.ts'), + ` + import { storage } from "void/storage"; + export const GET = async () => Response.json(await storage.list()); + `, + ); + + const result = inferProjectBindings(root, undefined, ['routes']); + expect(result.needsR2).toBe(true); + }); + it('detects auth from void/client import with auth specifier in src/', () => { const root = createTempRoot(); writeFileSync( diff --git a/packages/void/test/unit/scan.test.ts b/packages/void/test/unit/scan.test.ts index 26b4db47a..577b3bead 100644 --- a/packages/void/test/unit/scan.test.ts +++ b/packages/void/test/unit/scan.test.ts @@ -1,7 +1,8 @@ import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { join } from 'pathslash'; import { describe, expect, it, beforeEach, afterEach } from 'vite-plus/test'; -import { scanRoutes } from '../../src/router/scan.ts'; +import { scanRoutePatternsSync, scanRoutes } from '../../src/router/scan.ts'; +import { scanWebSocketRoutesSync } from '../../src/router/websocket.ts'; const TMP = join(import.meta.dirname, '__tmp_scan_test__'); @@ -223,4 +224,93 @@ describe('scanRoutes', () => { const result = await scanRoutes(TMP); expect(result.queues).toEqual([]); }); + + it('includes .dev routes and excludes .prod routes in dev', async () => { + createFile('routes/api/always.ts', 'export const GET = () => {}'); + createFile('routes/api/hello.dev.ts', 'export const GET = () => {}'); + createFile('routes/api/hello.prod.ts', 'export const GET = () => {}'); + + const result = await scanRoutes(TMP, { environment: 'dev' }); + expect(result.routes.map((r) => r.filePath)).toEqual(['api/always.ts', 'api/hello.dev.ts']); + }); + + it('includes .prod routes and excludes .dev routes in prod', async () => { + createFile('routes/api/always.ts', 'export const GET = () => {}'); + createFile('routes/api/hello.dev.ts', 'export const GET = () => {}'); + createFile('routes/api/hello.prod.ts', 'export const GET = () => {}'); + + const result = await scanRoutes(TMP, { environment: 'prod' }); + expect(result.routes.map((r) => r.filePath)).toEqual(['api/always.ts', 'api/hello.prod.ts']); + }); + + it('includes every route when no environment is specified', async () => { + createFile('routes/api/always.ts', 'export const GET = () => {}'); + createFile('routes/api/hello.dev.ts', 'export const GET = () => {}'); + createFile('routes/api/hello.prod.ts', 'export const GET = () => {}'); + + const result = await scanRoutes(TMP); + expect(result.routes.map((r) => r.filePath).sort()).toEqual([ + 'api/always.ts', + 'api/hello.dev.ts', + 'api/hello.prod.ts', + ]); + }); + + it('filters websocket routes by environment', async () => { + createFile('routes/chat.dev.ws.ts', 'export default defineWebSocket({})'); + createFile('routes/chat.prod.ws.ts', 'export default defineWebSocket({})'); + + const dev = await scanRoutes(TMP, { environment: 'dev' }); + const prod = await scanRoutes(TMP, { environment: 'prod' }); + + expect(dev.websockets?.map((r) => r.filePath)).toEqual(['chat.dev.ws.ts']); + expect(prod.websockets?.map((r) => r.filePath)).toEqual(['chat.prod.ws.ts']); + }); +}); + +describe('sync scanners', () => { + beforeEach(() => { + mkdirSync(TMP, { recursive: true }); + }); + + afterEach(() => { + rmSync(TMP, { recursive: true, force: true }); + }); + + it('scanRoutePatternsSync filters routes by environment', () => { + createFile('routes/api/always.ts'); + createFile('routes/api/hello.dev.ts'); + createFile('routes/api/hello.prod.ts'); + + expect(scanRoutePatternsSync(TMP, undefined, 'dev').map((r) => r.filePath)).toEqual([ + 'api/always.ts', + 'api/hello.dev.ts', + ]); + expect(scanRoutePatternsSync(TMP, undefined, 'prod').map((r) => r.filePath)).toEqual([ + 'api/always.ts', + 'api/hello.prod.ts', + ]); + expect( + scanRoutePatternsSync(TMP) + .map((r) => r.filePath) + .sort(), + ).toEqual(['api/always.ts', 'api/hello.dev.ts', 'api/hello.prod.ts']); + }); + + it('scanWebSocketRoutesSync filters websocket routes by environment', () => { + createFile('routes/chat.dev.ws.ts', 'export default defineWebSocket({})'); + createFile('routes/chat.prod.ws.ts', 'export default defineWebSocket({})'); + + expect(scanWebSocketRoutesSync(TMP, undefined, 'dev').map((r) => r.filePath)).toEqual([ + 'chat.dev.ws.ts', + ]); + expect(scanWebSocketRoutesSync(TMP, undefined, 'prod').map((r) => r.filePath)).toEqual([ + 'chat.prod.ws.ts', + ]); + expect( + scanWebSocketRoutesSync(TMP) + .map((r) => r.filePath) + .sort(), + ).toEqual(['chat.dev.ws.ts', 'chat.prod.ws.ts']); + }); });