@real-router/core 0.52.0 → 0.54.1

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.
Files changed (95) hide show
  1. package/README.md +22 -10
  2. package/dist/cjs/Router-CJihdrWA.d.ts +67 -0
  3. package/dist/cjs/Router-CJihdrWA.d.ts.map +1 -0
  4. package/dist/cjs/Router-D8Awa7bY.js +6 -0
  5. package/dist/cjs/Router-D8Awa7bY.js.map +1 -0
  6. package/dist/cjs/RouterError-Bm9YnZ6e.d.ts +310 -0
  7. package/dist/cjs/RouterError-Bm9YnZ6e.d.ts.map +1 -0
  8. package/dist/cjs/api.d.ts +2 -1
  9. package/dist/cjs/api.d.ts.map +1 -1
  10. package/dist/cjs/api.js +1 -1
  11. package/dist/cjs/api.js.map +1 -1
  12. package/dist/cjs/cloneRouter-q-jHlBiv.js +2 -0
  13. package/dist/cjs/cloneRouter-q-jHlBiv.js.map +1 -0
  14. package/dist/cjs/index-8oPDJBQc.d.ts +306 -0
  15. package/dist/cjs/index-8oPDJBQc.d.ts.map +1 -0
  16. package/dist/cjs/{Router-DrBkBdZ5.d.ts → index-EwbhzRQw.d.ts} +4 -69
  17. package/dist/cjs/index-EwbhzRQw.d.ts.map +1 -0
  18. package/dist/cjs/index.d.ts +4 -197
  19. package/dist/cjs/index.d.ts.map +1 -1
  20. package/dist/cjs/index.js +1 -1
  21. package/dist/cjs/index.js.map +1 -1
  22. package/dist/cjs/{internals-na15rxo_.js → internals-CM6oaz9n.js} +1 -1
  23. package/dist/cjs/internals-CM6oaz9n.js.map +1 -0
  24. package/dist/cjs/utils.d.ts +2 -91
  25. package/dist/cjs/utils.js +1 -1
  26. package/dist/cjs/utils.js.map +1 -1
  27. package/dist/cjs/validation.d.ts +55 -44
  28. package/dist/cjs/validation.d.ts.map +1 -1
  29. package/dist/cjs/validation.js +1 -1
  30. package/dist/esm/Router-BeMyxy_V.mjs +6 -0
  31. package/dist/esm/Router-BeMyxy_V.mjs.map +1 -0
  32. package/dist/esm/Router-BmhiDQUJ.d.mts +67 -0
  33. package/dist/esm/Router-BmhiDQUJ.d.mts.map +1 -0
  34. package/dist/esm/RouterError-hhfSVGtY.d.mts +310 -0
  35. package/dist/esm/RouterError-hhfSVGtY.d.mts.map +1 -0
  36. package/dist/esm/api.d.mts +2 -1
  37. package/dist/esm/api.d.mts.map +1 -1
  38. package/dist/esm/api.mjs +1 -1
  39. package/dist/esm/api.mjs.map +1 -1
  40. package/dist/esm/cloneRouter-C_ULpzHM.mjs +2 -0
  41. package/dist/esm/cloneRouter-C_ULpzHM.mjs.map +1 -0
  42. package/dist/esm/{Router-BeXr2zW4.d.mts → index-DNjaY7KH.d.mts} +4 -69
  43. package/dist/esm/index-DNjaY7KH.d.mts.map +1 -0
  44. package/dist/esm/index-r_JTvSBH.d.mts +306 -0
  45. package/dist/esm/index-r_JTvSBH.d.mts.map +1 -0
  46. package/dist/esm/index.d.mts +4 -197
  47. package/dist/esm/index.d.mts.map +1 -1
  48. package/dist/esm/index.mjs +1 -1
  49. package/dist/esm/index.mjs.map +1 -1
  50. package/dist/esm/{internals-CCymabFj.mjs → internals-C59msvHY.mjs} +1 -1
  51. package/dist/esm/internals-C59msvHY.mjs.map +1 -0
  52. package/dist/esm/utils.d.mts +2 -91
  53. package/dist/esm/utils.mjs +1 -1
  54. package/dist/esm/utils.mjs.map +1 -1
  55. package/dist/esm/validation.d.mts +56 -43
  56. package/dist/esm/validation.d.mts.map +1 -1
  57. package/dist/esm/validation.mjs +1 -1
  58. package/package.json +1 -1
  59. package/src/Router.ts +13 -16
  60. package/src/api/getDependenciesApi.ts +3 -11
  61. package/src/api/getPluginApi.ts +3 -3
  62. package/src/api/getRoutesApi.ts +4 -2
  63. package/src/internals.ts +13 -1
  64. package/src/namespaces/DependenciesNamespace/dependenciesStore.ts +1 -1
  65. package/src/namespaces/NavigationNamespace/NavigationNamespace.ts +8 -4
  66. package/src/namespaces/NavigationNamespace/transition/completeTransition.ts +4 -0
  67. package/src/namespaces/RoutesNamespace/RoutesNamespace.ts +3 -3
  68. package/src/namespaces/StateNamespace/StateNamespace.ts +2 -2
  69. package/src/utils/createRequestScope.ts +174 -0
  70. package/src/utils/hydrateRouter.ts +68 -12
  71. package/src/utils/index.ts +17 -1
  72. package/src/utils/serializeRouterState.ts +51 -4
  73. package/src/utils/serializeState.ts +46 -5
  74. package/dist/cjs/Router-DrBkBdZ5.d.ts.map +0 -1
  75. package/dist/cjs/Router-Pztue5fk.js +0 -6
  76. package/dist/cjs/Router-Pztue5fk.js.map +0 -1
  77. package/dist/cjs/RouterError-BmvAyBlx.js +0 -2
  78. package/dist/cjs/RouterError-BmvAyBlx.js.map +0 -1
  79. package/dist/cjs/RouterValidator-DLy_W2du.d.ts +0 -114
  80. package/dist/cjs/RouterValidator-DLy_W2du.d.ts.map +0 -1
  81. package/dist/cjs/getPluginApi-CUcFDzuA.js +0 -2
  82. package/dist/cjs/getPluginApi-CUcFDzuA.js.map +0 -1
  83. package/dist/cjs/internals-na15rxo_.js.map +0 -1
  84. package/dist/cjs/utils.d.ts.map +0 -1
  85. package/dist/esm/Router-BeXr2zW4.d.mts.map +0 -1
  86. package/dist/esm/Router-CK8U23pP.mjs +0 -6
  87. package/dist/esm/Router-CK8U23pP.mjs.map +0 -1
  88. package/dist/esm/RouterError-D-Zjbdv9.mjs +0 -2
  89. package/dist/esm/RouterError-D-Zjbdv9.mjs.map +0 -1
  90. package/dist/esm/RouterValidator-C-PvV00i.d.mts +0 -114
  91. package/dist/esm/RouterValidator-C-PvV00i.d.mts.map +0 -1
  92. package/dist/esm/getPluginApi-CsTfDB-O.mjs +0 -2
  93. package/dist/esm/getPluginApi-CsTfDB-O.mjs.map +0 -1
  94. package/dist/esm/internals-CCymabFj.mjs.map +0 -1
  95. package/dist/esm/utils.d.mts.map +0 -1
package/src/Router.ts CHANGED
@@ -214,7 +214,7 @@ export class Router<
214
214
  this.#options.get(),
215
215
  ),
216
216
  interceptorsMap,
217
- ) as unknown as RouterInternals["buildPath"],
217
+ ),
218
218
  emitTransitionError: (error) => {
219
219
  this.#eventBus.sendFailSafe(undefined, this.#state.get(), error);
220
220
  },
@@ -257,11 +257,7 @@ export class Router<
257
257
  dependenciesGetStore: () => this.#dependenciesStore,
258
258
  // Clone support (issue #173)
259
259
  cloneOptions: () => ({ ...this.#options.get() }),
260
- cloneDependencies: () =>
261
- ({ ...this.#dependenciesStore.dependencies }) as Record<
262
- string,
263
- unknown
264
- >,
260
+ cloneDependencies: () => ({ ...this.#dependenciesStore.dependencies }),
265
261
  getLifecycleFactories: () => this.#routeLifecycle.getFactories(),
266
262
  getPluginFactories: () => this.#plugins.getAll(),
267
263
  routeGetStore: () => this.#routes.getStore(),
@@ -276,6 +272,7 @@ export class Router<
276
272
  },
277
273
  routerExtensions: [],
278
274
  contextClaimRecords: new Set(),
275
+ hydrationState: null,
279
276
  });
280
277
 
281
278
  // =========================================================================
@@ -677,16 +674,16 @@ export class Router<
677
674
  }
678
675
 
679
676
  #markDisposed(): void {
680
- this.navigate = throwDisposed as never;
681
- this.navigateToDefault = throwDisposed as never;
682
- this.navigateToNotFound = throwDisposed as never;
683
- this.start = throwDisposed as never;
684
- this.stop = throwDisposed as never;
685
- this.usePlugin = throwDisposed as never;
686
-
687
- this.subscribe = throwDisposed as never;
688
- this.subscribeLeave = throwDisposed as never;
689
- this.canNavigateTo = throwDisposed as never;
677
+ this.navigate = throwDisposed;
678
+ this.navigateToDefault = throwDisposed;
679
+ this.navigateToNotFound = throwDisposed;
680
+ this.start = throwDisposed;
681
+ this.stop = throwDisposed;
682
+ this.usePlugin = throwDisposed;
683
+
684
+ this.subscribe = throwDisposed;
685
+ this.subscribeLeave = throwDisposed;
686
+ this.canNavigateTo = throwDisposed;
690
687
  }
691
688
  }
692
689
 
@@ -110,12 +110,7 @@ export function getDependenciesApi<
110
110
  "setDependency",
111
111
  );
112
112
 
113
- setDependency(
114
- ctx.dependenciesGetStore(),
115
- name as string,
116
- value,
117
- ctx.validator,
118
- );
113
+ setDependency(ctx.dependenciesGetStore(), name, value, ctx.validator);
119
114
  },
120
115
  setAll: (deps) => {
121
116
  throwIfDisposed(ctx.isDisposed);
@@ -144,7 +139,7 @@ export function getDependenciesApi<
144
139
 
145
140
  const store = ctx.dependenciesGetStore();
146
141
 
147
- if (!Object.hasOwn(store.dependencies, name as string)) {
142
+ if (!Object.hasOwn(store.dependencies, name)) {
148
143
  ctx.validator?.dependencies.warnRemoveNonExistent(name);
149
144
  }
150
145
 
@@ -159,10 +154,7 @@ export function getDependenciesApi<
159
154
  has: (name) => {
160
155
  ctx.validator?.dependencies.validateDependencyName(name, "hasDependency");
161
156
 
162
- return Object.hasOwn(
163
- ctx.dependenciesGetStore().dependencies,
164
- name as string,
165
- );
157
+ return Object.hasOwn(ctx.dependenciesGetStore().dependencies, name);
166
158
  },
167
159
  };
168
160
  }
@@ -200,7 +200,7 @@ export function getPluginApi<
200
200
  throwIfDisposed(ctx.isDisposed);
201
201
  ctx.emitTransitionError(error);
202
202
  },
203
- claimContextNamespace: ((namespace: string) => {
203
+ claimContextNamespace: (namespace: string) => {
204
204
  throwIfDisposed(ctx.isDisposed);
205
205
 
206
206
  if (ctx.contextClaimRecords.has(namespace)) {
@@ -213,13 +213,13 @@ export function getPluginApi<
213
213
 
214
214
  return {
215
215
  write(state: State, value: unknown) {
216
- (state.context as Record<string, unknown>)[namespace] = value;
216
+ state.context[namespace] = value;
217
217
  },
218
218
  release() {
219
219
  ctx.contextClaimRecords.delete(namespace);
220
220
  },
221
221
  } satisfies ContextNamespaceClaim;
222
- }) as PluginApi["claimContextNamespace"],
222
+ },
223
223
  };
224
224
 
225
225
  cache.set(router, api);
@@ -360,7 +360,8 @@ function updateRouteConfig<
360
360
  const decoder = updates.decodeParams;
361
361
 
362
362
  store.config.decoders[name] = (params: Params): Params =>
363
- (decoder(params) as Params | undefined) ?? params;
363
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime fallback if user-provided decoder violates its return type
364
+ decoder(params) ?? params;
364
365
  }
365
366
  }
366
367
 
@@ -371,7 +372,8 @@ function updateRouteConfig<
371
372
  const encoder = updates.encodeParams;
372
373
 
373
374
  store.config.encoders[name] = (params: Params): Params =>
374
- (encoder(params) as Params | undefined) ?? params;
375
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime fallback if user-provided encoder violates its return type
376
+ encoder(params) ?? params;
375
377
  }
376
378
  }
377
379
  }
package/src/internals.ts CHANGED
@@ -1,8 +1,9 @@
1
- import type { DependenciesStore } from "./namespaces/DependenciesNamespace";
1
+ import type { DependenciesStore } from "./namespaces";
2
2
  import type { RoutesStore } from "./namespaces/RoutesNamespace";
3
3
  import type { Router as RouterClass } from "./Router";
4
4
  import type { EventMethodMap, GuardFnFactory, PluginFactory } from "./types";
5
5
  import type { RouterValidator } from "./types/RouterValidator";
6
+ import type { SerializedRouterState } from "./utils";
6
7
  import type {
7
8
  DefaultDependencies,
8
9
  EventName,
@@ -105,6 +106,17 @@ export interface RouterInternals<
105
106
  readonly setState: (state: State) => void;
106
107
  readonly routerExtensions: { keys: string[] }[];
107
108
  readonly contextClaimRecords: Set<string>;
109
+
110
+ /**
111
+ * One-shot hydration scratchpad populated by `hydrateRouter` immediately
112
+ * before delegating to `router.start(parsed.path)` and cleared in the
113
+ * matching `finally`. SSR loader plugins read this slot directly via
114
+ * `getInternals(router).hydrationState` to short-circuit their own loader
115
+ * call when the server-resolved namespace value is already present in the
116
+ * parsed state (#596). `null` outside of an active `hydrateRouter`
117
+ * invocation.
118
+ */
119
+ hydrationState: SerializedRouterState | null;
108
120
  }
109
121
 
110
122
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- existential type: stores RouterInternals for all Dependencies types
@@ -13,7 +13,7 @@ export interface DependenciesStore<
13
13
  export function createDependenciesStore<
14
14
  Dependencies extends DefaultDependencies = DefaultDependencies,
15
15
  >(
16
- initialDependencies: Partial<Dependencies> = {} as Dependencies,
16
+ initialDependencies: Partial<Dependencies> = {},
17
17
  ): DependenciesStore<Dependencies> {
18
18
  const dependencies = Object.create(null) as Partial<Dependencies>;
19
19
 
@@ -105,7 +105,8 @@ export class NavigationNamespace {
105
105
  /* v8 ignore next 3 -- @preserve: reachable only via validator-driven
106
106
  throws from buildNavigateState (validateStateBuilderArgs) — covered
107
107
  in @real-router/validation-plugin's suite, not in core. */
108
- return Promise.reject(error as Error);
108
+ // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- preserve original throw shape from user-provided buildNavigateState
109
+ return Promise.reject(error);
109
110
  }
110
111
 
111
112
  if (!toState) {
@@ -197,7 +198,8 @@ export class NavigationNamespace {
197
198
  try {
198
199
  ({ route, params } = deps.resolveDefault());
199
200
  } catch (error) {
200
- return Promise.reject(error as Error);
201
+ // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- preserve original throw shape from user-provided resolveDefault callback
202
+ return Promise.reject(error);
201
203
  }
202
204
 
203
205
  if (!route) {
@@ -233,6 +235,7 @@ export class NavigationNamespace {
233
235
  phase: "activating",
234
236
  ...(fromState && { from: fromState.name }),
235
237
  reason: "success",
238
+ replace: true,
236
239
  segments,
237
240
  };
238
241
 
@@ -240,7 +243,7 @@ export class NavigationNamespace {
240
243
 
241
244
  const state: State = {
242
245
  name: constants.UNKNOWN_ROUTE,
243
- params: EMPTY_PARAMS as Params,
246
+ params: EMPTY_PARAMS,
244
247
  path,
245
248
  transition: transitionMeta,
246
249
  context: {},
@@ -404,7 +407,8 @@ export class NavigationNamespace {
404
407
  fromState,
405
408
  );
406
409
 
407
- return Promise.reject(error as Error);
410
+ // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- preserve original throw shape from guards or transition pipeline
411
+ return Promise.reject(error);
408
412
  }
409
413
  }
410
414
 
@@ -42,6 +42,10 @@ function buildTransitionMeta(
42
42
  meta.reload = opts.reload;
43
43
  }
44
44
 
45
+ if (opts.replace !== undefined) {
46
+ meta.replace = opts.replace;
47
+ }
48
+
45
49
  if (opts.redirected !== undefined) {
46
50
  meta.redirected = opts.redirected;
47
51
  }
@@ -64,7 +64,7 @@ export function createRouteState<P extends RouteParams = RouteParams>(
64
64
  return {
65
65
  name: resolvedName,
66
66
  params: matchResult.params as P,
67
- meta: matchResult.meta as Record<string, Record<string, "url" | "query">>,
67
+ meta: matchResult.meta,
68
68
  };
69
69
  }
70
70
 
@@ -255,7 +255,7 @@ export class RoutesNamespace<
255
255
 
256
256
  const decodedParams =
257
257
  typeof this.#store.config.decoders[name] === "function"
258
- ? this.#store.config.decoders[name](params as Params)
258
+ ? this.#store.config.decoders[name](params)
259
259
  : params;
260
260
 
261
261
  const { name: routeName, params: routeParams } = this.#deps.forwardState<P>(
@@ -503,7 +503,7 @@ export class RoutesNamespace<
503
503
  return {
504
504
  ...this.#store.config.defaultParams[routeName],
505
505
  ...params,
506
- } as P;
506
+ };
507
507
  }
508
508
 
509
509
  return params;
@@ -129,7 +129,7 @@ export class StateNamespace {
129
129
  } else if (!params || params === EMPTY_PARAMS) {
130
130
  mergedParams = EMPTY_PARAMS as P;
131
131
  } else {
132
- mergedParams = Object.freeze({ ...params }) as P;
132
+ mergedParams = Object.freeze({ ...params });
133
133
  }
134
134
 
135
135
  const state = {
@@ -141,7 +141,7 @@ export class StateNamespace {
141
141
  } as State<P>;
142
142
 
143
143
  if (meta) {
144
- setStateMetaParams(state, meta as unknown as Params);
144
+ setStateMetaParams(state, meta);
145
145
  }
146
146
 
147
147
  return skipFreeze ? state : freezeStateInPlace(state);
@@ -0,0 +1,174 @@
1
+ import { cloneRouter } from "../api/cloneRouter";
2
+
3
+ import type { Router as RouterClass } from "../Router";
4
+ import type { DefaultDependencies, Router } from "@real-router/types";
5
+
6
+ /**
7
+ * Subset of Node's `http.IncomingMessage` that `createRequestScope` relies on:
8
+ * a `"close"` event indicating that the client disconnected (or the response
9
+ * was fully sent) and the standard `removeListener` cleanup hook.
10
+ */
11
+ export interface IncomingMessageLike {
12
+ on: (event: "close", listener: () => void) => unknown;
13
+ removeListener?: (event: "close", listener: () => void) => unknown;
14
+ }
15
+
16
+ /**
17
+ * Web `Request`-shaped object — anything carrying an `AbortSignal`. Web
18
+ * runtimes (Bun, Cloudflare Workers, Vite RSC) surface client-disconnect via
19
+ * `request.signal` directly, so no listener attachment is needed.
20
+ */
21
+ export interface RequestLike {
22
+ signal: AbortSignal;
23
+ }
24
+
25
+ export type RequestScopeSource = IncomingMessageLike | RequestLike;
26
+
27
+ export interface RequestScope<
28
+ Dependencies extends DefaultDependencies = DefaultDependencies,
29
+ > extends AsyncDisposable {
30
+ /**
31
+ * Per-request router clone. Carries `abortSignal` injected into its
32
+ * dependencies — loaders can `getDep("abortSignal")` and pass it to fetch /
33
+ * `withTimeout` for cooperative cancellation when the client disconnects.
34
+ */
35
+ readonly router: RouterClass<Dependencies>;
36
+
37
+ /**
38
+ * Aborts when the request closes (Node `IncomingMessage`'s `"close"` event)
39
+ * or when the upstream Web `Request.signal` aborts.
40
+ */
41
+ readonly signal: AbortSignal;
42
+
43
+ /**
44
+ * Detach the close listener (if attached to a Node `IncomingMessage`) and
45
+ * dispose the cloned router. Idempotent — safe to call multiple times or in
46
+ * combination with `Symbol.asyncDispose`.
47
+ */
48
+ dispose: () => Promise<void>;
49
+ }
50
+
51
+ function isRequestLike(request: RequestScopeSource): request is RequestLike {
52
+ return (
53
+ "signal" in request &&
54
+ typeof (request as Partial<RequestLike>).signal === "object" &&
55
+ (request as Partial<RequestLike>).signal !== undefined &&
56
+ typeof request.signal.aborted === "boolean"
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Build a per-request router scope: clones `base`, attaches an `AbortSignal`
62
+ * tied to the request's lifetime, and exposes `dispose()` (plus
63
+ * `Symbol.asyncDispose` for `await using` declarations).
64
+ *
65
+ * Replaces the four-step boilerplate that every server entry repeats:
66
+ *
67
+ * 1. `new AbortController()` per request
68
+ * 2. `req.on("close", () => controller.abort())`
69
+ * 3. `cloneRouter(base, { ...deps, abortSignal: signal })`
70
+ * 4. `try { ... } finally { router.dispose() }`
71
+ *
72
+ * The signal is injected into the router clone under `abortSignal` so existing
73
+ * loaders that read `getDep("abortSignal")` keep working without changes.
74
+ *
75
+ * ## `await using` compatibility
76
+ *
77
+ * The scope implements `Symbol.asyncDispose`, so `await using scope = …` is
78
+ * supported on runtimes that ship the well-known `Symbol.asyncDispose`:
79
+ *
80
+ * - **Node.js 24+** (full support; partial in 20.4–20.17 only for `fs`/`stream`)
81
+ * - **Bun 1.0.23+**, **Deno 1.37+**
82
+ * - **Chrome / Edge 127+**, **Firefox 141+**
83
+ * - **Safari**: not yet supported (irrelevant in practice — this helper is
84
+ * server-side only and never reaches the browser)
85
+ *
86
+ * On Node.js 22 LTS the well-known symbol is unavailable, so `await using`
87
+ * fails. **The bundled SSR examples therefore use the explicit
88
+ * `try/finally` + `await scope.dispose()` form**, which works on every
89
+ * runtime. Use `await using` only when you control the deployment target and
90
+ * know it ships the symbol.
91
+ *
92
+ * @example
93
+ * ```typescript
94
+ * // Explicit dispose — works on Node 18+, all browsers, every CI image
95
+ * export async function render(url: string, req: IncomingMessage) {
96
+ * const scope = createRequestScope(req, baseRouter, { currentUser });
97
+ * try {
98
+ * scope.router.usePlugin(ssrDataPluginFactory(loaders));
99
+ * return await renderShell(scope.router, url);
100
+ * } finally {
101
+ * await scope.dispose();
102
+ * }
103
+ * }
104
+ *
105
+ * // `await using` — Node 24+, Bun, Deno, modern browsers
106
+ * export async function render(url: string, req: IncomingMessage) {
107
+ * await using scope = createRequestScope(req, baseRouter, { currentUser });
108
+ * scope.router.usePlugin(ssrDataPluginFactory(loaders));
109
+ * return await renderShell(scope.router, url);
110
+ * }
111
+ *
112
+ * // Web runtime (signal already on the request)
113
+ * async function handler(request: Request) {
114
+ * const scope = createRequestScope(request, baseRouter, { db });
115
+ * try {
116
+ * scope.router.usePlugin(rscServerPluginFactory(loaders));
117
+ * return await render(scope.router, request.url);
118
+ * } finally {
119
+ * await scope.dispose();
120
+ * }
121
+ * }
122
+ * ```
123
+ */
124
+ export function createRequestScope<
125
+ Dependencies extends DefaultDependencies = DefaultDependencies,
126
+ >(
127
+ request: RequestScopeSource,
128
+ base: Router<Dependencies>,
129
+ deps?: Partial<Dependencies>,
130
+ ): RequestScope<Dependencies> {
131
+ let detach: (() => void) | undefined;
132
+ let signal: AbortSignal;
133
+
134
+ if (isRequestLike(request)) {
135
+ signal = request.signal;
136
+ } else {
137
+ const controller = new AbortController();
138
+ const onClose = (): void => {
139
+ controller.abort();
140
+ };
141
+
142
+ request.on("close", onClose);
143
+ signal = controller.signal;
144
+ detach = () => {
145
+ request.removeListener?.("close", onClose);
146
+ };
147
+ }
148
+
149
+ const router = cloneRouter(base, {
150
+ ...deps,
151
+ abortSignal: signal,
152
+ } as Dependencies);
153
+
154
+ let disposed = false;
155
+
156
+ const dispose = (): Promise<void> => {
157
+ if (disposed) {
158
+ return Promise.resolve();
159
+ }
160
+
161
+ disposed = true;
162
+ detach?.();
163
+ router.dispose();
164
+
165
+ return Promise.resolve();
166
+ };
167
+
168
+ return {
169
+ router,
170
+ signal,
171
+ dispose,
172
+ [Symbol.asyncDispose]: dispose,
173
+ };
174
+ }
@@ -1,16 +1,51 @@
1
+ import { getInternals } from "../internals";
2
+
3
+ import type { SerializedRouterState } from "./serializeRouterState";
1
4
  import type { Router, State } from "@real-router/types";
2
5
 
3
6
  /**
4
- * Hydrate a fresh router from server-serialized State (#563).
7
+ * Custom deserializer signature for {@link hydrateRouter} (#606). Compatible
8
+ * with `JSON.parse` (default), `devalue.parse`, `superjson.parse`, or any
9
+ * user-supplied function.
10
+ */
11
+ export type Deserialize = (json: string) => unknown;
12
+
13
+ export interface HydrateRouterOptions {
14
+ /**
15
+ * Custom deserializer (e.g., `devalue.parse` / `superjson.parse`) for
16
+ * matching the `serialize` passed to {@link serializeRouterState}. Defaults
17
+ * to `JSON.parse`. Ignored when `source` is already an object.
18
+ *
19
+ * @default JSON.parse
20
+ *
21
+ * @example
22
+ * ```typescript
23
+ * import * as devalue from "devalue";
24
+ * await hydrateRouter(router, ssrJson, { deserialize: devalue.parse });
25
+ * ```
26
+ */
27
+ deserialize?: Deserialize;
28
+ }
29
+
30
+ /**
31
+ * Hydrate a fresh router from server-serialized State (#563, #596).
32
+ *
33
+ * Accepts either a JSON string (parsed via `JSON.parse` by default, or
34
+ * `options.deserialize` when provided) or a State-shaped object. Extracts
35
+ * `state.path` and delegates to `router.start(state.path)` — the canonical
36
+ * URL is the source of truth for the router on hydration.
5
37
  *
6
- * Accepts either a JSON string (parsed via `JSON.parse`) or a State-shaped
7
- * object. Extracts `state.path` and delegates to `router.start(state.path)`
8
- * the canonical URL is the source of truth for the router on hydration.
38
+ * The full parsed state (incl. `state.context.<namespace>` payloads) is
39
+ * deposited into a one-shot scratchpad on `RouterInternals.hydrationState`
40
+ * before `start()` is invoked and cleared in the matching `finally`. SSR
41
+ * loader plugins (`@real-router/ssr-data-plugin`,
42
+ * `@real-router/rsc-server-plugin`) read this scratchpad to skip their loader
43
+ * call when the server-resolved namespace value is already present — avoiding
44
+ * the post-hydration loader re-run on first paint.
9
45
  *
10
- * The serialized State (produced by `serializeRouterState`) is still useful
11
- * for application-level concerns: `state.context.<namespace>` payloads (e.g.
12
- * server-side data from `ssr-data-plugin`) can be read separately by app code
13
- * before or after `hydrateRouter` resolves.
46
+ * Single-shot semantics: the scratchpad is consumed during the first `start()`
47
+ * triggered by `hydrateRouter` regardless of route mismatch; subsequent
48
+ * `start()` calls run loaders normally.
14
49
  *
15
50
  * @example
16
51
  * ```typescript
@@ -19,15 +54,36 @@ import type { Router, State } from "@real-router/types";
19
54
  * router.usePlugin(browserPluginFactory());
20
55
  * await hydrateRouter(router, window.__SSR_STATE__);
21
56
  * ```
57
+ *
58
+ * @example
59
+ * ```typescript
60
+ * // With non-JSON types (Date / Map / Set / RegExp / BigInt) via devalue (#606)
61
+ * import * as devalue from "devalue";
62
+ *
63
+ * await hydrateRouter(router, window.__SSR_STATE__, {
64
+ * deserialize: devalue.parse,
65
+ * });
66
+ * ```
22
67
  */
23
- export function hydrateRouter(
68
+ export async function hydrateRouter(
24
69
  router: Router,
25
70
  source: string | { path: string },
71
+ options?: HydrateRouterOptions,
26
72
  ): Promise<State> {
73
+ const deserialize: Deserialize = options?.deserialize ?? JSON.parse;
27
74
  const parsed =
28
75
  typeof source === "string"
29
- ? (JSON.parse(source) as { path: string })
30
- : source;
76
+ ? (deserialize(source) as SerializedRouterState)
77
+ : (source as SerializedRouterState);
78
+
79
+ const ctx = getInternals(router);
80
+ const previous = ctx.hydrationState;
81
+
82
+ ctx.hydrationState = parsed;
31
83
 
32
- return router.start(parsed.path);
84
+ try {
85
+ return await router.start(parsed.path);
86
+ } finally {
87
+ ctx.hydrationState = previous;
88
+ }
33
89
  }
@@ -1,11 +1,27 @@
1
+ export { createRequestScope } from "./createRequestScope";
2
+
3
+ export type {
4
+ IncomingMessageLike,
5
+ RequestLike,
6
+ RequestScope,
7
+ RequestScopeSource,
8
+ } from "./createRequestScope";
9
+
1
10
  export { getStaticPaths } from "./getStaticPaths";
2
11
 
3
12
  export { hydrateRouter } from "./hydrateRouter";
4
13
 
14
+ export type { Deserialize, HydrateRouterOptions } from "./hydrateRouter";
15
+
5
16
  export { serializeRouterState } from "./serializeRouterState";
6
17
 
7
- export type { SerializeRouterStateOptions } from "./serializeRouterState";
18
+ export type {
19
+ SerializedRouterState,
20
+ SerializeRouterStateOptions,
21
+ } from "./serializeRouterState";
8
22
 
9
23
  export { serializeState } from "./serializeState";
10
24
 
25
+ export type { Serialize, SerializeStateOptions } from "./serializeState";
26
+
11
27
  export type { StaticPathEntries } from "./getStaticPaths";
@@ -1,6 +1,20 @@
1
1
  import { serializeState } from "./serializeState";
2
2
 
3
- import type { State } from "@real-router/types";
3
+ import type { Serialize } from "./serializeState";
4
+ import type { Params, State } from "@real-router/types";
5
+
6
+ /**
7
+ * Parsed shape produced by {@link serializeRouterState} (after `JSON.parse`).
8
+ *
9
+ * Identical to {@link State} minus `transition` (per-navigation `TransitionMeta`
10
+ * is meaningless after hydration; the client builds its own on commit). Used as
11
+ * the input shape for {@link hydrateRouter} and as the type of the one-shot
12
+ * hydration scratchpad consumed by SSR loader plugins.
13
+ */
14
+ export type SerializedRouterState<P extends Params = Params> = Omit<
15
+ State<P>,
16
+ "transition"
17
+ >;
4
18
 
5
19
  export interface SerializeRouterStateOptions {
6
20
  /**
@@ -11,6 +25,25 @@ export interface SerializeRouterStateOptions {
11
25
  * @default []
12
26
  */
13
27
  excludeContext?: readonly string[];
28
+
29
+ /**
30
+ * Custom serializer (e.g., `devalue.stringify` / `superjson.stringify`) to
31
+ * support non-JSON types in `state.params` and `state.context.<ns>` payloads
32
+ * (Date / Map / Set / RegExp / BigInt). Defaults to `JSON.stringify`.
33
+ *
34
+ * Pair with the matching `deserialize` on `hydrateRouter` to round-trip the
35
+ * extended types on the client.
36
+ *
37
+ * @default JSON.stringify
38
+ *
39
+ * @example
40
+ * ```typescript
41
+ * import * as devalue from "devalue";
42
+ *
43
+ * const json = serializeRouterState(state, { serialize: devalue.stringify });
44
+ * ```
45
+ */
46
+ serialize?: Serialize;
14
47
  }
15
48
 
16
49
  /**
@@ -42,6 +75,16 @@ export interface SerializeRouterStateOptions {
42
75
  * const state = await router.start(url);
43
76
  * const json = serializeRouterState(state, { excludeContext: ["rsc"] });
44
77
  * ```
78
+ *
79
+ * @example
80
+ * ```typescript
81
+ * // Non-JSON types (Date / Map / Set / RegExp / BigInt) via devalue (#606)
82
+ * import * as devalue from "devalue";
83
+ *
84
+ * const json = serializeRouterState(state, { serialize: devalue.stringify });
85
+ * // On the client:
86
+ * await hydrateRouter(router, json, { deserialize: devalue.parse });
87
+ * ```
45
88
  */
46
89
  export function serializeRouterState(
47
90
  state: State,
@@ -53,7 +96,7 @@ export function serializeRouterState(
53
96
 
54
97
  if (exclude?.length) {
55
98
  const filtered: Record<string, unknown> = {};
56
- const source = state.context as Record<string, unknown>;
99
+ const source = state.context;
57
100
 
58
101
  for (const key of Object.keys(source)) {
59
102
  if (!exclude.includes(key)) {
@@ -64,10 +107,14 @@ export function serializeRouterState(
64
107
  context = filtered;
65
108
  }
66
109
 
67
- return serializeState({
110
+ const payload = {
68
111
  name: state.name,
69
112
  params: state.params,
70
113
  path: state.path,
71
114
  context,
72
- });
115
+ };
116
+
117
+ return options?.serialize
118
+ ? serializeState(payload, { serialize: options.serialize })
119
+ : serializeState(payload);
73
120
  }