@rangojs/router 0.0.0-experimental.139 → 0.0.0-experimental.140
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/dist/bin/rango.js +27 -2
- package/dist/vite/index.js +147 -30
- package/package.json +1 -1
- package/skills/breadcrumbs/SKILL.md +1 -1
- package/skills/cache-guide/SKILL.md +1 -0
- package/skills/caching/SKILL.md +1 -1
- package/skills/migrate-nextjs/SKILL.md +15 -0
- package/skills/migrate-react-router/SKILL.md +15 -2
- package/skills/ppr/SKILL.md +426 -0
- package/skills/rango/SKILL.md +28 -25
- package/skills/route/SKILL.md +43 -0
- package/src/build/route-trie.ts +35 -7
- package/src/cache/cf/cf-cache-store.ts +155 -0
- package/src/cache/index.ts +6 -0
- package/src/cache/memory-segment-store.ts +57 -1
- package/src/cache/shell-cache.ts +386 -0
- package/src/cache/types.ts +58 -0
- package/src/cache/vercel/vercel-cache-store.ts +159 -5
- package/src/index.rsc.ts +5 -0
- package/src/index.ts +17 -0
- package/src/router/middleware.ts +14 -5
- package/src/router/parse-pattern.ts +115 -0
- package/src/router/pattern-matching.ts +53 -64
- package/src/router/segment-resolution/fresh.ts +12 -1
- package/src/router/segment-resolution/loader-cache.ts +14 -0
- package/src/router/segment-resolution/loader-mask.ts +44 -0
- package/src/router/substitute-pattern-params.ts +54 -35
- package/src/router/trie-matching.ts +19 -11
- package/src/router/url-params.ts +13 -0
- package/src/rsc/full-payload.ts +70 -0
- package/src/rsc/rsc-rendering.ts +105 -51
- package/src/rsc/shell-capture.ts +439 -0
- package/src/rsc/types.ts +26 -0
- package/src/server/cookie-store.ts +45 -0
- package/src/server/live.ts +130 -0
- package/src/server/request-context.ts +49 -0
- package/src/ssr/index.tsx +377 -180
- package/src/ssr/ssr-root.tsx +228 -0
- package/src/testing/render-route.tsx +7 -9
- package/src/types/route-config.ts +19 -7
- package/src/urls/type-extraction.ts +43 -18
- package/src/vite/discovery/discovery-errors.ts +61 -0
- package/src/vite/plugins/virtual-entries.ts +27 -2
- package/src/vite/router-discovery.ts +69 -15
- package/src/vite/utils/prerender-utils.ts +17 -4
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { renderSegments } from "../segment-system.js";
|
|
3
|
+
import {
|
|
4
|
+
filterSegmentOrder,
|
|
5
|
+
filterRouteSegmentIds,
|
|
6
|
+
} from "../browser/react/filter-segment-order.js";
|
|
7
|
+
import { ThemeProvider } from "../theme/ThemeProvider.js";
|
|
8
|
+
import { NonceContext } from "../browser/react/nonce-context.js";
|
|
9
|
+
import { NavigationStoreContext } from "../browser/react/context.js";
|
|
10
|
+
import type { NavigationStoreContextValue } from "../browser/react/context.js";
|
|
11
|
+
import type { HandleData } from "../browser/types.js";
|
|
12
|
+
import type { ResolvedSegment } from "../types.js";
|
|
13
|
+
import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
|
|
14
|
+
import type {
|
|
15
|
+
EventController,
|
|
16
|
+
DerivedNavigationState,
|
|
17
|
+
} from "../browser/event-controller.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* createFromReadableStream from @rangojs/router/internal/deps/ssr.
|
|
21
|
+
* Deserializes the Flight branch used to build the SSR VDOM.
|
|
22
|
+
*/
|
|
23
|
+
export type CreateFromReadableStream = <T>(
|
|
24
|
+
stream: ReadableStream<Uint8Array>,
|
|
25
|
+
) => Promise<T>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* RSC payload type (minimal interface for SSR)
|
|
29
|
+
*/
|
|
30
|
+
export interface RscPayload {
|
|
31
|
+
metadata?: {
|
|
32
|
+
segments?: ResolvedSegment[];
|
|
33
|
+
rootLayout?: React.ComponentType<{ children: React.ReactNode }>;
|
|
34
|
+
handles?: AsyncGenerator<HandleData, void, unknown>;
|
|
35
|
+
matched?: string[];
|
|
36
|
+
pathname?: string;
|
|
37
|
+
params?: Record<string, string>;
|
|
38
|
+
basename?: string;
|
|
39
|
+
themeConfig?: ResolvedThemeConfig | null;
|
|
40
|
+
initialTheme?: Theme;
|
|
41
|
+
version?: string;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Consume an async generator and return a Promise that resolves with the final value.
|
|
47
|
+
* Used for SSR where we need to await all handle data before rendering.
|
|
48
|
+
*/
|
|
49
|
+
async function consumeAsyncGenerator(
|
|
50
|
+
generator: AsyncGenerator<HandleData, void, unknown>,
|
|
51
|
+
): Promise<HandleData> {
|
|
52
|
+
let lastData: HandleData = {};
|
|
53
|
+
for await (const data of generator) {
|
|
54
|
+
lastData = data;
|
|
55
|
+
}
|
|
56
|
+
return lastData;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Create a minimal event controller for SSR.
|
|
61
|
+
* This provides the correct pathname so useNavigation returns the right value during SSR.
|
|
62
|
+
*/
|
|
63
|
+
function createSsrEventController(opts: {
|
|
64
|
+
pathname: string;
|
|
65
|
+
params?: Record<string, string>;
|
|
66
|
+
handleData?: HandleData;
|
|
67
|
+
matched?: string[];
|
|
68
|
+
}): EventController {
|
|
69
|
+
const location = new URL(opts.pathname, "http://localhost");
|
|
70
|
+
let params = opts.params ?? {};
|
|
71
|
+
const rawMatched = opts.matched ?? [];
|
|
72
|
+
const handleState = {
|
|
73
|
+
data: opts.handleData ?? {},
|
|
74
|
+
segmentOrder: filterSegmentOrder(rawMatched),
|
|
75
|
+
routeSegmentIds: filterRouteSegmentIds(rawMatched),
|
|
76
|
+
};
|
|
77
|
+
const state: DerivedNavigationState = {
|
|
78
|
+
state: "idle",
|
|
79
|
+
isStreaming: false,
|
|
80
|
+
isNavigating: false,
|
|
81
|
+
location,
|
|
82
|
+
pendingUrl: null,
|
|
83
|
+
inflightActions: [],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
getState: () => state,
|
|
88
|
+
getLocation: () => location,
|
|
89
|
+
subscribe: () => () => {},
|
|
90
|
+
getActionState: () => ({
|
|
91
|
+
state: "idle",
|
|
92
|
+
actionId: null,
|
|
93
|
+
payload: null,
|
|
94
|
+
error: null,
|
|
95
|
+
result: null,
|
|
96
|
+
}),
|
|
97
|
+
subscribeToAction: () => () => {},
|
|
98
|
+
subscribeToHandles: () => () => {},
|
|
99
|
+
setHandleData: () => {},
|
|
100
|
+
getHandleState: () => handleState,
|
|
101
|
+
setRouteSegmentIds: () => {},
|
|
102
|
+
setParams: (nextParams) => {
|
|
103
|
+
params = nextParams;
|
|
104
|
+
},
|
|
105
|
+
getParams: () => params,
|
|
106
|
+
setLocation: () => {},
|
|
107
|
+
startNavigation: () => {
|
|
108
|
+
throw new Error("Navigation not supported during SSR");
|
|
109
|
+
},
|
|
110
|
+
abortNavigation: () => {},
|
|
111
|
+
startAction: () => {
|
|
112
|
+
throw new Error("Actions not supported during SSR");
|
|
113
|
+
},
|
|
114
|
+
abortAllActions: () => {},
|
|
115
|
+
getCurrentNavigation: () => null,
|
|
116
|
+
getInflightActions: () => new Map(),
|
|
117
|
+
hadAnyConcurrentActions: () => false,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Options for {@link createSsrRootComponent}.
|
|
123
|
+
*/
|
|
124
|
+
export interface SsrRootOptions {
|
|
125
|
+
/** createFromReadableStream for the SSR branch of the Flight stream. */
|
|
126
|
+
createFromReadableStream: CreateFromReadableStream;
|
|
127
|
+
/** The Flight stream branch to deserialize into the SSR VDOM. */
|
|
128
|
+
rscStream: ReadableStream<Uint8Array>;
|
|
129
|
+
/** Nonce for CSP; propagated to NonceContext. */
|
|
130
|
+
nonce?: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Build the closure component that deserializes the Flight payload, consumes
|
|
135
|
+
* handles to completion, builds the segment tree, and wraps it in the
|
|
136
|
+
* NavigationStore / Nonce / Theme providers.
|
|
137
|
+
*
|
|
138
|
+
* The full-fizz path (renderHTML), the shell prerender pass (capture), and the
|
|
139
|
+
* shell resume pass all render this identical tree. `resume` requires the tree
|
|
140
|
+
* above the postponed holes to match the prerendered tree; rendering the same
|
|
141
|
+
* builder over the same replayed segments is what makes that hold. A fresh
|
|
142
|
+
* component instance per pass is fine — replay matches structure, not function
|
|
143
|
+
* identity.
|
|
144
|
+
*
|
|
145
|
+
* The memo slots (payload/handles/context/root) are closure-scoped to the
|
|
146
|
+
* returned instance, so each render pass memoizes independently. renderSegments
|
|
147
|
+
* is async: React.use() on a fresh promise would suspend and replay SsrRoot,
|
|
148
|
+
* re-running the whole segment-tree build unless the promise is memoized.
|
|
149
|
+
*/
|
|
150
|
+
export function createSsrRootComponent(opts: SsrRootOptions): React.FC {
|
|
151
|
+
const { createFromReadableStream, rscStream, nonce } = opts;
|
|
152
|
+
|
|
153
|
+
let payload: Promise<RscPayload> | undefined;
|
|
154
|
+
let handlesPromise: Promise<HandleData> | undefined;
|
|
155
|
+
let ssrContextValue: NavigationStoreContextValue | undefined;
|
|
156
|
+
let rootPromise: Promise<React.ReactNode> | undefined;
|
|
157
|
+
|
|
158
|
+
return function SsrRoot() {
|
|
159
|
+
payload ??= createFromReadableStream<RscPayload>(rscStream);
|
|
160
|
+
const resolved = React.use(payload);
|
|
161
|
+
|
|
162
|
+
const themeConfig = resolved.metadata?.themeConfig ?? null;
|
|
163
|
+
const pathname = resolved.metadata?.pathname ?? "/";
|
|
164
|
+
|
|
165
|
+
// Await handles before creating SSR event controller so hooks can
|
|
166
|
+
// read request-local handle data via NavigationStoreContext.
|
|
167
|
+
// The handles property is an async generator that yields on each push
|
|
168
|
+
// Memoize the promise since async generators can only be iterated once
|
|
169
|
+
let handleData: HandleData = {};
|
|
170
|
+
if (resolved.metadata?.handles) {
|
|
171
|
+
handlesPromise ??= consumeAsyncGenerator(resolved.metadata.handles);
|
|
172
|
+
handleData = React.use(handlesPromise);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Create SSR context with request-local pathname/params/handles.
|
|
176
|
+
ssrContextValue ??= {
|
|
177
|
+
store: null as any,
|
|
178
|
+
eventController: createSsrEventController({
|
|
179
|
+
pathname,
|
|
180
|
+
params: resolved.metadata?.params,
|
|
181
|
+
handleData,
|
|
182
|
+
matched: resolved.metadata?.matched,
|
|
183
|
+
}),
|
|
184
|
+
navigate: async () => {},
|
|
185
|
+
refresh: async () => {},
|
|
186
|
+
version: resolved.metadata?.version,
|
|
187
|
+
basename: resolved.metadata?.basename,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// Build content tree from segments.
|
|
191
|
+
// Order must match NavigationProvider: NavigationStoreContext > NonceContext > ThemeProvider > content
|
|
192
|
+
// Memoize like payload/handles above: renderSegments is async, so
|
|
193
|
+
// React.use() on a fresh promise suspends and replays SsrRoot, which
|
|
194
|
+
// would re-run the entire segment-tree build on every initial render.
|
|
195
|
+
rootPromise ??= Promise.resolve(
|
|
196
|
+
renderSegments(resolved.metadata?.segments ?? [], {
|
|
197
|
+
rootLayout: resolved.metadata?.rootLayout,
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
let content: React.ReactNode = React.use(rootPromise);
|
|
201
|
+
|
|
202
|
+
// Wrap content with ThemeProvider if theme is enabled
|
|
203
|
+
if (themeConfig) {
|
|
204
|
+
content = (
|
|
205
|
+
<ThemeProvider
|
|
206
|
+
config={themeConfig}
|
|
207
|
+
initialTheme={resolved.metadata?.initialTheme}
|
|
208
|
+
>
|
|
209
|
+
{content}
|
|
210
|
+
</ThemeProvider>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Wrap with NonceContext so client components (e.g. MetaTags) can
|
|
215
|
+
// apply CSP nonces to inline scripts during SSR. Always present to
|
|
216
|
+
// match the browser-side NavigationProvider tree shape for hydration.
|
|
217
|
+
content = (
|
|
218
|
+
<NonceContext.Provider value={nonce}>{content}</NonceContext.Provider>
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
// Wrap with NavigationStoreContext for useNavigation hook
|
|
222
|
+
return (
|
|
223
|
+
<NavigationStoreContext.Provider value={ssrContextValue!}>
|
|
224
|
+
{content}
|
|
225
|
+
</NavigationStoreContext.Provider>
|
|
226
|
+
);
|
|
227
|
+
};
|
|
228
|
+
}
|
|
@@ -51,7 +51,10 @@ import type { NavigationStore, NavigationBridge } from "../browser/types.js";
|
|
|
51
51
|
import type { EventController } from "../browser/event-controller.js";
|
|
52
52
|
import type { ResolvedSegment, RscMetadata } from "../browser/types.js";
|
|
53
53
|
import { NavigationProvider } from "../browser/react/NavigationProvider.js";
|
|
54
|
-
import {
|
|
54
|
+
import {
|
|
55
|
+
compilePattern,
|
|
56
|
+
buildParamsFromMatch,
|
|
57
|
+
} from "../router/pattern-matching.js";
|
|
55
58
|
import { normalizeBasename } from "../router/basename.js";
|
|
56
59
|
import type { LoaderDefinition } from "../types.js";
|
|
57
60
|
import type { LocationStateDefinition } from "../browser/react/location-state-shared.js";
|
|
@@ -303,14 +306,9 @@ function matchLeaf(
|
|
|
303
306
|
const compiled = compilePattern(pattern);
|
|
304
307
|
const match = compiled.regex.exec(pathname);
|
|
305
308
|
if (!match) return null;
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
if (value !== undefined) {
|
|
310
|
-
params[name] = decodeURIComponent(value);
|
|
311
|
-
}
|
|
312
|
-
});
|
|
313
|
-
return params;
|
|
309
|
+
// Reuse the production param builder so the harness matches the real matcher
|
|
310
|
+
// exactly (named catch-all "" binding, decoding) instead of forking it.
|
|
311
|
+
return buildParamsFromMatch(match, compiled.paramNames, compiled.catchAll);
|
|
314
312
|
}
|
|
315
313
|
|
|
316
314
|
function staticPrefix(pattern: string): string {
|
|
@@ -10,20 +10,29 @@ export type DocumentProps = {
|
|
|
10
10
|
type ParseConstraint<T extends string> =
|
|
11
11
|
T extends `${infer First}|${infer Rest}` ? First | ParseConstraint<Rest> : T;
|
|
12
12
|
|
|
13
|
+
// Named catch-all (`:name*` / `:name+`) is matched BEFORE the `?`/suffix
|
|
14
|
+
// branches. Its modifier is anchored to the END of the token (no trailing
|
|
15
|
+
// `${string}`) so it is a true suffix and never mis-splits a constraint body
|
|
16
|
+
// such as `id(\d+)`. Both are a required `string`: a matched catch-all always
|
|
17
|
+
// binds a value (possibly ""), so the key is always present.
|
|
13
18
|
type ExtractParamInfo<T extends string> =
|
|
14
19
|
T extends `${infer Name}(${infer Constraint})?${string}`
|
|
15
20
|
? { name: Name; optional: true; type: ParseConstraint<Constraint> }
|
|
16
21
|
: T extends `${infer Name}(${infer Constraint})${string}`
|
|
17
22
|
? { name: Name; optional: false; type: ParseConstraint<Constraint> }
|
|
18
|
-
: T extends `${infer Name}
|
|
19
|
-
? { name: Name; optional:
|
|
20
|
-
: T extends `${infer Name}
|
|
23
|
+
: T extends `${infer Name}*`
|
|
24
|
+
? { name: Name; optional: false; type: string }
|
|
25
|
+
: T extends `${infer Name}+`
|
|
21
26
|
? { name: Name; optional: false; type: string }
|
|
22
|
-
: T extends `${infer Name}
|
|
23
|
-
? { name: Name; optional:
|
|
24
|
-
: T extends `${infer Name}
|
|
27
|
+
: T extends `${infer Name}?${string}`
|
|
28
|
+
? { name: Name; optional: true; type: string }
|
|
29
|
+
: T extends `${infer Name}.${string}`
|
|
25
30
|
? { name: Name; optional: false; type: string }
|
|
26
|
-
:
|
|
31
|
+
: T extends `${infer Name}-${string}`
|
|
32
|
+
? { name: Name; optional: false; type: string }
|
|
33
|
+
: T extends `${infer Name}~${string}`
|
|
34
|
+
? { name: Name; optional: false; type: string }
|
|
35
|
+
: { name: T; optional: false; type: string };
|
|
27
36
|
|
|
28
37
|
type ParamFromInfo<Info> = Info extends {
|
|
29
38
|
name: infer N extends string;
|
|
@@ -51,12 +60,15 @@ type MergeParams<A, B> = Pick<A, keyof A> & Pick<B, keyof B> extends infer O
|
|
|
51
60
|
* - Optional params: /:locale? -> { locale?: string }
|
|
52
61
|
* - Constrained params: /:locale(en|gb) -> { locale: "en" | "gb" }
|
|
53
62
|
* - Optional + constrained: /:locale(en|gb)? -> { locale?: "en" | "gb" }
|
|
63
|
+
* - Named catch-all: /:path+ (one-or-more), /:slug* (zero-or-more) -> string
|
|
54
64
|
*
|
|
55
65
|
* @example
|
|
56
66
|
* ExtractParams<"/products/:id"> // { id: string }
|
|
57
67
|
* ExtractParams<"/:locale?/blog/:slug"> // { locale?: string; slug: string }
|
|
58
68
|
* ExtractParams<"/:locale(en|gb)/blog"> // { locale: "en" | "gb" }
|
|
59
69
|
* ExtractParams<"/:locale(en|gb)?/blog/:slug"> // { locale?: "en" | "gb"; slug: string }
|
|
70
|
+
* ExtractParams<"/docs/:slug*"> // { slug: string }
|
|
71
|
+
* ExtractParams<"/shop/:path+"> // { path: string }
|
|
60
72
|
*/
|
|
61
73
|
export type ExtractParams<
|
|
62
74
|
T extends string,
|
|
@@ -81,16 +81,26 @@ type ExtractRoutesFromItem<T> =
|
|
|
81
81
|
// When search schema is non-empty, value becomes { path, search } object
|
|
82
82
|
T extends TypedRouteItem<infer TName, infer TPattern, any, infer TSearch>
|
|
83
83
|
? TName extends string
|
|
84
|
-
?
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
84
|
+
? // Widened-name guard (#642): some name-less call forms — notably the
|
|
85
|
+
// 3-arg children-fn overload path(pattern, component, () => [...]) —
|
|
86
|
+
// let TName infer to the bare `string` constraint instead of the
|
|
87
|
+
// UnnamedRoute sentinel, because the children-fn argument structurally
|
|
88
|
+
// satisfies the all-optional PathOptions<TName> union member. Mapping
|
|
89
|
+
// over a bare `string` key would emit `{ [K in string]: TPattern }`, an
|
|
90
|
+
// index signature that poisons the whole sibling map (Rango.Path
|
|
91
|
+
// collapses to never). Treat an unresolved name as unnamed.
|
|
92
|
+
string extends TName
|
|
93
|
+
? {}
|
|
94
|
+
: TName extends UnnamedRoute
|
|
95
|
+
? {} // Exclude unnamed routes from type map
|
|
96
|
+
: {} extends TSearch
|
|
97
|
+
? { [K in TName]: TPattern }
|
|
98
|
+
: {
|
|
99
|
+
[K in TName]: {
|
|
100
|
+
readonly path: TPattern;
|
|
101
|
+
readonly search: TSearch;
|
|
102
|
+
};
|
|
103
|
+
}
|
|
94
104
|
: {}
|
|
95
105
|
: // TypedIncludeItem: extract prefixed routes (both name and URL prefix)
|
|
96
106
|
T extends TypedIncludeItem<
|
|
@@ -128,9 +138,15 @@ type ExtractRoutesFromItems<T extends readonly any[]> = T extends readonly any[]
|
|
|
128
138
|
? UnionToIntersection<
|
|
129
139
|
{ [K in keyof T]: ExtractRoutesFromItem<T[K]> }[number]
|
|
130
140
|
> extends infer R
|
|
131
|
-
?
|
|
132
|
-
|
|
133
|
-
|
|
141
|
+
? // Blast-radius guard: never let a single malformed item collapse the
|
|
142
|
+
// whole map. A `never` intersection satisfies `extends Record<string,any>`
|
|
143
|
+
// (never extends everything), so check it explicitly first and fall back
|
|
144
|
+
// to `{}` rather than propagating `never`. See #642.
|
|
145
|
+
[R] extends [never]
|
|
146
|
+
? {}
|
|
147
|
+
: R extends Record<string, any>
|
|
148
|
+
? R
|
|
149
|
+
: {}
|
|
134
150
|
: {}
|
|
135
151
|
: {};
|
|
136
152
|
|
|
@@ -169,9 +185,15 @@ type PrefixKeys<
|
|
|
169
185
|
type ExtractResponsesFromItem<T> =
|
|
170
186
|
T extends TypedRouteItem<infer TName, any, infer TData>
|
|
171
187
|
? TName extends string
|
|
172
|
-
?
|
|
188
|
+
? // Widened-name guard (#642), parallels ExtractRoutesFromItem. A name-less
|
|
189
|
+
// children-fn path.json(pattern, handler, () => [...]) infers TName as
|
|
190
|
+
// bare `string`; without this the response map picks up an index
|
|
191
|
+
// signature { [K in string]: TData } that wipes named siblings.
|
|
192
|
+
string extends TName
|
|
173
193
|
? {}
|
|
174
|
-
:
|
|
194
|
+
: TName extends UnnamedRoute
|
|
195
|
+
? {}
|
|
196
|
+
: { [K in TName]: TData }
|
|
175
197
|
: {}
|
|
176
198
|
: T extends TypedIncludeItem<any, infer TNamePrefix, any, infer TResponses>
|
|
177
199
|
? TNamePrefix extends LocalOnlyInclude
|
|
@@ -206,9 +228,12 @@ type ExtractResponsesFromItems<T extends readonly any[]> =
|
|
|
206
228
|
? UnionToIntersection<
|
|
207
229
|
{ [K in keyof T]: ExtractResponsesFromItem<T[K]> }[number]
|
|
208
230
|
> extends infer R
|
|
209
|
-
?
|
|
210
|
-
|
|
211
|
-
|
|
231
|
+
? // Blast-radius guard (parallels ExtractRoutesFromItems). See #642.
|
|
232
|
+
[R] extends [never]
|
|
233
|
+
? {}
|
|
234
|
+
: R extends Record<string, unknown>
|
|
235
|
+
? R
|
|
236
|
+
: {}
|
|
212
237
|
: {}
|
|
213
238
|
: {};
|
|
214
239
|
|
|
@@ -192,3 +192,64 @@ export class DiscoveryError extends Error {
|
|
|
192
192
|
Object.setPrototypeOf(this, DiscoveryError.prototype);
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
|
+
|
|
196
|
+
/** How the dev caller should surface a discovery failure. */
|
|
197
|
+
export interface DiscoveryFailureReport {
|
|
198
|
+
level: "error" | "warn";
|
|
199
|
+
message: string;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Decide how to surface a dev-boot discovery failure in the terminal.
|
|
204
|
+
*
|
|
205
|
+
* The bare "no routers found" case (a DiscoveryError with no caught host-handler
|
|
206
|
+
* failures) is ambiguous. It is either:
|
|
207
|
+
* - a genuine misconfiguration — the entry never calls createRouter(), or the
|
|
208
|
+
* configured entry path is wrong; or
|
|
209
|
+
* - a transient artifact of a Vite dependency re-optimization racing with boot
|
|
210
|
+
* discovery (a module read before the entry import resolves to the
|
|
211
|
+
* pre-optimize copy of the runner graph while createRouter() populated the
|
|
212
|
+
* post-optimize copy — see router-discovery.ts).
|
|
213
|
+
*
|
|
214
|
+
* We can only tell them apart when the caller observed the dep optimizer's
|
|
215
|
+
* `browserHash` change across the discovery attempt (`reoptimizeObserved`): a
|
|
216
|
+
* reload-causing re-optimization landed mid-flight, so the empty read was
|
|
217
|
+
* transient and the app self-heals per-request (handler.ts builds the trie from
|
|
218
|
+
* the router's live urlpatterns) with discovery re-running on the next boot.
|
|
219
|
+
*
|
|
220
|
+
* A DiscoveryError that DOES carry caught host-handler failures already embeds
|
|
221
|
+
* the real cause in its message, and any non-DiscoveryError is a hard failure;
|
|
222
|
+
* both stay loud with full detail.
|
|
223
|
+
*/
|
|
224
|
+
export function describeDiscoveryFailure(
|
|
225
|
+
err: unknown,
|
|
226
|
+
opts: { reoptimizeObserved?: boolean } = {},
|
|
227
|
+
): DiscoveryFailureReport {
|
|
228
|
+
if (err instanceof DiscoveryError && err.caught.length === 0) {
|
|
229
|
+
const entry = err.entryPath ?? "the router entry";
|
|
230
|
+
if (opts.reoptimizeObserved) {
|
|
231
|
+
return {
|
|
232
|
+
level: "warn",
|
|
233
|
+
message:
|
|
234
|
+
`[rango] No routers found while Vite was re-optimizing dependencies on ` +
|
|
235
|
+
`dev boot. This is transient: routes are served per-request and ` +
|
|
236
|
+
`discovery re-runs automatically, so it clears on the next boot. If ` +
|
|
237
|
+
`routes still 404, confirm ${entry} calls createRouter().`,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
level: "error",
|
|
242
|
+
message:
|
|
243
|
+
`${err.message}\n` +
|
|
244
|
+
` Ensure ${entry} calls createRouter() at module top level and that the ` +
|
|
245
|
+
`configured router entry path is correct.`,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const e = err as { stack?: string; message?: string };
|
|
250
|
+
const detail = e?.stack ?? e?.message ?? String(err);
|
|
251
|
+
return {
|
|
252
|
+
level: "error",
|
|
253
|
+
message: `[rango] Router discovery failed: ${detail}`,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
@@ -38,9 +38,14 @@ initializeApp().catch(console.error);
|
|
|
38
38
|
|
|
39
39
|
export const VIRTUAL_ENTRY_SSR: string = `
|
|
40
40
|
import { createFromReadableStream } from "@rangojs/router/internal/deps/ssr";
|
|
41
|
-
import { renderToReadableStream } from "react-dom/server.edge";
|
|
41
|
+
import { renderToReadableStream, resume } from "react-dom/server.edge";
|
|
42
|
+
import { prerender } from "react-dom/static.edge";
|
|
42
43
|
import { injectRSCPayload } from "@rangojs/router/internal/deps/html-stream-server";
|
|
43
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
createSSRHandler,
|
|
46
|
+
createShellCaptureHandler,
|
|
47
|
+
createShellResumeHandler,
|
|
48
|
+
} from "@rangojs/router/ssr";
|
|
44
49
|
|
|
45
50
|
export const renderHTML = createSSRHandler({
|
|
46
51
|
createFromReadableStream,
|
|
@@ -49,6 +54,26 @@ export const renderHTML = createSSRHandler({
|
|
|
49
54
|
loadBootstrapScriptContent: () =>
|
|
50
55
|
import.meta.viteRsc.loadBootstrapScriptContent("index"),
|
|
51
56
|
});
|
|
57
|
+
|
|
58
|
+
export const captureShellHTML = createShellCaptureHandler({
|
|
59
|
+
createFromReadableStream,
|
|
60
|
+
renderToReadableStream,
|
|
61
|
+
injectRSCPayload,
|
|
62
|
+
prerender,
|
|
63
|
+
resume,
|
|
64
|
+
loadBootstrapScriptContent: () =>
|
|
65
|
+
import.meta.viteRsc.loadBootstrapScriptContent("index"),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export const resumeShellHTML = createShellResumeHandler({
|
|
69
|
+
createFromReadableStream,
|
|
70
|
+
renderToReadableStream,
|
|
71
|
+
injectRSCPayload,
|
|
72
|
+
prerender,
|
|
73
|
+
resume,
|
|
74
|
+
loadBootstrapScriptContent: () =>
|
|
75
|
+
import.meta.viteRsc.loadBootstrapScriptContent("index"),
|
|
76
|
+
});
|
|
52
77
|
`.trim();
|
|
53
78
|
|
|
54
79
|
/**
|
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
peekSelfGenWrite,
|
|
44
44
|
} from "./discovery/self-gen-tracking.js";
|
|
45
45
|
import { discoverRouters } from "./discovery/discover-routers.js";
|
|
46
|
+
import { describeDiscoveryFailure } from "./discovery/discovery-errors.js";
|
|
46
47
|
import {
|
|
47
48
|
writeCombinedRouteTypesWithTracking,
|
|
48
49
|
writeRouteTypesFiles,
|
|
@@ -653,6 +654,30 @@ export function createRouterDiscoveryPlugin(
|
|
|
653
654
|
return tempRscEnv;
|
|
654
655
|
}
|
|
655
656
|
|
|
657
|
+
// Surface a discovery failure on either dev path (Node RSC runner or the
|
|
658
|
+
// Cloudflare temp Node server). `hashBefore`/`hashAfter` are the discovering
|
|
659
|
+
// environment's dep-optimizer browserHash snapshots: a change across the
|
|
660
|
+
// attempt means a reload-causing re-optimization landed mid-flight, so an
|
|
661
|
+
// empty registry was the transient race (downgraded to a warning) rather
|
|
662
|
+
// than a genuine misconfig (loud, actionable error). Shared so both catch
|
|
663
|
+
// sites frame the same failure identically.
|
|
664
|
+
const emitDiscoveryFailure = (
|
|
665
|
+
err: unknown,
|
|
666
|
+
hashBefore: string | undefined,
|
|
667
|
+
hashAfter: string | undefined,
|
|
668
|
+
): void => {
|
|
669
|
+
const reoptimizeObserved =
|
|
670
|
+
hashBefore !== undefined &&
|
|
671
|
+
hashAfter !== undefined &&
|
|
672
|
+
hashBefore !== hashAfter;
|
|
673
|
+
const report = describeDiscoveryFailure(err, { reoptimizeObserved });
|
|
674
|
+
if (report.level === "warn") {
|
|
675
|
+
console.warn(report.message);
|
|
676
|
+
} else {
|
|
677
|
+
console.error(report.message);
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
|
|
656
681
|
const discover = async () => {
|
|
657
682
|
const discoverStart = performance.now();
|
|
658
683
|
const rscEnv = (server.environments as any)?.rsc;
|
|
@@ -668,18 +693,26 @@ export function createRouterDiscoveryPlugin(
|
|
|
668
693
|
|
|
669
694
|
// Create a temp Node.js server to run runtime discovery and generate
|
|
670
695
|
// named route types (static parser can't resolve factory calls).
|
|
696
|
+
// The temp server is a separate Vite instance with its own dep
|
|
697
|
+
// optimizer; snapshot ITS browserHash (hoisted so the catch can tell a
|
|
698
|
+
// transient re-optimization apart from a genuine empty registry, the
|
|
699
|
+
// same way the Node path below does).
|
|
700
|
+
let tempRscEnv: any;
|
|
701
|
+
let optimizerHashBefore: string | undefined;
|
|
671
702
|
try {
|
|
672
703
|
// Acquire build-time env bindings for dev prerender
|
|
673
704
|
await timed(debugDiscovery, "acquireBuildEnv", () =>
|
|
674
705
|
acquireBuildEnv(s, viteCommand, viteMode),
|
|
675
706
|
);
|
|
676
707
|
|
|
677
|
-
|
|
708
|
+
tempRscEnv = await timed(
|
|
678
709
|
debugDiscovery,
|
|
679
710
|
"getOrCreateTempServer",
|
|
680
711
|
() => getOrCreateTempServer(),
|
|
681
712
|
);
|
|
682
713
|
if (tempRscEnv) {
|
|
714
|
+
optimizerHashBefore =
|
|
715
|
+
tempRscEnv.depsOptimizer?.metadata?.browserHash;
|
|
683
716
|
await timed(debugDiscovery, "discoverRouters (cloudflare)", () =>
|
|
684
717
|
discoverRouters(s, tempRscEnv),
|
|
685
718
|
);
|
|
@@ -688,8 +721,10 @@ export function createRouterDiscoveryPlugin(
|
|
|
688
721
|
);
|
|
689
722
|
}
|
|
690
723
|
} catch (err: any) {
|
|
691
|
-
|
|
692
|
-
|
|
724
|
+
emitDiscoveryFailure(
|
|
725
|
+
err,
|
|
726
|
+
optimizerHashBefore,
|
|
727
|
+
tempRscEnv?.depsOptimizer?.metadata?.browserHash,
|
|
693
728
|
);
|
|
694
729
|
}
|
|
695
730
|
|
|
@@ -701,6 +736,13 @@ export function createRouterDiscoveryPlugin(
|
|
|
701
736
|
return;
|
|
702
737
|
}
|
|
703
738
|
|
|
739
|
+
// Snapshot the dep-optimizer hash before discovery so the catch can tell
|
|
740
|
+
// a transient re-optimization race apart from a genuine empty registry.
|
|
741
|
+
// A reload-causing re-optimization regenerates browserHash; if it changed
|
|
742
|
+
// across the attempt, an empty read was almost certainly the race below.
|
|
743
|
+
const optimizerHashBefore: string | undefined =
|
|
744
|
+
rscEnv.depsOptimizer?.metadata?.browserHash;
|
|
745
|
+
|
|
704
746
|
try {
|
|
705
747
|
// Acquire build-time env bindings for dev prerender (Node.js path)
|
|
706
748
|
debugDiscovery?.("dev: node path start");
|
|
@@ -708,21 +750,31 @@ export function createRouterDiscoveryPlugin(
|
|
|
708
750
|
acquireBuildEnv(s, viteCommand, viteMode),
|
|
709
751
|
);
|
|
710
752
|
|
|
711
|
-
//
|
|
712
|
-
//
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
753
|
+
// Discover routers FIRST, then arm the manifest-readiness gate on the
|
|
754
|
+
// server module discovery actually read the registry from.
|
|
755
|
+
//
|
|
756
|
+
// We deliberately do NOT pre-import "@rangojs/router/server" before the
|
|
757
|
+
// entry to arm the gate early. During a Vite dependency re-optimization
|
|
758
|
+
// (dev boot after a lockfile change, or `vite dev --force`), a module
|
|
759
|
+
// imported here before the entry resolves to the pre-optimize copy of
|
|
760
|
+
// the runner's module graph, while discoverRouters' entry import — which
|
|
761
|
+
// awaits the in-flight re-optimization — resolves to the post-optimize
|
|
762
|
+
// copy. createRouter() then populates RouterRegistry on the fresh copy,
|
|
763
|
+
// but a stale pre-imported "@rangojs/router/server" reads the other
|
|
764
|
+
// copy's empty Map and discovery throws a spurious "No routers found"
|
|
765
|
+
// even though the app is configured correctly. discoverRouters imports
|
|
766
|
+
// the entry first and reads the registry off the same instance, keeping
|
|
767
|
+
// read and write on one copy. The virtual manifest module's own gate
|
|
768
|
+
// (s.discoveryDone, armed by beginDiscoveryGate) already blocks early
|
|
769
|
+
// requests during discovery on the Node path, so arming
|
|
770
|
+
// manifestReadyPromise after discovery is sufficient here.
|
|
771
|
+
const serverMod = await timed(debugDiscovery, "discoverRouters", () =>
|
|
772
|
+
discoverRouters(s, rscEnv),
|
|
717
773
|
);
|
|
718
774
|
if (serverMod?.setManifestReadyPromise) {
|
|
719
775
|
serverMod.setManifestReadyPromise(discoveryPromise);
|
|
720
776
|
}
|
|
721
777
|
|
|
722
|
-
await timed(debugDiscovery, "discoverRouters", () =>
|
|
723
|
-
discoverRouters(s, rscEnv),
|
|
724
|
-
);
|
|
725
|
-
|
|
726
778
|
// Store server origin for dev prerender endpoint (virtual module injection)
|
|
727
779
|
s.devServerOrigin = getDevServerOrigin();
|
|
728
780
|
|
|
@@ -740,8 +792,10 @@ export function createRouterDiscoveryPlugin(
|
|
|
740
792
|
propagateDiscoveryState(rscEnv),
|
|
741
793
|
);
|
|
742
794
|
} catch (err: any) {
|
|
743
|
-
|
|
744
|
-
|
|
795
|
+
emitDiscoveryFailure(
|
|
796
|
+
err,
|
|
797
|
+
optimizerHashBefore,
|
|
798
|
+
rscEnv.depsOptimizer?.metadata?.browserHash,
|
|
745
799
|
);
|
|
746
800
|
} finally {
|
|
747
801
|
debugDiscovery?.(
|