@rangojs/router 0.0.0-experimental.135 → 0.0.0-experimental.136

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.
@@ -2297,7 +2297,7 @@ import { resolve } from "node:path";
2297
2297
  // package.json
2298
2298
  var package_default = {
2299
2299
  name: "@rangojs/router",
2300
- version: "0.0.0-experimental.135",
2300
+ version: "0.0.0-experimental.136",
2301
2301
  description: "Django-inspired RSC router with composable URL patterns",
2302
2302
  keywords: [
2303
2303
  "react",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.135",
3
+ "version": "0.0.0-experimental.136",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -77,13 +77,24 @@ export interface DecodedPrefetch {
77
77
 
78
78
  let cacheTTL = 300_000;
79
79
 
80
+ // Max stored entries before FIFO eviction. Mirrors DEFAULT_PREFETCH_CACHE_SIZE
81
+ // (router/prefetch-limits.ts); kept as a local literal so the client bundle
82
+ // doesn't pull in router-layer code, matching the cacheTTL default above.
83
+ // Overridden at startup by initPrefetchCache from server metadata.
84
+ let maxPrefetchCacheSize = 100;
85
+
80
86
  /**
81
- * Initialize the prefetch cache with the configured TTL.
82
- * Called once at app startup with the value from server metadata.
83
- * A TTL of 0 disables the in-memory cache and all prefetching.
87
+ * Initialize the prefetch cache with the configured TTL and max size.
88
+ * Called once at app startup with the values from server metadata. Each
89
+ * argument is applied only when provided, so a caller can set just the TTL.
90
+ * A TTL of 0 disables the in-memory cache and all prefetching. A size below 1
91
+ * is ignored (the default is kept) — disabling prefetch is the TTL's job.
84
92
  */
85
- export function initPrefetchCache(ttlMs: number): void {
86
- cacheTTL = ttlMs;
93
+ export function initPrefetchCache(ttlMs?: number, maxSize?: number): void {
94
+ if (ttlMs !== undefined) cacheTTL = ttlMs;
95
+ if (maxSize !== undefined && Number.isFinite(maxSize) && maxSize >= 1) {
96
+ maxPrefetchCacheSize = Math.floor(maxSize);
97
+ }
87
98
  }
88
99
 
89
100
  /**
@@ -93,7 +104,6 @@ export function initPrefetchCache(ttlMs: number): void {
93
104
  export function isPrefetchCacheDisabled(): boolean {
94
105
  return cacheTTL <= 0;
95
106
  }
96
- const MAX_PREFETCH_CACHE_SIZE = 50;
97
107
 
98
108
  interface PrefetchCacheEntry {
99
109
  entry: DecodedPrefetch;
@@ -268,7 +278,7 @@ export function storePrefetch(
268
278
  }
269
279
 
270
280
  // FIFO eviction if at capacity
271
- if (cache.size >= MAX_PREFETCH_CACHE_SIZE) {
281
+ if (cache.size >= maxPrefetchCacheSize) {
272
282
  const oldest = cache.keys().next().value;
273
283
  if (oldest) cache.delete(oldest);
274
284
  }
@@ -16,9 +16,24 @@
16
16
 
17
17
  import { wait, waitForIdle, waitForViewportImages } from "./resource-ready.js";
18
18
 
19
- const MAX_CONCURRENT = 2;
19
+ // Max prefetches executing at once. Mirrors DEFAULT_PREFETCH_CONCURRENCY
20
+ // (router/prefetch-limits.ts); kept as a local literal so the client bundle
21
+ // doesn't pull in router-layer code. Overridden at startup by
22
+ // setPrefetchConcurrency from server metadata.
23
+ let maxConcurrent = 2;
20
24
  const IMAGE_WAIT_TIMEOUT = 2000;
21
25
 
26
+ /**
27
+ * Set the max number of concurrently-executing speculative prefetches.
28
+ * Called once at app startup with the value from server metadata. A value
29
+ * below 1 (or non-finite) is ignored, keeping the default.
30
+ */
31
+ export function setPrefetchConcurrency(n: number): void {
32
+ if (Number.isFinite(n) && n >= 1) {
33
+ maxConcurrent = Math.floor(n);
34
+ }
35
+ }
36
+
22
37
  let active = 0;
23
38
  const queue: Array<{
24
39
  key: string;
@@ -42,7 +57,7 @@ function startExecution(
42
57
  abortControllers.delete(key);
43
58
  // Only decrement if this key wasn't already cleared by cancelAllPrefetches.
44
59
  // Without this guard, cancelled tasks' .finally() would underflow active
45
- // below zero, breaking the MAX_CONCURRENT guarantee.
60
+ // below zero, breaking the maxConcurrent guarantee.
46
61
  if (executing.delete(key)) {
47
62
  active--;
48
63
  }
@@ -63,7 +78,7 @@ function startExecution(
63
78
  */
64
79
  function scheduleDrain(): void {
65
80
  if (drainScheduled) return;
66
- if (active >= MAX_CONCURRENT || queue.length === 0) return;
81
+ if (active >= maxConcurrent || queue.length === 0) return;
67
82
  drainScheduled = true;
68
83
  const gen = drainGeneration;
69
84
  waitForIdle()
@@ -83,7 +98,7 @@ function scheduleDrain(): void {
83
98
  }
84
99
 
85
100
  function drain(): void {
86
- while (active < MAX_CONCURRENT && queue.length > 0) {
101
+ while (active < maxConcurrent && queue.length > 0) {
87
102
  const item = queue.shift()!;
88
103
  queued.delete(item.key);
89
104
  startExecution(item.key, item.execute);
@@ -25,6 +25,7 @@ import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
25
25
  import { initRangoState } from "./rango-state.js";
26
26
  import { registerNavigationStore } from "./navigation-store-handle.js";
27
27
  import { initPrefetchCache } from "./prefetch/cache.js";
28
+ import { setPrefetchConcurrency } from "./prefetch/queue.js";
28
29
  import { setPrefetchDecoder } from "./prefetch/fetch.js";
29
30
  import { setAppVersion } from "./app-version.js";
30
31
  import {
@@ -245,11 +246,17 @@ export async function initBrowserApp(
245
246
  initRangoState(version ?? "0", initialPayload.metadata?.stateCookieName);
246
247
  setAppVersion(version);
247
248
 
248
- // Initialize the in-memory prefetch cache TTL from server config.
249
- // A value of 0 disables the cache; undefined falls back to the module default.
249
+ // Initialize the in-memory prefetch cache (TTL + max size) and the prefetch
250
+ // queue concurrency from server config. A TTL of 0 disables the cache;
251
+ // undefined values fall back to the module defaults.
250
252
  const prefetchCacheTTL = initialPayload.metadata?.prefetchCacheTTL;
251
- if (prefetchCacheTTL !== undefined) {
252
- initPrefetchCache(prefetchCacheTTL);
253
+ const prefetchCacheSize = initialPayload.metadata?.prefetchCacheSize;
254
+ if (prefetchCacheTTL !== undefined || prefetchCacheSize !== undefined) {
255
+ initPrefetchCache(prefetchCacheTTL, prefetchCacheSize);
256
+ }
257
+ const prefetchConcurrency = initialPayload.metadata?.prefetchConcurrency;
258
+ if (prefetchConcurrency !== undefined) {
259
+ setPrefetchConcurrency(prefetchConcurrency);
253
260
  }
254
261
 
255
262
  // Wire the RSC decoder so prefetches decode eagerly and warm the route's
@@ -70,6 +70,16 @@ export interface RscMetadata {
70
70
  * Sent on initial render so the browser can configure its cache duration.
71
71
  */
72
72
  prefetchCacheTTL?: number;
73
+ /**
74
+ * Max entries in the client-side in-memory prefetch cache (FIFO eviction).
75
+ * Sent on initial render so the browser can configure its cache capacity.
76
+ */
77
+ prefetchCacheSize?: number;
78
+ /**
79
+ * Max concurrent speculative prefetch requests on the client.
80
+ * Sent on initial render so the browser can configure its prefetch queue.
81
+ */
82
+ prefetchConcurrency?: number;
73
83
  /**
74
84
  * Server-resolved rango state cookie name (`{prefix}_{routerId}`). The client
75
85
  * reads it verbatim and binds the rango state cookie to it; composition
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Resolve the client-side prefetch limits — the in-memory prefetch cache size
3
+ * and the speculative-prefetch queue concurrency — once, at router init. Both
4
+ * are positive-integer counts shipped to the browser in payload metadata; the
5
+ * browser entry feeds them to the prefetch cache (`initPrefetchCache`) and the
6
+ * prefetch queue (`setPrefetchConcurrency`) at startup.
7
+ *
8
+ * Policy note: this is a SEPARATE policy from resolvePrefetchCacheTTL
9
+ * (prefetch-cache-ttl.ts). These are counts, not durations, so the disable
10
+ * paths differ: a TTL of `false`/0 intentionally turns prefetching off, but a
11
+ * cache size or concurrency of 0 would silently break prefetching while leaving
12
+ * it nominally enabled — never the intent of passing a number. So any value
13
+ * that is not a finite integer >= 1 (0, negative, fractional below 1, NaN,
14
+ * Infinity, undefined) falls back to the default. Finite values >= 1 are
15
+ * floored. Do not unify this guard with the TTL guard.
16
+ */
17
+
18
+ export const DEFAULT_PREFETCH_CACHE_SIZE = 100;
19
+ export const DEFAULT_PREFETCH_CONCURRENCY = 2;
20
+
21
+ export function resolvePrefetchCacheSize(raw: number | undefined): number {
22
+ return resolvePositiveCount(raw, DEFAULT_PREFETCH_CACHE_SIZE);
23
+ }
24
+
25
+ export function resolvePrefetchConcurrency(raw: number | undefined): number {
26
+ return resolvePositiveCount(raw, DEFAULT_PREFETCH_CONCURRENCY);
27
+ }
28
+
29
+ function resolvePositiveCount(
30
+ raw: number | undefined,
31
+ fallback: number,
32
+ ): number {
33
+ if (typeof raw === "number" && Number.isFinite(raw) && raw >= 1) {
34
+ return Math.floor(raw);
35
+ }
36
+ return fallback;
37
+ }
@@ -301,6 +301,20 @@ export interface RangoInternal<
301
301
  */
302
302
  readonly prefetchCacheTTL: number;
303
303
 
304
+ /**
305
+ * Maximum number of decoded prefetch payloads the client keeps in its
306
+ * in-memory prefetch cache (FIFO eviction at capacity). Shipped to the
307
+ * client in payload metadata. Derived from prefetchCacheSize.
308
+ */
309
+ readonly prefetchCacheSize: number;
310
+
311
+ /**
312
+ * Maximum number of speculative prefetch requests the client runs
313
+ * concurrently. Shipped to the client in payload metadata. Derived from
314
+ * prefetchConcurrency.
315
+ */
316
+ readonly prefetchConcurrency: number;
317
+
304
318
  /**
305
319
  * Resolved rango state cookie name (`{prefix}_{routerId}`), composed once at
306
320
  * router init and shipped to the client in payload metadata. The server-side
@@ -497,6 +497,36 @@ export interface RangoOptions<TEnv = any> {
497
497
  */
498
498
  prefetchCacheTTL?: number | false;
499
499
 
500
+ /**
501
+ * Maximum number of decoded prefetch payloads the client keeps in its
502
+ * in-memory prefetch cache. When the cache is full the oldest entry is
503
+ * evicted (FIFO) to make room for a new prefetch.
504
+ *
505
+ * Each entry retains a fully decoded RSC payload (and the route's client
506
+ * chunks pulled in while decoding), so this is the lever on client-side
507
+ * prefetch memory: a higher value warms more routes at the cost of more
508
+ * retained payloads. Staleness is bounded separately by `prefetchCacheTTL`;
509
+ * this bounds the entry COUNT.
510
+ *
511
+ * Values below 1 (or non-finite) fall back to the default. To turn
512
+ * prefetching off entirely, set `prefetchCacheTTL: false` instead.
513
+ *
514
+ * @default 100
515
+ */
516
+ prefetchCacheSize?: number;
517
+
518
+ /**
519
+ * Maximum number of speculative prefetch requests (viewport/render strategy)
520
+ * the client runs concurrently. Hover prefetches bypass this queue and fire
521
+ * immediately; this caps only the background, idle-gated queue so prefetches
522
+ * never saturate the browser's connection pool.
523
+ *
524
+ * Values below 1 (or non-finite) fall back to the default.
525
+ *
526
+ * @default 2
527
+ */
528
+ prefetchConcurrency?: number;
529
+
500
530
  /**
501
531
  * Prefix for the rango state cookie name. The resolved name is
502
532
  * `{prefix}_{routerId}`; the prefix is sanitized to cookie-name-safe
package/src/router.ts CHANGED
@@ -111,6 +111,10 @@ import {
111
111
  } from "./router/prerender-match.js";
112
112
  import { resolveStateCookieName } from "./router/state-cookie-name.js";
113
113
  import { resolvePrefetchCacheTTL } from "./router/prefetch-cache-ttl.js";
114
+ import {
115
+ resolvePrefetchCacheSize,
116
+ resolvePrefetchConcurrency,
117
+ } from "./router/prefetch-limits.js";
114
118
 
115
119
  // Re-export public types and values from extracted modules
116
120
  export { RSC_ROUTER_BRAND, RouterRegistry } from "./router/router-registry.js";
@@ -150,6 +154,8 @@ export function createRouter<TEnv = any>(
150
154
  nonce,
151
155
  version,
152
156
  prefetchCacheTTL: prefetchCacheTTLOption,
157
+ prefetchCacheSize: prefetchCacheSizeOption,
158
+ prefetchConcurrency: prefetchConcurrencyOption,
153
159
  stateCookiePrefix: stateCookiePrefixOption,
154
160
  warmup: warmupOption,
155
161
  allowDebugManifest: allowDebugManifestOption = false,
@@ -242,6 +248,14 @@ export function createRouter<TEnv = any>(
242
248
  const prefetchCacheControl: string | false =
243
249
  resolvedPrefetchCacheTTL.cacheControl;
244
250
 
251
+ // Resolve client-side prefetch limits (in-memory cache size and queue
252
+ // concurrency). Both are positive-integer counts; sub-1/non-finite inputs
253
+ // fall back to the defaults. Shipped to the client in payload metadata.
254
+ const prefetchCacheSize = resolvePrefetchCacheSize(prefetchCacheSizeOption);
255
+ const prefetchConcurrency = resolvePrefetchConcurrency(
256
+ prefetchConcurrencyOption,
257
+ );
258
+
245
259
  // Resolve warmup enabled flag (default: true)
246
260
  const warmupEnabled = warmupOption !== false;
247
261
 
@@ -968,6 +982,8 @@ export function createRouter<TEnv = any>(
968
982
  // Expose prefetch cache settings
969
983
  prefetchCacheControl,
970
984
  prefetchCacheTTL,
985
+ prefetchCacheSize,
986
+ prefetchConcurrency,
971
987
 
972
988
  // Expose the resolved rango state cookie name for the server-side writer
973
989
  // (invalidateClientCache) and for shipping to the client in metadata.
@@ -1134,6 +1134,8 @@ export function createRSCHandler<
1134
1134
  handles: handleStore.stream(),
1135
1135
  version,
1136
1136
  prefetchCacheTTL: router.prefetchCacheTTL,
1137
+ prefetchCacheSize: router.prefetchCacheSize,
1138
+ prefetchConcurrency: router.prefetchConcurrency,
1137
1139
  stateCookieName: router.resolvedStateCookieName,
1138
1140
  themeConfig: router.themeConfig,
1139
1141
  warmupEnabled: router.warmupEnabled,
@@ -81,6 +81,8 @@ async function handleRscRenderingInner<TEnv>(
81
81
  handles: handleStore.stream(),
82
82
  version: ctx.version,
83
83
  prefetchCacheTTL: ctx.router.prefetchCacheTTL,
84
+ prefetchCacheSize: ctx.router.prefetchCacheSize,
85
+ prefetchConcurrency: ctx.router.prefetchConcurrency,
84
86
  stateCookieName: ctx.router.resolvedStateCookieName,
85
87
  themeConfig: ctx.router.themeConfig,
86
88
  // Carry warmupEnabled on the initial full-render payload so the client
@@ -141,6 +143,8 @@ async function handleRscRenderingInner<TEnv>(
141
143
  handles: handleStore.stream(),
142
144
  version: ctx.version,
143
145
  prefetchCacheTTL: ctx.router.prefetchCacheTTL,
146
+ prefetchCacheSize: ctx.router.prefetchCacheSize,
147
+ prefetchConcurrency: ctx.router.prefetchConcurrency,
144
148
  stateCookieName: ctx.router.resolvedStateCookieName,
145
149
  },
146
150
  };
package/src/rsc/types.ts CHANGED
@@ -43,6 +43,10 @@ export interface RscPayload {
43
43
  version?: string;
44
44
  /** TTL in milliseconds for the client-side in-memory prefetch cache */
45
45
  prefetchCacheTTL?: number;
46
+ /** Max entries in the client-side in-memory prefetch cache (FIFO eviction) */
47
+ prefetchCacheSize?: number;
48
+ /** Max concurrent speculative prefetch requests on the client */
49
+ prefetchConcurrency?: number;
46
50
  /** Server-resolved rango state cookie name; the client reads it verbatim. */
47
51
  stateCookieName?: string;
48
52
  /** Theme configuration for FOUC prevention */