@rangojs/router 0.0.0-experimental.74 → 0.0.0-experimental.75

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.
@@ -1864,7 +1864,7 @@ import { resolve } from "node:path";
1864
1864
  // package.json
1865
1865
  var package_default = {
1866
1866
  name: "@rangojs/router",
1867
- version: "0.0.0-experimental.74",
1867
+ version: "0.0.0-experimental.75",
1868
1868
  description: "Django-inspired RSC router with composable URL patterns",
1869
1869
  keywords: [
1870
1870
  "react",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.74",
3
+ "version": "0.0.0-experimental.75",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -98,8 +98,14 @@ export function buildRouteTrie(
98
98
  }
99
99
 
100
100
  /**
101
- * Insert a route into the trie, handling optional params by forking
102
- * the insertion path (one terminal without the param, one with).
101
+ * Insert a route into the trie. Optional params expand into two branches at
102
+ * registration time (skip-first, then present), so each terminal lives at the
103
+ * correct depth for its number of bound params and carries a branch-local
104
+ * `pa` listing only those names. The trie's single-slot `node.p` is reused
105
+ * across branches because matching ignores `node.p.n` — the leaf's `pa` is
106
+ * the source of truth for naming. Skip-first ordering lets `mergeLeaf`'s
107
+ * last-wins rule produce greedy-leftmost semantics for free at any shared
108
+ * terminal depth.
103
109
  */
104
110
  function insertRoute(
105
111
  node: TrieNode,
@@ -107,14 +113,13 @@ function insertRoute(
107
113
  index: number,
108
114
  leaf: Omit<TrieLeaf, "op" | "cv" | "pa">,
109
115
  ): void {
110
- // Collect param names, optional param names, and constraints across all segments
111
- const paramNames: string[] = [];
116
+ // op (full optional list) and cv (full constraint map) are route-level and
117
+ // identical on every terminal, so compute them once on the shared base.
112
118
  const optionalParams: string[] = [];
113
119
  const constraints: Record<string, string[]> = {};
114
120
 
115
121
  for (const seg of segments) {
116
122
  if (seg.type === "param") {
117
- paramNames.push(seg.value);
118
123
  if (seg.optional) {
119
124
  optionalParams.push(seg.value);
120
125
  }
@@ -124,21 +129,15 @@ function insertRoute(
124
129
  }
125
130
  }
126
131
 
127
- const fullLeaf: TrieLeaf = {
132
+ const leafBase: Omit<TrieLeaf, "pa"> = {
128
133
  ...leaf,
129
- ...(paramNames.length > 0 ? { pa: paramNames } : {}),
130
134
  ...(optionalParams.length > 0 ? { op: optionalParams } : {}),
131
135
  ...(Object.keys(constraints).length > 0 ? { cv: constraints } : {}),
132
136
  };
133
137
 
134
- insertSegments(node, segments, index, fullLeaf);
138
+ insertSegments(node, segments, index, leafBase, []);
135
139
  }
136
140
 
137
- /**
138
- * Recursively insert segments into the trie.
139
- * For optional params, we add a terminal at the current node (param absent)
140
- * AND continue inserting into the param child (param present).
141
- */
142
141
  /**
143
142
  * Extract ancestry map from a built trie by visiting all leaf nodes.
144
143
  * Returns { routeName: ancestryShortCodes[] } for every route in the trie.
@@ -218,15 +217,25 @@ function mergeLeaf(node: TrieNode, leaf: TrieLeaf): void {
218
217
  node.r = mergeLeaves(node.r, leaf);
219
218
  }
220
219
 
220
+ function buildLeaf(
221
+ leafBase: Omit<TrieLeaf, "pa">,
222
+ paramNames: string[],
223
+ ): TrieLeaf {
224
+ return paramNames.length > 0
225
+ ? { ...leafBase, pa: [...paramNames] }
226
+ : { ...leafBase };
227
+ }
228
+
221
229
  function insertSegments(
222
230
  node: TrieNode,
223
231
  segments: ParsedSegment[],
224
232
  index: number,
225
- leaf: TrieLeaf,
233
+ leafBase: Omit<TrieLeaf, "pa">,
234
+ paramNames: string[],
226
235
  ): void {
227
- // Base case: all segments consumed, add terminal
236
+ // Base case: all segments consumed, add terminal with branch-local pa
228
237
  if (index >= segments.length) {
229
- mergeLeaf(node, leaf);
238
+ mergeLeaf(node, buildLeaf(leafBase, paramNames));
230
239
  return;
231
240
  }
232
241
 
@@ -235,12 +244,19 @@ function insertSegments(
235
244
  if (segment.type === "static") {
236
245
  if (!node.s) node.s = {};
237
246
  if (!node.s[segment.value]) node.s[segment.value] = {};
238
- insertSegments(node.s[segment.value], segments, index + 1, leaf);
247
+ insertSegments(
248
+ node.s[segment.value],
249
+ segments,
250
+ index + 1,
251
+ leafBase,
252
+ paramNames,
253
+ );
239
254
  } else if (segment.type === "param") {
240
255
  if (segment.optional) {
241
- // Optional param: add terminal at current node (param absent)
242
- mergeLeaf(node, leaf);
243
- // AND continue with param child (param present)
256
+ // SKIP first: continue at the same node without binding this name.
257
+ // Skip-first ordering means the present-branch's TAKE overwrites any
258
+ // shared terminal later, giving greedy-leftmost semantics.
259
+ insertSegments(node, segments, index + 1, leafBase, paramNames);
244
260
  }
245
261
  if (segment.suffix) {
246
262
  // Suffix param: keyed by suffix string (e.g., ".html")
@@ -248,16 +264,26 @@ function insertSegments(
248
264
  if (!node.xp[segment.suffix]) {
249
265
  node.xp[segment.suffix] = { n: segment.value, c: {} };
250
266
  }
251
- insertSegments(node.xp[segment.suffix].c, segments, index + 1, leaf);
267
+ insertSegments(node.xp[segment.suffix].c, segments, index + 1, leafBase, [
268
+ ...paramNames,
269
+ segment.value,
270
+ ]);
252
271
  } else {
253
272
  if (!node.p) {
254
273
  node.p = { n: segment.value, c: {} };
255
274
  }
256
- insertSegments(node.p.c, segments, index + 1, leaf);
275
+ insertSegments(node.p.c, segments, index + 1, leafBase, [
276
+ ...paramNames,
277
+ segment.value,
278
+ ]);
257
279
  }
258
280
  } else if (segment.type === "wildcard") {
259
- // Wildcard consumes all remaining segments
260
- const wildLeaf = { ...leaf, pn: "*" };
281
+ // Wildcard consumes all remaining segments. Carry any params bound before
282
+ // the wildcard in pa so they zip correctly against paramValues at match.
283
+ const wildLeaf: TrieLeaf & { pn: string } = {
284
+ ...buildLeaf(leafBase, paramNames),
285
+ pn: "*",
286
+ };
261
287
  const existing = node.w ? ({ ...node.w } as TrieLeaf) : undefined;
262
288
  const merged = mergeLeaves(existing, wildLeaf);
263
289
  node.w = merged as TrieLeaf & { pn: string };
package/src/index.ts CHANGED
@@ -147,24 +147,52 @@ export { createVar, type ContextVar } from "./context-var.js";
147
147
  export { nonce } from "./rsc/nonce.js";
148
148
 
149
149
  /**
150
- * Error-throwing stub for server-only `Prerender` function.
150
+ * SSR/client stub for server-only `Prerender` function.
151
+ *
152
+ * Returns a lightweight stub object instead of throwing so that the
153
+ * production SSR build can safely bundle the RSC entry chunk — the SSR
154
+ * bundler resolves `@rangojs/router` to this (SSR) entry, so Prerender
155
+ * calls in RSC code must not crash at module-evaluation time.
151
156
  */
152
- export function Prerender(): never {
153
- throw serverOnlyStubError("Prerender");
157
+ export function Prerender(
158
+ _handler?: any,
159
+ _optionsOrId?: any,
160
+ __injectedId?: string,
161
+ ): any {
162
+ const id =
163
+ typeof _optionsOrId === "string" ? _optionsOrId : __injectedId || "";
164
+ return { __brand: "prerenderHandler" as const, $$id: id };
154
165
  }
155
166
 
156
167
  /**
157
- * Error-throwing stub for server-only `Passthrough` function.
168
+ * SSR/client stub for server-only `Passthrough` function.
158
169
  */
159
- export function Passthrough(): never {
160
- throw serverOnlyStubError("Passthrough");
170
+ export function Passthrough(
171
+ _handler?: any,
172
+ _optionsOrId?: any,
173
+ __injectedId?: string,
174
+ ): any {
175
+ const id =
176
+ typeof _optionsOrId === "string" ? _optionsOrId : __injectedId || "";
177
+ return { __brand: "passthroughHandler" as const, $$id: id };
161
178
  }
162
179
 
163
180
  /**
164
- * Error-throwing stub for server-only `Static` function.
181
+ * SSR/client stub for server-only `Static` function.
182
+ *
183
+ * Returns a lightweight stub object instead of throwing so that the
184
+ * production SSR build can safely bundle the RSC entry chunk — the SSR
185
+ * bundler resolves `@rangojs/router` to this (SSR) entry, so Static
186
+ * calls in RSC code must not crash at module-evaluation time.
165
187
  */
166
- export function Static(): never {
167
- throw serverOnlyStubError("Static");
188
+ export function Static(
189
+ _handler?: any,
190
+ _optionsOrId?: any,
191
+ __injectedId?: string,
192
+ ): any {
193
+ const id =
194
+ typeof _optionsOrId === "string" ? _optionsOrId : __injectedId || "";
195
+ return { __brand: "staticHandler" as const, $$id: id };
168
196
  }
169
197
 
170
198
  /**
@@ -55,6 +55,9 @@ const hasRoutesInItem = (item: AllUseItems): boolean => {
55
55
  if (item.type === "layout" && item.uses) {
56
56
  return item.uses.some((child) => hasRoutesInItem(child));
57
57
  }
58
+ if (item.type === "middleware" && item.uses) {
59
+ return item.uses.some((child) => hasRoutesInItem(child));
60
+ }
58
61
  return false;
59
62
  };
60
63
 
@@ -353,10 +356,37 @@ const cache: RouteHelpers<any, any>["cache"] = (
353
356
  return { name: namespace, type: "cache", uses: result } as CacheItem;
354
357
  };
355
358
 
356
- const middleware: RouteHelpers<any, any>["middleware"] = (...fn) => {
359
+ const middleware: RouteHelpers<any, any>["middleware"] = (...args: any[]) => {
360
+ // Four call forms:
361
+ // middleware(fn) — single fn, sibling
362
+ // middleware(fn, () => [...]) — single fn, wrapping
363
+ // middleware([fn1, fn2]) — array, sibling
364
+ // middleware([fn1, fn2], () => [...]) — array, wrapping
365
+ const isArray = Array.isArray(args[0]);
366
+
367
+ // Reject the removed variadic form before executing anything.
368
+ // middleware(fn1, fn2, fn3) — 3+ args, always wrong.
369
+ // middleware(fn1, fn2) where fn2 is a middleware fn (length >= 1), not a
370
+ // children callback (length === 0) — legacy two-fn form, reject early.
371
+ if (
372
+ args.length > 2 ||
373
+ (!isArray &&
374
+ args.length === 2 &&
375
+ typeof args[1] === "function" &&
376
+ args[1].length > 0)
377
+ ) {
378
+ throw new Error(
379
+ "middleware() no longer accepts variadic arguments. " +
380
+ "Use middleware([fn1, fn2, ...]) instead of middleware(fn1, fn2, ...).",
381
+ );
382
+ }
383
+
384
+ const fns: MiddlewareFn<any>[] = isArray ? args[0] : [args[0]];
385
+ const children: (() => any[]) | undefined =
386
+ typeof args[1] === "function" ? args[1] : undefined;
387
+
357
388
  // Prevent "use cache" functions from being used as middleware.
358
- // Checked before context validation — this is a static invariant.
359
- for (const f of fn) {
389
+ for (const f of fns) {
360
390
  if (isCachedFunction(f)) {
361
391
  throw new Error(
362
392
  `A "use cache" function cannot be used as middleware. ` +
@@ -367,17 +397,80 @@ const middleware: RouteHelpers<any, any>["middleware"] = (...fn) => {
367
397
  }
368
398
  }
369
399
 
370
- const ctx = getContext().getStore();
400
+ const store = getContext();
401
+ const ctx = store.getStore();
371
402
  if (!ctx) throw new Error("middleware() must be called inside map()");
372
403
 
373
- // Attach to last entry in stack
374
- const parent = ctx.parent;
375
- if (!parent || !("middleware" in parent)) {
376
- invariant(false, "No parent entry available for middleware()");
404
+ if (!children) {
405
+ // Sibling mode: attach to parent entry
406
+ const parent = ctx.parent;
407
+ if (!parent || !("middleware" in parent)) {
408
+ invariant(false, "No parent entry available for middleware()");
409
+ }
410
+ const name = `$${store.getNextIndex("middleware")}`;
411
+ parent.middleware.push(...fns);
412
+ return { name, type: "middleware" } as MiddlewareItem;
413
+ }
414
+
415
+ // Wrapping mode: create a transparent layout that carries the middleware
416
+ const mwIndex = store.getNextIndex("middleware");
417
+ const namespace = `${ctx.namespace}.${mwIndex}`;
418
+
419
+ const urlPrefix = getUrlPrefix();
420
+ const entry = {
421
+ id: namespace,
422
+ shortCode: store.getShortCode("layout"),
423
+ type: "layout",
424
+ parent: ctx.parent,
425
+ handler: RootLayout,
426
+ loading: undefined,
427
+ middleware: [...fns],
428
+ revalidate: [],
429
+ errorBoundary: [],
430
+ notFoundBoundary: [],
431
+ layout: [],
432
+ parallel: {},
433
+ intercept: [],
434
+ loader: [],
435
+ ...(urlPrefix ? { mountPath: urlPrefix } : {}),
436
+ } as EntryData;
437
+
438
+ // Run children callback. If the second arg was actually a middleware fn
439
+ // (old variadic form: middleware(mw1, mw2)), this will return a non-array
440
+ // and the invariant below gives a clear migration error.
441
+ const rawResult = store.run(namespace, entry, children);
442
+
443
+ invariant(
444
+ Array.isArray(rawResult),
445
+ "middleware(fn, children) expects the second argument to return an array of use items. " +
446
+ "To pass multiple middleware, use middleware([fn1, fn2]).",
447
+ );
448
+
449
+ const result = rawResult.flat(3);
450
+
451
+ invariant(
452
+ result.every((item: any) => isValidUseItem(item)),
453
+ `middleware() children callback must return an array of use items [${namespace}]`,
454
+ );
455
+
456
+ const hasRoutes =
457
+ result &&
458
+ Array.isArray(result) &&
459
+ result.some((item) => item != null && hasRoutesInItem(item));
460
+
461
+ if (!hasRoutes) {
462
+ const parent = ctx.parent;
463
+ if (parent && "layout" in parent) {
464
+ entry.parent = null;
465
+ parent.layout.push(entry);
466
+ }
377
467
  }
378
- const name = `$${getContext().getNextIndex("middleware")}`;
379
- parent.middleware.push(...fn);
380
- return { name, type: "middleware" } as MiddlewareItem;
468
+
469
+ return {
470
+ name: namespace,
471
+ type: "middleware",
472
+ uses: result,
473
+ } as MiddlewareItem;
381
474
  };
382
475
 
383
476
  const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
@@ -182,21 +182,41 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
182
182
  ): InterceptItem;
183
183
  };
184
184
  /**
185
- * Attach middleware to the current route/layout
185
+ * Attach middleware to the current route/layout, or wrap child segments
186
+ *
187
+ * **Sibling mode** — attaches middleware to the parent entry:
186
188
  * ```typescript
187
- * middleware(async (ctx, next) => {
188
- * const session = await getSession(ctx.request);
189
- * if (!session) return redirect("/login");
190
- * ctx.set("user", session.user);
191
- * next();
192
- * })
189
+ * layout(<DashboardShell />, () => [
190
+ * middleware(authMiddleware),
191
+ * middleware([authMiddleware, loggingMiddleware]),
192
+ * path("/", DashboardPage),
193
+ * ])
194
+ * ```
195
+ *
196
+ * **Wrapping mode** — scopes middleware to the children only:
197
+ * ```typescript
198
+ * middleware(authMiddleware, () => [
199
+ * path("/dashboard", DashboardPage),
200
+ * path("/settings", SettingsPage),
201
+ * ])
193
202
  *
194
- * // Chain multiple middleware
195
- * middleware(authMiddleware, loggingMiddleware, rateLimitMiddleware)
203
+ * middleware([authMiddleware, loggingMiddleware], () => [
204
+ * path("/admin", AdminPage),
205
+ * ])
196
206
  * ```
197
- * @param fns - One or more middleware functions to execute in order
198
207
  */
199
- middleware: (...fns: MiddlewareFn<TEnv>[]) => MiddlewareItem;
208
+ middleware: {
209
+ (fn: MiddlewareFn<TEnv>): MiddlewareItem;
210
+ (
211
+ fn: MiddlewareFn<TEnv>,
212
+ children: () => UseItems<LayoutUseItem>,
213
+ ): MiddlewareItem;
214
+ (fns: MiddlewareFn<TEnv>[]): MiddlewareItem;
215
+ (
216
+ fns: MiddlewareFn<TEnv>[],
217
+ children: () => UseItems<LayoutUseItem>,
218
+ ): MiddlewareItem;
219
+ };
200
220
  /**
201
221
  * Control when a segment should revalidate during navigation
202
222
  * ```typescript
@@ -264,9 +264,20 @@ export type PathHelpers<TEnv> = {
264
264
  ) => InterceptItem;
265
265
 
266
266
  /**
267
- * Attach middleware to the current route/layout
267
+ * Attach middleware to the current route/layout, or wrap child segments
268
268
  */
269
- middleware: (...fns: MiddlewareFn<TEnv>[]) => MiddlewareItem;
269
+ middleware: {
270
+ (fn: MiddlewareFn<TEnv>): MiddlewareItem;
271
+ (
272
+ fn: MiddlewareFn<TEnv>,
273
+ children: () => UseItems<LayoutUseItem>,
274
+ ): MiddlewareItem;
275
+ (fns: MiddlewareFn<TEnv>[]): MiddlewareItem;
276
+ (
277
+ fns: MiddlewareFn<TEnv>[],
278
+ children: () => UseItems<LayoutUseItem>,
279
+ ): MiddlewareItem;
280
+ };
270
281
 
271
282
  /**
272
283
  * Control when a segment should revalidate during navigation