akanjs 2.3.0 → 2.3.1-rc.1

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 (180) hide show
  1. package/base/primitiveRegistry.ts +19 -12
  2. package/client/csrTypes.ts +16 -0
  3. package/constant/fieldInfo.ts +11 -9
  4. package/constant/getDefault.ts +1 -1
  5. package/fetch/requestStorage.ts +5 -0
  6. package/package.json +4 -4
  7. package/server/akanApp.ts +26 -3
  8. package/server/akanServer.ts +5 -1
  9. package/server/cachePolicy.ts +99 -5
  10. package/server/imageOptimizer.ts +14 -1
  11. package/server/metadata.tsx +117 -33
  12. package/server/resolver/database.resolver.ts +4 -4
  13. package/server/routeElementComposer.tsx +46 -14
  14. package/server/routeState.ts +379 -0
  15. package/server/routeTreeBuilder.ts +3 -2
  16. package/server/rscClient.tsx +316 -46
  17. package/server/rscClientFetch.ts +57 -0
  18. package/server/rscClientPatch.ts +157 -0
  19. package/server/rscHeadPatch.ts +80 -0
  20. package/server/rscNavigationState.ts +315 -0
  21. package/server/rscPartialCommit.ts +3 -0
  22. package/server/rscPatchSafety.ts +57 -0
  23. package/server/rscSegmentOutlet.tsx +69 -0
  24. package/server/rscSegmentOutletReference.ts +24 -0
  25. package/server/rscWorker.tsx +380 -53
  26. package/server/rscWorkerCache.ts +180 -0
  27. package/server/rscWorkerHost.ts +40 -12
  28. package/server/rscWorkerReplay.ts +11 -2
  29. package/server/ssrFromRscRenderer.tsx +15 -10
  30. package/server/ssrTypes.ts +18 -0
  31. package/server/types.tsx +4 -0
  32. package/server/webRouter.ts +198 -42
  33. package/service/predefinedAdaptor/database.adaptor.ts +72 -25
  34. package/signal/signalContext.ts +1 -1
  35. package/types/base/primitiveRegistry.d.ts +6 -6
  36. package/types/client/csrTypes.d.ts +16 -0
  37. package/types/constant/fieldInfo.d.ts +8 -7
  38. package/types/fetch/requestStorage.d.ts +2 -0
  39. package/types/server/cachePolicy.d.ts +36 -0
  40. package/types/server/metadata.d.ts +10 -1
  41. package/types/server/routeElementComposer.d.ts +9 -1
  42. package/types/server/routeState.d.ts +94 -0
  43. package/types/server/rscClient.d.ts +1 -0
  44. package/types/server/rscClientFetch.d.ts +24 -0
  45. package/types/server/rscClientPatch.d.ts +21 -0
  46. package/types/server/rscHeadPatch.d.ts +12 -0
  47. package/types/server/rscNavigationState.d.ts +78 -0
  48. package/types/server/rscPartialCommit.d.ts +1 -0
  49. package/types/server/rscPatchSafety.d.ts +8 -0
  50. package/types/server/rscSegmentOutlet.d.ts +17 -0
  51. package/types/server/rscSegmentOutletReference.d.ts +2 -0
  52. package/types/server/rscWorker.d.ts +5 -0
  53. package/types/server/rscWorkerCache.d.ts +63 -0
  54. package/types/server/rscWorkerHost.d.ts +8 -4
  55. package/types/server/rscWorkerReplay.d.ts +3 -0
  56. package/types/server/ssrFromRscRenderer.d.ts +1 -0
  57. package/types/server/ssrTypes.d.ts +17 -0
  58. package/types/server/types.d.ts +4 -0
  59. package/types/server/webRouter.d.ts +7 -3
  60. package/types/service/predefinedAdaptor/database.adaptor.d.ts +6 -0
  61. package/types/ui/Button.d.ts +1 -1
  62. package/types/ui/ClientSide.d.ts +1 -1
  63. package/types/ui/Constant/Doc.d.ts +6 -6
  64. package/types/ui/Constant/Mermaid.d.ts +1 -1
  65. package/types/ui/Constant/index.d.ts +1 -1
  66. package/types/ui/Constant/schemaDoc.d.ts +1 -1
  67. package/types/ui/Copy.d.ts +1 -1
  68. package/types/ui/CsrImage.d.ts +1 -1
  69. package/types/ui/Data/CardList.d.ts +1 -1
  70. package/types/ui/Data/Dashboard.d.ts +1 -1
  71. package/types/ui/Data/Insight.d.ts +1 -1
  72. package/types/ui/Data/Item.d.ts +6 -6
  73. package/types/ui/Data/ListContainer.d.ts +1 -1
  74. package/types/ui/Data/Pagination.d.ts +1 -1
  75. package/types/ui/Data/TableList.d.ts +1 -1
  76. package/types/ui/DatePicker.d.ts +3 -3
  77. package/types/ui/Dialog/Close.d.ts +1 -1
  78. package/types/ui/Dialog/Content.d.ts +1 -1
  79. package/types/ui/Dialog/Provider.d.ts +1 -1
  80. package/types/ui/Dialog/Trigger.d.ts +1 -1
  81. package/types/ui/Dialog/index.d.ts +3 -3
  82. package/types/ui/DragAction.d.ts +4 -4
  83. package/types/ui/DraggableList.d.ts +3 -3
  84. package/types/ui/Dropdown.d.ts +1 -1
  85. package/types/ui/Empty.d.ts +1 -1
  86. package/types/ui/Field.d.ts +22 -22
  87. package/types/ui/Image.d.ts +1 -1
  88. package/types/ui/InfiniteScroll.d.ts +1 -1
  89. package/types/ui/Input.d.ts +6 -6
  90. package/types/ui/KeyboardAvoiding.d.ts +1 -1
  91. package/types/ui/Layout/BottomAction.d.ts +1 -1
  92. package/types/ui/Layout/BottomInset.d.ts +1 -1
  93. package/types/ui/Layout/BottomTab.d.ts +1 -1
  94. package/types/ui/Layout/Header.d.ts +1 -1
  95. package/types/ui/Layout/LeftSider.d.ts +1 -1
  96. package/types/ui/Layout/Navbar.d.ts +1 -1
  97. package/types/ui/Layout/RightSider.d.ts +1 -1
  98. package/types/ui/Layout/Sider.d.ts +1 -1
  99. package/types/ui/Layout/Template.d.ts +1 -1
  100. package/types/ui/Layout/TopLeftAction.d.ts +1 -1
  101. package/types/ui/Layout/Unit.d.ts +1 -1
  102. package/types/ui/Layout/View.d.ts +1 -1
  103. package/types/ui/Layout/Zone.d.ts +1 -1
  104. package/types/ui/Layout/index.d.ts +12 -12
  105. package/types/ui/Link/Back.d.ts +1 -1
  106. package/types/ui/Link/Close.d.ts +1 -1
  107. package/types/ui/Link/CsrLink.d.ts +1 -1
  108. package/types/ui/Link/Lang.d.ts +1 -1
  109. package/types/ui/Link/SsrLink.d.ts +1 -1
  110. package/types/ui/Link/index.d.ts +1 -1
  111. package/types/ui/Load/Edit.d.ts +1 -1
  112. package/types/ui/Load/Edit_Client.d.ts +1 -1
  113. package/types/ui/Load/PageCSR.d.ts +1 -1
  114. package/types/ui/Load/Pagination.d.ts +1 -1
  115. package/types/ui/Load/Units.d.ts +1 -1
  116. package/types/ui/Load/View.d.ts +1 -1
  117. package/types/ui/Loading/Area.d.ts +1 -1
  118. package/types/ui/Loading/Button.d.ts +1 -1
  119. package/types/ui/Loading/Input.d.ts +1 -1
  120. package/types/ui/Loading/ProgressBar.d.ts +1 -1
  121. package/types/ui/Loading/Skeleton.d.ts +1 -1
  122. package/types/ui/Loading/Spin.d.ts +1 -1
  123. package/types/ui/Loading/index.d.ts +6 -6
  124. package/types/ui/Menu.d.ts +1 -1
  125. package/types/ui/Modal.d.ts +1 -1
  126. package/types/ui/Model/AdminPanel.d.ts +1 -1
  127. package/types/ui/Model/Edit.d.ts +1 -1
  128. package/types/ui/Model/EditModal.d.ts +1 -1
  129. package/types/ui/Model/EditWrapper.d.ts +1 -1
  130. package/types/ui/Model/LoadInit.d.ts +1 -1
  131. package/types/ui/Model/New.d.ts +1 -1
  132. package/types/ui/Model/NewWrapper.d.ts +1 -1
  133. package/types/ui/Model/NewWrapper_Client.d.ts +1 -1
  134. package/types/ui/Model/Remove.d.ts +1 -1
  135. package/types/ui/Model/RemoveWrapper.d.ts +1 -1
  136. package/types/ui/Model/SureToRemove.d.ts +1 -1
  137. package/types/ui/Model/View.d.ts +1 -1
  138. package/types/ui/Model/ViewEditModal.d.ts +1 -1
  139. package/types/ui/Model/ViewModal.d.ts +1 -1
  140. package/types/ui/Model/ViewWrapper.d.ts +1 -1
  141. package/types/ui/More.d.ts +1 -1
  142. package/types/ui/ObjectId.d.ts +1 -1
  143. package/types/ui/Popconfirm.d.ts +1 -1
  144. package/types/ui/Radio.d.ts +2 -2
  145. package/types/ui/RecentTime.d.ts +1 -1
  146. package/types/ui/Refresh.d.ts +1 -1
  147. package/types/ui/ScreenNavigator.d.ts +3 -3
  148. package/types/ui/Select.d.ts +1 -1
  149. package/types/ui/Signal/Arg.d.ts +13 -13
  150. package/types/ui/Signal/Doc.d.ts +6 -6
  151. package/types/ui/Signal/Listener.d.ts +2 -2
  152. package/types/ui/Signal/Message.d.ts +4 -4
  153. package/types/ui/Signal/Object.d.ts +4 -4
  154. package/types/ui/Signal/PubSub.d.ts +4 -4
  155. package/types/ui/Signal/Request.d.ts +2 -2
  156. package/types/ui/Signal/Response.d.ts +3 -3
  157. package/types/ui/Signal/RestApi.d.ts +5 -5
  158. package/types/ui/Signal/WebSocket.d.ts +2 -2
  159. package/types/ui/System/CSR.d.ts +5 -5
  160. package/types/ui/System/Client.d.ts +8 -8
  161. package/types/ui/System/Common.d.ts +2 -2
  162. package/types/ui/System/DevModeToggle.d.ts +1 -1
  163. package/types/ui/System/Gtag.d.ts +1 -1
  164. package/types/ui/System/Messages.d.ts +1 -1
  165. package/types/ui/System/Reconnect.d.ts +1 -1
  166. package/types/ui/System/Root.d.ts +1 -1
  167. package/types/ui/System/SSR.d.ts +4 -4
  168. package/types/ui/System/SelectLanguage.d.ts +1 -1
  169. package/types/ui/System/ThemeToggle.d.ts +1 -1
  170. package/types/ui/System/index.d.ts +7 -7
  171. package/types/ui/Tab/Menu.d.ts +1 -1
  172. package/types/ui/Tab/Menus.d.ts +1 -1
  173. package/types/ui/Tab/Panel.d.ts +1 -1
  174. package/types/ui/Tab/Provider.d.ts +1 -1
  175. package/types/ui/Tab/index.d.ts +4 -4
  176. package/types/ui/Table.d.ts +1 -1
  177. package/types/ui/ToggleSelect.d.ts +2 -2
  178. package/types/ui/Unauthorized.d.ts +1 -1
  179. package/ui/Constant/schemaDoc.ts +1 -1
  180. package/server/resolver/resolver.contract.fixture.ts +0 -222
@@ -0,0 +1,157 @@
1
+ import { type AkanHeadSnapshotV1, type AkanRscPatchMetadata, readAkanRouterStateResponseHeader } from "./routeState";
2
+ import { getRscPayloadStream } from "./rscHttp";
3
+ import {
4
+ type AkanSegmentCacheNode,
5
+ type AkanSegmentPatchFailureReason,
6
+ type AkanSegmentPatchResult,
7
+ applyAkanSegmentCachePatch,
8
+ } from "./rscNavigationState";
9
+ import { hasAkanSegmentOutlet } from "./rscSegmentOutlet";
10
+
11
+ const RSC_PATCH_REDIRECT_ROW_RE = /^[0-9a-z]+:E\{[^\n]*"digest":"AKAN_REDIRECT(?:;[^"]*)?"[^\n]*\}\n?$/;
12
+ const RSC_PATCH_ERROR_ROW_RE = /^[0-9a-z]+:E\{[^\n]*\}\n?$/;
13
+
14
+ interface ValidateRscSegmentPatchInput<T extends PromiseLike<unknown>> {
15
+ href: string;
16
+ response: Response;
17
+ patch: AkanRscPatchMetadata;
18
+ currentTree: AkanSegmentCacheNode<T> | null;
19
+ createThenable: (stream: ReadableStream<Uint8Array>) => T;
20
+ navId?: number;
21
+ getCurrentNavId?: () => number;
22
+ getHeadSnapshotPatchFailureReason?: (snapshot: AkanHeadSnapshotV1) => AkanSegmentPatchFailureReason | null;
23
+ }
24
+
25
+ function guardRscPatchControlRows(
26
+ stream: ReadableStream<Uint8Array>,
27
+ onControl: (reason: Extract<AkanSegmentPatchFailureReason, "redirect-in-patch" | "error-in-patch">) => void,
28
+ ): ReadableStream<Uint8Array> {
29
+ const decoder = new TextDecoder("utf-8", { fatal: true });
30
+ let buffered = new Uint8Array(new ArrayBuffer(0));
31
+
32
+ const concat = (left: Uint8Array, right: Uint8Array): Uint8Array<ArrayBuffer> => {
33
+ const combined = new Uint8Array(new ArrayBuffer(left.byteLength + right.byteLength));
34
+ combined.set(left, 0);
35
+ combined.set(right, left.byteLength);
36
+ return combined;
37
+ };
38
+ const inspectRow = (row: Uint8Array): void => {
39
+ try {
40
+ const text = decoder.decode(row);
41
+ if (RSC_PATCH_REDIRECT_ROW_RE.test(text)) onControl("redirect-in-patch");
42
+ else if (RSC_PATCH_ERROR_ROW_RE.test(text)) onControl("error-in-patch");
43
+ } catch {
44
+ }
45
+ };
46
+
47
+ return stream.pipeThrough(
48
+ new TransformStream<Uint8Array, Uint8Array>({
49
+ transform(chunk, controller) {
50
+ buffered = concat(buffered, chunk);
51
+ let rowStart = 0;
52
+ for (let index = 0; index < buffered.byteLength; index += 1) {
53
+ if (buffered[index] !== 10) continue;
54
+ const row = buffered.slice(rowStart, index + 1);
55
+ inspectRow(row);
56
+ controller.enqueue(row);
57
+ rowStart = index + 1;
58
+ }
59
+ buffered = rowStart === 0 ? buffered : buffered.slice(rowStart);
60
+ },
61
+ flush(controller) {
62
+ if (buffered.byteLength === 0) return;
63
+ inspectRow(buffered);
64
+ controller.enqueue(buffered);
65
+ buffered = new Uint8Array(new ArrayBuffer(0));
66
+ },
67
+ }),
68
+ );
69
+ }
70
+
71
+ export async function validateRscPatchAndRequestFullFallback<T extends PromiseLike<unknown>>({
72
+ href,
73
+ response,
74
+ patch,
75
+ currentTree,
76
+ createThenable,
77
+ navId,
78
+ getCurrentNavId,
79
+ }: ValidateRscSegmentPatchInput<T>): Promise<{
80
+ sendRouterState: false;
81
+ patchResult: AkanSegmentPatchResult<T>;
82
+ }> {
83
+ const patchResult = await validateRscSegmentPatch({
84
+ href,
85
+ response,
86
+ patch,
87
+ currentTree,
88
+ createThenable,
89
+ navId,
90
+ getCurrentNavId,
91
+ });
92
+ return {
93
+ sendRouterState: false,
94
+ patchResult,
95
+ };
96
+ }
97
+
98
+ export async function validateRscSegmentPatch<T extends PromiseLike<unknown>>({
99
+ href,
100
+ response,
101
+ patch,
102
+ currentTree,
103
+ createThenable,
104
+ navId,
105
+ getCurrentNavId,
106
+ }: ValidateRscSegmentPatchInput<T>): Promise<AkanSegmentPatchResult<T>> {
107
+ const patchStream = getRscPayloadStream(response);
108
+ let patchControlReason: Extract<AkanSegmentPatchFailureReason, "redirect-in-patch" | "error-in-patch"> | undefined;
109
+ const guardedPatchStream = patchStream
110
+ ? guardRscPatchControlRows(patchStream, (reason) => {
111
+ patchControlReason ??= reason;
112
+ })
113
+ : null;
114
+ const patchThenable = guardedPatchStream ? createThenable(guardedPatchStream) : undefined;
115
+ let decodeFailed = !patchThenable;
116
+
117
+ if (patchThenable) {
118
+ try {
119
+ await patchThenable;
120
+ } catch {
121
+ decodeFailed = true;
122
+ }
123
+ }
124
+
125
+ if (patchControlReason) return { status: "rejected", reason: patchControlReason };
126
+
127
+ return applyAkanSegmentCachePatch({
128
+ currentTree,
129
+ targetRouterState: readAkanRouterStateResponseHeader(response.headers),
130
+ patch,
131
+ href,
132
+ thenable: patchThenable,
133
+ navId,
134
+ getCurrentNavId,
135
+ decodeFailed,
136
+ });
137
+ }
138
+
139
+ export async function validateRscPatchForGuardedCommit<T extends PromiseLike<unknown>>({
140
+ partialCommitEnabled,
141
+ ...input
142
+ }: ValidateRscSegmentPatchInput<T> & {
143
+ partialCommitEnabled: boolean;
144
+ }): Promise<AkanSegmentPatchResult<T>> {
145
+ const patchResult = await validateRscSegmentPatch(input);
146
+ if (patchResult.status === "rejected" && patchResult.reason !== "missing-current-tree") return patchResult;
147
+ if (!partialCommitEnabled) return { status: "rejected", reason: "guard-disabled" };
148
+ if (input.patch.headSafe !== true) return { status: "rejected", reason: "head-unsafe" };
149
+ if (input.patch.headSnapshotFailure) return { status: "rejected", reason: input.patch.headSnapshotFailure };
150
+ if (!input.patch.headSnapshot) return { status: "rejected", reason: "head-missing" };
151
+ const headPatchFailureReason = input.getHeadSnapshotPatchFailureReason?.(input.patch.headSnapshot);
152
+ if (headPatchFailureReason) return { status: "rejected", reason: headPatchFailureReason };
153
+ if (patchResult.status === "rejected") return patchResult;
154
+ if (!patchResult.patchedNode.thenable) return { status: "rejected", reason: "decode-error" };
155
+ if (!hasAkanSegmentOutlet(patchResult.outletKey)) return { status: "rejected", reason: "outlet-missing" };
156
+ return { ...patchResult, headSnapshot: input.patch.headSnapshot };
157
+ }
@@ -0,0 +1,80 @@
1
+ import type { AkanHeadSnapshotNode, AkanHeadSnapshotV1 } from "./routeState";
2
+ import type { AkanSegmentPatchFailureReason } from "./rscNavigationState";
3
+
4
+ const AKAN_ROUTE_HEAD_SELECTOR = '[data-akan-head="route"]';
5
+
6
+ export interface PreparedAkanHeadSnapshotPatch {
7
+ existing: HTMLElement[];
8
+ replacement: HTMLElement[];
9
+ }
10
+
11
+ function createHeadElement(node: AkanHeadSnapshotNode, index: number): HTMLElement {
12
+ const element = document.createElement(node.tag);
13
+ element.setAttribute("data-akan-head", "route");
14
+ element.setAttribute("data-akan-head-key", `${node.tag}:${index}`);
15
+ for (const [key, value] of Object.entries(node.attrs ?? {})) {
16
+ element.setAttribute(key, value);
17
+ }
18
+ if (node.tag === "title") element.textContent = node.text ?? "";
19
+ return element;
20
+ }
21
+
22
+ export function getAkanHeadSnapshotPatchFailureReason(
23
+ snapshot: AkanHeadSnapshotV1,
24
+ ): Extract<AkanSegmentPatchFailureReason, "head-missing" | "head-invalid"> | null {
25
+ if (typeof document === "undefined" || !document.head) return "head-invalid";
26
+ const existing = document.head.querySelector(AKAN_ROUTE_HEAD_SELECTOR);
27
+ if (!existing) return "head-missing";
28
+ if (!Array.isArray(snapshot.nodes)) return "head-invalid";
29
+ return null;
30
+ }
31
+
32
+ export function canApplyAkanHeadSnapshotPatch(snapshot: AkanHeadSnapshotV1): boolean {
33
+ return getAkanHeadSnapshotPatchFailureReason(snapshot) === null;
34
+ }
35
+
36
+ export function prepareAkanHeadSnapshotPatch(snapshot: AkanHeadSnapshotV1): PreparedAkanHeadSnapshotPatch | null {
37
+ try {
38
+ if (getAkanHeadSnapshotPatchFailureReason(snapshot)) return null;
39
+ const existing = [...document.head.querySelectorAll<HTMLElement>(AKAN_ROUTE_HEAD_SELECTOR)];
40
+ if (!existing[0]) return null;
41
+ return {
42
+ existing,
43
+ replacement: snapshot.nodes.map((node, index) => createHeadElement(node, index)),
44
+ };
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ export function commitPreparedAkanHeadSnapshotPatch(prepared: PreparedAkanHeadSnapshotPatch): boolean {
51
+ try {
52
+ const anchor = prepared.existing[0];
53
+ if (!anchor) return false;
54
+ const fragment = document.createDocumentFragment();
55
+ for (const node of prepared.replacement) fragment.appendChild(node);
56
+ document.head.insertBefore(fragment, anchor);
57
+ for (const node of prepared.existing) node.remove();
58
+ return true;
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ export function rollbackPreparedAkanHeadSnapshotPatch(prepared: PreparedAkanHeadSnapshotPatch): void {
65
+ try {
66
+ const anchor = prepared.replacement.find((node) => node.parentNode === document.head) ?? null;
67
+ if (anchor) {
68
+ const fragment = document.createDocumentFragment();
69
+ for (const node of prepared.existing) fragment.appendChild(node);
70
+ document.head.insertBefore(fragment, anchor);
71
+ }
72
+ for (const node of prepared.replacement) node.remove();
73
+ } catch {
74
+ }
75
+ }
76
+
77
+ export function applyAkanHeadSnapshotPatch(snapshot: AkanHeadSnapshotV1): boolean {
78
+ const prepared = prepareAkanHeadSnapshotPatch(snapshot);
79
+ return prepared ? commitPreparedAkanHeadSnapshotPatch(prepared) : false;
80
+ }
@@ -1,3 +1,11 @@
1
+ import {
2
+ type AkanHeadSnapshotV1,
3
+ type AkanRouterStateV1,
4
+ type AkanRouteSegmentState,
5
+ type AkanRscPatchMetadata,
6
+ createAkanSegmentOutletKey,
7
+ } from "./routeState";
8
+
1
9
  export interface RscNavigationCache<T> {
2
10
  get(key: string): T | undefined;
3
11
  set(key: string, value: T): void;
@@ -6,6 +14,190 @@ export interface RscNavigationCache<T> {
6
14
  readonly size: number;
7
15
  }
8
16
 
17
+ export interface RscNavigationCacheNode<T> {
18
+ href: string;
19
+ thenable: T;
20
+ routerState: AkanRouterStateV1 | null;
21
+ }
22
+
23
+ export interface RscPatchNavigationCacheNode<T> {
24
+ href: string;
25
+ thenable: T;
26
+ routerState: AkanRouterStateV1;
27
+ patch: AkanRscPatchMetadata;
28
+ outletKey: string;
29
+ headSnapshot: AkanHeadSnapshotV1;
30
+ }
31
+
32
+ export interface AkanSegmentCacheNode<T> {
33
+ segment: AkanRouteSegmentState;
34
+ href: string;
35
+ routerState: AkanRouterStateV1;
36
+ thenable?: T;
37
+ children: AkanSegmentCacheNode<T>[];
38
+ }
39
+
40
+ export type AkanSegmentPatchFailureReason =
41
+ | "missing-current-tree"
42
+ | "segment-path-mismatch"
43
+ | "stale"
44
+ | "unsupported-suffix"
45
+ | "decode-error"
46
+ | "guard-disabled"
47
+ | "outlet-missing"
48
+ | "redirect-in-patch"
49
+ | "error-in-patch"
50
+ | "head-unsafe"
51
+ | "head-missing"
52
+ | "head-invalid"
53
+ | "head-too-large";
54
+
55
+ export type AkanSegmentPatchResult<T> =
56
+ | {
57
+ status: "patched";
58
+ tree: AkanSegmentCacheNode<T>;
59
+ patchedNode: AkanSegmentCacheNode<T>;
60
+ outletKey: string;
61
+ headSnapshot?: AkanHeadSnapshotV1;
62
+ }
63
+ | {
64
+ status: "rejected";
65
+ reason: AkanSegmentPatchFailureReason;
66
+ };
67
+
68
+ export type RscPatchNavigationCacheResult<T> =
69
+ | {
70
+ status: "patched";
71
+ tree: AkanSegmentCacheNode<T>;
72
+ patchedNode: AkanSegmentCacheNode<T>;
73
+ outletKey: string;
74
+ headSnapshot: AkanHeadSnapshotV1;
75
+ }
76
+ | {
77
+ status: "rejected";
78
+ reason: AkanSegmentPatchFailureReason;
79
+ };
80
+
81
+ export function createRscNavigationCacheNode<T>({
82
+ href,
83
+ thenable,
84
+ routerState,
85
+ }: RscNavigationCacheNode<T>): RscNavigationCacheNode<T> {
86
+ return { href, thenable, routerState };
87
+ }
88
+
89
+ export function createRscPatchNavigationCacheNode<T>({
90
+ href,
91
+ patch,
92
+ patchedNode,
93
+ outletKey,
94
+ headSnapshot,
95
+ }: {
96
+ href: string;
97
+ patch: AkanRscPatchMetadata;
98
+ patchedNode: AkanSegmentCacheNode<T>;
99
+ outletKey: string;
100
+ headSnapshot: AkanHeadSnapshotV1;
101
+ }): RscPatchNavigationCacheNode<T> | null {
102
+ if (!patchedNode.thenable) return null;
103
+ return {
104
+ href,
105
+ thenable: patchedNode.thenable,
106
+ routerState: patchedNode.routerState,
107
+ patch,
108
+ outletKey,
109
+ headSnapshot,
110
+ };
111
+ }
112
+
113
+ export function createAkanSegmentCacheTree<T>(node: RscNavigationCacheNode<T>): AkanSegmentCacheNode<T> | null {
114
+ const routerState = node.routerState;
115
+ if (!routerState || routerState.segments.length === 0) return null;
116
+
117
+ const root = createAkanSegmentCacheNode<T>({
118
+ segment: routerState.segments[0],
119
+ href: node.href,
120
+ routerState,
121
+ });
122
+ let current = root;
123
+ for (let index = 1; index < routerState.segments.length; index++) {
124
+ const child = createAkanSegmentCacheNode<T>({
125
+ segment: routerState.segments[index],
126
+ href: node.href,
127
+ routerState,
128
+ });
129
+ current.children.push(child);
130
+ current = child;
131
+ }
132
+ current.thenable = node.thenable;
133
+ return root;
134
+ }
135
+
136
+ export function applyAkanSegmentCachePatch<T>({
137
+ currentTree,
138
+ targetRouterState,
139
+ patch,
140
+ href,
141
+ thenable,
142
+ navId,
143
+ getCurrentNavId,
144
+ decodeFailed,
145
+ }: {
146
+ currentTree: AkanSegmentCacheNode<T> | null;
147
+ targetRouterState: AkanRouterStateV1 | null;
148
+ patch: AkanRscPatchMetadata;
149
+ href: string;
150
+ thenable?: T;
151
+ navId?: number;
152
+ getCurrentNavId?: () => number;
153
+ decodeFailed?: boolean;
154
+ }): AkanSegmentPatchResult<T> {
155
+ if (decodeFailed) return { status: "rejected", reason: "decode-error" };
156
+ if (navId !== undefined && getCurrentNavId && navId !== getCurrentNavId()) {
157
+ return { status: "rejected", reason: "stale" };
158
+ }
159
+ if (!currentTree || !targetRouterState) {
160
+ return { status: "rejected", reason: "missing-current-tree" };
161
+ }
162
+ if (!isSupportedSinglePagePatch(targetRouterState, patch)) {
163
+ return { status: "rejected", reason: "unsupported-suffix" };
164
+ }
165
+
166
+ const targetPatchPath = targetRouterState.segments.slice(0, patch.patchStartIndex + 1).map((segment) => segment.key);
167
+ const expectedPatchPath = patch.segmentPath.slice(0, patch.patchStartIndex + 1);
168
+ if (
169
+ targetPatchPath.length !== expectedPatchPath.length ||
170
+ targetPatchPath.some((segmentKey, index) => segmentKey !== expectedPatchPath[index])
171
+ ) {
172
+ return { status: "rejected", reason: "segment-path-mismatch" };
173
+ }
174
+
175
+ const currentPrefix = flattenAkanSegmentCacheTree(currentTree, patch.patchStartIndex);
176
+ const expectedPrefix = patch.segmentPath.slice(0, patch.patchStartIndex);
177
+ if (
178
+ currentPrefix.length !== expectedPrefix.length ||
179
+ currentPrefix.some((node, index) => node.segment.key !== expectedPrefix[index])
180
+ ) {
181
+ return { status: "rejected", reason: "segment-path-mismatch" };
182
+ }
183
+
184
+ const patchedTree = cloneAkanSegmentCacheTree(currentTree);
185
+ const parent = getAkanSegmentCacheNodeAt(patchedTree, patch.patchStartIndex - 1);
186
+ if (!parent) return { status: "rejected", reason: "segment-path-mismatch" };
187
+
188
+ const patchedSegment = targetRouterState.segments[patch.patchStartIndex];
189
+ const outletKey = createAkanSegmentOutletKey(patch.segmentPath, patch.patchStartIndex);
190
+ if (!outletKey) return { status: "rejected", reason: "unsupported-suffix" };
191
+ const patchedNode = createAkanSegmentCacheNode({
192
+ segment: patchedSegment,
193
+ href,
194
+ routerState: targetRouterState,
195
+ thenable,
196
+ });
197
+ parent.children = [patchedNode];
198
+ return { status: "patched", tree: patchedTree, patchedNode, outletKey };
199
+ }
200
+
9
201
  export function rememberRscCacheEntry<T>(
10
202
  cache: RscNavigationCache<T>,
11
203
  href: string,
@@ -26,6 +218,107 @@ export function deleteRscCacheEntryIfCurrent<T>(cache: RscNavigationCache<T>, hr
26
218
  return cache.delete(href);
27
219
  }
28
220
 
221
+ export function rememberRscCacheNode<T>(
222
+ cache: RscNavigationCache<RscNavigationCacheNode<T>>,
223
+ node: RscNavigationCacheNode<T>,
224
+ maxEntries: number,
225
+ ): void {
226
+ rememberRscCacheEntry(cache, node.href, node, maxEntries);
227
+ }
228
+
229
+ export function rememberRscPatchCacheNode<T>(
230
+ cache: RscNavigationCache<RscPatchNavigationCacheNode<T>>,
231
+ node: RscPatchNavigationCacheNode<T>,
232
+ maxEntries: number,
233
+ ): void {
234
+ rememberRscCacheEntry(cache, node.href, node, maxEntries);
235
+ }
236
+
237
+ export function resolveCachedRscPatchNavigation<T>({
238
+ currentTree,
239
+ node,
240
+ partialCommitEnabled,
241
+ navId,
242
+ getCurrentNavId,
243
+ }: {
244
+ currentTree: AkanSegmentCacheNode<T> | null;
245
+ node: RscPatchNavigationCacheNode<T>;
246
+ partialCommitEnabled: boolean;
247
+ navId?: number;
248
+ getCurrentNavId?: () => number;
249
+ }): RscPatchNavigationCacheResult<T> {
250
+ if (!partialCommitEnabled) return { status: "rejected", reason: "guard-disabled" };
251
+ const patchResult = applyAkanSegmentCachePatch({
252
+ currentTree,
253
+ targetRouterState: node.routerState,
254
+ patch: node.patch,
255
+ href: node.href,
256
+ thenable: node.thenable,
257
+ navId,
258
+ getCurrentNavId,
259
+ });
260
+ if (patchResult.status === "rejected") return patchResult;
261
+ if (patchResult.outletKey !== node.outletKey) return { status: "rejected", reason: "segment-path-mismatch" };
262
+ return { ...patchResult, headSnapshot: node.headSnapshot };
263
+ }
264
+
265
+ function createAkanSegmentCacheNode<T>({
266
+ segment,
267
+ href,
268
+ routerState,
269
+ thenable,
270
+ }: {
271
+ segment: AkanRouteSegmentState;
272
+ href: string;
273
+ routerState: AkanRouterStateV1;
274
+ thenable?: T;
275
+ }): AkanSegmentCacheNode<T> {
276
+ return {
277
+ segment,
278
+ href,
279
+ routerState,
280
+ thenable,
281
+ children: [],
282
+ };
283
+ }
284
+
285
+ function flattenAkanSegmentCacheTree<T>(
286
+ tree: AkanSegmentCacheNode<T>,
287
+ limit = Number.POSITIVE_INFINITY,
288
+ ): AkanSegmentCacheNode<T>[] {
289
+ const nodes: AkanSegmentCacheNode<T>[] = [];
290
+ let current: AkanSegmentCacheNode<T> | undefined = tree;
291
+ while (current && nodes.length < limit) {
292
+ nodes.push(current);
293
+ current = current.children[0];
294
+ }
295
+ return nodes;
296
+ }
297
+
298
+ function getAkanSegmentCacheNodeAt<T>(tree: AkanSegmentCacheNode<T>, index: number): AkanSegmentCacheNode<T> | null {
299
+ if (index < 0) return null;
300
+ return flattenAkanSegmentCacheTree(tree, index + 1)[index] ?? null;
301
+ }
302
+
303
+ function cloneAkanSegmentCacheTree<T>(tree: AkanSegmentCacheNode<T>): AkanSegmentCacheNode<T> {
304
+ return {
305
+ segment: tree.segment,
306
+ href: tree.href,
307
+ routerState: tree.routerState,
308
+ thenable: tree.thenable,
309
+ children: tree.children.map(cloneAkanSegmentCacheTree),
310
+ };
311
+ }
312
+
313
+ function isSupportedSinglePagePatch(targetRouterState: AkanRouterStateV1, patch: AkanRscPatchMetadata): boolean {
314
+ const targetSuffix = targetRouterState.segments.slice(patch.patchStartIndex);
315
+ if (targetSuffix.length !== 1 || targetSuffix[0]?.kind !== "page") return false;
316
+ if (patch.segmentPath.length !== targetRouterState.segments.length) return false;
317
+ if (patch.segmentPath[patch.patchStartIndex] !== patch.patchStartSegmentKey) return false;
318
+ const patchStartSegment = targetRouterState.segments[patch.patchStartIndex];
319
+ return patchStartSegment?.key === patch.patchStartSegmentKey;
320
+ }
321
+
29
322
  interface CommitRscNavigationInput<T> {
30
323
  cache: RscNavigationCache<T>;
31
324
  href: string;
@@ -93,3 +386,25 @@ export function observeRscNavigation<T extends PromiseLike<unknown>>({
93
386
  if (navId === getCurrentNavId()) onLatestError(error);
94
387
  });
95
388
  }
389
+
390
+ export function observeRscNavigationNode<T extends PromiseLike<unknown>>({
391
+ cache,
392
+ node,
393
+ navId,
394
+ getCurrentNavId,
395
+ isExpectedNavigationError,
396
+ onLatestError,
397
+ }: {
398
+ cache: RscNavigationCache<RscNavigationCacheNode<T>>;
399
+ node: RscNavigationCacheNode<T>;
400
+ navId: number;
401
+ getCurrentNavId: () => number;
402
+ isExpectedNavigationError?: (error: unknown) => boolean;
403
+ onLatestError: (error: unknown) => void;
404
+ }): void {
405
+ void Promise.resolve(node.thenable).catch((error) => {
406
+ deleteRscCacheEntryIfCurrent(cache, node.href, node);
407
+ if (isExpectedNavigationError?.(error)) return;
408
+ if (navId === getCurrentNavId()) onLatestError(error);
409
+ });
410
+ }
@@ -0,0 +1,3 @@
1
+ export function isAkanRscPartialCommitEnabled(): boolean {
2
+ return process.env.AKAN_PUBLIC_RSC_PARTIAL_COMMIT === "1";
3
+ }
@@ -0,0 +1,57 @@
1
+ import type { PageConfig } from "akanjs/client";
2
+ import {
3
+ type AkanHeadSnapshotV1,
4
+ type AkanRscPatchDecision,
5
+ encodeAkanHeadSnapshot,
6
+ isAkanHeadSnapshotV1,
7
+ } from "./routeState";
8
+
9
+ export function resolveAkanRscHeadSafePatchDecision({
10
+ partialCommitEnabled,
11
+ patchDecision,
12
+ pageConfig,
13
+ headSnapshot,
14
+ }: {
15
+ partialCommitEnabled: boolean;
16
+ patchDecision: AkanRscPatchDecision;
17
+ pageConfig?: PageConfig;
18
+ headSnapshot?: AkanHeadSnapshotV1;
19
+ }): AkanRscPatchDecision {
20
+ if (!partialCommitEnabled || patchDecision.status !== "patch" || !patchDecision.patch) return patchDecision;
21
+ if (pageConfig?.rscPatchHeadSafe !== true) {
22
+ return {
23
+ status: "full",
24
+ reason: "head-unsafe",
25
+ commonPrefixLength: patchDecision.commonPrefixLength,
26
+ };
27
+ }
28
+ if (!headSnapshot) {
29
+ return {
30
+ status: "full",
31
+ reason: "head-missing",
32
+ commonPrefixLength: patchDecision.commonPrefixLength,
33
+ };
34
+ }
35
+ if (!isAkanHeadSnapshotV1(headSnapshot)) {
36
+ return {
37
+ status: "full",
38
+ reason: "head-invalid",
39
+ commonPrefixLength: patchDecision.commonPrefixLength,
40
+ };
41
+ }
42
+ if (!encodeAkanHeadSnapshot(headSnapshot)) {
43
+ return {
44
+ status: "full",
45
+ reason: "head-too-large",
46
+ commonPrefixLength: patchDecision.commonPrefixLength,
47
+ };
48
+ }
49
+ return {
50
+ ...patchDecision,
51
+ patch: {
52
+ ...patchDecision.patch,
53
+ headSafe: true,
54
+ headSnapshot,
55
+ },
56
+ };
57
+ }
@@ -0,0 +1,69 @@
1
+ "use client";
2
+
3
+ import { type ReactNode, use, useSyncExternalStore } from "react";
4
+
5
+ type RscSegmentThenable = PromiseLike<ReactNode>;
6
+
7
+ interface RscSegmentOutletStore {
8
+ entries: Map<string, RscSegmentThenable>;
9
+ listeners: Map<string, Set<() => void>>;
10
+ }
11
+
12
+ declare global {
13
+ var __AKAN_RSC_SEGMENT_OUTLET_STORE__: RscSegmentOutletStore | undefined;
14
+ }
15
+
16
+ function getStore(): RscSegmentOutletStore {
17
+ globalThis.__AKAN_RSC_SEGMENT_OUTLET_STORE__ ??= {
18
+ entries: new Map(),
19
+ listeners: new Map(),
20
+ };
21
+ return globalThis.__AKAN_RSC_SEGMENT_OUTLET_STORE__;
22
+ }
23
+
24
+ function subscribeSegment(segmentKey: string, listener: () => void): () => void {
25
+ const store = getStore();
26
+ let listeners = store.listeners.get(segmentKey);
27
+ if (!listeners) {
28
+ listeners = new Set();
29
+ store.listeners.set(segmentKey, listeners);
30
+ }
31
+ listeners.add(listener);
32
+ return () => {
33
+ listeners?.delete(listener);
34
+ if (listeners?.size === 0) store.listeners.delete(segmentKey);
35
+ };
36
+ }
37
+
38
+ function getSegmentThenable(segmentKey: string): RscSegmentThenable | null {
39
+ return getStore().entries.get(segmentKey) ?? null;
40
+ }
41
+
42
+ export function hasAkanSegmentOutlet(segmentKey: string): boolean {
43
+ return Boolean(getStore().listeners.get(segmentKey)?.size);
44
+ }
45
+
46
+ export function commitAkanSegmentOutletPatch(segmentKey: string, thenable: RscSegmentThenable): boolean {
47
+ if (!hasAkanSegmentOutlet(segmentKey)) return false;
48
+ const store = getStore();
49
+ store.entries.set(segmentKey, thenable);
50
+ for (const listener of store.listeners.get(segmentKey) ?? []) listener();
51
+ return true;
52
+ }
53
+
54
+ export function resetAkanSegmentOutletPatches(): void {
55
+ const store = getStore();
56
+ store.entries.clear();
57
+ for (const listeners of store.listeners.values()) {
58
+ for (const listener of listeners) listener();
59
+ }
60
+ }
61
+
62
+ export function AkanSegmentOutlet({ segmentKey, children }: { segmentKey: string; children: ReactNode }): ReactNode {
63
+ const patchedThenable = useSyncExternalStore(
64
+ (listener) => subscribeSegment(segmentKey, listener),
65
+ () => getSegmentThenable(segmentKey),
66
+ () => null,
67
+ );
68
+ return patchedThenable ? use(patchedThenable) : children;
69
+ }