diff --git a/docs/src/content/docs/reference/sdk-router.mdx b/docs/src/content/docs/reference/sdk-router.mdx index fc40e9dae..5038cd458 100644 --- a/docs/src/content/docs/reference/sdk-router.mdx +++ b/docs/src/content/docs/reference/sdk-router.mdx @@ -48,7 +48,14 @@ This will match `/user/login` and `/user/logout` ## render The `render` function is used to statically render the contents of a JSX element. It cannot contain any dynamic content. Use this to control the output of your HTML. -The `rscPayload` option is used to toggle the RSC payload that's appended to the Document. Disabling this will mean that interactivity can no longer work. Your document should not include any client side initialization. + +### Options + +The `render` function accepts an optional third parameter with the following options: + +- **`rscPayload`** (boolean, default: `true`) - Toggle the RSC payload that's appended to the Document. Disabling this will mean that interactivity can no longer work. Your document should not include any client side initialization. + +- **`ssr`** (boolean, default: `true`) - Enable or disable server-side rendering for these routes. When disabled, only client-side rendering is used, which requires `rscPayload` to be enabled. With SSR disabled, the server returns a minimal HTML shell with the RSC payload, allowing the client to hydrate the page. ```tsx import { render } from "rwsdk/router"; @@ -58,9 +65,16 @@ import { StaticDocument } from "@/app/Document"; import { routes as appRoutes } from "@/app/pages/app/routes"; import { routes as docsRoutes } from "@/app/pages/docs/routes"; +import { routes as spaRoutes } from "@/app/pages/spa/routes"; export default defineApp([ + // Default: SSR enabled with RSC payload render(ReactDocument, [prefix("/app", appRoutes)]), + + // Static rendering: SSR enabled, RSC payload disabled render(StaticDocument, [prefix("/docs", docsRoutes)], { rscPayload: false }), + + // Client-side only: SSR disabled, RSC payload enabled + render(ReactDocument, [prefix("/spa", spaRoutes)], { ssr: false }), ]); ``` diff --git a/sdk/src/runtime/lib/router.ts b/sdk/src/runtime/lib/router.ts index 4e758083b..d94c22dbe 100644 --- a/sdk/src/runtime/lib/router.ts +++ b/sdk/src/runtime/lib/router.ts @@ -15,6 +15,7 @@ export type RwContext = { nonce: string; Document: React.FC>; rscPayload: boolean; + ssr: boolean; layouts?: React.FC>[]; }; @@ -27,7 +28,9 @@ export type RouteMiddleware = ( | Promise | Promise; -type RouteFunction = (requestInfo: T) => Response | Promise; +type RouteFunction = ( + requestInfo: T, +) => Response | Promise; type MaybePromise = T | Promise; @@ -40,7 +43,10 @@ type RouteHandler = | RouteComponent | [...RouteMiddleware[], RouteFunction | RouteComponent]; -export type Route = RouteMiddleware | RouteDefinition | Array>; +export type Route = + | RouteMiddleware + | RouteDefinition + | Array>; export type RouteDefinition = { path: string; @@ -123,7 +129,9 @@ export function matchPath( return params; } -function flattenRoutes(routes: Route[]): (RouteMiddleware | RouteDefinition)[] { +function flattenRoutes( + routes: Route[], +): (RouteMiddleware | RouteDefinition)[] { return routes.reduce((acc: Route[], route) => { if (Array.isArray(route)) { return [...acc, ...flattenRoutes(route)]; @@ -132,7 +140,9 @@ function flattenRoutes(routes: Route[]): }, []) as (RouteMiddleware | RouteDefinition)[]; } -export function defineRoutes(routes: Route[]): { +export function defineRoutes( + routes: Route[], +): { routes: Route[]; handle: ({ request, @@ -230,7 +240,10 @@ export function defineRoutes(routes: Route< }; } -export function route(path: string, handler: RouteHandler): RouteDefinition { +export function route( + path: string, + handler: RouteHandler, +): RouteDefinition { if (!path.endsWith("/")) { path = path + "/"; } @@ -241,11 +254,16 @@ export function route(path: string, handler }; } -export function index(handler: RouteHandler): RouteDefinition { +export function index( + handler: RouteHandler, +): RouteDefinition { return route("/", handler); } -export function prefix(prefixPath: string, routes: Route[]): Route[] { +export function prefix( + prefixPath: string, + routes: Route[], +): Route[] { return routes.map((r) => { if (typeof r === "function") { // Pass through middleware as-is @@ -274,7 +292,10 @@ function wrapWithLayouts( } // Check if the final route component is a client component - const isRouteClientComponent = Object.prototype.hasOwnProperty.call(Component, "$$isClientReference"); + const isRouteClientComponent = Object.prototype.hasOwnProperty.call( + Component, + "$$isClientReference", + ); // Create nested layout structure - layouts[0] should be outermost, so use reduceRight return layouts.reduceRight((WrappedComponent, Layout) => { @@ -285,7 +306,10 @@ function wrapWithLayouts( ); return React.createElement(Layout, { - children: React.createElement(WrappedComponent, isRouteClientComponent ? {} : props), + children: React.createElement( + WrappedComponent, + isRouteClientComponent ? {} : props, + ), // Only pass requestInfo to server components to avoid serialization issues ...(isClientComponent ? {} : { requestInfo }), }); @@ -322,14 +346,23 @@ export function render( /** * @param options - Configuration options for rendering. * @param options.rscPayload - Toggle the RSC payload that's appended to the Document. Disabling this will mean that interactivity no longer works. + * @param options.ssr - Disable sever side rendering for all these routes. This only allow client side rendering`, which requires `rscPayload` to be enabled. */ options: { - rscPayload: boolean; - } = { rscPayload: true }, + rscPayload?: boolean; + ssr?: boolean; + } = {}, ): Route[] { + options = { + rscPayload: true, + ssr: true, + ...options, + }; + const documentMiddleware: RouteMiddleware = ({ rw }) => { rw.Document = Document; - rw.rscPayload = options.rscPayload; + rw.rscPayload = options.rscPayload ?? true; + rw.ssr = options.ssr ?? true; }; return [documentMiddleware, ...routes]; diff --git a/sdk/src/runtime/worker.tsx b/sdk/src/runtime/worker.tsx index 3afc9ee4f..eca625238 100644 --- a/sdk/src/runtime/worker.tsx +++ b/sdk/src/runtime/worker.tsx @@ -23,7 +23,11 @@ declare global { DB: D1Database; }; } -export const defineApp = >(routes: Route[]) => { +export const defineApp = < + T extends RequestInfo = RequestInfo, +>( + routes: Route[], +) => { return { fetch: async (request: Request, env: Env, cf: ExecutionContext) => { globalThis.__webpack_require__ = ssrWebpackRequire; @@ -60,6 +64,7 @@ export const defineApp = = { @@ -144,15 +149,29 @@ export const defineApp = ; + + if (rw.ssr) { + html = await transformRscToHtmlStream({ + stream: rscPayloadStream1, + Document: rw.Document, + requestInfo: requestInfo, + }); + } else { + const emptyRscStream = renderToRscStream({ + node: React.createElement(React.Fragment, null, null), + actionResult: undefined, + onError, + }); + html = await transformRscToHtmlStream({ + stream: emptyRscStream, + Document: rw.Document, + requestInfo: requestInfo, + }); + } - let html: ReadableStream = htmlStream; if (rw.rscPayload) { - html = htmlStream.pipeThrough( + html = html.pipeThrough( injectRSCPayload(rscPayloadStream2, { nonce: rw.nonce, }),