@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.
- package/README.md +338 -0
- package/dist/cjs/errors.d.ts +97 -0
- package/dist/cjs/errors.d.ts.map +1 -0
- package/dist/cjs/errors.js +2 -0
- package/dist/cjs/errors.js.map +1 -0
- package/dist/cjs/index.d.ts +330 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +2 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/esm/errors.d.mts +97 -0
- package/dist/esm/errors.d.mts.map +1 -0
- package/dist/esm/errors.mjs +2 -0
- package/dist/esm/errors.mjs.map +1 -0
- package/dist/esm/index.d.mts +330 -0
- package/dist/esm/index.d.mts.map +1 -0
- package/dist/esm/index.mjs +2 -0
- package/dist/esm/index.mjs.map +1 -0
- package/package.json +77 -0
- package/src/actionFactory.ts +148 -0
- package/src/buildRscPayload.ts +64 -0
- package/src/constants.ts +16 -0
- package/src/errors.ts +6 -0
- package/src/factory.ts +56 -0
- package/src/getSsrRscMode.ts +41 -0
- package/src/index.ts +28 -0
- package/src/invalidate.ts +36 -0
- package/src/types.ts +122 -0
|
@@ -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
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -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
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
|
+
}
|