@finesoft/front 0.5.1 → 0.5.3

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 (128) hide show
  1. package/README.md +4 -4
  2. package/dist/Outlet.svelte +39 -0
  3. package/dist/Outlet.svelte.d.ts +7 -0
  4. package/dist/browser-CR5vhgXg.mjs +1317 -0
  5. package/dist/browser-kFMjlLGT.d.mts +262 -0
  6. package/dist/browser.d.mts +7 -2
  7. package/dist/browser.mjs +9 -1
  8. package/dist/controller-types-CgmJ6-le.d.mts +16 -0
  9. package/dist/cookies-BiRlUanX.d.mts +786 -0
  10. package/dist/fetch-policy-BHT8RtrL.mjs +82 -0
  11. package/dist/host-guard-DDWxLpFL.mjs +222 -0
  12. package/dist/http-B6CJqDyf.d.mts +46 -0
  13. package/dist/http-CaxrMD1A.d.mts +1 -0
  14. package/dist/http-D70PL72H.mjs +257 -0
  15. package/dist/http.d.mts +3 -0
  16. package/dist/http.mjs +2 -0
  17. package/dist/index-node.d.mts +15 -0
  18. package/dist/index-node.mjs +15 -0
  19. package/dist/index.d.mts +48 -697
  20. package/dist/index.mjs +44 -261
  21. package/dist/load-node.d.mts +5 -0
  22. package/dist/load-node.mjs +10 -0
  23. package/dist/load-portable.d.mts +5 -0
  24. package/dist/load-portable.mjs +8 -0
  25. package/dist/lru-map-BKoUAySU.mjs +50 -0
  26. package/dist/messages-CAt2QdGr.mjs +140 -0
  27. package/dist/native-contract-DuR25hYB.d.mts +14 -0
  28. package/dist/native-contract.d.mts +2 -0
  29. package/dist/native-contract.mjs +1 -0
  30. package/dist/node-D9hB4dsz.d.mts +35 -0
  31. package/dist/node.d.mts +2 -0
  32. package/dist/node.mjs +59 -0
  33. package/dist/path-CGFl2w7D.mjs +113 -0
  34. package/dist/path-CXT6xGPO.d.mts +261 -0
  35. package/dist/portable-CaxrMD1A.d.mts +1 -0
  36. package/dist/portable.d.mts +11 -0
  37. package/dist/portable.mjs +12 -0
  38. package/dist/proxy-2dSWO-Xw.d.mts +53 -0
  39. package/dist/proxy-z02VvGIj.mjs +7520 -0
  40. package/dist/public-types-BcJM-AYc.mjs +835 -0
  41. package/dist/react-DhwBRw01.d.mts +16 -0
  42. package/dist/react.d.mts +3 -0
  43. package/dist/react.mjs +29 -0
  44. package/dist/rolldown-runtime-B4iAMlE-.mjs +35 -0
  45. package/dist/secure-fetch-Xlht2jd7.d.mts +30 -0
  46. package/dist/server-controller-proxy-BkVuVWVD.d.mts +38 -0
  47. package/dist/session-DnB4ZC3x.d.mts +1279 -0
  48. package/dist/src-BQBfaaPO.mjs +3825 -0
  49. package/dist/src-Ftl_0rhu.mjs +28 -0
  50. package/dist/ssr-BLzYP4wU.d.mts +207 -0
  51. package/dist/ssr-Tn4YkuxM.mjs +357 -0
  52. package/dist/ssr.d.mts +3 -0
  53. package/dist/ssr.mjs +3 -0
  54. package/dist/svelte-Dr5to3SE.d.mts +16 -0
  55. package/dist/svelte.d.mts +3 -0
  56. package/dist/svelte.mjs +13 -0
  57. package/dist/typegen-C-WeJCtf.d.mts +12 -0
  58. package/dist/typegen-cli.d.mts +1 -0
  59. package/dist/typegen-cli.mjs +11 -0
  60. package/dist/typegen.d.mts +3 -0
  61. package/dist/typegen.mjs +2 -0
  62. package/dist/types-B1BT0N3t.mjs +402 -0
  63. package/dist/undici-CPfL25Hr.mjs +22262 -0
  64. package/dist/vite-Cj4SPA8D.d.mts +277 -0
  65. package/dist/vite.d.mts +4 -0
  66. package/dist/vite.mjs +2354 -0
  67. package/dist/vue-DGmzuKho.d.mts +32 -0
  68. package/dist/vue.d.mts +3 -0
  69. package/dist/vue.mjs +56 -0
  70. package/dist/web.d.mts +6 -0
  71. package/dist/web.mjs +8 -0
  72. package/dist/worker.d.mts +2 -0
  73. package/dist/worker.mjs +2 -0
  74. package/docs/01-getting-started.md +67 -199
  75. package/docs/02-routing-and-controllers.md +163 -241
  76. package/docs/03-middleware.md +10 -212
  77. package/docs/04-rendering-and-hydration.md +7 -332
  78. package/docs/05-i18n.md +7 -237
  79. package/docs/06-http-client.md +20 -263
  80. package/docs/07-di-container.md +23 -257
  81. package/docs/08-observability.md +6 -286
  82. package/docs/09-server-and-deployment.md +59 -219
  83. package/docs/10-features-platform-pwa.md +7 -231
  84. package/docs/11-navigation.md +143 -288
  85. package/docs/12-session-restoration.md +6 -214
  86. package/docs/README.md +7 -7
  87. package/docs/advanced/custom-action-handler.md +29 -229
  88. package/docs/advanced/custom-adapter.md +7 -259
  89. package/docs/advanced/custom-event-recorder.md +7 -312
  90. package/docs/advanced/inline-proxy-codegen.md +6 -185
  91. package/docs/advanced/multi-tenant-scopes.md +10 -323
  92. package/docs/engineering/ci-release-flow.md +35 -222
  93. package/docs/engineering/project-structure.md +34 -277
  94. package/docs/engineering/testing.md +8 -310
  95. package/docs/pitfalls/container-scope-leak.md +2 -214
  96. package/docs/pitfalls/i18n-bundle-size.md +6 -176
  97. package/docs/pitfalls/proxy-binary-payloads.md +8 -12
  98. package/docs/pitfalls/ssr-hydration-mismatch.md +9 -155
  99. package/docs/pitfalls/ssr-vs-csr-globals.md +9 -170
  100. package/docs/zh/01-getting-started.md +67 -199
  101. package/docs/zh/02-routing-and-controllers.md +166 -244
  102. package/docs/zh/03-middleware.md +10 -212
  103. package/docs/zh/04-rendering-and-hydration.md +7 -332
  104. package/docs/zh/05-i18n.md +7 -237
  105. package/docs/zh/06-http-client.md +20 -263
  106. package/docs/zh/07-di-container.md +23 -257
  107. package/docs/zh/08-observability.md +6 -283
  108. package/docs/zh/09-server-and-deployment.md +59 -219
  109. package/docs/zh/10-features-platform-pwa.md +7 -231
  110. package/docs/zh/11-navigation.md +130 -290
  111. package/docs/zh/12-session-restoration.md +6 -214
  112. package/docs/zh/README.md +4 -4
  113. package/docs/zh/advanced/custom-action-handler.md +29 -229
  114. package/docs/zh/advanced/custom-adapter.md +7 -259
  115. package/docs/zh/advanced/custom-event-recorder.md +7 -312
  116. package/docs/zh/advanced/inline-proxy-codegen.md +6 -185
  117. package/docs/zh/advanced/multi-tenant-scopes.md +10 -323
  118. package/docs/zh/engineering/ci-release-flow.md +35 -222
  119. package/docs/zh/engineering/project-structure.md +34 -277
  120. package/docs/zh/engineering/testing.md +8 -310
  121. package/docs/zh/pitfalls/container-scope-leak.md +2 -214
  122. package/docs/zh/pitfalls/i18n-bundle-size.md +6 -176
  123. package/docs/zh/pitfalls/proxy-binary-payloads.md +8 -12
  124. package/docs/zh/pitfalls/ssr-hydration-mismatch.md +9 -155
  125. package/docs/zh/pitfalls/ssr-vs-csr-globals.md +9 -170
  126. package/package.json +118 -20
  127. package/dist/browser-BHhVWXik.mjs +0 -2
  128. package/dist/browser-BV2BBXm7.d.mts +0 -2811
@@ -0,0 +1,1317 @@
1
+ import { A as createActiveLeafCodec, F as serializeNavigation, P as deserializeNavigation, R as collectVisibleDestinations, Rt as generateUuid, V as mapNavigationLeaves, a as createSessionStore, at as stack, b as decodeWireEnvelope, d as createWebSession, dt as createHydrationDigest, ft as PrefetchedIntents, h as StorageUnavailableError, ht as ACTION_KINDS, i as createWebRuntime, r as resolveInitialNavigation, rt as leaf, t as parseCookieString, zt as detectPlatform } from "./src-BQBfaaPO.mjs";
2
+ import { n as ExecutionError } from "./path-CGFl2w7D.mjs";
3
+ import { t as resolveConfiguredMessages } from "./messages-CAt2QdGr.mjs";
4
+ import { t as LruMap } from "./lru-map-BKoUAySU.mjs";
5
+ import "./src-Ftl_0rhu.mjs";
6
+ //#region ../browser/src/utils/try-scroll.ts
7
+ const MAX_WAIT_MS = 5e3;
8
+ const POLL_INTERVAL_MS = 100;
9
+ const SCROLL_TOLERANCE = 2;
10
+ let pendingCleanup = null;
11
+ function cancelTryScroll() {
12
+ pendingCleanup?.();
13
+ }
14
+ function tryScroll(log, getScrollableElement, scrollY) {
15
+ cancelTryScroll();
16
+ const target = Math.max(0, scrollY);
17
+ const startedAt = Date.now();
18
+ let disposed = false;
19
+ let pendingFrame = null;
20
+ let intervalId = null;
21
+ let timeoutId = null;
22
+ let mutationObserver = null;
23
+ pendingCleanup = cleanup;
24
+ observeDocumentActivity();
25
+ document.addEventListener("load", scheduleAttempt, true);
26
+ intervalId = setInterval(scheduleAttempt, POLL_INTERVAL_MS);
27
+ timeoutId = setTimeout(scheduleAttempt, MAX_WAIT_MS);
28
+ scheduleAttempt();
29
+ function scheduleAttempt() {
30
+ if (disposed || pendingFrame !== null) return;
31
+ pendingFrame = requestAnimationFrame(() => {
32
+ pendingFrame = null;
33
+ attemptRestore();
34
+ });
35
+ }
36
+ function attemptRestore() {
37
+ if (disposed) return;
38
+ const elapsedMs = Date.now() - startedAt;
39
+ const element = getScrollableElement();
40
+ if (!element) {
41
+ if (elapsedMs >= MAX_WAIT_MS) {
42
+ log.warn("tryScroll: timed out waiting for the scrollable element", {
43
+ target,
44
+ elapsedMs
45
+ });
46
+ cleanup();
47
+ }
48
+ return;
49
+ }
50
+ element.scrollTop = target;
51
+ const actual = element.scrollTop;
52
+ if (actual >= target - SCROLL_TOLERANCE) {
53
+ log.info("scroll restored", {
54
+ target,
55
+ actual,
56
+ elapsedMs
57
+ });
58
+ cleanup();
59
+ return;
60
+ }
61
+ if (elapsedMs >= MAX_WAIT_MS) {
62
+ log.warn("tryScroll: timed out before reaching the target", {
63
+ target,
64
+ actual,
65
+ elapsedMs,
66
+ scrollHeight: element.scrollHeight,
67
+ clientHeight: element.clientHeight
68
+ });
69
+ cleanup();
70
+ }
71
+ }
72
+ function observeDocumentActivity() {
73
+ if (typeof MutationObserver === "undefined") return;
74
+ const root = document.body ?? document.documentElement;
75
+ if (!root) return;
76
+ mutationObserver = new MutationObserver(() => {
77
+ scheduleAttempt();
78
+ });
79
+ mutationObserver.observe(root, {
80
+ childList: true,
81
+ subtree: true
82
+ });
83
+ }
84
+ function cleanup() {
85
+ if (disposed) return;
86
+ disposed = true;
87
+ if (pendingCleanup === cleanup) pendingCleanup = null;
88
+ if (pendingFrame !== null) {
89
+ cancelAnimationFrame(pendingFrame);
90
+ pendingFrame = null;
91
+ }
92
+ if (intervalId !== null) {
93
+ clearInterval(intervalId);
94
+ intervalId = null;
95
+ }
96
+ if (timeoutId !== null) {
97
+ clearTimeout(timeoutId);
98
+ timeoutId = null;
99
+ }
100
+ mutationObserver?.disconnect();
101
+ mutationObserver = null;
102
+ document.removeEventListener("load", scheduleAttempt, true);
103
+ }
104
+ }
105
+ //#endregion
106
+ //#region ../browser/src/utils/history.ts
107
+ const HISTORY_SIZE_LIMIT = 10;
108
+ function ownedPosition(state) {
109
+ const value = state?.__finesoftHistory;
110
+ return value && typeof value.owner === "string" && value.owner.length > 0 && Number.isSafeInteger(value.position) ? value : void 0;
111
+ }
112
+ var History = class {
113
+ entries;
114
+ log;
115
+ getScrollablePageElement;
116
+ persistInHistoryState;
117
+ currentStateId;
118
+ popstateSequence = 0;
119
+ positions = /* @__PURE__ */ new Map();
120
+ position = 0;
121
+ owner;
122
+ positionKnown = true;
123
+ committedStateId;
124
+ compensation;
125
+ cleanups = [];
126
+ dispose() {
127
+ this.popstateSequence++;
128
+ cancelTryScroll();
129
+ this.compensation = void 0;
130
+ this.positions.clear();
131
+ for (const cleanup of this.cleanups.splice(0)) cleanup();
132
+ }
133
+ constructor(log, options, sizeLimit = HISTORY_SIZE_LIMIT) {
134
+ this.entries = new LruMap(sizeLimit);
135
+ const native = window.history.state;
136
+ const position = ownedPosition(native);
137
+ this.owner = position?.owner;
138
+ if (position && typeof native?.id === "string") {
139
+ this.position = position.position;
140
+ this.currentStateId = this.committedStateId = native.id;
141
+ this.positions.set(native.id, position.position);
142
+ }
143
+ this.log = log;
144
+ this.getScrollablePageElement = options.getScrollablePageElement;
145
+ this.persistInHistoryState = options.persistInHistoryState ?? false;
146
+ }
147
+ /** 写入 window.history.state 的载荷:persist 时连 state 一起带(跨刷新保留)。 */
148
+ historyState(id, state) {
149
+ return {
150
+ id,
151
+ ...this.persistInHistoryState && state !== void 0 ? { state } : {},
152
+ __finesoftHistory: {
153
+ owner: this.owner ??= id,
154
+ position: this.positions.get(id) ?? this.position
155
+ }
156
+ };
157
+ }
158
+ replaceState(state, url) {
159
+ const id = this.write("replaceState", url, {
160
+ state,
161
+ scrollY: 0
162
+ });
163
+ this.log.info("replaceState", state, url, id);
164
+ }
165
+ pushState(state, url) {
166
+ const id = this.write("pushState", url, {
167
+ state,
168
+ scrollY: 0
169
+ });
170
+ this.log.info("pushState", state, url, id);
171
+ }
172
+ write(method, url, entry) {
173
+ cancelTryScroll();
174
+ const id = generateUuid();
175
+ this.recordWrite(id, method === "pushState");
176
+ window.history[method](this.historyState(id, entry?.state), "", url);
177
+ this.currentStateId = id;
178
+ if (entry) this.entries.set(id, entry);
179
+ this.scrollTop = 0;
180
+ return id;
181
+ }
182
+ recordWrite(id, push) {
183
+ this.popstateSequence++;
184
+ if (!this.positionKnown) {
185
+ this.positions.clear();
186
+ this.owner = void 0;
187
+ this.position = 0;
188
+ this.positionKnown = true;
189
+ }
190
+ if (push) {
191
+ for (const [key, position] of this.positions) if (position > this.position) this.positions.delete(key);
192
+ this.position++;
193
+ }
194
+ this.positions.set(id, this.position);
195
+ this.committedStateId = id;
196
+ }
197
+ compensate(targetId, sequence) {
198
+ if (sequence !== this.popstateSequence || this.currentStateId !== targetId) return;
199
+ const committed = this.committedStateId;
200
+ const targetPosition = targetId && this.positions.get(targetId);
201
+ const committedPosition = committed && this.positions.get(committed);
202
+ if (!committed || typeof targetPosition !== "number" || typeof committedPosition !== "number") {
203
+ this.log.warn("Cannot compensate rejected navigation to an entry outside this History owner");
204
+ return;
205
+ }
206
+ const distance = committedPosition - targetPosition;
207
+ if (!distance) return;
208
+ this.compensation = { targetId: committed };
209
+ window.history.go(distance);
210
+ }
211
+ beforeTransition() {
212
+ cancelTryScroll();
213
+ const { state } = window.history;
214
+ if (!state) return;
215
+ this.saveScrollPosition(this.currentStateId === this.committedStateId ? state.id : this.committedStateId);
216
+ }
217
+ saveScrollPosition(stateId) {
218
+ if (!stateId) return;
219
+ const oldEntry = this.entries.get(stateId);
220
+ if (!oldEntry) {
221
+ this.log.info("current history state evicted from LRU, not saving scroll position");
222
+ return;
223
+ }
224
+ const { scrollTop } = this;
225
+ this.entries.set(stateId, {
226
+ ...oldEntry,
227
+ scrollY: scrollTop
228
+ });
229
+ this.log.info("saving scroll position", scrollTop);
230
+ }
231
+ onPopState(listener) {
232
+ const handler = (event) => {
233
+ cancelTryScroll();
234
+ const targetStateId = event.state?.id;
235
+ const sequence = ++this.popstateSequence;
236
+ if (this.currentStateId === this.committedStateId) this.saveScrollPosition(this.currentStateId);
237
+ this.currentStateId = targetStateId;
238
+ const nativePosition = ownedPosition(event.state);
239
+ if (nativePosition && nativePosition.owner === this.owner && typeof targetStateId === "string") this.positions.set(targetStateId, nativePosition.position);
240
+ const targetPosition = this.positions.get(targetStateId);
241
+ this.positionKnown = targetPosition !== void 0;
242
+ if (targetPosition !== void 0) this.position = targetPosition;
243
+ if (this.compensation && this.compensation.targetId === targetStateId) {
244
+ this.compensation = void 0;
245
+ if (targetStateId !== this.committedStateId) {
246
+ this.compensate(targetStateId, sequence);
247
+ return;
248
+ }
249
+ const retained = this.entries.get(targetStateId);
250
+ if (retained) tryScroll(this.log, () => this.getScrollablePageElement(), retained.scrollY);
251
+ return;
252
+ }
253
+ if (!this.currentStateId) this.log.warn("encountered a null event.state.id in onPopState event:", window.location.href);
254
+ this.log.info("popstate", this.entries, this.currentStateId);
255
+ const entry = this.currentStateId ? this.entries.get(this.currentStateId) : void 0;
256
+ const embedded = this.persistInHistoryState ? event.state?.state : void 0;
257
+ const cachedState = entry?.state ?? embedded;
258
+ let navigation;
259
+ try {
260
+ navigation = listener(window.location.href, cachedState);
261
+ } catch (error) {
262
+ this.log.error("onPopState listener error:", error);
263
+ this.compensate(targetStateId, sequence);
264
+ return;
265
+ }
266
+ Promise.resolve(navigation).then(() => {
267
+ if (sequence !== this.popstateSequence || this.currentStateId !== targetStateId) return;
268
+ this.committedStateId = targetStateId;
269
+ if (!entry) return;
270
+ const { scrollY } = entry;
271
+ this.log.info("restoring scroll to", scrollY);
272
+ tryScroll(this.log, () => this.getScrollablePageElement(), scrollY);
273
+ }, (error) => {
274
+ this.log.error("onPopState listener error:", error);
275
+ this.compensate(targetStateId, sequence);
276
+ });
277
+ };
278
+ window.addEventListener("popstate", handler);
279
+ this.cleanups.push(() => window.removeEventListener("popstate", handler));
280
+ }
281
+ /** 仅推入 URL,不缓存页面状态(用于页面加载失败场景) */
282
+ pushUrl(url) {
283
+ const id = this.write("pushState", url);
284
+ this.log.info("pushUrl (no state)", url, id);
285
+ }
286
+ /** 仅替换 URL,不缓存页面状态(用于页面加载失败场景) */
287
+ replaceUrl(url) {
288
+ const id = this.write("replaceState", url);
289
+ this.log.info("replaceUrl (no state)", url, id);
290
+ }
291
+ updateState(update, url) {
292
+ if (!this.currentStateId) {
293
+ this.log.warn("failed: encountered a null currentStateId inside updateState");
294
+ return;
295
+ }
296
+ const currentState = this.entries.get(this.currentStateId);
297
+ const newState = update(currentState?.state);
298
+ this.log.info("updateState", newState, this.currentStateId);
299
+ this.entries.set(this.currentStateId, {
300
+ scrollY: currentState?.scrollY ?? 0,
301
+ state: newState
302
+ });
303
+ if (url !== void 0 || this.persistInHistoryState) window.history.replaceState(this.historyState(this.currentStateId, newState), "", url);
304
+ }
305
+ get scrollTop() {
306
+ return this.getScrollablePageElement()?.scrollTop || 0;
307
+ }
308
+ set scrollTop(scrollTop) {
309
+ const element = this.getScrollablePageElement();
310
+ if (element) element.scrollTop = scrollTop;
311
+ }
312
+ };
313
+ //#endregion
314
+ //#region ../browser/src/navigation-bridge.ts
315
+ /** 默认可滚动元素查找(与 FlowAction handler 一致)。 */
316
+ function defaultGetScrollable() {
317
+ return document.getElementById("scrollable-page-override") || document.getElementById("scrollable-page") || document.documentElement;
318
+ }
319
+ /**
320
+ * 创建 NavigationBridge:订阅 controller、装配 popstate、返回历史绑定。
321
+ *
322
+ * 调用后 bridge 已激活(已订阅 controller + 已注册 popstate listener)。应用应在调用前/后
323
+ * 调一次 `controller.start()` 完成首屏解析;首屏的快照提交会被 bridge 用 `replaceState`
324
+ * 写入 history(first-page 语义),不会污染历史栈。
325
+ */
326
+ function createNavigationBridge(deps) {
327
+ const { controller, codec, router, log } = deps;
328
+ const history = new History(log, {
329
+ getScrollablePageElement: deps.getScrollablePageElement ?? defaultGetScrollable,
330
+ persistInHistoryState: true
331
+ });
332
+ let isApplyingHistory = false;
333
+ let isFirstSnapshot = true;
334
+ let lastEntryId;
335
+ let popSequence = 0;
336
+ const unsubscribe = controller.onCommit((snapshot) => {
337
+ if (isApplyingHistory) return;
338
+ ++popSequence;
339
+ const url = codec.encode(snapshot.tree, router);
340
+ const state = { tree: serializeNavigation(snapshot.tree) };
341
+ const entryId = snapshot.destinations.at(-1)?.entryId;
342
+ const shouldReplace = isFirstSnapshot || snapshot.historyMode === "replace" || snapshot.historyMode !== "push" && entryId !== void 0 && entryId === lastEntryId;
343
+ lastEntryId = entryId;
344
+ history.beforeTransition();
345
+ if (shouldReplace) history.replaceState(state, url);
346
+ else history.pushState(state, url);
347
+ isFirstSnapshot = false;
348
+ log.debug(`[navigation] snapshot → ${shouldReplace ? "replace" : "push"} ${url}`);
349
+ });
350
+ history.onPopState(async (url, cachedState) => {
351
+ log.debug(`[navigation] popstate → ${url}, cached=${!!cachedState}`);
352
+ const pop = ++popSequence;
353
+ deps.onPopStart?.();
354
+ controller.cancel?.();
355
+ isApplyingHistory = false;
356
+ const tree = restoreTree(url, cachedState) ?? await deps.resolveUrl?.(url);
357
+ if (pop !== popSequence) return;
358
+ if (tree === void 0) {
359
+ log.warn(`[navigation] popstate: cannot restore tree for ${url}, keeping current`);
360
+ throw Error(`Cannot restore navigation for ${url}`);
361
+ }
362
+ isApplyingHistory = true;
363
+ try {
364
+ const result = await controller.perform({
365
+ kind: "hydrate",
366
+ tree
367
+ });
368
+ if (result !== controller.getSnapshot()) throw Error(`Navigation rejected for ${url}`);
369
+ await deps.viewReady?.();
370
+ if (pop !== popSequence) return;
371
+ const canonical = codec.encode(result.tree, router);
372
+ if (new URL(canonical, window.location.origin).href !== new URL(url, window.location.origin).href) history.updateState(() => ({ tree: serializeNavigation(result.tree) }), canonical);
373
+ lastEntryId = result.destinations.at(-1)?.entryId;
374
+ } finally {
375
+ if (pop === popSequence) isApplyingHistory = false;
376
+ }
377
+ });
378
+ /** 还原整棵树:优先 history 缓存(反序列化),未命中回退 codec.decode。 */
379
+ function restoreTree(url, cachedState) {
380
+ if (cachedState !== void 0) try {
381
+ return deserializeNavigation(cachedState.tree);
382
+ } catch (error) {
383
+ log.warn("[navigation] cached tree deserialize failed, falling back to codec", error);
384
+ }
385
+ try {
386
+ return codec.decode(url, router);
387
+ } catch (error) {
388
+ log.error("[navigation] codec.decode failed:", error);
389
+ return;
390
+ }
391
+ }
392
+ return { dispose() {
393
+ unsubscribe();
394
+ history.dispose();
395
+ } };
396
+ }
397
+ //#endregion
398
+ //#region ../browser/src/server-data.ts
399
+ function deserializeServerData(source) {
400
+ const script = source.script;
401
+ if (!script?.textContent) return {
402
+ status: "fresh-load",
403
+ code: "missing"
404
+ };
405
+ script.parentNode?.removeChild(script);
406
+ let value;
407
+ try {
408
+ value = JSON.parse(script.textContent);
409
+ } catch {
410
+ return {
411
+ status: "fresh-load",
412
+ code: "invalid-json"
413
+ };
414
+ }
415
+ return decodeWireEnvelope(value, source.buildId);
416
+ }
417
+ function createPrefetchedIntentsFromDom(source) {
418
+ const result = deserializeServerData(source);
419
+ if (result.status === "ready") return PrefetchedIntents.fromArray(result.data.pages);
420
+ source.onFallback?.(result.code);
421
+ return PrefetchedIntents.empty();
422
+ }
423
+ //#endregion
424
+ //#region ../browser/src/session-bridge.ts
425
+ /** 导航变更后自动落盘的默认防抖窗口(ms):合并连续导航,避免每跳一屏写一次。 */
426
+ const SESSION_DEFAULT_DEBOUNCE_MS = 500;
427
+ /** 剥离 query / hash,仅取路径部分(用于「根入口」判定)。 */
428
+ function pathOf(url) {
429
+ const cut = [url.indexOf("?"), url.indexOf("#")].filter((i) => i >= 0);
430
+ return cut.length > 0 ? url.slice(0, Math.min(...cut)) : url;
431
+ }
432
+ /**
433
+ * 默认恢复门控策略(精确、无歧义,遵循「显式深链优先」)。
434
+ *
435
+ * - **带可比 URL**(快照含 `url` —— 扁平天然有,结构化由适配器在 capture 时记录浏览器
436
+ * `location`):当且仅当 `currentUrl` 全等 `snapshot.url`,**或** `currentUrl` 路径为根 `/`
437
+ * (重载同深链 / 全新进入 → 恢复;改去别的深链 → 跳过,显式深链不被旧会话覆盖)。
438
+ * 这让结构化导航与扁平**对称**:重载 `/item/1` 即恢复其作用域状态。
439
+ * - **回退(无 `url`)**:适配器不提供 URL 时只在根 `/` 放行。
440
+ * - **无 `navigation`**(仅切片):总恢复(与 URL 无关)。
441
+ *
442
+ * 「根」判定为路径 `=== "/"`(剥离 query/hash);带 base path 的应用应覆盖 `shouldRestore`。
443
+ */
444
+ function defaultShouldRestore(snapshot, currentUrl) {
445
+ if (snapshot.navigation === void 0) return true;
446
+ const atRoot = pathOf(currentUrl) === "/";
447
+ if (snapshot.url !== void 0) return currentUrl === snapshot.url || atRoot;
448
+ return atRoot;
449
+ }
450
+ /**
451
+ * 创建 SessionBridge:订阅导航、装配生命周期监听、返回会话句柄。
452
+ *
453
+ * 调用后 bridge 已激活(已订阅导航 + 已注册 `pagehide` / `visibilitychange`)。应用应在
454
+ * 首次导航完成后调一次 `restoreFromUrl(initialUrl)` 完成 boot 恢复。
455
+ */
456
+ function createSessionBridge(options) {
457
+ const { store, navigation, subscribeNavigation } = options;
458
+ const clear = store.clear.bind(store), dispose = store.dispose.bind(store);
459
+ const debounceMs = options.debounceMs ?? 500;
460
+ const shouldRestore = options.shouldRestore ?? defaultShouldRestore;
461
+ let disposed;
462
+ let restoring = options.deferPersistenceUntilRestore ?? false;
463
+ let timer;
464
+ function cancelTimer() {
465
+ if (timer !== void 0) {
466
+ clearTimeout(timer);
467
+ timer = void 0;
468
+ }
469
+ }
470
+ /** 立即落盘并取消挂起的防抖(生命周期事件用)。 */
471
+ function flush() {
472
+ if (disposed || restoring) return;
473
+ cancelTimer();
474
+ store.save();
475
+ }
476
+ /** 导航变更:先 prune 离树作用域,再防抖落盘。 */
477
+ function onNavigationChange() {
478
+ if (disposed || restoring) return;
479
+ store.scope.prune(navigation.presentKeys());
480
+ cancelTimer();
481
+ timer = setTimeout(() => {
482
+ timer = void 0;
483
+ store.save();
484
+ }, debounceMs);
485
+ }
486
+ /** `visibilitychange`:仅在隐藏时落盘(切后台 / 回收前抓末态)。 */
487
+ function onVisibilityChange() {
488
+ if (document.visibilityState === "hidden") flush();
489
+ }
490
+ const unsubscribeNavigation = subscribeNavigation?.(onNavigationChange);
491
+ window.addEventListener("pagehide", flush);
492
+ document.addEventListener("visibilitychange", onVisibilityChange);
493
+ return Object.assign(store, {
494
+ async restoreFromUrl(currentUrl) {
495
+ if (disposed) return { status: "closed" };
496
+ restoring = true;
497
+ cancelTimer();
498
+ try {
499
+ const loaded = await store.load();
500
+ if (disposed) return { status: "closed" };
501
+ if (loaded.status !== "loaded") return loaded;
502
+ if (!shouldRestore(loaded.snapshot, currentUrl)) return { status: "skipped" };
503
+ return await store.restore(loaded.snapshot);
504
+ } finally {
505
+ restoring = false;
506
+ }
507
+ },
508
+ clear() {
509
+ cancelTimer();
510
+ return clear();
511
+ },
512
+ dispose() {
513
+ if (disposed) return disposed;
514
+ if (!restoring) flush();
515
+ cancelTimer();
516
+ unsubscribeNavigation?.();
517
+ window.removeEventListener("pagehide", flush);
518
+ document.removeEventListener("visibilitychange", onVisibilityChange);
519
+ disposed = dispose();
520
+ return disposed;
521
+ }
522
+ });
523
+ }
524
+ //#endregion
525
+ //#region ../browser/src/web-storage.ts
526
+ /** Rejections remain observable by the session owner; unavailable is never a successful no-op. */
527
+ function createWebStorage(kind) {
528
+ function area() {
529
+ try {
530
+ const storage = kind === "session" ? window.sessionStorage : window.localStorage;
531
+ if (storage) return storage;
532
+ } catch {}
533
+ throw new StorageUnavailableError();
534
+ }
535
+ return {
536
+ async get(key) {
537
+ return area().getItem(key) ?? void 0;
538
+ },
539
+ async set(key, value) {
540
+ area().setItem(key, value);
541
+ },
542
+ async delete(key) {
543
+ area().removeItem(key);
544
+ }
545
+ };
546
+ }
547
+ //#endregion
548
+ //#region ../browser/src/dom-restore.ts
549
+ function entryOwns(entry, element, appRoot) {
550
+ if (element.closest("[data-fs-entry]") !== entry) return false;
551
+ return !appRoot?.hasAttribute("data-fs-app") || element.closest("[data-fs-app]") === appRoot;
552
+ }
553
+ function restoreRoots(entry, appRoot) {
554
+ const candidates = [];
555
+ if (entry.hasAttribute("data-restore-root") && entryOwns(entry, entry, appRoot)) candidates.push(entry);
556
+ for (const root of entry.querySelectorAll("[data-restore-root]")) if (entryOwns(entry, root, appRoot)) candidates.push(root);
557
+ return candidates;
558
+ }
559
+ function fieldKey(element) {
560
+ const restoreKey = element.getAttribute("data-restore-key");
561
+ if (restoreKey) return restoreKey;
562
+ return element.name || void 0;
563
+ }
564
+ function entryKey(entry) {
565
+ return entry.getAttribute("data-fs-key") ?? void 0;
566
+ }
567
+ function keyedElements(root, entry, appRoot, selector, key) {
568
+ const result = /* @__PURE__ */ new Map();
569
+ for (const element of root.querySelectorAll(selector)) {
570
+ if (!entryOwns(entry, element, appRoot)) continue;
571
+ const value = key(element);
572
+ if (value && !result.has(value)) result.set(value, element);
573
+ }
574
+ return result;
575
+ }
576
+ function createDomRestore(options) {
577
+ const schedule = options.schedule ?? ((callback) => callback());
578
+ let disposed = false;
579
+ let boundOutlet;
580
+ function collect(entry) {
581
+ const fields = Object.create(null);
582
+ const details = Object.create(null);
583
+ const scroll = Object.create(null);
584
+ for (const root of restoreRoots(entry, boundOutlet)) {
585
+ for (const element of root.querySelectorAll("input, textarea, select")) {
586
+ if (!entryOwns(entry, element, boundOutlet)) continue;
587
+ if (element.type === "password" || element.hasAttribute("data-restore-ignore")) continue;
588
+ const key = fieldKey(element);
589
+ if (!key) continue;
590
+ fields[key] = element.type === "checkbox" || element.type === "radio" ? element.checked : element.value;
591
+ }
592
+ for (const element of root.querySelectorAll("details")) {
593
+ if (!entryOwns(entry, element, boundOutlet)) continue;
594
+ const key = element.getAttribute("data-restore-key");
595
+ if (key) details[key] = element.open;
596
+ }
597
+ for (const element of root.querySelectorAll("[data-restore-scroll]")) {
598
+ if (!entryOwns(entry, element, boundOutlet)) continue;
599
+ const key = element.getAttribute("data-restore-key") ?? element.getAttribute("data-restore-scroll");
600
+ if (key) scroll[key] = {
601
+ top: element.scrollTop,
602
+ left: element.scrollLeft
603
+ };
604
+ }
605
+ }
606
+ return {
607
+ fields,
608
+ details,
609
+ scroll
610
+ };
611
+ }
612
+ function captureEntry(entry) {
613
+ if (!belongsToBoundApp(entry)) return;
614
+ const key = entryKey(entry);
615
+ if (!key) return;
616
+ const bag = options.scope.get(key) ?? {};
617
+ options.scope.set(key, {
618
+ ...bag,
619
+ __dom: collect(entry)
620
+ });
621
+ }
622
+ function apply(entry, dom) {
623
+ for (const root of restoreRoots(entry, boundOutlet)) {
624
+ const fields = keyedElements(root, entry, boundOutlet, "input, textarea, select", fieldKey);
625
+ for (const [key, value] of Object.entries(dom.fields ?? {})) {
626
+ const element = fields.get(key);
627
+ if (!element || element.type === "password" || element.hasAttribute("data-restore-ignore")) continue;
628
+ if (typeof value === "boolean") element.checked = value;
629
+ else {
630
+ const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(element), "value");
631
+ if (descriptor?.set) descriptor.set.call(element, value);
632
+ else element.value = value;
633
+ }
634
+ element.dispatchEvent(new Event("input", { bubbles: true }));
635
+ element.dispatchEvent(new Event("change", { bubbles: true }));
636
+ }
637
+ const details = keyedElements(root, entry, boundOutlet, "details", (element) => element.getAttribute("data-restore-key") ?? void 0);
638
+ for (const [key, open] of Object.entries(dom.details ?? {})) {
639
+ const element = details.get(key);
640
+ if (element) element.open = open;
641
+ }
642
+ const scroll = keyedElements(root, entry, boundOutlet, "[data-restore-scroll]", (element) => element.getAttribute("data-restore-key") ?? element.getAttribute("data-restore-scroll") ?? void 0);
643
+ for (const [key, position] of Object.entries(dom.scroll ?? {})) {
644
+ const element = scroll.get(key);
645
+ if (!element) continue;
646
+ element.scrollTop = position.top;
647
+ element.scrollLeft = position.left;
648
+ }
649
+ }
650
+ }
651
+ function restoreEntry(entry) {
652
+ if (!belongsToBoundApp(entry)) return;
653
+ const key = entryKey(entry);
654
+ const dom = key ? options.scope.get(key)?.__dom : void 0;
655
+ if (!dom) return;
656
+ schedule(() => {
657
+ if (!disposed) apply(entry, dom);
658
+ });
659
+ }
660
+ function belongsToBoundApp(entry) {
661
+ return !boundOutlet?.hasAttribute("data-fs-app") || entry.closest("[data-fs-app]") === boundOutlet;
662
+ }
663
+ function ownsEntry(entry) {
664
+ if (!boundOutlet || !belongsToBoundApp(entry)) return false;
665
+ for (let current = entry; current; current = current.parentElement) if (current === boundOutlet) return true;
666
+ return false;
667
+ }
668
+ function entryFrom(target) {
669
+ const entry = target?.closest("[data-fs-entry]");
670
+ return entry && ownsEntry(entry) ? entry : void 0;
671
+ }
672
+ const onEdit = (event) => {
673
+ const entry = entryFrom(event.target);
674
+ if (entry) captureEntry(entry);
675
+ };
676
+ const flushEntries = () => {
677
+ if (!boundOutlet) return;
678
+ if (boundOutlet.hasAttribute("data-fs-entry")) captureEntry(boundOutlet);
679
+ for (const entry of boundOutlet.querySelectorAll("[data-fs-entry]")) if (ownsEntry(entry)) captureEntry(entry);
680
+ };
681
+ const onVisibility = () => {
682
+ if (document.visibilityState === "hidden") flushEntries();
683
+ };
684
+ function attach(outlet) {
685
+ disposed = false;
686
+ boundOutlet = outlet;
687
+ outlet.addEventListener("input", onEdit, true);
688
+ outlet.addEventListener("change", onEdit, true);
689
+ window.addEventListener("pagehide", flushEntries);
690
+ document.addEventListener("visibilitychange", onVisibility);
691
+ }
692
+ function dispose() {
693
+ disposed = true;
694
+ if (!boundOutlet) return;
695
+ boundOutlet.removeEventListener("input", onEdit, true);
696
+ boundOutlet.removeEventListener("change", onEdit, true);
697
+ window.removeEventListener("pagehide", flushEntries);
698
+ document.removeEventListener("visibilitychange", onVisibility);
699
+ boundOutlet = void 0;
700
+ }
701
+ return {
702
+ captureEntry,
703
+ restoreEntry,
704
+ attach,
705
+ dispose
706
+ };
707
+ }
708
+ //#endregion
709
+ //#region ../browser/src/middleware/context.ts
710
+ /** 从 document.cookie 构建浏览器端上下文 */
711
+ function createBrowserContext(options) {
712
+ const { url, intent, container } = options;
713
+ return {
714
+ url,
715
+ path: new URL(url, window.location.origin).pathname,
716
+ params: intent.params ?? {},
717
+ query: intent.query ?? {},
718
+ intent,
719
+ isServer: false,
720
+ container,
721
+ getCookie: (name) => parseCookieString(document.cookie).get(name),
722
+ getHeader: () => void 0
723
+ };
724
+ }
725
+ //#endregion
726
+ //#region ../browser/src/hydration.ts
727
+ /** Inspect renderer children only; host attributes and the rest of the document are independent. */
728
+ function digestHydrationDOM(target) {
729
+ const digest = createHydrationDigest();
730
+ function write(node) {
731
+ if (node.nodeType === 3 || node.nodeType === 8) {
732
+ digest.write(node.nodeType === 3 ? "text" : "comment");
733
+ digest.write(node.nodeValue ?? "");
734
+ } else if (node.nodeType === 1) {
735
+ const element = node;
736
+ if (element.matches("script[data-fs-server-data]")) return;
737
+ digest.write("element");
738
+ digest.write(element.namespaceURI ?? "");
739
+ digest.write(element.localName);
740
+ if (element.localName.includes("-")) {
741
+ digest.write("opaque");
742
+ return;
743
+ }
744
+ for (const attribute of [...element.attributes].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) {
745
+ digest.write(attribute.name);
746
+ digest.write(attribute.name === "nonce" ? element.nonce : attribute.value);
747
+ }
748
+ digest.write("children");
749
+ const parent = element.localName === "template" ? element.content : element;
750
+ for (const child of parent.childNodes) write(child);
751
+ digest.write("end");
752
+ }
753
+ }
754
+ for (const child of target.childNodes) write(child);
755
+ return digest.value();
756
+ }
757
+ /** Decide how to attach a native root without guessing which script modified it. */
758
+ function inspectHydration(target, serverReady, expected) {
759
+ if (!serverReady || !target.hasChildNodes()) return { shouldHydrate: false };
760
+ if (!expected?.startsWith("v1:") || digestHydrationDOM(target) === expected) return { shouldHydrate: true };
761
+ const document = target.ownerDocument;
762
+ const selection = document.getSelection();
763
+ return {
764
+ shouldHydrate: !!(!!document.defaultView?.scrollX || !!document.defaultView?.scrollY || document.activeElement !== target && target.contains(document.activeElement) || selection && !selection.isCollapsed && target.contains(selection.anchorNode) || target.scrollTop !== 0 || target.scrollLeft !== 0 || [...target.querySelectorAll("*")].some((element) => {
765
+ if (element.localName.includes("-") || element.hasAttribute("data-fs-app") || element.shadowRoot) return true;
766
+ if (element.isContentEditable || element.scrollTop || element.scrollLeft) return true;
767
+ switch (element.tagName) {
768
+ case "INPUT": {
769
+ const input = element;
770
+ return input.type === "checkbox" || input.type === "radio" ? input.checked !== input.defaultChecked : input.value !== input.defaultValue || !!input.files?.length;
771
+ }
772
+ case "TEXTAREA": {
773
+ const input = element;
774
+ return input.value !== input.defaultValue;
775
+ }
776
+ case "SELECT": {
777
+ const input = element;
778
+ const options = [...input.options];
779
+ return options.some((option) => option.defaultSelected) ? options.some((option) => option.selected !== option.defaultSelected) : input.multiple ? input.selectedIndex !== -1 : input.selectedIndex > 0;
780
+ }
781
+ case "DETAILS":
782
+ case "IFRAME":
783
+ case "OBJECT":
784
+ case "EMBED":
785
+ case "CANVAS": return true;
786
+ case "AUDIO":
787
+ case "VIDEO": {
788
+ const media = element;
789
+ return !media.paused || media.currentTime !== 0;
790
+ }
791
+ default: return false;
792
+ }
793
+ })),
794
+ changedDOM: true
795
+ };
796
+ }
797
+ //#endregion
798
+ //#region ../browser/src/start-app.ts
799
+ const targets = /* @__PURE__ */ new WeakSet();
800
+ const windows = /* @__PURE__ */ new WeakSet();
801
+ async function createBrowserApp(config) {
802
+ const { target, definition } = config;
803
+ if (!target) throw Error("A browser target is required");
804
+ const ownerWindow = target.ownerDocument.defaultView;
805
+ if (!ownerWindow) throw Error("A browser window is required");
806
+ const win = ownerWindow;
807
+ const browserHistory = (config.history ?? "browser") === "browser";
808
+ if (targets.has(target)) throw Error("Browser target already owned");
809
+ if (browserHistory && windows.has(win)) throw Error("Browser history already owned; use memory history for embedded apps");
810
+ if (config.session && !config.persistenceKey) throw Error("Session requires a stable persistenceKey");
811
+ if (config.domRestore && !config.session) throw Error("DOM restore requires session storage");
812
+ targets.add(target);
813
+ if (browserHistory) windows.add(win);
814
+ let web, controller;
815
+ let disposeSession;
816
+ let bridge, session, dom;
817
+ let closed = false, mounted = false, restoring = !!config.session;
818
+ let acknowledged = 0, navigationSequence = 0, disposal;
819
+ let settleReady, failReady;
820
+ const ready = new Promise((resolve, reject) => {
821
+ settleReady = resolve;
822
+ failReady = reject;
823
+ });
824
+ ready.catch(() => {});
825
+ const pending = /* @__PURE__ */ new Map();
826
+ const domResets = /* @__PURE__ */ new Set();
827
+ const modalControllers = /* @__PURE__ */ new Set();
828
+ const modalWork = /* @__PURE__ */ new Set();
829
+ const cleanups = [];
830
+ const originalLocale = {
831
+ lang: target.getAttribute("lang"),
832
+ dir: target.getAttribute("dir"),
833
+ "data-fs-app": target.getAttribute("data-fs-app")
834
+ };
835
+ target.setAttribute("data-fs-app", definition.id);
836
+ const containers = () => [...target.querySelectorAll("[data-fs-entry]")].filter((element) => element.closest("[data-fs-app]") === target);
837
+ const capture = () => {
838
+ if (!restoring) for (const element of containers()) dom?.captureEntry(element);
839
+ };
840
+ const restore = () => {
841
+ for (const element of containers()) if (!element.hidden) dom?.restoreEntry(element);
842
+ };
843
+ const dispose = () => disposal ??= (async () => {
844
+ capture();
845
+ closed = true;
846
+ controller?.cancel();
847
+ for (const modal of modalControllers) modal.cancel();
848
+ for (const waiter of pending.values()) waiter.reject(new ExecutionError("cancelled"));
849
+ pending.clear();
850
+ failReady(new ExecutionError("cancelled"));
851
+ for (const cleanup of cleanups) cleanup();
852
+ bridge?.dispose();
853
+ const errors = [];
854
+ for (const cleanup of [
855
+ () => disposeSession?.(),
856
+ () => Promise.allSettled(modalWork),
857
+ () => session?.dispose(),
858
+ () => dom?.dispose(),
859
+ () => web?.dispose()
860
+ ]) try {
861
+ await cleanup();
862
+ } catch (error) {
863
+ errors.push(error);
864
+ }
865
+ for (const [key, value] of Object.entries(originalLocale)) if (value === null) target.removeAttribute(key);
866
+ else target.setAttribute(key, value);
867
+ targets.delete(target);
868
+ if (browserHistory) windows.delete(win);
869
+ if (errors.length) throw new AggregateError(errors, "Browser cleanup failed");
870
+ })();
871
+ try {
872
+ const initialUrl = config.url ?? (browserHistory ? win.location.pathname + win.location.search : "/");
873
+ const serverDataScript = config.serverDataSource === void 0 ? target.querySelector("script[data-fs-server-data]") : config.serverDataSource;
874
+ const expectedDOM = serverDataScript?.parentNode === target ? serverDataScript.getAttribute("data-fs-dom") : void 0;
875
+ const wire = deserializeServerData({
876
+ script: serverDataScript,
877
+ buildId: config.buildId
878
+ });
879
+ const configuration = definition.configuration ?? {};
880
+ const locale = configuration.locale ?? target.getAttribute("lang") ?? target.ownerDocument.documentElement.lang ?? void 0;
881
+ const fetch = configuration.fetch ?? globalThis.fetch?.bind(globalThis);
882
+ const messages = await resolveConfiguredMessages({
883
+ locale,
884
+ loadMessages: definition.loadMessages,
885
+ context: locale ? {
886
+ runtime: "browser",
887
+ url: initialUrl,
888
+ fetch
889
+ } : void 0
890
+ });
891
+ web = createWebRuntime({
892
+ ...configuration,
893
+ definition,
894
+ locale,
895
+ fetch,
896
+ messages,
897
+ safeFetch: {
898
+ validateDns: false,
899
+ ...configuration.safeFetch
900
+ },
901
+ platform: configuration.platform ?? detectPlatform(win.navigator.userAgent, win.navigator.maxTouchPoints),
902
+ prefetchedIntents: wire.status === "ready" ? PrefetchedIntents.fromArray(wire.data.pages) : PrefetchedIntents.empty()
903
+ });
904
+ const activeWeb = web;
905
+ const [log, attributes] = await Promise.all([web.getLogger(), web.getLocale()]);
906
+ if (attributes) {
907
+ target.setAttribute("lang", attributes.lang);
908
+ target.setAttribute("dir", attributes.dir);
909
+ }
910
+ const codec = definition.navigationCodec ?? createActiveLeafCodec();
911
+ const resolveUrl = async (url) => (await resolveInitialNavigation(activeWeb, url, { codec }))?.tree;
912
+ function browserUrl(value, external = false) {
913
+ let url;
914
+ try {
915
+ url = new URL(value, win.location.href);
916
+ } catch {
917
+ throw new ExecutionError("validation", "Invalid browser destination");
918
+ }
919
+ if (url.protocol !== "http:" && url.protocol !== "https:" && !(external && (url.protocol === "mailto:" || url.protocol === "tel:"))) throw new ExecutionError("validation", "Unsupported browser destination protocol");
920
+ return url;
921
+ }
922
+ const initial = wire.status === "ready" && wire.data.tree ? deserializeNavigation(wire.data.tree) : await resolveUrl(initialUrl) ?? stack(leaf("@finesoft/not-found", {}, { url: initialUrl }));
923
+ async function navigate(url, invocation) {
924
+ if (closed) throw new ExecutionError("configuration", "Browser application is closed");
925
+ const parsed = browserUrl(url);
926
+ if (parsed.origin !== win.location.origin) {
927
+ win.location.assign(parsed.href);
928
+ return;
929
+ }
930
+ const sequence = ++navigationSequence;
931
+ controller.cancel(invocation);
932
+ const path = parsed.pathname + parsed.search + parsed.hash;
933
+ const tree = controller.getTree();
934
+ const recovering = tree.kind === "stack" && tree.entries.length === 1 && tree.entries[0]?.kind === "leaf" && ["@finesoft/not-found", "@finesoft/error"].includes(tree.entries[0].intent);
935
+ const overlay = (recovering ? await resolveUrl(path) : void 0) ?? codec.decode(path, activeWeb.router);
936
+ const match = overlay ? void 0 : await activeWeb.router.resolve(path);
937
+ if (closed || sequence !== navigationSequence || invocation?.signal?.aborted) throw new ExecutionError("cancelled");
938
+ const action = !recovering && parsed.href === new URL(codec.encode(tree, activeWeb.router), win.location.href).href ? { kind: "refresh" } : overlay ? {
939
+ kind: "hydrate",
940
+ tree: overlay
941
+ } : definition.navigation || definition.navigationCodec ? {
942
+ kind: "push",
943
+ intent: match?.intent.id ?? "@finesoft/not-found",
944
+ params: match?.intent.params,
945
+ query: match?.intent.query,
946
+ url: path
947
+ } : {
948
+ kind: "hydrate",
949
+ tree: stack(leaf(match?.intent.id ?? "@finesoft/not-found", match?.intent.params, {
950
+ url: path,
951
+ query: match?.intent.query
952
+ }))
953
+ };
954
+ if (await controller.perform(action, invocation) !== controller.getSnapshot()) throw new ExecutionError("denied", "Navigation was not committed");
955
+ }
956
+ const createContext = ({ intent, params, query, url, execution }) => createBrowserContext({
957
+ url: url ?? initialUrl,
958
+ intent: {
959
+ id: intent,
960
+ params,
961
+ query
962
+ },
963
+ container: execution.container
964
+ });
965
+ const followRedirect = async (value, candidate) => {
966
+ const url = browserUrl(value.url);
967
+ if (url.origin !== win.location.origin) {
968
+ win.location.assign(url.href);
969
+ return;
970
+ }
971
+ const path = url.pathname + url.search + url.hash;
972
+ const overlay = codec.decode(path, activeWeb.router);
973
+ if (overlay) return overlay;
974
+ const match = await activeWeb.router.resolve(path);
975
+ const target = leaf(match?.intent.id ?? "@finesoft/not-found", match?.intent.params, {
976
+ url: path,
977
+ query: match?.intent.query
978
+ });
979
+ const redirected = candidate.destinations.find((entry) => entry.status && entry.status >= 300 && entry.status < 400)?.entryId ?? collectVisibleDestinations(candidate.tree).at(-1)?.entryId;
980
+ return redirected ? mapNavigationLeaves(candidate.tree, (entry) => entry.entryId === redirected ? target : entry) : stack(target);
981
+ };
982
+ const waitForCommit = (revision, signal) => {
983
+ if (!mounted || revision === acknowledged) return Promise.resolve();
984
+ if (signal?.aborted || closed) return Promise.reject(new ExecutionError("cancelled"));
985
+ return new Promise((resolve, reject) => {
986
+ const finish = (error) => {
987
+ pending.delete(revision);
988
+ signal?.removeEventListener("abort", abort);
989
+ if (error) reject(error);
990
+ else resolve();
991
+ };
992
+ const abort = () => finish(new ExecutionError("cancelled"));
993
+ pending.set(revision, {
994
+ resolve: () => finish(),
995
+ reject: finish
996
+ });
997
+ signal?.addEventListener("abort", abort, { once: true });
998
+ });
999
+ };
1000
+ const recordPageView = (snapshot) => {
1001
+ const page = snapshot.destinations.at(-1)?.page;
1002
+ if (page) activeWeb.runtime.record("PageView", {
1003
+ page: page.pageType,
1004
+ pageId: page.id,
1005
+ title: page.title,
1006
+ transitionId: snapshot.transitionId
1007
+ });
1008
+ };
1009
+ controller = createWebSession({
1010
+ session: () => session,
1011
+ commit(revision) {
1012
+ if (closed) return;
1013
+ acknowledged = revision;
1014
+ for (const entryId of domResets) {
1015
+ const bag = session?.scope.get(entryId);
1016
+ if (bag) {
1017
+ const { __dom: _oldDom, ...state } = bag;
1018
+ session.scope.set(entryId, state);
1019
+ }
1020
+ }
1021
+ domResets.clear();
1022
+ pending.get(revision)?.resolve();
1023
+ if (mounted) return;
1024
+ mounted = true;
1025
+ recordPageView(controller.getSnapshot());
1026
+ queueMicrotask(() => {
1027
+ if (closed) return;
1028
+ (async () => {
1029
+ await session?.restoreFromUrl(initialUrl);
1030
+ if (closed) return;
1031
+ restoring = false;
1032
+ restore();
1033
+ settleReady();
1034
+ })().catch((error) => {
1035
+ restoring = false;
1036
+ failReady(error);
1037
+ log.error("Session restore failed", error);
1038
+ });
1039
+ });
1040
+ },
1041
+ captureUrl: () => browserHistory ? win.location.pathname + win.location.search : codec.encode(controller.getTree(), activeWeb.router),
1042
+ web,
1043
+ initial,
1044
+ isServer: false,
1045
+ createContext,
1046
+ onRedirect: followRedirect,
1047
+ viewReady: async (snapshot, signal) => {
1048
+ await waitForCommit(controller.getSnapshot().revision, signal);
1049
+ if (!mounted) return;
1050
+ if (!restoring) restore();
1051
+ recordPageView(snapshot);
1052
+ }
1053
+ });
1054
+ disposeSession = controller.dispose;
1055
+ if (browserHistory) bridge = createNavigationBridge({
1056
+ controller,
1057
+ codec,
1058
+ router: web.router,
1059
+ log,
1060
+ onPopStart: () => {
1061
+ navigationSequence++;
1062
+ },
1063
+ resolveUrl,
1064
+ getScrollablePageElement: () => target.querySelector("[data-fs-scroll]") ?? target
1065
+ });
1066
+ controller.onAction(ACTION_KINDS.FLOW, async (action, invocation) => {
1067
+ if (closed) throw new ExecutionError("configuration", "Browser application is closed");
1068
+ if (invocation?.signal?.aborted) throw new ExecutionError("cancelled");
1069
+ if (action.presentationContext !== "modal") return navigate(action.url, invocation);
1070
+ if (!config.onModal) throw new ExecutionError("configuration", "Modal actions require onModal");
1071
+ const work = (async () => {
1072
+ const match = await activeWeb.router.resolve(action.url);
1073
+ if (closed || invocation?.signal?.aborted) throw new ExecutionError("cancelled");
1074
+ let handedOff = false;
1075
+ const modal = createWebSession({
1076
+ web: activeWeb,
1077
+ initial: stack(leaf(match?.intent.id ?? "@finesoft/not-found", match?.intent.params, {
1078
+ url: action.url,
1079
+ query: match?.intent.query
1080
+ })),
1081
+ isServer: false,
1082
+ createContext,
1083
+ onRedirect: async (value, candidate) => {
1084
+ const tree = await followRedirect(value, candidate);
1085
+ handedOff = !tree;
1086
+ return tree;
1087
+ }
1088
+ });
1089
+ modalControllers.add(modal);
1090
+ try {
1091
+ const snapshot = await modal.start(invocation);
1092
+ if (closed || handedOff) return;
1093
+ const page = snapshot.destinations.at(-1)?.page;
1094
+ if (page) await config.onModal(page, {
1095
+ app: controller,
1096
+ snapshot
1097
+ });
1098
+ } finally {
1099
+ await modal.dispose();
1100
+ modalControllers.delete(modal);
1101
+ }
1102
+ })();
1103
+ modalWork.add(work);
1104
+ try {
1105
+ await work;
1106
+ } finally {
1107
+ modalWork.delete(work);
1108
+ }
1109
+ });
1110
+ controller.onAction(ACTION_KINDS.EXTERNAL_URL, (action, invocation) => {
1111
+ if (closed) throw new ExecutionError("configuration", "Browser application is closed");
1112
+ if (invocation?.signal?.aborted) throw new ExecutionError("cancelled");
1113
+ win.open(browserUrl(action.url, true).href, "_blank", "noopener,noreferrer");
1114
+ });
1115
+ cleanups.push(controller.onCommit((next, previousSnapshot) => {
1116
+ capture();
1117
+ for (const destination of next.destinations) {
1118
+ const previous = previousSnapshot.entries.find((entry) => entry.entryId === destination.entryId);
1119
+ if (previous && previous.page.pageType !== destination.page.pageType && session) domResets.add(destination.entryId);
1120
+ }
1121
+ }));
1122
+ if (config.session) {
1123
+ const store = createSessionStore({
1124
+ ...config.session,
1125
+ storage: config.session.storage ?? createWebStorage("local"),
1126
+ key: `finesoft:${definition.id}:${config.persistenceKey}`,
1127
+ navigation: controller
1128
+ });
1129
+ for (const provider of config.session.providers ?? []) store.register(provider);
1130
+ session = createSessionBridge({
1131
+ store,
1132
+ navigation: controller,
1133
+ subscribeNavigation: controller.subscribe,
1134
+ debounceMs: config.session.debounceMs,
1135
+ shouldRestore: config.session.shouldRestore,
1136
+ deferPersistenceUntilRestore: true
1137
+ });
1138
+ if (config.domRestore) {
1139
+ dom = createDomRestore({
1140
+ schedule: (callback) => callback(),
1141
+ get scope() {
1142
+ return session.scope;
1143
+ }
1144
+ });
1145
+ dom.attach(target);
1146
+ }
1147
+ }
1148
+ await controller.start();
1149
+ const onClick = (event) => {
1150
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return;
1151
+ const anchor = event.target?.closest?.("a[href]");
1152
+ if (!anchor || !target.contains(anchor) || anchor.closest("[data-fs-app]") !== target || anchor.hasAttribute("download") || anchor.target && anchor.target !== "_self" || anchor.rel.split(/\s+/).includes("external")) return;
1153
+ const url = new URL(anchor.href, win.location.href);
1154
+ if (url.origin !== win.location.origin || !["http:", "https:"].includes(url.protocol)) return;
1155
+ if (url.pathname === win.location.pathname && url.search === win.location.search && url.hash) return;
1156
+ event.preventDefault();
1157
+ controller.perform({
1158
+ kind: "flow",
1159
+ url: url.href
1160
+ }).catch((error) => {
1161
+ if (!(error instanceof ExecutionError && error.code === "cancelled")) log.error("Navigation failed", error);
1162
+ });
1163
+ };
1164
+ target.addEventListener("click", onClick);
1165
+ cleanups.push(() => target.removeEventListener("click", onClick));
1166
+ const hydration = inspectHydration(target, wire.status === "ready", expectedDOM);
1167
+ if (hydration.changedDOM) log.info(hydration.shouldHydrate ? "SSR DOM changed before mount; retaining native hydration to avoid discarding browser state" : "SSR DOM changed before mount; rendering a fresh native root with the existing server data", {
1168
+ source: "hydration",
1169
+ code: "dom-changed",
1170
+ recovery: hydration.shouldHydrate ? "deferred" : "native-mount"
1171
+ });
1172
+ return Object.assign(controller, {
1173
+ shouldHydrate: hydration.shouldHydrate,
1174
+ ready,
1175
+ dispose
1176
+ });
1177
+ } catch (error) {
1178
+ await dispose().catch(() => {});
1179
+ throw error;
1180
+ }
1181
+ }
1182
+ //#endregion
1183
+ //#region ../browser/src/logger/local-storage-filter.ts
1184
+ const LEVEL_TO_NUM = {
1185
+ "*": 4,
1186
+ debug: 4,
1187
+ info: 3,
1188
+ warn: 2,
1189
+ error: 1,
1190
+ off: 0,
1191
+ "": 0
1192
+ };
1193
+ let cachedRules;
1194
+ let cachedRaw;
1195
+ function parseRules() {
1196
+ if (typeof globalThis.localStorage === "undefined") return {};
1197
+ let raw;
1198
+ try {
1199
+ raw = globalThis.localStorage.getItem("onyxLog");
1200
+ } catch {
1201
+ return {};
1202
+ }
1203
+ if (!raw) return {};
1204
+ if (raw === cachedRaw && cachedRules) return cachedRules;
1205
+ cachedRaw = raw;
1206
+ const rules = {};
1207
+ const parts = raw.split(",");
1208
+ for (const part of parts) {
1209
+ const [name, level] = part.trim().split("=");
1210
+ if (!name || level === void 0) continue;
1211
+ const num = LEVEL_TO_NUM[level.toLowerCase()] ?? void 0;
1212
+ if (num === void 0) continue;
1213
+ if (name === "*") rules.defaultLevel = num;
1214
+ else {
1215
+ rules.named ??= {};
1216
+ rules.named[name] = num;
1217
+ }
1218
+ }
1219
+ cachedRules = rules;
1220
+ return rules;
1221
+ }
1222
+ function shouldLog(name, level) {
1223
+ const rules = parseRules();
1224
+ if (rules.defaultLevel === void 0 && !rules.named) return true;
1225
+ const currentNum = LEVEL_TO_NUM[level] ?? 4;
1226
+ if (rules.named?.[name] !== void 0) return currentNum <= rules.named[name];
1227
+ if (rules.defaultLevel !== void 0) return currentNum <= rules.defaultLevel;
1228
+ return true;
1229
+ }
1230
+ function resetFilterCache() {
1231
+ cachedRules = void 0;
1232
+ cachedRaw = void 0;
1233
+ }
1234
+ //#endregion
1235
+ //#region ../browser/src/utils/pwa.ts
1236
+ /**
1237
+ * 检测 PWA display mode
1238
+ *
1239
+ * - `standalone`: 已安装的 PWA(通过 Add to Home Screen)
1240
+ * - `twa`: Trusted Web Activity(Android 原生壳)
1241
+ * - `browser`: 普通浏览器标签页
1242
+ */
1243
+ function getPWADisplayMode() {
1244
+ if (typeof window === "undefined") return "browser";
1245
+ if (document.referrer.startsWith("android-app://")) return "twa";
1246
+ if (window.matchMedia("(display-mode: standalone)").matches || "standalone" in window.navigator && window.navigator.standalone === true) return "standalone";
1247
+ return "browser";
1248
+ }
1249
+ //#endregion
1250
+ //#region ../browser/src/metrics/impression-observer.ts
1251
+ var IntersectionImpressionObserver = class {
1252
+ observer;
1253
+ tracked = /* @__PURE__ */ new Map();
1254
+ captured = [];
1255
+ minDuration;
1256
+ constructor(options = {}) {
1257
+ this.minDuration = options.minVisibleDuration ?? 1e3;
1258
+ this.observer = new IntersectionObserver((entries) => {
1259
+ const now = Date.now();
1260
+ for (const entry of entries) {
1261
+ const tracked = this.tracked.get(entry.target);
1262
+ if (!tracked) continue;
1263
+ if (entry.isIntersecting) {
1264
+ if (tracked.visibleSince === null) tracked.visibleSince = now;
1265
+ } else if (tracked.visibleSince !== null) {
1266
+ if (now - tracked.visibleSince >= this.minDuration) this.captured.push({
1267
+ id: tracked.id,
1268
+ timestamp: tracked.visibleSince,
1269
+ metadata: tracked.metadata
1270
+ });
1271
+ tracked.visibleSince = null;
1272
+ }
1273
+ }
1274
+ }, { threshold: options.threshold ?? .5 });
1275
+ }
1276
+ observe(element, id, metadata) {
1277
+ this.tracked.set(element, {
1278
+ id,
1279
+ metadata,
1280
+ visibleSince: null
1281
+ });
1282
+ this.observer.observe(element);
1283
+ }
1284
+ unobserve(element) {
1285
+ this.observer.unobserve(element);
1286
+ this.tracked.delete(element);
1287
+ }
1288
+ consume() {
1289
+ const now = Date.now();
1290
+ for (const [, tracked] of this.tracked) if (tracked.visibleSince !== null) {
1291
+ if (now - tracked.visibleSince >= this.minDuration) {
1292
+ this.captured.push({
1293
+ id: tracked.id,
1294
+ timestamp: tracked.visibleSince,
1295
+ metadata: tracked.metadata
1296
+ });
1297
+ tracked.visibleSince = now;
1298
+ }
1299
+ }
1300
+ return this.captured.splice(0);
1301
+ }
1302
+ destroy() {
1303
+ this.observer.disconnect();
1304
+ this.tracked.clear();
1305
+ this.captured.length = 0;
1306
+ }
1307
+ };
1308
+ //#endregion
1309
+ //#region ../browser/src/i18n/locale.ts
1310
+ function setHtmlLocaleAttributes(attrs) {
1311
+ document.documentElement.lang = attrs.lang;
1312
+ document.documentElement.dir = attrs.dir;
1313
+ }
1314
+ //#endregion
1315
+ export { tryScroll as _, shouldLog as a, createDomRestore as c, createSessionBridge as d, defaultShouldRestore as f, History as g, createNavigationBridge as h, resetFilterCache as i, createWebStorage as l, deserializeServerData as m, IntersectionImpressionObserver as n, createBrowserApp as o, createPrefetchedIntentsFromDom as p, getPWADisplayMode as r, createBrowserContext as s, setHtmlLocaleAttributes as t, SESSION_DEFAULT_DEBOUNCE_MS as u };
1316
+
1317
+ //# sourceMappingURL=browser-CR5vhgXg.mjs.map