@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.
- package/README.md +22 -10
- package/dist/cjs/Router-CJihdrWA.d.ts +67 -0
- package/dist/cjs/Router-CJihdrWA.d.ts.map +1 -0
- package/dist/cjs/Router-D8Awa7bY.js +6 -0
- package/dist/cjs/Router-D8Awa7bY.js.map +1 -0
- package/dist/cjs/RouterError-Bm9YnZ6e.d.ts +310 -0
- package/dist/cjs/RouterError-Bm9YnZ6e.d.ts.map +1 -0
- package/dist/cjs/api.d.ts +2 -1
- package/dist/cjs/api.d.ts.map +1 -1
- package/dist/cjs/api.js +1 -1
- package/dist/cjs/api.js.map +1 -1
- package/dist/cjs/cloneRouter-q-jHlBiv.js +2 -0
- package/dist/cjs/cloneRouter-q-jHlBiv.js.map +1 -0
- package/dist/cjs/index-8oPDJBQc.d.ts +306 -0
- package/dist/cjs/index-8oPDJBQc.d.ts.map +1 -0
- package/dist/cjs/{Router-DrBkBdZ5.d.ts → index-EwbhzRQw.d.ts} +4 -69
- package/dist/cjs/index-EwbhzRQw.d.ts.map +1 -0
- package/dist/cjs/index.d.ts +4 -197
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/{internals-na15rxo_.js → internals-CM6oaz9n.js} +1 -1
- package/dist/cjs/internals-CM6oaz9n.js.map +1 -0
- package/dist/cjs/utils.d.ts +2 -91
- package/dist/cjs/utils.js +1 -1
- package/dist/cjs/utils.js.map +1 -1
- package/dist/cjs/validation.d.ts +55 -44
- package/dist/cjs/validation.d.ts.map +1 -1
- package/dist/cjs/validation.js +1 -1
- package/dist/esm/Router-BeMyxy_V.mjs +6 -0
- package/dist/esm/Router-BeMyxy_V.mjs.map +1 -0
- package/dist/esm/Router-BmhiDQUJ.d.mts +67 -0
- package/dist/esm/Router-BmhiDQUJ.d.mts.map +1 -0
- package/dist/esm/RouterError-hhfSVGtY.d.mts +310 -0
- package/dist/esm/RouterError-hhfSVGtY.d.mts.map +1 -0
- package/dist/esm/api.d.mts +2 -1
- package/dist/esm/api.d.mts.map +1 -1
- package/dist/esm/api.mjs +1 -1
- package/dist/esm/api.mjs.map +1 -1
- package/dist/esm/cloneRouter-C_ULpzHM.mjs +2 -0
- package/dist/esm/cloneRouter-C_ULpzHM.mjs.map +1 -0
- package/dist/esm/{Router-BeXr2zW4.d.mts → index-DNjaY7KH.d.mts} +4 -69
- package/dist/esm/index-DNjaY7KH.d.mts.map +1 -0
- package/dist/esm/index-r_JTvSBH.d.mts +306 -0
- package/dist/esm/index-r_JTvSBH.d.mts.map +1 -0
- package/dist/esm/index.d.mts +4 -197
- package/dist/esm/index.d.mts.map +1 -1
- package/dist/esm/index.mjs +1 -1
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/{internals-CCymabFj.mjs → internals-C59msvHY.mjs} +1 -1
- package/dist/esm/internals-C59msvHY.mjs.map +1 -0
- package/dist/esm/utils.d.mts +2 -91
- package/dist/esm/utils.mjs +1 -1
- package/dist/esm/utils.mjs.map +1 -1
- package/dist/esm/validation.d.mts +56 -43
- package/dist/esm/validation.d.mts.map +1 -1
- package/dist/esm/validation.mjs +1 -1
- package/package.json +1 -1
- package/src/Router.ts +13 -16
- package/src/api/getDependenciesApi.ts +3 -11
- package/src/api/getPluginApi.ts +3 -3
- package/src/api/getRoutesApi.ts +4 -2
- package/src/internals.ts +13 -1
- package/src/namespaces/DependenciesNamespace/dependenciesStore.ts +1 -1
- package/src/namespaces/NavigationNamespace/NavigationNamespace.ts +8 -4
- package/src/namespaces/NavigationNamespace/transition/completeTransition.ts +4 -0
- package/src/namespaces/RoutesNamespace/RoutesNamespace.ts +3 -3
- package/src/namespaces/StateNamespace/StateNamespace.ts +2 -2
- package/src/utils/createRequestScope.ts +174 -0
- package/src/utils/hydrateRouter.ts +68 -12
- package/src/utils/index.ts +17 -1
- package/src/utils/serializeRouterState.ts +51 -4
- package/src/utils/serializeState.ts +46 -5
- package/dist/cjs/Router-DrBkBdZ5.d.ts.map +0 -1
- package/dist/cjs/Router-Pztue5fk.js +0 -6
- package/dist/cjs/Router-Pztue5fk.js.map +0 -1
- package/dist/cjs/RouterError-BmvAyBlx.js +0 -2
- package/dist/cjs/RouterError-BmvAyBlx.js.map +0 -1
- package/dist/cjs/RouterValidator-DLy_W2du.d.ts +0 -114
- package/dist/cjs/RouterValidator-DLy_W2du.d.ts.map +0 -1
- package/dist/cjs/getPluginApi-CUcFDzuA.js +0 -2
- package/dist/cjs/getPluginApi-CUcFDzuA.js.map +0 -1
- package/dist/cjs/internals-na15rxo_.js.map +0 -1
- package/dist/cjs/utils.d.ts.map +0 -1
- package/dist/esm/Router-BeXr2zW4.d.mts.map +0 -1
- package/dist/esm/Router-CK8U23pP.mjs +0 -6
- package/dist/esm/Router-CK8U23pP.mjs.map +0 -1
- package/dist/esm/RouterError-D-Zjbdv9.mjs +0 -2
- package/dist/esm/RouterError-D-Zjbdv9.mjs.map +0 -1
- package/dist/esm/RouterValidator-C-PvV00i.d.mts +0 -114
- package/dist/esm/RouterValidator-C-PvV00i.d.mts.map +0 -1
- package/dist/esm/getPluginApi-CsTfDB-O.mjs +0 -2
- package/dist/esm/getPluginApi-CsTfDB-O.mjs.map +0 -1
- package/dist/esm/internals-CCymabFj.mjs.map +0 -1
- 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
|
-
)
|
|
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
|
|
681
|
-
this.navigateToDefault = throwDisposed
|
|
682
|
-
this.navigateToNotFound = throwDisposed
|
|
683
|
-
this.start = throwDisposed
|
|
684
|
-
this.stop = throwDisposed
|
|
685
|
-
this.usePlugin = throwDisposed
|
|
686
|
-
|
|
687
|
-
this.subscribe = throwDisposed
|
|
688
|
-
this.subscribeLeave = throwDisposed
|
|
689
|
-
this.canNavigateTo = throwDisposed
|
|
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
|
|
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
|
}
|
package/src/api/getPluginApi.ts
CHANGED
|
@@ -200,7 +200,7 @@ export function getPluginApi<
|
|
|
200
200
|
throwIfDisposed(ctx.isDisposed);
|
|
201
201
|
ctx.emitTransitionError(error);
|
|
202
202
|
},
|
|
203
|
-
claimContextNamespace: (
|
|
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
|
-
|
|
216
|
+
state.context[namespace] = value;
|
|
217
217
|
},
|
|
218
218
|
release() {
|
|
219
219
|
ctx.contextClaimRecords.delete(namespace);
|
|
220
220
|
},
|
|
221
221
|
} satisfies ContextNamespaceClaim;
|
|
222
|
-
}
|
|
222
|
+
},
|
|
223
223
|
};
|
|
224
224
|
|
|
225
225
|
cache.set(router, api);
|
package/src/api/getRoutesApi.ts
CHANGED
|
@@ -360,7 +360,8 @@ function updateRouteConfig<
|
|
|
360
360
|
const decoder = updates.decodeParams;
|
|
361
361
|
|
|
362
362
|
store.config.decoders[name] = (params: Params): Params =>
|
|
363
|
-
|
|
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
|
-
|
|
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
|
|
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> = {}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
|
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
|
|
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
|
-
}
|
|
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 })
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
? (
|
|
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
|
-
|
|
84
|
+
try {
|
|
85
|
+
return await router.start(parsed.path);
|
|
86
|
+
} finally {
|
|
87
|
+
ctx.hydrationState = previous;
|
|
88
|
+
}
|
|
33
89
|
}
|
package/src/utils/index.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
}
|