Skip to content
Open
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
12 changes: 11 additions & 1 deletion docs/guide/pages-routing/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,17 @@ Pages can be flat files such as `about.vue` or directory-based routes such as `a

Keep one file per route. Void reports an error when two page files resolve to the same path, such as `about.vue` and `about/index.vue`, or the same page in two route groups.

Each page can have a companion `.server.ts` file that runs exclusively on the server. It can export:
Each page can have a companion `.server.ts`, `.server.tsx`, `.server.js`, `.server.jsx`, `.server.mts`, or `.server.mjs` file that runs exclusively on the server. Use `.server.tsx` or `.server.jsx` when the companion contains JSX; it uses your app's configured JSX runtime. A companion belongs to its matching page and does not create a separate route:

| File | Role |
| ---------------------- | ------------------------------ |
| `routes/api.tsx` | Server HTTP handler at `/api` |
| `pages/foo.tsx` | Page component at `/foo` |
| `pages/foo.server.tsx` | Server companion for that page |
| `pages/bar.jsx` | Page component at `/bar` |
| `pages/bar.server.jsx` | Server companion for that page |

A companion can export:

- A [**loader**](./loaders), which runs on `GET` and returns the data that becomes the page component's props
- [**Actions**](./actions-and-forms), which handle mutations from forms and programmatic calls. Export a single `action` or multiple [named actions](./actions-and-forms#named-actions) when a page has several mutations
Expand Down
16 changes: 15 additions & 1 deletion docs/guide/server-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Create a file in `routes/` to add an endpoint, and export a handler for each HTT

## Route Files

Create route handlers in `routes/**/*.ts`. Each file maps to a URL path based on its location in the filesystem:
Create route handlers in `routes/` using `.ts`, `.tsx`, `.js`, `.jsx`, `.mts`, or `.mjs`. Each file maps to a URL path based on its location in the filesystem:

<RoutesFileTree />

Expand All @@ -23,6 +23,8 @@ routes/
api/health.ts → always
api/debug.dev.ts → development only
api/metrics.prod.ts → production only
preview.dev.tsx → development only, with JSX
report.prod.jsx → production only, with JSX
```

`.dev` routes run only in development; `.prod` routes run in builds, deployments, and production previews. A resource used only by `.dev` routes is not provisioned for production.
Expand Down Expand Up @@ -61,6 +63,18 @@ export const POST = defineHandler(async (c) => {

See [Database](./database.md) for queries with D1, PostgreSQL, or MySQL.

### JSX handlers

Use `.tsx` or `.jsx` when a server handler contains JSX. Void uses your app's configured JSX runtime. Render JSX with that runtime's server renderer and return HTML. For example, in a React app:

```tsx
// routes/hello.tsx → GET /hello
import { renderToStaticMarkup } from 'react-dom/server';
import { defineHandler } from 'void';

export const GET = defineHandler((c) => c.html(renderToStaticMarkup(<h1>Hello!</h1>)));
```

## `defineHandler`

The handler receives a Hono `Context` with typed Cloudflare bindings on `c.env` (see [Cloudflare](../integrations/cloudflare.md)). You can use the full Hono API (`c.json()`, `c.text()`, `c.header()`, etc.).
Expand Down
4 changes: 3 additions & 1 deletion docs/guide/websockets.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,16 @@ For example, `/rooms/[id]` gives each chat room its own instance. Use it for cha

## Route files

Create WebSocket routes in `routes/` with the `.ws.ts` suffix:
Create WebSocket routes in `routes/` using `.ws.ts`, `.ws.tsx`, `.ws.js`, `.ws.jsx`, `.ws.mts`, or `.ws.mjs`:

```text
routes/
chat/[room].ws.ts
notifications.ws.ts
```

Use `.ws.tsx` or `.ws.jsx` when a handler contains JSX, such as rendering HTML fragments to send to connected clients. JSX uses your app's configured runtime.

Filename rules match regular server routes:

- `index.ws.ts` becomes the parent path
Expand Down
1 change: 1 addition & 0 deletions docs/reference/structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ File-based HTTP API endpoints built on [Hono](https://hono.dev). Each file expor
<RoutesFileTree />

- **Named exports**: `export const GET = defineHandler(...)`, `export const POST = ...`
- **Extensions**: `.ts`, `.tsx`, `.js`, `.jsx`, `.mts`, or `.mjs`; use `.tsx` or `.jsx` for JSX handlers
- **Dynamic segments**: `[id]` becomes `:id` route parameter
- **Catch-all**: `[...slug]` matches the remaining path
- **Route groups**: `(admin)/` organizes files without affecting URL paths
Expand Down
4 changes: 3 additions & 1 deletion packages/void/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,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`)
1. Strip extension (`.ts`, `.tsx`, `.js`, `.jsx`, `.mts`, `.mjs`)
2. Strip env suffix (`.dev`, `.prod`) and record it on `RouteDefinition.env`
3. Strip trailing `index`
4. Remove route group dirs `(groupname)`
Expand All @@ -163,6 +163,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.

`listRouteFiles()` uses `ROUTE_EXTENSIONS`, shared with HTTP/WebSocket filename parsing, resource-name inference, and route binding inference. Both route scanners use it, with `isWebSocketPatternFile()` sharing the WebSocket filename regex to distinguish `.ws.*` files. `listConventionFiles()` keeps fixed script extensions for crons and queues. JSX uses the app's configured runtime. Page `.server.tsx` and `.server.jsx` companions supply server metadata and handlers; exclude them from component inventories and component manifests.

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, generated route/WebSocket config, and inferred bindings. `void prepare` boots no Vite, passes no `environment`, and generates types for both environments. `voidPlugin()` scans unfiltered source during construction for binding-name reservation and plugin membership, then recomputes environment-specific bindings in its `config` hook before emitting Cloudflare config. Binding inference filters only recognized route files under `paths.routesDir`; the suffix has no effect in `pages/`, `middleware/`, `crons/`, `queues/`, or other source files.

## Code Generation (`void gen`)
Expand Down
12 changes: 7 additions & 5 deletions packages/void/src/pages/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type AstProgram,
type AstVariableDeclaration,
} from '../shared/ast.ts';
import { ROUTE_EXTENSIONS, ROUTE_EXTENSIONS_SET } from '../shared/route-extensions.ts';
import { deriveFromSource } from '../shared/source-analysis-cache.ts';
import { staticExportInitializers, staticLiteral } from '../shared/static-exports.ts';
import { scriptBlocks } from './island-imports.ts';
Expand All @@ -33,7 +34,6 @@ import type {
} from './types.ts';

const COMPONENT_EXTENSIONS = ['vue', 'tsx', 'jsx', 'svelte', 'md'];
const SERVER_EXTENSIONS = ['ts', 'tsx', 'js', 'mjs', 'mts'];
const PAGE_METHODS = new Set(['loader', 'action', 'actions']);
export const MAX_REVALIDATE = 31536000; // 1 year in seconds

Expand Down Expand Up @@ -233,6 +233,9 @@ export async function scanPages(root: string, options?: PageScanOptions): Promis

const ext = extname(file);
let base = basename(file, ext);
if (base.endsWith('.server') && ROUTE_EXTENSIONS_SET.has(ext)) {
continue;
}

// Detect island suffix (.island.tsx, .island.vue, etc.)
let island: boolean | undefined;
Expand Down Expand Up @@ -274,8 +277,7 @@ export async function scanPages(root: string, options?: PageScanOptions): Promis
}

// Page files — parse as route
// parseRouteFilename only strips .ts/.js/.mts/.mjs — convert component
// extension to .ts so the parser handles it correctly.
// Convert component extensions to .ts so the route parser handles them uniformly.
// Strip .island suffix before parsing so route patterns are clean.
const parseName = hasIslandSuffix
? file.replace(`.island${ext}`, '.ts')
Expand All @@ -288,15 +290,15 @@ export async function scanPages(root: string, options?: PageScanOptions): Promis
.replace(/\/index$/, '')
.replace(/^index$/, '') || 'index';

// Check for companion .server.ts — server files never have .island suffix
// Check for a server companion — server files never have .island suffix
let serverPath: string | null = null;
const methods: Array<string> = [];
let revalidate: number | undefined;
let prerender: ExplicitBoolean;
let ssr: ExplicitBoolean;
let hasGetPrerenderPaths: boolean | undefined;
const serverBase = hasIslandSuffix ? file.replace(`.island${ext}`, '') : file.replace(ext, '');
for (const serverExt of SERVER_EXTENSIONS) {
for (const serverExt of ROUTE_EXTENSIONS) {
const candidate = `${serverBase}.server.${serverExt}`;
if (existsSync(join(pagesDir, candidate))) {
serverPath = candidate;
Expand Down
22 changes: 13 additions & 9 deletions packages/void/src/plugin-inference.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { existsSync, readFileSync } from 'node:fs';
import { join, relative, toSlash } from 'pathslash';
import { join, toSlash } from 'pathslash';
import { globSync } from 'tinyglobby';
import { applyBindingOverrides } from './application-features.ts';
import { inferBindingsFromSource, mergeBindings, type InferredBindings } from './inference/walk.ts';
Expand All @@ -11,6 +11,7 @@ import {
type RouteEnvironment,
} from './router/parse-filename.ts';
import { isWebSocketPatternFile } from './router/websocket.ts';
import { conventionFileGlobs, isRouteFile } from './shared/convention-files.ts';
import { deriveFromSource } from './shared/source-analysis-cache.ts';
import { getSsrServerEntryPath } from './ssr/entry.ts';

Expand Down Expand Up @@ -150,18 +151,21 @@ export function inferProjectBindings(
const allBindings: Array<InferredBindings> = [];
const scannedFiles = new Set<string>();
const routesPrefix = `${toSlash(paths.routesDir).replace(/\/$/, '')}/`;
const scanSourceFiles = (dirPath: string): Array<string> =>
globSync(conventionFileGlobs(dirPath, SOURCE_GLOB, paths.routesDir), {
cwd: dirPath,
absolute: true,
});
const addFile = (file: string): void => {
const normalizedFile = toSlash(file);
if (scannedFiles.has(normalizedFile)) {
return;
}
scannedFiles.add(normalizedFile);
if (
options?.environment &&
normalizedFile.startsWith(routesPrefix) &&
/\.(?:ts|mts|js|mjs)$/.test(normalizedFile)
) {
const routePath = relative(paths.routesDir, normalizedFile);
const routePath = normalizedFile.startsWith(routesPrefix)
? normalizedFile.slice(routesPrefix.length)
: undefined;
if (options?.environment && routePath !== undefined && isRouteFile(routePath)) {
const route = isWebSocketPatternFile(routePath)
? parseWebSocketFilename(routePath)
: parseRouteFilename(routePath);
Expand All @@ -179,7 +183,7 @@ export function inferProjectBindings(
const dirPath = join(root, dir);
let files: Array<string>;
try {
files = globSync(SOURCE_GLOB, { cwd: dirPath, absolute: true });
files = scanSourceFiles(dirPath);
} catch {
continue;
}
Expand All @@ -202,7 +206,7 @@ export function inferProjectBindings(
const srcPath = paths.sourceDir ? paths.sourceRoot : join(root, 'src');
let srcFiles: Array<string>;
try {
srcFiles = globSync(SOURCE_GLOB, { cwd: srcPath, absolute: true });
srcFiles = scanSourceFiles(srcPath);
} catch {
srcFiles = [];
}
Expand Down
11 changes: 6 additions & 5 deletions packages/void/src/router/parse-filename.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ROUTE_EXTENSIONS } from '../shared/route-extensions.ts';

export type RouteEnvironment = 'dev' | 'prod';

export type RouteDefinition = {
Expand All @@ -15,7 +17,6 @@ export type RouteDefinition = {
filePath: string;
};

const EXTENSIONS = ['.ts', '.mts', '.js', '.mjs'];
const ENV_SUFFIXES: Array<RouteEnvironment> = ['dev', 'prod'];

function parsePath(relativePath: string, isWebSocketRoute: boolean): RouteDefinition {
Expand All @@ -25,9 +26,9 @@ function parsePath(relativePath: string, isWebSocketRoute: boolean): RouteDefini
let path = relativePath.replace(/\\/g, '/');

// 1. Strip extension
for (const ext of EXTENSIONS) {
if (path.endsWith(ext)) {
path = path.slice(0, -ext.length);
for (const ext of ROUTE_EXTENSIONS) {
if (path.endsWith(`.${ext}`)) {
path = path.slice(0, -(ext.length + 1));
break;
}
}
Expand Down Expand Up @@ -103,7 +104,7 @@ function parsePath(relativePath: string, isWebSocketRoute: boolean): RouteDefini
* Parse a route filename (relative to routes/) into a RouteDefinition.
*
* Rules (applied in order):
* 1. Strip file extension (.ts, .js, .mts, .mjs)
* 1. Strip file extension (.ts, .tsx, .js, .jsx, .mts, .mjs)
* 2. Check for env suffix (.dev, .prod) → strip and record
* 3. Strip trailing "index" segment
* 4. Remove route group directories: (groupname)
Expand Down
4 changes: 2 additions & 2 deletions packages/void/src/router/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { scanPages } from '../pages/scan.ts';
import type { PageScanResult } from '../pages/types.ts';
import { readProjectPaths, type ProjectPaths } from '../project-paths.ts';
import type { QueueDefinition } from '../queues/scan.ts';
import { listConventionFiles } from '../shared/convention-files.ts';
import { listRouteFiles } from '../shared/convention-files.ts';
import { detectHttpExports } from './detect-exports.ts';
import type { EmailHandlersScan } from './email.ts';
import {
Expand Down Expand Up @@ -105,7 +105,7 @@ export function scanRoutePatternsSync(
paths: ProjectPaths = readProjectPaths(root),
environment?: RouteEnvironment,
): Array<RouteDefinition> {
return listConventionFiles(paths.routesDir)
return listRouteFiles(paths.routesDir)
.filter((f) => !isWebSocketPatternFile(f))
.map((f) => parseRouteFilename(f))
.filter((route) => isRouteEnabledForEnvironment(route, environment))
Expand Down
15 changes: 11 additions & 4 deletions packages/void/src/router/websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,20 @@ import {
type AstNode,
type AstProperty,
} from '../shared/ast.ts';
import { listConventionFiles } from '../shared/convention-files.ts';
import { listRouteFiles } from '../shared/convention-files.ts';
import { readResourceDefinition } from '../shared/resource-definition.ts';
import { resourceNameWords } from '../shared/resource-name.ts';
import { ROUTE_EXTENSIONS } from '../shared/route-extensions.ts';
import {
isRouteEnabledForEnvironment,
parseWebSocketFilename,
type RouteDefinition,
type RouteEnvironment,
} from './parse-filename.ts';

const WEBSOCKET_ROUTE_RE = /(?:\.(?:dev|prod))?\.ws\.(?:ts|mts|js|mjs)$/;
const WEBSOCKET_ROUTE_RE = new RegExp(
`(?:\\.(?:dev|prod))?\\.ws\\.(?:${ROUTE_EXTENSIONS.join('|')})$`,
);

export type WebSocketRouteDefinition = RouteDefinition & {
className: string;
Expand Down Expand Up @@ -59,7 +62,11 @@ export function deriveWebSocketClassNameFromName(name: string): string {
}

export function inferWebSocketResourceName(filePath: string): string {
const normalized = filePath.replace(/\\/g, '/').replace(/\.(ts|mts|js|mjs)$/, '');
let normalized = filePath.replace(/\\/g, '/');
const extension = ROUTE_EXTENSIONS.find((ext) => normalized.endsWith(`.${ext}`));
if (extension) {
normalized = normalized.slice(0, -(extension.length + 1));
}
const parts = normalized
.split('/')
.filter((segment) => segment.length > 0 && !(segment.startsWith('(') && segment.endsWith(')')))
Expand Down Expand Up @@ -168,7 +175,7 @@ export function scanWebSocketRoutesSync(
paths: ProjectPaths = readProjectPaths(root),
environment?: RouteEnvironment,
): Array<WebSocketRouteDefinition> {
return parseWebSocketFiles(root, listConventionFiles(paths.routesDir), paths, environment);
return parseWebSocketFiles(root, listRouteFiles(paths.routesDir), paths, environment);
}

export function isWebSocketPatternFile(filePath: string): boolean {
Expand Down
51 changes: 48 additions & 3 deletions packages/void/src/shared/convention-files.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,53 @@
import { join, relative } from 'pathslash';
import { globSync } from 'tinyglobby';
import { ROUTE_EXTENSIONS } from './route-extensions.ts';

const WELL_KNOWN_DIR = '.well-known';

/** Include the convention directory's .well-known files when it is inside the scan directory. */
export function conventionFileGlobs(
scanDir: string,
pattern: string,
conventionDir: string = scanDir,
): Array<string> {
const patterns = [pattern];
const conventionRelative = relative(scanDir, conventionDir);
if (conventionRelative !== '..' && !conventionRelative.startsWith('../')) {
patterns.push(join(conventionRelative, WELL_KNOWN_DIR, pattern));
}
return patterns;
}

function isConventionPath(filePath: string): boolean {
return filePath
.replace(/\\/g, '/')
.split('/')
.every(
(segment, index) =>
!segment.startsWith('_') &&
(!segment.startsWith('.') || (index === 0 && segment === WELL_KNOWN_DIR)),
);
}

/** Whether a path relative to routes/ participates in HTTP or WebSocket routing. */
export function isRouteFile(filePath: string): boolean {
return isConventionPath(filePath) && ROUTE_EXTENSIONS.some((ext) => filePath.endsWith(`.${ext}`));
}

/** Handler files under `dir`, relative to it, skipping paths with a `_`-prefixed segment. */
export function listConventionFiles(dir: string): Array<string> {
return globSync(['**/*.{ts,mts,js,mjs}', '.well-known/**/*.{ts,mts,js,mjs}'], {
function listFilesWithExtensions(dir: string, extensions: ReadonlyArray<string>): Array<string> {
const suffix = `*.{${extensions.join(',')}}`;
return globSync(conventionFileGlobs(dir, `**/${suffix}`), {
cwd: dir,
}).filter((filePath) => !filePath.split('/').some((segment) => segment.startsWith('_')));
}).filter(isConventionPath);
}

/** Cron and queue handlers with .ts, .mts, .js, or .mjs extensions. */
export function listConventionFiles(dir: string): Array<string> {
return listFilesWithExtensions(dir, ['ts', 'mts', 'js', 'mjs']);
}

/** HTTP and WebSocket handlers, also allowing .tsx and .jsx for JSX. */
export function listRouteFiles(dir: string): Array<string> {
return listFilesWithExtensions(dir, ROUTE_EXTENSIONS);
}
7 changes: 7 additions & 0 deletions packages/void/src/shared/route-extensions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Page server companions use the first matching extension.
export const ROUTE_EXTENSIONS = ['ts', 'tsx', 'js', 'jsx', 'mjs', 'mts'] as const;

// Includes the leading dot returned by extname().
export const ROUTE_EXTENSIONS_SET: ReadonlySet<string> = new Set(
ROUTE_EXTENSIONS.map((ext) => `.${ext}`),
);
Loading