akanjs 2.2.13-rc.0 → 2.3.0

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 (149) hide show
  1. package/client/csrTypes.ts +37 -6
  2. package/client/makePageProto.tsx +8 -8
  3. package/client/router.ts +5 -2
  4. package/fetch/requestStorage.ts +41 -11
  5. package/package.json +1 -1
  6. package/server/cachePolicy.ts +192 -0
  7. package/server/metadata.tsx +114 -0
  8. package/server/routeElementComposer.tsx +21 -1
  9. package/server/routeTreeBuilder.ts +44 -5
  10. package/server/rscClient.tsx +127 -50
  11. package/server/rscHttp.ts +120 -0
  12. package/server/rscNavigationState.ts +95 -0
  13. package/server/rscWorker.tsx +177 -86
  14. package/server/rscWorkerHost.ts +47 -1
  15. package/server/rscWorkerReplay.ts +5 -0
  16. package/server/ssrFromRscRenderer.tsx +18 -6
  17. package/server/ssrTypes.ts +2 -1
  18. package/server/webRouter.ts +114 -110
  19. package/types/client/csrTypes.d.ts +37 -6
  20. package/types/fetch/requestStorage.d.ts +16 -6
  21. package/types/server/cachePolicy.d.ts +55 -0
  22. package/types/server/metadata.d.ts +13 -0
  23. package/types/server/routeElementComposer.d.ts +6 -1
  24. package/types/server/rscHttp.d.ts +16 -0
  25. package/types/server/rscNavigationState.d.ts +35 -0
  26. package/types/server/rscWorkerHost.d.ts +9 -0
  27. package/types/server/rscWorkerReplay.d.ts +6 -0
  28. package/types/server/ssrFromRscRenderer.d.ts +2 -0
  29. package/types/server/ssrTypes.d.ts +2 -1
  30. package/types/server/webRouter.d.ts +22 -1
  31. package/types/ui/Button.d.ts +1 -1
  32. package/types/ui/ClientSide.d.ts +1 -1
  33. package/types/ui/Constant/Doc.d.ts +6 -6
  34. package/types/ui/Constant/Mermaid.d.ts +1 -1
  35. package/types/ui/Constant/index.d.ts +1 -1
  36. package/types/ui/Copy.d.ts +1 -1
  37. package/types/ui/CsrImage.d.ts +1 -1
  38. package/types/ui/Data/CardList.d.ts +1 -1
  39. package/types/ui/Data/Dashboard.d.ts +1 -1
  40. package/types/ui/Data/Insight.d.ts +1 -1
  41. package/types/ui/Data/Item.d.ts +6 -6
  42. package/types/ui/Data/ListContainer.d.ts +1 -1
  43. package/types/ui/Data/Pagination.d.ts +1 -1
  44. package/types/ui/Data/TableList.d.ts +1 -1
  45. package/types/ui/DatePicker.d.ts +3 -3
  46. package/types/ui/Dialog/Close.d.ts +1 -1
  47. package/types/ui/Dialog/Content.d.ts +1 -1
  48. package/types/ui/Dialog/Provider.d.ts +1 -1
  49. package/types/ui/Dialog/Trigger.d.ts +1 -1
  50. package/types/ui/Dialog/index.d.ts +3 -3
  51. package/types/ui/DragAction.d.ts +4 -4
  52. package/types/ui/DraggableList.d.ts +3 -3
  53. package/types/ui/Dropdown.d.ts +1 -1
  54. package/types/ui/Empty.d.ts +1 -1
  55. package/types/ui/Field.d.ts +22 -22
  56. package/types/ui/Image.d.ts +1 -1
  57. package/types/ui/InfiniteScroll.d.ts +1 -1
  58. package/types/ui/Input.d.ts +6 -6
  59. package/types/ui/KeyboardAvoiding.d.ts +1 -1
  60. package/types/ui/Layout/BottomAction.d.ts +1 -1
  61. package/types/ui/Layout/BottomInset.d.ts +1 -1
  62. package/types/ui/Layout/BottomTab.d.ts +1 -1
  63. package/types/ui/Layout/Header.d.ts +1 -1
  64. package/types/ui/Layout/LeftSider.d.ts +1 -1
  65. package/types/ui/Layout/Navbar.d.ts +1 -1
  66. package/types/ui/Layout/RightSider.d.ts +1 -1
  67. package/types/ui/Layout/Sider.d.ts +1 -1
  68. package/types/ui/Layout/Template.d.ts +1 -1
  69. package/types/ui/Layout/TopLeftAction.d.ts +1 -1
  70. package/types/ui/Layout/Unit.d.ts +1 -1
  71. package/types/ui/Layout/View.d.ts +1 -1
  72. package/types/ui/Layout/Zone.d.ts +1 -1
  73. package/types/ui/Layout/index.d.ts +12 -12
  74. package/types/ui/Link/Back.d.ts +1 -1
  75. package/types/ui/Link/Close.d.ts +1 -1
  76. package/types/ui/Link/CsrLink.d.ts +1 -1
  77. package/types/ui/Link/Lang.d.ts +1 -1
  78. package/types/ui/Link/SsrLink.d.ts +1 -1
  79. package/types/ui/Link/index.d.ts +1 -1
  80. package/types/ui/Load/Edit.d.ts +1 -1
  81. package/types/ui/Load/Edit_Client.d.ts +1 -1
  82. package/types/ui/Load/PageCSR.d.ts +1 -1
  83. package/types/ui/Load/Pagination.d.ts +1 -1
  84. package/types/ui/Load/Units.d.ts +1 -1
  85. package/types/ui/Load/View.d.ts +1 -1
  86. package/types/ui/Loading/Area.d.ts +1 -1
  87. package/types/ui/Loading/Button.d.ts +1 -1
  88. package/types/ui/Loading/Input.d.ts +1 -1
  89. package/types/ui/Loading/ProgressBar.d.ts +1 -1
  90. package/types/ui/Loading/Skeleton.d.ts +1 -1
  91. package/types/ui/Loading/Spin.d.ts +1 -1
  92. package/types/ui/Loading/index.d.ts +6 -6
  93. package/types/ui/Menu.d.ts +1 -1
  94. package/types/ui/Modal.d.ts +1 -1
  95. package/types/ui/Model/AdminPanel.d.ts +1 -1
  96. package/types/ui/Model/Edit.d.ts +1 -1
  97. package/types/ui/Model/EditModal.d.ts +1 -1
  98. package/types/ui/Model/EditWrapper.d.ts +1 -1
  99. package/types/ui/Model/LoadInit.d.ts +1 -1
  100. package/types/ui/Model/New.d.ts +1 -1
  101. package/types/ui/Model/NewWrapper.d.ts +1 -1
  102. package/types/ui/Model/NewWrapper_Client.d.ts +1 -1
  103. package/types/ui/Model/Remove.d.ts +1 -1
  104. package/types/ui/Model/RemoveWrapper.d.ts +1 -1
  105. package/types/ui/Model/SureToRemove.d.ts +1 -1
  106. package/types/ui/Model/View.d.ts +1 -1
  107. package/types/ui/Model/ViewEditModal.d.ts +1 -1
  108. package/types/ui/Model/ViewModal.d.ts +1 -1
  109. package/types/ui/Model/ViewWrapper.d.ts +1 -1
  110. package/types/ui/More.d.ts +1 -1
  111. package/types/ui/ObjectId.d.ts +1 -1
  112. package/types/ui/Popconfirm.d.ts +1 -1
  113. package/types/ui/Radio.d.ts +2 -2
  114. package/types/ui/RecentTime.d.ts +1 -1
  115. package/types/ui/Refresh.d.ts +1 -1
  116. package/types/ui/ScreenNavigator.d.ts +3 -3
  117. package/types/ui/Select.d.ts +1 -1
  118. package/types/ui/Signal/Arg.d.ts +13 -13
  119. package/types/ui/Signal/Doc.d.ts +6 -6
  120. package/types/ui/Signal/Listener.d.ts +2 -2
  121. package/types/ui/Signal/Message.d.ts +4 -4
  122. package/types/ui/Signal/Object.d.ts +4 -4
  123. package/types/ui/Signal/PubSub.d.ts +4 -4
  124. package/types/ui/Signal/Request.d.ts +2 -2
  125. package/types/ui/Signal/Response.d.ts +3 -3
  126. package/types/ui/Signal/RestApi.d.ts +5 -5
  127. package/types/ui/Signal/WebSocket.d.ts +2 -2
  128. package/types/ui/System/CSR.d.ts +5 -5
  129. package/types/ui/System/Client.d.ts +8 -8
  130. package/types/ui/System/Common.d.ts +2 -2
  131. package/types/ui/System/DevModeToggle.d.ts +1 -1
  132. package/types/ui/System/Gtag.d.ts +1 -1
  133. package/types/ui/System/Messages.d.ts +1 -1
  134. package/types/ui/System/Reconnect.d.ts +1 -1
  135. package/types/ui/System/Root.d.ts +1 -1
  136. package/types/ui/System/SSR.d.ts +4 -4
  137. package/types/ui/System/SelectLanguage.d.ts +1 -1
  138. package/types/ui/System/ThemeToggle.d.ts +1 -1
  139. package/types/ui/System/index.d.ts +7 -7
  140. package/types/ui/Tab/Menu.d.ts +1 -1
  141. package/types/ui/Tab/Menus.d.ts +1 -1
  142. package/types/ui/Tab/Panel.d.ts +1 -1
  143. package/types/ui/Tab/Provider.d.ts +1 -1
  144. package/types/ui/Tab/index.d.ts +4 -4
  145. package/types/ui/Table.d.ts +1 -1
  146. package/types/ui/ToggleSelect.d.ts +2 -2
  147. package/types/ui/Unauthorized.d.ts +1 -1
  148. package/types/webkit/useCsrValues.d.ts +1 -1
  149. package/webkit/bootCsr.tsx +16 -2
@@ -6,13 +6,35 @@ import type {
6
6
  RedirectStatus,
7
7
  } from "akanjs/client";
8
8
  import { type AkanI18nConfig, DEFAULT_AKAN_I18N, getBasePathFromPathname, Logger } from "akanjs/common";
9
- import { cookies, getRequest, getRequestTheme, requestStorage, updateRequestPolicy } from "akanjs/fetch";
9
+ import {
10
+ getRequestDynamicUsage,
11
+ getRequestPolicy,
12
+ getRequestTheme,
13
+ requestStorage,
14
+ untrackedCookies,
15
+ untrackedRequest,
16
+ updateRequestPolicy,
17
+ } from "akanjs/fetch";
10
18
  import type { ReactNode } from "react";
11
19
  import { renderToReadableStream } from "react-server-dom-webpack/server.node";
12
20
  import type { ClientManifest } from "./artifact";
21
+ import {
22
+ createRouteCacheEntry,
23
+ isPublicRouteCacheableRequest,
24
+ isRouteCachePathAllowed,
25
+ LruTtlCache,
26
+ parsePositiveInt,
27
+ type RouteCacheEntry,
28
+ type RouteCacheRenderState,
29
+ resolveAutoRouteCacheTtl,
30
+ resolveRouteCacheStoreTtl,
31
+ shouldStoreRouteCache,
32
+ } from "./cachePolicy";
33
+ import { shouldRenderLocaleAlternates } from "./metadata";
13
34
  import { ProcessMetricsCollector } from "./processMetricsCollector";
14
35
  import { RouteElementComposer } from "./routeElementComposer";
15
36
  import { type PagesContext, RouteTreeBuilder } from "./routeTreeBuilder";
37
+ import { encodeAkanRedirectDigest } from "./rscHttp";
16
38
  import { replayCachedRscResult } from "./rscWorkerReplay";
17
39
  import { createSystemPageDocument, getSystemPageHomeHref } from "./systemPages";
18
40
 
@@ -49,7 +71,11 @@ interface UpdateCssAssetsMsg {
49
71
  type: "updateCssAssets";
50
72
  cssAssets: Record<string, { cssUrl: string; cssRelPath: string }>;
51
73
  }
52
- type InMsg = InitMsg | RenderMsg | CancelMsg | ReloadMsg | UpdateCssAssetsMsg;
74
+ interface InvalidateCacheMsg {
75
+ type: "invalidate-cache";
76
+ reason?: string;
77
+ }
78
+ type InMsg = InitMsg | RenderMsg | CancelMsg | ReloadMsg | UpdateCssAssetsMsg | InvalidateCacheMsg;
53
79
  type RenderControl =
54
80
  | { type: "redirect"; location: string; method: "replace" | "push"; status: RedirectStatus }
55
81
  | { type: "not-found" }
@@ -87,11 +113,11 @@ interface RouteRenderStats {
87
113
  }
88
114
 
89
115
  interface CachedRscResult {
90
- expiresAt: number;
91
116
  chunks: Uint8Array[];
92
117
  bytes: number;
93
118
  chunksCount: number;
94
119
  theme?: string;
120
+ cacheState: RouteCacheRenderState;
95
121
  }
96
122
 
97
123
  export function isAkanRedirectError(error: unknown): error is AkanRedirectError {
@@ -140,7 +166,9 @@ class RscRenderer {
140
166
  pagesBundleBuildId: 0,
141
167
  };
142
168
  readonly #routeStats = new Map<string, RouteRenderStats>();
143
- readonly #resultCache = new Map<string, CachedRscResult>();
169
+ #resultCache = new LruTtlCache<CachedRscResult>(
170
+ parsePositiveInt(process.env.AKAN_RSC_RESULT_CACHE_MAX_ENTRIES) ?? 100,
171
+ );
144
172
  readonly #activeRenderReaders = new Map<string, ReadableStreamDefaultReader<Uint8Array>>();
145
173
  readonly #cancelledRenderRequests = new Set<string>();
146
174
  #resultCacheHits = 0;
@@ -185,6 +213,10 @@ class RscRenderer {
185
213
  this.#logger.verbose(`received updateCssAssets count=${Object.keys(msg.cssAssets).length}`);
186
214
  this.#cssAssets = msg.cssAssets;
187
215
  return;
216
+ case "invalidate-cache":
217
+ this.#logger.verbose(`received invalidate-cache reason=${msg.reason ?? "(none)"}`);
218
+ this.#resultCache.clear();
219
+ return;
188
220
  }
189
221
  }
190
222
 
@@ -316,8 +348,8 @@ class RscRenderer {
316
348
  );
317
349
  else this.#logger.verbose(`render[${requestId}] no route matched pathname=${urlObj.pathname} — rendering 404`);
318
350
  const beforeLoadedKeys = RouteTreeBuilder.getCacheStats().loadedModuleKeys;
319
- const cacheKey = match ? await this.#getResultCacheKey(request, urlObj, match.pathRoute) : null;
320
- const cached = cacheKey ? this.#getCachedResult(cacheKey) : null;
351
+ const cacheEntry = match ? this.#getResultCacheEntry(request, urlObj) : null;
352
+ const cached = cacheEntry ? this.#getCachedResult(cacheEntry.key) : null;
321
353
  if (cached) {
322
354
  this.#stats.lastRenderDurationMs = Date.now() - startedAt;
323
355
  this.#stats.lastRenderLoadedModuleDelta = 0;
@@ -331,12 +363,13 @@ class RscRenderer {
331
363
  requestId,
332
364
  chunks: cached.chunks,
333
365
  theme: cached.theme,
366
+ cacheState: cached.cacheState,
334
367
  send: (message) => this.#send(message),
335
368
  isCancelled: () => this.#cancelledRenderRequests.has(requestId),
336
369
  });
337
370
  return;
338
371
  }
339
- const theme = cookies().get("theme")?.value;
372
+ const theme = untrackedCookies().get("theme")?.value;
340
373
  const searchParams = RouteTreeBuilder.parseSearchParams(urlObj.search);
341
374
  let element: ReactNode;
342
375
  if (match) element = await this.#renderMatched(urlObj, match, theme, searchParams);
@@ -344,8 +377,31 @@ class RscRenderer {
344
377
  this.#logger.verbose(`render[${requestId}] starting Flight stream`);
345
378
  const result = await this.#renderFlightElement(element, msg.clientManifest ?? this.#clientManifest, {
346
379
  requestId,
347
- collectChunks: cacheKey !== null,
380
+ collectChunks: cacheEntry !== null,
348
381
  status: match ? undefined : 404,
382
+ onComplete: ({ chunks, bytes, chunksCount, control, lateControlSent }) => {
383
+ const cacheState = shouldStoreRouteCache({
384
+ policy: getRequestPolicy(),
385
+ dynamicUsage: getRequestDynamicUsage(),
386
+ renderControlType: control?.type,
387
+ lateRedirect: control?.type === "redirect" && lateControlSent,
388
+ });
389
+ const storeTtl = cacheEntry ? resolveRouteCacheStoreTtl(cacheEntry.ttl, cacheState) : null;
390
+ if (cacheEntry && storeTtl !== null) {
391
+ this.#setCachedResult(
392
+ cacheEntry.key,
393
+ {
394
+ chunks,
395
+ bytes,
396
+ chunksCount,
397
+ theme: getRequestTheme(),
398
+ cacheState,
399
+ },
400
+ storeTtl,
401
+ );
402
+ }
403
+ return cacheState;
404
+ },
349
405
  });
350
406
  if (result.cancelled) return;
351
407
  const control = result.control;
@@ -383,6 +439,16 @@ class RscRenderer {
383
439
  ) {
384
440
  return;
385
441
  }
442
+ if (
443
+ control.type === "not-found" &&
444
+ (await this.#trySendSystemNotFoundRender({
445
+ requestId,
446
+ url: urlObj,
447
+ clientManifest: msg.clientManifest ?? this.#clientManifest,
448
+ }))
449
+ ) {
450
+ return;
451
+ }
386
452
  this.#sendRenderControl(requestId, control);
387
453
  return;
388
454
  }
@@ -396,15 +462,10 @@ class RscRenderer {
396
462
  this.#stats.lastRenderLoadedModuleDelta = this.#stats.lastRenderLoadedModules.length;
397
463
  this.#recordRouteStats(routeId, result.bytes, this.#stats.lastRenderDurationMs);
398
464
  const responseTheme = getRequestTheme();
399
- if (cacheKey)
400
- this.#setCachedResult(cacheKey, {
401
- chunks: result.chunks,
402
- bytes: result.bytes,
403
- chunksCount: result.chunksCount,
404
- theme: responseTheme,
405
- });
406
465
  this.#logger.verbose(
407
- `render[${requestId}] done chunks=${result.chunksCount} bytes=${result.bytes} in ${Date.now() - startedAt}ms`,
466
+ `render[${requestId}] done chunks=${result.chunksCount} bytes=${result.bytes} theme=${responseTheme ?? "(none)"} in ${
467
+ Date.now() - startedAt
468
+ }ms`,
408
469
  );
409
470
  });
410
471
  } catch (error) {
@@ -441,6 +502,16 @@ class RscRenderer {
441
502
  ) {
442
503
  return;
443
504
  }
505
+ if (
506
+ fallbackUrl &&
507
+ (await this.#trySendSystemNotFoundRender({
508
+ requestId,
509
+ url: fallbackUrl,
510
+ clientManifest: msg.clientManifest ?? this.#clientManifest,
511
+ }))
512
+ ) {
513
+ return;
514
+ }
444
515
  this.#send({ type: "not-found", requestId });
445
516
  return;
446
517
  }
@@ -527,6 +598,13 @@ class RscRenderer {
527
598
  requestId?: string;
528
599
  collectChunks?: boolean;
529
600
  status?: number;
601
+ onComplete?: (result: {
602
+ chunks: Uint8Array[];
603
+ bytes: number;
604
+ chunksCount: number;
605
+ control: RenderControl | null;
606
+ lateControlSent: boolean;
607
+ }) => Promise<RouteCacheRenderState> | RouteCacheRenderState;
530
608
  } = {},
531
609
  ): Promise<FlightRenderResult> {
532
610
  const controlRef: { current: RenderControl | null } = { current: null };
@@ -539,7 +617,11 @@ class RscRenderer {
539
617
  method: error.method,
540
618
  status: error.status,
541
619
  };
542
- return error.digest;
620
+ return encodeAkanRedirectDigest({
621
+ location: error.location,
622
+ method: error.method,
623
+ status: error.status,
624
+ });
543
625
  }
544
626
  if (isAkanNotFoundError(error)) {
545
627
  controlRef.current = { type: "not-found" };
@@ -611,6 +693,14 @@ class RscRenderer {
611
693
  return { chunks, bytes, chunksCount, control: controlRef.current, lateControlSent, cancelled: false };
612
694
  if (options.requestId) {
613
695
  sendMeta();
696
+ const cacheState = (await options.onComplete?.({
697
+ chunks,
698
+ bytes,
699
+ chunksCount,
700
+ control: controlRef.current,
701
+ lateControlSent,
702
+ })) ?? { cacheable: false, reason: "uncacheable-render" };
703
+ this.#send({ type: "cache-state", requestId: options.requestId, state: cacheState });
614
704
  this.#send({ type: "end", requestId: options.requestId });
615
705
  }
616
706
  return {
@@ -677,6 +767,37 @@ class RscRenderer {
677
767
  }
678
768
  }
679
769
 
770
+ async #trySendSystemNotFoundRender({
771
+ requestId,
772
+ url,
773
+ clientManifest,
774
+ }: {
775
+ requestId: string;
776
+ url: URL;
777
+ clientManifest: ClientManifest;
778
+ }): Promise<boolean> {
779
+ try {
780
+ const result = await this.#renderFlightElement(this.#renderSystemNotFound(url), clientManifest, {
781
+ requestId,
782
+ status: 404,
783
+ });
784
+ if (result.cancelled) return true;
785
+ if (result.control) return false;
786
+ this.#stats.lastFlightBytes = result.bytes;
787
+ this.#stats.lastFlightChunks = result.chunksCount;
788
+ this.#stats.totalFlightBytes += result.bytes;
789
+ this.#stats.totalFlightChunks += result.chunksCount;
790
+ return true;
791
+ } catch (error) {
792
+ this.#logger.error(
793
+ `render[${requestId}] system not-found fallback failed: ${
794
+ error instanceof Error ? (error.stack ?? error.message) : String(error)
795
+ }`,
796
+ );
797
+ return false;
798
+ }
799
+ }
800
+
680
801
  #sendRenderControl(requestId: string, control: RenderControl): void {
681
802
  if (control.type === "redirect") {
682
803
  this.#logger.verbose(`render[${requestId}] redirect ${control.location}`);
@@ -719,35 +840,29 @@ class RscRenderer {
719
840
  }));
720
841
  }
721
842
 
722
- async #getResultCacheKey(request: Request, url: URL, pathRoute: PathRoute): Promise<string | null> {
723
- const config = await pathRoute.renderPage.getPageConfig?.();
724
- const ttl = RscRenderer.#normalizeCacheTtl(config?.rscCacheTtl);
725
- updateRequestPolicy({
726
- rscCache: config?.rscCache,
727
- rscCacheTtl: config?.rscCacheTtl,
728
- cacheable: config?.rscCache === "public" || ttl !== null,
843
+ #getResultCacheEntry(request: Request, url: URL): RouteCacheEntry | null {
844
+ const ttl = resolveAutoRouteCacheTtl({
845
+ enabled: process.env.AKAN_RSC_RESULT_CACHE,
846
+ ttl: process.env.AKAN_RSC_RESULT_CACHE_TTL,
729
847
  });
730
- if (config?.rscCache !== "public" && ttl === null) {
848
+ if (ttl === null) {
731
849
  this.#resultCacheBypass += 1;
732
850
  return null;
733
851
  }
734
- if (ttl === 0) {
852
+ if (
853
+ !isRouteCachePathAllowed(url.pathname, {
854
+ allow: process.env.AKAN_RSC_RESULT_CACHE_PATHS,
855
+ deny: process.env.AKAN_RSC_RESULT_CACHE_EXCLUDE_PATHS,
856
+ })
857
+ ) {
735
858
  this.#resultCacheBypass += 1;
736
859
  return null;
737
860
  }
738
- if (!RscRenderer.#isPublicCacheableRequest(request)) {
861
+ if (!isPublicRouteCacheableRequest(request)) {
739
862
  this.#resultCacheBypass += 1;
740
863
  return null;
741
864
  }
742
- return [
743
- request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? url.host,
744
- request.headers.get("x-base-path") ?? "",
745
- url.pathname,
746
- url.search,
747
- request.headers.get("accept-language") ?? "",
748
- cookies().get("theme")?.value ?? "",
749
- ttl ?? 30,
750
- ].join("\n");
865
+ return createRouteCacheEntry({ request, url, theme: untrackedCookies().get("theme")?.value, ttl });
751
866
  }
752
867
 
753
868
  #getCachedResult(cacheKey: string): CachedRscResult | null {
@@ -756,27 +871,12 @@ class RscRenderer {
756
871
  this.#resultCacheMisses += 1;
757
872
  return null;
758
873
  }
759
- if (cached.expiresAt <= Date.now()) {
760
- this.#resultCache.delete(cacheKey);
761
- this.#resultCacheMisses += 1;
762
- return null;
763
- }
764
874
  this.#resultCacheHits += 1;
765
875
  return cached;
766
876
  }
767
877
 
768
- #setCachedResult(
769
- cacheKey: string,
770
- result: { chunks: Uint8Array[]; bytes: number; chunksCount: number; theme?: string },
771
- ): void {
772
- const ttl = Number.parseInt(cacheKey.split("\n").at(-1) ?? "30", 10);
773
- const maxEntries = RscRenderer.#parsePositiveIntEnv("AKAN_RSC_RESULT_CACHE_MAX_ENTRIES") ?? 100;
774
- while (this.#resultCache.size >= maxEntries) {
775
- const firstKey = this.#resultCache.keys().next().value;
776
- if (!firstKey) break;
777
- this.#resultCache.delete(firstKey);
778
- }
779
- this.#resultCache.set(cacheKey, { ...result, expiresAt: Date.now() + ttl * 1000 });
878
+ #setCachedResult(cacheKey: string, result: CachedRscResult, ttl: number): void {
879
+ this.#resultCache.set(cacheKey, result, ttl);
780
880
  }
781
881
 
782
882
  #runWithRequest<T>(request: Request, fn: () => Promise<T>): Promise<T> {
@@ -813,8 +913,18 @@ class RscRenderer {
813
913
  digest,
814
914
  });
815
915
  if (!body) return null;
816
- const routeHead = "resolveHead" in route ? await route.resolveHead?.({ params, searchParams }) : undefined;
817
- const theme = cookies().get("theme")?.value;
916
+ const routeHead =
917
+ "resolveHead" in route
918
+ ? await RouteElementComposer.resolveHeadWithMetadata({
919
+ pathRoute: route,
920
+ params,
921
+ searchParams,
922
+ })
923
+ : { node: undefined, hasExplicitLanguageAlternates: false };
924
+ const renderLocaleAlternates = shouldRenderLocaleAlternates({
925
+ hasExplicitLanguageAlternates: routeHead.hasExplicitLanguageAlternates,
926
+ });
927
+ const theme = untrackedCookies().get("theme")?.value;
818
928
  return (
819
929
  <html
820
930
  lang={params.lang ?? RscRenderer.#getLocale(pathname, this.#i18n)}
@@ -824,8 +934,8 @@ class RscRenderer {
824
934
  <meta key="charset" charSet="utf-8" />
825
935
  <meta key="viewport" name="viewport" content="width=device-width, initial-scale=1" />
826
936
  <meta key="robots" name="robots" content="noindex" />
827
- {routeHead ?? this.#renderDefaultHead()}
828
- {this.#renderLocaleAlternates(url)}
937
+ {routeHead.node ?? this.#renderDefaultHead()}
938
+ {renderLocaleAlternates ? this.#renderLocaleAlternates(url) : null}
829
939
  {this.#renderStylesheet(pathname)}
830
940
  </head>
831
941
  <body key="body">{body}</body>
@@ -842,11 +952,15 @@ class RscRenderer {
842
952
  this.#logger.verbose(
843
953
  `composing route element pathname=${url.pathname} search=${url.search || "(none)"} params=${JSON.stringify(match.params)}`,
844
954
  );
845
- const routeHead = await RouteElementComposer.resolveHead({
955
+ const routeHead = await RouteElementComposer.resolveHeadWithMetadata({
846
956
  pathRoute: match.pathRoute,
847
957
  params: match.params,
848
958
  searchParams,
849
959
  });
960
+ const renderLocaleAlternates = shouldRenderLocaleAlternates({
961
+ isSpecialRoute: match.pathRoute.isSpecialRoute,
962
+ hasExplicitLanguageAlternates: routeHead.hasExplicitLanguageAlternates,
963
+ });
850
964
  const body = RouteElementComposer.compose({
851
965
  pathRoute: match.pathRoute,
852
966
  params: match.params,
@@ -860,8 +974,8 @@ class RscRenderer {
860
974
  <head key="head">
861
975
  <meta key="charset" charSet="utf-8" />
862
976
  <meta key="viewport" name="viewport" content="width=device-width, initial-scale=1" />
863
- {routeHead ?? this.#renderDefaultHead()}
864
- {match.pathRoute.isSpecialRoute ? null : this.#renderLocaleAlternates(url)}
977
+ {routeHead.node ?? this.#renderDefaultHead()}
978
+ {renderLocaleAlternates ? this.#renderLocaleAlternates(url) : null}
865
979
  {this.#renderStylesheet(url.pathname)}
866
980
  </head>
867
981
  <body key="body">{body}</body>
@@ -900,7 +1014,7 @@ class RscRenderer {
900
1014
  pathname: url.pathname,
901
1015
  i18n: this.#i18n,
902
1016
  basePaths: this.#basePaths,
903
- headerBasePath: getRequest()?.headers.get("x-base-path"),
1017
+ headerBasePath: untrackedRequest()?.headers.get("x-base-path"),
904
1018
  }),
905
1019
  stylesheetHref: this.#getStylesheetHref(url.pathname),
906
1020
  });
@@ -938,7 +1052,7 @@ class RscRenderer {
938
1052
  const basePath = getBasePathFromPathname(pathname, {
939
1053
  basePaths: Object.keys(this.#cssAssets),
940
1054
  i18n: this.#i18n,
941
- headerBasePath: getRequest()?.headers.get("x-base-path"),
1055
+ headerBasePath: untrackedRequest()?.headers.get("x-base-path"),
942
1056
  });
943
1057
  return this.#cssAssets[basePath ?? ""]?.cssUrl ?? null;
944
1058
  }
@@ -948,29 +1062,6 @@ class RscRenderer {
948
1062
  return segment && i18n.locales.includes(segment) ? segment : i18n.defaultLocale;
949
1063
  }
950
1064
 
951
- static #parsePositiveIntEnv(name: string): number | null {
952
- const parsed = Number.parseInt(process.env[name] ?? "", 10);
953
- return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
954
- }
955
-
956
- static #normalizeCacheTtl(value: unknown): number | null {
957
- if (value === undefined || value === null) return null;
958
- if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return null;
959
- return Math.floor(value);
960
- }
961
-
962
- static #isPublicCacheableRequest(request: Request): boolean {
963
- if (request.method !== "GET") return false;
964
- if (request.headers.has("authorization")) return false;
965
- const cookie = request.headers.get("cookie");
966
- if (!cookie) return true;
967
- return cookie
968
- .split(";")
969
- .map((part) => part.trim().split("=")[0])
970
- .filter(Boolean)
971
- .every((name) => name === "theme" || name.startsWith("akan_public_"));
972
- }
973
-
974
1065
  static #errorForFallback(error: unknown): unknown {
975
1066
  if (process.env.NODE_ENV !== "production") return error;
976
1067
  return undefined;
@@ -978,7 +1069,7 @@ class RscRenderer {
978
1069
 
979
1070
  static #getPublicRequestUrl(url: URL): URL {
980
1071
  const publicUrl = new URL(url);
981
- const req = getRequest();
1072
+ const req = untrackedRequest();
982
1073
  const headers = req?.headers;
983
1074
  const host = headers?.get("x-forwarded-host") ?? headers?.get("host");
984
1075
  const proto = headers?.get("x-forwarded-proto");
@@ -5,6 +5,7 @@ import { type AkanI18nConfig, DEFAULT_AKAN_I18N, Logger } from "akanjs/common";
5
5
  import type { AkanTheme } from "akanjs/fetch";
6
6
  import type { AkanMetricsReport } from "akanjs/service";
7
7
  import type { ClientManifest } from "./artifact";
8
+ import type { RouteCacheRenderState } from "./cachePolicy";
8
9
  import type { SsrLateRedirect } from "./ssrTypes";
9
10
  import type { BaseBuildArtifact, CssAsset } from "./types";
10
11
 
@@ -15,6 +16,7 @@ export interface RscPending {
15
16
  onEnd: () => void;
16
17
  onError: (message: string) => void;
17
18
  onMeta?: (meta: { theme?: AkanTheme; status?: number }) => void;
19
+ onCacheState?: (state: RouteCacheRenderState) => void;
18
20
  onRedirect?: (location: string, method: RscRedirectMethod, status: RscRedirectStatus) => void;
19
21
  onLateRedirect?: (location: string, method: RscRedirectMethod, status: RscRedirectStatus) => void;
20
22
  onNotFound?: () => void;
@@ -23,6 +25,11 @@ export interface RscPending {
23
25
  export type RscRedirectMethod = "replace" | "push";
24
26
  export type RscRedirectStatus = 303 | 307 | 308;
25
27
 
28
+ export interface RscWorkerInvalidateCacheMessage {
29
+ type: "invalidate-cache";
30
+ reason?: string;
31
+ }
32
+
26
33
  export type RscRenderResult =
27
34
  | {
28
35
  type: "stream";
@@ -30,6 +37,7 @@ export type RscRenderResult =
30
37
  theme?: AkanTheme;
31
38
  status?: number;
32
39
  lateControl: Promise<SsrLateRedirect | null>;
40
+ cacheState: Promise<RouteCacheRenderState>;
33
41
  cancel: (reason?: unknown) => void;
34
42
  }
35
43
  | { type: "redirect"; location: string; method: RscRedirectMethod; status: RscRedirectStatus }
@@ -64,6 +72,10 @@ export function createIdempotentRscRenderCancel(onCancel: (reason?: unknown) =>
64
72
  };
65
73
  }
66
74
 
75
+ export function createRscWorkerInvalidateCacheMessage(reason?: string): RscWorkerInvalidateCacheMessage {
76
+ return reason ? { type: "invalidate-cache", reason } : { type: "invalidate-cache" };
77
+ }
78
+
67
79
  export function createRscHostRenderStream(input: {
68
80
  setPending: (pending: RscPending) => void;
69
81
  deletePending: () => void;
@@ -78,15 +90,25 @@ export function createRscHostRenderStream(input: {
78
90
  let theme: AkanTheme | undefined;
79
91
  let status: number | undefined;
80
92
  let resolveLateControl!: (control: SsrLateRedirect | null) => void;
93
+ let resolveCacheState!: (state: RouteCacheRenderState) => void;
81
94
  const lateControl = new Promise<SsrLateRedirect | null>((resolve) => {
82
95
  resolveLateControl = resolve;
83
96
  });
97
+ const cacheState = new Promise<RouteCacheRenderState>((resolve) => {
98
+ resolveCacheState = resolve;
99
+ });
84
100
  let lateControlSettled = false;
101
+ let cacheStateSettled = false;
85
102
  const settleLateControl = (control: SsrLateRedirect | null) => {
86
103
  if (lateControlSettled) return;
87
104
  lateControlSettled = true;
88
105
  resolveLateControl(control);
89
106
  };
107
+ const settleCacheState = (state: RouteCacheRenderState) => {
108
+ if (cacheStateSettled) return;
109
+ cacheStateSettled = true;
110
+ resolveCacheState(state);
111
+ };
90
112
  const maxPendingChunks = input.maxPendingChunks ?? getRscHostMaxPendingChunks();
91
113
  let pendingChunks = 0;
92
114
  let removeAbortListener: (() => void) | undefined;
@@ -97,6 +119,7 @@ export function createRscHostRenderStream(input: {
97
119
  const cancelRender = createIdempotentRscRenderCancel((reason) => {
98
120
  input.deletePending();
99
121
  settleLateControl(null);
122
+ settleCacheState({ cacheable: false, reason: "cancelled" });
100
123
  input.cancelRender(reason);
101
124
  cleanupAbortListener();
102
125
  });
@@ -117,7 +140,7 @@ export function createRscHostRenderStream(input: {
117
140
  const settleStream = () => {
118
141
  if (settled) return;
119
142
  settled = true;
120
- resolve({ type: "stream", stream, theme, status, lateControl, cancel: cancelRender });
143
+ resolve({ type: "stream", stream, theme, status, lateControl, cacheState, cancel: cancelRender });
121
144
  };
122
145
  input.setPending({
123
146
  onMeta: (meta) => {
@@ -140,12 +163,14 @@ export function createRscHostRenderStream(input: {
140
163
  },
141
164
  onEnd: () => {
142
165
  settleLateControl(null);
166
+ settleCacheState({ cacheable: false, reason: "missing-cache-state" });
143
167
  cleanupAbortListener();
144
168
  settleStream();
145
169
  controller.close();
146
170
  },
147
171
  onError: (msg) => {
148
172
  settleLateControl(null);
173
+ settleCacheState({ cacheable: false, reason: "error" });
149
174
  cleanupAbortListener();
150
175
  if (!settled) {
151
176
  settled = true;
@@ -156,6 +181,7 @@ export function createRscHostRenderStream(input: {
156
181
  },
157
182
  onRedirect: (location, method, status) => {
158
183
  settleLateControl(null);
184
+ settleCacheState({ cacheable: false, reason: "redirect" });
159
185
  cleanupAbortListener();
160
186
  if (!settled) {
161
187
  settled = true;
@@ -168,8 +194,12 @@ export function createRscHostRenderStream(input: {
168
194
  onLateRedirect: (location, method, status) => {
169
195
  settleLateControl({ type: "redirect", location, method, status });
170
196
  },
197
+ onCacheState: (state) => {
198
+ settleCacheState(state);
199
+ },
171
200
  onNotFound: () => {
172
201
  settleLateControl(null);
202
+ settleCacheState({ cacheable: false, reason: "not-found" });
173
203
  cleanupAbortListener();
174
204
  if (!settled) {
175
205
  settled = true;
@@ -205,6 +235,7 @@ type RscInMsg =
205
235
  | { type: "ready" }
206
236
  | { type: "reloaded"; buildId: number }
207
237
  | { type: "meta"; requestId: string; theme?: AkanTheme; status?: number }
238
+ | { type: "cache-state"; requestId: string; state: RouteCacheRenderState }
208
239
  | { type: "chunk"; requestId: string; data: Uint8Array }
209
240
  | { type: "end"; requestId: string }
210
241
  | { type: "redirect"; requestId: string; location: string; method?: RscRedirectMethod; status?: RscRedirectStatus }
@@ -386,6 +417,17 @@ export class RscWorker {
386
417
  });
387
418
  }
388
419
 
420
+ invalidateRouteResultCache(reason?: string): void {
421
+ if (this.#status !== "ready") return;
422
+ try {
423
+ this.#proc.send(createRscWorkerInvalidateCacheMessage(reason));
424
+ } catch (error) {
425
+ this.#logger.warn(
426
+ `rsc worker cache invalidate send failed: ${error instanceof Error ? error.message : String(error)}`,
427
+ );
428
+ }
429
+ }
430
+
389
431
  kill(): void {
390
432
  this.#killed = true;
391
433
  this.#status = "stopped";
@@ -527,6 +569,10 @@ export class RscWorker {
527
569
 
528
570
  #handleMessage(message: RscInMsg, proc: Bun.Subprocess<"ignore", "inherit", "inherit">): void {
529
571
  if (proc !== this.#proc) return;
572
+ if (message.type === "cache-state") {
573
+ this.#pending.get(message.requestId)?.onCacheState?.(message.state);
574
+ return;
575
+ }
530
576
  switch (message.type) {
531
577
  case "hello":
532
578
 
@@ -1,5 +1,8 @@
1
+ import type { RouteCacheRenderState } from "./cachePolicy";
2
+
1
3
  export type CachedRscReplayMessage =
2
4
  | { type: "meta"; requestId: string; theme?: string; status?: number }
5
+ | { type: "cache-state"; requestId: string; state: RouteCacheRenderState }
3
6
  | { type: "chunk"; requestId: string; data: Uint8Array }
4
7
  | { type: "end"; requestId: string };
5
8
 
@@ -12,6 +15,7 @@ export async function replayCachedRscResult(input: {
12
15
  chunks: readonly Uint8Array[];
13
16
  theme?: string;
14
17
  status?: number;
18
+ cacheState?: RouteCacheRenderState;
15
19
  send: (message: CachedRscReplayMessage) => void;
16
20
  isCancelled: () => boolean;
17
21
  yieldEveryChunks?: number;
@@ -24,6 +28,7 @@ export async function replayCachedRscResult(input: {
24
28
  const yieldToHost = input.yieldToHost ?? yieldToHostEventLoop;
25
29
  if (input.isCancelled()) return false;
26
30
  input.send({ type: "meta", requestId: input.requestId, theme: input.theme, status: input.status });
31
+ input.send({ type: "cache-state", requestId: input.requestId, state: input.cacheState ?? { cacheable: true } });
27
32
  for (let index = 0; index < input.chunks.length; index += 1) {
28
33
  if (input.isCancelled()) return false;
29
34
  input.send({ type: "chunk", requestId: input.requestId, data: input.chunks[index] });