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)), + }, + ], }, }), };