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
@@ -1,5 +1,5 @@
1
1
  import { Readable } from "node:stream";
2
- import { type AkanTheme, pushRequestFallback, requestStorage } from "akanjs/fetch";
2
+ import { type AkanRequestStore, type AkanTheme, pushRequestFallback, requestStorage } from "akanjs/fetch";
3
3
  import { type ReactNode, use } from "react";
4
4
  import { renderToReadableStream } from "react-dom/server.browser";
5
5
  import { createFromNodeStream } from "react-server-dom-webpack/client.node";
@@ -109,9 +109,14 @@ export function createInlineRscScript(chunk: Uint8Array): string {
109
109
  return `<script>self.__RSC_PUSH__(${type},${htmlEscapeJsonString(data)})</script>`;
110
110
  }
111
111
 
112
+ function escapeHtmlAttr(value: string): string {
113
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
114
+ }
115
+
112
116
  export function createSoftRedirectScript(redirect: SsrLateRedirect): string {
113
117
  const method = redirect.method === "push" ? "assign" : "replace";
114
- return `<script>window.location.${method}(${htmlEscapeJsonString(redirect.location)})</script>`;
118
+ const fallback = `<noscript><meta http-equiv="refresh" content="0;url=${escapeHtmlAttr(redirect.location)}"></noscript>`;
119
+ return `${fallback}<script>window.location.${method}(${htmlEscapeJsonString(redirect.location)})</script>`;
115
120
  }
116
121
 
117
122
  function sanitizeFlightRows(
@@ -121,7 +126,7 @@ function sanitizeFlightRows(
121
126
  const decoder = new TextDecoder("utf-8", { fatal: true });
122
127
  const encoder = new TextEncoder();
123
128
  const hlStylesheetRe = /(:HL\["[^"\\]*(?:\\.[^"\\]*)*",)"stylesheet"(\])/g;
124
- const redirectErrorRowRe = /^([0-9a-z]+):E(\{[^\n]*"digest":"AKAN_REDIRECT"[^\n]*\})(\n?)$/;
129
+ const redirectErrorRowRe = /^([0-9a-z]+):E(\{[^\n]*"digest":"AKAN_REDIRECT(?:;[^"]*)?"[^\n]*\})(\n?)$/;
125
130
  let buffered: Uint8Array<ArrayBuffer> = new Uint8Array(0);
126
131
 
127
132
  const concatBytes = (left: Uint8Array, right: Uint8Array): Uint8Array<ArrayBuffer> => {
@@ -335,6 +340,7 @@ export function interleaveRscScriptsWithHtml(
335
340
  onComplete?: () => void;
336
341
  onCancel?: (reason?: unknown) => void;
337
342
  request?: Request;
343
+ requestStore?: AkanRequestStore;
338
344
  } = {},
339
345
  ): ReadableStream<Uint8Array> {
340
346
  const encoder = new TextEncoder();
@@ -464,7 +470,8 @@ export function interleaveRscScriptsWithHtml(
464
470
  };
465
471
 
466
472
  const runPump = () => {
467
- const cleanup = options.request ? pushRequestFallback(options.request) : undefined;
473
+ const requestContext = options.requestStore ?? options.request;
474
+ const cleanup = requestContext ? pushRequestFallback(requestContext) : undefined;
468
475
  return pump()
469
476
  .catch(fail)
470
477
  .finally(() => {
@@ -472,7 +479,8 @@ export function interleaveRscScriptsWithHtml(
472
479
  options.onComplete?.();
473
480
  });
474
481
  };
475
- if (options.request && requestStorage) void requestStorage.run(options.request, runPump);
482
+ const requestContext = options.requestStore ?? options.request;
483
+ if (requestContext && requestStorage) void requestStorage.run(requestContext, runPump);
476
484
  else void runPump();
477
485
  },
478
486
  cancel(reason) {
@@ -589,8 +597,9 @@ export class SsrFromRscRenderer {
589
597
  renderToReadableStream(<Root />, {
590
598
  bootstrapScriptContent: bootstrap,
591
599
  });
600
+ const requestContext = input.requestStore ?? input.request;
592
601
  const htmlStream =
593
- input.request && requestStorage ? await requestStorage.run(input.request, renderHtml) : await renderHtml();
602
+ requestContext && requestStorage ? await requestStorage.run(requestContext, renderHtml) : await renderHtml();
594
603
 
595
604
  const withHeadScripts = SsrFromRscRenderer.#injectHeadScriptsIntoHead(htmlStream, {
596
605
  importmap: input.importmap,
@@ -604,6 +613,7 @@ export class SsrFromRscRenderer {
604
613
  SsrFromRscRenderer.#sanitizeFlightForClient(rscForClient),
605
614
  input.bootstrapModules,
606
615
  input.request,
616
+ input.requestStore,
607
617
  input.lateControl,
608
618
  () => stderrSuppressor?.stop(),
609
619
  input.onCancel,
@@ -771,6 +781,7 @@ export class SsrFromRscRenderer {
771
781
  rscClientStream: ReadableStream<Uint8Array>,
772
782
  bootstrapModules?: string[],
773
783
  request?: Request,
784
+ requestStore?: AkanRequestStore,
774
785
  lateControl?: Promise<SsrLateRedirect | null>,
775
786
  onComplete?: () => void,
776
787
  onCancel?: (reason?: unknown) => void,
@@ -783,6 +794,7 @@ export class SsrFromRscRenderer {
783
794
  onComplete,
784
795
  onCancel,
785
796
  request,
797
+ requestStore,
786
798
  });
787
799
  }
788
800
  }
@@ -1,4 +1,4 @@
1
- import type { AkanTheme } from "akanjs/fetch";
1
+ import type { AkanRequestStore, AkanTheme } from "akanjs/fetch";
2
2
 
3
3
  export interface SsrManifestEntry {
4
4
  id: string;
@@ -28,6 +28,7 @@ export interface SsrLateRedirect {
28
28
 
29
29
  export interface SsrFromRscInput {
30
30
  request?: Request;
31
+ requestStore?: AkanRequestStore;
31
32
  rscStream: ReadableStream<Uint8Array>;
32
33
  ssrManifest: SsrManifest;
33
34
  bootstrapModules?: string[];
@@ -8,7 +8,7 @@ import {
8
8
  Logger,
9
9
  parseAkanI18nEnv,
10
10
  } from "akanjs/common";
11
- import { parseCookieHeader } from "akanjs/fetch";
11
+ import { type AkanRequestStore, createRequestStore, parseCookieHeader } from "akanjs/fetch";
12
12
  import type { AkanMetricsReport } from "akanjs/service";
13
13
  import {
14
14
  type BuilderRpc,
@@ -17,6 +17,19 @@ import {
17
17
  RouteSeedIndexStore,
18
18
  RoutesManifestStore,
19
19
  } from "./artifact";
20
+ import {
21
+ createRouteCacheEntry,
22
+ getClientFacingOrigin,
23
+ isPublicRouteCacheableRequest,
24
+ isRouteCachePathAllowed,
25
+ LruTtlCache,
26
+ normalizeRouteCacheTtl,
27
+ parsePositiveInt,
28
+ type RouteCacheEntry,
29
+ type RouteCacheRenderState,
30
+ resolveRouteCacheStoreTtl,
31
+ shouldStoreRouteCache,
32
+ } from "./cachePolicy";
20
33
  import { DevHmrController } from "./hmr";
21
34
  import { HMR_CLIENT_SCRIPT } from "./hmr/clientScript";
22
35
  import type { HmrWsData, HmrWsHub } from "./hmr/wsHub";
@@ -57,22 +70,36 @@ export function createRscStreamResponse(stream: BodyInit, status = 200): Respons
57
70
  });
58
71
  }
59
72
 
73
+ export function createRscNotFoundFallbackResponse(): Response {
74
+ return createRscStreamResponse("0:null\n", 404);
75
+ }
76
+
60
77
  export function cacheHtmlWhileStreaming(
61
78
  stream: ReadableStream<Uint8Array>,
62
79
  onComplete: (html: string) => void,
80
+ options: { shouldCache?: () => boolean | Promise<boolean>; maxBodyBytes?: number | null } = {},
63
81
  ): ReadableStream<Uint8Array> {
64
82
  const chunks: Uint8Array[] = [];
65
83
  let byteLength = 0;
84
+ let exceededMaxBodyBytes = false;
66
85
  const decoder = new TextDecoder();
67
86
 
68
87
  return stream.pipeThrough(
69
88
  new TransformStream<Uint8Array, Uint8Array>({
70
89
  transform(chunk, controller) {
71
- chunks.push(chunk.slice());
72
- byteLength += chunk.byteLength;
90
+ if (!exceededMaxBodyBytes) {
91
+ byteLength += chunk.byteLength;
92
+ if (options.maxBodyBytes && byteLength > options.maxBodyBytes) {
93
+ exceededMaxBodyBytes = true;
94
+ chunks.length = 0;
95
+ } else {
96
+ chunks.push(chunk.slice());
97
+ }
98
+ }
73
99
  controller.enqueue(chunk);
74
100
  },
75
- flush() {
101
+ async flush() {
102
+ if (exceededMaxBodyBytes) return;
76
103
  const body = new Uint8Array(byteLength);
77
104
  let offset = 0;
78
105
  for (const chunk of chunks) {
@@ -80,6 +107,7 @@ export function cacheHtmlWhileStreaming(
80
107
  offset += chunk.byteLength;
81
108
  }
82
109
  try {
110
+ if (options.shouldCache && !(await options.shouldCache())) return;
83
111
  onComplete(decoder.decode(body));
84
112
  } catch {
85
113
  }
@@ -93,34 +121,41 @@ export function cancelStreamForHeadResponse(stream: ReadableStream<Uint8Array>,
93
121
  });
94
122
  }
95
123
 
124
+ export function resolveHtmlRouteCacheStoreTtl(input: {
125
+ baseTtl: number;
126
+ workerCacheState: RouteCacheRenderState;
127
+ hostRequestStore: AkanRequestStore;
128
+ lateControl?: { type: "redirect" } | null;
129
+ }): number | null {
130
+ if (input.lateControl?.type === "redirect") return null;
131
+ const workerTtl = resolveRouteCacheStoreTtl(input.baseTtl, input.workerCacheState);
132
+ if (workerTtl === null) return null;
133
+ const hostCacheState = shouldStoreRouteCache({
134
+ policy: input.hostRequestStore.policy,
135
+ dynamicUsage: input.hostRequestStore.dynamicUsage,
136
+ });
137
+ return resolveRouteCacheStoreTtl(workerTtl, hostCacheState);
138
+ }
139
+
140
+ export function isHtmlRouteCachePathAllowed(
141
+ pathname: string,
142
+ env: {
143
+ [key: string]: string | undefined;
144
+ AKAN_HTML_RESULT_CACHE_PATHS?: string;
145
+ AKAN_HTML_RESULT_CACHE_EXCLUDE_PATHS?: string;
146
+ } = process.env as Record<string, string | undefined>,
147
+ ): boolean {
148
+ return isRouteCachePathAllowed(pathname, {
149
+ allow: env.AKAN_HTML_RESULT_CACHE_PATHS,
150
+ deny: env.AKAN_HTML_RESULT_CACHE_EXCLUDE_PATHS,
151
+ });
152
+ }
153
+
96
154
  export async function createRscNavigationStreamResponse(
97
155
  result: Extract<RscRenderResult, { type: "stream" }>,
98
156
  ): Promise<Response> {
99
157
 
100
- const chunks: Uint8Array[] = [];
101
- let byteLength = 0;
102
- const reader = result.stream.getReader();
103
- try {
104
- while (true) {
105
- const { value, done } = await reader.read();
106
- if (done) break;
107
- chunks.push(value);
108
- byteLength += value.byteLength;
109
- }
110
- } finally {
111
- reader.releaseLock();
112
- }
113
-
114
- const lateControl = await result.lateControl;
115
- if (lateControl?.type === "redirect")
116
- return createRscRedirectResponse(lateControl.location, lateControl.method, lateControl.status);
117
- const body = new Uint8Array(byteLength);
118
- let offset = 0;
119
- for (const chunk of chunks) {
120
- body.set(chunk, offset);
121
- offset += chunk.byteLength;
122
- }
123
- return createRscStreamResponse(body, result.status ?? 200);
158
+ return createRscStreamResponse(result.stream, result.status ?? 200);
124
159
  }
125
160
 
126
161
  export function normalizeRscTargetUrlForHostBasePath(
@@ -173,7 +208,6 @@ interface WebRouterOptions {
173
208
  }
174
209
 
175
210
  interface CachedHtmlResult {
176
- expiresAt: number;
177
211
  html: string;
178
212
  }
179
213
 
@@ -194,7 +228,9 @@ export class WebRouter {
194
228
  csr: 0,
195
229
  image: 0,
196
230
  };
197
- readonly #htmlCache = new Map<string, CachedHtmlResult>();
231
+ readonly #htmlCache = new LruTtlCache<CachedHtmlResult>(
232
+ parsePositiveInt(process.env.AKAN_HTML_RESULT_CACHE_MAX_ENTRIES) ?? 100,
233
+ );
198
234
  #htmlCacheHits = 0;
199
235
  #htmlCacheMisses = 0;
200
236
  #htmlCacheBypass = 0;
@@ -444,8 +480,8 @@ export class WebRouter {
444
480
  try {
445
481
  this.#requestStats.fullSsr += 1;
446
482
  const manifest = await this.#ensureRoute(url);
447
- const htmlCacheKey = this.#getHtmlCacheKey(req, url);
448
- const cachedHtml = htmlCacheKey ? this.#getCachedHtml(htmlCacheKey) : null;
483
+ const htmlCacheEntry = this.#getHtmlCacheEntry(req, url);
484
+ const cachedHtml = htmlCacheEntry ? this.#getCachedHtml(htmlCacheEntry.key) : null;
449
485
  if (cachedHtml) {
450
486
  return new Response(cachedHtml, {
451
487
  headers: {
@@ -462,8 +498,10 @@ export class WebRouter {
462
498
  return Response.redirect(new URL(rscResult.location, url.origin), rscResult.status);
463
499
  if (rscResult.type === "not-found") return this.#renderNotFoundResponse(req, url);
464
500
  const themeCookieExists = WebRouter.#hasCookie(req, "theme");
501
+ const hostRequestStore = createRequestStore(req);
465
502
  const htmlStream = await new SsrFromRscRenderer().render({
466
503
  request: req,
504
+ requestStore: hostRequestStore,
467
505
  rscStream: rscResult.stream,
468
506
  ssrManifest: manifest.ssrManifest,
469
507
  bootstrapModules: [this.#artifact.rscClientUrl],
@@ -471,7 +509,7 @@ export class WebRouter {
471
509
  importmap: this.#artifact.vendorMap,
472
510
  theme: themeCookieExists ? undefined : (rscResult.theme ?? "system"),
473
511
  lateControl: rscResult.lateControl,
474
- onCancel: (reason) => {
512
+ onCancel: (reason: unknown) => {
475
513
  rscResult.cancel(reason);
476
514
  },
477
515
  });
@@ -479,17 +517,38 @@ export class WebRouter {
479
517
  const responseHeaders = WebRouter.#htmlResponseHeaders(responseStatus);
480
518
  if (req.method === "HEAD") {
481
519
  const headers = new Headers(responseHeaders);
482
- if (htmlCacheKey && responseStatus === 200) headers.set("X-Akan-Cache", "MISS");
520
+ if (htmlCacheEntry && responseStatus === 200) headers.set("X-Akan-Cache", "MISS");
483
521
  cancelStreamForHeadResponse(htmlStream, new Error("HEAD response does not consume body"));
484
522
  return new Response(null, { status: responseStatus, headers });
485
523
  }
486
- if (htmlCacheKey && responseStatus === 200) {
524
+ if (htmlCacheEntry && responseStatus === 200) {
487
525
  const headers = new Headers(responseHeaders);
488
526
  headers.set("X-Akan-Cache", "MISS");
527
+ let htmlStoreTtl = htmlCacheEntry.ttl;
528
+ const shouldCacheHtml = Promise.all([rscResult.lateControl, rscResult.cacheState]).then(
529
+ ([control, cacheState]) => {
530
+ const storeTtl = resolveHtmlRouteCacheStoreTtl({
531
+ baseTtl: htmlCacheEntry.ttl,
532
+ workerCacheState: cacheState,
533
+ hostRequestStore,
534
+ lateControl: control,
535
+ });
536
+ if (storeTtl === null) return false;
537
+ htmlStoreTtl = storeTtl;
538
+ return true;
539
+ },
540
+ );
489
541
  return new Response(
490
- cacheHtmlWhileStreaming(htmlStream, (html) => {
491
- this.#setCachedHtml(htmlCacheKey, html);
492
- }),
542
+ cacheHtmlWhileStreaming(
543
+ htmlStream,
544
+ (html) => {
545
+ this.#setCachedHtml(htmlCacheEntry.key, html, htmlStoreTtl);
546
+ },
547
+ {
548
+ shouldCache: () => shouldCacheHtml,
549
+ maxBodyBytes: parsePositiveInt(process.env.AKAN_HTML_RESULT_CACHE_MAX_BODY_BYTES),
550
+ },
551
+ ),
493
552
  {
494
553
  status: responseStatus,
495
554
  headers,
@@ -533,23 +592,19 @@ export class WebRouter {
533
592
  httpHtmlCacheBypass: this.#htmlCacheBypass,
534
593
  };
535
594
  }
595
+
596
+ /** @internal Clears local route result caches owned by the host and RSC worker. */
597
+ invalidateRouteCaches(reason?: string): void {
598
+ this.#htmlCache.clear();
599
+ this.#rsc.invalidateRouteResultCache(reason);
600
+ }
601
+
536
602
  /**
537
603
  * Reconstruct origin as the browser saw it when behind Ingress / reverse proxies
538
604
  * (prevents `/__rsc` same-origin rejecting because `req.url` is internal).
539
605
  */
540
606
  static #clientFacingOrigin(req: Request): string {
541
- const parsed = new URL(req.url);
542
- const fwdProto = req.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
543
- const fwdHost = req.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
544
- const hostFallback = fwdHost ?? req.headers.get("host");
545
- const protoFallback = fwdProto ?? parsed.protocol.slice(0, -1);
546
- if (hostFallback && protoFallback) {
547
- try {
548
- return new URL(`${protoFallback}://${hostFallback}`).origin;
549
- } catch {
550
- }
551
- }
552
- return parsed.origin;
607
+ return getClientFacingOrigin(req);
553
608
  }
554
609
 
555
610
  static #basePathForRequestHost(req: Request, subRoutes: Record<string, string[]>): string | null {
@@ -576,33 +631,25 @@ export class WebRouter {
576
631
  static #hasCookie(req: Request, name: string): boolean {
577
632
  return parseCookieHeader(req.headers.get("cookie") ?? "").has(name);
578
633
  }
579
- #getHtmlCacheKey(req: Request, url: URL): string | null {
634
+ #getHtmlCacheEntry(req: Request, url: URL): RouteCacheEntry | null {
580
635
  if (!this.#prodMode || process.env.AKAN_HTML_RESULT_CACHE !== "1") {
581
636
  this.#htmlCacheBypass += 1;
582
637
  return null;
583
638
  }
584
- if (!WebRouter.#isPublicCacheableRequest(req)) {
639
+ if (!isPublicRouteCacheableRequest(req)) {
585
640
  this.#htmlCacheBypass += 1;
586
641
  return null;
587
642
  }
588
- if (!WebRouter.#isHtmlCachePathAllowed(url.pathname)) {
643
+ if (!isHtmlRouteCachePathAllowed(url.pathname)) {
589
644
  this.#htmlCacheBypass += 1;
590
645
  return null;
591
646
  }
592
- const ttl = WebRouter.#htmlCacheTtlSeconds();
593
- if (ttl <= 0) {
647
+ const ttl = normalizeRouteCacheTtl(process.env.AKAN_HTML_RESULT_CACHE_TTL);
648
+ if (ttl === null) {
594
649
  this.#htmlCacheBypass += 1;
595
650
  return null;
596
651
  }
597
- return [
598
- WebRouter.#clientFacingOrigin(req),
599
- req.headers.get("x-base-path") ?? "",
600
- url.pathname,
601
- url.search,
602
- req.headers.get("accept-language") ?? "",
603
- WebRouter.#cookieValue(req, "theme") ?? "",
604
- ttl,
605
- ].join("\n");
652
+ return createRouteCacheEntry({ request: req, url, theme: WebRouter.#cookieValue(req, "theme"), ttl });
606
653
  }
607
654
 
608
655
  #getCachedHtml(cacheKey: string): string | null {
@@ -611,58 +658,18 @@ export class WebRouter {
611
658
  this.#htmlCacheMisses += 1;
612
659
  return null;
613
660
  }
614
- if (cached.expiresAt <= Date.now()) {
615
- this.#htmlCache.delete(cacheKey);
616
- this.#htmlCacheMisses += 1;
617
- return null;
618
- }
619
661
  this.#htmlCacheHits += 1;
620
662
  return cached.html;
621
663
  }
622
664
 
623
- #setCachedHtml(cacheKey: string, html: string): void {
624
- const ttl = Number.parseInt(cacheKey.split("\n").at(-1) ?? "30", 10);
625
- const maxEntries = WebRouter.#positiveIntEnv("AKAN_HTML_RESULT_CACHE_MAX_ENTRIES") ?? 100;
626
- while (this.#htmlCache.size >= maxEntries) {
627
- const firstKey = this.#htmlCache.keys().next().value;
628
- if (!firstKey) break;
629
- this.#htmlCache.delete(firstKey);
630
- }
631
- this.#htmlCache.set(cacheKey, { html, expiresAt: Date.now() + ttl * 1000 });
665
+ #setCachedHtml(cacheKey: string, html: string, ttl: number): void {
666
+ this.#htmlCache.set(cacheKey, { html }, ttl);
632
667
  }
633
668
 
634
669
  static #cookieValue(req: Request, name: string): string | undefined {
635
670
  return parseCookieHeader(req.headers.get("cookie") ?? "").get(name)?.value;
636
671
  }
637
672
 
638
- static #isPublicCacheableRequest(req: Request): boolean {
639
- if (req.method !== "GET") return false;
640
- if (req.headers.has("authorization")) return false;
641
- const cookie = req.headers.get("cookie");
642
- if (!cookie) return true;
643
- return [...parseCookieHeader(cookie).keys()].every((name) => name === "theme" || name.startsWith("akan_public_"));
644
- }
645
-
646
- static #htmlCacheTtlSeconds(): number {
647
- return WebRouter.#positiveIntEnv("AKAN_HTML_RESULT_CACHE_TTL") ?? 30;
648
- }
649
-
650
- static #isHtmlCachePathAllowed(pathname: string): boolean {
651
- const prefixes = (process.env.AKAN_HTML_RESULT_CACHE_PATHS ?? "")
652
- .split(",")
653
- .map((prefix) => prefix.trim())
654
- .filter(Boolean);
655
- if (prefixes.length === 0) return false;
656
- return prefixes.some(
657
- (prefix) => pathname === prefix || pathname.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`),
658
- );
659
- }
660
-
661
- static #positiveIntEnv(name: string): number | null {
662
- const parsed = Number.parseInt(process.env[name] ?? "", 10);
663
- return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
664
- }
665
-
666
673
  static #isImageOptimizerPath(pathname: string): boolean {
667
674
  return pathname === "/_akan/image" || pathname.endsWith("/_akan/image");
668
675
  }
@@ -756,10 +763,7 @@ export class WebRouter {
756
763
  return `${html.slice(0, last.index)}${snippet}\n${html.slice(last.index)}`;
757
764
  }
758
765
  static #rscNotFoundResponse(): Response {
759
- return new Response("Not Found", {
760
- status: 404,
761
- headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" },
762
- });
766
+ return createRscNotFoundFallbackResponse();
763
767
  }
764
768
  #getProductionRouteCache() {
765
769
  return new RouteClientCache({
@@ -5,7 +5,7 @@ import type { AnimatedComponent, AnimatedProps, Interpolation, SpringValue } fro
5
5
  import type { RouterInstance } from "./router.d.ts";
6
6
  import type { ReactFont } from "./types.d.ts";
7
7
  export type TransitionType = "none" | "fade" | "bottomUp" | "stack" | "scaleOut";
8
- /** Per-page CSR configuration for transition, safe-area, gesture, and cache behavior. */
8
+ /** Per-page CSR configuration for transition, safe-area, and gesture behavior. */
9
9
  export interface PageConfig {
10
10
  transition?: TransitionType;
11
11
  safeArea?: boolean | "top" | "bottom";
@@ -16,8 +16,6 @@ export interface PageConfig {
16
16
  bottomInset?: boolean | number;
17
17
  gesture?: boolean;
18
18
  cache?: boolean;
19
- rscCache?: "public" | false;
20
- rscCacheTtl?: number;
21
19
  topSafeAreaColor?: string;
22
20
  bottomSafeAreaColor?: string;
23
21
  }
@@ -63,7 +61,12 @@ export interface LayoutErrorProps extends LayoutNotFoundProps {
63
61
  }
64
62
  export type Head = ReactNode;
65
63
  export type GenerateHead = (props: PageProps) => PromiseOrObject<Head | null | undefined>;
66
- export type ResolveHead = (props: PageProps) => PromiseOrObject<Head | null | undefined>;
64
+ export interface ResolvedHead {
65
+ node: Head | null | undefined;
66
+ hasExplicitLanguageAlternates: boolean;
67
+ }
68
+ export type ResolveHeadResult = Head | ResolvedHead | null | undefined;
69
+ export type ResolveHead = (props: PageProps) => PromiseOrObject<ResolveHeadResult>;
67
70
  export type HeadProps = PageProps;
68
71
  export type PageRender = (props: PageProps) => PromiseOrObject<ReactNode>;
69
72
  export type LayoutRender = (props: LayoutProps) => PromiseOrObject<ReactNode>;
@@ -107,17 +110,45 @@ export interface WebAppManifest {
107
110
  screenshots?: WebAppManifestIcon[];
108
111
  [key: string]: unknown;
109
112
  }
113
+ export interface AkanMetadata {
114
+ title?: string;
115
+ description?: string;
116
+ robots?: string;
117
+ openGraph?: {
118
+ title?: string;
119
+ description?: string;
120
+ type?: string;
121
+ url?: string;
122
+ siteName?: string;
123
+ images?: string | string[];
124
+ };
125
+ twitter?: {
126
+ card?: "summary" | "summary_large_image" | "app" | "player" | (string & {});
127
+ title?: string;
128
+ description?: string;
129
+ images?: string | string[];
130
+ };
131
+ alternates?: {
132
+ canonical?: string;
133
+ languages?: Record<string, string>;
134
+ };
135
+ }
136
+ export type GenerateMetadata = (props: PageProps) => PromiseOrObject<AkanMetadata | null | undefined>;
110
137
  export interface PageModule {
111
138
  default?: PageRender;
112
139
  pageConfig?: PageConfig;
113
140
  head?: Head;
141
+ metadata?: AkanMetadata;
114
142
  generateHead?: GenerateHead;
143
+ generateMetadata?: GenerateMetadata;
115
144
  Loading?: PageLoadingRender;
116
145
  }
117
146
  export interface LayoutModule {
118
147
  default?: LayoutRender;
119
148
  head?: Head;
149
+ metadata?: AkanMetadata;
120
150
  generateHead?: GenerateHead;
151
+ generateMetadata?: GenerateMetadata;
121
152
  fonts?: ReactFont[];
122
153
  manifest?: WebAppManifest;
123
154
  theme?: string;
@@ -137,7 +168,7 @@ export interface Route {
137
168
  renderLayout?: RouteRender;
138
169
  pageIncludesOwnLayout?: boolean;
139
170
  isSpecialRoute?: boolean;
140
- loader?: () => any;
171
+ loader?: () => unknown;
141
172
  pageState?: PageState;
142
173
  children: Map<string, Route>;
143
174
  }
@@ -218,7 +249,7 @@ export interface RouteState {
218
249
  pathRoutes: PathRoute[];
219
250
  }
220
251
  export type UseCsrTransition = CsrTransitionStyles & {
221
- pageBind: (...args: any[]) => ReactDOMAttributes;
252
+ pageBind: (...args: unknown[]) => ReactDOMAttributes;
222
253
  pageClassName: string;
223
254
  transDirection: "vertical" | "horizontal" | "none";
224
255
  transUnitRange: number[];
@@ -1,8 +1,6 @@
1
1
  export type AkanTheme = "css" | "system" | (string & {});
2
2
  export interface AkanRequestPolicy {
3
3
  routeId?: string;
4
- rscCache?: "public" | false;
5
- rscCacheTtl?: number;
6
4
  cacheable?: boolean;
7
5
  revalidate?: number | false;
8
6
  tags: Set<string>;
@@ -31,11 +29,15 @@ export declare function createRequestStore(request: Request, policy?: Partial<Om
31
29
  /** Stores theme preference on the active request when server rendering. */
32
30
  export declare function setRequestTheme(theme: AkanTheme | undefined): void;
33
31
  export declare function getRequestTheme(): AkanTheme | undefined;
34
- export declare function pushRequestFallback(req: Request): () => void;
32
+ export declare function pushRequestFallback(storeOrRequest: Request | AkanRequestStore): () => void;
35
33
  /** Returns the active server request store from AsyncLocalStorage or the fallback stack. */
36
34
  export declare function getRequestStore(): AkanRequestStore | undefined;
37
35
  /** Returns the active server request from AsyncLocalStorage or the fallback stack. */
38
- export declare function getRequest(): Request | undefined;
36
+ export declare function getRequest(options?: {
37
+ trackDynamic?: boolean;
38
+ }): Request | undefined;
39
+ /** Reads the framework's active server request without marking the user route dynamic. */
40
+ export declare function untrackedRequest(): Request | undefined;
39
41
  export declare function getRequestPolicy(): AkanRequestPolicy | undefined;
40
42
  export declare function updateRequestPolicy(patch: Partial<Omit<AkanRequestPolicy, "tags">> & {
41
43
  tags?: Iterable<string>;
@@ -44,11 +46,19 @@ export declare function getRequestDynamicUsage(): AkanDynamicUsage | undefined;
44
46
  /** Deduplicates a promise-producing query within the active request. */
45
47
  export declare function memoizeRequestQuery<T>(key: string, factory: () => Promise<T>): Promise<T>;
46
48
  /** Returns current request headers as a Map, or an empty Map outside a request. */
47
- export declare function headers(): Map<string, string>;
49
+ export declare function headers(options?: {
50
+ trackDynamic?: boolean;
51
+ }): Map<string, string>;
52
+ /** Reads headers for framework internals without marking the user route dynamic. */
53
+ export declare function untrackedHeaders(): Map<string, string>;
48
54
  export interface CookieEntry {
49
55
  name: string;
50
56
  value: string;
51
57
  }
52
58
  export declare function parseCookieHeader(cookieHeader: string): Map<string, CookieEntry>;
53
59
  /** Returns parsed cookies from the current request, or an empty Map outside a request. */
54
- export declare function cookies(): Map<string, CookieEntry>;
60
+ export declare function cookies(options?: {
61
+ trackDynamic?: boolean;
62
+ }): Map<string, CookieEntry>;
63
+ /** Reads cookies for framework internals without marking the user route dynamic. */
64
+ export declare function untrackedCookies(): Map<string, CookieEntry>;
@@ -0,0 +1,55 @@
1
+ import { type AkanDynamicUsage, type AkanRequestPolicy } from "akanjs/fetch";
2
+ export declare const DEFAULT_ROUTE_CACHE_TTL_SECONDS = 30;
3
+ export interface RouteCacheKeyInput {
4
+ request: Request;
5
+ url: URL;
6
+ theme?: string;
7
+ }
8
+ export interface RouteCacheRenderState {
9
+ cacheable: boolean;
10
+ revalidate?: number | false;
11
+ tags?: string[];
12
+ dynamicUsage?: AkanDynamicUsage;
13
+ reason?: string;
14
+ }
15
+ export interface RouteCacheEntry {
16
+ key: string;
17
+ ttl: number;
18
+ }
19
+ export type RouteCacheRenderControlType = "redirect" | "not-found" | "error";
20
+ export declare function parsePositiveInt(value: string | undefined | null): number | null;
21
+ export declare function normalizeRouteCacheTtl(value: unknown, fallback?: number): number | null;
22
+ export declare function resolveAutoRouteCacheTtl(input: {
23
+ enabled?: string | null;
24
+ ttl?: string | null;
25
+ defaultTtl?: number;
26
+ }): number | null;
27
+ export declare function combineMinRevalidate(...values: Array<number | false | null | undefined>): number | false | undefined;
28
+ export declare function getClientFacingOrigin(request: Request, url?: URL): string;
29
+ export declare function isPublicRouteCacheableRequest(request: Request): boolean;
30
+ export declare function isRouteCachePathAllowed(pathname: string, options?: {
31
+ allow?: string | null;
32
+ deny?: string | null;
33
+ }): boolean;
34
+ export declare function createRouteCacheKey({ request, url, theme }: RouteCacheKeyInput): string;
35
+ export declare function createRouteCacheEntry(input: RouteCacheKeyInput & {
36
+ ttl: number;
37
+ }): RouteCacheEntry;
38
+ export declare function resolveRouteCacheStoreTtl(baseTtl: number, state: RouteCacheRenderState): number | null;
39
+ export declare function shouldStoreRouteCache(input: {
40
+ policy?: AkanRequestPolicy;
41
+ dynamicUsage?: AkanDynamicUsage;
42
+ renderControlType?: RouteCacheRenderControlType;
43
+ lateRedirect?: boolean;
44
+ }): RouteCacheRenderState;
45
+ export declare class LruTtlCache<T> {
46
+ #private;
47
+ readonly maxEntries: number;
48
+ constructor(maxEntries?: number);
49
+ get size(): number;
50
+ get(key: string): T | null;
51
+ set(key: string, value: T, ttlSeconds: number): void;
52
+ delete(key: string): boolean;
53
+ invalidate(predicate: (key: string, value: T) => boolean): number;
54
+ clear(): void;
55
+ }