diff --git a/docs/design.md b/docs/design.md
index 358e3661..4cfb7fed 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -466,23 +466,23 @@ Every export, with who uses it and why it exists. An export with no user in the
or ChessMerge goes; the list is checked at every release. At 0.8.0 and 0.5.0 that removed
`serveAsset` and `useSignedIn`.
-| Export | Used by | Why |
-| ------------------------------------------------------------------------- | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| hosting `serveSite` | `convex/http.ts` of every app | The one entry: files, then `worker`, which is the Worker script; its absence is the files-only Worker |
-| hosting `convex.config` | `convex/convex.config.ts` of every app | Convex's component definition, for `app.use` |
-| hosting `_generated/component.js` (types) | The generated `api.d.ts` of every app | Convex's codegen imports it for the component's API type |
-| CLI `deploy --build --dist [--dev \| --preview-name]` | App scripts and CI | The upload must run between the build and the push, with the deployment chosen by the same flags as Convex's CLI |
-| CLI `--html-handling`, `--not-found-handling` | SPA example; ChessMerge (`none`) | Cloudflare's own asset settings, stored with the version |
-| CLI `--component` | None today | Convex's own `app.use(hosting, { name })` option; the CLI must match it; Bristol's production used it while hosting 0.3 and 0.4 ran under two names |
-| CLI `upload` | `deploy` itself, inside `npx convex deploy --cmd` | The step after the build has to be a program that a shell can call |
-| adapter `vite`: `convexSsr()` | `vite.config.ts` of server-rendered apps | The `ssr` environment settings that make Start's server build run in Convex's runtime |
-| adapter `server` | `src/server.ts` of server-rendered apps | Start's handler with React's Web Stream renderer, typed as TanStack's `RequestHandler`, so `fetch(request, { context })` takes the app's registered context |
-| adapter `server-build` (types) | `convex/http.ts` of server-rendered apps | The type of the built server module: `{ fetch(request) }`, the shape of the app's `src/server.ts` |
-| adapter `convex-auth`: `sessionBeforeLoad` | The root route | The same `beforeLoad` in every app with cookie sessions |
-| adapter `convex-auth`: `SessionProvider` | The root component | Convex Auth's provider with the server token, plus the release of `expectAuth` for a signed-out visitor |
-| adapter `convex-auth`: `redirectWithoutSession`, `RedirectWithoutSession` | The `_authed` layout | The server render has the cookie; the browser follows the provider; two moments, two pieces |
-| adapter `convex-auth`: `SignedIn`, `SignedOut` | Bristol's header and menus | Convex's provider reports loading during the server render and the hydration; these wait for the answer |
-| adapter `convex-auth`: `useSignOutSession` | The header's sign-out in the SSR example and Bristol | Convex Auth's `signOut()` resolves when the cookie route failed; this asks the route first, then clears the client, then reloads |
+| Export | Used by | Why |
+| ------------------------------------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| hosting `serveSite` | `convex/http.ts` of every app | The one entry: files, then `worker`, which is the Worker script; its absence is the files-only Worker |
+| hosting `convex.config` | `convex/convex.config.ts` of every app | Convex's component definition, for `app.use` |
+| hosting `_generated/component.js` (types) | The generated `api.d.ts` of every app | Convex's codegen imports it for the component's API type |
+| CLI `deploy --build --dist [--dev \| --preview-name]` | App scripts and CI | The upload must run between the build and the push, with the deployment chosen by the same flags as Convex's CLI |
+| CLI `--html-handling`, `--not-found-handling` | SPA example; ChessMerge (`none`) | Cloudflare's own asset settings, stored with the version |
+| CLI `--component` | None today | Convex's own `app.use(hosting, { name })` option; the CLI must match it; Bristol's production used it while hosting 0.3 and 0.4 ran under two names |
+| CLI `upload` | `deploy` itself, inside `npx convex deploy --cmd` | The step after the build has to be a program that a shell can call |
+| adapter `vite`: `convexSsr()` | `vite.config.ts` of server-rendered apps | The `ssr` environment settings that make Start's server build run in Convex's runtime, the `node:stream` stub included |
+| adapter `server` | `src/server.ts` of server-rendered apps | Start's own stream handler, typed as TanStack's `RequestHandler`, so `fetch(request, { context })` takes the app's registered context; pages stream as on a Worker |
+| adapter `server-build` (types) | `convex/http.ts` of server-rendered apps | The type of the built server module: `{ fetch(request) }`, the shape of the app's `src/server.ts` |
+| adapter `convex-auth`: `sessionBeforeLoad` | The root route | The same `beforeLoad` in every app with cookie sessions |
+| adapter `convex-auth`: `SessionProvider` | The root component | Convex Auth's provider with the server token, plus the release of `expectAuth` for a signed-out visitor |
+| adapter `convex-auth`: `redirectWithoutSession`, `RedirectWithoutSession` | The `_authed` layout | The server render has the cookie; the browser follows the provider; two moments, two pieces |
+| adapter `convex-auth`: `SignedIn`, `SignedOut` | Bristol's header and menus | Convex's provider reports loading during the server render and the hydration; these wait for the answer |
+| adapter `convex-auth`: `useSignOutSession` | The header's sign-out in the SSR example and Bristol | Convex Auth's `signOut()` resolves when the cookie route failed; this asks the route first, then clears the client, then reloads |
## Repository
diff --git a/examples/tanstack-ssr-workers.patch b/examples/tanstack-ssr-workers.patch
index a0f70595..35d99464 100644
--- a/examples/tanstack-ssr-workers.patch
+++ b/examples/tanstack-ssr-workers.patch
@@ -313,14 +313,13 @@ index c1fa9a2d4c1224a8cbda8132e633b7b066e137b4..3e6badb4024aed63c5e612137eb9fc19
tanstackStart({ pages: [{ path: "/about", prerender: { enabled: true, crawlLinks: false } }] }),
diff --git a/wrangler.jsonc b/wrangler.jsonc
new file mode 100644
-index 0000000000000000000000000000000000000000..0514290649d7d95f8a16f528db32522d804ba834
+index 0000000000000000000000000000000000000000..48c90a9e2b5789638c5baf349fe0cd88dcbbb282
--- /dev/null
+++ b/wrangler.jsonc
-@@ -0,0 +1,9 @@
+@@ -0,0 +1,8 @@
+{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "compatibility_date": "2026-10-01",
-+ "compatibility_flags": ["nodejs_compat"],
+ "main": "src/server.ts",
+ "preview_urls": true,
+ // `wrangler preview`, the Workers Builds preview command, requires this block.
diff --git a/examples/tanstack-ssr-workers/src/routeTree.gen.ts b/examples/tanstack-ssr-workers/src/routeTree.gen.ts
index d67f1306..a40b99e4 100644
--- a/examples/tanstack-ssr-workers/src/routeTree.gen.ts
+++ b/examples/tanstack-ssr-workers/src/routeTree.gen.ts
@@ -13,6 +13,7 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as AuthedRouteImport } from './routes/_authed'
import { Route as AboutRouteImport } from './routes/about'
import { Route as SigninRouteImport } from './routes/signin'
+import { Route as StreamRouteImport } from './routes/stream'
import { Route as AuthedTodosRouteImport } from './routes/_authed/todos'
const IndexRoute = IndexRouteImport.update({
@@ -34,6 +35,11 @@ const SigninRoute = SigninRouteImport.update({
path: '/signin',
getParentRoute: () => rootRouteImport,
} as any)
+const StreamRoute = StreamRouteImport.update({
+ id: '/stream',
+ path: '/stream',
+ getParentRoute: () => rootRouteImport,
+} as any)
const AuthedTodosRoute = AuthedTodosRouteImport.update({
id: '/todos',
path: '/todos',
@@ -44,12 +50,14 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/about': typeof AboutRoute
'/signin': typeof SigninRoute
+ '/stream': typeof StreamRoute
'/todos': typeof AuthedTodosRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/about': typeof AboutRoute
'/signin': typeof SigninRoute
+ '/stream': typeof StreamRoute
'/todos': typeof AuthedTodosRoute
}
export interface FileRoutesById {
@@ -58,14 +66,22 @@ export interface FileRoutesById {
'/_authed': typeof AuthedRouteWithChildren
'/about': typeof AboutRoute
'/signin': typeof SigninRoute
+ '/stream': typeof StreamRoute
'/_authed/todos': typeof AuthedTodosRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
- fullPaths: '/' | '/about' | '/signin' | '/todos'
+ fullPaths: '/' | '/about' | '/signin' | '/stream' | '/todos'
fileRoutesByTo: FileRoutesByTo
- to: '/' | '/about' | '/signin' | '/todos'
- id: '__root__' | '/' | '/_authed' | '/about' | '/signin' | '/_authed/todos'
+ to: '/' | '/about' | '/signin' | '/stream' | '/todos'
+ id:
+ | '__root__'
+ | '/'
+ | '/_authed'
+ | '/about'
+ | '/signin'
+ | '/stream'
+ | '/_authed/todos'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -73,6 +89,7 @@ export interface RootRouteChildren {
AuthedRoute: typeof AuthedRouteWithChildren
AboutRoute: typeof AboutRoute
SigninRoute: typeof SigninRoute
+ StreamRoute: typeof StreamRoute
}
declare module '@tanstack/react-router' {
@@ -105,6 +122,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SigninRouteImport
parentRoute: typeof rootRouteImport
}
+ '/stream': {
+ id: '/stream'
+ path: '/stream'
+ fullPath: '/stream'
+ preLoaderRoute: typeof StreamRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/_authed/todos': {
id: '/_authed/todos'
path: '/todos'
@@ -131,6 +155,7 @@ const rootRouteChildren: RootRouteChildren = {
AuthedRoute: AuthedRouteWithChildren,
AboutRoute: AboutRoute,
SigninRoute: SigninRoute,
+ StreamRoute: StreamRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
diff --git a/examples/tanstack-ssr-workers/src/routes/__root.tsx b/examples/tanstack-ssr-workers/src/routes/__root.tsx
index 628e2bbd..536f1379 100644
--- a/examples/tanstack-ssr-workers/src/routes/__root.tsx
+++ b/examples/tanstack-ssr-workers/src/routes/__root.tsx
@@ -9,6 +9,8 @@ import { useQuery, type QueryClient } from "@tanstack/react-query";
import { convexQuery, type ConvexQueryClient } from "@convex-dev/react-query";
import {
SessionProvider,
+ SignedIn,
+ SignedOut,
sessionBeforeLoad,
useSignOutSession,
} from "@samebase/convex-hosting-tanstack-start/convex-auth";
@@ -53,6 +55,10 @@ function Root() {
Home
About
Todos
+ {/* A document request: a navigation inside the app would run the loader in the browser. */}
+
+ Stream
+
@@ -65,13 +71,28 @@ function Root() {
}
function SessionHeader() {
- // `useQuery` does not fetch on the server, so the header of a prerendered page stays empty until
- // the browser has the answer.
+ // The server render knows the session from its token, so a rendered page carries the right
+ // header; a prerendered page has no token and shows its header once the browser knows.
+ return (
+ <>
+
+ Sign in
+
+
+
+
+ >
+ );
+}
+
+function SignedInHeader() {
+ // The root loader has queried the user for a page rendered as the user, so the name is in the
+ // cache; `useQuery` does not fetch on the server, so a prerendered page shows it when the browser
+ // has it.
const { data: me } = useQuery(convexQuery(api.users.me, {}));
const signOut = useSignOutSession("/auth/signout");
const [signOutFailed, setSignOutFailed] = useState(false);
- if (me === undefined) return null;
- if (me === null) return Sign in;
+ if (!me) return null;
return (
<>
{`Signed in as ${me.name}`}
diff --git a/examples/tanstack-ssr-workers/src/routes/stream.tsx b/examples/tanstack-ssr-workers/src/routes/stream.tsx
new file mode 100644
index 00000000..b7739f19
--- /dev/null
+++ b/examples/tanstack-ssr-workers/src/routes/stream.tsx
@@ -0,0 +1,30 @@
+import { Await, createFileRoute } from "@tanstack/react-router";
+
+export const Route = createFileRoute("/stream")({
+ loader: () => ({
+ // A promise the loader does not await. A host that streams sends the page at once, with the
+ // fallback below, and the value when the promise resolves; a host that buffers sends the whole
+ // page when the promise has resolved. Two seconds, so the two parts are telling apart.
+ later: new Promise((resolve) =>
+ setTimeout(() => resolve(new Date().toISOString()), 2000),
+ ),
+ }),
+ component: Stream,
+});
+
+function Stream() {
+ const { later } = Route.useLoaderData();
+ return (
+
+
Streaming
+
+ The loader of this page returns a promise that resolves two seconds later, without awaiting
+ it. The server sends what it has and the value when it arrives, or the whole page at the
+ end, depending on the host. The value is the time the promise resolved on the server.
+
+ Waiting for the value...
}>
+ {(value) =>
Resolved at {value}.
}
+
+
+ );
+}
diff --git a/examples/tanstack-ssr-workers/wrangler.jsonc b/examples/tanstack-ssr-workers/wrangler.jsonc
index 05142906..48c90a9e 100644
--- a/examples/tanstack-ssr-workers/wrangler.jsonc
+++ b/examples/tanstack-ssr-workers/wrangler.jsonc
@@ -1,7 +1,6 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"compatibility_date": "2026-10-01",
- "compatibility_flags": ["nodejs_compat"],
"main": "src/server.ts",
"preview_urls": true,
// `wrangler preview`, the Workers Builds preview command, requires this block.
diff --git a/examples/tanstack-ssr/src/routeTree.gen.ts b/examples/tanstack-ssr/src/routeTree.gen.ts
index d67f1306..a40b99e4 100644
--- a/examples/tanstack-ssr/src/routeTree.gen.ts
+++ b/examples/tanstack-ssr/src/routeTree.gen.ts
@@ -13,6 +13,7 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as AuthedRouteImport } from './routes/_authed'
import { Route as AboutRouteImport } from './routes/about'
import { Route as SigninRouteImport } from './routes/signin'
+import { Route as StreamRouteImport } from './routes/stream'
import { Route as AuthedTodosRouteImport } from './routes/_authed/todos'
const IndexRoute = IndexRouteImport.update({
@@ -34,6 +35,11 @@ const SigninRoute = SigninRouteImport.update({
path: '/signin',
getParentRoute: () => rootRouteImport,
} as any)
+const StreamRoute = StreamRouteImport.update({
+ id: '/stream',
+ path: '/stream',
+ getParentRoute: () => rootRouteImport,
+} as any)
const AuthedTodosRoute = AuthedTodosRouteImport.update({
id: '/todos',
path: '/todos',
@@ -44,12 +50,14 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/about': typeof AboutRoute
'/signin': typeof SigninRoute
+ '/stream': typeof StreamRoute
'/todos': typeof AuthedTodosRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/about': typeof AboutRoute
'/signin': typeof SigninRoute
+ '/stream': typeof StreamRoute
'/todos': typeof AuthedTodosRoute
}
export interface FileRoutesById {
@@ -58,14 +66,22 @@ export interface FileRoutesById {
'/_authed': typeof AuthedRouteWithChildren
'/about': typeof AboutRoute
'/signin': typeof SigninRoute
+ '/stream': typeof StreamRoute
'/_authed/todos': typeof AuthedTodosRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
- fullPaths: '/' | '/about' | '/signin' | '/todos'
+ fullPaths: '/' | '/about' | '/signin' | '/stream' | '/todos'
fileRoutesByTo: FileRoutesByTo
- to: '/' | '/about' | '/signin' | '/todos'
- id: '__root__' | '/' | '/_authed' | '/about' | '/signin' | '/_authed/todos'
+ to: '/' | '/about' | '/signin' | '/stream' | '/todos'
+ id:
+ | '__root__'
+ | '/'
+ | '/_authed'
+ | '/about'
+ | '/signin'
+ | '/stream'
+ | '/_authed/todos'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -73,6 +89,7 @@ export interface RootRouteChildren {
AuthedRoute: typeof AuthedRouteWithChildren
AboutRoute: typeof AboutRoute
SigninRoute: typeof SigninRoute
+ StreamRoute: typeof StreamRoute
}
declare module '@tanstack/react-router' {
@@ -105,6 +122,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SigninRouteImport
parentRoute: typeof rootRouteImport
}
+ '/stream': {
+ id: '/stream'
+ path: '/stream'
+ fullPath: '/stream'
+ preLoaderRoute: typeof StreamRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/_authed/todos': {
id: '/_authed/todos'
path: '/todos'
@@ -131,6 +155,7 @@ const rootRouteChildren: RootRouteChildren = {
AuthedRoute: AuthedRouteWithChildren,
AboutRoute: AboutRoute,
SigninRoute: SigninRoute,
+ StreamRoute: StreamRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
diff --git a/examples/tanstack-ssr/src/routes/__root.tsx b/examples/tanstack-ssr/src/routes/__root.tsx
index 628e2bbd..536f1379 100644
--- a/examples/tanstack-ssr/src/routes/__root.tsx
+++ b/examples/tanstack-ssr/src/routes/__root.tsx
@@ -9,6 +9,8 @@ import { useQuery, type QueryClient } from "@tanstack/react-query";
import { convexQuery, type ConvexQueryClient } from "@convex-dev/react-query";
import {
SessionProvider,
+ SignedIn,
+ SignedOut,
sessionBeforeLoad,
useSignOutSession,
} from "@samebase/convex-hosting-tanstack-start/convex-auth";
@@ -53,6 +55,10 @@ function Root() {
Home
About
Todos
+ {/* A document request: a navigation inside the app would run the loader in the browser. */}
+
+ Stream
+
@@ -65,13 +71,28 @@ function Root() {
}
function SessionHeader() {
- // `useQuery` does not fetch on the server, so the header of a prerendered page stays empty until
- // the browser has the answer.
+ // The server render knows the session from its token, so a rendered page carries the right
+ // header; a prerendered page has no token and shows its header once the browser knows.
+ return (
+ <>
+
+ Sign in
+
+
+
+
+ >
+ );
+}
+
+function SignedInHeader() {
+ // The root loader has queried the user for a page rendered as the user, so the name is in the
+ // cache; `useQuery` does not fetch on the server, so a prerendered page shows it when the browser
+ // has it.
const { data: me } = useQuery(convexQuery(api.users.me, {}));
const signOut = useSignOutSession("/auth/signout");
const [signOutFailed, setSignOutFailed] = useState(false);
- if (me === undefined) return null;
- if (me === null) return Sign in;
+ if (!me) return null;
return (
<>
{`Signed in as ${me.name}`}
diff --git a/examples/tanstack-ssr/src/routes/stream.tsx b/examples/tanstack-ssr/src/routes/stream.tsx
new file mode 100644
index 00000000..b7739f19
--- /dev/null
+++ b/examples/tanstack-ssr/src/routes/stream.tsx
@@ -0,0 +1,30 @@
+import { Await, createFileRoute } from "@tanstack/react-router";
+
+export const Route = createFileRoute("/stream")({
+ loader: () => ({
+ // A promise the loader does not await. A host that streams sends the page at once, with the
+ // fallback below, and the value when the promise resolves; a host that buffers sends the whole
+ // page when the promise has resolved. Two seconds, so the two parts are telling apart.
+ later: new Promise((resolve) =>
+ setTimeout(() => resolve(new Date().toISOString()), 2000),
+ ),
+ }),
+ component: Stream,
+});
+
+function Stream() {
+ const { later } = Route.useLoaderData();
+ return (
+
+
Streaming
+
+ The loader of this page returns a promise that resolves two seconds later, without awaiting
+ it. The server sends what it has and the value when it arrives, or the whole page at the
+ end, depending on the host. The value is the time the promise resolved on the server.
+
+ Waiting for the value...}>
+ {(value) =>
Resolved at {value}.
}
+
+
+ );
+}
diff --git a/packages/convex-hosting-tanstack-start/CHANGELOG.md b/packages/convex-hosting-tanstack-start/CHANGELOG.md
index 57480a08..0cd97525 100644
--- a/packages/convex-hosting-tanstack-start/CHANGELOG.md
+++ b/packages/convex-hosting-tanstack-start/CHANGELOG.md
@@ -1,5 +1,14 @@
# Changelog
+## 0.9.0
+
+- Pages stream, as on a Cloudflare Worker: the server entry is TanStack's own stream handler, the
+ document goes out as it renders, and each Suspense boundary or deferred loader value follows in
+ its own chunk. The entry no longer waits for the whole render, and the `progressiveChunkSize`
+ override of 0.6.0 is gone with it. `convexSsr()` resolves `node:stream` to a stub for the server
+ build: TanStack imports Node's stream classes for a branch that never runs where React's Web
+ Stream renderer exists, and Convex's bundler had nothing to resolve them to.
+
## 0.8.0
- The published manifest no longer lists `vite` in `devDependencies`. 0.5.0 to 0.7.0 listed it as
diff --git a/packages/convex-hosting-tanstack-start/README.md b/packages/convex-hosting-tanstack-start/README.md
index 82ee2c4a..0cd64f2c 100644
--- a/packages/convex-hosting-tanstack-start/README.md
+++ b/packages/convex-hosting-tanstack-start/README.md
@@ -209,11 +209,13 @@ ignore the unmet-peer warning that pnpm prints; npm refuses the install instead,
- `worker` selects TanStack's server exports; `browser` selects Web API adapters.
- `react-dom/server.edge` avoids the browser renderer's `MessageChannel` dependency.
- `ssr.noExternal` bundles dependencies instead of leaving runtime Node imports.
-- React's Web Stream renderer waits for Suspense, then TanStack's `renderSsrHtmlResponse` handles
- the buffered HTML, hydration scripts, status, and cleanup. The default streaming handler imports
- Node stream modules that Convex cannot bundle.
-- React emits a doctype and TanStack's HTML helper adds its own, so the server entry removes
- React's.
+- `node:stream` resolves to a stub. TanStack's stream renderer imports Node's stream classes at the
+ top of its files for a branch that runs only where React has no Web Stream renderer; on Convex
+ that renderer exists, the branch never runs, and the bundler has no `node:stream`. The stub's
+ classes throw if anything calls them.
+- The server entry is TanStack's own stream handler, so pages stream as they do on a Worker: the
+ document goes out as it renders, and each Suspense boundary or deferred loader value follows in
+ its own chunk. A client that TanStack takes for a bot, by its user agent, gets the whole document.
- The server entry supplies the missing `URLSearchParams.size` getter on Convex. TanStack needs it
to normalize URLs with query parameters. Native implementations are left intact.
diff --git a/packages/convex-hosting-tanstack-start/package.json b/packages/convex-hosting-tanstack-start/package.json
index cac90dc2..042d393e 100644
--- a/packages/convex-hosting-tanstack-start/package.json
+++ b/packages/convex-hosting-tanstack-start/package.json
@@ -1,6 +1,6 @@
{
"name": "@samebase/convex-hosting-tanstack-start",
- "version": "0.8.0",
+ "version": "0.9.0",
"description": "Render TanStack Start pages in a Convex HTTP action.",
"license": "Apache-2.0",
"repository": {
diff --git a/packages/convex-hosting-tanstack-start/src/node-stream.ts b/packages/convex-hosting-tanstack-start/src/node-stream.ts
new file mode 100644
index 00000000..47bc7b04
--- /dev/null
+++ b/packages/convex-hosting-tanstack-start/src/node-stream.ts
@@ -0,0 +1,26 @@
+// What `node:stream` resolves to in the server build. TanStack's stream renderer imports Node's
+// stream classes at the top of its files for its Node branch, which runs only where React has no
+// Web Stream renderer; on Convex, React's `renderToReadableStream` exists, so that branch never
+// runs, and Convex's bundler has no `node:stream` to resolve. These classes are never called.
+
+function notOnConvex(): never {
+ throw new Error(
+ "node:stream is not available on Convex; TanStack's Web Stream path does not use it.",
+ );
+}
+
+export class Readable {
+ static toWeb(): never {
+ return notOnConvex();
+ }
+
+ static fromWeb(): never {
+ return notOnConvex();
+ }
+}
+
+export class PassThrough {
+ constructor() {
+ notOnConvex();
+ }
+}
diff --git a/packages/convex-hosting-tanstack-start/src/server.ts b/packages/convex-hosting-tanstack-start/src/server.ts
index e7ce3324..c643072e 100644
--- a/packages/convex-hosting-tanstack-start/src/server.ts
+++ b/packages/convex-hosting-tanstack-start/src/server.ts
@@ -3,10 +3,11 @@
// TanStack and React, never app code.
import type { Register } from "@tanstack/react-start";
-import { createStartHandler, StartServer, type RequestHandler } from "@tanstack/react-start/server";
-import { renderSsrHtmlResponse } from "@tanstack/react-router/ssr/server";
-import { createElement } from "react";
-import { renderToReadableStream } from "react-dom/server";
+import {
+ createStartHandler,
+ defaultStreamHandler,
+ type RequestHandler,
+} from "@tanstack/react-start/server";
// Convex omits size, which TanStack uses when normalizing query-string URLs.
if (new URLSearchParams().size === undefined) {
@@ -18,33 +19,14 @@ if (new URLSearchParams().size === undefined) {
});
}
+// TanStack's own stream handler, as on every other host: React's Web Stream renderer, the
+// document sent as it renders, each Suspense boundary and deferred loader value in a later chunk.
// Apps call only `fetch(request, { context })`. Start gives `context` to each route's `beforeLoad`
// and loader on the server as `serverContext`. `context` follows the app's `Register`: with a
// registered `requestContext`, such as the `convex-auth` entry's, it is required and has that type.
// The explicit annotation is what the declaration emits, so it names only public entries.
const server: { fetch: RequestHandler } = {
- fetch: createStartHandler((context) => {
- const { router, responseHeaders, request } = context;
- return renderSsrHtmlResponse({
- router,
- responseHeaders,
- render: async () => {
- // Await Suspense with React's Web Stream renderer. TanStack's stream
- // handler also imports node:stream, which Convex cannot bundle.
- const stream = await renderToReadableStream(createElement(StartServer, { router }), {
- signal: request.signal,
- // The document is sent only when it is complete, so React's progressive reveal of a
- // large Suspense boundary (a hidden segment plus a script) would add bytes and a frame.
- progressiveChunkSize: Number.POSITIVE_INFINITY,
- ...(router.options.ssr?.nonce === undefined ? {} : { nonce: router.options.ssr.nonce }),
- });
- await stream.allReady;
- const html = await new Response(stream).text();
- // React emits a doctype; TanStack's HTML helper adds its own.
- return html.replace(/^/i, "");
- },
- });
- }),
+ fetch: createStartHandler(defaultStreamHandler),
};
export default server;
diff --git a/packages/convex-hosting-tanstack-start/src/vite.ts b/packages/convex-hosting-tanstack-start/src/vite.ts
index b9de1f16..f5148e76 100644
--- a/packages/convex-hosting-tanstack-start/src/vite.ts
+++ b/packages/convex-hosting-tanstack-start/src/vite.ts
@@ -1,5 +1,6 @@
// Vite settings that let TanStack Start's server build run in Convex HTTP actions.
+import { fileURLToPath } from "node:url";
import type { Plugin } from "vite";
export function convexSsr(): Plugin {
@@ -12,7 +13,15 @@ export function convexSsr(): Plugin {
resolve: { conditions: ["worker", "browser", "module", "import"] },
},
resolve: {
- alias: [{ find: /^react-dom\/server$/, replacement: "react-dom/server.edge" }],
+ alias: [
+ { find: /^react-dom\/server$/, replacement: "react-dom/server.edge" },
+ // TanStack's stream renderer imports Node's stream classes for a branch that never runs
+ // where React's Web Stream renderer exists; Convex has no `node:stream` to resolve.
+ {
+ find: /^node:stream$/,
+ replacement: fileURLToPath(new URL("./node-stream.js", import.meta.url)),
+ },
+ ],
},
}),
};