@rangojs/router 0.0.0-experimental.142 → 0.0.0-experimental.144

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 (55) hide show
  1. package/dist/vite/index.js +25 -6
  2. package/package.json +4 -2
  3. package/skills/cache-guide/SKILL.md +3 -1
  4. package/skills/caching/SKILL.md +41 -2
  5. package/skills/catalog.json +6 -0
  6. package/skills/composability/SKILL.md +32 -0
  7. package/skills/defer-hydration/SKILL.md +235 -0
  8. package/skills/loader/SKILL.md +5 -0
  9. package/skills/migrate-nextjs/SKILL.md +4 -2
  10. package/skills/observability/SKILL.md +8 -0
  11. package/skills/parallel/SKILL.md +4 -0
  12. package/skills/ppr/SKILL.md +110 -20
  13. package/skills/rango/SKILL.md +10 -0
  14. package/skills/route/SKILL.md +8 -0
  15. package/skills/typesafety/SKILL.md +1 -0
  16. package/skills/typesafety/generated-files-and-cli.md +30 -0
  17. package/skills/use-cache/SKILL.md +12 -2
  18. package/src/browser/partial-update.ts +7 -0
  19. package/src/cache/cache-key-utils.ts +29 -0
  20. package/src/cache/cache-scope.ts +2 -17
  21. package/src/cache/cache-tag.ts +60 -14
  22. package/src/cache/cf/cf-cache-store.ts +54 -20
  23. package/src/cache/document-cache.ts +17 -11
  24. package/src/cache/vercel/vercel-cache-store.ts +9 -19
  25. package/src/cloudflare/tracing.ts +7 -8
  26. package/src/index.rsc.ts +1 -0
  27. package/src/index.ts +12 -8
  28. package/src/redirect-origin.ts +14 -0
  29. package/src/route-definition/helpers-types.ts +5 -4
  30. package/src/route-map-builder.ts +41 -4
  31. package/src/router/find-match.ts +15 -1
  32. package/src/router/instrument.ts +9 -4
  33. package/src/router/lazy-includes.ts +8 -2
  34. package/src/router/loader-resolution.ts +14 -2
  35. package/src/router/match-handlers.ts +175 -133
  36. package/src/router/middleware.ts +40 -30
  37. package/src/router/router-interfaces.ts +9 -0
  38. package/src/router/segment-resolution/loader-snapshot.ts +98 -17
  39. package/src/router/telemetry-otel.ts +6 -8
  40. package/src/router/telemetry.ts +9 -1
  41. package/src/router/tracing.ts +14 -5
  42. package/src/router.ts +22 -14
  43. package/src/rsc/handler.ts +55 -32
  44. package/src/rsc/redirect-guard.ts +2 -1
  45. package/src/rsc/rsc-rendering.ts +35 -2
  46. package/src/rsc/shell-capture.ts +98 -20
  47. package/src/server/context.ts +47 -9
  48. package/src/server/cookie-store.ts +26 -5
  49. package/src/server/request-context.ts +22 -0
  50. package/src/ssr/index.tsx +145 -107
  51. package/src/testing/dispatch.ts +149 -37
  52. package/src/urls/path-helper-types.ts +9 -4
  53. package/src/vercel/tracing.ts +7 -7
  54. package/src/vite/inject-client-debug.ts +64 -12
  55. package/src/vite/router-discovery.ts +9 -1
@@ -19,7 +19,7 @@ import {
19
19
  type RequestContext,
20
20
  } from "../server/request-context.js";
21
21
  import { mayNeedSSR } from "../rsc/ssr-setup.js";
22
- import { sortedSearchString } from "./cache-key-utils.js";
22
+ import { cacheKeyBase } from "./cache-key-utils.js";
23
23
  import { runBackground } from "./background-task.js";
24
24
  import { reportCacheError } from "./cache-error.js";
25
25
 
@@ -188,7 +188,13 @@ export interface DocumentCacheOptions<TEnv = any> {
188
188
  skipPaths?: string[];
189
189
 
190
190
  /**
191
- * Custom cache key generator
191
+ * Custom cache key generator.
192
+ *
193
+ * Replaces the default `host + pathname + search` key entirely. On a
194
+ * multi-domain deployment served by one function you MUST include `url.host`
195
+ * (or an equivalent tenant discriminator) yourself — the default key is
196
+ * host-namespaced, but a custom generator's output is used verbatim, so
197
+ * omitting host bleeds one hostname's cached response to another.
192
198
  */
193
199
  keyGenerator?: (url: URL) => string;
194
200
 
@@ -311,17 +317,17 @@ export function createDocumentCacheMiddleware<TEnv = any>(
311
317
  isPartial && clientSegments ? `:${hashSegmentIds(clientSegments)}` : "";
312
318
  const typeSuffix = isRscRequest ? ":rsc" : ":html";
313
319
 
314
- let searchSuffix = "";
315
- if (!keyGenerator) {
316
- const sorted = sortedSearchString(url.searchParams);
317
- if (sorted) {
318
- searchSuffix = `?${sorted}`;
319
- }
320
- }
321
-
320
+ // Default key rides the shared host-namespaced base (cacheKeyBase) so the
321
+ // segment tier (cache-scope.ts) and this document tier cannot drift on the
322
+ // host-namespacing rule -- see the contract on cacheKeyBase.
323
+ // The keyGenerator branch is left untouched: a consumer-supplied generator
324
+ // owns its own namespacing (auto-prefixing host would silently change their
325
+ // existing keys and double any host they already include).
322
326
  const cacheKey = keyGenerator
323
327
  ? keyGenerator(url) + segmentHash + typeSuffix
324
- : `${url.pathname}${searchSuffix}${segmentHash}${typeSuffix}`;
328
+ : cacheKeyBase(url.host, url.pathname, url.searchParams) +
329
+ segmentHash +
330
+ typeSuffix;
325
331
  // 1. Check cache
326
332
  const cached = await store.getResponse(cacheKey);
327
333
 
@@ -51,6 +51,15 @@ import {
51
51
  } from "../cache-policy.js";
52
52
  import { reportCacheError, reportingAsync } from "../cache-error.js";
53
53
  import type { CacheErrorCategory } from "../cache-error.js";
54
+ // Reuse the CF store's binary-safe base64 helpers. bufferToBase64 caps each
55
+ // String.fromCharCode batch at 8192 and uses .apply (never a spread), so a large
56
+ // Response/PPR-shell body cannot blow the JS argument-count ceiling (~65k) and
57
+ // throw RangeError inside putResponse/putShell - which the outer try/catch would
58
+ // swallow as a cache-write degrade, silently never caching the entry. Output is
59
+ // byte-identical to a per-byte encoder (chunk size does not affect base64), so
60
+ // this is a robustness fix, not a format change. Do NOT reintroduce a local
61
+ // spread-based encoder or raise the chunk here; cf-base64.ts is import-pure.
62
+ import { bufferToBase64, base64ToBuffer } from "../cf/cf-base64.js";
54
63
 
55
64
  /**
56
65
  * Minimal structural shape of the Vercel Runtime Cache returned by `getCache()`
@@ -277,25 +286,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
277
286
  return typeof value === "object" && value !== null;
278
287
  }
279
288
 
280
- /** Encode binary body bytes to base64 in chunks (avoids call-stack blowups). */
281
- function bufferToBase64(buffer: ArrayBuffer): string {
282
- const bytes = new Uint8Array(buffer);
283
- let binary = "";
284
- const CHUNK = 0x8000;
285
- for (let i = 0; i < bytes.length; i += CHUNK) {
286
- binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
287
- }
288
- return btoa(binary);
289
- }
290
-
291
- /** Decode a base64 body back into bytes. */
292
- function base64ToBuffer(b64: string): ArrayBuffer {
293
- const binary = atob(b64);
294
- const bytes = new Uint8Array(binary.length);
295
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
296
- return bytes.buffer;
297
- }
298
-
299
289
  /**
300
290
  * Vercel Runtime Cache-backed segment cache store.
301
291
  *
@@ -39,7 +39,7 @@ import { _getRequestContext } from "../server/request-context.js";
39
39
  import {
40
40
  type RouterTracingConfig,
41
41
  type SpanRunner,
42
- type TracePhaseToggles,
42
+ type TracingToggleOptions,
43
43
  NOOP_TRACE_SPAN,
44
44
  } from "../router/tracing.js";
45
45
 
@@ -57,13 +57,12 @@ interface CloudflareTracing {
57
57
  enterSpan<T>(name: string, callback: (span: CloudflareSpan) => T): T;
58
58
  }
59
59
 
60
- /** Options for createCloudflareTracing. */
61
- export interface CloudflareTracingOptions {
62
- /** Master switch. Defaults to true. */
63
- enabled?: boolean;
64
- /** Per-phase span toggles. Omitted phases default to enabled. */
65
- spans?: TracePhaseToggles;
66
- }
60
+ /**
61
+ * Options for createCloudflareTracing. Alias of the shared
62
+ * {@link TracingToggleOptions} (`enabled` master switch + per-phase `spans`
63
+ * toggles); the name is public API.
64
+ */
65
+ export type CloudflareTracingOptions = TracingToggleOptions;
67
66
 
68
67
  /**
69
68
  * Resolve the per-request Cloudflare tracer from the active execution context.
package/src/index.rsc.ts CHANGED
@@ -308,6 +308,7 @@ export type {
308
308
  RouterTracingConfig,
309
309
  TracePhase,
310
310
  TracePhaseToggles,
311
+ TracingToggleOptions,
311
312
  } from "./router/tracing.js";
312
313
 
313
314
  // Timeout types and error class
package/src/index.ts CHANGED
@@ -343,14 +343,17 @@ export {
343
343
  // Path and response types are ambient on the `Rango` namespace (`Rango.Path`,
344
344
  // `Rango.PathResponse`, declared in href-client.ts) — no import needed.
345
345
 
346
- // Telemetry types only — the createConsoleSink/createOTelSink values are
347
- // server-only and live in index.rsc.ts (the `react-server` condition of the
348
- // bare `@rangojs/router` import). Re-exporting them as values from this
349
- // (default/client) entry would pull telemetry.ts and telemetry-otel.ts into
350
- // the client module graph; both tree-shake to zero bytes but still appear in
351
- // bundle analysis output and slow build-time module resolution. Consumers
352
- // who need the values in non-RSC contexts can import from
353
- // `@rangojs/router/server`.
346
+ // Telemetry types only — the createConsoleSink / createOTelSink /
347
+ // createOTelTracing VALUES are server-only and live in index.rsc.ts (the
348
+ // `react-server` condition of the bare `@rangojs/router` import). Re-exporting
349
+ // them as values from this (default/client) entry would pull telemetry.ts and
350
+ // telemetry-otel.ts into the client module graph; both tree-shake to zero bytes
351
+ // but still appear in bundle analysis output and slow build-time module
352
+ // resolution. The factory values are NOT re-exported from `@rangojs/router/server`
353
+ // either — that subpath is internal, not user-facing (see server.ts header).
354
+ // Non-RSC server code imports these TYPES from the root and obtains the factory
355
+ // VALUES from its own router definition module, which resolves to index.rsc.ts
356
+ // under the `react-server` condition.
354
357
  export type {
355
358
  OTelTracer,
356
359
  OTelActiveSpanTracer,
@@ -386,6 +389,7 @@ export type {
386
389
  RouterTracingConfig,
387
390
  TracePhase,
388
391
  TracePhaseToggles,
392
+ TracingToggleOptions,
389
393
  } from "./router/tracing.js";
390
394
 
391
395
  // Timeout types and error class
@@ -60,6 +60,20 @@ export function resolveExternalRedirect(
60
60
  }
61
61
  }
62
62
 
63
+ /**
64
+ * The safe same-origin landing for a blocked redirect.
65
+ *
66
+ * Every guard that neutralizes a cross-origin/unsafe redirect target sends the
67
+ * browser here instead: the app's basename root, or `"/"` when unset. Kept
68
+ * beside the resolvers so the "where does a blocked redirect go" answer lives
69
+ * in ONE place -- the server 3xx guard (`rsc/redirect-guard.ts`) and the
70
+ * shell-HIT degradation path (`rsc/rsc-rendering.ts`) must agree, or a blocked
71
+ * redirect lands differently depending on which exit it took.
72
+ */
73
+ export function safeSameOriginLanding(basename: string | undefined): string {
74
+ return basename && basename !== "/" ? basename : "/";
75
+ }
76
+
63
77
  /**
64
78
  * Out-of-band brand for `redirect(url, { external: true })`.
65
79
  *
@@ -149,8 +149,11 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
149
149
  * so they take precedence on `loading()` and other last-write-wins
150
150
  * fields.
151
151
  */
152
- parallel: <
153
- TSlots extends Record<
152
+ // Not generic over the slots record: an inferred type parameter makes the
153
+ // object literal an inference site, which suppresses contextual typing of
154
+ // arrow slot handlers (`(ctx) => ...` was implicit any).
155
+ parallel: (
156
+ slots: Record<
154
157
  `@${string}`,
155
158
  | Handler<any, any, TEnv>
156
159
  | ReactNode
@@ -159,8 +162,6 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
159
162
  use?: () => UseItems<ParallelUseItem>;
160
163
  }
161
164
  >,
162
- >(
163
- slots: TSlots,
164
165
  use?: () => UseItems<ParallelUseItem>,
165
166
  ) => ParallelItem;
166
167
  /**
@@ -20,11 +20,25 @@ let cachedPrecomputedEntries: Array<{
20
20
  /**
21
21
  * Register routes into the global route map.
22
22
  * Routes are merged with any existing registered routes.
23
- * Called by createRouter() during module evaluation.
23
+ * Called by createRouter() during module evaluation, and by lazy-include
24
+ * expansion (src/router/lazy-includes.ts) with each expansion's route delta.
25
+ *
26
+ * Merges IN PLACE — O(|map|), not O(total routes). The previous
27
+ * `globalRouteMap = { ...globalRouteMap, ...map }` copy made every
28
+ * lazy-include first hit O(total routes) on the request path: with a 26k-route
29
+ * manifest the spread measured 8.9ms/call (M4, node), paid once per level of a
30
+ * nested async-include chain (3 calls on a 3-level chain — the 464ms edge
31
+ * cold-hit in issue #666).
32
+ *
33
+ * In-place mutation is safe because every getGlobalRouteMap() consumer reads
34
+ * it fresh per call (server/request-context.ts, rsc/loader-fetch.ts,
35
+ * router/intercept-resolution.ts, testing/generated-routes.ts,
36
+ * rsc/manifest-init.ts) — none memoizes the returned reference. If you add a
37
+ * consumer that caches the map object, it will now observe later
38
+ * registrations; snapshot it yourself if you need frozen contents.
24
39
  */
25
40
  export function registerRouteMap(map: Record<string, string>): void {
26
- // Always merge with existing map (don't replace)
27
- globalRouteMap = { ...globalRouteMap, ...map };
41
+ Object.assign(globalRouteMap, map);
28
42
  }
29
43
 
30
44
  /**
@@ -139,6 +153,7 @@ export function clearAllRouterData(): void {
139
153
  perRouterManifestMap.clear();
140
154
  perRouterTrieMap.clear();
141
155
  perRouterPrecomputedEntriesMap.clear();
156
+ authoritativeTrieRouters.clear();
142
157
  }
143
158
 
144
159
  export function setRouterManifest(
@@ -162,6 +177,23 @@ export function setRouterTrie(
162
177
  perRouterTrieMap.set(routerId, trie);
163
178
  }
164
179
 
180
+ // Routers whose trie came from the COMPLETE build manifest (deserialized via
181
+ // ensureRouterManifest). For these, a trie miss is a real 404 and findMatch
182
+ // skips the regex fallback scan — the only remaining route-count-proportional
183
+ // match path (#664). Dev rebuilds (manifest-init.ts, router-discovery HMR
184
+ // pushes) deliberately never mark authoritative: the dev-only trie-gap warning
185
+ // in find-match.ts depends on the fallback running on misses, and dev route
186
+ // churn (HMR, dev-time routes) makes a stale-trie 404 unacceptable there.
187
+ const authoritativeTrieRouters: Set<string> = new Set();
188
+
189
+ export function markRouterTrieAuthoritative(routerId: string): void {
190
+ authoritativeTrieRouters.add(routerId);
191
+ }
192
+
193
+ export function isRouterTrieAuthoritative(routerId: string): boolean {
194
+ return authoritativeTrieRouters.has(routerId);
195
+ }
196
+
165
197
  export function getRouterTrie(
166
198
  routerId: string,
167
199
  ): import("./build/route-trie.js").TrieNode | undefined {
@@ -204,7 +236,12 @@ export async function ensureRouterManifest(routerId: string): Promise<void> {
204
236
  if (loader) {
205
237
  const mod = await loader();
206
238
  if (mod.manifest) perRouterManifestMap.set(routerId, mod.manifest);
207
- if (mod.trie) perRouterTrieMap.set(routerId, mod.trie);
239
+ if (mod.trie) {
240
+ perRouterTrieMap.set(routerId, mod.trie);
241
+ // A trie serialized into the build manifest comes from complete
242
+ // discovery — misses are authoritative 404s (see find-match.ts).
243
+ markRouterTrieAuthoritative(routerId);
244
+ }
208
245
  if (mod.precomputedEntries)
209
246
  perRouterPrecomputedEntriesMap.set(routerId, mod.precomputedEntries);
210
247
  routerManifestLoaders.delete(routerId);
@@ -1,5 +1,8 @@
1
1
  import { tryTrieMatch } from "./trie-matching.js";
2
- import { getRouterTrie } from "../route-map-builder.js";
2
+ import {
3
+ getRouterTrie,
4
+ isRouterTrieAuthoritative,
5
+ } from "../route-map-builder.js";
3
6
  import {
4
7
  findMatch as findRouteMatch,
5
8
  isLazyEvaluationNeeded,
@@ -166,6 +169,17 @@ export function createFindMatch<TEnv = any>(
166
169
  };
167
170
  return cloneMatchResult(lastFindMatchResult);
168
171
  }
172
+ } else if (isRouterTrieAuthoritative(deps.routerId)) {
173
+ // Authoritative miss (#664): this trie was deserialized from the
174
+ // COMPLETE build manifest, so trailing-slash redirects are already
175
+ // trie-native hits and a miss means no route exists. Skip the regex
176
+ // fallback — the only route-count-proportional match path — and do
177
+ // not evaluate lazy includes for unmatched (bot-probe) traffic.
178
+ // Trie hits that need lazy splicing keep the fallback loop below
179
+ // (trieMatched === true never reaches this branch).
180
+ lastFindMatchPathname = pathname;
181
+ lastFindMatchResult = null;
182
+ return null;
169
183
  }
170
184
  }
171
185
 
@@ -318,10 +318,10 @@ export function observeHandler<C, R>(
318
318
  * sink is configured.
319
319
  *
320
320
  * This is the canonical emitter for SYNCHRONOUS facts that fire inside the
321
- * request's ALS scope (handler errors, timeouts, origin rejections, revalidation
322
- * decisions). A few emitters deliberately stay on the lower-level
323
- * resolveSink + safeEmit because observeEvent's lazy, per-call
324
- * getRouterContext() read does not fit them — keep this the complete list:
321
+ * request's ALS scope (revalidation decisions, cache-lookup decisions). A few
322
+ * emitters deliberately stay on the lower-level resolveSink + safeEmit because
323
+ * observeEvent's lazy, per-call getRouterContext() read does not fit them — keep
324
+ * this the complete list:
325
325
  * - router.ts wrapLoaderPromise (loader.start/end/error) and
326
326
  * segment-resolution/streamed-handler-telemetry.ts (streamed handler.error)
327
327
  * capture the sink + request id EAGERLY and emit from a fire-and-forget
@@ -330,6 +330,11 @@ export function observeHandler<C, R>(
330
330
  * loop (request.start/end/error, cache.decision, ...).
331
331
  * - segment-resolution/helpers.ts emits via a caller-provided report.telemetry
332
332
  * sink rather than the ALS router context.
333
+ * - rsc/handler.ts handleTimeoutResponse (request.timeout), the origin guard
334
+ * (request.origin-rejected), and handleStore.onError (late-handle
335
+ * handler.error) emit via router.telemetry directly — they run outside the
336
+ * RouterContext ALS (only match()/matchPartial() enter it), so a
337
+ * getRouterContext() read there throws and the event would vanish.
333
338
  */
334
339
  export function observeEvent(event: TelemetryEvent): void {
335
340
  // getRouterContext() either throws (real impl, outside a router context — e.g.
@@ -91,7 +91,11 @@ export function evaluateLazyEntry<TEnv = any>(
91
91
  for (const [name, pattern] of Object.entries(routes)) {
92
92
  deps.mergedRouteMap[name] = pattern;
93
93
  }
94
- registerRouteMap(deps.mergedRouteMap);
94
+ // Register only this entry's routes (the delta): the full
95
+ // mergedRouteMap is seeded from the generated manifest at
96
+ // createRouter() time and already registered there — re-passing it
97
+ // made this request-path call O(total routes) (issue #666).
98
+ registerRouteMap(routes);
95
99
  return;
96
100
  }
97
101
  }
@@ -245,7 +249,9 @@ function runExpansion<TEnv = any>(
245
249
  deps.routesEntries.splice(insertIndex, 0, nestedEntry);
246
250
  }
247
251
 
248
- registerRouteMap(deps.mergedRouteMap);
252
+ // Delta only — see the matching comment on the precomputed branch above and
253
+ // the WHY block on registerRouteMap (issue #666).
254
+ registerRouteMap(routesObject);
249
255
 
250
256
  // Expansion fully succeeded (handler ran, routes + nested includes spliced) —
251
257
  // mark done now so a mid-expansion throw above leaves lazyEvaluated=false and
@@ -420,10 +420,22 @@ function createLoaderExecutor<TEnv>(
420
420
  // throw. rendered() gating uses the captured isDslLoader (above), so this
421
421
  // does not grant rendered() to handler-invoked loaders. Uses a body-only
422
422
  // scope, so isInsideLoaderScope() / barrier / deadlock gating is unchanged.
423
+ //
424
+ // `handlerInvoked` (!isDslLoader) rides on the scope for the CONSUMPTION-
425
+ // LANE RULE: a handler-consumed loader's value is a BAKED copy in every
426
+ // shared artifact (cache(), "use cache", the PPR shell), so its identity
427
+ // reads are exempt from the shell-capture guard — same allowance the
428
+ // cache-purity guards give it. DSL segment loaders keep their lane
429
+ // machinery (live = masked at capture, bake = guarded). A DSL loader's
430
+ // nested deps inherit isDslLoader=false only when the CHAIN started in a
431
+ // handler; a chain started by the segment funnel stays DSL (the loader
432
+ // scope ALS survives the body's awaits).
423
433
  const promise = observePhase(PHASES.loader(loader.$$id), () =>
424
434
  Promise.resolve(
425
- runInsideLoaderBodyScope(() =>
426
- loaderFn(loaderCtx as LoaderContext<any, TEnv>),
435
+ runInsideLoaderBodyScope(
436
+ () => loaderFn(loaderCtx as LoaderContext<any, TEnv>),
437
+ loader.$$id,
438
+ !isDslLoader,
427
439
  ),
428
440
  ).finally(() => {
429
441
  pendingLoaders.delete(loader.$$id);