@rangojs/router 0.0.0-experimental.63 → 0.0.0-experimental.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,9 @@
1
- import { loadManifest } from "./manifest.js";
2
- import { traverseBack } from "./pattern-matching.js";
3
- import { collectRouteMiddleware } from "./middleware.js";
4
- import {
5
- parseAcceptTypes,
6
- RSC_RESPONSE_TYPE,
7
- pickNegotiateVariant,
8
- } from "./content-negotiation.js";
1
+ import { negotiateRoute } from "./content-negotiation.js";
9
2
  import { runWithRouterLogContext, withRouterLogScope } from "./logging.js";
10
3
  import type { EntryData } from "../server/context";
11
4
  import type { RouteMatchResult } from "./pattern-matching.js";
12
5
  import type { MiddlewareFn } from "./middleware.js";
6
+ import { resolveRoute } from "./route-snapshot.js";
13
7
 
14
8
  export interface PreviewMatchDeps<TEnv = any> {
15
9
  findMatch: (pathname: string) => RouteMatchResult<TEnv> | null;
@@ -42,110 +36,44 @@ export async function previewMatch<TEnv = any>(
42
36
  const url = new URL(request.url);
43
37
  const pathname = url.pathname;
44
38
 
45
- // Quick route matching
46
- const matched = deps.findMatch(pathname);
47
- if (!matched) {
39
+ // Route resolution via snapshot (lite mode: skip entries/cacheScope
40
+ // since previewMatch only needs matched, manifestEntry, routeMiddleware,
41
+ // and responseType)
42
+ const result = await resolveRoute<TEnv>(pathname, {
43
+ findMatch: deps.findMatch,
44
+ lite: true,
45
+ });
46
+
47
+ if (!result) {
48
48
  return null;
49
49
  }
50
50
 
51
51
  // Skip redirect check - will be handled in full match
52
- if (matched.redirectTo) {
52
+ if (result.type === "redirect") {
53
53
  return { routeMiddleware: undefined };
54
54
  }
55
55
 
56
- // Load manifest (without segment resolution)
57
- const manifestEntry = await loadManifest(
58
- matched.entry,
59
- matched.routeKey,
60
- pathname,
61
- undefined, // No metrics store for preview
62
- false, // isSSR - doesn't matter for preview
63
- );
64
-
65
- // Collect route-level middleware from entry tree
66
- // Includes middleware from orphan layouts (inline layouts within routes)
67
- const routeMiddleware = collectRouteMiddleware(
68
- traverseBack(manifestEntry),
69
- matched.params,
70
- );
71
-
72
- // Check for response type (from trie match or manifest entry)
73
- const responseType =
74
- matched.responseType ||
75
- (manifestEntry.type === "route"
76
- ? manifestEntry.responseType
77
- : undefined);
78
-
79
- // Content negotiation: when negotiate variants exist, pick the best
80
- // handler based on the Accept header. Uses q-values and client order
81
- // as tiebreaker (matching Express/Hono behavior). RSC routes participate
82
- // as text/html candidates so browsers naturally get HTML without
83
- // special-casing.
84
- if (matched.negotiateVariants && matched.negotiateVariants.length > 0) {
85
- const acceptEntries = parseAcceptTypes(
86
- request.headers.get("accept") || "",
87
- );
88
-
89
- // Build candidate list preserving definition order.
90
- // For wildcard (*/*) and no-Accept fallback, the first candidate wins.
91
- const variants = matched.negotiateVariants;
92
- let candidates: Array<{ routeKey: string; responseType: string }>;
93
- if (responseType) {
94
- // Primary is response-type — include it as a candidate
95
- candidates = [
96
- ...variants,
97
- { routeKey: matched.routeKey, responseType },
98
- ];
99
- } else {
100
- // Primary is RSC — insert as text/html candidate in definition order
101
- const rscCandidate = {
102
- routeKey: matched.routeKey,
103
- responseType: RSC_RESPONSE_TYPE,
104
- };
105
- candidates = matched.rscFirst
106
- ? [rscCandidate, ...variants]
107
- : [...variants, rscCandidate];
108
- }
109
-
110
- const variant = pickNegotiateVariant(acceptEntries, candidates);
56
+ const snapshot = result.snapshot;
57
+ const { matched, manifestEntry, routeMiddleware, responseType } =
58
+ snapshot;
111
59
 
112
- // If the winner is RSC, fall through to default RSC handling
113
- if (variant.responseType === RSC_RESPONSE_TYPE) {
114
- // Fall through — RSC won negotiation
115
- } else if (responseType && variant.routeKey === matched.routeKey) {
116
- // Fall through — response-type primary won, already set
117
- } else {
118
- const negotiateEntry = await loadManifest(
119
- matched.entry,
120
- variant.routeKey,
121
- pathname,
122
- undefined,
123
- false,
124
- );
125
- // Recompute middleware from the selected variant's entry tree
126
- // since different variants can have different middleware chains.
127
- const variantMiddleware = collectRouteMiddleware(
128
- traverseBack(negotiateEntry),
129
- matched.params,
130
- );
131
- return {
132
- routeMiddleware:
133
- variantMiddleware.length > 0 ? variantMiddleware : undefined,
134
- responseType: variant.responseType,
135
- handler:
136
- negotiateEntry.type === "route"
137
- ? negotiateEntry.handler
138
- : undefined,
139
- params: matched.params,
140
- negotiated: true,
141
- manifestEntry: negotiateEntry,
142
- routeKey: matched.routeKey,
143
- };
144
- }
60
+ const negotiation = await negotiateRoute(request, pathname, snapshot);
61
+ if (negotiation) {
62
+ return {
63
+ routeMiddleware:
64
+ negotiation.routeMiddleware.length > 0
65
+ ? negotiation.routeMiddleware
66
+ : undefined,
67
+ responseType: negotiation.responseType,
68
+ handler: negotiation.handler,
69
+ params: matched.params,
70
+ negotiated: true,
71
+ manifestEntry: negotiation.manifestEntry,
72
+ routeKey: matched.routeKey,
73
+ };
145
74
  }
146
75
 
147
- // If we passed through the negotiation block (variants exist), mark as
148
- // negotiated so the handler sets Vary: Accept on the response.
76
+ // No negotiation or RSC won return default route info
149
77
  const hasVariants =
150
78
  matched.negotiateVariants && matched.negotiateVariants.length > 0;
151
79
  return {
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Request Classification
3
+ *
4
+ * Replaces the implicit "preview then match again" model with a clean
5
+ * two-stage architecture:
6
+ *
7
+ * 1. Classification — classifyRequest() produces a RequestPlan that answers
8
+ * all routing questions once: target route, request mode, route middleware,
9
+ * response-route info, negotiation state.
10
+ *
11
+ * 2. Execution — executeRequest() dispatches on the plan to the appropriate
12
+ * handler (response route, loader fetch, full render, partial render,
13
+ * action revalidation, PE render).
14
+ *
15
+ * Builds on RouteSnapshot from route-snapshot.ts.
16
+ */
17
+
18
+ import { RouteNotFoundError } from "../errors.js";
19
+ import type { EntryData } from "../server/context.js";
20
+ import type { CollectedMiddleware } from "./middleware-types.js";
21
+ import type { RouteMatchResult } from "./pattern-matching.js";
22
+ import { negotiateRoute } from "./content-negotiation.js";
23
+ import { stripInternalParams } from "./handler-context.js";
24
+ import { resolveRoute, type RouteSnapshot } from "./route-snapshot.js";
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // RequestPlan — discriminated union
28
+ // ---------------------------------------------------------------------------
29
+
30
+ interface RedirectPlan<TEnv = any> {
31
+ mode: "redirect";
32
+ route: RouteSnapshot<TEnv>;
33
+ redirectUrl: string;
34
+ }
35
+
36
+ interface VersionMismatchPlan<TEnv = any> {
37
+ mode: "version-mismatch";
38
+ /** May be undefined when version mismatch is detected before route resolution */
39
+ route?: RouteSnapshot<TEnv>;
40
+ reloadUrl: string;
41
+ }
42
+
43
+ interface ResponseRoutePlan<TEnv = any> {
44
+ mode: "response";
45
+ route: RouteSnapshot<TEnv>;
46
+ handler: Function;
47
+ responseType: string;
48
+ negotiated: boolean;
49
+ manifestEntry: EntryData;
50
+ routeMiddleware: CollectedMiddleware[];
51
+ }
52
+
53
+ interface LoaderFetchPlan<TEnv = any> {
54
+ mode: "loader";
55
+ route: RouteSnapshot<TEnv>;
56
+ }
57
+
58
+ interface PeRenderPlan<TEnv = any> {
59
+ mode: "pe-render";
60
+ route: RouteSnapshot<TEnv>;
61
+ }
62
+
63
+ interface ActionPlan<TEnv = any> {
64
+ mode: "action";
65
+ route: RouteSnapshot<TEnv>;
66
+ actionId: string;
67
+ negotiated: boolean;
68
+ }
69
+
70
+ interface FullRenderPlan<TEnv = any> {
71
+ mode: "full-render";
72
+ route: RouteSnapshot<TEnv>;
73
+ negotiated: boolean;
74
+ }
75
+
76
+ interface PartialRenderPlan<TEnv = any> {
77
+ mode: "partial-render";
78
+ route: RouteSnapshot<TEnv>;
79
+ negotiated: boolean;
80
+ }
81
+
82
+ /**
83
+ * The output of request classification. A discriminated union where each
84
+ * variant carries exactly the fields needed for its execution path.
85
+ */
86
+ export type RequestPlan<TEnv = any> =
87
+ | RedirectPlan<TEnv>
88
+ | VersionMismatchPlan<TEnv>
89
+ | ResponseRoutePlan<TEnv>
90
+ | LoaderFetchPlan<TEnv>
91
+ | PeRenderPlan<TEnv>
92
+ | ActionPlan<TEnv>
93
+ | FullRenderPlan<TEnv>
94
+ | PartialRenderPlan<TEnv>;
95
+
96
+ /**
97
+ * Plans that have passed the terminal-check gate (version-mismatch handled)
98
+ * and are ready for execution. Always have a `route` field.
99
+ */
100
+ export type ExecutableRequestPlan<TEnv = any> = Exclude<
101
+ RequestPlan<TEnv>,
102
+ VersionMismatchPlan<TEnv>
103
+ >;
104
+
105
+ /**
106
+ * Re-export individual plan types for consumers that need to narrow.
107
+ */
108
+ export type {
109
+ RedirectPlan,
110
+ VersionMismatchPlan,
111
+ ResponseRoutePlan,
112
+ LoaderFetchPlan,
113
+ PeRenderPlan,
114
+ ActionPlan,
115
+ FullRenderPlan,
116
+ PartialRenderPlan,
117
+ };
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // classifyRequest — the single authoritative classification step
121
+ // ---------------------------------------------------------------------------
122
+
123
+ export interface ClassifyRequestDeps<TEnv = any> {
124
+ findMatch: (pathname: string) => RouteMatchResult<TEnv> | null;
125
+ routerVersion: string;
126
+ routerId: string;
127
+ }
128
+
129
+ /**
130
+ * Classify an incoming request into a RequestPlan.
131
+ *
132
+ * This is the single source of truth for request mode detection. It replaces
133
+ * the scattered previewMatch + isAction/isLoaderFetch/isPartial checks in
134
+ * handler.ts.
135
+ *
136
+ * Classification order:
137
+ * 1. Route resolution (findMatch + loadManifest via resolveRoute lite)
138
+ * 2. Redirect detection
139
+ * 3. Version mismatch
140
+ * 4. Response route + content negotiation
141
+ * 5. Mode detection from headers/params
142
+ */
143
+ export async function classifyRequest<TEnv = any>(
144
+ request: Request,
145
+ url: URL,
146
+ deps: ClassifyRequestDeps<TEnv>,
147
+ ): Promise<RequestPlan<TEnv>> {
148
+ const pathname = url.pathname;
149
+ const isAction =
150
+ request.headers.has("rsc-action") || url.searchParams.has("_rsc_action");
151
+
152
+ // Version mismatch — runs BEFORE route resolution so stale clients
153
+ // requesting removed routes get a reload, not a 404.
154
+ const clientVersion = url.searchParams.get("_rsc_v");
155
+ if (
156
+ deps.routerVersion &&
157
+ clientVersion &&
158
+ clientVersion !== deps.routerVersion
159
+ ) {
160
+ // Strip internal _rsc_* params so the browser reloads to a clean URL
161
+ let reloadUrl = stripInternalParams(url).toString();
162
+ if (isAction) {
163
+ const referer = request.headers.get("referer");
164
+ if (referer) {
165
+ try {
166
+ const refererUrl = new URL(referer);
167
+ if (refererUrl.origin === url.origin) {
168
+ reloadUrl = referer;
169
+ }
170
+ } catch {
171
+ // Malformed referer, fall back to stripped url
172
+ }
173
+ }
174
+ }
175
+
176
+ return {
177
+ mode: "version-mismatch",
178
+ reloadUrl,
179
+ };
180
+ }
181
+
182
+ // No metricsStore — classification is a lightweight gating step.
183
+ // Route-matching and manifest-loading metrics belong in the match path
184
+ // (createMatchContextForFull/Partial) which runs the authoritative resolution.
185
+ const result = await resolveRoute<TEnv>(pathname, {
186
+ findMatch: deps.findMatch,
187
+ lite: true,
188
+ });
189
+
190
+ if (!result) {
191
+ throw new RouteNotFoundError(`No route matched for ${pathname}`, {
192
+ cause: { pathname, method: request.method },
193
+ });
194
+ }
195
+
196
+ // Redirect
197
+ if (result.type === "redirect") {
198
+ const snapshot: RouteSnapshot<TEnv> = {
199
+ matched: result as any,
200
+ manifestEntry: null as any,
201
+ entries: [],
202
+ routeKey: "",
203
+ localRouteName: "",
204
+ params: {},
205
+ routeMiddleware: [],
206
+ cacheScope: null,
207
+ isPassthrough: false,
208
+ };
209
+ return {
210
+ mode: "redirect",
211
+ route: snapshot,
212
+ redirectUrl: result.redirectTo + url.search,
213
+ };
214
+ }
215
+
216
+ const snapshot = result.snapshot;
217
+
218
+ // Response route — non-RSC short-circuit (JSON, streaming, etc.)
219
+ const responseResult = await classifyResponseRoute(
220
+ request,
221
+ pathname,
222
+ snapshot,
223
+ );
224
+ if (responseResult) {
225
+ return responseResult;
226
+ }
227
+
228
+ // Mode detection from request signals
229
+ const actionId =
230
+ request.headers.get("rsc-action") || url.searchParams.get("_rsc_action");
231
+ const isLoaderFetch = url.searchParams.has("_rsc_loader");
232
+
233
+ const hasVariants =
234
+ snapshot.matched.negotiateVariants &&
235
+ snapshot.matched.negotiateVariants.length > 0;
236
+ const negotiated = !!hasVariants;
237
+
238
+ if (isAction && actionId) {
239
+ return { mode: "action", route: snapshot, actionId, negotiated };
240
+ }
241
+
242
+ if (isLoaderFetch) {
243
+ return { mode: "loader", route: snapshot };
244
+ }
245
+
246
+ // PE detection: POST with form content-type, but not a server action
247
+ const contentType = request.headers.get("content-type") || "";
248
+ const isFormSubmission =
249
+ contentType.includes("multipart/form-data") ||
250
+ contentType.includes("application/x-www-form-urlencoded");
251
+ if (request.method === "POST" && !isAction && isFormSubmission) {
252
+ return { mode: "pe-render", route: snapshot };
253
+ }
254
+
255
+ // App switch: client's routerId doesn't match this router
256
+ const clientRouterId = url.searchParams.get("_rsc_rid");
257
+ const isAppSwitch = !!(clientRouterId && clientRouterId !== deps.routerId);
258
+ const isPartial = url.searchParams.has("_rsc_partial") && !isAppSwitch;
259
+
260
+ if (isPartial) {
261
+ return { mode: "partial-render", route: snapshot, negotiated };
262
+ }
263
+
264
+ return { mode: "full-render", route: snapshot, negotiated };
265
+ }
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // Content negotiation for response routes
269
+ // ---------------------------------------------------------------------------
270
+
271
+ /**
272
+ * Check if the route is a response route and perform content negotiation
273
+ * if negotiate variants exist. Returns a ResponseRoutePlan if the route
274
+ * is a response route, null otherwise (RSC route).
275
+ */
276
+ async function classifyResponseRoute<TEnv>(
277
+ request: Request,
278
+ pathname: string,
279
+ snapshot: RouteSnapshot<TEnv>,
280
+ ): Promise<ResponseRoutePlan<TEnv> | null> {
281
+ const { manifestEntry, responseType } = snapshot;
282
+
283
+ const negotiation = await negotiateRoute(request, pathname, snapshot);
284
+ if (negotiation) {
285
+ return {
286
+ mode: "response",
287
+ route: snapshot,
288
+ ...negotiation,
289
+ };
290
+ }
291
+
292
+ // Non-negotiated response route (no variants, or RSC won negotiation)
293
+ if (responseType) {
294
+ const handler =
295
+ manifestEntry.type === "route" ? manifestEntry.handler : undefined;
296
+ if (handler) {
297
+ return {
298
+ mode: "response",
299
+ route: snapshot,
300
+ handler,
301
+ responseType,
302
+ negotiated: false,
303
+ manifestEntry,
304
+ routeMiddleware: snapshot.routeMiddleware,
305
+ };
306
+ }
307
+ }
308
+
309
+ return null;
310
+ }