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', ''); 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);