diff --git a/docs/guide/pages-routing/overview.md b/docs/guide/pages-routing/overview.md
index 174b72fc6..58dfd156d 100644
--- a/docs/guide/pages-routing/overview.md
+++ b/docs/guide/pages-routing/overview.md
@@ -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
diff --git a/docs/guide/server-routing.md b/docs/guide/server-routing.md
index 474682b4f..8033e07e4 100644
--- a/docs/guide/server-routing.md
+++ b/docs/guide/server-routing.md
@@ -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:
@@ -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.
@@ -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(
Hello!
)));
+```
+
## `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.).
diff --git a/docs/guide/websockets.md b/docs/guide/websockets.md
index 0c37b3b22..95271a66e 100644
--- a/docs/guide/websockets.md
+++ b/docs/guide/websockets.md
@@ -14,7 +14,7 @@ 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/
@@ -22,6 +22,8 @@ routes/
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
diff --git a/docs/reference/structure.md b/docs/reference/structure.md
index 379f02f22..058b1cb6b 100644
--- a/docs/reference/structure.md
+++ b/docs/reference/structure.md
@@ -67,6 +67,7 @@ File-based HTTP API endpoints built on [Hono](https://hono.dev). Each file expor
- **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
diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md
index 473867ba7..8f5572848 100644
--- a/packages/void/AGENTS.md
+++ b/packages/void/AGENTS.md
@@ -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)`
@@ -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`)
diff --git a/packages/void/src/pages/scan.ts b/packages/void/src/pages/scan.ts
index 46d10aa59..e84a19abb 100644
--- a/packages/void/src/pages/scan.ts
+++ b/packages/void/src/pages/scan.ts
@@ -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';
@@ -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
@@ -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;
@@ -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')
@@ -288,7 +290,7 @@ 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 = [];
let revalidate: number | undefined;
@@ -296,7 +298,7 @@ export async function scanPages(root: string, options?: PageScanOptions): Promis
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;
diff --git a/packages/void/src/plugin-inference.ts b/packages/void/src/plugin-inference.ts
index f3a84b3df..4763c78a8 100644
--- a/packages/void/src/plugin-inference.ts
+++ b/packages/void/src/plugin-inference.ts
@@ -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';
@@ -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';
@@ -150,18 +151,21 @@ export function inferProjectBindings(
const allBindings: Array = [];
const scannedFiles = new Set();
const routesPrefix = `${toSlash(paths.routesDir).replace(/\/$/, '')}/`;
+ const scanSourceFiles = (dirPath: string): Array =>
+ 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);
@@ -179,7 +183,7 @@ export function inferProjectBindings(
const dirPath = join(root, dir);
let files: Array;
try {
- files = globSync(SOURCE_GLOB, { cwd: dirPath, absolute: true });
+ files = scanSourceFiles(dirPath);
} catch {
continue;
}
@@ -202,7 +206,7 @@ export function inferProjectBindings(
const srcPath = paths.sourceDir ? paths.sourceRoot : join(root, 'src');
let srcFiles: Array;
try {
- srcFiles = globSync(SOURCE_GLOB, { cwd: srcPath, absolute: true });
+ srcFiles = scanSourceFiles(srcPath);
} catch {
srcFiles = [];
}
diff --git a/packages/void/src/router/parse-filename.ts b/packages/void/src/router/parse-filename.ts
index 69f7456bf..cc1ee2bbd 100644
--- a/packages/void/src/router/parse-filename.ts
+++ b/packages/void/src/router/parse-filename.ts
@@ -1,3 +1,5 @@
+import { ROUTE_EXTENSIONS } from '../shared/route-extensions.ts';
+
export type RouteEnvironment = 'dev' | 'prod';
export type RouteDefinition = {
@@ -15,7 +17,6 @@ export type RouteDefinition = {
filePath: string;
};
-const EXTENSIONS = ['.ts', '.mts', '.js', '.mjs'];
const ENV_SUFFIXES: Array = ['dev', 'prod'];
function parsePath(relativePath: string, isWebSocketRoute: boolean): RouteDefinition {
@@ -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;
}
}
@@ -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)
diff --git a/packages/void/src/router/scan.ts b/packages/void/src/router/scan.ts
index 981938b7b..88d2361be 100644
--- a/packages/void/src/router/scan.ts
+++ b/packages/void/src/router/scan.ts
@@ -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 {
@@ -105,7 +105,7 @@ export function scanRoutePatternsSync(
paths: ProjectPaths = readProjectPaths(root),
environment?: RouteEnvironment,
): Array {
- return listConventionFiles(paths.routesDir)
+ return listRouteFiles(paths.routesDir)
.filter((f) => !isWebSocketPatternFile(f))
.map((f) => parseRouteFilename(f))
.filter((route) => isRouteEnabledForEnvironment(route, environment))
diff --git a/packages/void/src/router/websocket.ts b/packages/void/src/router/websocket.ts
index d0f324958..b0a5bdd67 100644
--- a/packages/void/src/router/websocket.ts
+++ b/packages/void/src/router/websocket.ts
@@ -8,9 +8,10 @@ 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,
@@ -18,7 +19,9 @@ import {
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;
@@ -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(')')))
@@ -168,7 +175,7 @@ export function scanWebSocketRoutesSync(
paths: ProjectPaths = readProjectPaths(root),
environment?: RouteEnvironment,
): Array {
- return parseWebSocketFiles(root, listConventionFiles(paths.routesDir), paths, environment);
+ return parseWebSocketFiles(root, listRouteFiles(paths.routesDir), paths, environment);
}
export function isWebSocketPatternFile(filePath: string): boolean {
diff --git a/packages/void/src/shared/convention-files.ts b/packages/void/src/shared/convention-files.ts
index 843f826c8..5e219bb0a 100644
--- a/packages/void/src/shared/convention-files.ts
+++ b/packages/void/src/shared/convention-files.ts
@@ -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 {
+ 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 {
- return globSync(['**/*.{ts,mts,js,mjs}', '.well-known/**/*.{ts,mts,js,mjs}'], {
+function listFilesWithExtensions(dir: string, extensions: ReadonlyArray): Array {
+ 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 {
+ return listFilesWithExtensions(dir, ['ts', 'mts', 'js', 'mjs']);
+}
+
+/** HTTP and WebSocket handlers, also allowing .tsx and .jsx for JSX. */
+export function listRouteFiles(dir: string): Array {
+ return listFilesWithExtensions(dir, ROUTE_EXTENSIONS);
}
diff --git a/packages/void/src/shared/route-extensions.ts b/packages/void/src/shared/route-extensions.ts
new file mode 100644
index 000000000..97cbbc653
--- /dev/null
+++ b/packages/void/src/shared/route-extensions.ts
@@ -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 = new Set(
+ ROUTE_EXTENSIONS.map((ext) => `.${ext}`),
+);
diff --git a/packages/void/test/integration/jsx-websocket.test.ts b/packages/void/test/integration/jsx-websocket.test.ts
new file mode 100644
index 000000000..9ff41da5c
--- /dev/null
+++ b/packages/void/test/integration/jsx-websocket.test.ts
@@ -0,0 +1,192 @@
+import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { Miniflare, convertV4MiniflareOptions } from 'miniflare';
+import { build } from 'vite';
+import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test';
+import {
+ createIsolatedPlayground,
+ type IsolatedPlayground,
+} from '../../../../test/integration-helpers.ts';
+import { analyzeApplication } from '../../src/application-routing.ts';
+import { generateRouteTypes } from '../../src/codegen/route-types.ts';
+import { readConfig } from '../../src/config.ts';
+import { compileRouteTable, compileWorkerEntry } from '../../src/router/compile.ts';
+
+const repository = join(import.meta.dirname, '../../../..');
+const variants = ['tsx', 'jsx'].flatMap((ext) =>
+ (['room', 'socket'] as const).map((mode) => ({ ext, mode })),
+);
+
+describe.each(
+ ['react', 'hono/jsx'].flatMap((jsxImportSource) =>
+ (['dev', 'prod'] as const).map((environment) => ({ jsxImportSource, environment })),
+ ),
+)('JSX WebSockets with $jsxImportSource in $environment', ({ jsxImportSource, environment }) => {
+ let playground: IsolatedPlayground;
+ let miniflare: Miniflare;
+ let types: string;
+ let modules: Array;
+
+ beforeAll(async () => {
+ playground = createIsolatedPlayground(repository, 'playground/websockets');
+ const root = playground.root;
+ for (const directory of ['routes', 'pages', 'middleware']) {
+ rmSync(join(root, directory), { recursive: true });
+ }
+ writeFileSync(join(root, 'void.config.ts'), 'export default {};');
+ writeFileSync(
+ join(root, 'tsconfig.json'),
+ JSON.stringify({ compilerOptions: { jsx: 'react-jsx', jsxImportSource } }),
+ );
+ for (const { ext, mode } of variants) {
+ mkdirSync(join(root, 'routes', ext), { recursive: true });
+ for (const env of ['dev', 'prod']) {
+ const helper = mode === 'room' ? 'defineRoom' : 'defineWebSocket';
+ const send = mode === 'room' ? 'ctx.room.broadcast' : 'ctx.socket.send';
+ writeFileSync(
+ join(root, 'routes', ext, `${mode}.${env}.ws.${ext}`),
+ `import * as v from 'valibot';
+import { ${helper} } from 'void/ws';
+${jsxImportSource === 'react' ? "import { renderToStaticMarkup } from 'react-dom/server.edge';" : ''}
+function ChatMessage({ text }) { return {text}
; }
+export default ${helper}({
+ messages: {
+ client: v.object({ text: v.string() }),
+ server: v.object({ html: v.string() }),
+ },
+ async onMessage(ctx, event) {
+ const element = ;
+ await ${send}({ html: ${jsxImportSource === 'react' ? 'renderToStaticMarkup(element)' : 'element.toString()'} });
+ },
+});`,
+ );
+ }
+ }
+ const { scan } = await analyzeApplication(root, { config: readConfig(root), environment });
+ types = generateRouteTypes(scan, join(root, 'routes'), join(root, '.void')).dts;
+ const routeTable = join(root, 'generated-routes.ts');
+ const worker = join(root, 'generated-worker.ts');
+ const runtimeEnvPath = join(repository, 'packages/void/src/runtime/env.ts');
+ writeFileSync(
+ routeTable,
+ compileRouteTable(
+ scan,
+ join(root, 'routes'),
+ join(root, 'middleware'),
+ join(root, 'crons'),
+ join(root, 'queues'),
+ null,
+ {
+ honoPath: import.meta.resolve('hono'),
+ honoRoutePath: import.meta.resolve('hono/route'),
+ runtimeEnvPath,
+ webSocketRuntimePath: join(repository, 'packages/void/src/runtime/ws-server.ts'),
+ projectRoot: root,
+ homeDir: root,
+ reactRefresh: false,
+ auth: null,
+ dev: environment === 'dev',
+ },
+ ),
+ );
+ writeFileSync(
+ worker,
+ compileWorkerEntry({
+ runtimeEnvPath,
+ webSocketClasses: scan.websockets!.map((route) => route.className),
+ dev: environment === 'dev',
+ }),
+ );
+ const output = join(root, 'dist');
+ await build({
+ root,
+ configFile: false,
+ logLevel: 'silent',
+ oxc: { jsx: { runtime: 'automatic', importSource: jsxImportSource } },
+ plugins: [
+ {
+ name: 'jsx-websocket-test',
+ resolveId(id) {
+ return id === 'virtual:void-routes' ? routeTable : null;
+ },
+ generateBundle() {
+ modules = [...this.getModuleIds()];
+ },
+ },
+ ],
+ ssr: { noExternal: true },
+ build: {
+ outDir: output,
+ ssr: worker,
+ target: 'esnext',
+ rollupOptions: {
+ external: [/^cloudflare:/],
+ output: { entryFileNames: 'worker.mjs', codeSplitting: false },
+ },
+ },
+ });
+ miniflare = new Miniflare(
+ convertV4MiniflareOptions({
+ compatibilityDate: '2026-07-01',
+ compatibilityFlags: ['nodejs_compat', 'web_socket_manual_reply_to_close'],
+ durableObjects: Object.fromEntries(
+ scan.websockets!.map((route) => [route.bindingName, route.className]),
+ ),
+ modules: true,
+ modulesRoot: output,
+ scriptPath: join(output, 'worker.mjs'),
+ }),
+ );
+ }, 30_000);
+
+ afterAll(async () => {
+ await miniflare?.dispose();
+ playground?.cleanup();
+ });
+
+ it.each(variants)('sends JSX-rendered HTML from .$ext $mode handlers', async ({ ext, mode }) => {
+ const response = await miniflare.dispatchFetch(`http://localhost/${ext}/${mode}`, {
+ headers: { Upgrade: 'websocket' },
+ });
+ expect(response.status).toBe(101);
+ const socket = response.webSocket!;
+ expect(socket).not.toBeNull();
+ socket.accept();
+ try {
+ const received = new Promise((resolve, reject) => {
+ socket.addEventListener('message', (event) => resolve(String(event.data)), { once: true });
+ socket.addEventListener('error', reject, { once: true });
+ });
+ socket.send(JSON.stringify({ text: 'Hello ' }));
+ expect(JSON.parse(await received)).toEqual({
+ html: `Hello <JSX>
`,
+ });
+ } finally {
+ const closed = new Promise((resolve) => {
+ socket.addEventListener('close', () => resolve(), { once: true });
+ });
+ socket.close(1000, 'test complete');
+ await closed;
+ }
+ });
+
+ it('generates WebSocket types and bundles only the selected environment and JSX runtime', () => {
+ const websocketTypes = types.split(' interface WebSocketRouteMap {')[1]!;
+ const graph = modules.join('\n').replaceAll('\\', '/');
+ const otherEnvironment = environment === 'dev' ? 'prod' : 'dev';
+ for (const { ext, mode } of variants) {
+ expect(websocketTypes).toContain(`"/${ext}/${mode}": {`);
+ expect(websocketTypes).toContain(
+ `typeof import("../routes/${ext}/${mode}.${environment}.ws.${ext}")`,
+ );
+ expect(types).not.toContain(`${mode}.${otherEnvironment}.ws.${ext}`);
+ expect(graph).toContain(`/routes/${ext}/${mode}.${environment}.ws.${ext}`);
+ expect(graph).not.toContain(`/routes/${ext}/${mode}.${otherEnvironment}.ws.${ext}`);
+ }
+ expect(graph).toMatch(
+ jsxImportSource === 'react'
+ ? /\/react\/jsx-(?:dev-)?runtime\.js/
+ : /\/hono\/dist\/jsx\/jsx-(?:dev-)?runtime\.js/,
+ );
+ });
+});
diff --git a/packages/void/test/integration/tsx-server.test.ts b/packages/void/test/integration/tsx-server.test.ts
new file mode 100644
index 000000000..7ce483106
--- /dev/null
+++ b/packages/void/test/integration/tsx-server.test.ts
@@ -0,0 +1,202 @@
+import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+import spawn from 'cross-spawn';
+import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test';
+import {
+ createIsolatedPlayground,
+ killChild,
+ startServer,
+ type IsolatedPlayground,
+} from '../../../../test/integration-helpers.ts';
+
+const repository = join(import.meta.dirname, '../../../..');
+const httpExtensions = ['tsx', 'jsx'];
+const companionPages = [
+ { name: 'foo', extension: 'tsx' },
+ { name: 'bar', extension: 'jsx' },
+];
+
+describe.each(['react', 'hono/jsx'])('server JSX with %s', (jsxImportSource) => {
+ let playground: IsolatedPlayground;
+ const servers: Array> = [];
+ const bases: Record = {};
+ let devTypes: string;
+
+ beforeAll(async () => {
+ playground = createIsolatedPlayground(repository, 'playground/node-app');
+ const root = playground.root;
+ rmSync(join(root, 'routes'), { recursive: true });
+ rmSync(join(root, 'pages'), { recursive: true });
+ mkdirSync(join(root, 'routes'));
+ const tsconfig = JSON.parse(readFileSync(join(root, 'tsconfig.json'), 'utf8'));
+ tsconfig.compilerOptions.jsxImportSource = jsxImportSource;
+ writeFileSync(join(root, 'tsconfig.json'), JSON.stringify(tsconfig));
+ writeFileSync(
+ join(root, 'vite.config.ts'),
+ `import { writeFileSync } from 'node:fs';
+import { defineConfig } from 'vite-plus';
+import { voidPlugin } from 'void';
+${jsxImportSource === 'react' ? "import { voidReact } from '@void/react/plugin';" : ''}
+export default defineConfig({
+ oxc: { jsx: { runtime: 'automatic', importSource: ${JSON.stringify(jsxImportSource)} } },
+ plugins: [voidPlugin(), ${jsxImportSource === 'react' ? 'voidReact(),' : ''} {
+ name: 'record-built-module-graphs',
+ generateBundle() {
+ writeFileSync('.void/jsx-' + this.environment.name + '.json', JSON.stringify([...this.getModuleIds()]));
+ },
+ }],
+});`,
+ );
+ const handler = (text: string) => `import { defineHandler } from 'void';
+${jsxImportSource === 'react' ? "import { renderToStaticMarkup } from 'react-dom/server';" : ''}
+export const GET = defineHandler(c => c.html(${jsxImportSource === 'react' ? `renderToStaticMarkup(${text}
)` : `${text}
`}));`;
+ for (const ext of httpExtensions) {
+ mkdirSync(join(root, 'routes', ext));
+ for (const [file, text] of [
+ [`jsx.${ext}`, 'JSX handler'],
+ [`environment.dev.${ext}`, 'Development JSX handler'],
+ [`environment.prod.${ext}`, 'Production JSX handler'],
+ [`debug.dev.${ext}`, 'DEV_ONLY_JSX_HANDLER'],
+ ]) {
+ writeFileSync(join(root, 'routes', ext, file), handler(text));
+ }
+ }
+ if (jsxImportSource === 'react') {
+ mkdirSync(join(root, 'pages'));
+ for (const { name, extension } of companionPages) {
+ writeFileSync(
+ join(root, `pages/${name}.${extension}`),
+ 'export default function Foo({ html }) { return ; }',
+ );
+ writeFileSync(
+ join(root, `pages/${name}.server.${extension}`),
+ `import { basename } from 'node:path';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { defineHandler } from 'void';
+export const ssr = true;
+export const prerender = false;
+export const loader = defineHandler(() => ({ html: renderToStaticMarkup({basename('/private/${name}_COMPANION_SERVER_ONLY')}
) }));
+export const actions = { save: defineHandler(c => c.html(renderToStaticMarkup(${name}_COMPANION_ACTION_ONLY))) };`,
+ );
+ }
+ }
+
+ const dev = await startServer(
+ (port) =>
+ spawn('vp', ['dev', '--host', '127.0.0.1', '--port', String(port), '--strictPort'], {
+ cwd: root,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ }),
+ 20_000,
+ );
+ servers.push(dev.child);
+ bases.dev = dev.base;
+ devTypes = readFileSync(join(root, '.void/routes.d.ts'), 'utf8');
+
+ const build = spawn.sync('vp', ['build'], {
+ cwd: root,
+ env: { ...process.env, NODE_ENV: 'production' },
+ encoding: 'utf8',
+ timeout: 60_000,
+ maxBuffer: 10 * 1024 * 1024,
+ });
+ expect(build.status, `${build.stdout ?? ''}${build.stderr ?? ''}`).toBe(0);
+ const prod = await startServer(
+ (port) =>
+ spawn(process.execPath, ['dist/ssr/index.js'], {
+ cwd: root,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ env: { ...process.env, NODE_ENV: 'production', PORT: String(port) },
+ }),
+ 20_000,
+ );
+ servers.push(prod.child);
+ bases.prod = prod.base;
+ });
+
+ afterAll(async () => {
+ await Promise.all(servers.map((child) => killChild(child)));
+ playground?.cleanup();
+ });
+
+ it.each(httpExtensions.flatMap((ext) => ['dev', 'prod'].map((env) => ({ ext, env }))))(
+ 'executes .$ext HTTP handlers and selects environment routes in $env',
+ async ({ ext, env }) => {
+ const response = await fetch(`${bases[env]}/${ext}/jsx`);
+ expect(response.status).toBe(200);
+ expect(response.headers.get('content-type')).toContain('text/html');
+ expect(await response.text()).toBe('JSX handler
');
+ const selected = await fetch(`${bases[env]}/${ext}/environment`);
+ expect(selected.status).toBe(200);
+ expect(await selected.text()).toBe(
+ `${env === 'dev' ? 'Development' : 'Production'} JSX handler
`,
+ );
+ expect((await fetch(`${bases[env]}/${ext}/debug`)).status).toBe(env === 'dev' ? 200 : 404);
+ },
+ );
+
+ it('generates environment-specific JSX HTTP types and excludes dev routes from the production graph', () => {
+ const prodTypes = readFileSync(join(playground.root, '.void/routes.d.ts'), 'utf8');
+ for (const [env, types] of [
+ ['dev', devTypes],
+ ['prod', prodTypes],
+ ]) {
+ for (const ext of httpExtensions) {
+ expect(types).toContain(`"/${ext}/jsx": {`);
+ expect(types).toContain(`typeof import("../routes/${ext}/environment.${env}.${ext}")`);
+ expect(types).not.toContain(`environment.${env === 'dev' ? 'prod' : 'dev'}.${ext}`);
+ }
+ }
+ const serverGraph = readFileSync(join(playground.root, '.void/jsx-ssr.json'), 'utf8');
+ for (const ext of httpExtensions) {
+ expect(serverGraph).toContain(`/routes/${ext}/jsx.${ext}`);
+ expect(serverGraph).toContain(`/routes/${ext}/environment.prod.${ext}`);
+ expect(serverGraph).not.toContain(`/routes/${ext}/environment.dev.${ext}`);
+ expect(serverGraph).not.toContain(`/routes/${ext}/debug.dev.${ext}`);
+ }
+ expect(serverGraph).toContain(`${jsxImportSource}/jsx-runtime`);
+ });
+
+ if (jsxImportSource === 'react') {
+ it.each(companionPages.flatMap((page) => ['dev', 'prod'].map((env) => ({ ...page, env }))))(
+ 'executes .server.$extension companion loaders and actions in $env',
+ async ({ name, env }) => {
+ const response = await fetch(`${bases[env]}/${name}`);
+ expect(response.status).toBe(200);
+ expect(await response.text()).toContain(
+ `${name}_COMPANION_SERVER_ONLY
`,
+ );
+ const action = await fetch(`${bases[env]}/${name}?save`, { method: 'POST' });
+ expect(action.status).toBe(200);
+ expect(await action.text()).toBe(`${name}_COMPANION_ACTION_ONLY`);
+ expect((await fetch(`${bases[env]}/${name}.server`)).status).toBe(404);
+ },
+ );
+
+ it('includes the companion on the server and excludes it from the client graph and assets', () => {
+ const root = playground.root;
+ const serverGraph = readFileSync(join(root, '.void/jsx-ssr.json'), 'utf8');
+ const clientGraph = readFileSync(join(root, '.void/jsx-client.json'), 'utf8');
+ for (const { name, extension } of companionPages) {
+ expect(serverGraph).toContain(`/pages/${name}.server.${extension}`);
+ expect(clientGraph).toContain(`/pages/${name}.${extension}`);
+ expect(clientGraph).not.toContain(`.server.${extension}`);
+ }
+ expect(clientGraph).not.toContain('/routes/');
+ const files = readdirSync(join(root, 'dist/client'), { recursive: true }) as Array;
+ const clientCode = files
+ .filter((file) => file.endsWith('.js') || file.endsWith('.json'))
+ .map((file) => readFileSync(join(root, 'dist/client', file), 'utf8'))
+ .join('\n');
+ expect(clientCode).not.toContain('COMPANION_SERVER_ONLY');
+ expect(clientCode).not.toContain('COMPANION_ACTION_ONLY');
+ expect(clientCode).not.toContain('DEV_ONLY_JSX_HANDLER');
+ const types = readFileSync(join(root, '.void/routes.d.ts'), 'utf8');
+ for (const { name, extension } of companionPages) {
+ expect(clientCode).not.toContain(`${name}.server`);
+ expect(types).toContain(`typeof import("../pages/${name}.server.${extension}")`);
+ expect(types).not.toContain(`"/${name}.server"`);
+ }
+ });
+ }
+});
diff --git a/packages/void/test/unit/pages-scan.test.ts b/packages/void/test/unit/pages-scan.test.ts
index 76e30a374..d7c732687 100644
--- a/packages/void/test/unit/pages-scan.test.ts
+++ b/packages/void/test/unit/pages-scan.test.ts
@@ -4,6 +4,7 @@ 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';
+import { generateComponentManifest } from '../../src/pages/manifest.ts';
import {
scanPages,
resolveLayoutChain,
@@ -124,6 +125,83 @@ describe('scanPages', () => {
expect(page!.methods).toEqual(['loader', 'action']);
});
+ it.each(['vue', 'svelte', 'md'])(
+ 'keeps non-script pages named .server.%s discoverable',
+ async (ext) => {
+ writeFile(`web.server.${ext}`, '');
+ writeFile('web.server.server.jsx', 'export const loader = () => Loaded
;');
+
+ const scan = await scanPages(TMP);
+ expect(scan.pages).toHaveLength(1);
+ expect(scan.pages[0]).toMatchObject({
+ pattern: '/web.server',
+ componentPath: `web.server.${ext}`,
+ serverPath: 'web.server.server.jsx',
+ methods: ['loader'],
+ });
+ const manifest = generateComponentManifest(scan, '/project/pages');
+ expect(manifest).toContain(`/project/pages/web.server.${ext}`);
+ expect(manifest).not.toContain('web.server.server.jsx');
+ },
+ );
+
+ it.each(
+ ['tsx', 'jsx'].flatMap((serverExt) =>
+ ['foo.tsx', 'foo.island.tsx', 'foo.jsx', 'foo.vue', 'foo.svelte', 'foo.md'].map(
+ (component) => ({ component, serverExt }),
+ ),
+ ),
+ )(
+ 'keeps the .server.$serverExt companion of $component out of page discovery and component manifests',
+ async ({ component, serverExt }) => {
+ writeFile(component, '');
+ writeFile(
+ `foo.server.${serverExt}`,
+ `export const loader = () => ({ html: Loaded
});
+export const actions = { save: () => Saved
};
+export const revalidate = 60;
+export const prerender = false;
+export const ssr = true;`,
+ );
+ writeFile(`orphan.server.${serverExt}`, 'export const loader = () => Orphan
;');
+ writeFile('layout.tsx', 'export default () => null;');
+ writeFile(`layout.server.${serverExt}`, 'export const helper = () => Server
;');
+ writeFile('_layouts/marketing.tsx', 'export default () => null;');
+ writeFile(
+ `_layouts/marketing.server.${serverExt}`,
+ 'export const helper = () => Server
;',
+ );
+
+ const scan = await scanPages(TMP);
+ expect(scan.pages).toHaveLength(1);
+ expect(scan.pages[0]).toMatchObject({
+ pattern: '/foo',
+ componentPath: component,
+ serverPath: `foo.server.${serverExt}`,
+ methods: ['loader', 'actions'],
+ revalidate: 60,
+ prerenderMode: 'disabled',
+ ssrMode: 'server',
+ });
+ expect(scan.layouts.map((layout) => layout.filePath)).toEqual(['layout.tsx']);
+ expect(scan.namedLayouts.map((layout) => layout.filePath)).toEqual([
+ '_layouts/marketing.tsx',
+ ]);
+ const manifest = generateComponentManifest(scan, '/project/pages');
+ expect(manifest).toContain(`/project/pages/${component}`);
+ expect(manifest).not.toContain('.server.');
+ const { dts } = generateRouteTypes(
+ { routes: [], middleware: [], jobs: [], queues: [], pages: scan },
+ join(TMP, 'routes'),
+ join(TMP, '.void'),
+ join(TMP, 'pages'),
+ );
+ expect(dts).toContain('"/foo": {');
+ expect(dts).toContain(`typeof import("../pages/foo.server.${serverExt}")`);
+ expect(dts).not.toContain('"/foo.server"');
+ },
+ );
+
it('detects actions (plural) export from .server.ts', async () => {
writeFile('settings.vue', 'Settings
');
writeFile(
diff --git a/packages/void/test/unit/parse-filename.test.ts b/packages/void/test/unit/parse-filename.test.ts
index 1946ddd2f..b6256aef5 100644
--- a/packages/void/test/unit/parse-filename.test.ts
+++ b/packages/void/test/unit/parse-filename.test.ts
@@ -38,6 +38,19 @@ describe('parseRouteFilename', () => {
expect(parseRouteFilename('hello.ts').pattern).toBe('/hello');
});
+ it.each([
+ ['hello.tsx', '/hello', null, []],
+ ['index.tsx', '/', null, []],
+ ['(api)/users/[id].dev.tsx', '/users/:id', 'dev', ['id']],
+ ['files/[...path].prod.tsx', '/files/:path{.+}', 'prod', ['path']],
+ ['hello.jsx', '/hello', null, []],
+ ['index.jsx', '/', null, []],
+ ['(api)/users/[id].dev.jsx', '/users/:id', 'dev', ['id']],
+ ['files/[...path].prod.jsx', '/files/:path{.+}', 'prod', ['path']],
+ ])('parses %s', (filePath, pattern, env, params) => {
+ expect(parseRouteFilename(filePath)).toMatchObject({ filePath, pattern, env, params });
+ });
+
it('.js', () => {
expect(parseRouteFilename('hello.js').pattern).toBe('/hello');
});
@@ -52,6 +65,18 @@ describe('parseRouteFilename', () => {
});
describe('environment suffixes', () => {
+ it.each(['tsx', 'jsx'])('parses JSX WebSocket routes in .%s', (ext) => {
+ expect(parseWebSocketFilename(`(app)/chat/[room].dev.ws.${ext}`)).toMatchObject({
+ pattern: '/chat/:room',
+ params: ['room'],
+ env: 'dev',
+ });
+ expect(parseWebSocketFilename(`chat/index.prod.ws.${ext}`)).toMatchObject({
+ pattern: '/chat',
+ env: 'prod',
+ });
+ });
+
it('.dev suffix', () => {
const r = parseRouteFilename('debug.dev.ts');
expect(r.pattern).toBe('/debug');
diff --git a/packages/void/test/unit/plugin-inference.test.ts b/packages/void/test/unit/plugin-inference.test.ts
index 3a973d2ba..4e6e6a166 100644
--- a/packages/void/test/unit/plugin-inference.test.ts
+++ b/packages/void/test/unit/plugin-inference.test.ts
@@ -86,26 +86,160 @@ describe('inferProjectBindings', () => {
});
});
- it('filters route-only bindings for the selected environment', () => {
- const root = createTempRoot();
- mkdirSync(join(root, 'routes', 'api'), { recursive: true });
- writeFileSync(
- join(root, 'routes', 'api', 'debug.dev.ts'),
- `
+ it.each(['ts', 'tsx', 'jsx'])(
+ 'filters route-only bindings in .%s for the selected environment',
+ (ext) => {
+ const root = createTempRoot();
+ mkdirSync(join(root, 'routes', 'api'), { recursive: true });
+ writeFileSync(
+ join(root, 'routes', 'api', `debug.dev.${ext}`),
+ `
import { storage } from "void/storage";
export const GET = async () => Response.json(await storage.list());
`,
+ );
+ writeFileSync(
+ join(root, 'routes', 'api', `events.dev.ws.${ext}`),
+ "import { defineLiveStream } from 'void/live';",
+ );
+
+ expect(inferProjectBindings(root, undefined, ['routes']).needsR2).toBe(true);
+ expect(inferProjectBindings(root, { environment: 'dev' }, ['routes']).needsR2).toBe(true);
+ expect(inferProjectBindings(root, { environment: 'dev' }, ['routes']).needsLive).toBe(true);
+ const production = inferProjectBindings(root, { environment: 'prod' }, ['routes']);
+ expect(production.needsR2).toBe(false);
+ expect(production.needsLive).toBe(false);
+ },
+ );
+
+ it.each(['tsx', 'jsx'])(
+ 'infers JSX bindings from .server.%s companions under sourceDir',
+ (serverExt) => {
+ const root = createTempRoot();
+ writeFileSync(join(root, 'void.json'), JSON.stringify({ sourceDir: 'src' }));
+ mkdirSync(join(root, 'src/routes'), { recursive: true });
+ mkdirSync(join(root, 'src/pages'), { recursive: true });
+ writeFileSync(
+ join(root, `src/routes/report.prod.${serverExt}`),
+ `import { kv } from 'void/kv'; export const GET = () => {kv.get('report')}
;`,
+ );
+ writeFileSync(
+ join(root, `src/pages/report.server.${serverExt}`),
+ `import { storage } from 'void/storage'; export const loader = () => {storage.list()}
;`,
+ );
+
+ expect(inferProjectBindings(root, { environment: 'dev' })).toMatchObject({
+ needsKV: false,
+ needsR2: true,
+ });
+ expect(inferProjectBindings(root, { environment: 'prod' })).toMatchObject({
+ needsKV: true,
+ needsR2: true,
+ });
+ },
+ );
+
+ it.each(['', 'src', 'app'])('infers .well-known bindings under sourceDir=%s', (sourceDir) => {
+ const root = createTempRoot();
+ if (sourceDir) {
+ writeFileSync(join(root, 'void.json'), JSON.stringify({ sourceDir }));
+ }
+ const routesDir = join(root, sourceDir, 'routes', '.well-known');
+ mkdirSync(join(routesDir, '_helpers'), { recursive: true });
+ writeFileSync(
+ join(routesDir, 'status.dev.tsx'),
+ "import { kv } from 'void/kv'; export const GET = () => {kv.get('status')}
;",
+ );
+ writeFileSync(
+ join(routesDir, 'status.prod.jsx'),
+ "import { storage } from 'void/storage'; export const GET = () => {storage.list()}
;",
+ );
+ writeFileSync(
+ join(routesDir, 'events.dev.ws.jsx'),
+ "import { db } from 'void/db'; export default defineWebSocket({ onMessage: () => {db.select()}
});",
);
writeFileSync(
- join(root, 'routes', 'api', 'events.dev.ws.ts'),
- "import { defineLiveStream } from 'void/live';",
+ join(routesDir, '_helpers', 'stream.prod.tsx'),
+ "import { defineLiveStream } from 'void/live'; export const Stream = () => Stream
;",
);
- expect(inferProjectBindings(root, undefined, ['routes']).needsR2).toBe(true);
- expect(inferProjectBindings(root, { environment: 'dev' }, ['routes']).needsR2).toBe(true);
- const production = inferProjectBindings(root, { environment: 'prod' }, ['routes']);
- expect(production.needsR2).toBe(false);
- expect(production.needsLive).toBe(false);
+ expect(inferProjectBindings(root, { environment: 'dev' })).toMatchObject({
+ needsKV: true,
+ needsR2: false,
+ needsD1: true,
+ needsLive: true,
+ });
+ expect(inferProjectBindings(root, { environment: 'prod' })).toMatchObject({
+ needsKV: false,
+ needsR2: true,
+ needsD1: false,
+ needsLive: true,
+ });
+ expect(inferProjectBindings(root)).toMatchObject({
+ needsKV: true,
+ needsR2: true,
+ needsD1: true,
+ needsLive: true,
+ });
+ });
+
+ it('keeps unrelated hidden directories out of .well-known inference', () => {
+ const root = createTempRoot();
+ for (const directory of [
+ 'routes/.cache',
+ 'routes/.well-known/.cache',
+ 'routes/nested/.well-known',
+ 'pages/.well-known',
+ ]) {
+ mkdirSync(join(root, directory), { recursive: true });
+ writeFileSync(join(root, directory, 'hidden.tsx'), "import { storage } from 'void/storage';");
+ }
+ writeFileSync(
+ join(root, 'routes/.well-known/status.tsx'),
+ "import { kv } from 'void/kv'; export const GET = () => Status
;",
+ );
+
+ expect(inferProjectBindings(root)).toMatchObject({ needsKV: true, needsR2: false });
+ expect(inferProjectBindings(root, undefined, ['pages']).needsKV).toBe(false);
+ });
+
+ it('infers .well-known bindings in the configured source fallback with custom scan directories', () => {
+ const root = createTempRoot();
+ writeFileSync(join(root, 'void.json'), JSON.stringify({ sourceDir: 'app' }));
+ mkdirSync(join(root, 'app/routes/.well-known'), { recursive: true });
+ writeFileSync(
+ join(root, 'app/routes/.well-known/status.prod.jsx'),
+ "import { kv } from 'void/kv'; export const GET = () => Status
;",
+ );
+
+ expect(inferProjectBindings(root, { environment: 'prod' }, ['server']).needsKV).toBe(true);
+ expect(inferProjectBindings(root, { environment: 'dev' }, ['server']).needsKV).toBe(false);
+ });
+
+ it.each(['tsx', 'jsx'])('keeps bindings from underscored route helpers in .%s', (ext) => {
+ const root = createTempRoot();
+ mkdirSync(join(root, 'routes/_components'), { recursive: true });
+ mkdirSync(join(root, 'routes/components'), { recursive: true });
+ writeFileSync(
+ join(root, `routes/_components/Toolbar.prod.${ext}`),
+ "import { kv } from 'void/kv'; export const Toolbar = () => Toolbar
;",
+ );
+ writeFileSync(
+ join(root, `routes/components/_Toolbar.dev.${ext}`),
+ "import { storage } from 'void/storage'; export const Toolbar = () => Toolbar
;",
+ );
+ writeFileSync(
+ join(root, `routes/_helper.prod.ws.${ext}`),
+ "import { db } from 'void/db'; export const helper = () => Helper
;",
+ );
+
+ for (const environment of ['dev', 'prod'] as const) {
+ expect(inferProjectBindings(root, { environment })).toMatchObject({
+ needsKV: true,
+ needsR2: true,
+ needsD1: true,
+ });
+ }
});
it('keeps bindings from non-route files with environment-like suffixes', () => {
@@ -113,7 +247,8 @@ describe('inferProjectBindings', () => {
mkdirSync(join(root, 'routes'), { recursive: true });
mkdirSync(join(root, 'queues'), { recursive: true });
writeFileSync(join(root, 'queues', 'sync.dev.ts'), "import { storage } from 'void/storage';");
- writeFileSync(join(root, 'routes', 'helper.dev.tsx'), "import { kv } from 'void/kv';");
+ mkdirSync(join(root, 'pages'), { recursive: true });
+ writeFileSync(join(root, 'pages', 'helper.dev.server.tsx'), "import { kv } from 'void/kv';");
const result = inferProjectBindings(root, { environment: 'prod' });
expect(result.needsR2).toBe(true);
diff --git a/packages/void/test/unit/plugin-route-binding-environment.test.ts b/packages/void/test/unit/plugin-route-binding-environment.test.ts
index fb7d53a5b..ed271330f 100644
--- a/packages/void/test/unit/plugin-route-binding-environment.test.ts
+++ b/packages/void/test/unit/plugin-route-binding-environment.test.ts
@@ -25,17 +25,20 @@ afterEach(() => {
}
});
-function createProject(): string {
+function createProject(ext: string, sourceDir = ''): string {
const root = mkdtempSync(join(tmpdir(), 'void-route-binding-env-'));
roots.push(root);
- mkdirSync(join(root, 'routes', 'api'), { recursive: true });
+ mkdirSync(join(root, sourceDir, 'routes', 'api'), { recursive: true });
writeFileSync(join(root, 'package.json'), JSON.stringify({ type: 'module' }));
writeFileSync(
join(root, 'void.json'),
- JSON.stringify({ worker: { compatibility_date: '2026-02-24' } }),
+ JSON.stringify({
+ sourceDir: sourceDir || undefined,
+ worker: { compatibility_date: '2026-02-24' },
+ }),
);
writeFileSync(
- join(root, 'routes', 'api', 'debug.dev.ts'),
+ join(root, sourceDir, 'routes', 'api', `debug.dev.${ext}`),
`import { db } from 'void/db';
import { kv } from 'void/kv';
import { storage } from 'void/storage';
@@ -69,34 +72,96 @@ async function generatedBindings(
}
describe('environment-specific route binding inference', () => {
- it('does not emit production bindings for imports exclusive to a dev route', async () => {
- const root = createProject();
- const config = await generatedBindings(root, { command: 'build', mode: 'production' });
-
- expect(config.d1_databases).toBeUndefined();
- expect(config.kv_namespaces).toBeUndefined();
- expect(config.r2_buckets).toBeUndefined();
- expect(config.ai).toBeUndefined();
- });
-
- it('keeps the same bindings when serving the dev route', async () => {
- const root = createProject();
- const config = await generatedBindings(root, { command: 'serve', mode: 'development' });
-
- expect(config.d1_databases).toBeDefined();
- expect(config.kv_namespaces).toBeDefined();
- expect(config.r2_buckets).toBeDefined();
- });
-
- it('does not emit container or live Durable Object bindings from a dev route', async () => {
- const root = createProject();
- writeFileSync(
- join(root, 'routes', 'api', 'debug.dev.ts'),
- "import { getSandbox } from 'void/sandbox'; import { defineLiveStream } from 'void/live'; export const GET = () => ({ getSandbox, defineLiveStream });",
- );
-
- const config = await generatedBindings(root, { command: 'build', mode: 'production' });
- expect(config.containers).toBeUndefined();
- expect(config.durable_objects).toBeUndefined();
- });
+ it.each([
+ { ext: 'tsx', sourceDir: '' },
+ { ext: 'jsx', sourceDir: 'src' },
+ ])(
+ 'emits .well-known and helper bindings in .$ext under sourceDir=$sourceDir',
+ async ({ ext, sourceDir }) => {
+ const root = createProject(ext, sourceDir);
+ const routesDir = join(root, sourceDir, 'routes');
+ rmSync(join(routesDir, 'api', `debug.dev.${ext}`));
+ mkdirSync(join(routesDir, '.well-known'));
+ mkdirSync(join(routesDir, '_components'));
+ writeFileSync(
+ join(routesDir, '.well-known', `status.prod.${ext}`),
+ "import { kv } from 'void/kv'; export const GET = () => {kv.get('status')}
;",
+ );
+ writeFileSync(
+ join(routesDir, '_components', `Toolbar.prod.${ext}`),
+ "import { storage } from 'void/storage'; export const Toolbar = () => Toolbar
;",
+ );
+
+ const development = await generatedBindings(root, { command: 'serve', mode: 'development' });
+ expect(development.kv_namespaces).toBeUndefined();
+ expect(development.r2_buckets).toBeDefined();
+ const production = await generatedBindings(root, { command: 'build', mode: 'production' });
+ expect(production.kv_namespaces).toBeDefined();
+ expect(production.r2_buckets).toBeDefined();
+ },
+ );
+
+ it.each(['tsx', 'jsx'])(
+ 'emits JSX WebSocket bindings only in the selected environment for .ws.%s',
+ async (ext) => {
+ const root = createProject(ext);
+ rmSync(join(root, 'routes', 'api', `debug.dev.${ext}`));
+ writeFileSync(
+ join(root, 'routes', 'api', `debug.dev.ws.${ext}`),
+ `import { kv } from 'void/kv';
+import { defineWebSocket } from 'void/ws';
+export default defineWebSocket({ onMessage() { return {kv.get('debug')}
; } });`,
+ );
+
+ const production = await generatedBindings(root, { command: 'build', mode: 'production' });
+ expect(production.kv_namespaces).toBeUndefined();
+ expect(production.durable_objects).toBeUndefined();
+
+ const development = await generatedBindings(root, { command: 'serve', mode: 'development' });
+ expect(development.kv_namespaces).toBeDefined();
+ expect(development.durable_objects).toEqual({
+ bindings: [{ name: 'ApiDebugWs', class_name: 'ApiDebugWs' }],
+ });
+ },
+ );
+
+ it.each(['ts', 'tsx', 'jsx'])(
+ 'does not emit production bindings for a dev .%s route',
+ async (ext) => {
+ const root = createProject(ext);
+ const config = await generatedBindings(root, { command: 'build', mode: 'production' });
+
+ expect(config.d1_databases).toBeUndefined();
+ expect(config.kv_namespaces).toBeUndefined();
+ expect(config.r2_buckets).toBeUndefined();
+ expect(config.ai).toBeUndefined();
+ },
+ );
+
+ it.each(['ts', 'tsx', 'jsx'])(
+ 'keeps the same bindings when serving the dev .%s route',
+ async (ext) => {
+ const root = createProject(ext);
+ const config = await generatedBindings(root, { command: 'serve', mode: 'development' });
+
+ expect(config.d1_databases).toBeDefined();
+ expect(config.kv_namespaces).toBeDefined();
+ expect(config.r2_buckets).toBeDefined();
+ },
+ );
+
+ it.each(['ts', 'tsx', 'jsx'])(
+ 'does not emit container or live bindings from a dev .%s route',
+ async (ext) => {
+ const root = createProject(ext);
+ writeFileSync(
+ join(root, 'routes', 'api', `debug.dev.${ext}`),
+ "import { getSandbox } from 'void/sandbox'; import { defineLiveStream } from 'void/live'; export const GET = () => ({ getSandbox, defineLiveStream });",
+ );
+
+ const config = await generatedBindings(root, { command: 'build', mode: 'production' });
+ expect(config.containers).toBeUndefined();
+ expect(config.durable_objects).toBeUndefined();
+ },
+ );
});
diff --git a/packages/void/test/unit/scan.test.ts b/packages/void/test/unit/scan.test.ts
index b572b778c..6f8025d20 100644
--- a/packages/void/test/unit/scan.test.ts
+++ b/packages/void/test/unit/scan.test.ts
@@ -2,6 +2,7 @@ import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it, beforeEach, afterEach } from 'vite-plus/test';
import { analyzeApplication } from '../../src/application-routing.ts';
+import { generateRouteTypes } from '../../src/codegen/route-types.ts';
import { readConfig } from '../../src/config.ts';
import type { RouteEnvironment } from '../../src/router/parse-filename.ts';
import { scanRoutePatternsSync } from '../../src/router/scan.ts';
@@ -65,6 +66,53 @@ describe('scanRoutes', () => {
expect(result.jobs).toEqual([]);
});
+ it.each(
+ ['tsx', 'jsx'].flatMap((ext) =>
+ ([undefined, 'dev', 'prod'] as const).map((environment) => ({ ext, environment })),
+ ),
+ )(
+ 'discovers .$ext handlers and generates types for environment=$environment',
+ async ({ ext, environment }) => {
+ createFile(`routes/api/hello.${ext}`, 'export const GET = () => Hello
;');
+ createFile(`routes/api/env.dev.${ext}`, 'export const GET = () => Dev
;');
+ createFile(`routes/api/env.prod.${ext}`, 'export const POST = () => Prod
;');
+ createFile(`routes/.well-known/status.${ext}`, 'export const GET = () => OK
;');
+ createFile(`routes/_private.${ext}`, 'export const GET = () => Private
;');
+ createFile(`routes/.cache/ignored.${ext}`);
+
+ const result = await scanRoutes(TMP, { environment });
+ const expected = [
+ `.well-known/status.${ext}`,
+ ...(environment !== 'prod' ? [`api/env.dev.${ext}`] : []),
+ ...(environment !== 'dev' ? [`api/env.prod.${ext}`] : []),
+ `api/hello.${ext}`,
+ ];
+ expect(result.routes.map((route) => route.filePath).sort()).toEqual(expected);
+ expect(result.routes.find((route) => route.pattern === '/api/hello')?.methods).toEqual([
+ 'GET',
+ ]);
+ const { dts } = generateRouteTypes(result, join(TMP, 'routes'), join(TMP, '.void'));
+ expect(dts).toContain('"/api/hello": {');
+ for (const file of expected) {
+ expect(dts).toContain(`typeof import("../routes/${file}")`);
+ }
+ if (environment) {
+ expect(dts).not.toContain(`env.${environment === 'dev' ? 'prod' : 'dev'}.${ext}`);
+ }
+ },
+ );
+
+ it.each(['tsx', 'jsx'])('keeps .%s out of cron and queue discovery', async (ext) => {
+ createFile(`routes/hello.${ext}`, 'export const GET = () => Hello
;');
+ createFile(`crons/sync.${ext}`, 'export const cron = "0 * * * *"; export default () => {};');
+ createFile(`queues/emails.${ext}`, 'export default async () => {};');
+
+ const result = await scanRoutes(TMP);
+ expect(result.routes.map((route) => route.filePath)).toEqual([`hello.${ext}`]);
+ expect(result.jobs).toEqual([]);
+ expect(result.queues).toEqual([]);
+ });
+
it('discovers conventions under explicit sourceDir only', async () => {
createFile('void.json', JSON.stringify({ sourceDir: 'src' }));
createFile('routes/api/root.ts', 'export const GET = () => {}');
@@ -268,6 +316,24 @@ export default () => {};`,
expect(dev.websockets?.map((r) => r.filePath)).toEqual(['chat.dev.ws.ts']);
expect(prod.websockets?.map((r) => r.filePath)).toEqual(['chat.prod.ws.ts']);
});
+
+ it('discovers all WebSocket route extensions separately from HTTP handlers', async () => {
+ createFile('routes/tsx.tsx', 'export const GET = () => TSX
;');
+ createFile('routes/jsx.jsx', 'export const GET = () => JSX
;');
+ for (const ext of ['ts', 'mts', 'js', 'mjs', 'tsx', 'jsx']) {
+ createFile(`routes/chat-${ext}.ws.${ext}`, 'export default defineWebSocket({});');
+ }
+ const result = await scanRoutes(TMP);
+ expect(result.websockets?.map((route) => route.filePath).sort()).toEqual([
+ 'chat-js.ws.js',
+ 'chat-jsx.ws.jsx',
+ 'chat-mjs.ws.mjs',
+ 'chat-mts.ws.mts',
+ 'chat-ts.ws.ts',
+ 'chat-tsx.ws.tsx',
+ ]);
+ expect(result.routes.map((route) => route.pattern)).toEqual(['/jsx', '/tsx']);
+ });
});
describe('sync scanners', () => {
diff --git a/packages/void/test/unit/websocket-scan.test.ts b/packages/void/test/unit/websocket-scan.test.ts
index e04d1d373..f9ffd4f1a 100644
--- a/packages/void/test/unit/websocket-scan.test.ts
+++ b/packages/void/test/unit/websocket-scan.test.ts
@@ -31,6 +31,43 @@ function createRoot(): string {
return root;
}
+describe('JSX WebSocket discovery', () => {
+ it.each(
+ ['tsx', 'jsx'].flatMap((ext) =>
+ (['defineRoom', 'defineWebSocket'] as const).map((helper) => ({ ext, helper })),
+ ),
+ )('discovers .$ext $helper routes and filters environments', ({ ext, helper }) => {
+ const root = createRoot();
+ const source = `export default ${helper}({ onBeforeConnect() { return Sign in
; } });`;
+ createRoute(root, `chat/[room].dev.ws.${ext}`, source);
+ createRoute(root, `chat/[room].prod.ws.${ext}`, source);
+ createRoute(root, `.well-known/status.ws.${ext}`, `export default ${helper}({});`);
+ createRoute(root, `_private.ws.${ext}`, source);
+ createRoute(root, `_private/hidden.ws.${ext}`, source);
+ createRoute(root, `.cache/ignored.ws.${ext}`, source);
+
+ for (const environment of ['dev', 'prod'] as const) {
+ expect(scanWebSocketRoutesSync(root, undefined, environment)).toEqual([
+ expect.objectContaining({
+ filePath: `.well-known/status.ws.${ext}`,
+ pattern: '/.well-known/status',
+ }),
+ expect.objectContaining({
+ filePath: `chat/[room].${environment}.ws.${ext}`,
+ pattern: '/chat/:room',
+ params: ['room'],
+ env: environment,
+ className: 'ChatRoomWs',
+ bindingName: 'ChatRoomWs',
+ mode: helper === 'defineRoom' ? 'room' : 'socket',
+ hasAuthGate: true,
+ }),
+ ]);
+ }
+ expect(scanWebSocketRoutesSync(root)).toHaveLength(3);
+ });
+});
+
describe('WebSocket resource names', () => {
it.each([
['chat/[room].ws.ts', 'chat-room', 'ChatRoomWs'],
@@ -39,6 +76,10 @@ describe('WebSocket resource names', () => {
['API_chat/roomID.ws.mjs', 'API-chat-roomID', 'APIChatRoomIDWs'],
['[...].ws.ts', 'WebSocket', 'WebSocketWs'],
['chat\\[room].ws.ts', 'chat-room', 'ChatRoomWs'],
+ ['chat/[room].ws.tsx', 'chat-room', 'ChatRoomWs'],
+ ['chat/[room].dev.ws.jsx', 'chat-room', 'ChatRoomWs'],
+ ['(admin)/API/[...IDs].prod.ws.tsx', 'API-IDs', 'APIIDsWs'],
+ ['chat\\[room].ws.jsx', 'chat-room', 'ChatRoomWs'],
])('preserves legacy identity for %s', (path, name, className) => {
expect(inferWebSocketResourceName(path)).toBe(name);
expect(deriveWebSocketClassName(path)).toBe(className);