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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion docs/guide/server-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
3 changes: 3 additions & 0 deletions docs/guide/websockets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`

Expand Down
1 change: 1 addition & 0 deletions docs/reference/structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 3 additions & 1 deletion packages/void/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,14 +144,16 @@ 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{.+}`, `[...]` → `*`
6. Prefix with `/`

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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 7 additions & 3 deletions packages/void/src/cli/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down Expand Up @@ -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,
Expand Down
83 changes: 63 additions & 20 deletions packages/void/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string, unknown> = {};
let assetRouting: AssetRoutingPolicy;
if (config.worker?.vars) {
workerConfig.vars = { ...config.worker.vars };
}
Expand Down Expand Up @@ -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<WorkerConfig> {
const environmentWebsocketRoutes = websocketRoutes.filter((route) =>
isRouteEnabledForEnvironment(route, routeEnvironment),
);
const result: Record<string, unknown> = { ...workerConfig };

// Void owns the generated asset routing policy. Replace stale root
Expand Down Expand Up @@ -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 }>;
Expand All @@ -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),
Expand All @@ -1101,7 +1114,7 @@ export function voidPlugin(options?: {
(result as Record<string, unknown>).migrations = [
{
tag: 'void-ws-v1',
new_classes: websocketRoutes.map((route) => route.className),
new_classes: environmentWebsocketRoutes.map((route) => route.className),
},
];
}
Expand Down Expand Up @@ -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/**'];
Expand Down Expand Up @@ -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<string> } | 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, '/**'];
}

Expand Down Expand Up @@ -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();
Comment thread
Brooooooklyn marked this conversation as resolved.

workerConfig.main = join(root, '.void', 'entry.ts');

Expand Down
6 changes: 5 additions & 1 deletion packages/void/src/plugin-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 });
Comment thread
Brooooooklyn marked this conversation as resolved.
authConfigPath = findVoidAuthConfig(root, paths);
const entries = validateSsrEntries(root, paths);
ssr = entries
Expand Down Expand Up @@ -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
Expand Down
15 changes: 12 additions & 3 deletions packages/void/src/router/parse-filename.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export type RouteEnvironment = 'dev' | 'prod';

export type RouteDefinition = {
/** URL pattern, e.g. "/api/users/:id" */
pattern: string;
Expand All @@ -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<RouteEnvironment> = ['dev', 'prod'];

function parsePath(relativePath: string, isWebSocketRoute: boolean): RouteDefinition {
const filePath = relativePath;
Expand All @@ -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;
Expand Down Expand Up @@ -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<RouteDefinition, 'env'>,
environment?: RouteEnvironment,
): boolean {
return environment === undefined || route.env === null || route.env === environment;
}
24 changes: 18 additions & 6 deletions packages/void/src/router/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<ScanResult> {
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<string>,
),
Expand All @@ -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))
Comment thread
Brooooooklyn marked this conversation as resolved.
.map((route) => {
route.methods = detectHttpExports(join(routesDir, route.filePath));
return route;
})
// Sort by specificity: static routes before dynamic params, catch-all last.
Expand Down Expand Up @@ -124,6 +134,7 @@ export function scanMiddlewareSync(
export function scanRoutePatternsSync(
root: string,
paths: ProjectPaths = readProjectPaths(root),
environment?: RouteEnvironment,
): Array<RouteDefinition> {
let routeFiles: Array<string>;
try {
Expand All @@ -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);
}
Loading