diff --git a/.github/workflows/check-sdk.yml b/.github/workflows/check-sdk.yml index 08ed59a8f..687cce36a 100644 --- a/.github/workflows/check-sdk.yml +++ b/.github/workflows/check-sdk.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - stable paths: - "sdk/**" pull_request: diff --git a/.github/workflows/check-starters.yml b/.github/workflows/check-starters.yml index da3c20cff..81a0274dc 100644 --- a/.github/workflows/check-starters.yml +++ b/.github/workflows/check-starters.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - stable paths-ignore: - "docs/**" pull_request: diff --git a/.github/workflows/smoke-test-starters.yml b/.github/workflows/smoke-test-starters.yml index fc43f8656..1848ce2b3 100644 --- a/.github/workflows/smoke-test-starters.yml +++ b/.github/workflows/smoke-test-starters.yml @@ -4,11 +4,13 @@ on: push: branches: - main + - stable paths-ignore: - "docs/**" pull_request: branches: - main + - stable paths-ignore: - "docs/**" workflow_dispatch: 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 fd86616eb..4e57648ab 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 d77f867d0..71f416bf1 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; @@ -58,6 +62,7 @@ export const defineApp = = { @@ -142,17 +147,33 @@ export const defineApp = ( - - ), - nonce: rw.nonce, - }); + let html: ReadableStream; + + if (rw.ssr) { + html = await transformRscToHtmlStream({ + stream: rscPayloadStream1, + Parent: ({ children }) => ( + + ), + nonce: rw.nonce, + }); + } else { + const emptyRscStream = renderToRscStream({ + node: React.createElement(React.Fragment, null, null), + actionResult: undefined, + onError, + }); + html = await transformRscToHtmlStream({ + stream: emptyRscStream, + Parent: ({ children }) => ( + + ), + nonce: rw.nonce, + }); + } - let html: ReadableStream = htmlStream; if (rw.rscPayload) { - html = htmlStream.pipeThrough( + html = html.pipeThrough( injectRSCPayload(rscPayloadStream2, { nonce: rw.nonce, }),