@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.
- package/README.md +4 -4
- package/dist/Outlet.svelte +39 -0
- package/dist/Outlet.svelte.d.ts +7 -0
- package/dist/browser-CR5vhgXg.mjs +1317 -0
- package/dist/browser-kFMjlLGT.d.mts +262 -0
- package/dist/browser.d.mts +7 -2
- package/dist/browser.mjs +9 -1
- package/dist/controller-types-CgmJ6-le.d.mts +16 -0
- package/dist/cookies-BiRlUanX.d.mts +786 -0
- package/dist/fetch-policy-BHT8RtrL.mjs +82 -0
- package/dist/host-guard-DDWxLpFL.mjs +222 -0
- package/dist/http-B6CJqDyf.d.mts +46 -0
- package/dist/http-CaxrMD1A.d.mts +1 -0
- package/dist/http-D70PL72H.mjs +257 -0
- package/dist/http.d.mts +3 -0
- package/dist/http.mjs +2 -0
- package/dist/index-node.d.mts +15 -0
- package/dist/index-node.mjs +15 -0
- package/dist/index.d.mts +48 -697
- package/dist/index.mjs +44 -261
- package/dist/load-node.d.mts +5 -0
- package/dist/load-node.mjs +10 -0
- package/dist/load-portable.d.mts +5 -0
- package/dist/load-portable.mjs +8 -0
- package/dist/lru-map-BKoUAySU.mjs +50 -0
- package/dist/messages-CAt2QdGr.mjs +140 -0
- package/dist/native-contract-DuR25hYB.d.mts +14 -0
- package/dist/native-contract.d.mts +2 -0
- package/dist/native-contract.mjs +1 -0
- package/dist/node-D9hB4dsz.d.mts +35 -0
- package/dist/node.d.mts +2 -0
- package/dist/node.mjs +59 -0
- package/dist/path-CGFl2w7D.mjs +113 -0
- package/dist/path-CXT6xGPO.d.mts +261 -0
- package/dist/portable-CaxrMD1A.d.mts +1 -0
- package/dist/portable.d.mts +11 -0
- package/dist/portable.mjs +12 -0
- package/dist/proxy-2dSWO-Xw.d.mts +53 -0
- package/dist/proxy-z02VvGIj.mjs +7520 -0
- package/dist/public-types-BcJM-AYc.mjs +835 -0
- package/dist/react-DhwBRw01.d.mts +16 -0
- package/dist/react.d.mts +3 -0
- package/dist/react.mjs +29 -0
- package/dist/rolldown-runtime-B4iAMlE-.mjs +35 -0
- package/dist/secure-fetch-Xlht2jd7.d.mts +30 -0
- package/dist/server-controller-proxy-BkVuVWVD.d.mts +38 -0
- package/dist/session-DnB4ZC3x.d.mts +1279 -0
- package/dist/src-BQBfaaPO.mjs +3825 -0
- package/dist/src-Ftl_0rhu.mjs +28 -0
- package/dist/ssr-BLzYP4wU.d.mts +207 -0
- package/dist/ssr-Tn4YkuxM.mjs +357 -0
- package/dist/ssr.d.mts +3 -0
- package/dist/ssr.mjs +3 -0
- package/dist/svelte-Dr5to3SE.d.mts +16 -0
- package/dist/svelte.d.mts +3 -0
- package/dist/svelte.mjs +13 -0
- package/dist/typegen-C-WeJCtf.d.mts +12 -0
- package/dist/typegen-cli.d.mts +1 -0
- package/dist/typegen-cli.mjs +11 -0
- package/dist/typegen.d.mts +3 -0
- package/dist/typegen.mjs +2 -0
- package/dist/types-B1BT0N3t.mjs +402 -0
- package/dist/undici-CPfL25Hr.mjs +22262 -0
- package/dist/vite-Cj4SPA8D.d.mts +277 -0
- package/dist/vite.d.mts +4 -0
- package/dist/vite.mjs +2354 -0
- package/dist/vue-DGmzuKho.d.mts +32 -0
- package/dist/vue.d.mts +3 -0
- package/dist/vue.mjs +56 -0
- package/dist/web.d.mts +6 -0
- package/dist/web.mjs +8 -0
- package/dist/worker.d.mts +2 -0
- package/dist/worker.mjs +2 -0
- package/docs/01-getting-started.md +67 -199
- package/docs/02-routing-and-controllers.md +163 -241
- package/docs/03-middleware.md +10 -212
- package/docs/04-rendering-and-hydration.md +7 -332
- package/docs/05-i18n.md +7 -237
- package/docs/06-http-client.md +20 -263
- package/docs/07-di-container.md +23 -257
- package/docs/08-observability.md +6 -286
- package/docs/09-server-and-deployment.md +59 -219
- package/docs/10-features-platform-pwa.md +7 -231
- package/docs/11-navigation.md +143 -288
- package/docs/12-session-restoration.md +6 -214
- package/docs/README.md +7 -7
- package/docs/advanced/custom-action-handler.md +29 -229
- package/docs/advanced/custom-adapter.md +7 -259
- package/docs/advanced/custom-event-recorder.md +7 -312
- package/docs/advanced/inline-proxy-codegen.md +6 -185
- package/docs/advanced/multi-tenant-scopes.md +10 -323
- package/docs/engineering/ci-release-flow.md +35 -222
- package/docs/engineering/project-structure.md +34 -277
- package/docs/engineering/testing.md +8 -310
- package/docs/pitfalls/container-scope-leak.md +2 -214
- package/docs/pitfalls/i18n-bundle-size.md +6 -176
- package/docs/pitfalls/proxy-binary-payloads.md +8 -12
- package/docs/pitfalls/ssr-hydration-mismatch.md +9 -155
- package/docs/pitfalls/ssr-vs-csr-globals.md +9 -170
- package/docs/zh/01-getting-started.md +67 -199
- package/docs/zh/02-routing-and-controllers.md +166 -244
- package/docs/zh/03-middleware.md +10 -212
- package/docs/zh/04-rendering-and-hydration.md +7 -332
- package/docs/zh/05-i18n.md +7 -237
- package/docs/zh/06-http-client.md +20 -263
- package/docs/zh/07-di-container.md +23 -257
- package/docs/zh/08-observability.md +6 -283
- package/docs/zh/09-server-and-deployment.md +59 -219
- package/docs/zh/10-features-platform-pwa.md +7 -231
- package/docs/zh/11-navigation.md +130 -290
- package/docs/zh/12-session-restoration.md +6 -214
- package/docs/zh/README.md +4 -4
- package/docs/zh/advanced/custom-action-handler.md +29 -229
- package/docs/zh/advanced/custom-adapter.md +7 -259
- package/docs/zh/advanced/custom-event-recorder.md +7 -312
- package/docs/zh/advanced/inline-proxy-codegen.md +6 -185
- package/docs/zh/advanced/multi-tenant-scopes.md +10 -323
- package/docs/zh/engineering/ci-release-flow.md +35 -222
- package/docs/zh/engineering/project-structure.md +34 -277
- package/docs/zh/engineering/testing.md +8 -310
- package/docs/zh/pitfalls/container-scope-leak.md +2 -214
- package/docs/zh/pitfalls/i18n-bundle-size.md +6 -176
- package/docs/zh/pitfalls/proxy-binary-payloads.md +8 -12
- package/docs/zh/pitfalls/ssr-hydration-mismatch.md +9 -155
- package/docs/zh/pitfalls/ssr-vs-csr-globals.md +9 -170
- package/package.json +118 -20
- package/dist/browser-BHhVWXik.mjs +0 -2
- 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
|