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
@@ -18,6 +18,12 @@ export interface PageConfig {
18
18
  bottomInset?: boolean | number;
19
19
  gesture?: boolean;
20
20
  cache?: boolean;
21
+ /**
22
+ * Opt in to guarded RSC page suffix commits when the page does not require
23
+ * head/metadata updates and the retained route chain head is invariant for
24
+ * sibling navigations under the same layout.
25
+ */
26
+ rscPatchHeadSafe?: boolean;
21
27
  topSafeAreaColor?: string;
22
28
  bottomSafeAreaColor?: string;
23
29
  }
@@ -58,9 +64,19 @@ export interface LayoutErrorProps extends LayoutNotFoundProps {
58
64
  }
59
65
  export type Head = ReactNode;
60
66
  export type GenerateHead = (props: PageProps) => PromiseOrObject<Head | null | undefined>;
67
+ export interface AkanHeadSnapshotNode {
68
+ tag: "title" | "meta" | "link";
69
+ attrs?: Record<string, string>;
70
+ text?: string;
71
+ }
72
+ export interface AkanHeadSnapshotV1 {
73
+ version: 1;
74
+ nodes: AkanHeadSnapshotNode[];
75
+ }
61
76
  export interface ResolvedHead {
62
77
  node: Head | null | undefined;
63
78
  hasExplicitLanguageAlternates: boolean;
79
+ headSnapshot?: AkanHeadSnapshotV1;
64
80
  }
65
81
  export type ResolveHeadResult = Head | ResolvedHead | null | undefined;
66
82
  export type ResolveHead = (props: PageProps) => PromiseOrObject<ResolveHeadResult>;
@@ -56,8 +56,10 @@ export type ExtractFieldInfoObject<Obj extends FieldInfoObject> = {
56
56
  : never;
57
57
  };
58
58
 
59
+ export type ConstantFieldKind = "property" | "hidden" | "secret" | "resolve";
60
+
59
61
  export interface ConstantFieldProps<
60
- FieldType extends "property" | "hidden" | "resolve" = "property" | "hidden" | "resolve",
62
+ FieldType extends ConstantFieldKind = ConstantFieldKind,
61
63
  FieldValue = any,
62
64
  MapValue = any,
63
65
  Metadata = { [key: string]: any },
@@ -87,7 +89,7 @@ export const fieldPresets = ["email", "password", "url"] as const;
87
89
  export type FieldPreset = (typeof fieldPresets)[number];
88
90
 
89
91
  class FieldInfo<
90
- FieldType extends "property" | "hidden" | "resolve" = any,
92
+ FieldType extends ConstantFieldKind = any,
91
93
  Value extends ConstantFieldTypeInput | null = null,
92
94
  ExplicitType = unknown,
93
95
  MapValue = Value extends MapConstructor ? typeof PrimitiveScalar : never,
@@ -121,7 +123,7 @@ class FieldInfo<
121
123
  }
122
124
 
123
125
  interface ConstantFieldBuildProps<
124
- FieldType extends "property" | "hidden" | "resolve" = any,
126
+ FieldType extends ConstantFieldKind = any,
125
127
  FieldValue = any,
126
128
  MapValue = any,
127
129
  Metadata = any,
@@ -172,7 +174,7 @@ export type FieldInfoObjectToFieldObject<Obj extends FieldInfoObject> = {
172
174
 
173
175
  /** Runtime metadata for a single Akan constant field. */
174
176
  export class ConstantField<
175
- FieldType extends "property" | "hidden" | "resolve" = "property" | "hidden" | "resolve",
177
+ FieldType extends ConstantFieldKind = ConstantFieldKind,
176
178
  Value extends ConstantFieldTypeInput | null = any,
177
179
  FieldValue = any,
178
180
  MapValue = any,
@@ -244,7 +246,7 @@ export class ConstantField<
244
246
  }
245
247
 
246
248
  static fromFieldInfo<
247
- FieldType extends "property" | "hidden" | "resolve" = any,
249
+ FieldType extends ConstantFieldKind = any,
248
250
  Value extends ConstantFieldTypeInput | null = null,
249
251
  FieldValue = any,
250
252
  MapValue = any,
@@ -341,11 +343,11 @@ type FieldOption<
341
343
  _FieldToValue = FieldToValue<Value, MapValue> | null | undefined,
342
344
  > =
343
345
  | Omit<
344
- ConstantFieldProps<"property" | "hidden" | "resolve", _FieldToValue, MapValue, Metadata>,
346
+ ConstantFieldProps<ConstantFieldKind, _FieldToValue, MapValue, Metadata>,
345
347
  "enum" | "meta" | "nullable" | "fieldType" | "select"
346
348
  >
347
349
  | Omit<
348
- ConstantFieldProps<"property" | "hidden" | "resolve", SingleValue<_FieldToValue>, MapValue, Metadata>,
350
+ ConstantFieldProps<ConstantFieldKind, SingleValue<_FieldToValue>, MapValue, Metadata>,
349
351
  "enum" | "meta" | "nullable" | "fieldType" | "select"
350
352
  >[];
351
353
 
@@ -392,9 +394,9 @@ field.secret = <
392
394
  value: Value,
393
395
  option: FieldOption<Value, MapValue> = {},
394
396
  ) =>
395
- new FieldInfo<"hidden", Value | null, ExplicitType | null, MapValue>(value, {
397
+ new FieldInfo<"secret", Value | null, ExplicitType | null, MapValue>(value, {
396
398
  ...option,
397
- fieldType: "hidden",
399
+ fieldType: "secret",
398
400
  select: false,
399
401
  nullable: true,
400
402
  });
@@ -5,7 +5,7 @@ import type { DefaultOf } from "./types";
5
5
  export const getDefault = <T>(fieldObj: FieldObject): DefaultOf<T> => {
6
6
  const result: Record<string, unknown> = {};
7
7
  for (const [key, field] of Object.entries(fieldObj)) {
8
- if (field.fieldType === "hidden") result[key] = null;
8
+ if (field.fieldType === "hidden" || field.fieldType === "secret") result[key] = null;
9
9
  else if (field.default !== undefined && field.default !== null) {
10
10
  if (typeof field.default === "function") result[key] = (field.default as () => object)();
11
11
  else result[key] = field.default as object;
@@ -149,6 +149,11 @@ export function updateRequestPolicy(
149
149
  return policy;
150
150
  }
151
151
 
152
+ /** @internal Route cache tag collection is reserved for framework-owned cache policy experiments. */
153
+ export function cacheTag(...tags: string[]): AkanRequestPolicy | undefined {
154
+ return updateRequestPolicy({ tags: tags.filter(Boolean) });
155
+ }
156
+
152
157
  export function getRequestDynamicUsage(): AkanDynamicUsage | undefined {
153
158
  return getRequestStore()?.dynamicUsage;
154
159
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "2.3.0",
3
+ "version": "2.3.1-rc.0",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -187,12 +187,12 @@
187
187
  "mermaid": "^11.15.0",
188
188
  "postgres": "^3.4.9",
189
189
  "protobufjs": "^8.4.0",
190
- "react": "19.2.6",
190
+ "react": "19.2.7",
191
191
  "react-datepicker": "^9.1.0",
192
- "react-dom": "19.2.6",
192
+ "react-dom": "19.2.7",
193
193
  "react-icons": "^5.6.0",
194
194
  "react-refresh": "^0.18.0",
195
- "react-server-dom-webpack": "^19.2.6",
195
+ "react-server-dom-webpack": "^19.2.7",
196
196
  "react-simple-pull-to-refresh": "^1.3.4",
197
197
  "react-spring": "^10.0.4",
198
198
  "scheduler": "^0.27.0",
package/server/akanApp.ts CHANGED
@@ -67,6 +67,7 @@ export class AkanApp {
67
67
  readonly #artifactDir: string;
68
68
  readonly #replica: AkanReplicaConfig;
69
69
  readonly #runtimeDir: string;
70
+ readonly #socketRunId = `${process.pid}-${Date.now().toString(36)}`;
70
71
  readonly #port: number;
71
72
  readonly #wsBasePort: number;
72
73
  readonly #children = new Map<number, ChildState>();
@@ -273,7 +274,7 @@ export class AkanApp {
273
274
 
274
275
  child.restartPending = true;
275
276
  child.ready = false;
276
- child.status = reason === "health-timeout" ? "unhealthy" : "exited";
277
+ child.status = reason === "health-timeout" || reason === "upstream-open-failed" ? "unhealthy" : "exited";
277
278
  child.upstream = undefined;
278
279
  child.healthPath = undefined;
279
280
  this.#invalidateFederationChildCache();
@@ -336,6 +337,7 @@ export class AkanApp {
336
337
  }
337
338
 
338
339
  async #stopChildForRestart(child: ChildState, proc: Bun.Subprocess<"ignore", "pipe", "pipe">, reason: string) {
340
+ if (reason.startsWith("exit:") || proc.killed) return;
339
341
  if (!proc.killed) {
340
342
  this.#sendToChild(child, { type: "shutdown", signal: reason } satisfies AkanIpcMessage);
341
343
  }
@@ -572,12 +574,27 @@ export class AkanApp {
572
574
  redirect: "manual",
573
575
  });
574
576
  return this.#proxyResponse(upstreamRes);
577
+ } catch (error) {
578
+ if (AkanApp.#isUpstreamOpenFailure(error)) {
579
+ this.logger.error(
580
+ `Child ${child.idx}/${child.role} upstream is unreachable (${child.upstream.socketPath}); restarting`,
581
+ );
582
+ this.#scheduleChildRestart(child, child.proc, "upstream-open-failed");
583
+ return new Response("Federation child upstream is unreachable; restarting", { status: 503 });
584
+ }
585
+ throw error;
575
586
  } finally {
576
587
  child.metrics.activeRequests = Math.max(0, (child.metrics.activeRequests ?? 1) - 1);
577
588
  if (traced) this.#recordProxyHop(performance.now() - hopStart);
578
589
  }
579
590
  }
580
591
 
592
+ static #isUpstreamOpenFailure(error: unknown): boolean {
593
+ if (!error || typeof error !== "object") return false;
594
+ const candidate = error as { code?: unknown; message?: unknown };
595
+ return candidate.code === "FailedToOpenSocket" || String(candidate.message ?? "").includes("FailedToOpenSocket");
596
+ }
597
+
581
598
  /**
582
599
  * Gateway-observed upstream round-trip time. The pure proxy overhead is this value
583
600
  * minus the child handler time captured in the per-request trace.
@@ -705,7 +722,7 @@ export class AkanApp {
705
722
 
706
723
  #getChildUpstream(idx: number, role: AkanChildRole): GatewayUpstream {
707
724
  return {
708
- http: { type: "unix", socketPath: path.join(this.#runtimeDir, `akan-child-${idx}.sock`) },
725
+ http: { type: "unix", socketPath: path.join(this.#runtimeDir, `akan-child-${this.#socketRunId}-${idx}.sock`) },
709
726
  ws: role === "batch" ? undefined : { type: "tcp", host: "127.0.0.1", port: this.#wsBasePort + idx },
710
727
  };
711
728
  }
@@ -927,11 +944,17 @@ export class AkanApp {
927
944
  void this.#scheduleChildRestart(child, child.proc, "health-timeout");
928
945
  return;
929
946
  }
930
- this.#sendToChild(child, {
947
+ const sent = this.#sendToChild(child, {
931
948
  type: "health.ping",
932
949
  nonce: crypto.randomUUID(),
933
950
  sentAt: now,
934
951
  } satisfies AkanIpcMessage);
952
+ if (!sent) {
953
+ child.status = "unhealthy";
954
+ this.#invalidateFederationChildCache();
955
+ void this.#scheduleChildRestart(child, child.proc, "health-send-failed");
956
+ return;
957
+ }
935
958
  }
936
959
  }
937
960
 
@@ -348,7 +348,11 @@ export class AkanServer {
348
348
  sentAt: message.sentAt,
349
349
  pid: process.pid,
350
350
  } satisfies AkanIpcMessage);
351
- else if (message.type === "shutdown") void this.stop();
351
+ else if (message.type === "shutdown") {
352
+ void this.stop()
353
+ .then(() => process.exit(0))
354
+ .catch(() => process.exit(1));
355
+ }
352
356
  }
353
357
 
354
358
  #startMetricsReporting() {
@@ -10,17 +10,53 @@ export interface RouteCacheKeyInput {
10
10
 
11
11
  export interface RouteCacheRenderState {
12
12
  cacheable: boolean;
13
+ routeId?: string;
13
14
  revalidate?: number | false;
14
15
  tags?: string[];
15
16
  dynamicUsage?: AkanDynamicUsage;
16
17
  reason?: string;
17
18
  }
18
19
 
20
+ export interface RouteCacheMetadata {
21
+ pathname: string;
22
+ routeId?: string;
23
+ tags?: string[];
24
+ }
25
+
26
+ export interface RouteCacheInvalidation {
27
+ tags?: string[];
28
+ paths?: string[];
29
+ reason?: string;
30
+ }
31
+
19
32
  export interface RouteCacheEntry {
20
33
  key: string;
21
34
  ttl: number;
22
35
  }
23
36
 
37
+ export interface PublicRouteCacheEntryInput extends RouteCacheKeyInput {
38
+ env: {
39
+ enabled?: string | null;
40
+ ttl?: string | null;
41
+ allow?: string | null;
42
+ deny?: string | null;
43
+ };
44
+ defaultTtl?: number;
45
+ defaultEnabled?: boolean;
46
+ defaultAllow?: boolean;
47
+ }
48
+
49
+ export type RouteCacheBypassReason =
50
+ | "env-opt-out"
51
+ | "dev-default-off"
52
+ | "ttl-disabled"
53
+ | "path-excluded"
54
+ | "request-not-public";
55
+
56
+ export type RouteCacheEntryDecision =
57
+ | { entry: RouteCacheEntry; reason?: undefined }
58
+ | { entry: null; reason: RouteCacheBypassReason };
59
+
24
60
  export type RouteCacheRenderControlType = "redirect" | "not-found" | "error";
25
61
 
26
62
  export function parsePositiveInt(value: string | undefined | null): number | null {
@@ -39,8 +75,10 @@ export function resolveAutoRouteCacheTtl(input: {
39
75
  enabled?: string | null;
40
76
  ttl?: string | null;
41
77
  defaultTtl?: number;
78
+ defaultEnabled?: boolean;
42
79
  }): number | null {
43
- if (input.enabled !== "1") return null;
80
+ if (input.enabled === "0") return null;
81
+ if (input.enabled !== "1" && !input.defaultEnabled) return null;
44
82
  return normalizeRouteCacheTtl(input.ttl, input.defaultTtl ?? DEFAULT_ROUTE_CACHE_TTL_SECONDS);
45
83
  }
46
84
 
@@ -78,7 +116,7 @@ export function isPublicRouteCacheableRequest(request: Request): boolean {
78
116
 
79
117
  export function isRouteCachePathAllowed(
80
118
  pathname: string,
81
- options: { allow?: string | null; deny?: string | null } = {},
119
+ options: { allow?: string | null; deny?: string | null; defaultAllow?: boolean } = {},
82
120
  ): boolean {
83
121
  const matches = (raw: string | null | undefined) => {
84
122
  const prefixes = (raw ?? "")
@@ -92,6 +130,7 @@ export function isRouteCachePathAllowed(
92
130
  };
93
131
  if (matches(options.deny)) return false;
94
132
  const allow = options.allow ?? "";
133
+ if (!allow.trim() && options.defaultAllow) return true;
95
134
  return matches(allow);
96
135
  }
97
136
 
@@ -112,6 +151,33 @@ export function createRouteCacheEntry(input: RouteCacheKeyInput & { ttl: number
112
151
  return { key: createRouteCacheKey(input), ttl: input.ttl };
113
152
  }
114
153
 
154
+ export function resolvePublicRouteCacheEntryDecision(input: PublicRouteCacheEntryInput): RouteCacheEntryDecision {
155
+ if (input.env.enabled === "0") return { entry: null, reason: "env-opt-out" };
156
+ if (input.env.enabled !== "1" && !input.defaultEnabled) return { entry: null, reason: "dev-default-off" };
157
+ const ttl = resolveAutoRouteCacheTtl({
158
+ enabled: input.env.enabled,
159
+ ttl: input.env.ttl,
160
+ defaultTtl: input.defaultTtl,
161
+ defaultEnabled: input.defaultEnabled,
162
+ });
163
+ if (ttl === null) return { entry: null, reason: "ttl-disabled" };
164
+ if (
165
+ !isRouteCachePathAllowed(input.url.pathname, {
166
+ allow: input.env.allow,
167
+ deny: input.env.deny,
168
+ defaultAllow: input.defaultAllow,
169
+ })
170
+ ) {
171
+ return { entry: null, reason: "path-excluded" };
172
+ }
173
+ if (!isPublicRouteCacheableRequest(input.request)) return { entry: null, reason: "request-not-public" };
174
+ return { entry: createRouteCacheEntry({ request: input.request, url: input.url, theme: input.theme, ttl }) };
175
+ }
176
+
177
+ export function resolvePublicRouteCacheEntry(input: PublicRouteCacheEntryInput): RouteCacheEntry | null {
178
+ return resolvePublicRouteCacheEntryDecision(input).entry;
179
+ }
180
+
115
181
  export function resolveRouteCacheStoreTtl(baseTtl: number, state: RouteCacheRenderState): number | null {
116
182
  if (!state.cacheable || state.revalidate === false) return null;
117
183
  if (typeof state.revalidate !== "number") return baseTtl;
@@ -126,6 +192,7 @@ export function shouldStoreRouteCache(input: {
126
192
  lateRedirect?: boolean;
127
193
  }): RouteCacheRenderState {
128
194
  const dynamicUsage = input.dynamicUsage ? { ...input.dynamicUsage } : undefined;
195
+ const routeId = input.policy?.routeId;
129
196
  const tags = input.policy ? [...input.policy.tags] : undefined;
130
197
  const revalidate = combineMinRevalidate(input.policy?.revalidate);
131
198
  if (input.renderControlType) {
@@ -133,11 +200,38 @@ export function shouldStoreRouteCache(input: {
133
200
  input.renderControlType === "redirect" && input.lateRedirect
134
201
  ? "late-redirect"
135
202
  : `render-${input.renderControlType}`;
136
- return { cacheable: false, revalidate, tags, dynamicUsage, reason };
203
+ return { cacheable: false, routeId, revalidate, tags, dynamicUsage, reason };
137
204
  }
138
205
  if (dynamicUsage?.headers || dynamicUsage?.cookies)
139
- return { cacheable: false, revalidate, tags, dynamicUsage, reason: "dynamic-request-api" };
140
- return { cacheable: input.policy?.cacheable !== false, revalidate, tags, dynamicUsage };
206
+ return { cacheable: false, routeId, revalidate, tags, dynamicUsage, reason: "dynamic-request-api" };
207
+ return { cacheable: input.policy?.cacheable !== false, routeId, revalidate, tags, dynamicUsage };
208
+ }
209
+
210
+ export function hasRouteCacheInvalidationScope(invalidation?: RouteCacheInvalidation): boolean {
211
+ return Boolean(invalidation?.tags?.length || invalidation?.paths?.length);
212
+ }
213
+
214
+ export function shouldInvalidateRouteCacheEntry(
215
+ metadata: RouteCacheMetadata,
216
+ invalidation: RouteCacheInvalidation,
217
+ ): boolean {
218
+ if (invalidation.tags?.length) {
219
+ const entryTags = new Set(metadata.tags ?? []);
220
+ if (invalidation.tags.some((tag) => entryTags.has(tag))) return true;
221
+ }
222
+ if (invalidation.paths?.length) {
223
+ return invalidation.paths.some((path) => {
224
+ if (!path) return false;
225
+ const normalized = path.startsWith("/") ? path : `/${path}`;
226
+ return (
227
+ metadata.pathname === normalized ||
228
+ metadata.pathname.startsWith(normalized.endsWith("/") ? normalized : `${normalized}/`) ||
229
+ metadata.routeId === normalized ||
230
+ Boolean(metadata.routeId?.startsWith(normalized.endsWith("/") ? normalized : `${normalized}/`))
231
+ );
232
+ });
233
+ }
234
+ return false;
141
235
  }
142
236
 
143
237
  export class LruTtlCache<T> {
@@ -1,7 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
- import sharp from "sharp";
5
4
  import { ImageOptimizerError } from "./imageOptimizerError";
6
5
  import {
7
6
  type AkanImageConfig,
@@ -11,6 +10,18 @@ import {
11
10
  mergeAkanImageConfig,
12
11
  } from "./types";
13
12
 
13
+ type SharpFactory = typeof import("sharp");
14
+
15
+ let sharpLoad: Promise<SharpFactory> | null = null;
16
+
17
+ function loadSharp(): Promise<SharpFactory> {
18
+ sharpLoad ??= import("sharp").then((mod) => {
19
+ const loaded = mod as unknown as { default?: SharpFactory } & SharpFactory;
20
+ return loaded.default ?? loaded;
21
+ });
22
+ return sharpLoad;
23
+ }
24
+
14
25
  export interface ImageOptimizerOptions {
15
26
  publicDir: string;
16
27
  cacheDir: string;
@@ -274,6 +285,7 @@ export class ImageOptimizer {
274
285
  buffer: Buffer,
275
286
  options: { width: number; quality: number; contentType: string },
276
287
  ): Promise<Buffer> {
288
+ const sharp = await loadSharp();
277
289
  if (sharp.concurrency() > 1) sharp.concurrency(Math.max(Math.floor(sharp.concurrency() / 2), 1));
278
290
  const transformer = sharp(buffer, {
279
291
  limitInputPixels: 268_402_689,
@@ -371,6 +383,7 @@ export class ImageOptimizer {
371
383
  )
372
384
  return false;
373
385
  try {
386
+ const sharp = await loadSharp();
374
387
  const metadata = await sharp(buffer, { animated: true }).metadata();
375
388
  return Boolean(metadata.pages && metadata.pages > 1);
376
389
  } catch {
@@ -1,5 +1,5 @@
1
1
  import type { AkanMetadata, Head, ResolvedHead, ResolveHeadResult } from "akanjs/client";
2
- import type { ReactNode } from "react";
2
+ import { AKAN_RSC_HEAD_SNAPSHOT_VERSION, type AkanHeadSnapshotNode, type AkanHeadSnapshotV1 } from "./routeState";
3
3
 
4
4
  function isRecord(value: unknown): value is Record<string, unknown> {
5
5
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
@@ -10,44 +10,70 @@ function normalizeStringArray(value: string | string[] | undefined): string[] {
10
10
  return Array.isArray(value) ? value : [value];
11
11
  }
12
12
 
13
- function renderOpenGraph(metadata: AkanMetadata): ReactNode[] {
13
+ function createMetaNode(attrs: Record<string, string | undefined>): AkanHeadSnapshotNode | null {
14
+ if (attrs.content === undefined || attrs.content === "") return null;
15
+ const normalizedAttrs = Object.fromEntries(
16
+ Object.entries(attrs).filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1] !== ""),
17
+ );
18
+ return Object.keys(normalizedAttrs).length > 0 ? { tag: "meta", attrs: normalizedAttrs } : null;
19
+ }
20
+
21
+ function createLinkNode(attrs: Record<string, string | undefined>): AkanHeadSnapshotNode | null {
22
+ if (attrs.href === undefined || attrs.href === "") return null;
23
+ const normalizedAttrs = Object.fromEntries(
24
+ Object.entries(attrs).filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1] !== ""),
25
+ );
26
+ return Object.keys(normalizedAttrs).length > 0 ? { tag: "link", attrs: normalizedAttrs } : null;
27
+ }
28
+
29
+ function createOpenGraphHeadSnapshotNodes(metadata: AkanMetadata): AkanHeadSnapshotNode[] {
14
30
  const openGraph = metadata.openGraph;
15
31
  if (!openGraph) return [];
16
- const nodes: ReactNode[] = [];
17
- if (openGraph.title) nodes.push(<meta key="og:title" property="og:title" content={openGraph.title} />);
18
- if (openGraph.description)
19
- nodes.push(<meta key="og:description" property="og:description" content={openGraph.description} />);
20
- if (openGraph.type) nodes.push(<meta key="og:type" property="og:type" content={openGraph.type} />);
21
- if (openGraph.url) nodes.push(<meta key="og:url" property="og:url" content={openGraph.url} />);
22
- if (openGraph.siteName) nodes.push(<meta key="og:site_name" property="og:site_name" content={openGraph.siteName} />);
23
- for (const [index, image] of normalizeStringArray(openGraph.images).entries()) {
24
- nodes.push(<meta key={`og:image:${index}`} property="og:image" content={image} />);
32
+ const nodes: AkanHeadSnapshotNode[] = [];
33
+ const pushMeta = (property: string, content: string | undefined) => {
34
+ const node = createMetaNode({ property, content });
35
+ if (node) nodes.push(node);
36
+ };
37
+ pushMeta("og:title", openGraph.title);
38
+ pushMeta("og:description", openGraph.description);
39
+ pushMeta("og:type", openGraph.type);
40
+ pushMeta("og:url", openGraph.url);
41
+ pushMeta("og:site_name", openGraph.siteName);
42
+ for (const image of normalizeStringArray(openGraph.images)) {
43
+ const node = createMetaNode({ property: "og:image", content: image });
44
+ if (node) nodes.push(node);
25
45
  }
26
46
  return nodes;
27
47
  }
28
48
 
29
- function renderTwitter(metadata: AkanMetadata): ReactNode[] {
49
+ function createTwitterHeadSnapshotNodes(metadata: AkanMetadata): AkanHeadSnapshotNode[] {
30
50
  const twitter = metadata.twitter;
31
51
  if (!twitter) return [];
32
- const nodes: ReactNode[] = [];
33
- if (twitter.card) nodes.push(<meta key="twitter:card" name="twitter:card" content={twitter.card} />);
34
- if (twitter.title) nodes.push(<meta key="twitter:title" name="twitter:title" content={twitter.title} />);
35
- if (twitter.description)
36
- nodes.push(<meta key="twitter:description" name="twitter:description" content={twitter.description} />);
37
- for (const [index, image] of normalizeStringArray(twitter.images).entries()) {
38
- nodes.push(<meta key={`twitter:image:${index}`} name="twitter:image" content={image} />);
52
+ const nodes: AkanHeadSnapshotNode[] = [];
53
+ const pushMeta = (name: string, content: string | undefined) => {
54
+ const node = createMetaNode({ name, content });
55
+ if (node) nodes.push(node);
56
+ };
57
+ pushMeta("twitter:card", twitter.card);
58
+ pushMeta("twitter:title", twitter.title);
59
+ pushMeta("twitter:description", twitter.description);
60
+ for (const image of normalizeStringArray(twitter.images)) {
61
+ const node = createMetaNode({ name: "twitter:image", content: image });
62
+ if (node) nodes.push(node);
39
63
  }
40
64
  return nodes;
41
65
  }
42
66
 
43
- function renderAlternates(metadata: AkanMetadata): ReactNode[] {
67
+ function createAlternateHeadSnapshotNodes(metadata: AkanMetadata): AkanHeadSnapshotNode[] {
44
68
  const alternates = metadata.alternates;
45
69
  if (!alternates) return [];
46
- const nodes: ReactNode[] = [];
47
- if (alternates.canonical) nodes.push(<link key="canonical" rel="canonical" href={alternates.canonical} />);
70
+ const nodes: AkanHeadSnapshotNode[] = [];
71
+ const canonical = createLinkNode({ rel: "canonical", href: alternates.canonical });
72
+ if (canonical) nodes.push(canonical);
48
73
  if (alternates.languages) {
49
74
  for (const [lang, href] of Object.entries(alternates.languages)) {
50
- nodes.push(<link key={`metadata:alternate:${lang}`} rel="alternate" hrefLang={lang} href={href} />);
75
+ const alternate = createLinkNode({ rel: "alternate", hrefLang: lang, href });
76
+ if (alternate) nodes.push(alternate);
51
77
  }
52
78
  }
53
79
  return nodes;
@@ -65,19 +91,67 @@ export function isAkanMetadata(value: unknown): value is AkanMetadata {
65
91
  );
66
92
  }
67
93
 
68
- export function renderMetadata(metadata: AkanMetadata): Head {
94
+ export function createAkanMetadataHeadSnapshot(metadata: AkanMetadata): AkanHeadSnapshotV1 {
95
+ const nodes: AkanHeadSnapshotNode[] = [];
96
+ if (metadata.title) nodes.push({ tag: "title", text: metadata.title });
97
+ const description = createMetaNode({ name: "description", content: metadata.description });
98
+ if (description) nodes.push(description);
99
+ const robots = createMetaNode({ name: "robots", content: metadata.robots });
100
+ if (robots) nodes.push(robots);
101
+ nodes.push(...createOpenGraphHeadSnapshotNodes(metadata));
102
+ nodes.push(...createTwitterHeadSnapshotNodes(metadata));
103
+ nodes.push(...createAlternateHeadSnapshotNodes(metadata));
104
+ return { version: AKAN_RSC_HEAD_SNAPSHOT_VERSION, nodes };
105
+ }
106
+
107
+ export function createAkanLocaleAlternateHeadSnapshot(languages: Record<string, string>): AkanHeadSnapshotV1 {
108
+ return {
109
+ version: AKAN_RSC_HEAD_SNAPSHOT_VERSION,
110
+ nodes: Object.entries(languages).map(([lang, href]) => ({
111
+ tag: "link",
112
+ attrs: { rel: "alternate", hrefLang: lang, href },
113
+ })),
114
+ };
115
+ }
116
+
117
+ export function mergeAkanHeadSnapshots(
118
+ ...snapshots: Array<AkanHeadSnapshotV1 | null | undefined>
119
+ ): AkanHeadSnapshotV1 | undefined {
120
+ const nodes = snapshots.flatMap((snapshot) => snapshot?.nodes ?? []);
121
+ return snapshots.some(Boolean) ? { version: AKAN_RSC_HEAD_SNAPSHOT_VERSION, nodes } : undefined;
122
+ }
123
+
124
+ export function renderAkanHeadSnapshot(snapshot: AkanHeadSnapshotV1, options: { markRouteOwned?: boolean } = {}): Head {
125
+ const markRouteOwned = options.markRouteOwned ?? true;
69
126
  return (
70
127
  <>
71
- {metadata.title ? <title>{metadata.title}</title> : null}
72
- {metadata.description ? <meta name="description" content={metadata.description} /> : null}
73
- {metadata.robots ? <meta name="robots" content={metadata.robots} /> : null}
74
- {renderOpenGraph(metadata)}
75
- {renderTwitter(metadata)}
76
- {renderAlternates(metadata)}
128
+ {snapshot.nodes.map((node, index) => {
129
+ const marker = markRouteOwned
130
+ ? {
131
+ "data-akan-head": "route",
132
+ "data-akan-head-key": `${node.tag}:${index}`,
133
+ }
134
+ : {};
135
+ if (node.tag === "title") {
136
+ return (
137
+ <title key={`${node.tag}:${index}`} {...marker}>
138
+ {node.text ?? ""}
139
+ </title>
140
+ );
141
+ }
142
+ if (node.tag === "meta") {
143
+ return <meta key={`${node.tag}:${index}`} {...node.attrs} {...marker} />;
144
+ }
145
+ return <link key={`${node.tag}:${index}`} {...node.attrs} {...marker} />;
146
+ })}
77
147
  </>
78
148
  );
79
149
  }
80
150
 
151
+ export function renderMetadata(metadata: AkanMetadata): Head {
152
+ return renderAkanHeadSnapshot(createAkanMetadataHeadSnapshot(metadata));
153
+ }
154
+
81
155
  export function hasExplicitLanguageAlternates(metadata: AkanMetadata | null | undefined): boolean {
82
156
  return Boolean(metadata?.alternates?.languages && Object.keys(metadata.alternates.languages).length > 0);
83
157
  }
@@ -94,14 +168,24 @@ export function isResolvedHead(value: unknown): value is ResolvedHead {
94
168
  }
95
169
 
96
170
  export function resolveMetadataHead(metadata: AkanMetadata): ResolvedHead {
171
+ const headSnapshot = createAkanMetadataHeadSnapshot(metadata);
97
172
  return {
98
- node: renderMetadata(metadata),
173
+ node: renderAkanHeadSnapshot(headSnapshot),
99
174
  hasExplicitLanguageAlternates: hasExplicitLanguageAlternates(metadata),
175
+ headSnapshot,
100
176
  };
101
177
  }
102
178
 
103
- export function resolveHeadExport(value: Head | AkanMetadata | null | undefined): ResolvedHead {
104
- return isAkanMetadata(value) ? resolveMetadataHead(value) : { node: value, hasExplicitLanguageAlternates: false };
179
+ export function resolveHeadExport(
180
+ value: Head | AkanMetadata | null | undefined,
181
+ options: { includeHeadSnapshot?: boolean } = {},
182
+ ): ResolvedHead {
183
+ if (!isAkanMetadata(value)) return { node: value, hasExplicitLanguageAlternates: false };
184
+ if (options.includeHeadSnapshot !== false) return resolveMetadataHead(value);
185
+ return {
186
+ node: renderAkanHeadSnapshot(createAkanMetadataHeadSnapshot(value), { markRouteOwned: false }),
187
+ hasExplicitLanguageAlternates: hasExplicitLanguageAlternates(value),
188
+ };
105
189
  }
106
190
 
107
191
  export function resolveHeadResult(value: ResolveHeadResult): ResolvedHead {