@rangojs/router 0.0.0-experimental.fa8a383a → 0.0.0-experimental.fb4fdc18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/README.md +188 -35
  2. package/dist/bin/rango.js +130 -47
  3. package/dist/vite/index.js +1884 -537
  4. package/dist/vite/index.js.bak +5448 -0
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +7 -5
  7. package/skills/breadcrumbs/SKILL.md +3 -1
  8. package/skills/cache-guide/SKILL.md +32 -0
  9. package/skills/caching/SKILL.md +8 -0
  10. package/skills/handler-use/SKILL.md +362 -0
  11. package/skills/hooks/SKILL.md +33 -20
  12. package/skills/i18n/SKILL.md +276 -0
  13. package/skills/intercept/SKILL.md +20 -0
  14. package/skills/layout/SKILL.md +22 -0
  15. package/skills/links/SKILL.md +93 -17
  16. package/skills/loader/SKILL.md +123 -46
  17. package/skills/middleware/SKILL.md +36 -3
  18. package/skills/migrate-nextjs/SKILL.md +562 -0
  19. package/skills/migrate-react-router/SKILL.md +769 -0
  20. package/skills/parallel/SKILL.md +133 -0
  21. package/skills/prerender/SKILL.md +110 -68
  22. package/skills/rango/SKILL.md +26 -22
  23. package/skills/response-routes/SKILL.md +8 -0
  24. package/skills/route/SKILL.md +75 -0
  25. package/skills/router-setup/SKILL.md +87 -2
  26. package/skills/server-actions/SKILL.md +739 -0
  27. package/skills/streams-and-websockets/SKILL.md +283 -0
  28. package/skills/typesafety/SKILL.md +19 -1
  29. package/src/__internal.ts +1 -1
  30. package/src/browser/app-shell.ts +52 -0
  31. package/src/browser/app-version.ts +14 -0
  32. package/src/browser/event-controller.ts +44 -4
  33. package/src/browser/navigation-bridge.ts +95 -7
  34. package/src/browser/navigation-client.ts +128 -53
  35. package/src/browser/navigation-store.ts +68 -9
  36. package/src/browser/partial-update.ts +93 -12
  37. package/src/browser/prefetch/cache.ts +129 -21
  38. package/src/browser/prefetch/fetch.ts +156 -18
  39. package/src/browser/prefetch/queue.ts +92 -29
  40. package/src/browser/prefetch/resource-ready.ts +77 -0
  41. package/src/browser/rango-state.ts +53 -13
  42. package/src/browser/react/Link.tsx +72 -8
  43. package/src/browser/react/NavigationProvider.tsx +82 -21
  44. package/src/browser/react/context.ts +7 -2
  45. package/src/browser/react/filter-segment-order.ts +51 -7
  46. package/src/browser/react/use-handle.ts +9 -58
  47. package/src/browser/react/use-navigation.ts +22 -2
  48. package/src/browser/react/use-params.ts +17 -4
  49. package/src/browser/react/use-router.ts +29 -9
  50. package/src/browser/react/use-segments.ts +11 -8
  51. package/src/browser/rsc-router.tsx +60 -9
  52. package/src/browser/scroll-restoration.ts +10 -8
  53. package/src/browser/segment-reconciler.ts +36 -14
  54. package/src/browser/server-action-bridge.ts +8 -6
  55. package/src/browser/types.ts +46 -5
  56. package/src/build/generate-manifest.ts +6 -6
  57. package/src/build/generate-route-types.ts +3 -0
  58. package/src/build/route-trie.ts +52 -25
  59. package/src/build/route-types/include-resolution.ts +8 -1
  60. package/src/build/route-types/router-processing.ts +211 -72
  61. package/src/build/route-types/scan-filter.ts +8 -1
  62. package/src/cache/cache-runtime.ts +15 -11
  63. package/src/cache/cache-scope.ts +46 -5
  64. package/src/cache/cf/cf-cache-store.ts +5 -7
  65. package/src/cache/taint.ts +55 -0
  66. package/src/client.tsx +84 -230
  67. package/src/context-var.ts +72 -2
  68. package/src/handle.ts +40 -0
  69. package/src/index.rsc.ts +6 -1
  70. package/src/index.ts +49 -6
  71. package/src/outlet-context.ts +1 -1
  72. package/src/prerender/store.ts +5 -4
  73. package/src/prerender.ts +138 -77
  74. package/src/response-utils.ts +28 -0
  75. package/src/reverse.ts +28 -2
  76. package/src/route-definition/dsl-helpers.ts +210 -35
  77. package/src/route-definition/helpers-types.ts +73 -20
  78. package/src/route-definition/index.ts +3 -0
  79. package/src/route-definition/redirect.ts +9 -1
  80. package/src/route-definition/resolve-handler-use.ts +155 -0
  81. package/src/route-types.ts +18 -0
  82. package/src/router/content-negotiation.ts +100 -1
  83. package/src/router/handler-context.ts +102 -25
  84. package/src/router/intercept-resolution.ts +9 -4
  85. package/src/router/lazy-includes.ts +6 -6
  86. package/src/router/loader-resolution.ts +159 -21
  87. package/src/router/manifest.ts +22 -13
  88. package/src/router/match-api.ts +128 -192
  89. package/src/router/match-handlers.ts +1 -0
  90. package/src/router/match-middleware/background-revalidation.ts +12 -1
  91. package/src/router/match-middleware/cache-lookup.ts +74 -14
  92. package/src/router/match-middleware/cache-store.ts +21 -4
  93. package/src/router/match-middleware/segment-resolution.ts +53 -0
  94. package/src/router/match-result.ts +112 -9
  95. package/src/router/metrics.ts +6 -1
  96. package/src/router/middleware-types.ts +20 -33
  97. package/src/router/middleware.ts +56 -12
  98. package/src/router/navigation-snapshot.ts +182 -0
  99. package/src/router/pattern-matching.ts +101 -17
  100. package/src/router/prerender-match.ts +110 -10
  101. package/src/router/preview-match.ts +30 -102
  102. package/src/router/request-classification.ts +310 -0
  103. package/src/router/revalidation.ts +15 -1
  104. package/src/router/route-snapshot.ts +245 -0
  105. package/src/router/router-context.ts +1 -0
  106. package/src/router/router-interfaces.ts +36 -4
  107. package/src/router/router-options.ts +37 -11
  108. package/src/router/segment-resolution/fresh.ts +114 -18
  109. package/src/router/segment-resolution/helpers.ts +29 -24
  110. package/src/router/segment-resolution/revalidation.ts +257 -127
  111. package/src/router/trie-matching.ts +18 -13
  112. package/src/router/types.ts +1 -0
  113. package/src/router/url-params.ts +49 -0
  114. package/src/router.ts +55 -7
  115. package/src/rsc/handler.ts +478 -383
  116. package/src/rsc/helpers.ts +69 -41
  117. package/src/rsc/loader-fetch.ts +23 -3
  118. package/src/rsc/manifest-init.ts +5 -1
  119. package/src/rsc/progressive-enhancement.ts +18 -2
  120. package/src/rsc/response-route-handler.ts +14 -1
  121. package/src/rsc/rsc-rendering.ts +20 -1
  122. package/src/rsc/server-action.ts +12 -0
  123. package/src/rsc/ssr-setup.ts +2 -2
  124. package/src/rsc/types.ts +15 -1
  125. package/src/segment-content-promise.ts +67 -0
  126. package/src/segment-loader-promise.ts +122 -0
  127. package/src/segment-system.tsx +22 -62
  128. package/src/server/context.ts +76 -4
  129. package/src/server/handle-store.ts +19 -0
  130. package/src/server/loader-registry.ts +9 -8
  131. package/src/server/request-context.ts +185 -57
  132. package/src/ssr/index.tsx +8 -1
  133. package/src/static-handler.ts +18 -6
  134. package/src/types/cache-types.ts +4 -4
  135. package/src/types/handler-context.ts +145 -68
  136. package/src/types/loader-types.ts +41 -15
  137. package/src/types/request-scope.ts +126 -0
  138. package/src/types/route-entry.ts +12 -1
  139. package/src/types/segments.ts +18 -1
  140. package/src/urls/include-helper.ts +24 -14
  141. package/src/urls/path-helper-types.ts +39 -6
  142. package/src/urls/path-helper.ts +47 -12
  143. package/src/urls/pattern-types.ts +12 -0
  144. package/src/urls/response-types.ts +18 -16
  145. package/src/use-loader.tsx +77 -5
  146. package/src/vite/debug.ts +184 -0
  147. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  148. package/src/vite/discovery/discover-routers.ts +36 -4
  149. package/src/vite/discovery/gate-state.ts +171 -0
  150. package/src/vite/discovery/prerender-collection.ts +175 -74
  151. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  152. package/src/vite/discovery/state.ts +13 -4
  153. package/src/vite/index.ts +4 -0
  154. package/src/vite/plugin-types.ts +60 -5
  155. package/src/vite/plugins/cjs-to-esm.ts +5 -0
  156. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  157. package/src/vite/plugins/client-ref-hashing.ts +16 -4
  158. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  159. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  160. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  161. package/src/vite/plugins/expose-action-id.ts +52 -28
  162. package/src/vite/plugins/expose-id-utils.ts +12 -0
  163. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  164. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  165. package/src/vite/plugins/expose-internal-ids.ts +563 -316
  166. package/src/vite/plugins/performance-tracks.ts +96 -0
  167. package/src/vite/plugins/refresh-cmd.ts +88 -26
  168. package/src/vite/plugins/use-cache-transform.ts +56 -43
  169. package/src/vite/plugins/version-injector.ts +37 -11
  170. package/src/vite/rango.ts +63 -11
  171. package/src/vite/router-discovery.ts +732 -86
  172. package/src/vite/utils/banner.ts +1 -1
  173. package/src/vite/utils/package-resolution.ts +41 -1
  174. package/src/vite/utils/prerender-utils.ts +38 -5
  175. package/src/vite/utils/shared-utils.ts +3 -2
@@ -157,13 +157,26 @@ export function formatNestedRouterConflictError(
157
157
  // ---------------------------------------------------------------------------
158
158
 
159
159
  /**
160
- * Extract the url patterns variable from a router file using AST.
161
- * Detects two patterns:
160
+ * Result of extracting URL patterns from a router file.
161
+ * - "variable": a named variable reference (e.g., `.routes(patterns)` or `urls: patterns`)
162
+ * - "inline": an inline builder function (e.g., `.routes(({ path }) => [...])` or `urls: ({ path }) => [...]`)
163
+ */
164
+ export type UrlsExtractionResult =
165
+ | { kind: "variable"; name: string }
166
+ | { kind: "inline"; block: string };
167
+
168
+ /**
169
+ * Extract the url patterns from a router file using AST.
170
+ * Detects four patterns:
162
171
  * 1. createRouter(...).routes(variableName)
163
172
  * 2. createRouter({ urls: variableName, ... })
164
- * Returns the local variable name.
173
+ * 3. createRouter(...).routes(({ path, ... }) => [...])
174
+ * 4. createRouter({ urls: ({ path, ... }) => [...], ... })
175
+ * Returns either a variable name or an inline code block.
165
176
  */
166
- export function extractUrlsVariableFromRouter(code: string): string | null {
177
+ export function extractUrlsFromRouter(
178
+ code: string,
179
+ ): UrlsExtractionResult | null {
167
180
  const sourceFile = ts.createSourceFile(
168
181
  "router.tsx",
169
182
  code,
@@ -171,7 +184,7 @@ export function extractUrlsVariableFromRouter(code: string): string | null {
171
184
  true,
172
185
  ts.ScriptKind.TSX,
173
186
  );
174
- let result: string | null = null;
187
+ let result: UrlsExtractionResult | null = null;
175
188
 
176
189
  function isCreateRouterCall(node: ts.Node): boolean {
177
190
  if (!ts.isCallExpression(node)) return false;
@@ -179,44 +192,108 @@ export function extractUrlsVariableFromRouter(code: string): string | null {
179
192
  return ts.isIdentifier(callee) && callee.text === "createRouter";
180
193
  }
181
194
 
195
+ /** Check if a node is an arrow/function expression (inline builder). */
196
+ function isInlineBuilder(node: ts.Node): boolean {
197
+ return ts.isArrowFunction(node) || ts.isFunctionExpression(node);
198
+ }
199
+
200
+ /** Check if a .routes() call chains from createRouter(). */
201
+ function isRoutesOnCreateRouter(node: ts.CallExpression): boolean {
202
+ if (
203
+ !ts.isPropertyAccessExpression(node.expression) ||
204
+ node.expression.name.text !== "routes"
205
+ )
206
+ return false;
207
+ let inner: ts.Expression = node.expression.expression;
208
+ while (
209
+ ts.isCallExpression(inner) &&
210
+ ts.isPropertyAccessExpression(inner.expression)
211
+ ) {
212
+ inner = inner.expression.expression;
213
+ }
214
+ return isCreateRouterCall(inner);
215
+ }
216
+
182
217
  function visit(node: ts.Node) {
183
218
  if (result) return;
184
219
 
185
- // Pattern 1: createRouter(...).routes(variableName)
186
- // The AST shape is CallExpression(.routes) -> PropertyAccessExpression -> CallExpression(createRouter)
220
+ // Pattern 1 & 3: createRouter(...).routes(variableName | builder)
187
221
  if (
188
222
  ts.isCallExpression(node) &&
189
- ts.isPropertyAccessExpression(node.expression) &&
190
- node.expression.name.text === "routes" &&
191
223
  node.arguments.length >= 1 &&
192
- ts.isIdentifier(node.arguments[0])
224
+ isRoutesOnCreateRouter(node)
193
225
  ) {
194
- // Walk up the chain: createRouter().middleware(...).routes(x) etc.
195
- // The innermost call should be createRouter(...)
196
- let inner: ts.Expression = node.expression.expression;
197
- while (
198
- ts.isCallExpression(inner) &&
199
- ts.isPropertyAccessExpression(inner.expression)
200
- ) {
201
- inner = inner.expression.expression;
202
- }
203
- if (isCreateRouterCall(inner)) {
204
- result = (node.arguments[0] as ts.Identifier).text;
205
- return;
226
+ const arg = node.arguments[0];
227
+ if (ts.isIdentifier(arg)) {
228
+ result = { kind: "variable", name: arg.text };
229
+ } else if (isInlineBuilder(arg)) {
230
+ result = { kind: "inline", block: arg.getText(sourceFile) };
206
231
  }
232
+ return;
207
233
  }
208
234
 
209
- // Pattern 2: createRouter({ urls: variableName, ... })
235
+ // Pattern 2 & 4: createRouter({ urls: variableName | builder, ... })
210
236
  if (isCreateRouterCall(node)) {
211
237
  const callExpr = node as ts.CallExpression;
212
- for (const arg of callExpr.arguments) {
238
+ for (const callArg of callExpr.arguments) {
239
+ if (ts.isObjectLiteralExpression(callArg)) {
240
+ for (const prop of callArg.properties) {
241
+ if (
242
+ ts.isPropertyAssignment(prop) &&
243
+ ts.isIdentifier(prop.name) &&
244
+ prop.name.text === "urls"
245
+ ) {
246
+ if (ts.isIdentifier(prop.initializer)) {
247
+ result = { kind: "variable", name: prop.initializer.text };
248
+ } else if (isInlineBuilder(prop.initializer)) {
249
+ result = {
250
+ kind: "inline",
251
+ block: prop.initializer.getText(sourceFile),
252
+ };
253
+ }
254
+ return;
255
+ }
256
+ }
257
+ }
258
+ }
259
+ }
260
+
261
+ ts.forEachChild(node, visit);
262
+ }
263
+
264
+ visit(sourceFile);
265
+ return result;
266
+ }
267
+
268
+ /**
269
+ * Extract the `basename` string literal from createRouter({ basename: "..." }).
270
+ * Returns the basename value or undefined if not present.
271
+ */
272
+ export function extractBasenameFromRouter(code: string): string | undefined {
273
+ const sourceFile = ts.createSourceFile(
274
+ "router.tsx",
275
+ code,
276
+ ts.ScriptTarget.Latest,
277
+ true,
278
+ ts.ScriptKind.TSX,
279
+ );
280
+ let result: string | undefined;
281
+
282
+ function visit(node: ts.Node) {
283
+ if (result !== undefined) return;
284
+ if (
285
+ ts.isCallExpression(node) &&
286
+ ts.isIdentifier(node.expression) &&
287
+ node.expression.text === "createRouter"
288
+ ) {
289
+ for (const arg of node.arguments) {
213
290
  if (ts.isObjectLiteralExpression(arg)) {
214
291
  for (const prop of arg.properties) {
215
292
  if (
216
293
  ts.isPropertyAssignment(prop) &&
217
294
  ts.isIdentifier(prop.name) &&
218
- prop.name.text === "urls" &&
219
- ts.isIdentifier(prop.initializer)
295
+ prop.name.text === "basename" &&
296
+ ts.isStringLiteral(prop.initializer)
220
297
  ) {
221
298
  result = prop.initializer.text;
222
299
  return;
@@ -225,7 +302,6 @@ export function extractUrlsVariableFromRouter(code: string): string | null {
225
302
  }
226
303
  }
227
304
  }
228
-
229
305
  ts.forEachChild(node, visit);
230
306
  }
231
307
 
@@ -233,9 +309,40 @@ export function extractUrlsVariableFromRouter(code: string): string | null {
233
309
  return result;
234
310
  }
235
311
 
312
+ /** @deprecated Use extractUrlsFromRouter instead */
313
+ export function extractUrlsVariableFromRouter(code: string): string | null {
314
+ const result = extractUrlsFromRouter(code);
315
+ return result?.kind === "variable" ? result.name : null;
316
+ }
317
+
318
+ /** Apply a basename prefix to all route patterns in a result set. */
319
+ function applyBasenameToRoutes(
320
+ result: {
321
+ routes: Record<string, string>;
322
+ searchSchemas: Record<string, Record<string, string>>;
323
+ },
324
+ basename: string,
325
+ ): {
326
+ routes: Record<string, string>;
327
+ searchSchemas: Record<string, Record<string, string>>;
328
+ } {
329
+ const prefixed: Record<string, string> = {};
330
+ for (const [name, pattern] of Object.entries(result.routes)) {
331
+ if (pattern === "/") {
332
+ prefixed[name] = basename;
333
+ } else if (basename.endsWith("/") && pattern.startsWith("/")) {
334
+ prefixed[name] = basename + pattern.slice(1);
335
+ } else {
336
+ prefixed[name] = basename + pattern;
337
+ }
338
+ }
339
+ return { routes: prefixed, searchSchemas: result.searchSchemas };
340
+ }
341
+
236
342
  /**
237
343
  * Resolve routes and search schemas from a router source file by following the
238
- * variable passed to `.routes(...)` or `urls: ...` in createRouter options.
344
+ * variable passed to `.routes(...)` or `urls: ...` in createRouter options,
345
+ * or by parsing an inline builder function directly.
239
346
  */
240
347
  export function buildCombinedRouteMapForRouterFile(routerFilePath: string): {
241
348
  routes: Record<string, string>;
@@ -248,21 +355,54 @@ export function buildCombinedRouteMapForRouterFile(routerFilePath: string): {
248
355
  return { routes: {}, searchSchemas: {} };
249
356
  }
250
357
 
251
- const urlsVarName = extractUrlsVariableFromRouter(routerSource);
252
- if (!urlsVarName) {
358
+ const extraction = extractUrlsFromRouter(routerSource);
359
+ if (!extraction) {
253
360
  return { routes: {}, searchSchemas: {} };
254
361
  }
255
362
 
256
- const imported = resolveImportedVariable(routerSource, urlsVarName);
257
- if (imported) {
258
- const targetFile = resolveImportPath(imported.specifier, routerFilePath);
259
- if (!targetFile) {
260
- return { routes: {}, searchSchemas: {} };
363
+ // Detect basename from createRouter({ basename: "..." })
364
+ const rawBasename = extractBasenameFromRouter(routerSource);
365
+ const basename = rawBasename
366
+ ? ("/" + rawBasename.replace(/^\/+|\/+$/g, "")).replace(/^\/$/, "")
367
+ : undefined;
368
+
369
+ let result: {
370
+ routes: Record<string, string>;
371
+ searchSchemas: Record<string, Record<string, string>>;
372
+ };
373
+
374
+ // Inline builder: extract routes directly from the function body
375
+ if (extraction.kind === "inline") {
376
+ result = buildCombinedRouteMapWithSearch(
377
+ routerFilePath,
378
+ undefined,
379
+ undefined,
380
+ undefined,
381
+ extraction.block,
382
+ );
383
+ } else {
384
+ // Variable reference: follow imports or same-file declaration
385
+ const imported = resolveImportedVariable(routerSource, extraction.name);
386
+ if (imported) {
387
+ const targetFile = resolveImportPath(imported.specifier, routerFilePath);
388
+ if (!targetFile) {
389
+ return { routes: {}, searchSchemas: {} };
390
+ }
391
+ result = buildCombinedRouteMapWithSearch(
392
+ targetFile,
393
+ imported.exportedName,
394
+ );
395
+ } else {
396
+ result = buildCombinedRouteMapWithSearch(routerFilePath, extraction.name);
261
397
  }
262
- return buildCombinedRouteMapWithSearch(targetFile, imported.exportedName);
263
398
  }
264
399
 
265
- return buildCombinedRouteMapWithSearch(routerFilePath, urlsVarName);
400
+ // Apply basename prefix to all extracted route patterns
401
+ if (basename) {
402
+ result = applyBasenameToRoutes(result, basename);
403
+ }
404
+
405
+ return result;
266
406
  }
267
407
 
268
408
  // ---------------------------------------------------------------------------
@@ -285,12 +425,26 @@ export function detectUnresolvableIncludes(
285
425
  return [];
286
426
  }
287
427
 
288
- // Extract the urls variable from the router file
289
- const urlsVarName = extractUrlsVariableFromRouter(source);
290
- if (!urlsVarName) return [];
428
+ // Extract the urls source from the router file
429
+ const extraction = extractUrlsFromRouter(source);
430
+ if (!extraction) return [];
291
431
 
292
- // Resolve where the urls variable comes from
293
- const imported = resolveImportedVariable(source, urlsVarName);
432
+ const diagnostics: UnresolvableInclude[] = [];
433
+
434
+ if (extraction.kind === "inline") {
435
+ // Inline builder: parse directly
436
+ buildCombinedRouteMapWithSearch(
437
+ realPath,
438
+ undefined,
439
+ new Set(),
440
+ diagnostics,
441
+ extraction.block,
442
+ );
443
+ return diagnostics;
444
+ }
445
+
446
+ // Variable reference: resolve where it comes from
447
+ const imported = resolveImportedVariable(source, extraction.name);
294
448
  let targetFile: string;
295
449
  let exportedName: string | undefined;
296
450
 
@@ -312,10 +466,9 @@ export function detectUnresolvableIncludes(
312
466
  } else {
313
467
  // Same-file urls() definition
314
468
  targetFile = realPath;
315
- exportedName = urlsVarName;
469
+ exportedName = extraction.name;
316
470
  }
317
471
 
318
- const diagnostics: UnresolvableInclude[] = [];
319
472
  buildCombinedRouteMapWithSearch(
320
473
  targetFile,
321
474
  exportedName,
@@ -397,34 +550,20 @@ export function writeCombinedRouteTypes(
397
550
  }
398
551
 
399
552
  for (const routerFilePath of routerFilePaths) {
400
- let routerSource: string;
401
- try {
402
- routerSource = readFileSync(routerFilePath, "utf-8");
403
- } catch {
404
- continue;
405
- }
406
- // Extract the urls variable name from .routes(varName) or urls: varName
407
- const urlsVarName = extractUrlsVariableFromRouter(routerSource);
408
- if (!urlsVarName) continue;
409
-
410
- // Resolve the variable to its source module
411
- let result: {
412
- routes: Record<string, string>;
413
- searchSchemas: Record<string, Record<string, string>>;
414
- };
415
-
416
- const imported = resolveImportedVariable(routerSource, urlsVarName);
417
- if (imported) {
418
- // Variable is imported from another module
419
- const targetFile = resolveImportPath(imported.specifier, routerFilePath);
420
- if (!targetFile) continue;
421
- result = buildCombinedRouteMapWithSearch(
422
- targetFile,
423
- imported.exportedName,
424
- );
425
- } else {
426
- // Variable is defined in the same file
427
- result = buildCombinedRouteMapWithSearch(routerFilePath, urlsVarName);
553
+ const result = buildCombinedRouteMapForRouterFile(routerFilePath);
554
+ if (
555
+ Object.keys(result.routes).length === 0 &&
556
+ Object.keys(result.searchSchemas).length === 0
557
+ ) {
558
+ // Check if the file even has a createRouter call — if not, skip entirely.
559
+ // If it does, fall through to write an empty placeholder below.
560
+ let routerSource: string;
561
+ try {
562
+ routerSource = readFileSync(routerFilePath, "utf-8");
563
+ } catch {
564
+ continue;
565
+ }
566
+ if (!extractUrlsFromRouter(routerSource)) continue;
428
567
  }
429
568
 
430
569
  const routerBasename = pathBasename(routerFilePath).replace(
@@ -61,7 +61,14 @@ export function findTsFiles(dir: string, filter?: ScanFilter): string[] {
61
61
  for (const entry of entries) {
62
62
  const fullPath = join(dir, entry.name);
63
63
  if (entry.isDirectory()) {
64
- if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
64
+ if (
65
+ entry.name === "node_modules" ||
66
+ entry.name.startsWith(".") ||
67
+ entry.name === "dist" ||
68
+ entry.name === "build" ||
69
+ entry.name === "coverage"
70
+ )
71
+ continue;
65
72
  results.push(...findTsFiles(fullPath, filter));
66
73
  } else if (
67
74
  (entry.name.endsWith(".ts") ||
@@ -214,11 +214,21 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
214
214
  bgStopCapture = c.stop;
215
215
  }
216
216
 
217
- // Stamp tainted args and RequestContext so request-scoped
218
- // reads (cookies, headers) and side effects (ctx.set, etc.)
219
- // throw inside background revalidation, same as the miss path.
220
- // Uses ref-counted stamp/unstamp so overlapping executions
221
- // sharing the same ctx don't clear each other's guards.
217
+ // Stamp tainted ARGS only not requestCtx. The args stamp guards
218
+ // direct ctx method calls (ctx.set, ctx.header, ctx.onResponse, etc.)
219
+ // which is sufficient for correctness.
220
+ //
221
+ // We intentionally skip stamping requestCtx here because:
222
+ // 1. runBackground starts the async task synchronously (before the
223
+ // first await), so stampCacheExec would pollute the shared
224
+ // requestCtx while the foreground pipeline is still running.
225
+ // This causes assertNotInsideCacheExec to fire when cache-store
226
+ // later calls requestCtx.onResponse().
227
+ // 2. requestCtx methods are closure-bound to the original ctx, so
228
+ // neither Object.create() nor a proxy can isolate the stamp.
229
+ // 3. The foreground miss path already stamps requestCtx and catches
230
+ // cookies()/headers() misuse on first execution. The background
231
+ // re-runs the same function with the same request.
222
232
  const bgTaintedArgs: unknown[] = [];
223
233
  for (const arg of args) {
224
234
  if (isTainted(arg)) {
@@ -226,9 +236,6 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
226
236
  bgTaintedArgs.push(arg);
227
237
  }
228
238
  }
229
- if (requestCtx) {
230
- stampCacheExec(requestCtx as object);
231
- }
232
239
 
233
240
  try {
234
241
  const freshResult = await fn.apply(this, args);
@@ -249,9 +256,6 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
249
256
  for (const arg of bgTaintedArgs) {
250
257
  unstampCacheExec(arg as object);
251
258
  }
252
- if (requestCtx) {
253
- unstampCacheExec(requestCtx as object);
254
- }
255
259
  // Restore original handle store
256
260
  if (originalHandleStore && requestCtx) {
257
261
  requestCtx._handleStore = originalHandleStore;
@@ -328,22 +328,59 @@ export class CacheScope {
328
328
  // Check if this is a partial request (navigation) vs document request
329
329
  const isPartial = requestCtx.originalUrl.searchParams.has("_rsc_partial");
330
330
 
331
+ if (INTERNAL_RANGO_DEBUG) {
332
+ debugCacheLog(
333
+ `[CacheScope] cacheRoute: scheduling waitUntil for ${key} (${nonLoaderSegments.length} segments, isPartial=${isPartial})`,
334
+ );
335
+ }
336
+
331
337
  requestCtx.waitUntil(async () => {
338
+ if (INTERNAL_RANGO_DEBUG) {
339
+ debugCacheLog(
340
+ `[CacheScope] waitUntil: awaiting handleStore.settled for ${key}`,
341
+ );
342
+ }
343
+
332
344
  await handleStore.settled;
333
345
 
334
- // For document requests: only cache if ALL segments have components (complete render)
335
- // For partial requests: null components are expected (client already has them)
346
+ if (INTERNAL_RANGO_DEBUG) {
347
+ debugCacheLog(`[CacheScope] waitUntil: handleStore settled for ${key}`);
348
+ }
349
+
350
+ // For document requests: only cache if layout segments have components
351
+ // (complete render). Parallel and route segments may legitimately have
352
+ // null components — UI-less @meta parallels return null, and void route
353
+ // handlers produce null when the UI lives in parallel slots/layouts.
354
+ // Partial requests always allow null components (client already has them).
336
355
  if (!isPartial) {
337
- const hasAllComponents = nonLoaderSegments.every(
338
- (s) => s.component !== null,
356
+ const hasIncompleteLayouts = nonLoaderSegments.some(
357
+ (s) => s.component === null && s.type === "layout",
339
358
  );
340
- if (!hasAllComponents) return;
359
+ if (hasIncompleteLayouts) {
360
+ const nullSegments = nonLoaderSegments
361
+ .filter((s) => s.component === null && s.type === "layout")
362
+ .map((s) => s.id);
363
+ const error = new Error(
364
+ `[CacheScope] Cache write skipped: layout segments have null components ` +
365
+ `(${nullSegments.join(", ")}). This indicates an incomplete render — ` +
366
+ `layout handlers must return JSX for document requests to be cacheable.`,
367
+ );
368
+ error.name = "CacheScopeInvariantError";
369
+ console.error(error.message);
370
+ return;
371
+ }
341
372
  }
342
373
 
343
374
  // Collect handle data for non-loader segments only
344
375
  const handles = captureHandles(nonLoaderSegments, handleStore);
345
376
 
346
377
  try {
378
+ if (INTERNAL_RANGO_DEBUG) {
379
+ debugCacheLog(
380
+ `[CacheScope] waitUntil: serializing ${nonLoaderSegments.length} segments for ${key}`,
381
+ );
382
+ }
383
+
347
384
  // Serialize non-loader segments only
348
385
  const serializedSegments = await serializeSegments(nonLoaderSegments);
349
386
 
@@ -353,6 +390,10 @@ export class CacheScope {
353
390
  expiresAt: Date.now() + ttl * 1000,
354
391
  };
355
392
 
393
+ if (INTERNAL_RANGO_DEBUG) {
394
+ debugCacheLog(`[CacheScope] waitUntil: calling store.set for ${key}`);
395
+ }
396
+
356
397
  await store.set(key, data, ttl, swr);
357
398
 
358
399
  if (INTERNAL_RANGO_DEBUG) {
@@ -67,13 +67,11 @@ export const MAX_REVALIDATION_INTERVAL = 30;
67
67
  // Types
68
68
  // ============================================================================
69
69
 
70
- /**
71
- * Cloudflare Workers ExecutionContext (subset we need)
72
- */
73
- export interface ExecutionContext {
74
- waitUntil(promise: Promise<any>): void;
75
- passThroughOnException(): void;
76
- }
70
+ // Re-exported from the canonical home so cf-cache-store consumers keep
71
+ // importing `ExecutionContext` from this module without a second interface
72
+ // drifting over time.
73
+ export type { ExecutionContext } from "../../types/request-scope.js";
74
+ import type { ExecutionContext } from "../../types/request-scope.js";
77
75
 
78
76
  /**
79
77
  * Minimal Cloudflare KV Namespace interface.
@@ -81,6 +81,61 @@ export function assertNotInsideCacheExec(
81
81
  }
82
82
  }
83
83
 
84
+ /**
85
+ * Symbol stamped on ctx when resolving handlers inside a cache() DSL boundary.
86
+ * Separate from INSIDE_CACHE_EXEC ("use cache") because cache() allows
87
+ * ctx.set() (children are also cached) but blocks response-level side effects
88
+ * (headers, cookies, status) which are lost on cache hit.
89
+ */
90
+ export const INSIDE_CACHE_SCOPE: unique symbol = Symbol.for(
91
+ "rango:inside-cache-scope",
92
+ ) as any;
93
+
94
+ /**
95
+ * Mark ctx as inside a cache() scope. Must be paired with unstampCacheScope.
96
+ */
97
+ export function stampCacheScope(obj: object): void {
98
+ const current = (obj as any)[INSIDE_CACHE_SCOPE] ?? 0;
99
+ (obj as any)[INSIDE_CACHE_SCOPE] = current + 1;
100
+ }
101
+
102
+ /**
103
+ * Remove cache() scope mark.
104
+ */
105
+ export function unstampCacheScope(obj: object): void {
106
+ const current = (obj as any)[INSIDE_CACHE_SCOPE] ?? 0;
107
+ if (current <= 1) {
108
+ delete (obj as any)[INSIDE_CACHE_SCOPE];
109
+ } else {
110
+ (obj as any)[INSIDE_CACHE_SCOPE] = current - 1;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Throw if ctx is inside a cache() DSL boundary.
116
+ * Call from response-level side effects (header, setCookie, setStatus, etc.)
117
+ * which are lost on cache hit because the handler body is skipped.
118
+ * ctx.set() is allowed inside cache() — children are also cached and can
119
+ * read the value.
120
+ */
121
+ export function assertNotInsideCacheScope(
122
+ ctx: unknown,
123
+ methodName: string,
124
+ ): void {
125
+ if (
126
+ ctx !== null &&
127
+ ctx !== undefined &&
128
+ typeof ctx === "object" &&
129
+ (INSIDE_CACHE_SCOPE as symbol) in (ctx as Record<symbol, unknown>)
130
+ ) {
131
+ throw new Error(
132
+ `ctx.${methodName}() cannot be called inside a cache() boundary. ` +
133
+ `On cache hit the handler is skipped, so this side effect would be lost. ` +
134
+ `Move ctx.${methodName}() to a middleware or layout outside the cache() scope.`,
135
+ );
136
+ }
137
+ }
138
+
84
139
  /**
85
140
  * Brand symbol for functions wrapped by registerCachedFunction().
86
141
  * Used at runtime to detect when a "use cache" function is misused