Skip to content

feat(router): support JSX HTTP/WebSocket routes and page companions - #561

Merged
Brooooooklyn merged 5 commits into
voidzero-dev:mainfrom
yangshun:codex/jsx-server-routes
Oct 26, 2026
Merged

Brooooooklyn merged 5 commits into
voidzero-dev:mainfrom
yangshun:codex/jsx-server-routes

Conversation

@yangshun

@yangshun yangshun commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This adds support for .tsx and .jsx in HTTP/WebSocket route files and recognizes .server.tsx/.server.jsx page companions.

It also fixes existing .server.tsx companions being discovered as standalone pages such as /foo.server and included in generated client component manifests.

The extension enables JSX syntax; the file's location and naming determine its role.

This PR ended up larger than I imagined, mainly because there are many cases to handle. Half of the changes are tests and docs updates. There's also a fair bit of consolidation going on.

P.S. TBH I didn't start out wanting to support JSX for WebSockets, but the route conventions + extensions logic was quite entangled and it was hard to untangle them. Adding support for JSX WebSockets was the easiest way forward while not making the code messy. One use case I convinced myself to add support for JSX in WebSockets is chat apps that sends rendered HTML over WebSockets (hopefully that's not too much of a stretch to imagine!).

Warning

This PR updates the docs with a new feature, and I think docs are published at merge time, which can result in unreleased features appearing in the docs.

Motivations

It's only a matter of time before someone requests for this, given the precedents for JSX-capable HTTP route files.

  • Vercel OG image generation: the official examples use api/og.tsx and api/og.jsx, as well as Next.js app/api/og/route.tsx, to generate images from JSX inside HTTP handlers.
  • TanStack Start server routes: the documentation defines HTTP server.handlers in routes/hello.tsx, alongside a JSX page component.

Christoph also mentioned that he prefers .tsx.

Scope

File Behavior
routes/api.tsx / routes/api.jsx HTTP handlers at /api
routes/api.dev.tsx / routes/api.prod.jsx HTTP handlers restricted to development / production
routes/chat.ws.tsx / routes/chat.ws.jsx WebSocket handlers at /chat
routes/chat.dev.ws.tsx / routes/chat.prod.ws.jsx WebSocket handlers restricted to development / production
pages/foo.server.tsx / pages/foo.server.jsx Loaders, actions, and page configuration for the matching page, excluded from standalone page discovery and generated client component manifests
pages/foo.tsx / pages/foo.jsx Existing page component support; no new component formats

Both JSX extensions support .dev and .prod restrictions. For WebSocket routes, the environment suffix goes before .ws.

HTTP and WebSocket discovery, filename parsing, page companion lookup, and route binding inference share ROUTE_EXTENSIONS (ts, tsx, js, jsx, mjs, mts) from shared/route-extensions.ts. The page scanner uses the derived ROUTE_EXTENSIONS_SET for membership checks. Both route scanners use the fixed listRouteFiles() helper and the existing WebSocket filename matcher to distinguish their roles. listConventionFiles() retains its existing script extensions for crons and queues.

Discovery and binding inference share the .well-known glob policy, including configured source directories. Environment filtering applies only to route files, so _-prefixed helpers retain their bindings in both environments. Companion exclusion applies only to supported script extensions, preserving page components such as web.server.md.

WebSocket resource-name inference strips JSX extensions so changing chat.ws.ts to chat.ws.tsx or chat.ws.jsx preserves the inferred Durable Object identity. Void sets no JSX options: route JSX compiles with the app's own JSX settings (framework plugins, tsconfig for TS files, or oxc.jsx). The docs show Hono's JSX via oxc.jsx for apps without a JSX framework.

The documentation covers supported HTTP, WebSocket, and companion extensions. General enforcement against manually importing .server.* modules from client code remains a separate feature.

Validation

  • vp run check: all 33 workspaces passed.
  • vp run test: 11,214 passed, 31 skipped.
  • vp test run --maxWorkers=4: 14,594 passed, 31 skipped.
  • Formatting and lint passed.

Regression coverage executes JSX HTTP handlers and WebSocket messages using React and Hono runtimes, checks environment filtering and generated types/bindings (including .well-known routes and underscored helpers), and executes companion loaders/actions. It also verifies that Vue, Svelte, and Markdown pages with a .server suffix remain discoverable. Production-build assertions confirm companion code is present on the server and absent from the generated client graph and assets.

Follow-up

#559 (merged) excludes server companions from React and Solid's client bundle detection, so island-only apps do not emit the full pages-client entry. Until this PR lands, main still lists a companion such as index.server.tsx as its own page (/index.server).

Review and polish (rebased on main)

Rebased onto main (the merge commit is dropped; the four commits are unchanged). A deep review found the code correct; one docs commit was added:

"uses your app's configured JSX runtime"  →  Void sets no JSX runtime
  React / Solid app        framework JSX → render with its server renderer
  API only / Vue / Svelte  no runtime (Vue's jsx: preserve leaves JSX raw → build fails)
                           → docs now show oxc.jsx with hono/jsx (needs hono in the app's deps)

Checked in real apps with that setup, .tsx and .jsx routes, dev and vp preview:

App /hello (.tsx) /hello2 (.jsx) existing pages
Vue pages 200 200 200
Svelte pages 200 200 200
API only (with hono dep) 200 200 200

Also in that commit: the WebSocket and companion docs link to the JSX section; the companion table drops the routes/ row and the repeated pair; reference/structure.md lists companion extensions; websockets.md says chat.ws.dev.ts is not a WebSocket route (it is served as an HTTP route at /chat.ws).

Mutation checks: reverting ROUTE_EXTENSIONS to the old list fails 93 tests; removing the .server page skip fails 20. Affected tests (vp run test --base origin/main, 518 files) pass except one unrelated platform browser test that times out at 5s under full load (passes alone); that is fixed separately.

Left for the maintainer (product calls)

  • Docs go live at merge, before the feature ships (as noted above).
  • .tsx/.jsx files already in routes/ that were ignored before now become routes; a release note could tell users to prefix non-route files with _.
  • Two companions for one page (foo.server.ts + foo.server.tsx) silently use the first by extension order, as .ts + .tsx already did on main.
  • Older, not from this PR: a.ts + a.tsx (like a.ts + a.js today) register two handlers for /a without an error.

@yangshun
yangshun marked this pull request as ready for review October 23, 2026 11:06
Brooooooklyn added a commit that referenced this pull request Oct 26, 2026
## Summary

Depends on [#561](#561), which
adds JSX server companion support. **Merge after #561, or together with
it.** On `main` without #561, the page scanner still lists
`index.server.tsx` as a page of its own.

React and Solid decide whether to build the `pages-client` bundle with
`hasNonIslandPages()`. That check counted server companions
(`index.server.tsx`, `index.server.jsx`) as ordinary pages. So an
island-only app still emitted a `pages-client` bundle it never used.

```
pages/
  index.island.tsx    island page      → islands-client only
  index.server.tsx    server companion → not a page (skipped now)
  status.server.v2.tsx  ordinary page  → still needs pages-client ✓
```

The skip matches #561's page scanner exactly: a name that **ends in**
`.server.tsx` or `.server.jsx` is a companion. A name that only contains
`.server.` (`status.server.v2.tsx`) is still a page.

## Tests

- `packages/{react,solid}/test/unit/plugin-pages.test.ts`:
- island-only pages plus a `.server.tsx` or `.server.jsx` file → no
`pages-client`;
- an ordinary page next to them (`about.tsx`, `about.jsx`, `blog/…`,
`status.server.v2.tsx`) → `pages-client` kept.
- **Mutation check:** without the skip, 4 tests fail. With a plain
"contains `.server.`" rule, the 2 `status.server.v2.tsx` cases fail.
- **With #561 merged** (no conflicts):
  - these tests and #561's own tests pass;
  - a real island-only React build has no `pages-client` chunk;
- `vp preview` serves only the islands script, and the island hydrates
(a button click updates it).
- Affected tests (`vp run test`, 508 files), lint and typecheck pass.

## Not changed here (older behavior)

- `hasNonIslandPages()` checks only the file name. So a file in a
`_`-prefixed folder (e.g. `_components/Button.tsx`) still counts as a
page, while the scanner skips it. Switching the adapters to the
scanner's own result would fix that; it's a separate change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: LongYinan <github@lyn.one>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Brooooooklyn
Brooooooklyn self-requested a review October 26, 2026 04:56
yangshun and others added 5 commits October 26, 2026 12:57
Void sets no JSX options, so "uses your app's configured runtime" left
API-only, Vue and Svelte apps without a working setup: a .tsx route fell
back to React's runtime or, under the Vue template's `jsx: preserve`, was
not compiled at all. Say that route JSX follows the app's JSX settings,
and show Hono's JSX through `oxc.jsx` for apps without a JSX framework
(tsconfig alone does not reach .jsx files).

Also: link the WebSocket and companion docs to that section, drop the
duplicate rows from the companion table, list companion extensions in
the structure reference, and say that `chat.ws.dev.ts` is not a
WebSocket route (it is served as an HTTP route).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Brooooooklyn
Brooooooklyn force-pushed the codex/jsx-server-routes branch from 965467d to 3653ca1 Compare October 26, 2026 05:19
@Brooooooklyn

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review 🔄 Running since 2026-10-26T05:26:10.399806Z 3653ca1 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

An unknown error occurred
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@Brooooooklyn

Copy link
Copy Markdown
Member

@codex review

@Brooooooklyn
Brooooooklyn merged commit 445b41a into voidzero-dev:main Oct 26, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants