Skip to content

Port #512 and #522 to stable #523

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/check-sdk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
push:
branches:
- main
- stable
paths:
- "sdk/**"
pull_request:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/check-starters.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
push:
branches:
- main
- stable
paths-ignore:
- "docs/**"
pull_request:
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/smoke-test-starters.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ on:
push:
branches:
- main
- stable
paths-ignore:
- "docs/**"
pull_request:
branches:
- main
- stable
paths-ignore:
- "docs/**"
workflow_dispatch:
Expand Down
16 changes: 15 additions & 1 deletion docs/src/content/docs/reference/sdk-router.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 }),
]);
```
57 changes: 45 additions & 12 deletions sdk/src/runtime/lib/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type RwContext = {
nonce: string;
Document: React.FC<DocumentProps<any>>;
rscPayload: boolean;
ssr: boolean;
layouts?: React.FC<LayoutProps<any>>[];
};

Expand All @@ -27,7 +28,9 @@ export type RouteMiddleware<T extends RequestInfo = RequestInfo> = (
| Promise<void>
| Promise<Response | void>;

type RouteFunction<T extends RequestInfo = RequestInfo> = (requestInfo: T) => Response | Promise<Response>;
type RouteFunction<T extends RequestInfo = RequestInfo> = (
requestInfo: T,
) => Response | Promise<Response>;

type MaybePromise<T> = T | Promise<T>;

Expand All @@ -40,7 +43,10 @@ type RouteHandler<T extends RequestInfo = RequestInfo> =
| RouteComponent<T>
| [...RouteMiddleware<T>[], RouteFunction<T> | RouteComponent<T>];

export type Route<T extends RequestInfo = RequestInfo> = RouteMiddleware<T> | RouteDefinition<T> | Array<Route<T>>;
export type Route<T extends RequestInfo = RequestInfo> =
| RouteMiddleware<T>
| RouteDefinition<T>
| Array<Route<T>>;

export type RouteDefinition<T extends RequestInfo = RequestInfo> = {
path: string;
Expand Down Expand Up @@ -123,7 +129,9 @@ export function matchPath<T extends RequestInfo = RequestInfo>(
return params;
}

function flattenRoutes<T extends RequestInfo = RequestInfo>(routes: Route<T>[]): (RouteMiddleware<T> | RouteDefinition<T>)[] {
function flattenRoutes<T extends RequestInfo = RequestInfo>(
routes: Route<T>[],
): (RouteMiddleware<T> | RouteDefinition<T>)[] {
return routes.reduce((acc: Route<T>[], route) => {
if (Array.isArray(route)) {
return [...acc, ...flattenRoutes(route)];
Expand All @@ -132,7 +140,9 @@ function flattenRoutes<T extends RequestInfo = RequestInfo>(routes: Route<T>[]):
}, []) as (RouteMiddleware<T> | RouteDefinition<T>)[];
}

export function defineRoutes<T extends RequestInfo = RequestInfo>(routes: Route<T>[]): {
export function defineRoutes<T extends RequestInfo = RequestInfo>(
routes: Route<T>[],
): {
routes: Route<T>[];
handle: ({
request,
Expand Down Expand Up @@ -230,7 +240,10 @@ export function defineRoutes<T extends RequestInfo = RequestInfo>(routes: Route<
};
}

export function route<T extends RequestInfo = RequestInfo>(path: string, handler: RouteHandler<T>): RouteDefinition<T> {
export function route<T extends RequestInfo = RequestInfo>(
path: string,
handler: RouteHandler<T>,
): RouteDefinition<T> {
if (!path.endsWith("/")) {
path = path + "/";
}
Expand All @@ -241,11 +254,16 @@ export function route<T extends RequestInfo = RequestInfo>(path: string, handler
};
}

export function index<T extends RequestInfo = RequestInfo>(handler: RouteHandler<T>): RouteDefinition<T> {
export function index<T extends RequestInfo = RequestInfo>(
handler: RouteHandler<T>,
): RouteDefinition<T> {
return route("/", handler);
}

export function prefix<T extends RequestInfo = RequestInfo>(prefixPath: string, routes: Route<T>[]): Route<T>[] {
export function prefix<T extends RequestInfo = RequestInfo>(
prefixPath: string,
routes: Route<T>[],
): Route<T>[] {
return routes.map((r) => {
if (typeof r === "function") {
// Pass through middleware as-is
Expand Down Expand Up @@ -274,7 +292,10 @@ function wrapWithLayouts<T extends RequestInfo = RequestInfo>(
}

// 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) => {
Expand All @@ -285,7 +306,10 @@ function wrapWithLayouts<T extends RequestInfo = RequestInfo>(
);

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 }),
});
Expand Down Expand Up @@ -322,14 +346,23 @@ export function render<T extends RequestInfo = RequestInfo>(
/**
* @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<T>[] {
options = {
rscPayload: true,
ssr: true,
...options,
};

const documentMiddleware: RouteMiddleware<T> = ({ rw }) => {
rw.Document = Document;
rw.rscPayload = options.rscPayload;
rw.rscPayload = options.rscPayload ?? true;
rw.ssr = options.ssr ?? true;
};

return [documentMiddleware, ...routes];
Expand Down
41 changes: 31 additions & 10 deletions sdk/src/runtime/worker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ declare global {
DB: D1Database;
};
}
export const defineApp = <T extends RequestInfo = RequestInfo<any, DefaultAppContext>>(routes: Route<T>[]) => {
export const defineApp = <
T extends RequestInfo = RequestInfo<any, DefaultAppContext>,
>(
routes: Route<T>[],
) => {
return {
fetch: async (request: Request, env: Env, cf: ExecutionContext) => {
globalThis.__webpack_require__ = ssrWebpackRequire;
Expand Down Expand Up @@ -58,6 +62,7 @@ export const defineApp = <T extends RequestInfo = RequestInfo<any, DefaultAppCon
Document: DefaultDocument,
nonce: generateNonce(),
rscPayload: true,
ssr: true,
};

const outerRequestInfo: RequestInfo<any, T["ctx"]> = {
Expand Down Expand Up @@ -142,17 +147,33 @@ export const defineApp = <T extends RequestInfo = RequestInfo<any, DefaultAppCon

const [rscPayloadStream1, rscPayloadStream2] = rscPayloadStream.tee();

const htmlStream = await transformRscToHtmlStream({
stream: rscPayloadStream1,
Parent: ({ children }) => (
<rw.Document {...requestInfo} children={children} />
),
nonce: rw.nonce,
});
let html: ReadableStream<any>;

if (rw.ssr) {
html = await transformRscToHtmlStream({
stream: rscPayloadStream1,
Parent: ({ children }) => (
<rw.Document {...requestInfo} children={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 }) => (
<rw.Document {...requestInfo} children={children} />
),
nonce: rw.nonce,
});
}

let html: ReadableStream<any> = htmlStream;
if (rw.rscPayload) {
html = htmlStream.pipeThrough(
html = html.pipeThrough(
injectRSCPayload(rscPayloadStream2, {
nonce: rw.nonce,
}),
Expand Down