@real-router/rsc-server-plugin 0.2.2 → 0.2.3

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.
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../../src/constants.ts","../../../../shared/ssr/defer.ts","../../../../shared/ssr/deferRegistry.ts","../../../../shared/ssr/staleRegistry.ts","../../../../shared/ssr/types.ts","../../../../shared/ssr/createSsrLoaderPlugin.ts","../../../../shared/ssr/createLoadersValidator.ts","../../src/factory.ts","../../src/actionFactory.ts","../../src/buildRscPayload.ts","../../src/getSsrRscMode.ts","../../src/invalidate.ts"],"sourcesContent":["import type { RscSsrMode } from \"./types\";\n\nconst LOGGER_CONTEXT = \"rsc-server-plugin\";\n\nexport const ERROR_PREFIX = `[@real-router/${LOGGER_CONTEXT}]`;\n\n/**\n * The strict subset of `SsrMode` that `rsc-server-plugin` accepts.\n * `\"data-only\"` is intentionally excluded — RSC has no semantically meaningful\n * \"data without component\" (the Flight payload IS the data + component).\n *\n * Single source of truth for `factory.ts` (`createSsrLoaderPlugin` allowedModes),\n * `validation.ts` (factory-time loader-map validator), and `getSsrRscMode.ts`\n * (runtime read-side guard against TS-cast-bypassed garbage in `state.context`).\n */\nexport const ALLOWED_RSC_MODES: readonly RscSsrMode[] = [\"full\", \"client-only\"];\n","/**\n * Marker symbol for `defer()` payloads. `Symbol.for` is used so the brand\n * survives across multiple module instances (a real concern in monorepo setups\n * with multiple `node_modules/@real-router/ssr-data-plugin` copies).\n */\nexport const DEFER_BRAND: unique symbol = Symbol.for(\n \"@real-router/ssr-data-plugin/defer\",\n);\n\nexport interface DeferredPayload<\n C,\n D extends Record<string, Promise<unknown>>,\n> {\n readonly critical: C;\n readonly deferred: D;\n readonly [DEFER_BRAND]: true;\n}\n\n/**\n * Wraps a loader return value to declare a critical/deferred split.\n *\n * - `critical` resolves before HTML render (blocks the shell).\n * - `deferred` is a record of named promises that the framework can stream\n * independently — `<Suspense>`, `<Await/>`, `{#await}`, etc.\n *\n * The plugin writes `critical` to `state.context.<namespace>` (e.g. `data`)\n * and the deferred promises to `state.context.<namespace>Deferred` (e.g.\n * `ssrDataDeferred`). Adapter-side `useDeferred(key)` reads from the same\n * shape and returns the matching promise for native framework awaiting.\n *\n * On the server: `state.context.ssrDataDeferred[key]` is the actual promise\n * the loader produced. On the client (post-hydration): the plugin reconstructs\n * promises from the global `__rrDeferRegistry__` that inline `__rrDefer__()`\n * scripts populate as the server stream lands.\n */\nexport function defer<\n const C,\n const D extends Record<string, Promise<unknown>>,\n>(options: { readonly critical: C; readonly deferred: D }): DeferredPayload<\n C,\n D\n> {\n if (options === null || typeof options !== \"object\") {\n throw new TypeError(\n \"[defer] expected an object with `critical` and `deferred` fields\",\n );\n }\n\n if (\n options.deferred === null ||\n typeof options.deferred !== \"object\" ||\n Array.isArray(options.deferred)\n ) {\n throw new TypeError(\n \"[defer] `deferred` must be a non-null, non-array object of promises\",\n );\n }\n\n for (const [key, value] of Object.entries(options.deferred)) {\n // Reserved keys would corrupt the prototype chain when the client-side\n // plugin reconstructs the deferred map via `[key] = ensureRegistryPromise(key)`.\n // The reconstruction path uses a null-prototype object as a defence-in-depth\n // measure, but rejecting these keys upstream keeps the wire-format\n // symmetric (server-side payload === client-side reconstruction).\n if (key === \"__proto__\" || key === \"constructor\" || key === \"prototype\") {\n throw new TypeError(\n `[defer] \\`deferred.${key}\\` is reserved — choose a different key`,\n );\n }\n\n if (\n value === null ||\n typeof value !== \"object\" ||\n typeof (value as { then?: unknown }).then !== \"function\"\n ) {\n throw new TypeError(\n `[defer] \\`deferred.${key}\\` must be a Promise (got ${typeof value})`,\n );\n }\n\n // Defensive sibling-handler: an eagerly-rejected promise (e.g.\n // `Promise.reject(new Error(...))` synchronously inside the loader)\n // races the server-side `injectDeferredScripts` `.then(...)`\n // attachment. Without a handler attached at construction time, Node\n // emits an `unhandledRejection` warning before the wire-format\n // settler can register. The no-op `.catch` does not consume the\n // rejection — it only marks the promise as \"handled\" for Node's\n // tracker, so the real settler still observes the rejection and\n // emits the `__rrDeferError__` script.\n //\n // Duck-typed thenables (no `.catch`) are skipped: Node's\n // unhandledRejection tracker only fires for native Promise objects,\n // so non-Promise thenables don't need the suppression anyway.\n const maybeCatch = (value as { catch?: unknown }).catch;\n\n if (typeof maybeCatch === \"function\") {\n (value as Promise<unknown>).catch(() => {\n /* no-op — see comment above */\n });\n }\n }\n\n // Freeze a *shallow clone* of the deferred map (rather than the user's\n // own reference) so:\n // 1. `Object.freeze` doesn't surprise the caller by freezing an object\n // they still hold a reference to.\n // 2. Post-`defer()` mutations to the user's original map (e.g.\n // `userMap.evil = somePromise`) cannot smuggle in entries that\n // bypass the validation/`.catch` loop above. Without this, a late\n // `userMap.__proto__ = …` or an eagerly-rejected promise added\n // after this call would land in `injectDeferredScripts` unchecked.\n // The clone is shallow — promise references are preserved, so the\n // settle pipeline observes the same Promise instances the validator\n // examined.\n return Object.freeze({\n critical: options.critical,\n deferred: Object.freeze({ ...options.deferred }) as D,\n [DEFER_BRAND]: true,\n });\n}\n\n/** Type guard — `true` iff `value` is a payload returned by `defer()`.\n *\n * The brand check uses `Object.hasOwn(value, DEFER_BRAND)` rather than a\n * plain property read so a prototype-chain inheritance bypass —\n * `Object.create({ [DEFER_BRAND]: true })` — does not falsely tag an\n * object as a deferred payload. The brand symbol is a `Symbol.for(...)`,\n * so a brand-marked object inherited by accident from a foreign realm\n * could otherwise sneak past `defer()`'s validation and land in\n * `processLoaderResult`'s slow path with no `critical`/`deferred` fields.\n */\nexport function isDeferred(\n value: unknown,\n): value is DeferredPayload<unknown, Record<string, Promise<unknown>>> {\n return (\n value !== null &&\n typeof value === \"object\" &&\n Object.hasOwn(value, DEFER_BRAND) &&\n (value as Record<symbol, unknown>)[DEFER_BRAND] === true\n );\n}\n","/**\n * Client-side registry for deferred values streamed from the server.\n *\n * The contract spans three actors:\n *\n * 1. **Server stream injects `<script>__rrDefer__(\"key\", \"json\")</script>`\n * tags** as each loader-returned promise resolves. The bootstrap script\n * (also server-emitted) installs `__rrDefer__` and the registry on\n * `globalThis` before any settle script runs.\n *\n * 2. **Plugin start interceptor** (post-hydration scratchpad path) reads the\n * `<deferredKeysNamespace>` list from the hydrated state, then calls\n * `ensureRegistryPromise(key)` once per key to obtain the promise that\n * `useDeferred()` will return. This ensures a stable Promise reference\n * across the initial render and any inline-script settlements.\n *\n * 3. **Adapter `useDeferred(key)`** reads from `state.context.<deferredNamespace>`\n * which the plugin populated above. The returned Promise integrates with\n * React `use()`, Solid `<Await/>`, Svelte `{#await}`, etc.\n */\n\ninterface RegistryEntry {\n promise: Promise<unknown>;\n resolve: (value: unknown) => void;\n reject: (error: unknown) => void;\n}\n\nconst REGISTRY_GLOBAL_KEY = \"__rrDeferRegistry__\";\nconst SETTLE_FN_NAME = \"__rrDefer__\";\nconst REJECT_FN_NAME = \"__rrDeferError__\";\n\ninterface DeferGlobal {\n [REGISTRY_GLOBAL_KEY]?: Map<string, RegistryEntry>;\n [SETTLE_FN_NAME]?: (key: string, json: string) => void;\n [REJECT_FN_NAME]?: (key: string, json: string) => void;\n}\n\nfunction getGlobal(): DeferGlobal {\n return globalThis as unknown as DeferGlobal;\n}\n\nfunction getOrCreateRegistry(): Map<string, RegistryEntry> {\n const g = getGlobal();\n let registry = g[REGISTRY_GLOBAL_KEY];\n\n if (registry === undefined) {\n registry = new Map<string, RegistryEntry>();\n g[REGISTRY_GLOBAL_KEY] = registry;\n }\n\n return registry;\n}\n\n/**\n * Returns the registered Promise for `key`, creating a fresh pending entry on\n * first access. Stable across calls — `useDeferred` relies on Promise\n * reference identity for React `use()` to track resolution.\n */\nexport function ensureRegistryPromise(key: string): Promise<unknown> {\n const registry = getOrCreateRegistry();\n let entry = registry.get(key);\n\n if (entry === undefined) {\n let resolve!: (value: unknown) => void;\n let reject!: (error: unknown) => void;\n\n const promise = new Promise<unknown>((res, rej) => {\n resolve = res;\n reject = rej;\n });\n\n entry = { promise, resolve, reject };\n registry.set(key, entry);\n }\n\n return entry.promise;\n}\n\n/**\n * Returns the inline bootstrap script (no `<script>` wrapper). Embed in a\n * `<script>` tag emitted **once before any `__rrDefer__()` call lands** in\n * the response stream. Idempotent — re-installing is a no-op.\n *\n * The script source is kept terse (ES5-ish, no template literals, no\n * arrow functions) so it works without transpilation in legacy browsers and\n * stays under ~600 bytes uncompressed.\n */\nexport function getDeferBootstrapScript(): string {\n // The script idempotently installs __rrDefer__/__rrDeferError__ on `g`. If\n // the registry already exists (e.g. from a prior call to\n // ensureRegistryPromise on the client adapter), reuse it — only the settle\n // functions are (re)assigned. This handles the realistic ordering:\n // adapter creates the registry during hydration; the first settle script\n // arriving in the response stream installs the global functions.\n return (\n \"(function(g){\" +\n `var R=g.${REGISTRY_GLOBAL_KEY};` +\n `if(!R)R=g.${REGISTRY_GLOBAL_KEY}=new Map();` +\n \"function E(k){\" +\n \"var e=R.get(k);\" +\n \"if(!e){\" +\n \"var rs,rj;\" +\n \"var p=new Promise(function(r,j){rs=r;rj=j});\" +\n \"e={promise:p,resolve:rs,reject:rj};\" +\n \"R.set(k,e)\" +\n \"}\" +\n \"return e\" +\n \"}\" +\n `g.${SETTLE_FN_NAME}=function(k,j){E(k).resolve(JSON.parse(j))};` +\n `g.${REJECT_FN_NAME}=function(k,j){` +\n \"var d=JSON.parse(j);\" +\n 'var er=new Error(d&&d.message?d.message:\"deferred error\");' +\n \"if(d&&d.name)er.name=d.name;\" +\n \"E(k).reject(er)\" +\n \"}\" +\n \"})(typeof globalThis!=='undefined'?globalThis:\" +\n \"(typeof window!=='undefined'?window:self));\"\n );\n}\n\n// Single-pass replacement table for the chars escapeForScript must encode\n// as `\\uXXXX` to keep them out of the raw HTML parser. Five consecutive\n// `replace` / `split`+`join` passes used to walk the string for each\n// codepoint; the regex + lookup form does it in one pass — ~1.6× faster\n// on large payloads, indistinguishable on short keys (the common case).\n//\n// Roundtrip + HTML-safety properties are pinned by the\n// `escapeForScript: pure-function security invariants` PBT block in\n// `tests/property/ssr-data.properties.ts` (numRuns: 1000).\n//\n// Built at module init via `String.fromCodePoint(...)` so the source file\n// itself never contains raw U+2028 / U+2029 codepoints (which would\n// terminate string literals / regex literals at parse time on legacy\n// JS engines and even in modern TS parsers under some configs).\nconst ESCAPE_FOR_SCRIPT_PAIRS: readonly (readonly [string, string])[] = [\n [\"<\", \"\\\\u003c\"],\n [\">\", \"\\\\u003e\"],\n [\"&\", \"\\\\u0026\"],\n [String.fromCodePoint(0x20_28), \"\\\\u2028\"],\n [String.fromCodePoint(0x20_29), \"\\\\u2029\"],\n] as const;\nconst ESCAPE_FOR_SCRIPT_TABLE: Record<string, string> = Object.fromEntries(\n ESCAPE_FOR_SCRIPT_PAIRS,\n);\nconst ESCAPE_FOR_SCRIPT_REGEX = new RegExp(\n `[${ESCAPE_FOR_SCRIPT_PAIRS.map(([c]) => c).join(\"\")}]`,\n \"g\",\n);\n\n/**\n * Encode an arbitrary string as a **JS string literal** that is also safe to\n * embed inside a `<script>...</script>` body. Returns the literal **with**\n * surrounding quotes — drop it directly into a script template.\n *\n * Encoding via Unicode escapes (`\\uXXXX`) means:\n * - The raw HTML parser sees no `<`, `>`, U+2028, or U+2029 — so it cannot\n * terminate the script tag prematurely (`</script>`, `<!--`) or trigger\n * legacy JS line-terminator interpretation.\n * - The JS parser interprets `<`/`>`/`
`/`
` back to\n * their original chars, so the runtime string value is bit-identical to\n * the input.\n * - Crucially, the same encoding works for two consumer paths:\n * 1. **Plain JS literal** (e.g. the deferred KEY): the JS parser hands\n * back the original string directly.\n * 2. **JS literal containing JSON** (e.g. the deferred VALUE): the JS\n * parser hands back a string with `<` text inside (the leading\n * `\\\\` of `\\\\u003c` escaped to `\\`, then `u003c` is plain text), and\n * `JSON.parse` then unescapes `<` → `<`. Net round-trip is\n * identity.\n * Both decode paths land on the original string — so the same\n * `escapeForScript` works for both keys (parsed as JS literal) and values\n * (parsed as JS literal containing JSON).\n *\n * The `&` → `&` substitution defends against `<![CDATA[` / template\n * engine post-processing that might re-interpret HTML entities; it is not\n * strictly necessary for `<script>` body parsing but cheap and conservative.\n */\nexport function escapeForScript(value: string): string {\n // The TS contract is `value: string`, but a cast at a callsite or a\n // misbehaving custom serializer can still smuggle a non-string through.\n // Three failure modes JSON.stringify can have on non-strings:\n // - returns `undefined` (`stringify(undefined)`, `stringify(symbol)`,\n // `stringify(function)`),\n // - throws (`stringify(bigint)` → `TypeError`,\n // `stringify(circular)` → `TypeError`),\n // - returns `\"null\"` (already safe for our pipeline).\n // Catch both and emit the JSON `null` literal — the safest single-token\n // representation that JSON.parse will accept downstream.\n let json: string | undefined;\n\n try {\n json = JSON.stringify(value);\n } catch {\n json = undefined;\n }\n\n if (typeof json !== \"string\") {\n return \"null\";\n }\n\n return json.replace(\n ESCAPE_FOR_SCRIPT_REGEX,\n (c) => ESCAPE_FOR_SCRIPT_TABLE[c] ?? c,\n );\n}\n\n/**\n * Format a single settle script for one resolved promise.\n * Output: `<script>__rrDefer__(\"key\",\"jsonString\")</script>`. Both `key`\n * and `serializedValue` are user-controlled in the general case (route\n * params can flow into deferred-map keys; loader returns flow into values),\n * so both go through {@link escapeForScript}.\n */\nexport function formatSettleScript(\n key: string,\n serializedValue: string,\n isError: boolean,\n): string {\n const fn = isError ? REJECT_FN_NAME : SETTLE_FN_NAME;\n const safeKey = escapeForScript(key);\n const safeValue = escapeForScript(serializedValue);\n\n return `<script>${fn}(${safeKey},${safeValue})</script>`;\n}\n\n/** Test-only — clears the global registry. Not exported from index.ts. */\nexport function __resetRegistryForTests(): void {\n const g = getGlobal();\n delete g[REGISTRY_GLOBAL_KEY];\n delete g[SETTLE_FN_NAME];\n delete g[REJECT_FN_NAME];\n}\n","import type { Router } from \"@real-router/types\";\n\nconst staleByRouter = new WeakMap<Router, Set<string>>();\n\n/**\n * Mark a context namespace as stale on the given router. The next navigation\n * that lands on a route with a registered loader for this namespace consumes\n * the flag in the SSR loader plugin's `subscribeLeave` handler — runs the\n * loader, overwrites `state.context.<namespace>`, and clears the flag.\n *\n * Idempotent (Set-deduplicated). Survives navigations that cannot refresh:\n * routes without an entry, `client-only` mode, mode-only entries, and\n * cancelled navigations all preserve the flag for the next attempt. The\n * flag is cleared only after the loader successfully runs and writes data.\n *\n * Returns `void` (fire-and-forget). For an explicit synchronous round-trip,\n * compose with the existing core API:\n * ```ts\n * markStale(router, \"data\");\n * await router.navigate(state.name, state.params, { reload: true });\n * ```\n */\nexport function markStale(router: Router, namespace: string): void {\n let set = staleByRouter.get(router);\n\n if (set === undefined) {\n set = new Set<string>();\n staleByRouter.set(router, set);\n }\n\n set.add(namespace);\n}\n\n/** Plugin-internal: peek without consuming. */\nexport function isStale(router: Router, namespace: string): boolean {\n return staleByRouter.get(router)?.has(namespace) ?? false;\n}\n\n/** Plugin-internal: clear the flag (no-op if not set). */\nexport function clearStale(router: Router, namespace: string): void {\n staleByRouter.get(router)?.delete(namespace);\n}\n","import type {\n DefaultDependencies,\n Params,\n Router,\n State,\n} from \"@real-router/types\";\n\nexport type SsrMode = \"full\" | \"data-only\" | \"client-only\";\n\nexport const ALL_SSR_MODES: readonly SsrMode[] = [\n \"full\",\n \"data-only\",\n \"client-only\",\n];\n\n/**\n * Resolves the SSR mode for a route per-navigation.\n *\n * Receives the resolved post-routing `state` (with `name`, `params`, `path`)\n * and returns one of the allowed `SsrMode` values for the host plugin.\n *\n * The resolver is invoked **before** the plugin writes the mode marker to\n * `state.context.<modeNamespace>`, so reading `state.context.ssrDataMode` /\n * `state.context.ssrRscMode` here yields `undefined`. Branch on\n * `state.params`, `state.path`, or `state.name` instead.\n *\n * Throwing from the resolver propagates through `start()` (standard\n * navigation error pipeline) — no partial mode write occurs. Returning a\n * value outside the host plugin's `allowedModes` rejects with a typed\n * `TypeError` at runtime.\n */\nexport type SsrModeResolver<M extends SsrMode = SsrMode> = (state: State) => M;\n\nexport type SsrModeConfig<M extends SsrMode = SsrMode> =\n | M\n | boolean\n | SsrModeResolver<M>;\n\n/**\n * Optional context object passed to the loader. The `signal` field is the\n * navigation's `AbortController.signal` when the plugin's `subscribeLeave`\n * handler invokes the loader (#605 `invalidate()` → CSR refresh path);\n * `undefined` from the `start` interceptor (SSR boot path — apps that need\n * a request-scoped signal use `getDep(\"abortSignal\")` injected via\n * `cloneRouter(base, { abortSignal })`, see `createRequestScope` and\n * `withTimeout({ upstreamSignal })` patterns).\n *\n * Loaders ignoring the second argument remain compatible (TypeScript\n * contravariance).\n */\nexport interface SsrLoaderContext {\n signal: AbortSignal;\n}\n\nexport type SsrLoaderFn<T> = (\n params: Params,\n context?: SsrLoaderContext,\n) => Promise<T> | T;\n\nexport type SsrLoaderFnFactory<\n T,\n Dependencies extends DefaultDependencies = DefaultDependencies,\n> = (\n router: Router<Dependencies>,\n getDependency: <K extends keyof Dependencies>(key: K) => Dependencies[K],\n) => SsrLoaderFn<T>;\n\nexport interface SsrRouteEntryObject<\n T,\n M extends SsrMode = SsrMode,\n Dependencies extends DefaultDependencies = DefaultDependencies,\n> {\n ssr?: SsrModeConfig<M>;\n loader?: SsrLoaderFnFactory<T, Dependencies>;\n}\n\nexport type SsrRouteEntry<\n T,\n M extends SsrMode = SsrMode,\n Dependencies extends DefaultDependencies = DefaultDependencies,\n> =\n | SsrLoaderFnFactory<T, Dependencies>\n | SsrRouteEntryObject<T, M, Dependencies>;\n\nexport type SsrLoaderFactoryMap<\n T,\n M extends SsrMode = SsrMode,\n Dependencies extends DefaultDependencies = DefaultDependencies,\n> = Record<string, SsrRouteEntry<T, M, Dependencies>>;\n\nexport interface SsrLoaderPluginConfig {\n namespace: string;\n modeNamespace: string;\n errorPrefix: string;\n allowedModes?: readonly SsrMode[];\n /**\n * When set, the plugin recognises `defer()` payloads from loaders.\n * Critical data is written to `<namespace>`, deferred promises to\n * `<deferredNamespace>`, and the deferred key list (for client-side\n * registry hydration) to `<deferredKeysNamespace>`.\n *\n * Both fields must be set together; one without the other rejects at\n * factory-time.\n */\n deferredNamespace?: string;\n deferredKeysNamespace?: string;\n}\n","import { getPluginApi } from \"@real-router/core/api\";\nimport { getInternals } from \"@real-router/core/validation\";\n\nimport { isDeferred } from \"./defer.js\";\nimport { ensureRegistryPromise } from \"./deferRegistry.js\";\nimport { clearStale, isStale } from \"./staleRegistry.js\";\nimport { ALL_SSR_MODES } from \"./types.js\";\n\nimport type {\n SsrLoaderFactoryMap,\n SsrLoaderFn,\n SsrLoaderPluginConfig,\n SsrMode,\n SsrModeConfig,\n} from \"./types.js\";\nimport type {\n ContextNamespaceClaim,\n DefaultDependencies,\n Plugin,\n PluginFactory,\n Router,\n State,\n} from \"@real-router/types\";\n\ninterface CompiledEntry<T> {\n /**\n * Pre-resolved mode for static `ssr` configs (undefined / boolean /\n * string). `null` marker means \"function-form resolver — must call\n * `resolveMode(modeFn, state, …)` at navigation time\". Pre-computing\n * skips the `resolveMode` walk on every `start()` + every stale-flag\n * leave handler invocation for the common static-config case.\n */\n staticMode: SsrMode | null;\n /**\n * Function-form mode resolver. Defined ONLY when `obj.ssr` is a\n * function; `undefined` for static forms (where `staticMode` is\n * authoritative). Kept as a typed field rather than reusing the\n * raw `obj.ssr` so the prepareEntry call site avoids a `typeof`\n * branch per navigation.\n */\n modeFn: ((state: State) => SsrMode) | undefined;\n loader: SsrLoaderFn<T> | undefined;\n}\n\n/**\n * Compile a `SsrLoaderFactoryMap` into a `Map<name, CompiledEntry>`.\n *\n * Extracted from the inline `for (const [name, raw] of …)` body that lived\n * inside `createSsrLoaderPlugin` so the main function reads top-down:\n * claims are acquired, compilation runs against this helper, and any throw\n * bubbles to the shared `rollback()` path. Tested in isolation by the same\n * functional + property suites that pin the previous inline behaviour.\n *\n * The compile step is pure — it touches no router state other than via the\n * caller-provided `router` + `getDependency` arguments, and it only walks\n * own-enumerable entries (`Object.entries`) so prototype pollution stays\n * structurally impossible.\n *\n * Mode pre-resolution: static `ssr` forms (`undefined` / boolean / string)\n * are resolved here at compile time and cached as `staticMode`. The\n * runtime path in `prepareEntry` then reuses the cached value on every\n * `start()` + stale-flag leave handler invocation, skipping the\n * `resolveMode` if/else chain. Function-form `ssr` keeps a typed\n * `modeFn` for per-navigation evaluation.\n */\nfunction compile<\n T,\n Dependencies extends DefaultDependencies = DefaultDependencies,\n>(\n loaders: SsrLoaderFactoryMap<T, SsrMode, Dependencies>,\n router: Router<Dependencies>,\n getDependency: <K extends keyof Dependencies>(key: K) => Dependencies[K],\n errorPrefix: string,\n allowed: readonly SsrMode[],\n): Map<string, CompiledEntry<T>> {\n const compiled = new Map<string, CompiledEntry<T>>();\n\n for (const [name, raw] of Object.entries(loaders)) {\n const obj = typeof raw === \"function\" ? { loader: raw } : raw;\n\n let loader: SsrLoaderFn<T> | undefined;\n\n if (obj.loader !== undefined) {\n const fn = obj.loader(router, getDependency);\n\n if (typeof fn !== \"function\") {\n throw new TypeError(\n `${errorPrefix} factory for route \"${name}\" must return a function`,\n );\n }\n\n loader = fn;\n }\n\n // Pre-resolve static modes; defer function-form to navigation-time.\n // The `resolveMode` runtime helper still validates function-form\n // returns AND any forms that the validator passed but createSsrLoaderPlugin's\n // narrower `allowedModes` rejects (consumer-specific allow-list).\n let staticMode: SsrMode | null = null;\n let modeFn: ((state: State) => SsrMode) | undefined;\n\n if (typeof obj.ssr === \"function\") {\n modeFn = obj.ssr;\n } else {\n // Static — undefined/true/false/string. Pass a synthetic state;\n // resolveMode ignores `state` for non-function forms.\n staticMode = resolveMode(\n obj.ssr,\n SYNTHETIC_STATE,\n allowed,\n errorPrefix,\n name,\n );\n }\n\n compiled.set(name, { staticMode, modeFn, loader });\n }\n\n return compiled;\n}\n\n// Placeholder state for compile-time static-mode resolution. The\n// resolveMode function reads `state` only for the function-form branch,\n// so any non-null reference works for the static branches. Kept module-\n// level so all compile() calls share one allocation.\nconst SYNTHETIC_STATE = {\n name: \"\",\n params: {},\n path: \"\",\n transition: {\n phase: \"activating\",\n reason: \"success\",\n segments: { deactivated: [], activated: [], intersection: \"\" },\n },\n context: {},\n} as unknown as State;\n\nfunction rejectMode(\n value: unknown,\n allowed: readonly SsrMode[],\n prefix: string,\n route: string,\n): never {\n throw new TypeError(\n `${prefix} mode \"${String(value)}\" is not allowed for route \"${route}\". Allowed: ${allowed.join(\", \")}`,\n );\n}\n\nfunction resolveMode(\n ssr: SsrModeConfig | undefined,\n state: State,\n allowed: readonly SsrMode[],\n prefix: string,\n route: string,\n): SsrMode {\n if (ssr === undefined || ssr === true) return \"full\";\n\n if (ssr === false) {\n if (!allowed.includes(\"client-only\")) {\n rejectMode(\"client-only\", allowed, prefix, route);\n }\n\n return \"client-only\";\n }\n\n const value = typeof ssr === \"function\" ? ssr(state) : ssr;\n\n if (typeof value !== \"string\" || !allowed.includes(value as SsrMode)) {\n rejectMode(value, allowed, prefix, route);\n }\n\n return value;\n}\n\nexport function createSsrLoaderPlugin<\n T,\n Dependencies extends DefaultDependencies = DefaultDependencies,\n>(\n loaders: SsrLoaderFactoryMap<T, SsrMode, Dependencies>,\n config: SsrLoaderPluginConfig,\n): PluginFactory<Dependencies> {\n if (\n (config.deferredNamespace !== undefined) !==\n (config.deferredKeysNamespace !== undefined)\n ) {\n throw new TypeError(\n `${config.errorPrefix} \\`deferredNamespace\\` and \\`deferredKeysNamespace\\` must be set together`,\n );\n }\n\n // Bundle the two namespace strings into a single nullable object so\n // downstream code narrows via `if (deferredConfig !== null)` instead\n // of the `config.deferredNamespace!` non-null assertion that TS can't\n // derive from the XOR check above.\n const deferredConfig =\n config.deferredNamespace !== undefined &&\n config.deferredKeysNamespace !== undefined\n ? {\n valueNamespace: config.deferredNamespace,\n keysNamespace: config.deferredKeysNamespace,\n }\n : null;\n\n return (router, getDependency): Plugin => {\n const api = getPluginApi(router);\n const allowed = config.allowedModes ?? ALL_SSR_MODES;\n\n // Sequential claim acquisition with all-or-nothing rollback. Any\n // failure (collision, validation error during compile loop) releases\n // every claim acquired so far and rethrows. This replaces the\n // previous 4 nested try/catch blocks with progressively-longer\n // release lists — same semantics, one shared rollback path.\n const acquired: ContextNamespaceClaim[] = [];\n const claim = (namespace: string): ContextNamespaceClaim => {\n const c = api.claimContextNamespace(namespace);\n acquired.push(c);\n return c;\n };\n const rollback = (): void => {\n for (const c of acquired) c.release();\n };\n\n let dataClaim: ContextNamespaceClaim;\n let modeClaim: ContextNamespaceClaim;\n let deferredClaims: {\n value: ContextNamespaceClaim;\n keys: ContextNamespaceClaim;\n } | null = null;\n let compiled: Map<string, CompiledEntry<T>>;\n\n try {\n dataClaim = claim(config.namespace);\n modeClaim = claim(config.modeNamespace);\n\n if (deferredConfig !== null) {\n deferredClaims = {\n value: claim(deferredConfig.valueNamespace),\n keys: claim(deferredConfig.keysNamespace),\n };\n }\n\n compiled = compile(\n loaders,\n router,\n getDependency,\n config.errorPrefix,\n allowed,\n );\n } catch (error) {\n rollback();\n\n throw error;\n }\n\n const internals = getInternals(router);\n\n // Hot path on every successful start() / subscribeLeave refresh. The\n // previous shape ran a `processLoaderResult` helper that always allocated\n // a `{ critical, deferred }` wrapper object — wasted on the common\n // plain-data path (and on every call from `rsc-server-plugin`, which\n // never opts into deferred support). Inlining the branch keeps the\n // fast path allocation-free and the slow path (defer payload) at one\n // intentional `Object.keys(...)` array allocation per loader.\n const writeLoaderResult = (state: State, value: T): void => {\n if (deferredClaims !== null && isDeferred(value)) {\n dataClaim.write(state, value.critical as T);\n deferredClaims.value.write(state, value.deferred);\n deferredClaims.keys.write(state, Object.keys(value.deferred));\n\n return;\n }\n\n dataClaim.write(state, value);\n };\n\n const reconstructDeferredFromHydration = (\n state: State,\n hydrated: Record<string, unknown>,\n ): void => {\n if (deferredConfig === null || deferredClaims === null) return;\n\n const keysRaw = hydrated[deferredConfig.keysNamespace];\n\n if (!Array.isArray(keysRaw)) return;\n\n const keys = keysRaw.filter(\n (k): k is string =>\n typeof k === \"string\" &&\n // Defensive: drop reserved keys that would corrupt the prototype\n // chain when assigned via `[key] = …`. `{ __proto__: x }` literal\n // does the same thing and would trigger the setter on the fresh\n // object below — turning useDeferred(\"then\") into a function ref\n // pulled from Promise.prototype. With a null-prototype object\n // (below) `__proto__` is just a property, but skipping these\n // keys outright keeps the surface predictable.\n k !== \"__proto__\" &&\n k !== \"constructor\" &&\n k !== \"prototype\",\n );\n\n if (keys.length === 0) return;\n\n // Null-prototype object so `[key] = …` cannot trigger the\n // `Object.prototype.__proto__` setter, even if the filter above is\n // bypassed by future refactors.\n const promises = Object.create(null) as Record<\n string,\n Promise<unknown>\n >;\n\n for (const key of keys) {\n promises[key] = ensureRegistryPromise(key);\n }\n\n deferredClaims.value.write(state, promises);\n deferredClaims.keys.write(state, keys);\n };\n\n // Shared between start interceptor (SSR boot path) and subscribeLeave\n // handler (CSR revalidation path). Returns the compiled entry only\n // when:\n // 1. the route is registered in this plugin's loaders map, AND\n // 2. the resolved mode is NOT \"client-only\".\n // In both successful cases the mode marker is published to\n // `state.context[modeNamespace]` BEFORE returning. Callers then own\n // the loader-invocation strategy (start path also checks the hydration\n // scratchpad; leave path gates on `entry.loader !== undefined`).\n const prepareEntry = (state: State): CompiledEntry<T> | null => {\n const entry = compiled.get(state.name);\n\n if (!entry) return null;\n\n // Static forms (the common case) — staticMode was pre-resolved at\n // compile time, skip the resolveMode if/else walk per navigation.\n // Function-form path: invoke modeFn with the resolved state and\n // re-validate via resolveMode (catches a resolver returning a\n // foreign string at runtime).\n const mode =\n entry.staticMode !== null\n ? entry.staticMode\n : resolveMode(\n entry.modeFn,\n state,\n allowed,\n config.errorPrefix,\n state.name,\n );\n\n modeClaim.write(state, mode);\n\n if (mode === \"client-only\") return null;\n\n return entry;\n };\n\n const removeStartInterceptor = api.addInterceptor(\n \"start\",\n async (next, path) => {\n const state = await next(path);\n const entry = prepareEntry(state);\n\n if (entry === null) return state;\n\n const hydrationState = internals.hydrationState;\n\n if (\n hydrationState !== null &&\n hydrationState.name === state.name &&\n // `in` — not `!== undefined` — is intentional. The contract is\n // \"scratchpad presence wins\": if the server explicitly serialised\n // a value into this namespace (even an `undefined` left over from\n // a programmatic state object), the plugin treats that as the\n // server's authoritative answer and skips re-running the loader\n // on the client. JSON-roundtrip strips `undefined` values, so in\n // practice this only matters for in-memory hydration paths —\n // see CLAUDE.md \"Gotchas → Hydration scratchpad: presence wins\".\n config.namespace in hydrationState.context\n ) {\n dataClaim.write(state, hydrationState.context[config.namespace] as T);\n reconstructDeferredFromHydration(state, hydrationState.context);\n } else if (entry.loader !== undefined) {\n writeLoaderResult(state, await entry.loader(state.params));\n }\n\n return state;\n },\n );\n\n // CSR revalidation channel for `invalidate(router, namespace)`.\n // Runs in the awaited LEAVE_APPROVE phase so fresh data lands on\n // `nextRoute.context` before `TRANSITION_SUCCESS` fires.\n // Flag is cleared only after a successful, non-cancelled loader write —\n // no-entry / client-only / cancelled navigations preserve it for retry.\n const removeLeaveListener = router.subscribeLeave(\n async ({ nextRoute, signal }) => {\n if (!isStale(router, config.namespace)) return;\n\n const entry = prepareEntry(nextRoute);\n\n if (entry === null || entry.loader === undefined) return;\n\n // Pass the navigation's signal so cancellation-aware loaders can\n // abort their in-flight work (fetch, DB query, etc.) when a newer\n // navigation supersedes this one. The post-await `signal.aborted`\n // check below remains as the final gate — loaders that ignore the\n // signal still benefit from the cancel-safety contract (#605).\n const data = await entry.loader(nextRoute.params, { signal });\n\n if (signal.aborted) return;\n\n clearStale(router, config.namespace);\n writeLoaderResult(nextRoute, data);\n },\n );\n\n return {\n teardown() {\n removeStartInterceptor();\n removeLeaveListener();\n dataClaim.release();\n modeClaim.release();\n deferredClaims?.value.release();\n deferredClaims?.keys.release();\n },\n };\n };\n}\n","import { ALL_SSR_MODES } from \"./types.js\";\n\nimport type { SsrMode } from \"./types.js\";\n\nexport function createLoadersValidator(\n errorPrefix: string,\n allowedModes: readonly SsrMode[] = ALL_SSR_MODES,\n) {\n return function validateLoaders(loaders: unknown): void {\n if (\n loaders === null ||\n typeof loaders !== \"object\" ||\n Array.isArray(loaders)\n ) {\n throw new TypeError(`${errorPrefix} loaders must be a non-null object`);\n }\n\n for (const [route, entry] of Object.entries(\n loaders as Record<string, unknown>,\n )) {\n if (typeof entry === \"function\") continue;\n\n if (entry === null || typeof entry !== \"object\" || Array.isArray(entry)) {\n throw new TypeError(\n `${errorPrefix} entry for route \"${route}\" must be a function or { ssr?, loader? } object`,\n );\n }\n\n for (const key of Object.keys(entry as Record<string, unknown>)) {\n if (key !== \"ssr\" && key !== \"loader\") {\n throw new TypeError(\n `${errorPrefix} unexpected key \"${key}\" in route \"${route}\" config`,\n );\n }\n }\n\n const obj = entry as { ssr?: unknown; loader?: unknown };\n\n if (obj.loader !== undefined && typeof obj.loader !== \"function\") {\n throw new TypeError(\n `${errorPrefix} loader for route \"${route}\" must be a function`,\n );\n }\n\n if (obj.ssr !== undefined) {\n const ssr = obj.ssr;\n\n if (typeof ssr === \"function\" || typeof ssr === \"boolean\") {\n continue;\n }\n\n if (typeof ssr === \"string\") {\n if (!(allowedModes as readonly string[]).includes(ssr)) {\n throw new TypeError(\n `${errorPrefix} mode \"${ssr}\" is not allowed for route \"${route}\". Allowed: ${allowedModes.join(\", \")}`,\n );\n }\n continue;\n }\n\n throw new TypeError(\n `${errorPrefix} ssr for route \"${route}\" must be SsrMode string, boolean, or (state) => SsrMode`,\n );\n }\n }\n };\n}\n","import { ALLOWED_RSC_MODES, ERROR_PREFIX } from \"./constants\";\nimport { createLoadersValidator, createSsrLoaderPlugin } from \"./shared-ssr\";\n\nimport type { RscLoaderFactoryMap } from \"./types\";\nimport type { DefaultDependencies, PluginFactory } from \"@real-router/types\";\nimport type { ReactNode } from \"react\";\n\n// Inlined from the deleted validation.ts — single 7-line consumer was\n// here, no other importer in src/ or tests/, so the indirection was\n// pure ceremony.\nconst validateLoaders = createLoadersValidator(ERROR_PREFIX, ALLOWED_RSC_MODES);\n\n/**\n * Plugin factory that loads per-route `ReactNode` (RSC payload) by intercepting\n * `router.start()`. Variant B from the RSC integration RFC: the plugin stores a\n * `ReactNode` on `state.context.rsc` — it does NOT render Flight bytes itself.\n *\n * The caller is responsible for piping the published `ReactNode` through the\n * appropriate bundler-specific renderer (e.g.\n * `@vitejs/plugin-rsc/rsc.renderToReadableStream`,\n * `react-server-dom-webpack/server.edge`, etc.) — keeping this plugin fully\n * bundler-agnostic.\n *\n * Sibling plugin `@real-router/ssr-data-plugin` follows the same factory\n * pattern via `createSsrLoaderPlugin` from `shared/ssr/`.\n *\n * @example\n * ```ts\n * const router = cloneRouter(baseRouter);\n *\n * router.usePlugin(rscServerPluginFactory({\n * \"users.profile\": () => async (params) => {\n * const user = await db.users.findById(params.id);\n * return <UserProfile user={user} />;\n * },\n * }));\n *\n * const state = await router.start(req.url);\n * if (state.context.rsc) {\n * const flight = renderToReadableStream(state.context.rsc);\n * // pipe flight to HTTP response\n * }\n * ```\n */\nexport function rscServerPluginFactory<\n Dependencies extends DefaultDependencies = DefaultDependencies,\n>(loaders: RscLoaderFactoryMap<Dependencies>): PluginFactory<Dependencies> {\n validateLoaders(loaders);\n\n return createSsrLoaderPlugin<ReactNode, Dependencies>(loaders, {\n namespace: \"rsc\",\n modeNamespace: \"ssrRscMode\",\n errorPrefix: ERROR_PREFIX,\n allowedModes: ALLOWED_RSC_MODES,\n });\n}\n","import { getPluginApi } from \"@real-router/core/api\";\n\nimport { ERROR_PREFIX } from \"./constants\";\n\nimport type { RscActionResult } from \"./types\";\nimport type {\n DefaultDependencies,\n Plugin,\n PluginFactory,\n} from \"@real-router/types\";\n\n/**\n * Per-start runtime validator for `getResult()` return values.\n *\n * Returns `null` when the value is acceptable (typed `RscActionResult`,\n * non-thenable, non-array, non-null object). Otherwise returns a short\n * descriptor used in the thrown `TypeError` message — keeps the error\n * actionable by pointing at the exact failure mode (`\"null\"`, `\"array\"`,\n * `\"Promise/thenable — wire your action result synchronously\"`, or the\n * raw `typeof` for primitives).\n *\n * Single source of truth for the two-decision pattern: \"throw or\n * accept\" + \"what to say in the error\". Previously the same checks\n * lived inline at the call site AND in `describeBadResult` — a typo in\n * one would silently break the symmetry. Unifying as one classifier\n * eliminates that drift class.\n */\nfunction classifyRscActionResult(value: unknown): string | null {\n if (value === null) {\n return \"null\";\n }\n if (Array.isArray(value)) {\n return \"array\";\n }\n if (typeof value !== \"object\") {\n return typeof value;\n }\n if (typeof (value as { then?: unknown }).then === \"function\") {\n return \"Promise/thenable — wire your action result synchronously\";\n }\n\n return null;\n}\n\n/**\n * Plugin factory that publishes a Server Action result to\n * `state.context.rscAction`. Pair with `rscServerPluginFactory` —\n * the `\"rsc\"` and `\"rscAction\"` namespaces are independent and the\n * two plugins coexist on the same router.\n *\n * The factory takes a `getResult` resolver evaluated at start-time\n * (inside the `start` interceptor, after the route resolves but\n * before the caller reads `state`). The caller has the action result\n * in scope (e.g. computed by `decodeAction` + `loadServerAction` in\n * their fetch handler) and returns it from the closure:\n *\n * @example\n * ```ts\n * let actionResult: RscActionResult | undefined;\n *\n * if (request.method === \"POST\") {\n * const decoded = await decodeAction(formData);\n * actionResult = { returnValue: { ok: true, data: await decoded() } };\n * }\n *\n * router.usePlugin(\n * rscServerPluginFactory(loaders),\n * rscActionPluginFactory(() => actionResult),\n * );\n *\n * const state = await router.start(pathname);\n * // state.context.rscAction === actionResult (or undefined)\n * ```\n *\n * When `getResult()` returns `undefined`, the interceptor skips the\n * write — `state.context.rscAction` stays `undefined`. Useful for\n * GET requests where there's no action to surface.\n *\n * The result is JSON-friendly (no ReactNode), so it serializes via\n * `serializeRouterState(state)` without needing `excludeContext`.\n * If you want to keep it server-side only (e.g. action result\n * contains secrets), pass `excludeContext: [\"rsc\", \"rscAction\"]`.\n */\nexport function rscActionPluginFactory<\n TReturn = unknown,\n TFormState = unknown,\n Dependencies extends DefaultDependencies = DefaultDependencies,\n>(\n getResult: () => RscActionResult<TReturn, TFormState> | undefined,\n): PluginFactory<Dependencies> {\n // Mirror the factory-time validation that `rscServerPluginFactory` and\n // `ssrDataPluginFactory` already perform on their loaders map: a TS-cast\n // bypass or a JS consumer can smuggle a non-function through, and the\n // failure would otherwise surface much later inside the start interceptor\n // as `TypeError: getResult is not a function`, after the `\"rscAction\"`\n // namespace has already been claimed and the start interceptor has been\n // registered. Failing eagerly with a typed, prefixed error keeps the API\n // consistent across all factories in this package.\n if (typeof getResult !== \"function\") {\n throw new TypeError(`${ERROR_PREFIX} getResult must be a function`);\n }\n\n return (router): Plugin => {\n const api = getPluginApi(router);\n const claim = api.claimContextNamespace(\"rscAction\");\n\n const removeStartInterceptor = api.addInterceptor(\n \"start\",\n async (next, path) => {\n const state = await next(path);\n // Read as `unknown`: the TS contract pins it to RscActionResult, but\n // we run a defensive shape guard below for cast-bypassed garbage.\n const result: unknown = getResult();\n\n if (result === undefined) {\n return state;\n }\n\n // Symmetry-with-loaders runtime guard. The TS contract is\n // `() => RscActionResult | undefined`, but the most common consumer\n // mistake is wiring an `async` getResult — TS allows it via cast,\n // and the resulting Promise would land in `state.context.rscAction`\n // and break every downstream `result.returnValue` access. Single\n // classifier — `classifyRscActionResult` is the source of truth for\n // BOTH the accept/reject decision AND the error description, so a\n // change to one cannot drift from the other.\n const badShape = classifyRscActionResult(result);\n\n if (badShape !== null) {\n throw new TypeError(\n `${ERROR_PREFIX} getResult must return an RscActionResult object or undefined (got ${badShape})`,\n );\n }\n\n claim.write(state, result as RscActionResult<TReturn, TFormState>);\n\n return state;\n },\n );\n\n return {\n teardown() {\n removeStartInterceptor();\n claim.release();\n },\n };\n };\n}\n","import type { RscActionResult, RscPayload } from \"./types\";\nimport type { State } from \"@real-router/types\";\nimport type { ReactNode } from \"react\";\n\n/**\n * Build a canonical Flight payload from `state.context.rsc` (+ optional\n * Server Component override) and `state.context.rscAction`.\n *\n * Removes the repeated `{ root, returnValue, formState }` boilerplate at\n * the call site:\n *\n * ```ts\n * import { renderToReadableStream } from \"@vitejs/plugin-rsc/rsc\";\n * const flight = renderToReadableStream(buildRscPayload(state));\n * ```\n *\n * Pass `rootOverride` to wrap the per-route Server Component tree (e.g.\n * with cross-cutting layout chrome) without rebuilding the payload by\n * hand:\n *\n * ```ts\n * const wrapped = (\n * <>\n * <NotificationBanner action={state.context.rscAction} />\n * {state.context.rsc}\n * </>\n * );\n * const payload = buildRscPayload<MyData, ReactFormState>(state, wrapped);\n * ```\n *\n * `rootOverride === undefined` means \"use the default\" (`state.context.rsc`).\n * Pass `null` to explicitly render nothing — `null` is a valid `ReactNode`\n * and is preserved as-is, **not** treated as \"fall back to default\".\n *\n * `returnValue` and `formState` are **omitted** (not set to `undefined`)\n * when their source is missing, so the result type-checks under\n * `exactOptionalPropertyTypes: true` consumers without ceremony.\n */\nexport function buildRscPayload<TReturn = unknown, TFormState = unknown>(\n state: State,\n rootOverride?: ReactNode,\n): RscPayload<TReturn, TFormState> {\n const ctx = state.context as {\n rsc?: ReactNode;\n rscAction?: RscActionResult<TReturn, TFormState>;\n };\n\n // `??` would collapse an explicit `null` override to the default — use a\n // strict `=== undefined` check so callers can render nothing on purpose.\n const root = rootOverride === undefined ? ctx.rsc : rootOverride;\n\n const payload: RscPayload<TReturn, TFormState> = { root };\n const action = ctx.rscAction;\n\n if (action?.returnValue !== undefined) {\n payload.returnValue = action.returnValue;\n }\n\n if (action?.formState !== undefined) {\n payload.formState = action.formState;\n }\n\n return payload;\n}\n","import { ALLOWED_RSC_MODES } from \"./constants\";\n\nimport type { RscSsrMode } from \"./types\";\nimport type { State } from \"@real-router/types\";\n\n/**\n * Returns the SSR mode resolved by `rsc-server-plugin` for the current state.\n * Falls back to `\"full\"` when the route has no plugin entry.\n *\n * Read this from `entry-server.tsx` to branch on full vs client-only:\n * - `\"full\"` — render the Server Component tree, pipe Flight stream.\n * - `\"client-only\"` — ship shell HTML and let the client fetch via its own mechanism.\n *\n * The mode is written to `state.context.ssrRscMode` by the plugin's `start`\n * interceptor for every route registered in the loaders map.\n *\n * Defensive read: if `state.context.ssrRscMode` was set to something outside\n * `ALLOWED_RSC_MODES` by a TS-cast bypass or a foreign writer, the function\n * collapses it to `\"full\"` rather than returning the bad value. Without this\n * guard, a downstream `mode === \"full\"` branch would silently misbehave for\n * `0`, `false`, `\"\"`, `null`, or any unknown string.\n *\n * The read itself is wrapped in `try/catch` — a foreign writer that installs\n * a throwing getter (`Object.defineProperty(ctx, \"ssrRscMode\", { get() { throw … } })`)\n * cannot break the contract. The function NEVER throws, no matter how\n * adversarial the context shape. `\"full\"` is the safe default for any error.\n */\nexport function getSsrRscMode(state: State): RscSsrMode {\n let raw: unknown;\n\n try {\n raw = (state.context as { ssrRscMode?: unknown }).ssrRscMode;\n } catch {\n return \"full\";\n }\n\n return typeof raw === \"string\" &&\n ALLOWED_RSC_MODES.includes(raw as RscSsrMode)\n ? (raw as RscSsrMode)\n : \"full\";\n}\n","import { markStale } from \"./shared-ssr\";\n\nimport type { Router } from \"@real-router/types\";\n\n/**\n * Mark the `\"rsc\"` namespace as stale on the given router. The next\n * navigation (including a same-route reload) re-runs the RSC loader for the\n * destination route and overwrites `state.context.rsc` (and the mode marker)\n * via the plugin's `subscribeLeave` listener.\n *\n * Honest fire-and-forget semantics — returns `void`. The flag is consumed in\n * the awaited LEAVE_APPROVE phase of the next navigation, so subscribers see\n * a fresh `ReactNode` when the navigation completes. Behaviour during an\n * in-flight transition: the current transition completes unchanged; the flag\n * is read by the *following* navigation. This keeps the invariant\n * \"one transition = one `state.context` snapshot\" intact.\n *\n * Composability through the existing core API:\n *\n * ```ts\n * // Fire-and-forget: stale until the user navigates somewhere\n * invalidate(router, \"rsc\");\n *\n * // Explicit await — pair with a same-route reload\n * invalidate(router, \"rsc\");\n * await router.navigate(state.name, state.params, { reload: true });\n * ```\n *\n * Surgical alternative to `router.navigate({ reload: true })` for multi-\n * namespace routes: only the `\"rsc\"` namespace re-runs; a side-by-side\n * `ssr-data-plugin` keeps its cached `state.context.data` on this transition\n * unless its own `invalidate()` was also called.\n */\nexport function invalidate(router: Router, namespace: \"rsc\"): void {\n markStale(router, namespace);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/constants.ts","../../../../shared/ssr/defer.ts","../../../../shared/ssr/deferRegistry.ts","../../../../shared/ssr/staleRegistry.ts","../../../../shared/ssr/types.ts","../../../../shared/ssr/createSsrLoaderPlugin.ts","../../../../shared/ssr/createLoadersValidator.ts","../../src/factory.ts","../../src/actionFactory.ts","../../src/buildRscPayload.ts","../../src/getSsrRscMode.ts","../../src/invalidate.ts"],"sourcesContent":["import type { RscSsrMode } from \"./types\";\n\nconst LOGGER_CONTEXT = \"rsc-server-plugin\";\n\nexport const ERROR_PREFIX = `[@real-router/${LOGGER_CONTEXT}]`;\n\n/**\n * The strict subset of `SsrMode` that `rsc-server-plugin` accepts.\n * `\"data-only\"` is intentionally excluded — RSC has no semantically meaningful\n * \"data without component\" (the Flight payload IS the data + component).\n *\n * Single source of truth for `factory.ts` (`createSsrLoaderPlugin` allowedModes),\n * `validation.ts` (factory-time loader-map validator), and `getSsrRscMode.ts`\n * (runtime read-side guard against TS-cast-bypassed garbage in `state.context`).\n */\nexport const ALLOWED_RSC_MODES: readonly RscSsrMode[] = [\"full\", \"client-only\"];\n","/**\n * Marker symbol for `defer()` payloads. `Symbol.for` is used so the brand\n * survives across multiple module instances (a real concern in monorepo setups\n * with multiple `node_modules/@real-router/ssr-data-plugin` copies).\n */\nexport const DEFER_BRAND: unique symbol = Symbol.for(\n \"@real-router/ssr-data-plugin/defer\",\n);\n\nexport interface DeferredPayload<\n C,\n D extends Record<string, Promise<unknown>>,\n> {\n readonly critical: C;\n readonly deferred: D;\n readonly [DEFER_BRAND]: true;\n}\n\n/**\n * Wraps a loader return value to declare a critical/deferred split.\n *\n * - `critical` resolves before HTML render (blocks the shell).\n * - `deferred` is a record of named promises that the framework can stream\n * independently — `<Suspense>`, `<Await/>`, `{#await}`, etc.\n *\n * The plugin writes `critical` to `state.context.<namespace>` (e.g. `data`)\n * and the deferred promises to `state.context.<namespace>Deferred` (e.g.\n * `ssrDataDeferred`). Adapter-side `useDeferred(key)` reads from the same\n * shape and returns the matching promise for native framework awaiting.\n *\n * On the server: `state.context.ssrDataDeferred[key]` is the actual promise\n * the loader produced. On the client (post-hydration): the plugin reconstructs\n * promises from the global `__rrDeferRegistry__` that inline `__rrDefer__()`\n * scripts populate as the server stream lands.\n */\nexport function defer<\n const C,\n const D extends Record<string, Promise<unknown>>,\n>(options: { readonly critical: C; readonly deferred: D }): DeferredPayload<\n C,\n D\n> {\n if (options === null || typeof options !== \"object\") {\n throw new TypeError(\n \"[defer] expected an object with `critical` and `deferred` fields\",\n );\n }\n\n if (\n options.deferred === null ||\n typeof options.deferred !== \"object\" ||\n Array.isArray(options.deferred)\n ) {\n throw new TypeError(\n \"[defer] `deferred` must be a non-null, non-array object of promises\",\n );\n }\n\n for (const [key, value] of Object.entries(options.deferred)) {\n // Reserved keys would corrupt the prototype chain when the client-side\n // plugin reconstructs the deferred map via `[key] = ensureRegistryPromise(key)`.\n // The reconstruction path uses a null-prototype object as a defence-in-depth\n // measure, but rejecting these keys upstream keeps the wire-format\n // symmetric (server-side payload === client-side reconstruction).\n if (key === \"__proto__\" || key === \"constructor\" || key === \"prototype\") {\n throw new TypeError(\n `[defer] \\`deferred.${key}\\` is reserved — choose a different key`,\n );\n }\n\n if (\n value === null ||\n typeof value !== \"object\" ||\n typeof (value as { then?: unknown }).then !== \"function\"\n ) {\n throw new TypeError(\n `[defer] \\`deferred.${key}\\` must be a Promise (got ${typeof value})`,\n );\n }\n\n // Defensive sibling-handler: an eagerly-rejected promise (e.g.\n // `Promise.reject(new Error(...))` synchronously inside the loader)\n // races the server-side `injectDeferredScripts` `.then(...)`\n // attachment. Without a handler attached at construction time, Node\n // emits an `unhandledRejection` warning before the wire-format\n // settler can register. The no-op `.catch` does not consume the\n // rejection — it only marks the promise as \"handled\" for Node's\n // tracker, so the real settler still observes the rejection and\n // emits the `__rrDeferError__` script.\n //\n // Duck-typed thenables (no `.catch`) are skipped: Node's\n // unhandledRejection tracker only fires for native Promise objects,\n // so non-Promise thenables don't need the suppression anyway.\n const maybeCatch = (value as { catch?: unknown }).catch;\n\n if (typeof maybeCatch === \"function\") {\n (value as Promise<unknown>).catch(() => {\n /* no-op — see comment above */\n });\n }\n }\n\n // Freeze a *shallow clone* of the deferred map (rather than the user's\n // own reference) so:\n // 1. `Object.freeze` doesn't surprise the caller by freezing an object\n // they still hold a reference to.\n // 2. Post-`defer()` mutations to the user's original map (e.g.\n // `userMap.evil = somePromise`) cannot smuggle in entries that\n // bypass the validation/`.catch` loop above. Without this, a late\n // `userMap.__proto__ = …` or an eagerly-rejected promise added\n // after this call would land in `injectDeferredScripts` unchecked.\n // The clone is shallow — promise references are preserved, so the\n // settle pipeline observes the same Promise instances the validator\n // examined.\n return Object.freeze({\n critical: options.critical,\n deferred: Object.freeze({ ...options.deferred }) as D,\n [DEFER_BRAND]: true,\n });\n}\n\n/** Type guard — `true` iff `value` is a payload returned by `defer()`.\n *\n * The brand check uses `Object.hasOwn(value, DEFER_BRAND)` rather than a\n * plain property read so a prototype-chain inheritance bypass —\n * `Object.create({ [DEFER_BRAND]: true })` — does not falsely tag an\n * object as a deferred payload. The brand symbol is a `Symbol.for(...)`,\n * so a brand-marked object inherited by accident from a foreign realm\n * could otherwise sneak past `defer()`'s validation and land in\n * `processLoaderResult`'s slow path with no `critical`/`deferred` fields.\n */\nexport function isDeferred(\n value: unknown,\n): value is DeferredPayload<unknown, Record<string, Promise<unknown>>> {\n return (\n value !== null &&\n typeof value === \"object\" &&\n Object.hasOwn(value, DEFER_BRAND) &&\n (value as Record<symbol, unknown>)[DEFER_BRAND] === true\n );\n}\n","/**\n * Client-side registry for deferred values streamed from the server.\n *\n * The contract spans three actors:\n *\n * 1. **Server stream injects `<script>__rrDefer__(\"key\", \"json\")</script>`\n * tags** as each loader-returned promise resolves. The bootstrap script\n * (also server-emitted) installs `__rrDefer__` and the registry on\n * `globalThis` before any settle script runs.\n *\n * 2. **Plugin start interceptor** (post-hydration scratchpad path) reads the\n * `<deferredKeysNamespace>` list from the hydrated state, then calls\n * `ensureRegistryPromise(key)` once per key to obtain the promise that\n * `useDeferred()` will return. This ensures a stable Promise reference\n * across the initial render and any inline-script settlements.\n *\n * 3. **Adapter `useDeferred(key)`** reads from `state.context.<deferredNamespace>`\n * which the plugin populated above. The returned Promise integrates with\n * React `use()`, Solid `<Await/>`, Svelte `{#await}`, etc.\n */\n\ninterface RegistryEntry {\n promise: Promise<unknown>;\n resolve: (value: unknown) => void;\n reject: (error: unknown) => void;\n}\n\nconst REGISTRY_GLOBAL_KEY = \"__rrDeferRegistry__\";\nconst SETTLE_FN_NAME = \"__rrDefer__\";\nconst REJECT_FN_NAME = \"__rrDeferError__\";\n\ninterface DeferGlobal {\n [REGISTRY_GLOBAL_KEY]?: Map<string, RegistryEntry>;\n [SETTLE_FN_NAME]?: (key: string, json: string) => void;\n [REJECT_FN_NAME]?: (key: string, json: string) => void;\n}\n\nfunction getGlobal(): DeferGlobal {\n return globalThis as unknown as DeferGlobal;\n}\n\nfunction getOrCreateRegistry(): Map<string, RegistryEntry> {\n const g = getGlobal();\n let registry = g[REGISTRY_GLOBAL_KEY];\n\n if (registry === undefined) {\n registry = new Map<string, RegistryEntry>();\n g[REGISTRY_GLOBAL_KEY] = registry;\n }\n\n return registry;\n}\n\n/**\n * Returns the registered Promise for `key`, creating a fresh pending entry on\n * first access. Stable across calls — `useDeferred` relies on Promise\n * reference identity for React `use()` to track resolution.\n */\nexport function ensureRegistryPromise(key: string): Promise<unknown> {\n const registry = getOrCreateRegistry();\n let entry = registry.get(key);\n\n if (entry === undefined) {\n let resolve!: (value: unknown) => void;\n let reject!: (error: unknown) => void;\n\n const promise = new Promise<unknown>((res, rej) => {\n resolve = res;\n reject = rej;\n });\n\n entry = { promise, resolve, reject };\n registry.set(key, entry);\n }\n\n return entry.promise;\n}\n\n/**\n * Returns the inline bootstrap script (no `<script>` wrapper). Embed in a\n * `<script>` tag emitted **once before any `__rrDefer__()` call lands** in\n * the response stream. Idempotent — re-installing is a no-op.\n *\n * The script source is kept terse (ES5-ish, no template literals, no\n * arrow functions) so it works without transpilation in legacy browsers and\n * stays under ~600 bytes uncompressed.\n */\nexport function getDeferBootstrapScript(): string {\n // The script idempotently installs __rrDefer__/__rrDeferError__ on `g`. If\n // the registry already exists (e.g. from a prior call to\n // ensureRegistryPromise on the client adapter), reuse it — only the settle\n // functions are (re)assigned. This handles the realistic ordering:\n // adapter creates the registry during hydration; the first settle script\n // arriving in the response stream installs the global functions.\n return (\n \"(function(g){\" +\n `var R=g.${REGISTRY_GLOBAL_KEY};` +\n `if(!R)R=g.${REGISTRY_GLOBAL_KEY}=new Map();` +\n \"function E(k){\" +\n \"var e=R.get(k);\" +\n \"if(!e){\" +\n \"var rs,rj;\" +\n \"var p=new Promise(function(r,j){rs=r;rj=j});\" +\n \"e={promise:p,resolve:rs,reject:rj};\" +\n \"R.set(k,e)\" +\n \"}\" +\n \"return e\" +\n \"}\" +\n `g.${SETTLE_FN_NAME}=function(k,j){E(k).resolve(JSON.parse(j))};` +\n `g.${REJECT_FN_NAME}=function(k,j){` +\n \"var d=JSON.parse(j);\" +\n 'var er=new Error(d&&d.message?d.message:\"deferred error\");' +\n \"if(d&&d.name)er.name=d.name;\" +\n \"E(k).reject(er)\" +\n \"}\" +\n \"})(typeof globalThis!=='undefined'?globalThis:\" +\n \"(typeof window!=='undefined'?window:self));\"\n );\n}\n\n// Single-pass replacement table for the chars escapeForScript must encode\n// as `\\uXXXX` to keep them out of the raw HTML parser. Five consecutive\n// `replace` / `split`+`join` passes used to walk the string for each\n// codepoint; the regex + lookup form does it in one pass — ~1.6× faster\n// on large payloads, indistinguishable on short keys (the common case).\n//\n// Roundtrip + HTML-safety properties are pinned by the\n// `escapeForScript: pure-function security invariants` PBT block in\n// `tests/property/ssr-data.properties.ts` (numRuns: 1000).\n//\n// Built at module init via `String.fromCodePoint(...)` so the source file\n// itself never contains raw U+2028 / U+2029 codepoints (which would\n// terminate string literals / regex literals at parse time on legacy\n// JS engines and even in modern TS parsers under some configs).\nconst ESCAPE_FOR_SCRIPT_PAIRS: readonly (readonly [string, string])[] = [\n [\"<\", \"\\\\u003c\"],\n [\">\", \"\\\\u003e\"],\n [\"&\", \"\\\\u0026\"],\n [String.fromCodePoint(0x20_28), \"\\\\u2028\"],\n [String.fromCodePoint(0x20_29), \"\\\\u2029\"],\n] as const;\nconst ESCAPE_FOR_SCRIPT_TABLE: Record<string, string> = Object.fromEntries(\n ESCAPE_FOR_SCRIPT_PAIRS,\n);\nconst ESCAPE_FOR_SCRIPT_REGEX = new RegExp(\n `[${ESCAPE_FOR_SCRIPT_PAIRS.map(([c]) => c).join(\"\")}]`,\n \"g\",\n);\n\n/**\n * Encode an arbitrary string as a **JS string literal** that is also safe to\n * embed inside a `<script>...</script>` body. Returns the literal **with**\n * surrounding quotes — drop it directly into a script template.\n *\n * Encoding via Unicode escapes (`\\uXXXX`) means:\n * - The raw HTML parser sees no `<`, `>`, U+2028, or U+2029 — so it cannot\n * terminate the script tag prematurely (`</script>`, `<!--`) or trigger\n * legacy JS line-terminator interpretation.\n * - The JS parser interprets `<`/`>`/`
`/`
` back to\n * their original chars, so the runtime string value is bit-identical to\n * the input.\n * - Crucially, the same encoding works for two consumer paths:\n * 1. **Plain JS literal** (e.g. the deferred KEY): the JS parser hands\n * back the original string directly.\n * 2. **JS literal containing JSON** (e.g. the deferred VALUE): the JS\n * parser hands back a string with `<` text inside (the leading\n * `\\\\` of `\\\\u003c` escaped to `\\`, then `u003c` is plain text), and\n * `JSON.parse` then unescapes `<` → `<`. Net round-trip is\n * identity.\n * Both decode paths land on the original string — so the same\n * `escapeForScript` works for both keys (parsed as JS literal) and values\n * (parsed as JS literal containing JSON).\n *\n * The `&` → `&` substitution defends against `<![CDATA[` / template\n * engine post-processing that might re-interpret HTML entities; it is not\n * strictly necessary for `<script>` body parsing but cheap and conservative.\n */\nexport function escapeForScript(value: string): string {\n // The TS contract is `value: string`, but a cast at a callsite or a\n // misbehaving custom serializer can still smuggle a non-string through.\n // Three failure modes JSON.stringify can have on non-strings:\n // - returns `undefined` (`stringify(undefined)`, `stringify(symbol)`,\n // `stringify(function)`),\n // - throws (`stringify(bigint)` → `TypeError`,\n // `stringify(circular)` → `TypeError`),\n // - returns `\"null\"` (already safe for our pipeline).\n // Catch both and emit the JSON `null` literal — the safest single-token\n // representation that JSON.parse will accept downstream.\n let json: string | undefined;\n\n try {\n json = JSON.stringify(value);\n } catch {\n json = undefined;\n }\n\n if (typeof json !== \"string\") {\n return \"null\";\n }\n\n return json.replace(\n ESCAPE_FOR_SCRIPT_REGEX,\n (c) => ESCAPE_FOR_SCRIPT_TABLE[c] ?? c,\n );\n}\n\n/**\n * Format a single settle script for one resolved promise.\n * Output: `<script>__rrDefer__(\"key\",\"jsonString\")</script>`. Both `key`\n * and `serializedValue` are user-controlled in the general case (route\n * params can flow into deferred-map keys; loader returns flow into values),\n * so both go through {@link escapeForScript}.\n */\nexport function formatSettleScript(\n key: string,\n serializedValue: string,\n isError: boolean,\n): string {\n const fn = isError ? REJECT_FN_NAME : SETTLE_FN_NAME;\n const safeKey = escapeForScript(key);\n const safeValue = escapeForScript(serializedValue);\n\n return `<script>${fn}(${safeKey},${safeValue})</script>`;\n}\n\n/** Test-only — clears the global registry. Not exported from index.ts. */\nexport function __resetRegistryForTests(): void {\n const g = getGlobal();\n delete g[REGISTRY_GLOBAL_KEY];\n delete g[SETTLE_FN_NAME];\n delete g[REJECT_FN_NAME];\n}\n","import type { Router } from \"@real-router/types\";\n\nconst staleByRouter = new WeakMap<Router, Set<string>>();\n\n/**\n * Mark a context namespace as stale on the given router. The next navigation\n * that lands on a route with a registered loader for this namespace consumes\n * the flag in the SSR loader plugin's `subscribeLeave` handler — runs the\n * loader, overwrites `state.context.<namespace>`, and clears the flag.\n *\n * Idempotent (Set-deduplicated). Survives navigations that cannot refresh:\n * routes without an entry, `client-only` mode, mode-only entries, and\n * cancelled navigations all preserve the flag for the next attempt. The\n * flag is cleared only after the loader successfully runs and writes data.\n *\n * Returns `void` (fire-and-forget). For an explicit synchronous round-trip,\n * compose with the existing core API:\n * ```ts\n * markStale(router, \"data\");\n * await router.navigate(state.name, state.params, { reload: true });\n * ```\n */\nexport function markStale(router: Router, namespace: string): void {\n let set = staleByRouter.get(router);\n\n if (set === undefined) {\n set = new Set<string>();\n staleByRouter.set(router, set);\n }\n\n set.add(namespace);\n}\n\n/** Plugin-internal: peek without consuming. */\nexport function isStale(router: Router, namespace: string): boolean {\n return staleByRouter.get(router)?.has(namespace) ?? false;\n}\n\n/** Plugin-internal: clear the flag (no-op if not set). */\nexport function clearStale(router: Router, namespace: string): void {\n staleByRouter.get(router)?.delete(namespace);\n}\n","import type {\n DefaultDependencies,\n Params,\n Router,\n State,\n} from \"@real-router/types\";\n\nexport type SsrMode = \"full\" | \"data-only\" | \"client-only\";\n\nexport const ALL_SSR_MODES: readonly SsrMode[] = [\n \"full\",\n \"data-only\",\n \"client-only\",\n];\n\n/**\n * Resolves the SSR mode for a route per-navigation.\n *\n * Receives the resolved post-routing `state` (with `name`, `params`, `path`)\n * and returns one of the allowed `SsrMode` values for the host plugin.\n *\n * The resolver is invoked **before** the plugin writes the mode marker to\n * `state.context.<modeNamespace>`, so reading `state.context.ssrDataMode` /\n * `state.context.ssrRscMode` here yields `undefined`. Branch on\n * `state.params`, `state.path`, or `state.name` instead.\n *\n * Throwing from the resolver propagates through `start()` (standard\n * navigation error pipeline) — no partial mode write occurs. Returning a\n * value outside the host plugin's `allowedModes` rejects with a typed\n * `TypeError` at runtime.\n */\nexport type SsrModeResolver<M extends SsrMode = SsrMode> = (state: State) => M;\n\nexport type SsrModeConfig<M extends SsrMode = SsrMode> =\n | M\n | boolean\n | SsrModeResolver<M>;\n\n/**\n * Optional context object passed to the loader. The `signal` field is the\n * navigation's `AbortController.signal` when the plugin's `subscribeLeave`\n * handler invokes the loader (#605 `invalidate()` → CSR refresh path);\n * `undefined` from the `start` interceptor (SSR boot path — apps that need\n * a request-scoped signal use `getDep(\"abortSignal\")` injected via\n * `cloneRouter(base, { abortSignal })`, see `createRequestScope` and\n * `withTimeout({ upstreamSignal })` patterns).\n *\n * Loaders ignoring the second argument remain compatible (TypeScript\n * contravariance).\n */\nexport interface SsrLoaderContext {\n signal: AbortSignal;\n}\n\nexport type SsrLoaderFn<T> = (\n params: Params,\n context?: SsrLoaderContext,\n) => Promise<T> | T;\n\nexport type SsrLoaderFnFactory<\n T,\n Dependencies extends DefaultDependencies = DefaultDependencies,\n> = (\n router: Router<Dependencies>,\n getDependency: <K extends keyof Dependencies>(key: K) => Dependencies[K],\n) => SsrLoaderFn<T>;\n\nexport interface SsrRouteEntryObject<\n T,\n M extends SsrMode = SsrMode,\n Dependencies extends DefaultDependencies = DefaultDependencies,\n> {\n ssr?: SsrModeConfig<M>;\n loader?: SsrLoaderFnFactory<T, Dependencies>;\n}\n\nexport type SsrRouteEntry<\n T,\n M extends SsrMode = SsrMode,\n Dependencies extends DefaultDependencies = DefaultDependencies,\n> =\n | SsrLoaderFnFactory<T, Dependencies>\n | SsrRouteEntryObject<T, M, Dependencies>;\n\nexport type SsrLoaderFactoryMap<\n T,\n M extends SsrMode = SsrMode,\n Dependencies extends DefaultDependencies = DefaultDependencies,\n> = Record<string, SsrRouteEntry<T, M, Dependencies>>;\n\nexport interface SsrLoaderPluginConfig {\n namespace: string;\n modeNamespace: string;\n errorPrefix: string;\n allowedModes?: readonly SsrMode[];\n /**\n * When set, the plugin recognises `defer()` payloads from loaders.\n * Critical data is written to `<namespace>`, deferred promises to\n * `<deferredNamespace>`, and the deferred key list (for client-side\n * registry hydration) to `<deferredKeysNamespace>`.\n *\n * Both fields must be set together; one without the other rejects at\n * factory-time.\n */\n deferredNamespace?: string;\n deferredKeysNamespace?: string;\n}\n","import { getPluginApi } from \"@real-router/core/api\";\nimport { getInternals } from \"@real-router/core/validation\";\n\nimport { isDeferred } from \"./defer.js\";\nimport { ensureRegistryPromise } from \"./deferRegistry.js\";\nimport { clearStale, isStale } from \"./staleRegistry.js\";\nimport { ALL_SSR_MODES } from \"./types.js\";\n\nimport type {\n SsrLoaderFactoryMap,\n SsrLoaderFn,\n SsrLoaderPluginConfig,\n SsrMode,\n SsrModeConfig,\n} from \"./types.js\";\nimport type {\n ContextNamespaceClaim,\n DefaultDependencies,\n Plugin,\n PluginFactory,\n Router,\n State,\n} from \"@real-router/types\";\n\ninterface CompiledEntry<T> {\n /**\n * Pre-resolved mode for static `ssr` configs (undefined / boolean /\n * string). `null` marker means \"function-form resolver — must call\n * `resolveMode(modeFn, state, …)` at navigation time\". Pre-computing\n * skips the `resolveMode` walk on every `start()` + every stale-flag\n * leave handler invocation for the common static-config case.\n */\n staticMode: SsrMode | null;\n /**\n * Function-form mode resolver. Defined ONLY when `obj.ssr` is a\n * function; `undefined` for static forms (where `staticMode` is\n * authoritative). Kept as a typed field rather than reusing the\n * raw `obj.ssr` so the prepareEntry call site avoids a `typeof`\n * branch per navigation.\n */\n modeFn: ((state: State) => SsrMode) | undefined;\n loader: SsrLoaderFn<T> | undefined;\n}\n\n/**\n * Compile a `SsrLoaderFactoryMap` into a `Map<name, CompiledEntry>`.\n *\n * Extracted from the inline `for (const [name, raw] of …)` body that lived\n * inside `createSsrLoaderPlugin` so the main function reads top-down:\n * claims are acquired, compilation runs against this helper, and any throw\n * bubbles to the shared `rollback()` path. Tested in isolation by the same\n * functional + property suites that pin the previous inline behaviour.\n *\n * The compile step is pure — it touches no router state other than via the\n * caller-provided `router` + `getDependency` arguments, and it only walks\n * own-enumerable entries (`Object.entries`) so prototype pollution stays\n * structurally impossible.\n *\n * Mode pre-resolution: static `ssr` forms (`undefined` / boolean / string)\n * are resolved here at compile time and cached as `staticMode`. The\n * runtime path in `prepareEntry` then reuses the cached value on every\n * `start()` + stale-flag leave handler invocation, skipping the\n * `resolveMode` if/else chain. Function-form `ssr` keeps a typed\n * `modeFn` for per-navigation evaluation.\n */\nfunction compile<\n T,\n Dependencies extends DefaultDependencies = DefaultDependencies,\n>(\n loaders: SsrLoaderFactoryMap<T, SsrMode, Dependencies>,\n router: Router<Dependencies>,\n getDependency: <K extends keyof Dependencies>(key: K) => Dependencies[K],\n errorPrefix: string,\n allowed: readonly SsrMode[],\n): Map<string, CompiledEntry<T>> {\n const compiled = new Map<string, CompiledEntry<T>>();\n\n for (const [name, raw] of Object.entries(loaders)) {\n const obj = typeof raw === \"function\" ? { loader: raw } : raw;\n\n let loader: SsrLoaderFn<T> | undefined;\n\n if (obj.loader !== undefined) {\n const fn = obj.loader(router, getDependency);\n\n if (typeof fn !== \"function\") {\n throw new TypeError(\n `${errorPrefix} factory for route \"${name}\" must return a function`,\n );\n }\n\n loader = fn;\n }\n\n // Pre-resolve static modes; defer function-form to navigation-time.\n // The `resolveMode` runtime helper still validates function-form\n // returns AND any forms that the validator passed but createSsrLoaderPlugin's\n // narrower `allowedModes` rejects (consumer-specific allow-list).\n let staticMode: SsrMode | null = null;\n let modeFn: ((state: State) => SsrMode) | undefined;\n\n if (typeof obj.ssr === \"function\") {\n modeFn = obj.ssr;\n } else {\n // Static — undefined/true/false/string. Pass a synthetic state;\n // resolveMode ignores `state` for non-function forms.\n staticMode = resolveMode(\n obj.ssr,\n SYNTHETIC_STATE,\n allowed,\n errorPrefix,\n name,\n );\n }\n\n compiled.set(name, { staticMode, modeFn, loader });\n }\n\n return compiled;\n}\n\n// Placeholder state for compile-time static-mode resolution. The\n// resolveMode function reads `state` only for the function-form branch,\n// so any non-null reference works for the static branches. Kept module-\n// level so all compile() calls share one allocation.\nconst SYNTHETIC_STATE = {\n name: \"\",\n params: {},\n path: \"\",\n transition: {\n phase: \"activating\",\n reason: \"success\",\n segments: { deactivated: [], activated: [], intersection: \"\" },\n },\n context: {},\n} as unknown as State;\n\nfunction rejectMode(\n value: unknown,\n allowed: readonly SsrMode[],\n prefix: string,\n route: string,\n): never {\n throw new TypeError(\n `${prefix} mode \"${String(value)}\" is not allowed for route \"${route}\". Allowed: ${allowed.join(\", \")}`,\n );\n}\n\nfunction resolveMode(\n ssr: SsrModeConfig | undefined,\n state: State,\n allowed: readonly SsrMode[],\n prefix: string,\n route: string,\n): SsrMode {\n if (ssr === undefined || ssr === true) return \"full\";\n\n if (ssr === false) {\n if (!allowed.includes(\"client-only\")) {\n rejectMode(\"client-only\", allowed, prefix, route);\n }\n\n return \"client-only\";\n }\n\n const value = typeof ssr === \"function\" ? ssr(state) : ssr;\n\n if (typeof value !== \"string\" || !allowed.includes(value as SsrMode)) {\n rejectMode(value, allowed, prefix, route);\n }\n\n return value;\n}\n\nexport function createSsrLoaderPlugin<\n T,\n Dependencies extends DefaultDependencies = DefaultDependencies,\n>(\n loaders: SsrLoaderFactoryMap<T, SsrMode, Dependencies>,\n config: SsrLoaderPluginConfig,\n): PluginFactory<Dependencies> {\n if (\n (config.deferredNamespace !== undefined) !==\n (config.deferredKeysNamespace !== undefined)\n ) {\n throw new TypeError(\n `${config.errorPrefix} \\`deferredNamespace\\` and \\`deferredKeysNamespace\\` must be set together`,\n );\n }\n\n // Bundle the two namespace strings into a single nullable object so\n // downstream code narrows via `if (deferredConfig !== null)` instead\n // of the `config.deferredNamespace!` non-null assertion that TS can't\n // derive from the XOR check above.\n const deferredConfig =\n config.deferredNamespace !== undefined &&\n config.deferredKeysNamespace !== undefined\n ? {\n valueNamespace: config.deferredNamespace,\n keysNamespace: config.deferredKeysNamespace,\n }\n : null;\n\n return (router, getDependency): Plugin => {\n const api = getPluginApi(router);\n const allowed = config.allowedModes ?? ALL_SSR_MODES;\n\n // Sequential claim acquisition with all-or-nothing rollback. Any\n // failure (collision, validation error during compile loop) releases\n // every claim acquired so far and rethrows. This replaces the\n // previous 4 nested try/catch blocks with progressively-longer\n // release lists — same semantics, one shared rollback path.\n const acquired: ContextNamespaceClaim[] = [];\n const claim = (namespace: string): ContextNamespaceClaim => {\n const c = api.claimContextNamespace(namespace);\n acquired.push(c);\n return c;\n };\n const rollback = (): void => {\n for (const c of acquired) c.release();\n };\n\n let dataClaim: ContextNamespaceClaim;\n let modeClaim: ContextNamespaceClaim;\n let deferredClaims: {\n value: ContextNamespaceClaim;\n keys: ContextNamespaceClaim;\n } | null = null;\n let compiled: Map<string, CompiledEntry<T>>;\n\n try {\n dataClaim = claim(config.namespace);\n modeClaim = claim(config.modeNamespace);\n\n if (deferredConfig !== null) {\n deferredClaims = {\n value: claim(deferredConfig.valueNamespace),\n keys: claim(deferredConfig.keysNamespace),\n };\n }\n\n compiled = compile(\n loaders,\n router,\n getDependency,\n config.errorPrefix,\n allowed,\n );\n } catch (error) {\n rollback();\n\n throw error;\n }\n\n const internals = getInternals(router);\n\n // Hot path on every successful start() / subscribeLeave refresh. The\n // previous shape ran a `processLoaderResult` helper that always allocated\n // a `{ critical, deferred }` wrapper object — wasted on the common\n // plain-data path (and on every call from `rsc-server-plugin`, which\n // never opts into deferred support). Inlining the branch keeps the\n // fast path allocation-free and the slow path (defer payload) at one\n // intentional `Object.keys(...)` array allocation per loader.\n const writeLoaderResult = (state: State, value: T): void => {\n if (deferredClaims !== null && isDeferred(value)) {\n dataClaim.write(state, value.critical as T);\n deferredClaims.value.write(state, value.deferred);\n deferredClaims.keys.write(state, Object.keys(value.deferred));\n\n return;\n }\n\n dataClaim.write(state, value);\n };\n\n const reconstructDeferredFromHydration = (\n state: State,\n hydrated: Record<string, unknown>,\n ): void => {\n if (deferredConfig === null || deferredClaims === null) return;\n\n const keysRaw = hydrated[deferredConfig.keysNamespace];\n\n if (!Array.isArray(keysRaw)) return;\n\n const keys = keysRaw.filter(\n (k): k is string =>\n typeof k === \"string\" &&\n // Defensive: drop reserved keys that would corrupt the prototype\n // chain when assigned via `[key] = …`. `{ __proto__: x }` literal\n // does the same thing and would trigger the setter on the fresh\n // object below — turning useDeferred(\"then\") into a function ref\n // pulled from Promise.prototype. With a null-prototype object\n // (below) `__proto__` is just a property, but skipping these\n // keys outright keeps the surface predictable.\n k !== \"__proto__\" &&\n k !== \"constructor\" &&\n k !== \"prototype\",\n );\n\n if (keys.length === 0) return;\n\n // Null-prototype object so `[key] = …` cannot trigger the\n // `Object.prototype.__proto__` setter, even if the filter above is\n // bypassed by future refactors.\n const promises = Object.create(null) as Record<\n string,\n Promise<unknown>\n >;\n\n for (const key of keys) {\n promises[key] = ensureRegistryPromise(key);\n }\n\n deferredClaims.value.write(state, promises);\n deferredClaims.keys.write(state, keys);\n };\n\n // Shared between start interceptor (SSR boot path) and subscribeLeave\n // handler (CSR revalidation path). Returns the compiled entry only\n // when:\n // 1. the route is registered in this plugin's loaders map, AND\n // 2. the resolved mode is NOT \"client-only\".\n // In both successful cases the mode marker is published to\n // `state.context[modeNamespace]` BEFORE returning. Callers then own\n // the loader-invocation strategy (start path also checks the hydration\n // scratchpad; leave path gates on `entry.loader !== undefined`).\n const prepareEntry = (state: State): CompiledEntry<T> | null => {\n const entry = compiled.get(state.name);\n\n if (!entry) return null;\n\n // Static forms (the common case) — staticMode was pre-resolved at\n // compile time, skip the resolveMode if/else walk per navigation.\n // Function-form path: invoke modeFn with the resolved state and\n // re-validate via resolveMode (catches a resolver returning a\n // foreign string at runtime).\n const mode =\n entry.staticMode !== null\n ? entry.staticMode\n : resolveMode(\n entry.modeFn,\n state,\n allowed,\n config.errorPrefix,\n state.name,\n );\n\n modeClaim.write(state, mode);\n\n if (mode === \"client-only\") return null;\n\n return entry;\n };\n\n const removeStartInterceptor = api.addInterceptor(\n \"start\",\n async (next, path) => {\n const state = await next(path);\n const entry = prepareEntry(state);\n\n if (entry === null) return state;\n\n const hydrationState = internals.hydrationState;\n\n if (\n hydrationState !== null &&\n hydrationState.name === state.name &&\n // `in` — not `!== undefined` — is intentional. The contract is\n // \"scratchpad presence wins\": if the server explicitly serialised\n // a value into this namespace (even an `undefined` left over from\n // a programmatic state object), the plugin treats that as the\n // server's authoritative answer and skips re-running the loader\n // on the client. JSON-roundtrip strips `undefined` values, so in\n // practice this only matters for in-memory hydration paths —\n // see CLAUDE.md \"Gotchas → Hydration scratchpad: presence wins\".\n config.namespace in hydrationState.context\n ) {\n dataClaim.write(state, hydrationState.context[config.namespace] as T);\n reconstructDeferredFromHydration(state, hydrationState.context);\n } else if (entry.loader !== undefined) {\n writeLoaderResult(state, await entry.loader(state.params));\n }\n\n return state;\n },\n );\n\n // CSR revalidation channel for `invalidate(router, namespace)`.\n // Runs in the awaited LEAVE_APPROVE phase so fresh data lands on\n // `nextRoute.context` before `TRANSITION_SUCCESS` fires.\n // Flag is cleared only after a successful, non-cancelled loader write —\n // no-entry / client-only / cancelled navigations preserve it for retry.\n const removeLeaveListener = router.subscribeLeave(\n async ({ nextRoute, signal }) => {\n if (!isStale(router, config.namespace)) return;\n\n const entry = prepareEntry(nextRoute);\n\n if (entry === null || entry.loader === undefined) return;\n\n // Pass the navigation's signal so cancellation-aware loaders can\n // abort their in-flight work (fetch, DB query, etc.) when a newer\n // navigation supersedes this one. The post-await `signal.aborted`\n // check below remains as the final gate — loaders that ignore the\n // signal still benefit from the cancel-safety contract (#605).\n const data = await entry.loader(nextRoute.params, { signal });\n\n if (signal.aborted) return;\n\n clearStale(router, config.namespace);\n writeLoaderResult(nextRoute, data);\n },\n );\n\n return {\n teardown() {\n removeStartInterceptor();\n removeLeaveListener();\n dataClaim.release();\n modeClaim.release();\n deferredClaims?.value.release();\n deferredClaims?.keys.release();\n },\n };\n };\n}\n","import { ALL_SSR_MODES } from \"./types.js\";\n\nimport type { SsrMode } from \"./types.js\";\n\nexport function createLoadersValidator(\n errorPrefix: string,\n allowedModes: readonly SsrMode[] = ALL_SSR_MODES,\n) {\n return function validateLoaders(loaders: unknown): void {\n if (\n loaders === null ||\n typeof loaders !== \"object\" ||\n Array.isArray(loaders)\n ) {\n throw new TypeError(`${errorPrefix} loaders must be a non-null object`);\n }\n\n for (const [route, entry] of Object.entries(\n loaders as Record<string, unknown>,\n )) {\n if (typeof entry === \"function\") continue;\n\n if (entry === null || typeof entry !== \"object\" || Array.isArray(entry)) {\n throw new TypeError(\n `${errorPrefix} entry for route \"${route}\" must be a function or { ssr?, loader? } object`,\n );\n }\n\n for (const key of Object.keys(entry as Record<string, unknown>)) {\n if (key !== \"ssr\" && key !== \"loader\") {\n throw new TypeError(\n `${errorPrefix} unexpected key \"${key}\" in route \"${route}\" config`,\n );\n }\n }\n\n const obj = entry as { ssr?: unknown; loader?: unknown };\n\n if (obj.loader !== undefined && typeof obj.loader !== \"function\") {\n throw new TypeError(\n `${errorPrefix} loader for route \"${route}\" must be a function`,\n );\n }\n\n if (obj.ssr !== undefined) {\n const ssr = obj.ssr;\n\n if (typeof ssr === \"function\" || typeof ssr === \"boolean\") {\n continue;\n }\n\n if (typeof ssr === \"string\") {\n if (!(allowedModes as readonly string[]).includes(ssr)) {\n throw new TypeError(\n `${errorPrefix} mode \"${ssr}\" is not allowed for route \"${route}\". Allowed: ${allowedModes.join(\", \")}`,\n );\n }\n continue;\n }\n\n throw new TypeError(\n `${errorPrefix} ssr for route \"${route}\" must be SsrMode string, boolean, or (state) => SsrMode`,\n );\n }\n }\n };\n}\n","import { ALLOWED_RSC_MODES, ERROR_PREFIX } from \"./constants\";\nimport { createLoadersValidator, createSsrLoaderPlugin } from \"./shared-ssr\";\n\nimport type { RscLoaderFactoryMap } from \"./types\";\nimport type { DefaultDependencies, PluginFactory } from \"@real-router/types\";\nimport type { ReactNode } from \"react\";\n\n// Inlined from the deleted validation.ts — single 7-line consumer was\n// here, no other importer in src/ or tests/, so the indirection was\n// pure ceremony.\nconst validateLoaders = createLoadersValidator(ERROR_PREFIX, ALLOWED_RSC_MODES);\n\n/**\n * Plugin factory that loads per-route `ReactNode` (RSC payload) by intercepting\n * `router.start()`. Variant B from the RSC integration RFC: the plugin stores a\n * `ReactNode` on `state.context.rsc` — it does NOT render Flight bytes itself.\n *\n * The caller is responsible for piping the published `ReactNode` through the\n * appropriate bundler-specific renderer (e.g.\n * `@vitejs/plugin-rsc/rsc.renderToReadableStream`,\n * `react-server-dom-webpack/server.edge`, etc.) — keeping this plugin fully\n * bundler-agnostic.\n *\n * Sibling plugin `@real-router/ssr-data-plugin` follows the same factory\n * pattern via `createSsrLoaderPlugin` from `shared/ssr/`.\n *\n * @example\n * ```ts\n * const router = cloneRouter(baseRouter);\n *\n * router.usePlugin(rscServerPluginFactory({\n * \"users.profile\": () => async (params) => {\n * const user = await db.users.findById(params.id);\n * return <UserProfile user={user} />;\n * },\n * }));\n *\n * const state = await router.start(req.url);\n * if (state.context.rsc) {\n * const flight = renderToReadableStream(state.context.rsc);\n * // pipe flight to HTTP response\n * }\n * ```\n */\nexport function rscServerPluginFactory<\n Dependencies extends DefaultDependencies = DefaultDependencies,\n>(loaders: RscLoaderFactoryMap<Dependencies>): PluginFactory<Dependencies> {\n validateLoaders(loaders);\n\n return createSsrLoaderPlugin<ReactNode, Dependencies>(loaders, {\n namespace: \"rsc\",\n modeNamespace: \"ssrRscMode\",\n errorPrefix: ERROR_PREFIX,\n allowedModes: ALLOWED_RSC_MODES,\n });\n}\n","import { getPluginApi } from \"@real-router/core/api\";\n\nimport { ERROR_PREFIX } from \"./constants\";\n\nimport type { RscActionResult } from \"./types\";\nimport type {\n DefaultDependencies,\n Plugin,\n PluginFactory,\n} from \"@real-router/types\";\n\n/**\n * Per-start runtime validator for `getResult()` return values.\n *\n * Returns `null` when the value is acceptable (typed `RscActionResult`,\n * non-thenable, non-array, non-null object). Otherwise returns a short\n * descriptor used in the thrown `TypeError` message — keeps the error\n * actionable by pointing at the exact failure mode (`\"null\"`, `\"array\"`,\n * `\"Promise/thenable — wire your action result synchronously\"`, or the\n * raw `typeof` for primitives).\n *\n * Single source of truth for the two-decision pattern: \"throw or\n * accept\" + \"what to say in the error\". Previously the same checks\n * lived inline at the call site AND in `describeBadResult` — a typo in\n * one would silently break the symmetry. Unifying as one classifier\n * eliminates that drift class.\n */\nfunction classifyRscActionResult(value: unknown): string | null {\n if (value === null) {\n return \"null\";\n }\n if (Array.isArray(value)) {\n return \"array\";\n }\n if (typeof value !== \"object\") {\n return typeof value;\n }\n if (typeof (value as { then?: unknown }).then === \"function\") {\n return \"Promise/thenable — wire your action result synchronously\";\n }\n\n return null;\n}\n\n/**\n * Plugin factory that publishes a Server Action result to\n * `state.context.rscAction`. Pair with `rscServerPluginFactory` —\n * the `\"rsc\"` and `\"rscAction\"` namespaces are independent and the\n * two plugins coexist on the same router.\n *\n * The factory takes a `getResult` resolver evaluated at start-time\n * (inside the `start` interceptor, after the route resolves but\n * before the caller reads `state`). The caller has the action result\n * in scope (e.g. computed by `decodeAction` + `loadServerAction` in\n * their fetch handler) and returns it from the closure:\n *\n * @example\n * ```ts\n * let actionResult: RscActionResult | undefined;\n *\n * if (request.method === \"POST\") {\n * const decoded = await decodeAction(formData);\n * actionResult = { returnValue: { ok: true, data: await decoded() } };\n * }\n *\n * router.usePlugin(\n * rscServerPluginFactory(loaders),\n * rscActionPluginFactory(() => actionResult),\n * );\n *\n * const state = await router.start(pathname);\n * // state.context.rscAction === actionResult (or undefined)\n * ```\n *\n * When `getResult()` returns `undefined`, the interceptor skips the\n * write — `state.context.rscAction` stays `undefined`. Useful for\n * GET requests where there's no action to surface.\n *\n * The result is JSON-friendly (no ReactNode), so it serializes via\n * `serializeRouterState(state)` without needing `excludeContext`.\n * If you want to keep it server-side only (e.g. action result\n * contains secrets), pass `excludeContext: [\"rsc\", \"rscAction\"]`.\n */\nexport function rscActionPluginFactory<\n TReturn = unknown,\n TFormState = unknown,\n Dependencies extends DefaultDependencies = DefaultDependencies,\n>(\n getResult: () => RscActionResult<TReturn, TFormState> | undefined,\n): PluginFactory<Dependencies> {\n // Mirror the factory-time validation that `rscServerPluginFactory` and\n // `ssrDataPluginFactory` already perform on their loaders map: a TS-cast\n // bypass or a JS consumer can smuggle a non-function through, and the\n // failure would otherwise surface much later inside the start interceptor\n // as `TypeError: getResult is not a function`, after the `\"rscAction\"`\n // namespace has already been claimed and the start interceptor has been\n // registered. Failing eagerly with a typed, prefixed error keeps the API\n // consistent across all factories in this package.\n if (typeof getResult !== \"function\") {\n throw new TypeError(`${ERROR_PREFIX} getResult must be a function`);\n }\n\n return (router): Plugin => {\n const api = getPluginApi(router);\n const claim = api.claimContextNamespace(\"rscAction\");\n\n const removeStartInterceptor = api.addInterceptor(\n \"start\",\n async (next, path) => {\n const state = await next(path);\n // Read as `unknown`: the TS contract pins it to RscActionResult, but\n // we run a defensive shape guard below for cast-bypassed garbage.\n const result: unknown = getResult();\n\n if (result === undefined) {\n return state;\n }\n\n // Symmetry-with-loaders runtime guard. The TS contract is\n // `() => RscActionResult | undefined`, but the most common consumer\n // mistake is wiring an `async` getResult — TS allows it via cast,\n // and the resulting Promise would land in `state.context.rscAction`\n // and break every downstream `result.returnValue` access. Single\n // classifier — `classifyRscActionResult` is the source of truth for\n // BOTH the accept/reject decision AND the error description, so a\n // change to one cannot drift from the other.\n const badShape = classifyRscActionResult(result);\n\n if (badShape !== null) {\n throw new TypeError(\n `${ERROR_PREFIX} getResult must return an RscActionResult object or undefined (got ${badShape})`,\n );\n }\n\n claim.write(state, result as RscActionResult<TReturn, TFormState>);\n\n return state;\n },\n );\n\n return {\n teardown() {\n removeStartInterceptor();\n claim.release();\n },\n };\n };\n}\n","import type { RscActionResult, RscPayload } from \"./types\";\nimport type { State } from \"@real-router/types\";\nimport type { ReactNode } from \"react\";\n\n/**\n * Build a canonical Flight payload from `state.context.rsc` (+ optional\n * Server Component override) and `state.context.rscAction`.\n *\n * Removes the repeated `{ root, returnValue, formState }` boilerplate at\n * the call site:\n *\n * ```ts\n * import { renderToReadableStream } from \"@vitejs/plugin-rsc/rsc\";\n * const flight = renderToReadableStream(buildRscPayload(state));\n * ```\n *\n * Pass `rootOverride` to wrap the per-route Server Component tree (e.g.\n * with cross-cutting layout chrome) without rebuilding the payload by\n * hand:\n *\n * ```ts\n * const wrapped = (\n * <>\n * <NotificationBanner action={state.context.rscAction} />\n * {state.context.rsc}\n * </>\n * );\n * const payload = buildRscPayload<MyData, ReactFormState>(state, wrapped);\n * ```\n *\n * `rootOverride === undefined` means \"use the default\" (`state.context.rsc`).\n * Pass `null` to explicitly render nothing — `null` is a valid `ReactNode`\n * and is preserved as-is, **not** treated as \"fall back to default\".\n *\n * `returnValue` and `formState` are **omitted** (not set to `undefined`)\n * when their source is missing, so the result type-checks under\n * `exactOptionalPropertyTypes: true` consumers without ceremony.\n */\nexport function buildRscPayload<TReturn = unknown, TFormState = unknown>(\n state: State,\n rootOverride?: ReactNode,\n): RscPayload<TReturn, TFormState> {\n const ctx = state.context as {\n rsc?: ReactNode;\n rscAction?: RscActionResult<TReturn, TFormState>;\n };\n\n // `??` would collapse an explicit `null` override to the default — use a\n // strict `=== undefined` check so callers can render nothing on purpose.\n const root = rootOverride === undefined ? ctx.rsc : rootOverride;\n\n const payload: RscPayload<TReturn, TFormState> = { root };\n const action = ctx.rscAction;\n\n if (action?.returnValue !== undefined) {\n payload.returnValue = action.returnValue;\n }\n\n if (action?.formState !== undefined) {\n payload.formState = action.formState;\n }\n\n return payload;\n}\n","import { ALLOWED_RSC_MODES } from \"./constants\";\n\nimport type { RscSsrMode } from \"./types\";\nimport type { State } from \"@real-router/types\";\n\n/**\n * Returns the SSR mode resolved by `rsc-server-plugin` for the current state.\n * Falls back to `\"full\"` when the route has no plugin entry.\n *\n * Read this from `entry-server.tsx` to branch on full vs client-only:\n * - `\"full\"` — render the Server Component tree, pipe Flight stream.\n * - `\"client-only\"` — ship shell HTML and let the client fetch via its own mechanism.\n *\n * The mode is written to `state.context.ssrRscMode` by the plugin's `start`\n * interceptor for every route registered in the loaders map.\n *\n * Defensive read: if `state.context.ssrRscMode` was set to something outside\n * `ALLOWED_RSC_MODES` by a TS-cast bypass or a foreign writer, the function\n * collapses it to `\"full\"` rather than returning the bad value. Without this\n * guard, a downstream `mode === \"full\"` branch would silently misbehave for\n * `0`, `false`, `\"\"`, `null`, or any unknown string.\n *\n * The read itself is wrapped in `try/catch` — a foreign writer that installs\n * a throwing getter (`Object.defineProperty(ctx, \"ssrRscMode\", { get() { throw … } })`)\n * cannot break the contract. The function NEVER throws, no matter how\n * adversarial the context shape. `\"full\"` is the safe default for any error.\n */\nexport function getSsrRscMode(state: State): RscSsrMode {\n let raw: unknown;\n\n try {\n raw = (state.context as { ssrRscMode?: unknown }).ssrRscMode;\n } catch {\n return \"full\";\n }\n\n return typeof raw === \"string\" &&\n ALLOWED_RSC_MODES.includes(raw as RscSsrMode)\n ? (raw as RscSsrMode)\n : \"full\";\n}\n","import { markStale } from \"./shared-ssr\";\n\nimport type { Router } from \"@real-router/types\";\n\n/**\n * Mark the `\"rsc\"` namespace as stale on the given router. The next\n * navigation (including a same-route reload) re-runs the RSC loader for the\n * destination route and overwrites `state.context.rsc` (and the mode marker)\n * via the plugin's `subscribeLeave` listener.\n *\n * Honest fire-and-forget semantics — returns `void`. The flag is consumed in\n * the awaited LEAVE_APPROVE phase of the next navigation, so subscribers see\n * a fresh `ReactNode` when the navigation completes. Behaviour during an\n * in-flight transition: the current transition completes unchanged; the flag\n * is read by the *following* navigation. This keeps the invariant\n * \"one transition = one `state.context` snapshot\" intact.\n *\n * Composability through the existing core API:\n *\n * ```ts\n * // Fire-and-forget: stale until the user navigates somewhere\n * invalidate(router, \"rsc\");\n *\n * // Explicit await — pair with a same-route reload\n * invalidate(router, \"rsc\");\n * await router.navigate(state.name, state.params, { reload: true });\n * ```\n *\n * Surgical alternative to `router.navigate({ reload: true })` for multi-\n * namespace routes: only the `\"rsc\"` namespace re-runs; a side-by-side\n * `ssr-data-plugin` keeps its cached `state.context.data` on this transition\n * unless its own `invalidate()` was also called.\n */\nexport function invalidate(router: Router, namespace: \"rsc\"): void {\n markStale(router, namespace);\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@real-router/rsc-server-plugin",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "type": "commonjs",
5
5
  "description": "RSC per-route ReactNode loading plugin for Real-Router (server-side, bundler-agnostic)",
6
6
  "main": "./dist/cjs/index.js",
@@ -34,8 +34,7 @@
34
34
  }
35
35
  },
36
36
  "files": [
37
- "dist",
38
- "src"
37
+ "dist"
39
38
  ],
40
39
  "repository": {
41
40
  "type": "git",
@@ -53,16 +52,16 @@
53
52
  "homepage": "https://github.com/greydragon888/real-router",
54
53
  "sideEffects": false,
55
54
  "dependencies": {
56
- "@real-router/types": "^0.35.0"
55
+ "@real-router/types": "^0.36.0"
57
56
  },
58
57
  "devDependencies": {
59
58
  "@types/react": "19.2.14",
60
59
  "react": "19.2.0",
61
- "@real-router/ssr-data-plugin": "^0.4.2"
60
+ "@real-router/ssr-data-plugin": "^0.4.3"
62
61
  },
63
62
  "peerDependencies": {
64
63
  "react": ">=19.0.0",
65
- "@real-router/core": "^0.55.0"
64
+ "@real-router/core": "^0.56.0"
66
65
  },
67
66
  "scripts": {
68
67
  "test": "vitest",
@@ -1,148 +0,0 @@
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
- }
@@ -1,64 +0,0 @@
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 DELETED
@@ -1,16 +0,0 @@
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 DELETED
@@ -1,6 +0,0 @@
1
- export {
2
- LoaderRedirect,
3
- LoaderNotFound,
4
- LoaderTimeout,
5
- withTimeout,
6
- } from "./shared-ssr/errors";
package/src/factory.ts DELETED
@@ -1,56 +0,0 @@
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
- }