akanjs 2.3.0 → 2.3.1-rc.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 (178) hide show
  1. package/client/csrTypes.ts +16 -0
  2. package/constant/fieldInfo.ts +11 -9
  3. package/constant/getDefault.ts +1 -1
  4. package/fetch/requestStorage.ts +5 -0
  5. package/package.json +4 -4
  6. package/server/akanApp.ts +26 -3
  7. package/server/akanServer.ts +5 -1
  8. package/server/cachePolicy.ts +99 -5
  9. package/server/imageOptimizer.ts +14 -1
  10. package/server/metadata.tsx +117 -33
  11. package/server/resolver/database.resolver.ts +4 -4
  12. package/server/routeElementComposer.tsx +46 -14
  13. package/server/routeState.ts +379 -0
  14. package/server/routeTreeBuilder.ts +3 -2
  15. package/server/rscClient.tsx +316 -46
  16. package/server/rscClientFetch.ts +57 -0
  17. package/server/rscClientPatch.ts +157 -0
  18. package/server/rscHeadPatch.ts +80 -0
  19. package/server/rscNavigationState.ts +315 -0
  20. package/server/rscPartialCommit.ts +3 -0
  21. package/server/rscPatchSafety.ts +57 -0
  22. package/server/rscSegmentOutlet.tsx +69 -0
  23. package/server/rscSegmentOutletReference.ts +24 -0
  24. package/server/rscWorker.tsx +380 -53
  25. package/server/rscWorkerCache.ts +180 -0
  26. package/server/rscWorkerHost.ts +40 -12
  27. package/server/rscWorkerReplay.ts +11 -2
  28. package/server/ssrFromRscRenderer.tsx +15 -10
  29. package/server/ssrTypes.ts +18 -0
  30. package/server/types.tsx +4 -0
  31. package/server/webRouter.ts +198 -42
  32. package/service/predefinedAdaptor/database.adaptor.ts +72 -25
  33. package/signal/signalContext.ts +1 -1
  34. package/types/client/csrTypes.d.ts +16 -0
  35. package/types/constant/fieldInfo.d.ts +8 -7
  36. package/types/fetch/requestStorage.d.ts +2 -0
  37. package/types/server/cachePolicy.d.ts +36 -0
  38. package/types/server/metadata.d.ts +10 -1
  39. package/types/server/routeElementComposer.d.ts +9 -1
  40. package/types/server/routeState.d.ts +94 -0
  41. package/types/server/rscClient.d.ts +1 -0
  42. package/types/server/rscClientFetch.d.ts +24 -0
  43. package/types/server/rscClientPatch.d.ts +21 -0
  44. package/types/server/rscHeadPatch.d.ts +12 -0
  45. package/types/server/rscNavigationState.d.ts +78 -0
  46. package/types/server/rscPartialCommit.d.ts +1 -0
  47. package/types/server/rscPatchSafety.d.ts +8 -0
  48. package/types/server/rscSegmentOutlet.d.ts +17 -0
  49. package/types/server/rscSegmentOutletReference.d.ts +2 -0
  50. package/types/server/rscWorker.d.ts +5 -0
  51. package/types/server/rscWorkerCache.d.ts +63 -0
  52. package/types/server/rscWorkerHost.d.ts +8 -4
  53. package/types/server/rscWorkerReplay.d.ts +3 -0
  54. package/types/server/ssrFromRscRenderer.d.ts +1 -0
  55. package/types/server/ssrTypes.d.ts +17 -0
  56. package/types/server/types.d.ts +4 -0
  57. package/types/server/webRouter.d.ts +7 -3
  58. package/types/service/predefinedAdaptor/database.adaptor.d.ts +6 -0
  59. package/types/ui/Button.d.ts +1 -1
  60. package/types/ui/ClientSide.d.ts +1 -1
  61. package/types/ui/Constant/Doc.d.ts +6 -6
  62. package/types/ui/Constant/Mermaid.d.ts +1 -1
  63. package/types/ui/Constant/index.d.ts +1 -1
  64. package/types/ui/Constant/schemaDoc.d.ts +1 -1
  65. package/types/ui/Copy.d.ts +1 -1
  66. package/types/ui/CsrImage.d.ts +1 -1
  67. package/types/ui/Data/CardList.d.ts +1 -1
  68. package/types/ui/Data/Dashboard.d.ts +1 -1
  69. package/types/ui/Data/Insight.d.ts +1 -1
  70. package/types/ui/Data/Item.d.ts +6 -6
  71. package/types/ui/Data/ListContainer.d.ts +1 -1
  72. package/types/ui/Data/Pagination.d.ts +1 -1
  73. package/types/ui/Data/TableList.d.ts +1 -1
  74. package/types/ui/DatePicker.d.ts +3 -3
  75. package/types/ui/Dialog/Close.d.ts +1 -1
  76. package/types/ui/Dialog/Content.d.ts +1 -1
  77. package/types/ui/Dialog/Provider.d.ts +1 -1
  78. package/types/ui/Dialog/Trigger.d.ts +1 -1
  79. package/types/ui/Dialog/index.d.ts +3 -3
  80. package/types/ui/DragAction.d.ts +4 -4
  81. package/types/ui/DraggableList.d.ts +3 -3
  82. package/types/ui/Dropdown.d.ts +1 -1
  83. package/types/ui/Empty.d.ts +1 -1
  84. package/types/ui/Field.d.ts +22 -22
  85. package/types/ui/Image.d.ts +1 -1
  86. package/types/ui/InfiniteScroll.d.ts +1 -1
  87. package/types/ui/Input.d.ts +6 -6
  88. package/types/ui/KeyboardAvoiding.d.ts +1 -1
  89. package/types/ui/Layout/BottomAction.d.ts +1 -1
  90. package/types/ui/Layout/BottomInset.d.ts +1 -1
  91. package/types/ui/Layout/BottomTab.d.ts +1 -1
  92. package/types/ui/Layout/Header.d.ts +1 -1
  93. package/types/ui/Layout/LeftSider.d.ts +1 -1
  94. package/types/ui/Layout/Navbar.d.ts +1 -1
  95. package/types/ui/Layout/RightSider.d.ts +1 -1
  96. package/types/ui/Layout/Sider.d.ts +1 -1
  97. package/types/ui/Layout/Template.d.ts +1 -1
  98. package/types/ui/Layout/TopLeftAction.d.ts +1 -1
  99. package/types/ui/Layout/Unit.d.ts +1 -1
  100. package/types/ui/Layout/View.d.ts +1 -1
  101. package/types/ui/Layout/Zone.d.ts +1 -1
  102. package/types/ui/Layout/index.d.ts +12 -12
  103. package/types/ui/Link/Back.d.ts +1 -1
  104. package/types/ui/Link/Close.d.ts +1 -1
  105. package/types/ui/Link/CsrLink.d.ts +1 -1
  106. package/types/ui/Link/Lang.d.ts +1 -1
  107. package/types/ui/Link/SsrLink.d.ts +1 -1
  108. package/types/ui/Link/index.d.ts +1 -1
  109. package/types/ui/Load/Edit.d.ts +1 -1
  110. package/types/ui/Load/Edit_Client.d.ts +1 -1
  111. package/types/ui/Load/PageCSR.d.ts +1 -1
  112. package/types/ui/Load/Pagination.d.ts +1 -1
  113. package/types/ui/Load/Units.d.ts +1 -1
  114. package/types/ui/Load/View.d.ts +1 -1
  115. package/types/ui/Loading/Area.d.ts +1 -1
  116. package/types/ui/Loading/Button.d.ts +1 -1
  117. package/types/ui/Loading/Input.d.ts +1 -1
  118. package/types/ui/Loading/ProgressBar.d.ts +1 -1
  119. package/types/ui/Loading/Skeleton.d.ts +1 -1
  120. package/types/ui/Loading/Spin.d.ts +1 -1
  121. package/types/ui/Loading/index.d.ts +6 -6
  122. package/types/ui/Menu.d.ts +1 -1
  123. package/types/ui/Modal.d.ts +1 -1
  124. package/types/ui/Model/AdminPanel.d.ts +1 -1
  125. package/types/ui/Model/Edit.d.ts +1 -1
  126. package/types/ui/Model/EditModal.d.ts +1 -1
  127. package/types/ui/Model/EditWrapper.d.ts +1 -1
  128. package/types/ui/Model/LoadInit.d.ts +1 -1
  129. package/types/ui/Model/New.d.ts +1 -1
  130. package/types/ui/Model/NewWrapper.d.ts +1 -1
  131. package/types/ui/Model/NewWrapper_Client.d.ts +1 -1
  132. package/types/ui/Model/Remove.d.ts +1 -1
  133. package/types/ui/Model/RemoveWrapper.d.ts +1 -1
  134. package/types/ui/Model/SureToRemove.d.ts +1 -1
  135. package/types/ui/Model/View.d.ts +1 -1
  136. package/types/ui/Model/ViewEditModal.d.ts +1 -1
  137. package/types/ui/Model/ViewModal.d.ts +1 -1
  138. package/types/ui/Model/ViewWrapper.d.ts +1 -1
  139. package/types/ui/More.d.ts +1 -1
  140. package/types/ui/ObjectId.d.ts +1 -1
  141. package/types/ui/Popconfirm.d.ts +1 -1
  142. package/types/ui/Radio.d.ts +2 -2
  143. package/types/ui/RecentTime.d.ts +1 -1
  144. package/types/ui/Refresh.d.ts +1 -1
  145. package/types/ui/ScreenNavigator.d.ts +3 -3
  146. package/types/ui/Select.d.ts +1 -1
  147. package/types/ui/Signal/Arg.d.ts +13 -13
  148. package/types/ui/Signal/Doc.d.ts +6 -6
  149. package/types/ui/Signal/Listener.d.ts +2 -2
  150. package/types/ui/Signal/Message.d.ts +4 -4
  151. package/types/ui/Signal/Object.d.ts +4 -4
  152. package/types/ui/Signal/PubSub.d.ts +4 -4
  153. package/types/ui/Signal/Request.d.ts +2 -2
  154. package/types/ui/Signal/Response.d.ts +3 -3
  155. package/types/ui/Signal/RestApi.d.ts +5 -5
  156. package/types/ui/Signal/WebSocket.d.ts +2 -2
  157. package/types/ui/System/CSR.d.ts +5 -5
  158. package/types/ui/System/Client.d.ts +8 -8
  159. package/types/ui/System/Common.d.ts +2 -2
  160. package/types/ui/System/DevModeToggle.d.ts +1 -1
  161. package/types/ui/System/Gtag.d.ts +1 -1
  162. package/types/ui/System/Messages.d.ts +1 -1
  163. package/types/ui/System/Reconnect.d.ts +1 -1
  164. package/types/ui/System/Root.d.ts +1 -1
  165. package/types/ui/System/SSR.d.ts +4 -4
  166. package/types/ui/System/SelectLanguage.d.ts +1 -1
  167. package/types/ui/System/ThemeToggle.d.ts +1 -1
  168. package/types/ui/System/index.d.ts +7 -7
  169. package/types/ui/Tab/Menu.d.ts +1 -1
  170. package/types/ui/Tab/Menus.d.ts +1 -1
  171. package/types/ui/Tab/Panel.d.ts +1 -1
  172. package/types/ui/Tab/Provider.d.ts +1 -1
  173. package/types/ui/Tab/index.d.ts +4 -4
  174. package/types/ui/Table.d.ts +1 -1
  175. package/types/ui/ToggleSelect.d.ts +2 -2
  176. package/types/ui/Unauthorized.d.ts +1 -1
  177. package/ui/Constant/schemaDoc.ts +1 -1
  178. package/server/resolver/resolver.contract.fixture.ts +0 -222
@@ -12,22 +12,25 @@ import { type AkanRequestStore, createRequestStore, parseCookieHeader } from "ak
12
12
  import type { AkanMetricsReport } from "akanjs/service";
13
13
  import {
14
14
  type BuilderRpc,
15
+ type ClientManifest,
16
+ type MergedManifest,
15
17
  RouteClientCache,
16
18
  type RouteSeedIndex,
17
19
  RouteSeedIndexStore,
18
20
  RoutesManifestStore,
19
21
  } from "./artifact";
20
22
  import {
21
- createRouteCacheEntry,
22
23
  getClientFacingOrigin,
23
- isPublicRouteCacheableRequest,
24
+ hasRouteCacheInvalidationScope,
24
25
  isRouteCachePathAllowed,
25
26
  LruTtlCache,
26
- normalizeRouteCacheTtl,
27
27
  parsePositiveInt,
28
28
  type RouteCacheEntry,
29
+ type RouteCacheInvalidation,
29
30
  type RouteCacheRenderState,
31
+ resolvePublicRouteCacheEntryDecision,
30
32
  resolveRouteCacheStoreTtl,
33
+ shouldInvalidateRouteCacheEntry,
31
34
  shouldStoreRouteCache,
32
35
  } from "./cachePolicy";
33
36
  import { DevHmrController } from "./hmr";
@@ -35,13 +38,24 @@ import { HMR_CLIENT_SCRIPT } from "./hmr/clientScript";
35
38
  import type { HmrWsData, HmrWsHub } from "./hmr/wsHub";
36
39
  import { ImageOptimizer } from "./imageOptimizer";
37
40
  import { createDefaultRobotsTxt } from "./robots";
41
+ import {
42
+ AKAN_RSC_PATCH_HEAD_SAFE_HEADER,
43
+ AKAN_RSC_PATCH_HEAD_SNAPSHOT_HEADER,
44
+ AKAN_RSC_PATCH_SEGMENT_PATH_HEADER,
45
+ AKAN_RSC_PATCH_START_INDEX_HEADER,
46
+ AKAN_RSC_PATCH_START_SEGMENT_HEADER,
47
+ AKAN_RSC_RESPONSE_STATE_HEADER,
48
+ } from "./routeState";
38
49
  import { type RscRedirectMethod, type RscRedirectStatus, type RscRenderResult, RscWorker } from "./rscWorkerHost";
39
50
  import { createDefaultSitemapXml, getSitemapBasePath } from "./sitemap";
40
51
  import { SsrFromRscRenderer } from "./ssrFromRscRenderer";
52
+ import type { RscTraceMetadata, SsrManifest } from "./ssrTypes";
41
53
  import { createSystemPageResponse, getSystemPageHomeHref } from "./systemPages";
42
54
  import type { BaseBuildArtifact, HttpRoutes, RenderState } from "./types";
43
55
 
44
56
  const RESERVED_BASE_PATHS = new Set(["admin"]);
57
+ const CLIENT_CLOSED_REQUEST_STATUS = 499;
58
+ export const DEFAULT_HTML_RESULT_CACHE_MAX_BODY_BYTES = 2 * 1024 * 1024;
45
59
 
46
60
  export function createRscRedirectResponse(
47
61
  location: string,
@@ -74,10 +88,36 @@ export function createRscNotFoundFallbackResponse(): Response {
74
88
  return createRscStreamResponse("0:null\n", 404);
75
89
  }
76
90
 
91
+ function appendRscTraceHeaders(headers: Headers, trace?: RscTraceMetadata): void {
92
+ if (!trace) return;
93
+ if (trace.navId) headers.set("X-Akan-Rsc-Nav-Id", trace.navId);
94
+ headers.set("X-Akan-Rsc-Pathname", trace.pathname);
95
+ headers.set("X-Akan-Rsc-Route", trace.routeId);
96
+ headers.set("X-Akan-Rsc-Cache", trace.cache);
97
+ if (trace.cacheReason) headers.set("X-Akan-Rsc-Cache-Reason", trace.cacheReason);
98
+ if (trace.cacheKeyHash) headers.set("X-Akan-Rsc-Cache-Key", trace.cacheKeyHash);
99
+ if (trace.partial) headers.set("X-Akan-Rsc-Partial", trace.partial);
100
+ if (trace.partialReason) headers.set("X-Akan-Rsc-Partial-Reason", trace.partialReason);
101
+ if (trace.partialCommonPrefixLength !== undefined) {
102
+ headers.set("X-Akan-Rsc-Partial-Common-Prefix", String(trace.partialCommonPrefixLength));
103
+ }
104
+ if (trace.patchStartIndex !== undefined)
105
+ headers.set(AKAN_RSC_PATCH_START_INDEX_HEADER, String(trace.patchStartIndex));
106
+ if (trace.patchSegmentPath) headers.set(AKAN_RSC_PATCH_SEGMENT_PATH_HEADER, trace.patchSegmentPath);
107
+ if (trace.patchStartSegment) headers.set(AKAN_RSC_PATCH_START_SEGMENT_HEADER, trace.patchStartSegment);
108
+ if (trace.patchHeadSafe) headers.set(AKAN_RSC_PATCH_HEAD_SAFE_HEADER, "1");
109
+ if (trace.patchHeadSnapshot) headers.set(AKAN_RSC_PATCH_HEAD_SNAPSHOT_HEADER, trace.patchHeadSnapshot);
110
+ if (trace.routeState) headers.set(AKAN_RSC_RESPONSE_STATE_HEADER, trace.routeState);
111
+ }
112
+
77
113
  export function cacheHtmlWhileStreaming(
78
114
  stream: ReadableStream<Uint8Array>,
79
115
  onComplete: (html: string) => void,
80
- options: { shouldCache?: () => boolean | Promise<boolean>; maxBodyBytes?: number | null } = {},
116
+ options: {
117
+ shouldCache?: () => boolean | Promise<boolean>;
118
+ maxBodyBytes?: number | null;
119
+ onSkip?: (reason: "body-too-large" | "store-skip") => void;
120
+ } = {},
81
121
  ): ReadableStream<Uint8Array> {
82
122
  const chunks: Uint8Array[] = [];
83
123
  let byteLength = 0;
@@ -99,7 +139,10 @@ export function cacheHtmlWhileStreaming(
99
139
  controller.enqueue(chunk);
100
140
  },
101
141
  async flush() {
102
- if (exceededMaxBodyBytes) return;
142
+ if (exceededMaxBodyBytes) {
143
+ options.onSkip?.("body-too-large");
144
+ return;
145
+ }
103
146
  const body = new Uint8Array(byteLength);
104
147
  let offset = 0;
105
148
  for (const chunk of chunks) {
@@ -107,7 +150,10 @@ export function cacheHtmlWhileStreaming(
107
150
  offset += chunk.byteLength;
108
151
  }
109
152
  try {
110
- if (options.shouldCache && !(await options.shouldCache())) return;
153
+ if (options.shouldCache && !(await options.shouldCache())) {
154
+ options.onSkip?.("store-skip");
155
+ return;
156
+ }
111
157
  onComplete(decoder.decode(body));
112
158
  } catch {
113
159
  }
@@ -144,10 +190,12 @@ export function isHtmlRouteCachePathAllowed(
144
190
  AKAN_HTML_RESULT_CACHE_PATHS?: string;
145
191
  AKAN_HTML_RESULT_CACHE_EXCLUDE_PATHS?: string;
146
192
  } = process.env as Record<string, string | undefined>,
193
+ options: { defaultAllow?: boolean } = {},
147
194
  ): boolean {
148
195
  return isRouteCachePathAllowed(pathname, {
149
196
  allow: env.AKAN_HTML_RESULT_CACHE_PATHS,
150
197
  deny: env.AKAN_HTML_RESULT_CACHE_EXCLUDE_PATHS,
198
+ defaultAllow: options.defaultAllow,
151
199
  });
152
200
  }
153
201
 
@@ -155,7 +203,9 @@ export async function createRscNavigationStreamResponse(
155
203
  result: Extract<RscRenderResult, { type: "stream" }>,
156
204
  ): Promise<Response> {
157
205
 
158
- return createRscStreamResponse(result.stream, result.status ?? 200);
206
+ const response = createRscStreamResponse(result.stream, result.status ?? 200);
207
+ appendRscTraceHeaders(response.headers, result.trace);
208
+ return response;
159
209
  }
160
210
 
161
211
  export function normalizeRscTargetUrlForHostBasePath(
@@ -209,6 +259,9 @@ interface WebRouterOptions {
209
259
 
210
260
  interface CachedHtmlResult {
211
261
  html: string;
262
+ pathname: string;
263
+ routeId?: string;
264
+ tags?: string[];
212
265
  }
213
266
 
214
267
  export class WebRouter {
@@ -217,7 +270,7 @@ export class WebRouter {
217
270
  #artifact: BaseBuildArtifact;
218
271
  #rsc: RscWorker;
219
272
  #hub: HmrWsHub | null = null;
220
- #prodMode = process.env.NODE_ENV === "production" || typeof process.send !== "function";
273
+ #prodMode = process.env.NODE_ENV === "production";
221
274
  #builderRpc: BuilderRpc | null;
222
275
  #routeCache: RouteClientCache;
223
276
  #devHmr: DevHmrController | null = null;
@@ -267,7 +320,7 @@ export class WebRouter {
267
320
  if (prebuilt) {
268
321
  this.#routeCache.seed(prebuilt);
269
322
  await this.#rsc.reload({
270
- clientManifest: this.#routeCache.merged.clientManifest,
323
+ clientManifest: this.#mergeRuntimeManifest(this.#routeCache.merged).clientManifest,
271
324
  cssAssets: this.renderState.cssAssets,
272
325
  buildId: this.renderState.buildId,
273
326
  });
@@ -480,7 +533,8 @@ export class WebRouter {
480
533
  try {
481
534
  this.#requestStats.fullSsr += 1;
482
535
  const manifest = await this.#ensureRoute(url);
483
- const htmlCacheEntry = this.#getHtmlCacheEntry(req, url);
536
+ const htmlCacheDecision = this.#getHtmlCacheEntry(req, url);
537
+ const htmlCacheEntry = htmlCacheDecision.entry;
484
538
  const cachedHtml = htmlCacheEntry ? this.#getCachedHtml(htmlCacheEntry.key) : null;
485
539
  if (cachedHtml) {
486
540
  return new Response(cachedHtml, {
@@ -496,16 +550,24 @@ export class WebRouter {
496
550
  });
497
551
  if (rscResult.type === "redirect")
498
552
  return Response.redirect(new URL(rscResult.location, url.origin), rscResult.status);
499
- if (rscResult.type === "not-found") return this.#renderNotFoundResponse(req, url);
553
+ if (rscResult.type === "not-found") return this.#renderSystemNotFoundFallbackResponse(req, url);
500
554
  const themeCookieExists = WebRouter.#hasCookie(req, "theme");
501
555
  const hostRequestStore = createRequestStore(req);
556
+ const extraBootstrapInline = [
557
+ rscResult.trace?.routeState
558
+ ? `self.__AKAN_RSC_INITIAL_STATE__=${JSON.stringify(rscResult.trace.routeState)};`
559
+ : "",
560
+ !this.#prodMode ? HMR_CLIENT_SCRIPT : "",
561
+ ]
562
+ .filter(Boolean)
563
+ .join("\n");
502
564
  const htmlStream = await new SsrFromRscRenderer().render({
503
565
  request: req,
504
566
  requestStore: hostRequestStore,
505
567
  rscStream: rscResult.stream,
506
568
  ssrManifest: manifest.ssrManifest,
507
569
  bootstrapModules: [this.#artifact.rscClientUrl],
508
- extraBootstrapInline: !this.#prodMode ? HMR_CLIENT_SCRIPT : undefined,
570
+ extraBootstrapInline: extraBootstrapInline || undefined,
509
571
  importmap: this.#artifact.vendorMap,
510
572
  theme: themeCookieExists ? undefined : (rscResult.theme ?? "system"),
511
573
  lateControl: rscResult.lateControl,
@@ -518,6 +580,10 @@ export class WebRouter {
518
580
  if (req.method === "HEAD") {
519
581
  const headers = new Headers(responseHeaders);
520
582
  if (htmlCacheEntry && responseStatus === 200) headers.set("X-Akan-Cache", "MISS");
583
+ else if (htmlCacheDecision.reason) {
584
+ headers.set("X-Akan-Cache", "BYPASS");
585
+ headers.set("X-Akan-Cache-Reason", htmlCacheDecision.reason);
586
+ }
521
587
  cancelStreamForHeadResponse(htmlStream, new Error("HEAD response does not consume body"));
522
588
  return new Response(null, { status: responseStatus, headers });
523
589
  }
@@ -525,6 +591,7 @@ export class WebRouter {
525
591
  const headers = new Headers(responseHeaders);
526
592
  headers.set("X-Akan-Cache", "MISS");
527
593
  let htmlStoreTtl = htmlCacheEntry.ttl;
594
+ let htmlCacheMetadata: Omit<CachedHtmlResult, "html"> = { pathname: url.pathname };
528
595
  const shouldCacheHtml = Promise.all([rscResult.lateControl, rscResult.cacheState]).then(
529
596
  ([control, cacheState]) => {
530
597
  const storeTtl = resolveHtmlRouteCacheStoreTtl({
@@ -535,6 +602,11 @@ export class WebRouter {
535
602
  });
536
603
  if (storeTtl === null) return false;
537
604
  htmlStoreTtl = storeTtl;
605
+ htmlCacheMetadata = {
606
+ pathname: url.pathname,
607
+ routeId: cacheState.routeId,
608
+ tags: cacheState.tags,
609
+ };
538
610
  return true;
539
611
  },
540
612
  );
@@ -542,11 +614,16 @@ export class WebRouter {
542
614
  cacheHtmlWhileStreaming(
543
615
  htmlStream,
544
616
  (html) => {
545
- this.#setCachedHtml(htmlCacheEntry.key, html, htmlStoreTtl);
617
+ this.#setCachedHtml(htmlCacheEntry.key, html, htmlStoreTtl, htmlCacheMetadata);
546
618
  },
547
619
  {
548
620
  shouldCache: () => shouldCacheHtml,
549
- maxBodyBytes: parsePositiveInt(process.env.AKAN_HTML_RESULT_CACHE_MAX_BODY_BYTES),
621
+ maxBodyBytes:
622
+ parsePositiveInt(process.env.AKAN_HTML_RESULT_CACHE_MAX_BODY_BYTES) ??
623
+ DEFAULT_HTML_RESULT_CACHE_MAX_BODY_BYTES,
624
+ onSkip: (reason) => {
625
+ this.#logger.verbose(`html cache store skipped pathname=${url.pathname} reason=${reason}`);
626
+ },
550
627
  },
551
628
  ),
552
629
  {
@@ -555,9 +632,14 @@ export class WebRouter {
555
632
  },
556
633
  );
557
634
  }
635
+ const headers = new Headers(responseHeaders);
636
+ if (htmlCacheDecision.reason) {
637
+ headers.set("X-Akan-Cache", "BYPASS");
638
+ headers.set("X-Akan-Cache-Reason", htmlCacheDecision.reason);
639
+ }
558
640
  return new Response(htmlStream, {
559
641
  status: responseStatus,
560
- headers: responseHeaders,
642
+ headers,
561
643
  });
562
644
  } catch (err) {
563
645
  return this.#renderErrorResponse(req, url.pathname, err);
@@ -593,10 +675,24 @@ export class WebRouter {
593
675
  };
594
676
  }
595
677
 
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);
678
+ /** @internal Clears or scopes invalidation for local route result caches owned by the host and RSC worker. */
679
+ invalidateRouteCaches(invalidation?: string | RouteCacheInvalidation): void {
680
+ const payload = typeof invalidation === "string" ? { reason: invalidation } : invalidation;
681
+ if (!hasRouteCacheInvalidationScope(payload)) {
682
+ this.#htmlCache.clear();
683
+ } else if (payload) {
684
+ this.#htmlCache.invalidate((_key, value) =>
685
+ shouldInvalidateRouteCacheEntry(
686
+ {
687
+ pathname: value.pathname,
688
+ routeId: value.routeId,
689
+ tags: value.tags,
690
+ },
691
+ payload,
692
+ ),
693
+ );
694
+ }
695
+ this.#rsc.invalidateRouteResultCache(invalidation);
600
696
  }
601
697
 
602
698
  /**
@@ -631,25 +727,22 @@ export class WebRouter {
631
727
  static #hasCookie(req: Request, name: string): boolean {
632
728
  return parseCookieHeader(req.headers.get("cookie") ?? "").has(name);
633
729
  }
634
- #getHtmlCacheEntry(req: Request, url: URL): RouteCacheEntry | null {
635
- if (!this.#prodMode || process.env.AKAN_HTML_RESULT_CACHE !== "1") {
636
- this.#htmlCacheBypass += 1;
637
- return null;
638
- }
639
- if (!isPublicRouteCacheableRequest(req)) {
640
- this.#htmlCacheBypass += 1;
641
- return null;
642
- }
643
- if (!isHtmlRouteCachePathAllowed(url.pathname)) {
644
- this.#htmlCacheBypass += 1;
645
- return null;
646
- }
647
- const ttl = normalizeRouteCacheTtl(process.env.AKAN_HTML_RESULT_CACHE_TTL);
648
- if (ttl === null) {
649
- this.#htmlCacheBypass += 1;
650
- return null;
651
- }
652
- return createRouteCacheEntry({ request: req, url, theme: WebRouter.#cookieValue(req, "theme"), ttl });
730
+ #getHtmlCacheEntry(req: Request, url: URL): { entry: RouteCacheEntry | null; reason?: string } {
731
+ const decision = resolvePublicRouteCacheEntryDecision({
732
+ request: req,
733
+ url,
734
+ theme: WebRouter.#cookieValue(req, "theme"),
735
+ defaultEnabled: this.#prodMode,
736
+ defaultAllow: this.#prodMode,
737
+ env: {
738
+ enabled: process.env.AKAN_HTML_RESULT_CACHE,
739
+ ttl: process.env.AKAN_HTML_RESULT_CACHE_TTL,
740
+ allow: process.env.AKAN_HTML_RESULT_CACHE_PATHS,
741
+ deny: process.env.AKAN_HTML_RESULT_CACHE_EXCLUDE_PATHS,
742
+ },
743
+ });
744
+ if (!decision.entry) this.#htmlCacheBypass += 1;
745
+ return decision;
653
746
  }
654
747
 
655
748
  #getCachedHtml(cacheKey: string): string | null {
@@ -662,8 +755,8 @@ export class WebRouter {
662
755
  return cached.html;
663
756
  }
664
757
 
665
- #setCachedHtml(cacheKey: string, html: string, ttl: number): void {
666
- this.#htmlCache.set(cacheKey, { html }, ttl);
758
+ #setCachedHtml(cacheKey: string, html: string, ttl: number, metadata: Omit<CachedHtmlResult, "html">): void {
759
+ this.#htmlCache.set(cacheKey, { html, ...metadata }, ttl);
667
760
  }
668
761
 
669
762
  static #cookieValue(req: Request, name: string): string | undefined {
@@ -683,9 +776,17 @@ export class WebRouter {
683
776
  this.#logger.verbose(
684
777
  `[route-cache] ensure pathname=${url.pathname} routeId=${matched?.entry.routeId ?? "(none)"} in ${Date.now() - started}ms`,
685
778
  );
686
- return this.#routeCache.snapshot();
779
+ return this.#mergeRuntimeManifest(this.#routeCache.snapshot());
780
+ }
781
+
782
+ #mergeRuntimeManifest(manifest: MergedManifest): MergedManifest {
783
+ return {
784
+ ...manifest,
785
+ clientManifest: WebRouter.#mergeClientManifest(this.#artifact.rscRuntimeClientManifest, manifest.clientManifest),
786
+ ssrManifest: WebRouter.#mergeSsrManifest(this.#artifact.rscRuntimeSsrManifest, manifest.ssrManifest),
787
+ };
687
788
  }
688
- #renderNotFoundResponse(req: Request, url: URL): Promise<Response> {
789
+ #renderSystemNotFoundFallbackResponse(req: Request, url: URL): Promise<Response> {
689
790
  return createSystemPageResponse({
690
791
  kind: "not-found",
691
792
  method: req.method,
@@ -697,6 +798,7 @@ export class WebRouter {
697
798
  }
698
799
 
699
800
  #renderErrorResponse(req: Request, scope: string, err: unknown): Promise<Response> {
801
+ if (WebRouter.#isExpectedRequestAbort(err)) return Promise.resolve(WebRouter.#clientClosedResponse());
700
802
  const message = err instanceof Error ? err.message : String(err);
701
803
  this.#logger.error(`[SSR] render failed scope=${scope}: ${message}`);
702
804
  this.#hub?.broadcast({ type: "error", message });
@@ -713,6 +815,7 @@ export class WebRouter {
713
815
  }
714
816
 
715
817
  #renderRscErrorResponse(scope: string, err: unknown): Response {
818
+ if (WebRouter.#isExpectedRequestAbort(err)) return WebRouter.#clientClosedResponse();
716
819
  const message = err instanceof Error ? err.message : String(err);
717
820
  this.#logger.error(`[SSR] render failed scope=${scope}: ${message}`);
718
821
  this.#hub?.broadcast({ type: "error", message });
@@ -722,6 +825,23 @@ export class WebRouter {
722
825
  });
723
826
  }
724
827
 
828
+ static #clientClosedResponse(): Response {
829
+ return new Response(null, {
830
+ status: CLIENT_CLOSED_REQUEST_STATUS,
831
+ headers: { "Cache-Control": "no-store" },
832
+ });
833
+ }
834
+
835
+ static #isExpectedRequestAbort(error: unknown): boolean {
836
+ if (!(error instanceof Error)) return false;
837
+ return (
838
+ error.name === "AbortError" ||
839
+ error.message === "The connection was closed." ||
840
+ error.message === "Connection closed." ||
841
+ error.message.includes("The connection was closed")
842
+ );
843
+ }
844
+
725
845
  #getSystemPageHomeHref(req: Request, pathname: string): string {
726
846
  return getSystemPageHomeHref({
727
847
  pathname,
@@ -811,10 +931,46 @@ export class WebRouter {
811
931
  ...artifact,
812
932
  cssAssets: artifact.cssAssets ?? {},
813
933
  pagesBundlePath,
934
+ rscRuntimeSsrManifest: artifact.rscRuntimeSsrManifest
935
+ ? WebRouter.#normalizeSsrManifest(artifact.rscRuntimeSsrManifest, normalizedArtifactDir)
936
+ : undefined,
814
937
  i18n: artifact.i18n ?? DEFAULT_AKAN_I18N,
815
938
  };
816
939
  }
817
940
 
941
+ static #mergeClientManifest(...manifests: Array<ClientManifest | undefined>): ClientManifest {
942
+ return Object.assign({}, ...manifests.filter(Boolean));
943
+ }
944
+
945
+ static #mergeSsrManifest(...manifests: Array<SsrManifest | undefined>): SsrManifest {
946
+ const definedManifests = manifests.filter((manifest): manifest is SsrManifest => Boolean(manifest));
947
+ return {
948
+ moduleLoading: null,
949
+ moduleMap: Object.assign({}, ...definedManifests.map((manifest) => manifest.moduleMap)),
950
+ };
951
+ }
952
+
953
+ static #normalizeSsrManifest(ssrManifest: SsrManifest, artifactDir: string): SsrManifest {
954
+ return {
955
+ ...ssrManifest,
956
+ moduleMap: Object.fromEntries(
957
+ Object.entries(ssrManifest.moduleMap).map(([entryUrl, byName]) => [
958
+ entryUrl,
959
+ Object.fromEntries(
960
+ Object.entries(byName).map(([name, entry]) => [
961
+ name,
962
+ {
963
+ ...entry,
964
+ id: WebRouter.#resolveArtifactPath(entry.id, artifactDir),
965
+ chunks: entry.chunks.map((chunk) => WebRouter.#resolveArtifactPath(chunk, artifactDir)),
966
+ },
967
+ ]),
968
+ ),
969
+ ]),
970
+ ),
971
+ };
972
+ }
973
+
818
974
  static async #loadCssBytesByUrl(
819
975
  artifact: BaseBuildArtifact,
820
976
  artifactDir: string,
@@ -593,20 +593,8 @@ export class SqliteDocumentStore {
593
593
  }
594
594
 
595
595
  async update(id: string, patch: DocumentRecord) {
596
- const current = await this.pickById(id);
597
- const doc = this.hydrate(this.prepareDocument({ ...current, ...patch, id, updatedAt: dayjs() }), current);
598
- await this.runHooks("save", "update", doc, "pre");
599
- await this.runHooks("update", "update", doc, "pre");
600
- const row = this.toRow(doc);
601
- await this.owner
602
- .getConnection()
603
- .prepare(
604
- `UPDATE ${quoteIdent(this.table)} SET "createdAt" = ?, "updatedAt" = ?, "removedAt" = ?, "_doc" = ? WHERE "id" = ?`,
605
- )
606
- .run(row.createdAt, row.updatedAt, row.removedAt, row._doc, id);
607
- await this.runHooks("update", "update", doc, "post");
608
- await this.runHooks("save", "update", doc, "post");
609
- return doc;
596
+ const current = await this.pickByIdForWrite(id);
597
+ return await this.writeUpdatedDocument(id, { ...current, ...patch, id, updatedAt: dayjs() }, current);
610
598
  }
611
599
 
612
600
  async remove(id: string) {
@@ -614,19 +602,20 @@ export class SqliteDocumentStore {
614
602
  }
615
603
 
616
604
  async updateOneByQuery(query: DocumentQuery, update: DocumentUpdate, options: DocumentUpdateOptions = {}) {
617
- const doc = await this.findOne(query);
605
+ const doc = await this.findOneForWrite(query);
618
606
  if (!doc) {
619
607
  if (!options.upsert) return { acknowledged: true, matchedCount: 0, modifiedCount: 0, upsertedId: null };
620
608
  const inserted = await this.create(this.applyDocumentUpdate(this.extractInsertBase(query), update, true));
621
609
  return { acknowledged: true, matchedCount: 0, modifiedCount: 1, upsertedId: inserted.id };
622
610
  }
623
- await this.update(doc.id, this.applyDocumentUpdate(doc, update));
611
+ await this.writeUpdatedDocument(doc.id as string, this.applyDocumentUpdate(doc, update), doc);
624
612
  return { acknowledged: true, matchedCount: 1, modifiedCount: 1, upsertedId: null };
625
613
  }
626
614
 
627
615
  async updateManyByQuery(query: DocumentQuery, update: DocumentUpdate) {
628
- const docs = await this.find(query);
629
- for (const doc of docs) await this.update(doc.id, this.applyDocumentUpdate(doc, update));
616
+ const docs = await this.findForWrite(query);
617
+ for (const doc of docs)
618
+ await this.writeUpdatedDocument(doc.id as string, this.applyDocumentUpdate(doc, update), doc);
630
619
  return { acknowledged: true, matchedCount: docs.length, modifiedCount: docs.length };
631
620
  }
632
621
 
@@ -656,12 +645,12 @@ export class SqliteDocumentStore {
656
645
  const limit = limitValue ? ` LIMIT ${limitValue}` : "";
657
646
  const offset = skipValue ? ` OFFSET ${skipValue}` : "";
658
647
  const order = options.sample ? "ORDER BY random()" : `ORDER BY ${this.compiler.orderBy(options.sort ?? undefined)}`;
659
- const projection = this.normalizeProjection(options.select);
648
+ const projection = this.resolveProjection(options.select);
660
649
  if (projection) {
661
650
  const rows = await this.prepareReadStmt(
662
651
  `SELECT ${this.projectionSql(projection)} FROM ${quoteIdent(this.table)} WHERE ${where} ${order}${limit}${offset}`,
663
652
  ).all<ProjectedSqliteDocumentRow>(...params);
664
- return rows.map((row) => this.fromProjectedRow(row, projection));
653
+ return rows.map((row) => this.hydrate(this.fromProjectedRow(row, projection)));
665
654
  }
666
655
  const rows = await this.prepareReadStmt(
667
656
  `SELECT * FROM ${quoteIdent(this.table)} WHERE ${where} ${order}${limit}${offset}`,
@@ -922,10 +911,22 @@ export class SqliteDocumentStore {
922
911
  const fields = Object.entries(select)
923
912
  .filter(([, included]) => included)
924
913
  .map(([field]) => field);
925
- if (!fields.length) return null;
926
914
  return [...new Set(fields.filter((field) => field !== "_doc"))];
927
915
  }
928
916
 
917
+ private resolveProjection(select: ProjectionOption): string[] | null {
918
+ const projection = this.normalizeProjection(select);
919
+ if (projection !== null) return projection;
920
+ return this.defaultProjection();
921
+ }
922
+
923
+ private defaultProjection(): string[] | null {
924
+ const fields = this.database.doc[FIELD_META] as unknown as FieldMap;
925
+ const entries = Object.entries(fields).filter(([key]) => !BASE_COLUMNS.has(key));
926
+ if (!entries.some(([, field]) => field.getProps().select === false)) return null;
927
+ return entries.flatMap(([key, field]) => (field.getProps().select === false ? [] : [key]));
928
+ }
929
+
929
930
  private projectionSql(fields: string[]) {
930
931
  const jsonFields = fields.filter((field) => !BASE_COLUMNS.has(field));
931
932
  const baseColumns = [...BASE_COLUMNS].map((field) => quoteIdent(field));
@@ -942,19 +943,65 @@ export class SqliteDocumentStore {
942
943
  private fromProjectedRow(row: ProjectedSqliteDocumentRow, fields: string[]) {
943
944
  const doc: DocumentRecord = {
944
945
  id: row.id,
945
- createdAt: Number(row.createdAt),
946
- updatedAt: Number(row.updatedAt),
947
- removedAt: row.removedAt ? Number(row.removedAt) : undefined,
946
+ createdAt: dayjs(Number(row.createdAt)),
947
+ updatedAt: dayjs(Number(row.updatedAt)),
948
+ removedAt: row.removedAt ? dayjs(Number(row.removedAt)) : undefined,
948
949
  };
949
950
  const jsonFields = fields.filter((field) => !BASE_COLUMNS.has(field));
950
951
  for (const [idx, field] of jsonFields.entries()) {
951
952
  const value = this.parseProjectedValue(row[this.projectionAlias(idx)]);
952
953
  const props = (this.database.doc[FIELD_META] as unknown as FieldMap)[field]?.getProps?.();
953
- doc[field] = props ? this.decodeFieldValue(value, props) : value;
954
+ if (value === null && props?.default !== undefined && props.default !== null && !props.nullable) {
955
+ doc[field] =
956
+ typeof props.default === "function" ? (props.default as (data: unknown) => unknown)(doc) : props.default;
957
+ } else {
958
+ doc[field] = props ? this.decodeFieldValue(value, props) : value;
959
+ }
954
960
  }
955
961
  return doc;
956
962
  }
957
963
 
964
+ private async findForWrite(query?: DocumentQuery, options: FindManyOptions = {}) {
965
+ const { where, params } = this.safeQuery(query);
966
+ const limitValue = Number(options.limit ?? 0);
967
+ const skipValue = Number(options.skip ?? 0);
968
+ const limit = limitValue ? ` LIMIT ${limitValue}` : "";
969
+ const offset = skipValue ? ` OFFSET ${skipValue}` : "";
970
+ const order = options.sample ? "ORDER BY random()" : `ORDER BY ${this.compiler.orderBy(options.sort ?? undefined)}`;
971
+ const rows = await this.prepareReadStmt(
972
+ `SELECT * FROM ${quoteIdent(this.table)} WHERE ${where} ${order}${limit}${offset}`,
973
+ ).all<SqliteDocumentRow>(...params);
974
+ return rows.map((row) => this.hydrate(this.fromRow(row)));
975
+ }
976
+
977
+ private async findOneForWrite(query?: DocumentQuery, options: FindOneOptions = {}) {
978
+ return (
979
+ (await this.findForWrite(query, { ...options, limit: 1, sample: options.sample ? 1 : undefined })).at(0) ?? null
980
+ );
981
+ }
982
+
983
+ private async pickByIdForWrite(id: string) {
984
+ const doc = await this.findOneForWrite({ id } as DocumentQuery);
985
+ if (!doc) throw new Error(`No Document (${this.table}): ${id}`);
986
+ return doc;
987
+ }
988
+
989
+ private async writeUpdatedDocument(id: string, data: DocumentRecord, originalData: DocumentRecord) {
990
+ const doc = this.hydrate(this.prepareDocument({ ...data, id, updatedAt: dayjs() }), originalData);
991
+ await this.runHooks("save", "update", doc, "pre");
992
+ await this.runHooks("update", "update", doc, "pre");
993
+ const row = this.toRow(doc);
994
+ await this.owner
995
+ .getConnection()
996
+ .prepare(
997
+ `UPDATE ${quoteIdent(this.table)} SET "createdAt" = ?, "updatedAt" = ?, "removedAt" = ?, "_doc" = ? WHERE "id" = ?`,
998
+ )
999
+ .run(row.createdAt, row.updatedAt, row.removedAt, row._doc, id);
1000
+ await this.runHooks("update", "update", doc, "post");
1001
+ await this.runHooks("save", "update", doc, "post");
1002
+ return doc;
1003
+ }
1004
+
958
1005
  private parseProjectedValue(value: unknown) {
959
1006
  if (typeof value !== "string") return value;
960
1007
  const trimmed = value.trim();
@@ -249,7 +249,7 @@ export class SignalContext<
249
249
  const resolvedValue = {} as RuntimeRecord;
250
250
  await Promise.all(
251
251
  Object.entries((returnRef as ConstantCls)[FIELD_META]).map(async ([key, field]) => {
252
- if (field.fieldType === "hidden") return;
252
+ if (field.fieldType === "hidden" || field.fieldType === "secret") return;
253
253
  else if (field.fieldType === "resolve") {
254
254
  const refName = ConstantRegistry.getRefName(returnRef as ConstantCls);
255
255
  const internal = live.internal.get(`${refName}Internal`);
@@ -16,6 +16,12 @@ export interface PageConfig {
16
16
  bottomInset?: boolean | number;
17
17
  gesture?: boolean;
18
18
  cache?: boolean;
19
+ /**
20
+ * Opt in to guarded RSC page suffix commits when the page does not require
21
+ * head/metadata updates and the retained route chain head is invariant for
22
+ * sibling navigations under the same layout.
23
+ */
24
+ rscPatchHeadSafe?: boolean;
19
25
  topSafeAreaColor?: string;
20
26
  bottomSafeAreaColor?: string;
21
27
  }
@@ -61,9 +67,19 @@ export interface LayoutErrorProps extends LayoutNotFoundProps {
61
67
  }
62
68
  export type Head = ReactNode;
63
69
  export type GenerateHead = (props: PageProps) => PromiseOrObject<Head | null | undefined>;
70
+ export interface AkanHeadSnapshotNode {
71
+ tag: "title" | "meta" | "link";
72
+ attrs?: Record<string, string>;
73
+ text?: string;
74
+ }
75
+ export interface AkanHeadSnapshotV1 {
76
+ version: 1;
77
+ nodes: AkanHeadSnapshotNode[];
78
+ }
64
79
  export interface ResolvedHead {
65
80
  node: Head | null | undefined;
66
81
  hasExplicitLanguageAlternates: boolean;
82
+ headSnapshot?: AkanHeadSnapshotV1;
67
83
  }
68
84
  export type ResolveHeadResult = Head | ResolvedHead | null | undefined;
69
85
  export type ResolveHead = (props: PageProps) => PromiseOrObject<ResolveHeadResult>;