@real-router/rsc-server-plugin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,148 @@
1
+ import { getPluginApi } from "@real-router/core/api";
2
+
3
+ import { ERROR_PREFIX } from "./constants";
4
+
5
+ import type { RscActionResult } from "./types";
6
+ import type {
7
+ DefaultDependencies,
8
+ Plugin,
9
+ PluginFactory,
10
+ } from "@real-router/types";
11
+
12
+ /**
13
+ * Per-start runtime validator for `getResult()` return values.
14
+ *
15
+ * Returns `null` when the value is acceptable (typed `RscActionResult`,
16
+ * non-thenable, non-array, non-null object). Otherwise returns a short
17
+ * descriptor used in the thrown `TypeError` message — keeps the error
18
+ * actionable by pointing at the exact failure mode (`"null"`, `"array"`,
19
+ * `"Promise/thenable — wire your action result synchronously"`, or the
20
+ * raw `typeof` for primitives).
21
+ *
22
+ * Single source of truth for the two-decision pattern: "throw or
23
+ * accept" + "what to say in the error". Previously the same checks
24
+ * lived inline at the call site AND in `describeBadResult` — a typo in
25
+ * one would silently break the symmetry. Unifying as one classifier
26
+ * eliminates that drift class.
27
+ */
28
+ function classifyRscActionResult(value: unknown): string | null {
29
+ if (value === null) {
30
+ return "null";
31
+ }
32
+ if (Array.isArray(value)) {
33
+ return "array";
34
+ }
35
+ if (typeof value !== "object") {
36
+ return typeof value;
37
+ }
38
+ if (typeof (value as { then?: unknown }).then === "function") {
39
+ return "Promise/thenable — wire your action result synchronously";
40
+ }
41
+
42
+ return null;
43
+ }
44
+
45
+ /**
46
+ * Plugin factory that publishes a Server Action result to
47
+ * `state.context.rscAction`. Pair with `rscServerPluginFactory` —
48
+ * the `"rsc"` and `"rscAction"` namespaces are independent and the
49
+ * two plugins coexist on the same router.
50
+ *
51
+ * The factory takes a `getResult` resolver evaluated at start-time
52
+ * (inside the `start` interceptor, after the route resolves but
53
+ * before the caller reads `state`). The caller has the action result
54
+ * in scope (e.g. computed by `decodeAction` + `loadServerAction` in
55
+ * their fetch handler) and returns it from the closure:
56
+ *
57
+ * @example
58
+ * ```ts
59
+ * let actionResult: RscActionResult | undefined;
60
+ *
61
+ * if (request.method === "POST") {
62
+ * const decoded = await decodeAction(formData);
63
+ * actionResult = { returnValue: { ok: true, data: await decoded() } };
64
+ * }
65
+ *
66
+ * router.usePlugin(
67
+ * rscServerPluginFactory(loaders),
68
+ * rscActionPluginFactory(() => actionResult),
69
+ * );
70
+ *
71
+ * const state = await router.start(pathname);
72
+ * // state.context.rscAction === actionResult (or undefined)
73
+ * ```
74
+ *
75
+ * When `getResult()` returns `undefined`, the interceptor skips the
76
+ * write — `state.context.rscAction` stays `undefined`. Useful for
77
+ * GET requests where there's no action to surface.
78
+ *
79
+ * The result is JSON-friendly (no ReactNode), so it serializes via
80
+ * `serializeRouterState(state)` without needing `excludeContext`.
81
+ * If you want to keep it server-side only (e.g. action result
82
+ * contains secrets), pass `excludeContext: ["rsc", "rscAction"]`.
83
+ */
84
+ export function rscActionPluginFactory<
85
+ TReturn = unknown,
86
+ TFormState = unknown,
87
+ Dependencies extends DefaultDependencies = DefaultDependencies,
88
+ >(
89
+ getResult: () => RscActionResult<TReturn, TFormState> | undefined,
90
+ ): PluginFactory<Dependencies> {
91
+ // Mirror the factory-time validation that `rscServerPluginFactory` and
92
+ // `ssrDataPluginFactory` already perform on their loaders map: a TS-cast
93
+ // bypass or a JS consumer can smuggle a non-function through, and the
94
+ // failure would otherwise surface much later inside the start interceptor
95
+ // as `TypeError: getResult is not a function`, after the `"rscAction"`
96
+ // namespace has already been claimed and the start interceptor has been
97
+ // registered. Failing eagerly with a typed, prefixed error keeps the API
98
+ // consistent across all factories in this package.
99
+ if (typeof getResult !== "function") {
100
+ throw new TypeError(`${ERROR_PREFIX} getResult must be a function`);
101
+ }
102
+
103
+ return (router): Plugin => {
104
+ const api = getPluginApi(router);
105
+ const claim = api.claimContextNamespace("rscAction");
106
+
107
+ const removeStartInterceptor = api.addInterceptor(
108
+ "start",
109
+ async (next, path) => {
110
+ const state = await next(path);
111
+ // Read as `unknown`: the TS contract pins it to RscActionResult, but
112
+ // we run a defensive shape guard below for cast-bypassed garbage.
113
+ const result: unknown = getResult();
114
+
115
+ if (result === undefined) {
116
+ return state;
117
+ }
118
+
119
+ // Symmetry-with-loaders runtime guard. The TS contract is
120
+ // `() => RscActionResult | undefined`, but the most common consumer
121
+ // mistake is wiring an `async` getResult — TS allows it via cast,
122
+ // and the resulting Promise would land in `state.context.rscAction`
123
+ // and break every downstream `result.returnValue` access. Single
124
+ // classifier — `classifyRscActionResult` is the source of truth for
125
+ // BOTH the accept/reject decision AND the error description, so a
126
+ // change to one cannot drift from the other.
127
+ const badShape = classifyRscActionResult(result);
128
+
129
+ if (badShape !== null) {
130
+ throw new TypeError(
131
+ `${ERROR_PREFIX} getResult must return an RscActionResult object or undefined (got ${badShape})`,
132
+ );
133
+ }
134
+
135
+ claim.write(state, result as RscActionResult<TReturn, TFormState>);
136
+
137
+ return state;
138
+ },
139
+ );
140
+
141
+ return {
142
+ teardown() {
143
+ removeStartInterceptor();
144
+ claim.release();
145
+ },
146
+ };
147
+ };
148
+ }
@@ -0,0 +1,64 @@
1
+ import type { RscActionResult, RscPayload } from "./types";
2
+ import type { State } from "@real-router/types";
3
+ import type { ReactNode } from "react";
4
+
5
+ /**
6
+ * Build a canonical Flight payload from `state.context.rsc` (+ optional
7
+ * Server Component override) and `state.context.rscAction`.
8
+ *
9
+ * Removes the repeated `{ root, returnValue, formState }` boilerplate at
10
+ * the call site:
11
+ *
12
+ * ```ts
13
+ * import { renderToReadableStream } from "@vitejs/plugin-rsc/rsc";
14
+ * const flight = renderToReadableStream(buildRscPayload(state));
15
+ * ```
16
+ *
17
+ * Pass `rootOverride` to wrap the per-route Server Component tree (e.g.
18
+ * with cross-cutting layout chrome) without rebuilding the payload by
19
+ * hand:
20
+ *
21
+ * ```ts
22
+ * const wrapped = (
23
+ * <>
24
+ * <NotificationBanner action={state.context.rscAction} />
25
+ * {state.context.rsc}
26
+ * </>
27
+ * );
28
+ * const payload = buildRscPayload<MyData, ReactFormState>(state, wrapped);
29
+ * ```
30
+ *
31
+ * `rootOverride === undefined` means "use the default" (`state.context.rsc`).
32
+ * Pass `null` to explicitly render nothing — `null` is a valid `ReactNode`
33
+ * and is preserved as-is, **not** treated as "fall back to default".
34
+ *
35
+ * `returnValue` and `formState` are **omitted** (not set to `undefined`)
36
+ * when their source is missing, so the result type-checks under
37
+ * `exactOptionalPropertyTypes: true` consumers without ceremony.
38
+ */
39
+ export function buildRscPayload<TReturn = unknown, TFormState = unknown>(
40
+ state: State,
41
+ rootOverride?: ReactNode,
42
+ ): RscPayload<TReturn, TFormState> {
43
+ const ctx = state.context as {
44
+ rsc?: ReactNode;
45
+ rscAction?: RscActionResult<TReturn, TFormState>;
46
+ };
47
+
48
+ // `??` would collapse an explicit `null` override to the default — use a
49
+ // strict `=== undefined` check so callers can render nothing on purpose.
50
+ const root = rootOverride === undefined ? ctx.rsc : rootOverride;
51
+
52
+ const payload: RscPayload<TReturn, TFormState> = { root };
53
+ const action = ctx.rscAction;
54
+
55
+ if (action?.returnValue !== undefined) {
56
+ payload.returnValue = action.returnValue;
57
+ }
58
+
59
+ if (action?.formState !== undefined) {
60
+ payload.formState = action.formState;
61
+ }
62
+
63
+ return payload;
64
+ }
@@ -0,0 +1,16 @@
1
+ import type { RscSsrMode } from "./types";
2
+
3
+ const LOGGER_CONTEXT = "rsc-server-plugin";
4
+
5
+ export const ERROR_PREFIX = `[@real-router/${LOGGER_CONTEXT}]`;
6
+
7
+ /**
8
+ * The strict subset of `SsrMode` that `rsc-server-plugin` accepts.
9
+ * `"data-only"` is intentionally excluded — RSC has no semantically meaningful
10
+ * "data without component" (the Flight payload IS the data + component).
11
+ *
12
+ * Single source of truth for `factory.ts` (`createSsrLoaderPlugin` allowedModes),
13
+ * `validation.ts` (factory-time loader-map validator), and `getSsrRscMode.ts`
14
+ * (runtime read-side guard against TS-cast-bypassed garbage in `state.context`).
15
+ */
16
+ export const ALLOWED_RSC_MODES: readonly RscSsrMode[] = ["full", "client-only"];
package/src/errors.ts ADDED
@@ -0,0 +1,6 @@
1
+ export {
2
+ LoaderRedirect,
3
+ LoaderNotFound,
4
+ LoaderTimeout,
5
+ withTimeout,
6
+ } from "./shared-ssr/errors";
package/src/factory.ts ADDED
@@ -0,0 +1,56 @@
1
+ import { ALLOWED_RSC_MODES, ERROR_PREFIX } from "./constants";
2
+ import { createLoadersValidator, createSsrLoaderPlugin } from "./shared-ssr";
3
+
4
+ import type { RscLoaderFactoryMap } from "./types";
5
+ import type { DefaultDependencies, PluginFactory } from "@real-router/types";
6
+ import type { ReactNode } from "react";
7
+
8
+ // Inlined from the deleted validation.ts — single 7-line consumer was
9
+ // here, no other importer in src/ or tests/, so the indirection was
10
+ // pure ceremony.
11
+ const validateLoaders = createLoadersValidator(ERROR_PREFIX, ALLOWED_RSC_MODES);
12
+
13
+ /**
14
+ * Plugin factory that loads per-route `ReactNode` (RSC payload) by intercepting
15
+ * `router.start()`. Variant B from the RSC integration RFC: the plugin stores a
16
+ * `ReactNode` on `state.context.rsc` — it does NOT render Flight bytes itself.
17
+ *
18
+ * The caller is responsible for piping the published `ReactNode` through the
19
+ * appropriate bundler-specific renderer (e.g.
20
+ * `@vitejs/plugin-rsc/rsc.renderToReadableStream`,
21
+ * `react-server-dom-webpack/server.edge`, etc.) — keeping this plugin fully
22
+ * bundler-agnostic.
23
+ *
24
+ * Sibling plugin `@real-router/ssr-data-plugin` follows the same factory
25
+ * pattern via `createSsrLoaderPlugin` from `shared/ssr/`.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * const router = cloneRouter(baseRouter);
30
+ *
31
+ * router.usePlugin(rscServerPluginFactory({
32
+ * "users.profile": () => async (params) => {
33
+ * const user = await db.users.findById(params.id);
34
+ * return <UserProfile user={user} />;
35
+ * },
36
+ * }));
37
+ *
38
+ * const state = await router.start(req.url);
39
+ * if (state.context.rsc) {
40
+ * const flight = renderToReadableStream(state.context.rsc);
41
+ * // pipe flight to HTTP response
42
+ * }
43
+ * ```
44
+ */
45
+ export function rscServerPluginFactory<
46
+ Dependencies extends DefaultDependencies = DefaultDependencies,
47
+ >(loaders: RscLoaderFactoryMap<Dependencies>): PluginFactory<Dependencies> {
48
+ validateLoaders(loaders);
49
+
50
+ return createSsrLoaderPlugin<ReactNode, Dependencies>(loaders, {
51
+ namespace: "rsc",
52
+ modeNamespace: "ssrRscMode",
53
+ errorPrefix: ERROR_PREFIX,
54
+ allowedModes: ALLOWED_RSC_MODES,
55
+ });
56
+ }
@@ -0,0 +1,41 @@
1
+ import { ALLOWED_RSC_MODES } from "./constants";
2
+
3
+ import type { RscSsrMode } from "./types";
4
+ import type { State } from "@real-router/types";
5
+
6
+ /**
7
+ * Returns the SSR mode resolved by `rsc-server-plugin` for the current state.
8
+ * Falls back to `"full"` when the route has no plugin entry.
9
+ *
10
+ * Read this from `entry-server.tsx` to branch on full vs client-only:
11
+ * - `"full"` — render the Server Component tree, pipe Flight stream.
12
+ * - `"client-only"` — ship shell HTML and let the client fetch via its own mechanism.
13
+ *
14
+ * The mode is written to `state.context.ssrRscMode` by the plugin's `start`
15
+ * interceptor for every route registered in the loaders map.
16
+ *
17
+ * Defensive read: if `state.context.ssrRscMode` was set to something outside
18
+ * `ALLOWED_RSC_MODES` by a TS-cast bypass or a foreign writer, the function
19
+ * collapses it to `"full"` rather than returning the bad value. Without this
20
+ * guard, a downstream `mode === "full"` branch would silently misbehave for
21
+ * `0`, `false`, `""`, `null`, or any unknown string.
22
+ *
23
+ * The read itself is wrapped in `try/catch` — a foreign writer that installs
24
+ * a throwing getter (`Object.defineProperty(ctx, "ssrRscMode", { get() { throw … } })`)
25
+ * cannot break the contract. The function NEVER throws, no matter how
26
+ * adversarial the context shape. `"full"` is the safe default for any error.
27
+ */
28
+ export function getSsrRscMode(state: State): RscSsrMode {
29
+ let raw: unknown;
30
+
31
+ try {
32
+ raw = (state.context as { ssrRscMode?: unknown }).ssrRscMode;
33
+ } catch {
34
+ return "full";
35
+ }
36
+
37
+ return typeof raw === "string" &&
38
+ ALLOWED_RSC_MODES.includes(raw as RscSsrMode)
39
+ ? (raw as RscSsrMode)
40
+ : "full";
41
+ }
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ export type {
2
+ RscActionResult,
3
+ RscLoaderFn,
4
+ RscLoaderFactoryMap,
5
+ RscLoaderFnFactory,
6
+ RscPayload,
7
+ RscRouteEntry,
8
+ RscSsrMode,
9
+ SsrLoaderContext,
10
+ } from "./types";
11
+
12
+ export { rscServerPluginFactory } from "./factory";
13
+
14
+ export { rscActionPluginFactory } from "./actionFactory";
15
+
16
+ export { buildRscPayload } from "./buildRscPayload";
17
+
18
+ export { getSsrRscMode } from "./getSsrRscMode";
19
+
20
+ export { invalidate } from "./invalidate";
21
+
22
+ declare module "@real-router/types" {
23
+ interface StateContext {
24
+ rsc?: import("react").ReactNode;
25
+ rscAction?: import("./types").RscActionResult;
26
+ ssrRscMode?: import("./types").RscSsrMode;
27
+ }
28
+ }
@@ -0,0 +1,36 @@
1
+ import { markStale } from "./shared-ssr";
2
+
3
+ import type { Router } from "@real-router/types";
4
+
5
+ /**
6
+ * Mark the `"rsc"` namespace as stale on the given router. The next
7
+ * navigation (including a same-route reload) re-runs the RSC loader for the
8
+ * destination route and overwrites `state.context.rsc` (and the mode marker)
9
+ * via the plugin's `subscribeLeave` listener.
10
+ *
11
+ * Honest fire-and-forget semantics — returns `void`. The flag is consumed in
12
+ * the awaited LEAVE_APPROVE phase of the next navigation, so subscribers see
13
+ * a fresh `ReactNode` when the navigation completes. Behaviour during an
14
+ * in-flight transition: the current transition completes unchanged; the flag
15
+ * is read by the *following* navigation. This keeps the invariant
16
+ * "one transition = one `state.context` snapshot" intact.
17
+ *
18
+ * Composability through the existing core API:
19
+ *
20
+ * ```ts
21
+ * // Fire-and-forget: stale until the user navigates somewhere
22
+ * invalidate(router, "rsc");
23
+ *
24
+ * // Explicit await — pair with a same-route reload
25
+ * invalidate(router, "rsc");
26
+ * await router.navigate(state.name, state.params, { reload: true });
27
+ * ```
28
+ *
29
+ * Surgical alternative to `router.navigate({ reload: true })` for multi-
30
+ * namespace routes: only the `"rsc"` namespace re-runs; a side-by-side
31
+ * `ssr-data-plugin` keeps its cached `state.context.data` on this transition
32
+ * unless its own `invalidate()` was also called.
33
+ */
34
+ export function invalidate(router: Router, namespace: "rsc"): void {
35
+ markStale(router, namespace);
36
+ }
package/src/types.ts ADDED
@@ -0,0 +1,122 @@
1
+ import type {
2
+ SsrLoaderFn,
3
+ SsrLoaderFnFactory,
4
+ SsrMode,
5
+ SsrRouteEntry,
6
+ } from "./shared-ssr";
7
+ import type { DefaultDependencies } from "@real-router/types";
8
+ import type { ReactNode } from "react";
9
+
10
+ export { type SsrLoaderContext } from "./shared-ssr";
11
+
12
+ /**
13
+ * SSR mode subset supported by `rsc-server-plugin`.
14
+ *
15
+ * `"data-only"` is intentionally excluded — RSC has no concept of "data
16
+ * without component" (the Flight payload IS the data + component). Using
17
+ * `"data-only"` with `rscServerPluginFactory` is a configuration error and
18
+ * is rejected at factory time.
19
+ */
20
+ export type RscSsrMode = Exclude<SsrMode, "data-only">;
21
+
22
+ /**
23
+ * Compiled RSC loader signature.
24
+ *
25
+ * Receives the resolved route's `params` and returns a `ReactNode` (a Server
26
+ * Component element, sync or async). Synchronous return is permitted because
27
+ * many Server Components are synchronous — wrapping them in `Promise.resolve`
28
+ * would be ceremonial.
29
+ */
30
+ export type RscLoaderFn = SsrLoaderFn<ReactNode>;
31
+
32
+ /**
33
+ * Factory function for creating RSC loaders.
34
+ *
35
+ * Receives the router instance and a dependency getter (same pattern as
36
+ * `DataLoaderFnFactory`/`GuardFnFactory`). Factory runs once at
37
+ * `usePlugin()` time; the returned loader is cached.
38
+ *
39
+ * @template Dependencies - Router dependency map for typed `getDependency()`.
40
+ * Defaults to `DefaultDependencies`. Pass your app's dependency interface
41
+ * for type-safe DI: `RscLoaderFnFactory<AppDependencies>`.
42
+ */
43
+ export type RscLoaderFnFactory<
44
+ Dependencies extends DefaultDependencies = DefaultDependencies,
45
+ > = SsrLoaderFnFactory<ReactNode, Dependencies>;
46
+
47
+ /**
48
+ * Per-route entry: either a loader factory (short form) or
49
+ * `{ ssr?, loader? }` object form. Mode defaults to `"full"`.
50
+ *
51
+ * Allowed `ssr` values for RSC: `"full"` | `"client-only"` (and the
52
+ * `true` / `false` aliases). `"data-only"` is rejected at factory time.
53
+ *
54
+ * Function form `(state) => RscSsrMode` is resolved per-navigation,
55
+ * **before** the mode is written to context.
56
+ */
57
+ export type RscRouteEntry<
58
+ Dependencies extends DefaultDependencies = DefaultDependencies,
59
+ > = SsrRouteEntry<ReactNode, RscSsrMode, Dependencies>;
60
+
61
+ /**
62
+ * Map of route name → entry (factory or `{ ssr?, loader? }`).
63
+ *
64
+ * Pass to `rscServerPluginFactory()`. Keys are route names (e.g. `"users.profile"`);
65
+ * values are factory or object-form route entries.
66
+ */
67
+ export type RscLoaderFactoryMap<
68
+ Dependencies extends DefaultDependencies = DefaultDependencies,
69
+ > = Record<string, RscRouteEntry<Dependencies>>;
70
+
71
+ /**
72
+ * Server Action result published by `rscActionPluginFactory` to
73
+ * `state.context.rscAction`. Consumers read either field at render
74
+ * time. Both are optional — typical flows write one or the other:
75
+ *
76
+ * - `returnValue` — set when the action was invoked via the hydrated
77
+ * client path (`setServerCallback` → `loadServerAction` →
78
+ * `decodeReply` in the RSC entry). Threaded back into
79
+ * `useActionState` on the client.
80
+ * - `formState` — set when the action was invoked via progressive
81
+ * enhancement (`<form action={fn}>` POST without JS) and decoded
82
+ * via `decodeAction(formData)` + `decodeFormState(result, formData)`.
83
+ *
84
+ * Both type parameters default to `unknown` to keep the plugin
85
+ * runtime-only — consumers narrow them at the call site.
86
+ */
87
+ export interface RscActionResult<TReturn = unknown, TFormState = unknown> {
88
+ returnValue?: { ok: boolean; data: TReturn };
89
+ formState?: TFormState;
90
+ }
91
+
92
+ /**
93
+ * Canonical Flight payload shape for RSC apps that ship Server Actions.
94
+ *
95
+ * The pipeline serializes this object via the bundler's RSC stream
96
+ * renderer (e.g. `@vitejs/plugin-rsc/rsc.renderToReadableStream`); the
97
+ * SSR + browser entries deserialize the same shape and thread
98
+ * `returnValue`/`formState` into `useActionState`.
99
+ *
100
+ * Apps without Server Actions can use `RscPayload` with all generics
101
+ * defaulted, or just type their payload as `{ root: ReactNode }`.
102
+ *
103
+ * Type parameters:
104
+ * - `TReturn` — narrowed shape of `returnValue.data` (e.g. mutation
105
+ * confirmation). Defaults to `unknown`.
106
+ * - `TFormState` — narrowed shape of `formState`. Defaults to
107
+ * `unknown` so the plugin stays free of `react-dom/client` import
108
+ * (which carries the canonical `ReactFormState` type). Consumers
109
+ * narrow at call site:
110
+ *
111
+ * ```ts
112
+ * import type { ReactFormState } from "react-dom/client";
113
+ * type AppPayload = RscPayload<{ id: string }, ReactFormState>;
114
+ * ```
115
+ */
116
+ export interface RscPayload<
117
+ TReturn = unknown,
118
+ TFormState = unknown,
119
+ > extends RscActionResult<TReturn, TFormState> {
120
+ /** Server Component tree to render. */
121
+ root: ReactNode;
122
+ }