@reckona/mreact-router 0.0.202 → 0.0.204

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 (152) hide show
  1. package/README.md +58 -11
  2. package/dist/actions.d.ts +8 -3
  3. package/dist/actions.d.ts.map +1 -1
  4. package/dist/actions.js +125 -45
  5. package/dist/actions.js.map +1 -1
  6. package/dist/adapters/aws-lambda.d.ts +15 -4
  7. package/dist/adapters/aws-lambda.d.ts.map +1 -1
  8. package/dist/adapters/aws-lambda.js +301 -63
  9. package/dist/adapters/aws-lambda.js.map +1 -1
  10. package/dist/adapters/cloudflare.d.ts +5 -2
  11. package/dist/adapters/cloudflare.d.ts.map +1 -1
  12. package/dist/adapters/cloudflare.js +35 -11
  13. package/dist/adapters/cloudflare.js.map +1 -1
  14. package/dist/adapters/edge.d.ts +1 -1
  15. package/dist/adapters/edge.d.ts.map +1 -1
  16. package/dist/adapters/edge.js.map +1 -1
  17. package/dist/adapters/node.d.ts +5 -3
  18. package/dist/adapters/node.d.ts.map +1 -1
  19. package/dist/adapters/node.js +2 -1
  20. package/dist/adapters/node.js.map +1 -1
  21. package/dist/adapters/static.d.ts.map +1 -1
  22. package/dist/adapters/static.js +24 -2
  23. package/dist/adapters/static.js.map +1 -1
  24. package/dist/build.d.ts +4 -1
  25. package/dist/build.d.ts.map +1 -1
  26. package/dist/build.js +185 -13
  27. package/dist/build.js.map +1 -1
  28. package/dist/built-runtime.d.ts +2 -0
  29. package/dist/built-runtime.d.ts.map +1 -1
  30. package/dist/built-runtime.js +16 -0
  31. package/dist/built-runtime.js.map +1 -1
  32. package/dist/cache.d.ts +4 -2
  33. package/dist/cache.d.ts.map +1 -1
  34. package/dist/cache.js +216 -17
  35. package/dist/cache.js.map +1 -1
  36. package/dist/cli-options.d.ts +3 -0
  37. package/dist/cli-options.d.ts.map +1 -1
  38. package/dist/cli-options.js +10 -2
  39. package/dist/cli-options.js.map +1 -1
  40. package/dist/cli.js +14 -3
  41. package/dist/cli.js.map +1 -1
  42. package/dist/client.d.ts +5 -0
  43. package/dist/client.d.ts.map +1 -1
  44. package/dist/client.js +43 -3
  45. package/dist/client.js.map +1 -1
  46. package/dist/config.d.ts +3 -0
  47. package/dist/config.d.ts.map +1 -1
  48. package/dist/config.js +21 -2
  49. package/dist/config.js.map +1 -1
  50. package/dist/csp.d.ts +1 -0
  51. package/dist/csp.d.ts.map +1 -1
  52. package/dist/csp.js +9 -0
  53. package/dist/csp.js.map +1 -1
  54. package/dist/dehydrate-policy.d.ts +3 -0
  55. package/dist/dehydrate-policy.d.ts.map +1 -0
  56. package/dist/dehydrate-policy.js +16 -0
  57. package/dist/dehydrate-policy.js.map +1 -0
  58. package/dist/dev-server.d.ts +7 -2
  59. package/dist/dev-server.d.ts.map +1 -1
  60. package/dist/dev-server.js +120 -11
  61. package/dist/dev-server.js.map +1 -1
  62. package/dist/http.d.ts +1 -1
  63. package/dist/http.d.ts.map +1 -1
  64. package/dist/http.js +147 -7
  65. package/dist/http.js.map +1 -1
  66. package/dist/index.d.ts +4 -3
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +1 -0
  69. package/dist/index.js.map +1 -1
  70. package/dist/logger.d.ts +22 -1
  71. package/dist/logger.d.ts.map +1 -1
  72. package/dist/logger.js +22 -8
  73. package/dist/logger.js.map +1 -1
  74. package/dist/metadata.d.ts +8 -0
  75. package/dist/metadata.d.ts.map +1 -1
  76. package/dist/metadata.js +60 -22
  77. package/dist/metadata.js.map +1 -1
  78. package/dist/navigation-marker.d.ts +2 -0
  79. package/dist/navigation-marker.d.ts.map +1 -0
  80. package/dist/navigation-marker.js +357 -0
  81. package/dist/navigation-marker.js.map +1 -0
  82. package/dist/navigation-runtime.d.ts +1 -1
  83. package/dist/navigation-runtime.d.ts.map +1 -1
  84. package/dist/navigation-runtime.js +1 -1
  85. package/dist/navigation-runtime.js.map +1 -1
  86. package/dist/navigation.d.ts +1 -0
  87. package/dist/navigation.d.ts.map +1 -1
  88. package/dist/navigation.js +1 -1
  89. package/dist/navigation.js.map +1 -1
  90. package/dist/node-server.d.ts +5 -2
  91. package/dist/node-server.d.ts.map +1 -1
  92. package/dist/node-server.js +75 -6
  93. package/dist/node-server.js.map +1 -1
  94. package/dist/prerender-entry.d.ts +17 -1
  95. package/dist/prerender-entry.d.ts.map +1 -1
  96. package/dist/prerender-entry.js +98 -3
  97. package/dist/prerender-entry.js.map +1 -1
  98. package/dist/render.d.ts +16 -2
  99. package/dist/render.d.ts.map +1 -1
  100. package/dist/render.js +142 -135
  101. package/dist/render.js.map +1 -1
  102. package/dist/serve.d.ts +11 -2
  103. package/dist/serve.d.ts.map +1 -1
  104. package/dist/serve.js +67 -41
  105. package/dist/serve.js.map +1 -1
  106. package/dist/session.d.ts.map +1 -1
  107. package/dist/session.js +52 -19
  108. package/dist/session.js.map +1 -1
  109. package/dist/upgrade.d.ts +59 -2
  110. package/dist/upgrade.d.ts.map +1 -1
  111. package/dist/upgrade.js +282 -1
  112. package/dist/upgrade.js.map +1 -1
  113. package/dist/vite-config.d.ts +2 -0
  114. package/dist/vite-config.d.ts.map +1 -1
  115. package/dist/vite-config.js +2 -0
  116. package/dist/vite-config.js.map +1 -1
  117. package/dist/vite.d.ts +4 -1
  118. package/dist/vite.d.ts.map +1 -1
  119. package/dist/vite.js +117 -6
  120. package/dist/vite.js.map +1 -1
  121. package/package.json +11 -11
  122. package/src/actions.ts +146 -60
  123. package/src/adapters/aws-lambda.ts +426 -115
  124. package/src/adapters/cloudflare.ts +58 -12
  125. package/src/adapters/edge.ts +2 -0
  126. package/src/adapters/node.ts +8 -10
  127. package/src/adapters/static.ts +31 -2
  128. package/src/build.ts +217 -13
  129. package/src/built-runtime.ts +28 -0
  130. package/src/cache.ts +272 -19
  131. package/src/cli-options.ts +15 -2
  132. package/src/cli.ts +16 -2
  133. package/src/client.ts +47 -3
  134. package/src/config.ts +43 -6
  135. package/src/csp.ts +11 -0
  136. package/src/dehydrate-policy.ts +23 -0
  137. package/src/dev-server.ts +188 -15
  138. package/src/http.ts +172 -6
  139. package/src/index.ts +14 -1
  140. package/src/logger.ts +47 -10
  141. package/src/metadata.ts +125 -60
  142. package/src/navigation-marker.ts +423 -0
  143. package/src/navigation-runtime.ts +1 -0
  144. package/src/navigation.ts +1 -1
  145. package/src/node-server.ts +128 -22
  146. package/src/prerender-entry.ts +127 -5
  147. package/src/render.ts +859 -749
  148. package/src/serve.ts +112 -82
  149. package/src/session.ts +56 -25
  150. package/src/upgrade.ts +402 -3
  151. package/src/vite-config.ts +6 -0
  152. package/src/vite.ts +157 -5
package/src/cache.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { responseHeadersContainCspNonce } from "./csp.js";
2
3
 
3
4
  /**
4
5
  * Describes the cache lifetime assigned to a rendered route response.
@@ -73,7 +74,6 @@ export interface MemoryRouteCacheOptions {
73
74
 
74
75
  interface AppRouterCacheState {
75
76
  activeContexts: RouteCacheContext[];
76
- invalidatedPaths: Set<string>;
77
77
  memoryCache: AppRouterCache;
78
78
  storage: AsyncLocalStorage<RouteCacheContext>;
79
79
  }
@@ -82,18 +82,42 @@ interface RouteCacheContext {
82
82
  cache: AppRouterCache;
83
83
  cachePolicy?: RouteCachePolicy | undefined;
84
84
  closed?: boolean | undefined;
85
+ externalInvalidationOverflowed: boolean;
86
+ externallyInvalidatedPaths: Set<string>;
85
87
  revalidatedPaths: Set<string>;
86
88
  }
87
89
 
90
+ interface AppRouterCacheInvalidationState {
91
+ activeContexts: WeakMap<AppRouterCache, Set<RouteCacheContext>>;
92
+ disabledCaches: WeakSet<AppRouterCache>;
93
+ finalizer: FinalizationRegistry<WeakRef<AppRouterCache>>;
94
+ liveReferences: Set<WeakRef<AppRouterCache>>;
95
+ pendingPaths: WeakMap<AppRouterCache, Set<string>>;
96
+ registeredCaches: WeakSet<AppRouterCache>;
97
+ running: WeakMap<AppRouterCache, Promise<void>>;
98
+ storesBeingInvalidated: WeakMap<AppRouterCache, number>;
99
+ }
100
+
101
+ const MAX_PENDING_ROUTE_INVALIDATIONS = 1024;
102
+
103
+ const cacheInvalidationState = ((
104
+ globalThis as {
105
+ __mreactAppRouterCacheInvalidation?: AppRouterCacheInvalidationState;
106
+ }
107
+ ).__mreactAppRouterCacheInvalidation ??= createCacheInvalidationState());
108
+ cacheInvalidationState.activeContexts ??= new WeakMap();
109
+ cacheInvalidationState.disabledCaches ??= new WeakSet();
110
+ cacheInvalidationState.storesBeingInvalidated ??= new WeakMap();
111
+
88
112
  const cacheState = ((
89
113
  globalThis as { __mreactAppRouterCache?: AppRouterCacheState }
90
114
  ).__mreactAppRouterCache ??= {
91
115
  activeContexts: [],
92
- invalidatedPaths: new Set(),
93
116
  memoryCache: createMemoryRouteCache(),
94
117
  storage: new AsyncLocalStorage<RouteCacheContext>(),
95
118
  });
96
119
  cacheState.storage ??= new AsyncLocalStorage<RouteCacheContext>();
120
+ registerRouteCache(cacheState.memoryCache);
97
121
 
98
122
  /**
99
123
  * Creates an in-memory route response cache for app-router rendering.
@@ -164,7 +188,7 @@ export function createMemoryRouteCache(options: MemoryRouteCacheOptions = {}): A
164
188
  }
165
189
  }
166
190
 
167
- return {
191
+ const cache: AppRouterCache = {
168
192
  deleteByPath(path) {
169
193
  const normalizedPath = normalizeRevalidationPath(path);
170
194
  const keys = keysByPath.get(normalizedPath);
@@ -212,6 +236,8 @@ export function createMemoryRouteCache(options: MemoryRouteCacheOptions = {}): A
212
236
  }
213
237
  },
214
238
  };
239
+ registerRouteCache(cache);
240
+ return cache;
215
241
  }
216
242
 
217
243
  function positiveIntegerOrDefault(value: number | undefined, fallback: number): number {
@@ -296,6 +322,10 @@ export function cachedRouteResponse(options: {
296
322
 
297
323
  const cache = options.cache ?? cacheState.memoryCache;
298
324
 
325
+ registerRouteCache(cache);
326
+ if (routeCacheIsUnavailable(cache)) {
327
+ return undefined;
328
+ }
299
329
  await consumeInvalidations(cache);
300
330
  const now = options.now ?? Date.now();
301
331
  const cached = await cache.get(options.key, now);
@@ -310,6 +340,9 @@ export function cachedRouteResponse(options: {
310
340
  }
311
341
 
312
342
  const headers = new Headers(cached.headers ?? {});
343
+ if (responseHeadersContainCspNonce(headers)) {
344
+ return undefined;
345
+ }
313
346
  headers.set("cache-control", cached.cacheControl);
314
347
  if (!headers.has("content-type")) {
315
348
  headers.set("content-type", "text/html; charset=utf-8");
@@ -367,6 +400,10 @@ export async function cacheRouteResponse(options: {
367
400
  const cacheControl = options.policy.cacheControl;
368
401
  const status = options.response.status;
369
402
  const contentType = options.response.headers.get("content-type") ?? "text/html; charset=utf-8";
403
+ const cache = options.cache ?? cacheState.memoryCache;
404
+ const normalizedPath = normalizeRevalidationPath(options.path);
405
+ registerRouteCache(cache);
406
+ const invalidatedBeforeStore = routeCachePathWasInvalidatedDuringActiveContext(normalizedPath);
370
407
 
371
408
  // A render that read a request header only produced HTML that is correct for
372
409
  // that header value. The cache key covers the path and query alone, so the
@@ -374,15 +411,23 @@ export async function cacheRouteResponse(options: {
374
411
  // shared caches too, which are keyed the same way.
375
412
  if (
376
413
  options.headerDependent === true ||
414
+ invalidatedBeforeStore ||
415
+ routeCacheIsUnavailable(cache) ||
377
416
  requestCarriesCredentials(options.request) ||
378
- options.response.headers.has("set-cookie")
417
+ options.response.headers.has("set-cookie") ||
418
+ responseHeadersContainCspNonce(options.response.headers)
379
419
  ) {
380
420
  const headers = new Headers(options.response.headers);
381
421
  headers.set("cache-control", "private, no-store");
382
422
  if (!headers.has("content-type")) {
383
423
  headers.set("content-type", contentType);
384
424
  }
385
- if (options.headerDependent === true) {
425
+ if (
426
+ options.headerDependent === true ||
427
+ invalidatedBeforeStore ||
428
+ routeCacheIsUnavailable(cache) ||
429
+ responseHeadersContainCspNonce(options.response.headers)
430
+ ) {
386
431
  headers.set("x-mreact-cache", "DYNAMIC");
387
432
  }
388
433
 
@@ -410,17 +455,57 @@ export async function cacheRouteResponse(options: {
410
455
  // from every later secure visitor.
411
456
  const hsts = options.strictTransportSecurity;
412
457
 
413
- await (options.cache ?? cacheState.memoryCache).set(options.key, {
458
+ await cache.set(options.key, {
414
459
  body,
415
460
  cacheControl,
416
461
  expiresAt: (options.now ?? Date.now()) + options.policy.revalidateSeconds * 1000,
417
462
  headers: storedHeaders,
418
463
  ...(hsts === undefined ? {} : { strictTransportSecurity: hsts }),
419
- path: normalizeRevalidationPath(options.path),
464
+ path: normalizedPath,
420
465
  schemaVersion: ROUTE_CACHE_ENTRY_SCHEMA_VERSION,
421
466
  status,
422
467
  });
423
468
 
469
+ if (
470
+ routeCachePathWasInvalidatedDuringActiveContext(normalizedPath) ||
471
+ routeCacheIsUnavailable(cache)
472
+ ) {
473
+ beginRouteCacheStoreInvalidation(cache);
474
+ try {
475
+ await cache.deleteByPath(normalizedPath);
476
+ endRouteCacheStoreInvalidation(cache);
477
+ } catch (error) {
478
+ // The invalidation may have completed before this asynchronous set did.
479
+ // If deleting the late write now fails, replace its exact key with an
480
+ // expired entry so another adapter object backed by the same persistent
481
+ // store cannot replay the stale current-schema value.
482
+ try {
483
+ await cache.set(options.key, {
484
+ body: "",
485
+ cacheControl: "no-store",
486
+ expiresAt: 0,
487
+ headers: {},
488
+ path: normalizedPath,
489
+ schemaVersion: ROUTE_CACHE_ENTRY_SCHEMA_VERSION,
490
+ status: 500,
491
+ });
492
+ } catch {
493
+ // The originating cache object still fails closed below even when the
494
+ // persistent backend rejects both deletion and the tombstone write.
495
+ }
496
+ cacheInvalidationState.disabledCaches.add(cache);
497
+ endRouteCacheStoreInvalidation(cache);
498
+ throw error;
499
+ }
500
+ const headers = new Headers(storedHeaders);
501
+ headers.set("cache-control", "private, no-store");
502
+ headers.set("x-mreact-cache", "DYNAMIC");
503
+ if (!headers.has("content-type")) {
504
+ headers.set("content-type", contentType);
505
+ }
506
+ return new Response(body, { headers, status });
507
+ }
508
+
424
509
  const headers = new Headers(storedHeaders);
425
510
  headers.set("cache-control", cacheControl);
426
511
  if (!headers.has("content-type")) {
@@ -541,7 +626,7 @@ const PUBLIC_ROUTE_CACHE_REQUEST_HEADERS = new Set([
541
626
  /**
542
627
  * Invalidates cached route responses for a normalized app-router path.
543
628
  *
544
- * During a request the invalidation is scoped to that route cache context; outside a request it is queued for the next cache access.
629
+ * During a request the invalidation is scoped to that route cache context. Outside a request it is broadcast to every live route cache registered in this process, and later reads wait for their cache's invalidation to finish.
545
630
  */
546
631
  export function revalidatePath(path: string): void {
547
632
  const normalizedPath = normalizeRevalidationPath(path);
@@ -552,31 +637,186 @@ export function revalidatePath(path: string): void {
552
637
  return;
553
638
  }
554
639
 
555
- cacheState.invalidatedPaths.add(normalizedPath);
640
+ for (const reference of cacheInvalidationState.liveReferences) {
641
+ const cache = reference.deref();
642
+ if (cache === undefined) {
643
+ cacheInvalidationState.liveReferences.delete(reference);
644
+ continue;
645
+ }
646
+
647
+ const pendingPaths = cacheInvalidationState.pendingPaths.get(cache);
648
+ if (
649
+ pendingPaths !== undefined &&
650
+ !cacheInvalidationState.disabledCaches.has(cache) &&
651
+ !pendingPaths.has(normalizedPath)
652
+ ) {
653
+ if (pendingPaths.size >= MAX_PENDING_ROUTE_INVALIDATIONS) {
654
+ pendingPaths.clear();
655
+ cacheInvalidationState.disabledCaches.add(cache);
656
+ } else {
657
+ pendingPaths.add(normalizedPath);
658
+ }
659
+ }
660
+ markActiveRouteCacheContextsInvalidated(cache, normalizedPath);
661
+ void startPendingInvalidations(cache).catch(() => {});
662
+ }
556
663
  }
557
664
 
558
665
  export async function consumeInvalidations(
559
666
  cache: AppRouterCache = cacheState.memoryCache,
560
667
  ): Promise<void> {
561
- if (cacheState.invalidatedPaths.size === 0) {
668
+ registerRouteCache(cache);
669
+ await startPendingInvalidations(cache);
670
+ }
671
+
672
+ function registerRouteCache(cache: AppRouterCache): void {
673
+ if (cacheInvalidationState.registeredCaches.has(cache)) {
562
674
  return;
563
675
  }
564
676
 
565
- for (const path of cacheState.invalidatedPaths) {
566
- await cache.deleteByPath(path);
677
+ cacheInvalidationState.registeredCaches.add(cache);
678
+ cacheInvalidationState.pendingPaths.set(cache, new Set());
679
+ const reference = new WeakRef(cache);
680
+ cacheInvalidationState.liveReferences.add(reference);
681
+ cacheInvalidationState.finalizer.register(cache, reference, reference);
682
+ }
683
+
684
+ function startPendingInvalidations(cache: AppRouterCache): Promise<void> {
685
+ const running = cacheInvalidationState.running.get(cache);
686
+ if (running !== undefined) {
687
+ return running;
567
688
  }
568
689
 
569
- cacheState.invalidatedPaths.clear();
690
+ const paths = cacheInvalidationState.pendingPaths.get(cache);
691
+ if (paths === undefined || paths.size === 0) {
692
+ return Promise.resolve();
693
+ }
694
+
695
+ const task = drainPendingInvalidations(cache, paths);
696
+ cacheInvalidationState.running.set(cache, task);
697
+ void task
698
+ .finally(() => {
699
+ if (cacheInvalidationState.running.get(cache) === task) {
700
+ cacheInvalidationState.running.delete(cache);
701
+ }
702
+ })
703
+ .catch(() => {});
704
+ return task;
705
+ }
706
+
707
+ function createCacheInvalidationState(): AppRouterCacheInvalidationState {
708
+ const liveReferences = new Set<WeakRef<AppRouterCache>>();
709
+ return {
710
+ activeContexts: new WeakMap(),
711
+ disabledCaches: new WeakSet(),
712
+ finalizer: new FinalizationRegistry((reference) => {
713
+ liveReferences.delete(reference);
714
+ }),
715
+ liveReferences,
716
+ pendingPaths: new WeakMap(),
717
+ registeredCaches: new WeakSet(),
718
+ running: new WeakMap(),
719
+ storesBeingInvalidated: new WeakMap(),
720
+ };
721
+ }
722
+
723
+ function routeCacheIsUnavailable(cache: AppRouterCache): boolean {
724
+ return (
725
+ cacheInvalidationState.disabledCaches.has(cache) ||
726
+ (cacheInvalidationState.storesBeingInvalidated.get(cache) ?? 0) > 0
727
+ );
728
+ }
729
+
730
+ function beginRouteCacheStoreInvalidation(cache: AppRouterCache): void {
731
+ cacheInvalidationState.storesBeingInvalidated.set(
732
+ cache,
733
+ (cacheInvalidationState.storesBeingInvalidated.get(cache) ?? 0) + 1,
734
+ );
735
+ }
736
+
737
+ function endRouteCacheStoreInvalidation(cache: AppRouterCache): void {
738
+ const remaining = (cacheInvalidationState.storesBeingInvalidated.get(cache) ?? 1) - 1;
739
+ if (remaining <= 0) {
740
+ cacheInvalidationState.storesBeingInvalidated.delete(cache);
741
+ } else {
742
+ cacheInvalidationState.storesBeingInvalidated.set(cache, remaining);
743
+ }
744
+ }
745
+
746
+ function routeCachePathWasInvalidatedDuringActiveContext(path: string): boolean {
747
+ const context = activeRouteCacheContext();
748
+ return (
749
+ context !== undefined &&
750
+ (context.externalInvalidationOverflowed || context.externallyInvalidatedPaths.has(path))
751
+ );
752
+ }
753
+
754
+ function markActiveRouteCacheContextsInvalidated(
755
+ cache: AppRouterCache,
756
+ normalizedPath: string,
757
+ ): void {
758
+ for (const context of cacheInvalidationState.activeContexts.get(cache) ?? []) {
759
+ if (cacheInvalidationState.disabledCaches.has(cache)) {
760
+ context.externalInvalidationOverflowed = true;
761
+ context.externallyInvalidatedPaths.clear();
762
+ } else if (
763
+ !context.externallyInvalidatedPaths.has(normalizedPath) &&
764
+ context.externallyInvalidatedPaths.size >= MAX_PENDING_ROUTE_INVALIDATIONS
765
+ ) {
766
+ context.externalInvalidationOverflowed = true;
767
+ context.externallyInvalidatedPaths.clear();
768
+ } else {
769
+ context.externallyInvalidatedPaths.add(normalizedPath);
770
+ }
771
+ }
772
+ }
773
+
774
+ function registerActiveRouteCacheContext(context: RouteCacheContext): void {
775
+ const contexts = cacheInvalidationState.activeContexts.get(context.cache);
776
+ if (contexts === undefined) {
777
+ cacheInvalidationState.activeContexts.set(context.cache, new Set([context]));
778
+ return;
779
+ }
780
+
781
+ contexts.add(context);
782
+ }
783
+
784
+ function unregisterActiveRouteCacheContext(context: RouteCacheContext): void {
785
+ const contexts = cacheInvalidationState.activeContexts.get(context.cache);
786
+ contexts?.delete(context);
787
+ if (contexts?.size === 0) {
788
+ cacheInvalidationState.activeContexts.delete(context.cache);
789
+ }
790
+ }
791
+
792
+ async function drainPendingInvalidations(
793
+ cache: AppRouterCache,
794
+ paths: Set<string>,
795
+ ): Promise<void> {
796
+ while (paths.size > 0) {
797
+ const path = paths.values().next().value;
798
+ if (path === undefined) {
799
+ return;
800
+ }
801
+
802
+ await cache.deleteByPath(path);
803
+ paths.delete(path);
804
+ }
570
805
  }
571
806
 
572
807
  export async function withRouteCacheContext<T>(
573
808
  cache: AppRouterCache | undefined,
574
809
  fn: () => T | Promise<T>,
575
810
  ): Promise<{ cachePolicy: RouteCachePolicy | undefined; revalidatedPaths: string[]; value: T }> {
811
+ const routeCache = cache ?? cacheState.memoryCache;
812
+ registerRouteCache(routeCache);
576
813
  const context: RouteCacheContext = {
577
- cache: cache ?? cacheState.memoryCache,
814
+ cache: routeCache,
815
+ externalInvalidationOverflowed: false,
816
+ externallyInvalidatedPaths: new Set(),
578
817
  revalidatedPaths: new Set(),
579
818
  };
819
+ registerActiveRouteCacheContext(context);
580
820
 
581
821
  return cacheState.storage.run(context, async () => {
582
822
  try {
@@ -592,6 +832,7 @@ export async function withRouteCacheContext<T>(
592
832
  return { cachePolicy, revalidatedPaths, value };
593
833
  } finally {
594
834
  context.closed = true;
835
+ unregisterActiveRouteCacheContext(context);
595
836
  }
596
837
  });
597
838
  }
@@ -600,12 +841,17 @@ export function beginRouteCacheContext(cache: AppRouterCache | undefined): {
600
841
  readonly cachePolicy: RouteCachePolicy | undefined;
601
842
  dispose(): Promise<{ revalidatedPaths: string[] }>;
602
843
  } {
844
+ const routeCache = cache ?? cacheState.memoryCache;
845
+ registerRouteCache(routeCache);
603
846
  const context: RouteCacheContext = {
604
- cache: cache ?? cacheState.memoryCache,
847
+ cache: routeCache,
848
+ externalInvalidationOverflowed: false,
849
+ externallyInvalidatedPaths: new Set(),
605
850
  revalidatedPaths: new Set(),
606
851
  };
607
852
 
608
853
  cacheState.activeContexts.push(context);
854
+ registerActiveRouteCacheContext(context);
609
855
 
610
856
  return {
611
857
  get cachePolicy() {
@@ -623,6 +869,7 @@ export function beginRouteCacheContext(cache: AppRouterCache | undefined): {
623
869
  if (index !== -1) {
624
870
  cacheState.activeContexts.splice(index, 1);
625
871
  }
872
+ unregisterActiveRouteCacheContext(context);
626
873
  }
627
874
 
628
875
  return { revalidatedPaths };
@@ -650,10 +897,16 @@ export function activeRouteCacheContext(): RouteCacheContext | undefined {
650
897
 
651
898
  // Host is excluded from the cache key to prevent attacker-supplied Host
652
899
  // headers from fragmenting / poisoning the cache (Issue 068). The Vary
653
- // dimension is the request path + query; same-origin reverse proxies are
654
- // expected to handle vhost separation at their layer.
655
- export function routeCacheKey(appDir: string, routePath: string, url: URL): string {
656
- return `${appDir}\0${normalizeRevalidationPath(routePath)}\0${url.pathname}${url.search}`;
900
+ // dimensions are the request path + query and the document/navigation HTML
901
+ // shape; same-origin reverse proxies are expected to handle vhost separation
902
+ // at their layer.
903
+ export function routeCacheKey(
904
+ appDir: string,
905
+ routePath: string,
906
+ url: URL,
907
+ responseVariant: "document" | "navigation" = "document",
908
+ ): string {
909
+ return `${appDir}\0${normalizeRevalidationPath(routePath)}\0${url.pathname}${url.search}\0${responseVariant}`;
657
910
  }
658
911
 
659
912
  export function stripRevalidateExport(code: string): string {
@@ -335,6 +335,7 @@ export function formatCliHelp(command?: string | undefined): string {
335
335
  "",
336
336
  "Options:",
337
337
  " --host <host> Bind address. Default: 127.0.0.1. Use 0.0.0.0 inside containers behind explicit port publishing or a reverse proxy.",
338
+ " --port <port> TCP port. Overrides PORT. Default: 3001.",
338
339
  " --host-policy=strict|trusted-proxy",
339
340
  " Control Host header trust for request origin reconstruction.",
340
341
  " --allowed-hosts <host[,host...]>",
@@ -469,6 +470,18 @@ export function resolveCliDevPort(
469
470
  return envValue === undefined || envValue === "" ? viteConfigPort : parseCliPort(envValue);
470
471
  }
471
472
 
473
+ export function resolveCliStartPort(
474
+ flagValue: number | undefined,
475
+ env: { PORT?: string | undefined },
476
+ ): number {
477
+ if (flagValue !== undefined) {
478
+ return flagValue;
479
+ }
480
+
481
+ const envValue = env.PORT;
482
+ return envValue === undefined || envValue === "" ? 3001 : parseCliPort(envValue, "PORT");
483
+ }
484
+
472
485
  export function resolveCliHostPolicy(
473
486
  flagValue: RequestHostPolicy | undefined,
474
487
  env: { MREACT_ROUTER_HOST_POLICY?: string | undefined },
@@ -527,7 +540,7 @@ function parseCliRequestLogMode(value: string): CliRequestLogMode {
527
540
  throw new Error(`Unsupported log mode ${JSON.stringify(value)}. Expected "requests".`);
528
541
  }
529
542
 
530
- function parseCliPort(value: string): number {
543
+ function parseCliPort(value: string, source = "port"): number {
531
544
  const port = Number(value);
532
545
 
533
546
  if (Number.isInteger(port) && port >= 0 && port <= 65535) {
@@ -535,7 +548,7 @@ function parseCliPort(value: string): number {
535
548
  }
536
549
 
537
550
  throw new Error(
538
- `Unsupported port ${JSON.stringify(value)}. Expected an integer from 0 to 65535.`,
551
+ `Unsupported ${source} ${JSON.stringify(value)}. Expected an integer from 0 to 65535.`,
539
552
  );
540
553
  }
541
554
 
package/src/cli.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  resolveCliHost,
26
26
  resolveCliHostPolicy,
27
27
  resolveCliRequestLogMode,
28
+ resolveCliStartPort,
28
29
  resolveCliTrustForwardedProto,
29
30
  } from "./cli-options.js";
30
31
  import { startDevServer } from "./dev-server.js";
@@ -63,7 +64,16 @@ if (parsed !== undefined) {
63
64
  const loaded =
64
65
  routeArg === undefined
65
66
  ? await loadMreactRouterViteConfigDetails({ command: "build", cwd: process.cwd() })
66
- : { project: { appDir: resolve(routeArg) }, viteConfig: undefined };
67
+ : {
68
+ dehydrateOptions: undefined,
69
+ project: { appDir: resolve(routeArg) },
70
+ viteConfig: undefined,
71
+ };
72
+ if (loaded.dehydrateOptions !== undefined) {
73
+ throw new Error(
74
+ "Production builds must configure dehydratePolicyModule instead of an inline dehydrateOptions callback so generated runtimes use the same policy as prerendering.",
75
+ );
76
+ }
67
77
  try {
68
78
  const result = await buildApp({
69
79
  ...loaded.project,
@@ -150,12 +160,16 @@ if (parsed !== undefined) {
150
160
  routeArg === undefined
151
161
  ? await loadMreactRouterViteConfigDetails({ command: "serve", cwd: process.cwd() })
152
162
  : {
163
+ dehydrateOptions: undefined,
153
164
  project: { appDir: resolve(routeArg) },
154
165
  serverPort: undefined,
155
166
  viteConfig: undefined,
156
167
  };
157
168
  const server = await startDevServer({
158
169
  ...loaded.project,
170
+ ...(loaded.dehydrateOptions === undefined
171
+ ? {}
172
+ : { dehydrateOptions: loaded.dehydrateOptions }),
159
173
  hostname: resolveCliHost(parsed.host, process.env),
160
174
  logger,
161
175
  port: resolveCliDevPort(parsed.port, process.env, loaded.serverPort),
@@ -169,7 +183,7 @@ if (parsed !== undefined) {
169
183
  hostname: resolveCliHost(parsed.host, process.env),
170
184
  logger,
171
185
  outDir: resolve(routeArg ?? ".mreact"),
172
- port: Number(process.env.PORT ?? 3001),
186
+ port: resolveCliStartPort(parsed.port, process.env),
173
187
  trustForwardedProto: resolveCliTrustForwardedProto(
174
188
  parsed.trustForwardedProto,
175
189
  process.env,
package/src/client.ts CHANGED
@@ -78,6 +78,7 @@ export interface BuildClientRouteOutputOptions {
78
78
  filename: string;
79
79
  minify?: boolean | undefined;
80
80
  routePath: string;
81
+ restoreRequestUrl?: boolean | undefined;
81
82
  sourceMap?: boolean | undefined;
82
83
  vitePlugins?: readonly PluginOption[] | undefined;
83
84
  clientNavigation?: boolean | undefined;
@@ -2632,9 +2633,18 @@ export function withHydrationMarkers(options: {
2632
2633
  }
2633
2634
 
2634
2635
  export function withRouteMarkers(options: { html: string; routePath: string }): string {
2635
- const routeId = routeIdForPath(options.routePath);
2636
+ const marker = routeMarkerParts(options.routePath);
2636
2637
 
2637
- return `<div ${routeHydrationContract.routeMarkerAttribute}="${escapeHtmlAttribute(routeId)}">${options.html}</div>`;
2638
+ return `${marker.prefix}${options.html}${marker.suffix}`;
2639
+ }
2640
+
2641
+ export function routeMarkerParts(routePath: string): { prefix: string; suffix: string } {
2642
+ const routeId = routeIdForPath(routePath);
2643
+
2644
+ return {
2645
+ prefix: `<div ${routeHydrationContract.routeMarkerAttribute}="${escapeHtmlAttribute(routeId)}">`,
2646
+ suffix: "</div>",
2647
+ };
2638
2648
  }
2639
2649
 
2640
2650
  export function hydrationMarkerParts(options: {
@@ -2872,6 +2882,8 @@ export async function buildClientRouteEntrySource(
2872
2882
  );
2873
2883
 
2874
2884
  const routeId = routeIdForPath(options.routePath);
2885
+ const restoreRequestUrl =
2886
+ options.restoreRequestUrl ?? (options.debugLabels === true || /\brequest\b/.test(options.code));
2875
2887
  const routeUsesCells = detectRouteCellStateHint(compiled.code);
2876
2888
  const routeUsesReactiveEffect = detectRouteReactiveEffectHint(compiled.code);
2877
2889
  const routeUsesDomRefs = compiled.metadata.imports.some(
@@ -3401,6 +3413,7 @@ ${routeOutOfOrderFragmentHydration} const __mreactMarker = document.querySelect
3401
3413
  const __mreactProps = __mreactPropsText === undefined
3402
3414
  ? {}
3403
3415
  : JSON.parse(__mreactPropsText);
3416
+ ${restoreRequestUrl ? " if (__mreactProps.request) __mreactProps.request.url = document.URL;\n" : ""}
3404
3417
  const __mreactClientReferences = __mreactClientReferencesElement?.textContent === undefined
3405
3418
  ? []
3406
3419
  : JSON.parse(__mreactClientReferencesElement.textContent);
@@ -3924,7 +3937,7 @@ function __mreactFetchNavigationHtml(href) {
3924
3937
  ? { "x-mreact-navigation": "1", "x-mreact-navigation-cache": "reload" }
3925
3938
  : { "x-mreact-navigation": "1" };
3926
3939
 
3927
- return fetch(href, {
3940
+ return fetch(__mreactStaticNavigationRequestHref(href), {
3928
3941
  headers,
3929
3942
  }).then((response) => {
3930
3943
  __mreactApplyRevalidationHeader(response);
@@ -3936,6 +3949,37 @@ function __mreactFetchNavigationHtml(href) {
3936
3949
  });
3937
3950
  }
3938
3951
 
3952
+ function __mreactStaticNavigationRequestHref(href) {
3953
+ if (typeof document === "undefined" || typeof location === "undefined") {
3954
+ return href;
3955
+ }
3956
+
3957
+ const base = document
3958
+ .querySelector('meta[name="mreact-static-navigation"]')
3959
+ ?.getAttribute("content")
3960
+ ?.trim();
3961
+ if (base === undefined || base === "") {
3962
+ return href;
3963
+ }
3964
+
3965
+ try {
3966
+ const destination = new URL(href, location.href);
3967
+ if (destination.origin !== location.origin) {
3968
+ return href;
3969
+ }
3970
+ const routePath = destination.pathname.replace(/^\\/+|\\/+$/g, "");
3971
+ const artifactPath = routePath === "" ? "index.html" : routePath + "/index.html";
3972
+ const artifact = new URL(
3973
+ base.replace(/\\/+$/g, "") + "/" + artifactPath,
3974
+ location.origin,
3975
+ );
3976
+ artifact.search = destination.search;
3977
+ return artifact.href;
3978
+ } catch {
3979
+ return href;
3980
+ }
3981
+ }
3982
+
3939
3983
  function __mreactNavigationResponseRequiresDocumentReload(response) {
3940
3984
  return response.status === 204 || response.headers.get("x-mreact-navigation") === "reload";
3941
3985
  }