@camera.ui/transport 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +22 -0
- package/README.md +10 -0
- package/dist/contract-d-0gfY8v.js +43 -0
- package/dist/core/kernel.d.ts +14 -0
- package/dist/core/reducer.d.ts +2 -0
- package/dist/core/resolver.d.ts +5 -0
- package/dist/core/types.d.ts +109 -0
- package/dist/effects/backoff.d.ts +14 -0
- package/dist/effects/crossTab.d.ts +14 -0
- package/dist/effects/networkChange.d.ts +10 -0
- package/dist/effects/persistence.d.ts +31 -0
- package/dist/effects/presence.d.ts +17 -0
- package/dist/effects/probeLoop.d.ts +30 -0
- package/dist/effects/tokenLifecycle.d.ts +39 -0
- package/dist/effects/transportSync.d.ts +11 -0
- package/dist/effects/transportWatchdog.d.ts +16 -0
- package/dist/effects/workerBridge.d.ts +17 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +1101 -0
- package/dist/race.d.ts +23 -0
- package/dist/testing/fakeTransport.d.ts +24 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing.js +53 -0
- package/dist/transports/contract.d.ts +29 -0
- package/dist/transports/http.d.ts +14 -0
- package/dist/transports/http.js +131 -0
- package/dist/transports/nativeHttp.d.ts +33 -0
- package/dist/transports/nativeHttp.js +119 -0
- package/dist/transports/nats.d.ts +25 -0
- package/dist/transports/nats.js +225 -0
- package/dist/transports/socketio.d.ts +19 -0
- package/dist/transports/socketio.js +160 -0
- package/dist/transports/ws.d.ts +34 -0
- package/dist/transports/ws.js +228 -0
- package/dist/worker/index.d.ts +3 -0
- package/dist/worker/mirror.d.ts +17 -0
- package/dist/worker/protocol.d.ts +23 -0
- package/dist/worker.js +66 -0
- package/package.json +95 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1101 @@
|
|
|
1
|
+
import { i as isTokenOnlyChange, n as isEndpointChange, r as isSameTarget, t as TransportEmitter } from "./contract-d-0gfY8v.js";
|
|
2
|
+
//#region src/core/reducer.ts
|
|
3
|
+
var EMPTY_TRANSPORTS = /* @__PURE__ */ new Map();
|
|
4
|
+
var DEFAULT_BACKOFF_MS = 5e3;
|
|
5
|
+
function reducer(phase, action, ctx) {
|
|
6
|
+
switch (action.type) {
|
|
7
|
+
case "RESET": return phase.kind === "idle" ? phase : { kind: "idle" };
|
|
8
|
+
case "BOOT":
|
|
9
|
+
if (phase.kind !== "idle" && phase.kind !== "offline" && phase.kind !== "needs-auth") return phase;
|
|
10
|
+
return {
|
|
11
|
+
kind: "discovering",
|
|
12
|
+
instanceId: action.instanceId,
|
|
13
|
+
attempt: 1
|
|
14
|
+
};
|
|
15
|
+
case "USER_RETRY":
|
|
16
|
+
if (phase.kind === "offline") return {
|
|
17
|
+
kind: "discovering",
|
|
18
|
+
instanceId: phase.instanceId ?? "",
|
|
19
|
+
attempt: 1
|
|
20
|
+
};
|
|
21
|
+
if (phase.kind === "reconnecting") return {
|
|
22
|
+
kind: "discovering",
|
|
23
|
+
instanceId: phase.instanceId,
|
|
24
|
+
attempt: 1
|
|
25
|
+
};
|
|
26
|
+
if (phase.kind === "needs-auth") return {
|
|
27
|
+
kind: "discovering",
|
|
28
|
+
instanceId: phase.instanceId ?? "",
|
|
29
|
+
attempt: 1
|
|
30
|
+
};
|
|
31
|
+
if (phase.kind === "online") return {
|
|
32
|
+
kind: "discovering",
|
|
33
|
+
instanceId: phase.instanceId,
|
|
34
|
+
attempt: 1
|
|
35
|
+
};
|
|
36
|
+
return phase;
|
|
37
|
+
case "PROBE_SUCCEEDED":
|
|
38
|
+
if (phase.kind === "discovering") return {
|
|
39
|
+
kind: "online",
|
|
40
|
+
instanceId: phase.instanceId,
|
|
41
|
+
target: {
|
|
42
|
+
endpoint: action.endpoint,
|
|
43
|
+
tokens: action.tokens
|
|
44
|
+
},
|
|
45
|
+
transports: EMPTY_TRANSPORTS
|
|
46
|
+
};
|
|
47
|
+
if (phase.kind === "reconnecting") return {
|
|
48
|
+
kind: "online",
|
|
49
|
+
instanceId: phase.instanceId,
|
|
50
|
+
target: {
|
|
51
|
+
endpoint: action.endpoint,
|
|
52
|
+
tokens: action.tokens
|
|
53
|
+
},
|
|
54
|
+
transports: phase.transports
|
|
55
|
+
};
|
|
56
|
+
return phase;
|
|
57
|
+
case "PROBE_FAILED_ALL": {
|
|
58
|
+
if (phase.kind !== "discovering" && phase.kind !== "reconnecting") return phase;
|
|
59
|
+
if (action.error === "needs-auth") return {
|
|
60
|
+
kind: "needs-auth",
|
|
61
|
+
instanceId: phase.instanceId,
|
|
62
|
+
reason: action.error
|
|
63
|
+
};
|
|
64
|
+
const attempt = phase.kind === "discovering" ? phase.attempt : 1;
|
|
65
|
+
const backoff = ctx.retryBackoffMs?.(attempt) ?? DEFAULT_BACKOFF_MS;
|
|
66
|
+
return {
|
|
67
|
+
kind: "offline",
|
|
68
|
+
instanceId: phase.instanceId,
|
|
69
|
+
lastError: action.error,
|
|
70
|
+
nextRetryAt: ctx.now() + backoff
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
case "TRANSPORT_UP":
|
|
74
|
+
if (phase.kind === "online") return {
|
|
75
|
+
...phase,
|
|
76
|
+
transports: setStatus(phase.transports, action.id, { up: true })
|
|
77
|
+
};
|
|
78
|
+
if (phase.kind === "reconnecting") {
|
|
79
|
+
const next = setStatus(phase.transports, action.id, { up: true });
|
|
80
|
+
if (phase.lastTarget && allPhaseGatingUp(next, ctx)) return {
|
|
81
|
+
kind: "online",
|
|
82
|
+
instanceId: phase.instanceId,
|
|
83
|
+
target: phase.lastTarget,
|
|
84
|
+
transports: next
|
|
85
|
+
};
|
|
86
|
+
return {
|
|
87
|
+
...phase,
|
|
88
|
+
transports: next
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return phase;
|
|
92
|
+
case "TRANSPORT_DOWN":
|
|
93
|
+
if (phase.kind === "online" || phase.kind === "reconnecting") return {
|
|
94
|
+
...phase,
|
|
95
|
+
transports: setStatus(phase.transports, action.id, {
|
|
96
|
+
up: false,
|
|
97
|
+
lastError: action.reason,
|
|
98
|
+
downSince: ctx.now()
|
|
99
|
+
})
|
|
100
|
+
};
|
|
101
|
+
return phase;
|
|
102
|
+
case "TRANSPORT_DOWN_CONFIRMED":
|
|
103
|
+
if (phase.kind !== "online") return phase;
|
|
104
|
+
if (!ctx.specs.get(action.id)?.phaseGating) return phase;
|
|
105
|
+
return {
|
|
106
|
+
kind: "reconnecting",
|
|
107
|
+
instanceId: phase.instanceId,
|
|
108
|
+
lastTarget: phase.target,
|
|
109
|
+
cause: "transport-down",
|
|
110
|
+
since: ctx.now(),
|
|
111
|
+
transports: setStatus(phase.transports, action.id, {
|
|
112
|
+
up: false,
|
|
113
|
+
lastError: "down-confirmed",
|
|
114
|
+
downSince: ctx.now()
|
|
115
|
+
})
|
|
116
|
+
};
|
|
117
|
+
case "TOKENS_REFRESHED":
|
|
118
|
+
if (phase.kind === "online") {
|
|
119
|
+
if (tokensEqual(phase.target.tokens, action.tokens)) return phase;
|
|
120
|
+
return {
|
|
121
|
+
...phase,
|
|
122
|
+
target: {
|
|
123
|
+
...phase.target,
|
|
124
|
+
tokens: action.tokens
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (phase.kind === "reconnecting" && phase.lastTarget) {
|
|
129
|
+
if (tokensEqual(phase.lastTarget.tokens, action.tokens)) return phase;
|
|
130
|
+
return {
|
|
131
|
+
...phase,
|
|
132
|
+
lastTarget: {
|
|
133
|
+
...phase.lastTarget,
|
|
134
|
+
tokens: action.tokens
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return phase;
|
|
139
|
+
case "TOKENS_INVALID":
|
|
140
|
+
if (phase.kind !== "online" && phase.kind !== "reconnecting") return phase;
|
|
141
|
+
if (action.transient === true) {
|
|
142
|
+
const backoff = ctx.retryBackoffMs?.(1) ?? DEFAULT_BACKOFF_MS;
|
|
143
|
+
return {
|
|
144
|
+
kind: "offline",
|
|
145
|
+
instanceId: phase.instanceId,
|
|
146
|
+
lastError: `tokens invalid: ${action.reason}`,
|
|
147
|
+
nextRetryAt: ctx.now() + backoff
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
kind: "needs-auth",
|
|
152
|
+
instanceId: phase.instanceId,
|
|
153
|
+
reason: `tokens invalid: ${action.reason}`
|
|
154
|
+
};
|
|
155
|
+
case "BACKOFF_HINT": {
|
|
156
|
+
if (phase.kind !== "offline") return phase;
|
|
157
|
+
const now = ctx.now();
|
|
158
|
+
const candidate = now + action.retryAfterMs;
|
|
159
|
+
if (candidate <= phase.nextRetryAt) return phase;
|
|
160
|
+
return {
|
|
161
|
+
...phase,
|
|
162
|
+
nextRetryAt: candidate,
|
|
163
|
+
backoffHint: {
|
|
164
|
+
retryAfterMs: action.retryAfterMs,
|
|
165
|
+
setAt: now,
|
|
166
|
+
source: action.source
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function setStatus(map, id, status) {
|
|
173
|
+
const next = new Map(map);
|
|
174
|
+
next.set(id, status);
|
|
175
|
+
return next;
|
|
176
|
+
}
|
|
177
|
+
function allPhaseGatingUp(map, ctx) {
|
|
178
|
+
for (const [id, spec] of ctx.specs) {
|
|
179
|
+
if (!spec.phaseGating) continue;
|
|
180
|
+
if (!map.get(id)?.up) return false;
|
|
181
|
+
}
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
function tokensEqual(a, b) {
|
|
185
|
+
return a.access === b.access && a.refresh === b.refresh && a.accessExpiresAt === b.accessExpiresAt && a.refreshExpiresAt === b.refreshExpiresAt && a.proxySession === b.proxySession && a.proxySessionExpiresAt === b.proxySessionExpiresAt && a.proxyRefresh === b.proxyRefresh;
|
|
186
|
+
}
|
|
187
|
+
//#endregion
|
|
188
|
+
//#region src/core/kernel.ts
|
|
189
|
+
function createKernel(options) {
|
|
190
|
+
let current = options.initial ?? { kind: "idle" };
|
|
191
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
192
|
+
const queue = [];
|
|
193
|
+
let dispatching = false;
|
|
194
|
+
let disposed = false;
|
|
195
|
+
function dispatch(action) {
|
|
196
|
+
if (disposed) return;
|
|
197
|
+
if (dispatching) {
|
|
198
|
+
queue.push(action);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
dispatching = true;
|
|
202
|
+
try {
|
|
203
|
+
let next = action;
|
|
204
|
+
while (next) {
|
|
205
|
+
const prev = current;
|
|
206
|
+
const after = reducer(prev, next, options.context);
|
|
207
|
+
if (after !== prev) {
|
|
208
|
+
current = after;
|
|
209
|
+
for (const listener of [...listeners]) try {
|
|
210
|
+
listener(after, prev, next);
|
|
211
|
+
} catch (err) {
|
|
212
|
+
console.warn("[kernel] listener threw on", next.type, err);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
next = queue.shift();
|
|
216
|
+
}
|
|
217
|
+
} finally {
|
|
218
|
+
dispatching = false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function subscribe(listener) {
|
|
222
|
+
listeners.add(listener);
|
|
223
|
+
return () => {
|
|
224
|
+
listeners.delete(listener);
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
function dispose() {
|
|
228
|
+
disposed = true;
|
|
229
|
+
listeners.clear();
|
|
230
|
+
queue.length = 0;
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
get phase() {
|
|
234
|
+
return current;
|
|
235
|
+
},
|
|
236
|
+
dispatch,
|
|
237
|
+
subscribe,
|
|
238
|
+
dispose
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
//#endregion
|
|
242
|
+
//#region src/core/resolver.ts
|
|
243
|
+
function sortByPriority(endpoints) {
|
|
244
|
+
return [...endpoints].sort((a, b) => (a.priority ?? 99) - (b.priority ?? 99));
|
|
245
|
+
}
|
|
246
|
+
function isSameEndpoint(a, b) {
|
|
247
|
+
return a.mode === b.mode && a.url === b.url;
|
|
248
|
+
}
|
|
249
|
+
function endpointKey(ep) {
|
|
250
|
+
return `${ep.mode}|${ep.url}`;
|
|
251
|
+
}
|
|
252
|
+
//#endregion
|
|
253
|
+
//#region src/effects/backoff.ts
|
|
254
|
+
var DEFAULT_SCHEDULE = [
|
|
255
|
+
5e3,
|
|
256
|
+
1e4,
|
|
257
|
+
3e4,
|
|
258
|
+
6e4
|
|
259
|
+
];
|
|
260
|
+
function attachBackoff(options) {
|
|
261
|
+
const schedule = options.schedule ?? DEFAULT_SCHEDULE;
|
|
262
|
+
if (schedule.length === 0) throw new Error("backoff: schedule must have at least one entry");
|
|
263
|
+
const now = options.now ?? (() => Date.now());
|
|
264
|
+
const setTimer = options.setTimer ?? ((cb, ms) => setTimeout(cb, ms));
|
|
265
|
+
const clearTimer = options.clearTimer ?? ((h) => clearTimeout(h));
|
|
266
|
+
let timer;
|
|
267
|
+
let attempt = 0;
|
|
268
|
+
let detached = false;
|
|
269
|
+
function cancelTimer(reason) {
|
|
270
|
+
if (timer !== void 0) {
|
|
271
|
+
clearTimer(timer);
|
|
272
|
+
timer = void 0;
|
|
273
|
+
if (reason) options.onCancelled?.(reason);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
function scheduleFromPhase(phase, isReschedule) {
|
|
277
|
+
cancelTimer(isReschedule ? "rescheduled" : void 0);
|
|
278
|
+
const scheduleDelay = schedule[Math.min(attempt, schedule.length - 1)];
|
|
279
|
+
const phaseDelay = Math.max(0, phase.nextRetryAt - now());
|
|
280
|
+
const delay = Math.max(scheduleDelay, phaseDelay);
|
|
281
|
+
options.onScheduled?.(attempt + 1, delay);
|
|
282
|
+
if (phase.backoffHint && phaseDelay > scheduleDelay) options.onHintApplied?.(delay, phase.backoffHint.source);
|
|
283
|
+
timer = setTimer(() => {
|
|
284
|
+
timer = void 0;
|
|
285
|
+
if (detached) return;
|
|
286
|
+
if (options.kernel.phase.kind !== "offline") return;
|
|
287
|
+
attempt += 1;
|
|
288
|
+
options.onFire?.(attempt);
|
|
289
|
+
options.kernel.dispatch({ type: "USER_RETRY" });
|
|
290
|
+
}, delay);
|
|
291
|
+
}
|
|
292
|
+
const unsubKernel = options.kernel.subscribe((next, prev) => {
|
|
293
|
+
if (next.kind === "offline" && prev.kind !== "offline") {
|
|
294
|
+
scheduleFromPhase(next, false);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (next.kind === "offline" && prev.kind === "offline" && next.nextRetryAt !== prev.nextRetryAt) {
|
|
298
|
+
scheduleFromPhase(next, true);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (prev.kind === "offline" && next.kind !== "offline") cancelTimer("phase-left-offline");
|
|
302
|
+
if (next.kind === "online" || next.kind === "idle") attempt = 0;
|
|
303
|
+
});
|
|
304
|
+
if (options.kernel.phase.kind === "offline") scheduleFromPhase(options.kernel.phase, false);
|
|
305
|
+
return () => {
|
|
306
|
+
detached = true;
|
|
307
|
+
cancelTimer("detach");
|
|
308
|
+
unsubKernel();
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
//#endregion
|
|
312
|
+
//#region src/effects/crossTab.ts
|
|
313
|
+
var DEFAULT_KEY$1 = "camera.ui:transport:target";
|
|
314
|
+
function attachCrossTab(options) {
|
|
315
|
+
const key = options.key ?? DEFAULT_KEY$1;
|
|
316
|
+
const source = options.source ?? (typeof window !== "undefined" ? window : void 0);
|
|
317
|
+
if (!source) throw new Error("attachCrossTab: no `source` provided and no global `window` available");
|
|
318
|
+
function handle(event) {
|
|
319
|
+
const e = event;
|
|
320
|
+
if (e.key !== key) return;
|
|
321
|
+
if (e.newValue === null) {
|
|
322
|
+
const k = options.kernel.phase.kind;
|
|
323
|
+
if (k !== "online" && k !== "reconnecting") return;
|
|
324
|
+
options.kernel.dispatch({ type: "RESET" });
|
|
325
|
+
options.onResetReceived?.();
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
let parsed;
|
|
329
|
+
try {
|
|
330
|
+
parsed = JSON.parse(e.newValue);
|
|
331
|
+
} catch (err) {
|
|
332
|
+
options.onError?.("parse", err);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (!parsed?.tokens?.access) return;
|
|
336
|
+
const k = options.kernel.phase.kind;
|
|
337
|
+
if (k !== "online" && k !== "reconnecting") return;
|
|
338
|
+
options.kernel.dispatch({
|
|
339
|
+
type: "TOKENS_REFRESHED",
|
|
340
|
+
tokens: parsed.tokens
|
|
341
|
+
});
|
|
342
|
+
options.onTokensReceived?.(parsed.tokens);
|
|
343
|
+
}
|
|
344
|
+
source.addEventListener("storage", handle);
|
|
345
|
+
return () => {
|
|
346
|
+
source.removeEventListener("storage", handle);
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
//#endregion
|
|
350
|
+
//#region src/effects/networkChange.ts
|
|
351
|
+
function attachNetworkChange(options) {
|
|
352
|
+
let detached = false;
|
|
353
|
+
const handler = (event) => {
|
|
354
|
+
if (detached) return;
|
|
355
|
+
options.onChange(options.kernel, event);
|
|
356
|
+
};
|
|
357
|
+
options.source.addEventListener("change", handler);
|
|
358
|
+
return () => {
|
|
359
|
+
detached = true;
|
|
360
|
+
options.source.removeEventListener("change", handler);
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
//#endregion
|
|
364
|
+
//#region src/effects/persistence.ts
|
|
365
|
+
var DEFAULT_KEY = "camera.ui:transport:target";
|
|
366
|
+
function attachPersistence(options) {
|
|
367
|
+
const key = options.key ?? DEFAULT_KEY;
|
|
368
|
+
const onError = options.onError ?? (() => {});
|
|
369
|
+
let detached = false;
|
|
370
|
+
let cached = null;
|
|
371
|
+
restore();
|
|
372
|
+
async function restore() {
|
|
373
|
+
let raw;
|
|
374
|
+
try {
|
|
375
|
+
raw = await options.storage.get(key);
|
|
376
|
+
} catch (err) {
|
|
377
|
+
onError("get", err);
|
|
378
|
+
raw = null;
|
|
379
|
+
}
|
|
380
|
+
if (detached) return;
|
|
381
|
+
if (cached !== null) {
|
|
382
|
+
options.onRestore?.(cached);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (!raw) {
|
|
386
|
+
options.onRestore?.(null);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
try {
|
|
390
|
+
const parsed = JSON.parse(raw);
|
|
391
|
+
if (!parsed?.endpoint?.url || !parsed?.tokens?.access) {
|
|
392
|
+
options.onRestore?.(null);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
cached = {
|
|
396
|
+
endpoint: parsed.endpoint,
|
|
397
|
+
tokens: parsed.tokens
|
|
398
|
+
};
|
|
399
|
+
options.onRestore?.(cached);
|
|
400
|
+
} catch (err) {
|
|
401
|
+
onError("parse", err);
|
|
402
|
+
options.onRestore?.(null);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
async function persist(target) {
|
|
406
|
+
const payload = {
|
|
407
|
+
endpoint: target.endpoint,
|
|
408
|
+
tokens: target.tokens,
|
|
409
|
+
savedAt: Date.now(),
|
|
410
|
+
version: 1
|
|
411
|
+
};
|
|
412
|
+
cached = {
|
|
413
|
+
endpoint: target.endpoint,
|
|
414
|
+
tokens: target.tokens
|
|
415
|
+
};
|
|
416
|
+
try {
|
|
417
|
+
await options.storage.set(key, JSON.stringify(payload));
|
|
418
|
+
options.onPersist?.(target);
|
|
419
|
+
} catch (err) {
|
|
420
|
+
onError("set", err);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
async function clear() {
|
|
424
|
+
cached = null;
|
|
425
|
+
try {
|
|
426
|
+
await options.storage.del(key);
|
|
427
|
+
options.onClear?.();
|
|
428
|
+
} catch (err) {
|
|
429
|
+
onError("del", err);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const unsub = options.kernel.subscribe((next, prev) => {
|
|
433
|
+
if (detached) return;
|
|
434
|
+
const nextTarget = next.kind === "online" ? next.target : next.kind === "reconnecting" ? next.lastTarget : null;
|
|
435
|
+
const prevTarget = prev.kind === "online" ? prev.target : prev.kind === "reconnecting" ? prev.lastTarget : null;
|
|
436
|
+
if (nextTarget && nextTarget !== prevTarget) {
|
|
437
|
+
persist(nextTarget);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (next.kind === "idle" && prev.kind !== "idle") clear();
|
|
441
|
+
});
|
|
442
|
+
return {
|
|
443
|
+
detach: () => {
|
|
444
|
+
detached = true;
|
|
445
|
+
unsub();
|
|
446
|
+
},
|
|
447
|
+
peek: () => cached,
|
|
448
|
+
seed: async (target) => {
|
|
449
|
+
if (detached) return;
|
|
450
|
+
await persist(target);
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
function localStorageAdapter(scope = globalThis.localStorage) {
|
|
455
|
+
if (!scope) throw new Error("localStorageAdapter: localStorage is not available in this environment");
|
|
456
|
+
return {
|
|
457
|
+
get(k) {
|
|
458
|
+
return scope.getItem(k);
|
|
459
|
+
},
|
|
460
|
+
set(k, v) {
|
|
461
|
+
scope.setItem(k, v);
|
|
462
|
+
},
|
|
463
|
+
del(k) {
|
|
464
|
+
scope.removeItem(k);
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
function memoryStorageAdapter(initial = {}) {
|
|
469
|
+
const store = new Map(Object.entries(initial));
|
|
470
|
+
return {
|
|
471
|
+
get(k) {
|
|
472
|
+
return store.get(k) ?? null;
|
|
473
|
+
},
|
|
474
|
+
set(k, v) {
|
|
475
|
+
store.set(k, v);
|
|
476
|
+
},
|
|
477
|
+
del(k) {
|
|
478
|
+
store.delete(k);
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
//#endregion
|
|
483
|
+
//#region src/effects/presence.ts
|
|
484
|
+
var defaultOnNetworkOnline = (kernel) => {
|
|
485
|
+
if (kernel.phase.kind === "offline") kernel.dispatch({ type: "USER_RETRY" });
|
|
486
|
+
};
|
|
487
|
+
function attachPresence(options) {
|
|
488
|
+
const networkSource = options.networkSource !== void 0 ? options.networkSource : typeof globalThis !== "undefined" && "window" in globalThis ? globalThis.window : null;
|
|
489
|
+
const visibilitySource = options.visibilitySource !== void 0 ? options.visibilitySource : typeof globalThis !== "undefined" && "document" in globalThis ? globalThis.document : null;
|
|
490
|
+
const onOnline = options.onOnline ?? defaultOnNetworkOnline;
|
|
491
|
+
const onOffline = options.onOffline;
|
|
492
|
+
const cleanups = [];
|
|
493
|
+
if (networkSource) {
|
|
494
|
+
const handleOnline = () => onOnline(options.kernel);
|
|
495
|
+
const handleOffline = () => onOffline?.(options.kernel);
|
|
496
|
+
networkSource.addEventListener("online", handleOnline);
|
|
497
|
+
networkSource.addEventListener("offline", handleOffline);
|
|
498
|
+
cleanups.push(() => {
|
|
499
|
+
networkSource.removeEventListener("online", handleOnline);
|
|
500
|
+
networkSource.removeEventListener("offline", handleOffline);
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
if (visibilitySource && (options.onVisibilityVisible || options.onVisibilityHidden)) {
|
|
504
|
+
const handleVisibility = () => {
|
|
505
|
+
if (visibilitySource.visibilityState !== "hidden") options.onVisibilityVisible?.(options.kernel);
|
|
506
|
+
else options.onVisibilityHidden?.(options.kernel);
|
|
507
|
+
};
|
|
508
|
+
visibilitySource.addEventListener("visibilitychange", handleVisibility);
|
|
509
|
+
cleanups.push(() => {
|
|
510
|
+
visibilitySource.removeEventListener("visibilitychange", handleVisibility);
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
return () => {
|
|
514
|
+
for (const fn of cleanups) fn();
|
|
515
|
+
cleanups.length = 0;
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
//#endregion
|
|
519
|
+
//#region src/race.ts
|
|
520
|
+
var DEFAULT_RACE_TIMEOUT_BY_MODE = {
|
|
521
|
+
"direct-lan": 2e3,
|
|
522
|
+
"direct-wan": 5e3
|
|
523
|
+
};
|
|
524
|
+
var RaceFirstError = class extends Error {
|
|
525
|
+
endpoint;
|
|
526
|
+
cause;
|
|
527
|
+
kind;
|
|
528
|
+
constructor(message, endpoint, cause, kind) {
|
|
529
|
+
super(message);
|
|
530
|
+
this.name = "RaceFirstError";
|
|
531
|
+
this.endpoint = endpoint;
|
|
532
|
+
this.cause = cause;
|
|
533
|
+
this.kind = kind;
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
function raceFirst(candidates, options = {}) {
|
|
537
|
+
const { timeoutByMode = (mode) => DEFAULT_RACE_TIMEOUT_BY_MODE[mode] ?? 5e3, shortCircuit, parentSignal } = options;
|
|
538
|
+
return new Promise((resolve, reject) => {
|
|
539
|
+
if (candidates.length === 0) {
|
|
540
|
+
reject(new RaceFirstError("raceFirst: no candidates", {
|
|
541
|
+
url: "",
|
|
542
|
+
mode: "direct-lan"
|
|
543
|
+
}, void 0, "all-failed"));
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (parentSignal?.aborted) {
|
|
547
|
+
reject(new RaceFirstError("raceFirst: parent aborted", candidates[0].endpoint, void 0, "aborted"));
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
let settled = false;
|
|
551
|
+
let remaining = candidates.length;
|
|
552
|
+
const lastErrors = /* @__PURE__ */ new Map();
|
|
553
|
+
const abortControllers = [];
|
|
554
|
+
const timers = [];
|
|
555
|
+
function cleanupAllExcept(except) {
|
|
556
|
+
for (const t of timers) clearTimeout(t);
|
|
557
|
+
for (const a of abortControllers) if (a !== except) a.abort();
|
|
558
|
+
}
|
|
559
|
+
function finishSuccess(endpoint, value, except) {
|
|
560
|
+
if (settled) return;
|
|
561
|
+
settled = true;
|
|
562
|
+
cleanupAllExcept(except);
|
|
563
|
+
if (onParentAbort) parentSignal?.removeEventListener("abort", onParentAbort);
|
|
564
|
+
resolve({
|
|
565
|
+
endpoint,
|
|
566
|
+
value
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
function finishFail(error) {
|
|
570
|
+
if (settled) return;
|
|
571
|
+
settled = true;
|
|
572
|
+
cleanupAllExcept(null);
|
|
573
|
+
if (onParentAbort) parentSignal?.removeEventListener("abort", onParentAbort);
|
|
574
|
+
reject(error);
|
|
575
|
+
}
|
|
576
|
+
const onParentAbort = parentSignal ? () => finishFail(new RaceFirstError("raceFirst: parent aborted", candidates[0].endpoint, void 0, "aborted")) : null;
|
|
577
|
+
if (onParentAbort) parentSignal.addEventListener("abort", onParentAbort);
|
|
578
|
+
candidates.forEach((cand) => {
|
|
579
|
+
const ctrl = new AbortController();
|
|
580
|
+
abortControllers.push(ctrl);
|
|
581
|
+
const delay = timeoutByMode(cand.endpoint.mode);
|
|
582
|
+
const timer = setTimeout(() => ctrl.abort(), delay);
|
|
583
|
+
timers.push(timer);
|
|
584
|
+
cand.run(ctrl.signal).then((value) => finishSuccess(cand.endpoint, value, ctrl), (err) => {
|
|
585
|
+
if (settled) {
|
|
586
|
+
lastErrors.set(cand.endpoint, err);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
const finalErr = ctrl.signal.aborted && !parentSignal?.aborted ? new RaceFirstError(`timeout (${delay}ms)`, cand.endpoint, err, "all-failed") : err;
|
|
590
|
+
lastErrors.set(cand.endpoint, finalErr);
|
|
591
|
+
if (shortCircuit?.(finalErr)) {
|
|
592
|
+
finishFail(new RaceFirstError("raceFirst: short-circuit", cand.endpoint, finalErr, "short-circuit"));
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
remaining--;
|
|
596
|
+
if (remaining <= 0) {
|
|
597
|
+
const [endpoint, cause] = [...lastErrors.entries()].find(([, e]) => {
|
|
598
|
+
if (e instanceof RaceFirstError) return e.kind !== "all-failed" || !(e.cause instanceof Error && e.cause.message === "aborted");
|
|
599
|
+
return true;
|
|
600
|
+
}) ?? [cand.endpoint, finalErr];
|
|
601
|
+
finishFail(new RaceFirstError("raceFirst: all candidates failed", endpoint, cause, "all-failed"));
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
//#endregion
|
|
608
|
+
//#region src/effects/probeLoop.ts
|
|
609
|
+
function makeProbeFailure(kind, message) {
|
|
610
|
+
const err = new Error(message);
|
|
611
|
+
err.kind = kind;
|
|
612
|
+
return err;
|
|
613
|
+
}
|
|
614
|
+
function isProbeFailure(err) {
|
|
615
|
+
return err instanceof Error && typeof err.kind === "string";
|
|
616
|
+
}
|
|
617
|
+
function attachProbeLoop(options) {
|
|
618
|
+
let masterAbort;
|
|
619
|
+
let detached = false;
|
|
620
|
+
function cancel() {
|
|
621
|
+
if (masterAbort) {
|
|
622
|
+
masterAbort.abort();
|
|
623
|
+
masterAbort = void 0;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
async function runRound() {
|
|
627
|
+
if (detached) return;
|
|
628
|
+
cancel();
|
|
629
|
+
const ctrl = new AbortController();
|
|
630
|
+
masterAbort = ctrl;
|
|
631
|
+
let pool;
|
|
632
|
+
try {
|
|
633
|
+
options.onDiscoverStart?.();
|
|
634
|
+
pool = await options.discover(ctrl.signal);
|
|
635
|
+
if (ctrl.signal.aborted) return;
|
|
636
|
+
options.onDiscoverSuccess?.(pool);
|
|
637
|
+
} catch (err) {
|
|
638
|
+
if (ctrl.signal.aborted) return;
|
|
639
|
+
options.onDiscoverError?.(err);
|
|
640
|
+
const reason = err instanceof Error ? `discover: ${err.message}` : "discover failed";
|
|
641
|
+
options.onAllFailed?.(reason);
|
|
642
|
+
options.kernel.dispatch({
|
|
643
|
+
type: "PROBE_FAILED_ALL",
|
|
644
|
+
error: reason
|
|
645
|
+
});
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (pool.length === 0) {
|
|
649
|
+
const reason = "discover returned empty pool";
|
|
650
|
+
options.onAllFailed?.(reason);
|
|
651
|
+
options.kernel.dispatch({
|
|
652
|
+
type: "PROBE_FAILED_ALL",
|
|
653
|
+
error: reason
|
|
654
|
+
});
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
const lastTokens = options.lastTarget?.()?.tokens;
|
|
658
|
+
const candidates = pool.map((endpoint) => ({
|
|
659
|
+
endpoint,
|
|
660
|
+
run: async (signal) => {
|
|
661
|
+
options.onProbeStart?.(endpoint);
|
|
662
|
+
try {
|
|
663
|
+
const tokens = await options.probe({
|
|
664
|
+
endpoint,
|
|
665
|
+
lastTokens,
|
|
666
|
+
signal
|
|
667
|
+
});
|
|
668
|
+
options.onProbeSuccess?.(endpoint, tokens);
|
|
669
|
+
return tokens;
|
|
670
|
+
} catch (err) {
|
|
671
|
+
const isLocalTimeout = signal.aborted && !ctrl.signal.aborted && !isProbeFailure(err);
|
|
672
|
+
const timeout = (options.timeoutByMode ?? ((m) => DEFAULT_RACE_TIMEOUT_BY_MODE[m] ?? 5e3))(endpoint.mode);
|
|
673
|
+
const finalErr = isLocalTimeout ? makeProbeFailure("transient", `timeout (${timeout}ms)`) : err;
|
|
674
|
+
options.onProbeError?.(endpoint, finalErr);
|
|
675
|
+
throw finalErr;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}));
|
|
679
|
+
try {
|
|
680
|
+
const { endpoint, value: tokens } = await raceFirst(candidates, {
|
|
681
|
+
timeoutByMode: options.timeoutByMode,
|
|
682
|
+
parentSignal: ctrl.signal,
|
|
683
|
+
shortCircuit: (err) => isProbeFailure(err) && (err.kind === "needs-auth" || err.kind === "fatal" || err.kind === "aborted")
|
|
684
|
+
});
|
|
685
|
+
if (ctrl.signal.aborted) return;
|
|
686
|
+
options.kernel.dispatch({
|
|
687
|
+
type: "PROBE_SUCCEEDED",
|
|
688
|
+
endpoint,
|
|
689
|
+
tokens
|
|
690
|
+
});
|
|
691
|
+
} catch (err) {
|
|
692
|
+
if (ctrl.signal.aborted) return;
|
|
693
|
+
const underlying = err instanceof RaceFirstError ? err.cause : err;
|
|
694
|
+
if (isProbeFailure(underlying) && underlying.kind === "aborted") {
|
|
695
|
+
setTimeout(() => {
|
|
696
|
+
if (!detached) runRound();
|
|
697
|
+
}, 200);
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
const reason = isProbeFailure(underlying) && underlying.kind === "needs-auth" ? "needs-auth" : underlying instanceof Error ? underlying.message : "all endpoints failed";
|
|
701
|
+
options.onAllFailed?.(reason);
|
|
702
|
+
options.kernel.dispatch({
|
|
703
|
+
type: "PROBE_FAILED_ALL",
|
|
704
|
+
error: reason
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
const unsub = options.kernel.subscribe((next, prev) => {
|
|
709
|
+
if (next.kind === "discovering" && prev.kind !== "discovering") runRound();
|
|
710
|
+
else if (next.kind !== "discovering" && prev.kind === "discovering") cancel();
|
|
711
|
+
});
|
|
712
|
+
if (options.kernel.phase.kind === "discovering") runRound();
|
|
713
|
+
return () => {
|
|
714
|
+
detached = true;
|
|
715
|
+
cancel();
|
|
716
|
+
unsub();
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
//#endregion
|
|
720
|
+
//#region src/effects/tokenLifecycle.ts
|
|
721
|
+
var DEFAULT_GRACE_MS$1 = 5e3;
|
|
722
|
+
var DEFAULT_MAX_TRANSIENT_RETRIES = 3;
|
|
723
|
+
var DEFAULT_TRANSIENT_RETRY_DELAY_MS = 2e3;
|
|
724
|
+
function attachTokenLifecycle(options) {
|
|
725
|
+
const graceMs = options.graceMs ?? DEFAULT_GRACE_MS$1;
|
|
726
|
+
const isTransient = options.isTransientError ?? (() => false);
|
|
727
|
+
const maxTransientRetries = options.maxTransientRetries ?? DEFAULT_MAX_TRANSIENT_RETRIES;
|
|
728
|
+
const transientRetryDelayMs = options.transientRetryDelayMs ?? DEFAULT_TRANSIENT_RETRY_DELAY_MS;
|
|
729
|
+
const now = options.now ?? (() => Date.now());
|
|
730
|
+
const setTimer = options.setTimer ?? ((cb, ms) => setTimeout(cb, ms));
|
|
731
|
+
const clearTimer = options.clearTimer ?? ((h) => clearTimeout(h));
|
|
732
|
+
let timer;
|
|
733
|
+
let inflight = false;
|
|
734
|
+
let detached = false;
|
|
735
|
+
let transientRetries = 0;
|
|
736
|
+
let pendingAuthError = false;
|
|
737
|
+
const cleanups = [];
|
|
738
|
+
function cancelTimer() {
|
|
739
|
+
if (timer !== void 0) {
|
|
740
|
+
clearTimer(timer);
|
|
741
|
+
timer = void 0;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
function schedule(target) {
|
|
745
|
+
cancelTimer();
|
|
746
|
+
const exp = target.tokens.accessExpiresAt;
|
|
747
|
+
if (!exp) return;
|
|
748
|
+
const delayMs = Math.max(0, exp - now() - graceMs);
|
|
749
|
+
options.onScheduled?.(delayMs, exp);
|
|
750
|
+
timer = setTimer(() => {
|
|
751
|
+
timer = void 0;
|
|
752
|
+
triggerRefresh("proactive");
|
|
753
|
+
}, delayMs);
|
|
754
|
+
}
|
|
755
|
+
async function triggerRefresh(reason) {
|
|
756
|
+
if (detached) {
|
|
757
|
+
options.onTriggerSkipped?.(reason, "detached", options.kernel.phase.kind);
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
if (inflight) {
|
|
761
|
+
if (reason === "auth-error") pendingAuthError = true;
|
|
762
|
+
options.onTriggerSkipped?.(reason, "already-inflight", options.kernel.phase.kind);
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
const phase = options.kernel.phase;
|
|
766
|
+
const target = phase.kind === "online" ? phase.target : phase.kind === "reconnecting" ? phase.lastTarget : null;
|
|
767
|
+
if (!target) {
|
|
768
|
+
options.onTriggerSkipped?.(reason, "no-target", phase.kind);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
inflight = true;
|
|
772
|
+
options.onRefreshStart?.(reason);
|
|
773
|
+
try {
|
|
774
|
+
const result = await (options.acquireRefreshLock ?? ((fn) => fn()))(async () => {
|
|
775
|
+
const fresh = options.getLatestTokens?.();
|
|
776
|
+
if (fresh?.accessExpiresAt && fresh.accessExpiresAt > now() + graceMs) {
|
|
777
|
+
options.kernel.dispatch({
|
|
778
|
+
type: "TOKENS_REFRESHED",
|
|
779
|
+
tokens: fresh
|
|
780
|
+
});
|
|
781
|
+
return {
|
|
782
|
+
tokens: fresh,
|
|
783
|
+
skipped: true
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
const tokens = await options.refresh(target, reason);
|
|
787
|
+
if (detached) return {
|
|
788
|
+
tokens,
|
|
789
|
+
skipped: false
|
|
790
|
+
};
|
|
791
|
+
options.kernel.dispatch({
|
|
792
|
+
type: "TOKENS_REFRESHED",
|
|
793
|
+
tokens
|
|
794
|
+
});
|
|
795
|
+
return {
|
|
796
|
+
tokens,
|
|
797
|
+
skipped: false
|
|
798
|
+
};
|
|
799
|
+
});
|
|
800
|
+
if (detached) return;
|
|
801
|
+
transientRetries = 0;
|
|
802
|
+
if (result.skipped) options.onRefreshSkipped?.(reason, result.tokens);
|
|
803
|
+
else options.onRefreshSuccess?.(reason, result.tokens);
|
|
804
|
+
} catch (err) {
|
|
805
|
+
if (detached) return;
|
|
806
|
+
const transient = isTransient(err);
|
|
807
|
+
if (transient && transientRetries < maxTransientRetries) {
|
|
808
|
+
transientRetries++;
|
|
809
|
+
const retriesLeft = maxTransientRetries - transientRetries;
|
|
810
|
+
options.onRefreshError?.(reason, err, {
|
|
811
|
+
transient: true,
|
|
812
|
+
retriesLeft,
|
|
813
|
+
willRetry: true
|
|
814
|
+
});
|
|
815
|
+
cancelTimer();
|
|
816
|
+
timer = setTimer(() => {
|
|
817
|
+
timer = void 0;
|
|
818
|
+
triggerRefresh(reason);
|
|
819
|
+
}, transientRetryDelayMs);
|
|
820
|
+
} else {
|
|
821
|
+
transientRetries = 0;
|
|
822
|
+
options.onRefreshError?.(reason, err, {
|
|
823
|
+
transient,
|
|
824
|
+
retriesLeft: 0,
|
|
825
|
+
willRetry: false
|
|
826
|
+
});
|
|
827
|
+
options.kernel.dispatch({
|
|
828
|
+
type: "TOKENS_INVALID",
|
|
829
|
+
reason: stringifyError(err),
|
|
830
|
+
transient
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
} finally {
|
|
834
|
+
inflight = false;
|
|
835
|
+
if (!detached && pendingAuthError) {
|
|
836
|
+
pendingAuthError = false;
|
|
837
|
+
triggerRefresh("auth-error");
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
const unsubKernel = options.kernel.subscribe((next, prev) => {
|
|
842
|
+
if (next.kind === "online") {
|
|
843
|
+
if (prev.kind !== "online" || prev.target.tokens.access !== next.target.tokens.access) {
|
|
844
|
+
transientRetries = 0;
|
|
845
|
+
schedule(next.target);
|
|
846
|
+
}
|
|
847
|
+
} else if (next.kind === "reconnecting") {} else {
|
|
848
|
+
cancelTimer();
|
|
849
|
+
transientRetries = 0;
|
|
850
|
+
}
|
|
851
|
+
});
|
|
852
|
+
cleanups.push(unsubKernel);
|
|
853
|
+
for (const transport of options.transports) {
|
|
854
|
+
const off = transport.on("auth-error", () => {
|
|
855
|
+
triggerRefresh("auth-error");
|
|
856
|
+
});
|
|
857
|
+
cleanups.push(off);
|
|
858
|
+
}
|
|
859
|
+
if (options.kernel.phase.kind === "online") schedule(options.kernel.phase.target);
|
|
860
|
+
function detach() {
|
|
861
|
+
detached = true;
|
|
862
|
+
cancelTimer();
|
|
863
|
+
for (const fn of cleanups) fn();
|
|
864
|
+
}
|
|
865
|
+
function wake() {
|
|
866
|
+
if (detached) return;
|
|
867
|
+
const phase = options.kernel.phase;
|
|
868
|
+
const target = phase.kind === "online" ? phase.target : phase.kind === "reconnecting" ? phase.lastTarget : null;
|
|
869
|
+
if (!target) {
|
|
870
|
+
options.onWakeChecked?.({
|
|
871
|
+
decision: "no-target",
|
|
872
|
+
phase: phase.kind
|
|
873
|
+
});
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
const exp = target.tokens.accessExpiresAt;
|
|
877
|
+
if (!exp) {
|
|
878
|
+
options.onWakeChecked?.({
|
|
879
|
+
decision: "no-expiry",
|
|
880
|
+
phase: phase.kind
|
|
881
|
+
});
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
const remaining = exp - now();
|
|
885
|
+
if (remaining < graceMs) {
|
|
886
|
+
options.onWakeChecked?.({
|
|
887
|
+
decision: "refresh-now",
|
|
888
|
+
remainingMs: remaining,
|
|
889
|
+
phase: phase.kind
|
|
890
|
+
});
|
|
891
|
+
triggerRefresh("proactive");
|
|
892
|
+
} else options.onWakeChecked?.({
|
|
893
|
+
decision: "still-fresh",
|
|
894
|
+
remainingMs: remaining,
|
|
895
|
+
phase: phase.kind
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
return {
|
|
899
|
+
detach,
|
|
900
|
+
wake
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
function stringifyError(err) {
|
|
904
|
+
if (err instanceof Error) return err.message;
|
|
905
|
+
if (typeof err === "string") return err;
|
|
906
|
+
return "refresh-failed";
|
|
907
|
+
}
|
|
908
|
+
//#endregion
|
|
909
|
+
//#region src/effects/transportSync.ts
|
|
910
|
+
var SKIP = Symbol("transport-sync-skip");
|
|
911
|
+
function syncTargetFor(phase) {
|
|
912
|
+
switch (phase.kind) {
|
|
913
|
+
case "idle":
|
|
914
|
+
case "offline":
|
|
915
|
+
case "needs-auth": return null;
|
|
916
|
+
case "online": return phase.target;
|
|
917
|
+
case "reconnecting": return phase.lastTarget;
|
|
918
|
+
case "discovering": return SKIP;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
function attachTransportSync(options) {
|
|
922
|
+
let detached = false;
|
|
923
|
+
let initialized = false;
|
|
924
|
+
let lastApplied = null;
|
|
925
|
+
function applyAll(target) {
|
|
926
|
+
if (detached) return;
|
|
927
|
+
if (initialized && isSameTarget(lastApplied, target)) return;
|
|
928
|
+
initialized = true;
|
|
929
|
+
lastApplied = target;
|
|
930
|
+
for (const transport of options.transports) transport.apply(target).catch((err) => {
|
|
931
|
+
options.onError?.(transport, target, err);
|
|
932
|
+
});
|
|
933
|
+
options.onApplied?.(target);
|
|
934
|
+
}
|
|
935
|
+
function syncFromPhase(phase) {
|
|
936
|
+
const decision = syncTargetFor(phase);
|
|
937
|
+
if (decision === SKIP) return;
|
|
938
|
+
applyAll(decision);
|
|
939
|
+
}
|
|
940
|
+
syncFromPhase(options.kernel.phase);
|
|
941
|
+
const unsub = options.kernel.subscribe((next) => {
|
|
942
|
+
syncFromPhase(next);
|
|
943
|
+
});
|
|
944
|
+
return () => {
|
|
945
|
+
detached = true;
|
|
946
|
+
unsub();
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
//#endregion
|
|
950
|
+
//#region src/effects/transportWatchdog.ts
|
|
951
|
+
var DEFAULT_GRACE_MS = 4e3;
|
|
952
|
+
function attachTransportWatchdog(options) {
|
|
953
|
+
const defaultGraceMs = options.defaultGraceMs ?? DEFAULT_GRACE_MS;
|
|
954
|
+
const setTimer = options.setTimer ?? ((cb, ms) => setTimeout(cb, ms));
|
|
955
|
+
const clearTimer = options.clearTimer ?? ((h) => clearTimeout(h));
|
|
956
|
+
const timers = /* @__PURE__ */ new Map();
|
|
957
|
+
const cleanups = [];
|
|
958
|
+
let detached = false;
|
|
959
|
+
function cancelTimer(id, reason) {
|
|
960
|
+
const handle = timers.get(id);
|
|
961
|
+
if (handle !== void 0) {
|
|
962
|
+
clearTimer(handle);
|
|
963
|
+
timers.delete(id);
|
|
964
|
+
options.onGraceCleared?.(id, reason);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
function cancelAll(reason) {
|
|
968
|
+
for (const id of [...timers.keys()]) cancelTimer(id, reason);
|
|
969
|
+
}
|
|
970
|
+
for (const transport of options.transports) {
|
|
971
|
+
const spec = transport.spec;
|
|
972
|
+
const offUp = transport.on("up", () => {
|
|
973
|
+
if (detached) return;
|
|
974
|
+
cancelTimer(spec.id, "up");
|
|
975
|
+
options.kernel.dispatch({
|
|
976
|
+
type: "TRANSPORT_UP",
|
|
977
|
+
id: spec.id
|
|
978
|
+
});
|
|
979
|
+
});
|
|
980
|
+
const offDown = transport.on("down", (payload) => {
|
|
981
|
+
if (detached) return;
|
|
982
|
+
options.kernel.dispatch({
|
|
983
|
+
type: "TRANSPORT_DOWN",
|
|
984
|
+
id: spec.id,
|
|
985
|
+
reason: payload.reason
|
|
986
|
+
});
|
|
987
|
+
if (!spec.phaseGating) return;
|
|
988
|
+
if (options.kernel.phase.kind !== "online") return;
|
|
989
|
+
if (timers.has(spec.id)) return;
|
|
990
|
+
const graceMs = spec.graceMs ?? defaultGraceMs;
|
|
991
|
+
options.onGraceStarted?.(spec.id, graceMs);
|
|
992
|
+
const handle = setTimer(() => {
|
|
993
|
+
timers.delete(spec.id);
|
|
994
|
+
if (detached) return;
|
|
995
|
+
options.onConfirmed?.(spec.id);
|
|
996
|
+
options.kernel.dispatch({
|
|
997
|
+
type: "TRANSPORT_DOWN_CONFIRMED",
|
|
998
|
+
id: spec.id
|
|
999
|
+
});
|
|
1000
|
+
}, graceMs);
|
|
1001
|
+
timers.set(spec.id, handle);
|
|
1002
|
+
});
|
|
1003
|
+
cleanups.push(offUp, offDown);
|
|
1004
|
+
}
|
|
1005
|
+
const unsubKernel = options.kernel.subscribe((next, prev) => {
|
|
1006
|
+
if (prev.kind === "online" && next.kind !== "online") cancelAll("phase-change");
|
|
1007
|
+
});
|
|
1008
|
+
cleanups.push(unsubKernel);
|
|
1009
|
+
return () => {
|
|
1010
|
+
detached = true;
|
|
1011
|
+
cancelAll("detach");
|
|
1012
|
+
for (const fn of cleanups) fn();
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
//#endregion
|
|
1016
|
+
//#region src/effects/workerBridge.ts
|
|
1017
|
+
function attachWorkerBridge(options) {
|
|
1018
|
+
let generation = 0;
|
|
1019
|
+
let detached = false;
|
|
1020
|
+
const hostListenerCleanups = /* @__PURE__ */ new Map();
|
|
1021
|
+
function makeSync(phase) {
|
|
1022
|
+
generation++;
|
|
1023
|
+
return {
|
|
1024
|
+
type: "kernel-sync",
|
|
1025
|
+
generation,
|
|
1026
|
+
phase
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
function broadcast(phase) {
|
|
1030
|
+
if (detached) return;
|
|
1031
|
+
const msg = makeSync(phase);
|
|
1032
|
+
let count = 0;
|
|
1033
|
+
for (const host of options.hosts()) try {
|
|
1034
|
+
host.postMessage(msg);
|
|
1035
|
+
count++;
|
|
1036
|
+
maybeAttachHostListener(host);
|
|
1037
|
+
} catch (err) {
|
|
1038
|
+
console.warn("[workerBridge] broadcast postMessage failed", {
|
|
1039
|
+
gen: msg.generation,
|
|
1040
|
+
phase: phase.kind,
|
|
1041
|
+
err
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
options.onBroadcast?.(generation, count);
|
|
1045
|
+
}
|
|
1046
|
+
function syncOne(host) {
|
|
1047
|
+
if (detached) return;
|
|
1048
|
+
const msg = makeSync(options.kernel.phase);
|
|
1049
|
+
try {
|
|
1050
|
+
host.postMessage(msg);
|
|
1051
|
+
maybeAttachHostListener(host);
|
|
1052
|
+
options.onSyncHost?.(generation);
|
|
1053
|
+
} catch (err) {
|
|
1054
|
+
console.warn("[workerBridge] syncOne postMessage failed", {
|
|
1055
|
+
gen: msg.generation,
|
|
1056
|
+
err
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
function maybeAttachHostListener(host) {
|
|
1061
|
+
if (!options.listenForResyncRequests) return;
|
|
1062
|
+
if (!host.addEventListener) return;
|
|
1063
|
+
if (hostListenerCleanups.has(host)) return;
|
|
1064
|
+
const listener = (event) => {
|
|
1065
|
+
if (detached) return;
|
|
1066
|
+
if (event.data?.type === "kernel-sync-request") syncOne(host);
|
|
1067
|
+
};
|
|
1068
|
+
host.addEventListener("message", listener);
|
|
1069
|
+
hostListenerCleanups.set(host, () => {
|
|
1070
|
+
host.removeEventListener?.("message", listener);
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
const unsubKernel = options.kernel.subscribe((next) => {
|
|
1074
|
+
broadcast(next);
|
|
1075
|
+
});
|
|
1076
|
+
return {
|
|
1077
|
+
detach() {
|
|
1078
|
+
detached = true;
|
|
1079
|
+
unsubKernel();
|
|
1080
|
+
for (const cleanup of hostListenerCleanups.values()) cleanup();
|
|
1081
|
+
hostListenerCleanups.clear();
|
|
1082
|
+
},
|
|
1083
|
+
syncHost(host) {
|
|
1084
|
+
syncOne(host);
|
|
1085
|
+
},
|
|
1086
|
+
syncAll() {
|
|
1087
|
+
broadcast(options.kernel.phase);
|
|
1088
|
+
},
|
|
1089
|
+
revalidateWorkers() {
|
|
1090
|
+
if (detached) return;
|
|
1091
|
+
for (const host of options.hosts()) try {
|
|
1092
|
+
host.postMessage({ type: "kernel-revalidate" });
|
|
1093
|
+
maybeAttachHostListener(host);
|
|
1094
|
+
} catch (err) {
|
|
1095
|
+
console.warn("[workerBridge] revalidate postMessage failed", err);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
//#endregion
|
|
1101
|
+
export { DEFAULT_RACE_TIMEOUT_BY_MODE, RaceFirstError, TransportEmitter, attachBackoff, attachCrossTab, attachNetworkChange, attachPersistence, attachPresence, attachProbeLoop, attachTokenLifecycle, attachTransportSync, attachTransportWatchdog, attachWorkerBridge, createKernel, defaultOnNetworkOnline, endpointKey, isEndpointChange, isProbeFailure, isSameEndpoint, isSameTarget, isTokenOnlyChange, localStorageAdapter, makeProbeFailure, memoryStorageAdapter, raceFirst, reducer, sortByPriority };
|