@reckona/mreact-router 0.0.203 → 0.0.205

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 (140) hide show
  1. package/README.md +53 -9
  2. package/dist/actions.d.ts +6 -0
  3. package/dist/actions.d.ts.map +1 -1
  4. package/dist/actions.js +70 -10
  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 +2 -1
  11. package/dist/adapters/cloudflare.d.ts.map +1 -1
  12. package/dist/adapters/cloudflare.js +18 -10
  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 +1 -0
  25. package/dist/build.d.ts.map +1 -1
  26. package/dist/build.js +91 -5
  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 +3 -1
  33. package/dist/cache.d.ts.map +1 -1
  34. package/dist/cache.js +211 -13
  35. package/dist/cache.js.map +1 -1
  36. package/dist/cli.js +12 -1
  37. package/dist/cli.js.map +1 -1
  38. package/dist/client.d.ts.map +1 -1
  39. package/dist/client.js +32 -1
  40. package/dist/client.js.map +1 -1
  41. package/dist/config.d.ts +3 -0
  42. package/dist/config.d.ts.map +1 -1
  43. package/dist/config.js +21 -2
  44. package/dist/config.js.map +1 -1
  45. package/dist/csp.d.ts +1 -0
  46. package/dist/csp.d.ts.map +1 -1
  47. package/dist/csp.js +11 -6
  48. package/dist/csp.js.map +1 -1
  49. package/dist/dehydrate-policy.d.ts +3 -0
  50. package/dist/dehydrate-policy.d.ts.map +1 -0
  51. package/dist/dehydrate-policy.js +16 -0
  52. package/dist/dehydrate-policy.js.map +1 -0
  53. package/dist/dev-server.d.ts +7 -2
  54. package/dist/dev-server.d.ts.map +1 -1
  55. package/dist/dev-server.js +120 -11
  56. package/dist/dev-server.js.map +1 -1
  57. package/dist/http.d.ts +1 -1
  58. package/dist/http.d.ts.map +1 -1
  59. package/dist/http.js +147 -7
  60. package/dist/http.js.map +1 -1
  61. package/dist/index.d.ts +4 -3
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +1 -0
  64. package/dist/index.js.map +1 -1
  65. package/dist/logger.d.ts +22 -1
  66. package/dist/logger.d.ts.map +1 -1
  67. package/dist/logger.js +22 -8
  68. package/dist/logger.js.map +1 -1
  69. package/dist/metadata.d.ts +8 -0
  70. package/dist/metadata.d.ts.map +1 -1
  71. package/dist/metadata.js +60 -22
  72. package/dist/metadata.js.map +1 -1
  73. package/dist/navigation-marker.d.ts.map +1 -1
  74. package/dist/navigation-marker.js +72 -9
  75. package/dist/navigation-marker.js.map +1 -1
  76. package/dist/navigation.d.ts +1 -0
  77. package/dist/navigation.d.ts.map +1 -1
  78. package/dist/navigation.js +1 -1
  79. package/dist/navigation.js.map +1 -1
  80. package/dist/node-server.d.ts +5 -2
  81. package/dist/node-server.d.ts.map +1 -1
  82. package/dist/node-server.js +75 -6
  83. package/dist/node-server.js.map +1 -1
  84. package/dist/prerender-entry.d.ts +14 -0
  85. package/dist/prerender-entry.d.ts.map +1 -1
  86. package/dist/prerender-entry.js +40 -3
  87. package/dist/prerender-entry.js.map +1 -1
  88. package/dist/render.d.ts +9 -1
  89. package/dist/render.d.ts.map +1 -1
  90. package/dist/render.js +102 -123
  91. package/dist/render.js.map +1 -1
  92. package/dist/serve.d.ts +8 -2
  93. package/dist/serve.d.ts.map +1 -1
  94. package/dist/serve.js +22 -20
  95. package/dist/serve.js.map +1 -1
  96. package/dist/session.d.ts.map +1 -1
  97. package/dist/session.js +52 -19
  98. package/dist/session.js.map +1 -1
  99. package/dist/upgrade.d.ts +59 -2
  100. package/dist/upgrade.d.ts.map +1 -1
  101. package/dist/upgrade.js +282 -1
  102. package/dist/upgrade.js.map +1 -1
  103. package/dist/vite-config.d.ts +2 -0
  104. package/dist/vite-config.d.ts.map +1 -1
  105. package/dist/vite-config.js +2 -0
  106. package/dist/vite-config.js.map +1 -1
  107. package/dist/vite.d.ts +4 -1
  108. package/dist/vite.d.ts.map +1 -1
  109. package/dist/vite.js +117 -6
  110. package/dist/vite.js.map +1 -1
  111. package/package.json +11 -11
  112. package/src/actions.ts +81 -11
  113. package/src/adapters/aws-lambda.ts +426 -115
  114. package/src/adapters/cloudflare.ts +29 -13
  115. package/src/adapters/edge.ts +2 -0
  116. package/src/adapters/node.ts +8 -10
  117. package/src/adapters/static.ts +31 -2
  118. package/src/build.ts +117 -10
  119. package/src/built-runtime.ts +28 -0
  120. package/src/cache.ts +262 -15
  121. package/src/cli.ts +14 -1
  122. package/src/client.ts +32 -1
  123. package/src/config.ts +43 -6
  124. package/src/csp.ts +14 -11
  125. package/src/dehydrate-policy.ts +23 -0
  126. package/src/dev-server.ts +188 -15
  127. package/src/http.ts +172 -6
  128. package/src/index.ts +14 -1
  129. package/src/logger.ts +47 -10
  130. package/src/metadata.ts +125 -60
  131. package/src/navigation-marker.ts +79 -9
  132. package/src/navigation.ts +1 -1
  133. package/src/node-server.ts +128 -22
  134. package/src/prerender-entry.ts +52 -3
  135. package/src/render.ts +807 -753
  136. package/src/serve.ts +49 -56
  137. package/src/session.ts +56 -25
  138. package/src/upgrade.ts +402 -3
  139. package/src/vite-config.ts +6 -0
  140. 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 };
package/src/cli.ts CHANGED
@@ -64,7 +64,16 @@ if (parsed !== undefined) {
64
64
  const loaded =
65
65
  routeArg === undefined
66
66
  ? await loadMreactRouterViteConfigDetails({ command: "build", cwd: process.cwd() })
67
- : { 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
+ }
68
77
  try {
69
78
  const result = await buildApp({
70
79
  ...loaded.project,
@@ -151,12 +160,16 @@ if (parsed !== undefined) {
151
160
  routeArg === undefined
152
161
  ? await loadMreactRouterViteConfigDetails({ command: "serve", cwd: process.cwd() })
153
162
  : {
163
+ dehydrateOptions: undefined,
154
164
  project: { appDir: resolve(routeArg) },
155
165
  serverPort: undefined,
156
166
  viteConfig: undefined,
157
167
  };
158
168
  const server = await startDevServer({
159
169
  ...loaded.project,
170
+ ...(loaded.dehydrateOptions === undefined
171
+ ? {}
172
+ : { dehydrateOptions: loaded.dehydrateOptions }),
160
173
  hostname: resolveCliHost(parsed.host, process.env),
161
174
  logger,
162
175
  port: resolveCliDevPort(parsed.port, process.env, loaded.serverPort),
package/src/client.ts CHANGED
@@ -3937,7 +3937,7 @@ function __mreactFetchNavigationHtml(href) {
3937
3937
  ? { "x-mreact-navigation": "1", "x-mreact-navigation-cache": "reload" }
3938
3938
  : { "x-mreact-navigation": "1" };
3939
3939
 
3940
- return fetch(href, {
3940
+ return fetch(__mreactStaticNavigationRequestHref(href), {
3941
3941
  headers,
3942
3942
  }).then((response) => {
3943
3943
  __mreactApplyRevalidationHeader(response);
@@ -3949,6 +3949,37 @@ function __mreactFetchNavigationHtml(href) {
3949
3949
  });
3950
3950
  }
3951
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
+
3952
3983
  function __mreactNavigationResponseRequiresDocumentReload(response) {
3953
3984
  return response.status === 204 || response.headers.get("x-mreact-navigation") === "reload";
3954
3985
  }
package/src/config.ts CHANGED
@@ -37,6 +37,8 @@ export interface AppRouterProjectOptions {
37
37
  buildConcurrency?: number | undefined;
38
38
  buildTargets?: readonly AppRouterBuildTarget[] | undefined;
39
39
  clientSourceMaps?: AppRouterClientSourceMapOption | undefined;
40
+ /** Project-local server module exporting a named `dehydrateOptions` policy. */
41
+ dehydratePolicyModule?: string | undefined;
40
42
  /**
41
43
  * Legacy route root. When provided without routesDir/projectRoot, this keeps
42
44
  * the historical "appDir is the whole app boundary" behavior.
@@ -61,6 +63,7 @@ export interface ResolvedAppRouterProject {
61
63
  buildTargets: readonly AppRouterBuildTarget[];
62
64
  clientSourceMaps: AppRouterClientSourceMapMode;
63
65
  clientConsolePureFunctions?: readonly string[] | undefined;
66
+ dehydratePolicyModule?: string | undefined;
64
67
  projectRoot: string;
65
68
  publicAssetBaseUrl?: string | undefined;
66
69
  publicDir: string;
@@ -80,11 +83,12 @@ export function resolveAppRouterProjectOptions(
80
83
  options.routesDir === undefined
81
84
  ) {
82
85
  const appDir = resolve(options.appDir);
86
+ const allowedSourceDirs = (options.allowedSourceDirs ?? [appDir]).map((directory) =>
87
+ resolveProjectPath(appDir, directory, "allowedSourceDirs"),
88
+ );
83
89
 
84
90
  return {
85
- allowedSourceDirs: (options.allowedSourceDirs ?? [appDir]).map((directory) =>
86
- resolveProjectPath(appDir, directory, "allowedSourceDirs"),
87
- ),
91
+ allowedSourceDirs,
88
92
  ...(options.assetBaseUrl === undefined ? {} : { assetBaseUrl: options.assetBaseUrl }),
89
93
  ...(options.buildConcurrency === undefined
90
94
  ? {}
@@ -92,6 +96,15 @@ export function resolveAppRouterProjectOptions(
92
96
  buildTargets: resolveBuildTargets(options.buildTargets),
93
97
  clientSourceMaps: resolveClientSourceMapMode(options.clientSourceMaps),
94
98
  ...(clientConsolePureFunctions === undefined ? {} : { clientConsolePureFunctions }),
99
+ ...(options.dehydratePolicyModule === undefined
100
+ ? {}
101
+ : {
102
+ dehydratePolicyModule: resolvePolicyModule(
103
+ appDir,
104
+ options.dehydratePolicyModule,
105
+ allowedSourceDirs,
106
+ ),
107
+ }),
95
108
  projectRoot: appDir,
96
109
  ...(options.publicAssetBaseUrl === undefined
97
110
  ? {}
@@ -105,11 +118,12 @@ export function resolveAppRouterProjectOptions(
105
118
  const routesDir = resolveProjectPath(projectRoot, options.routesDir ?? "src/app", "routesDir");
106
119
  const defaultAllowedSourceDirs =
107
120
  options.routesDir === undefined ? ["src"] : [defaultAllowedSourceDir(projectRoot, routesDir)];
121
+ const allowedSourceDirs = (options.allowedSourceDirs ?? defaultAllowedSourceDirs).map(
122
+ (directory) => resolveProjectPath(projectRoot, directory, "allowedSourceDirs"),
123
+ );
108
124
 
109
125
  return {
110
- allowedSourceDirs: (options.allowedSourceDirs ?? defaultAllowedSourceDirs).map((directory) =>
111
- resolveProjectPath(projectRoot, directory, "allowedSourceDirs"),
112
- ),
126
+ allowedSourceDirs,
113
127
  ...(options.assetBaseUrl === undefined ? {} : { assetBaseUrl: options.assetBaseUrl }),
114
128
  ...(options.buildConcurrency === undefined
115
129
  ? {}
@@ -117,6 +131,15 @@ export function resolveAppRouterProjectOptions(
117
131
  buildTargets: resolveBuildTargets(options.buildTargets),
118
132
  clientSourceMaps: resolveClientSourceMapMode(options.clientSourceMaps),
119
133
  ...(clientConsolePureFunctions === undefined ? {} : { clientConsolePureFunctions }),
134
+ ...(options.dehydratePolicyModule === undefined
135
+ ? {}
136
+ : {
137
+ dehydratePolicyModule: resolvePolicyModule(
138
+ projectRoot,
139
+ options.dehydratePolicyModule,
140
+ allowedSourceDirs,
141
+ ),
142
+ }),
120
143
  projectRoot,
121
144
  ...(options.publicAssetBaseUrl === undefined
122
145
  ? {}
@@ -126,6 +149,20 @@ export function resolveAppRouterProjectOptions(
126
149
  };
127
150
  }
128
151
 
152
+ function resolvePolicyModule(
153
+ projectRoot: string,
154
+ modulePath: string,
155
+ allowedSourceDirs: readonly string[],
156
+ ): string {
157
+ const resolvedModule = resolveProjectPath(projectRoot, modulePath, "dehydratePolicyModule");
158
+ if (!allowedSourceDirs.some((directory) => isInsideDirectory(directory, resolvedModule))) {
159
+ throw new Error(
160
+ `mreactRouter dehydratePolicyModule must resolve inside allowedSourceDirs: ${resolvedModule}`,
161
+ );
162
+ }
163
+ return resolvedModule;
164
+ }
165
+
129
166
  function defaultAllowedSourceDir(projectRoot: string, routesDir: string): string {
130
167
  const sourceRoot = dirname(routesDir);
131
168
  return basename(routesDir) === "app" && sourceRoot !== projectRoot ? sourceRoot : routesDir;
package/src/csp.ts CHANGED
@@ -18,6 +18,17 @@ export interface ContentSecurityPolicyInput {
18
18
  replace?: Record<string, readonly string[] | string>;
19
19
  }
20
20
 
21
+ export function responseHeadersContainCspNonce(headers: Headers): boolean {
22
+ for (const name of ["content-security-policy", "content-security-policy-report-only"]) {
23
+ const value = headers.get(name);
24
+ if (value !== null && /(?:^|[\s;,])'nonce-[^']+'(?=$|[\s;,])/i.test(value)) {
25
+ return true;
26
+ }
27
+ }
28
+
29
+ return false;
30
+ }
31
+
21
32
  const VALID_NONCE = /^[A-Za-z0-9+/=_-]+$/;
22
33
  const VALID_DIRECTIVE_NAME = /^[a-z][a-z0-9-]*$/i;
23
34
  // One CSP "source expression". Reject anything containing `;`, quote,
@@ -46,13 +57,7 @@ function isValidDirectiveValue(value: string): boolean {
46
57
  for (let index = 0; index < value.length; index += 1) {
47
58
  const code = value.charCodeAt(index);
48
59
 
49
- if (
50
- code <= 0x20 ||
51
- code === 0x22 ||
52
- code === 0x27 ||
53
- code === 0x3b ||
54
- code === 0x7f
55
- ) {
60
+ if (code <= 0x20 || code === 0x22 || code === 0x27 || code === 0x3b || code === 0x7f) {
56
61
  return false;
57
62
  }
58
63
  }
@@ -84,9 +89,7 @@ export function contentSecurityPolicy(
84
89
 
85
90
  for (const rawValue of rawValues) {
86
91
  if (typeof rawValue !== "string" || !isValidDirectiveValue(rawValue)) {
87
- throw new TypeError(
88
- `invalid CSP directive value for ${name}: ${JSON.stringify(rawValue)}`,
89
- );
92
+ throw new TypeError(`invalid CSP directive value for ${name}: ${JSON.stringify(rawValue)}`);
90
93
  }
91
94
  }
92
95
 
@@ -99,5 +102,5 @@ export function contentSecurityPolicy(
99
102
  serialized.push(`${name} ${values.join(" ")}`);
100
103
  }
101
104
 
102
- return serialized.join("; ");
105
+ return serialized.length === 0 ? undefined : serialized.join("; ");
103
106
  }
@@ -0,0 +1,23 @@
1
+ import type { DehydrateOptions } from "@reckona/mreact-query";
2
+
3
+ export function dehydrateOptionsFromModule(
4
+ module: unknown,
5
+ label: string,
6
+ ): DehydrateOptions {
7
+ if (module === null || typeof module !== "object" || !("dehydrateOptions" in module)) {
8
+ throw new Error(`${label} must export a named dehydrateOptions object.`);
9
+ }
10
+
11
+ const dehydrateOptions = (module as { dehydrateOptions?: unknown }).dehydrateOptions;
12
+ if (dehydrateOptions === null || typeof dehydrateOptions !== "object") {
13
+ throw new Error(`${label} must export a named dehydrateOptions object.`);
14
+ }
15
+
16
+ const shouldDehydrateQuery = (dehydrateOptions as { shouldDehydrateQuery?: unknown })
17
+ .shouldDehydrateQuery;
18
+ if (shouldDehydrateQuery !== undefined && typeof shouldDehydrateQuery !== "function") {
19
+ throw new Error(`${label} dehydrateOptions.shouldDehydrateQuery must be a function.`);
20
+ }
21
+
22
+ return dehydrateOptions as DehydrateOptions;
23
+ }