@steve31415/baselib 3.20.0 → 3.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app-update-browser/build-update.d.ts +10 -0
- package/dist/app-update-browser/build-update.js +29 -0
- package/dist/sync-browser/client.d.ts +9 -0
- package/dist/sync-browser/client.js +57 -3
- package/dist/sync-browser/index.d.ts +1 -0
- package/dist/sync-browser/index.js +1 -0
- package/dist/sync-browser/quiet-status.d.ts +23 -0
- package/dist/sync-browser/quiet-status.js +34 -0
- package/package.json +1 -1
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import type { Logger } from '../log-browser.js';
|
|
2
|
+
/** A newer build learned this soon after the page became visible was found
|
|
3
|
+
* *because* the user returned (a frozen tab learns of deploys only then):
|
|
4
|
+
* reloading now would replace the page just as the eyes land on it
|
|
5
|
+
* (plasticine-way PERFORMANCE.md, "No updates on foreground"). Such a reload
|
|
6
|
+
* waits for the page to be hidden again. */
|
|
7
|
+
export declare const RETURN_RELOAD_DEFER_MS = 10000;
|
|
2
8
|
export type BuildUpdateState = {
|
|
3
9
|
kind: 'current';
|
|
4
10
|
} | {
|
|
@@ -34,6 +40,9 @@ export declare class BuildUpdateMonitor {
|
|
|
34
40
|
private appliedRequestNumber;
|
|
35
41
|
private trafficBuild;
|
|
36
42
|
private checkFailure;
|
|
43
|
+
/** When the page last went from hidden to visible (not page load). */
|
|
44
|
+
private returnedAt;
|
|
45
|
+
private deferUntilHidden;
|
|
37
46
|
constructor(deps: BuildUpdateDeps);
|
|
38
47
|
getState: () => BuildUpdateState;
|
|
39
48
|
subscribe: (listener: () => void) => (() => void);
|
|
@@ -49,6 +58,7 @@ export declare class BuildUpdateMonitor {
|
|
|
49
58
|
private observeTrafficBuild;
|
|
50
59
|
private markAvailable;
|
|
51
60
|
private maybeReload;
|
|
61
|
+
private justReturned;
|
|
52
62
|
private setState;
|
|
53
63
|
/** Polls only while visible: an interval in a hidden page would wake it
|
|
54
64
|
* every few minutes to do nothing. Hidden pages learn of deploys from the
|
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
// sync observations are an opportunistic second signal. Reload policy is
|
|
3
3
|
// separate and injected through ReloadSafety.
|
|
4
4
|
const DEFAULT_POLL_MS = 5 * 60 * 1000;
|
|
5
|
+
/** A newer build learned this soon after the page became visible was found
|
|
6
|
+
* *because* the user returned (a frozen tab learns of deploys only then):
|
|
7
|
+
* reloading now would replace the page just as the eyes land on it
|
|
8
|
+
* (plasticine-way PERFORMANCE.md, "No updates on foreground"). Such a reload
|
|
9
|
+
* waits for the page to be hidden again. */
|
|
10
|
+
export const RETURN_RELOAD_DEFER_MS = 10_000;
|
|
5
11
|
const LEGACY_BUILD = 'legacy-pre-build-id';
|
|
6
12
|
export function pageBuildId() {
|
|
7
13
|
return document.querySelector('meta[name="pw-build-id"]')?.content || 'development';
|
|
@@ -18,6 +24,9 @@ export class BuildUpdateMonitor {
|
|
|
18
24
|
appliedRequestNumber = 0;
|
|
19
25
|
trafficBuild = null;
|
|
20
26
|
checkFailure = false;
|
|
27
|
+
/** When the page last went from hidden to visible (not page load). */
|
|
28
|
+
returnedAt = null;
|
|
29
|
+
deferUntilHidden = false;
|
|
21
30
|
constructor(deps) {
|
|
22
31
|
this.deps = deps;
|
|
23
32
|
}
|
|
@@ -116,6 +125,7 @@ export class BuildUpdateMonitor {
|
|
|
116
125
|
if (buildId === this.deps.currentBuildId) {
|
|
117
126
|
if (this.state.kind === 'available')
|
|
118
127
|
this.setState({ kind: 'current' });
|
|
128
|
+
this.deferUntilHidden = false;
|
|
119
129
|
return;
|
|
120
130
|
}
|
|
121
131
|
this.markAvailable(buildId);
|
|
@@ -129,12 +139,22 @@ export class BuildUpdateMonitor {
|
|
|
129
139
|
observedBuildId: buildId,
|
|
130
140
|
});
|
|
131
141
|
this.setState({ kind: 'available', buildId });
|
|
142
|
+
if (!this.deferUntilHidden && this.justReturned()) {
|
|
143
|
+
this.deferUntilHidden = true;
|
|
144
|
+
this.deps.logger.info('build reload deferred until hidden', {
|
|
145
|
+
pageBuildId: this.deps.currentBuildId,
|
|
146
|
+
observedBuildId: buildId,
|
|
147
|
+
sinceReturnMs: Date.now() - (this.returnedAt ?? Date.now()),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
132
150
|
}
|
|
133
151
|
this.maybeReload();
|
|
134
152
|
}
|
|
135
153
|
maybeReload() {
|
|
136
154
|
if (this.state.kind !== 'available' || !this.deps.safety.isSafe())
|
|
137
155
|
return;
|
|
156
|
+
if (this.deferUntilHidden && document.visibilityState === 'visible')
|
|
157
|
+
return;
|
|
138
158
|
const buildId = this.state.buildId;
|
|
139
159
|
this.setState({ kind: 'reloading', buildId });
|
|
140
160
|
if (this.deps.reload)
|
|
@@ -142,6 +162,11 @@ export class BuildUpdateMonitor {
|
|
|
142
162
|
else
|
|
143
163
|
location.reload();
|
|
144
164
|
}
|
|
165
|
+
justReturned() {
|
|
166
|
+
return (document.visibilityState === 'visible' &&
|
|
167
|
+
this.returnedAt !== null &&
|
|
168
|
+
Date.now() - this.returnedAt < RETURN_RELOAD_DEFER_MS);
|
|
169
|
+
}
|
|
145
170
|
setState(state) {
|
|
146
171
|
this.state = state;
|
|
147
172
|
for (const listener of this.listeners)
|
|
@@ -165,11 +190,15 @@ export class BuildUpdateMonitor {
|
|
|
165
190
|
}
|
|
166
191
|
onVisibility = () => {
|
|
167
192
|
if (document.visibilityState === 'visible') {
|
|
193
|
+
this.returnedAt = Date.now();
|
|
168
194
|
this.startPolling();
|
|
169
195
|
void this.checkNow();
|
|
170
196
|
}
|
|
171
197
|
else {
|
|
198
|
+
this.returnedAt = null;
|
|
172
199
|
this.stopPolling();
|
|
200
|
+
// A reload deferred at the return happens now, out of sight.
|
|
201
|
+
this.maybeReload();
|
|
173
202
|
}
|
|
174
203
|
};
|
|
175
204
|
onOnline = () => {
|
|
@@ -127,7 +127,11 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
|
|
|
127
127
|
private readonly abortControllers;
|
|
128
128
|
private readonly removeListeners;
|
|
129
129
|
private currentState;
|
|
130
|
+
/** Failed attempts in the current visible spell: drives the ladder and the
|
|
131
|
+
* outage log's `attempts` (what the user could have waited through). */
|
|
130
132
|
private reconnectAttempts;
|
|
133
|
+
/** Failed visible attempts across the whole outage (every spell). */
|
|
134
|
+
private totalAttempts;
|
|
131
135
|
private reconnectTimer;
|
|
132
136
|
private countdownTimer;
|
|
133
137
|
private pingTimer;
|
|
@@ -216,6 +220,11 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
|
|
|
216
220
|
/** One INFO per hidden spell: what the user saw change on return. */
|
|
217
221
|
private finishReturnWindow;
|
|
218
222
|
private connectNow;
|
|
223
|
+
/** Records a visible socket failure inside a return window. */
|
|
224
|
+
private noteReturnFailure;
|
|
225
|
+
/** The first failure of a return, before sync is back, retries quickly
|
|
226
|
+
* (once per return); anything else takes the ladder. */
|
|
227
|
+
private takeQuickRetry;
|
|
219
228
|
private scheduleReconnect;
|
|
220
229
|
private probeSession;
|
|
221
230
|
private startOutage;
|
|
@@ -21,6 +21,12 @@ const WAKE_CONNECT_REPLY_MS = 5_000;
|
|
|
21
21
|
/** A hidden attempt's socket that dies sooner than this counts as failed. */
|
|
22
22
|
const HIDDEN_SOCKET_MIN_LIFE_MS = 60_000;
|
|
23
23
|
const RETURN_REPORT_TIMEOUT_MS = 15_000;
|
|
24
|
+
/** The first visible failure of a return retries after this instead of the
|
|
25
|
+
* ladder's 1 s. After a phone tab was frozen, its old socket turns out dead
|
|
26
|
+
* (its close lands ~30 ms after the return) or the first new handshake fails
|
|
27
|
+
* without reaching the server, while HTTP works at once; the ladder's 1 s
|
|
28
|
+
* step then made every such return take 1.5-2 s (reader, 2026-09-26). */
|
|
29
|
+
const RETURN_QUICK_RETRY_MS = 250;
|
|
24
30
|
const SOCKET_CONNECTING = 0;
|
|
25
31
|
const SOCKET_OPEN = 1;
|
|
26
32
|
class LifecycleAbort {
|
|
@@ -47,7 +53,11 @@ export class SyncClient {
|
|
|
47
53
|
abortControllers = new Set();
|
|
48
54
|
removeListeners = [];
|
|
49
55
|
currentState = { kind: 'starting', pending: 0 };
|
|
56
|
+
/** Failed attempts in the current visible spell: drives the ladder and the
|
|
57
|
+
* outage log's `attempts` (what the user could have waited through). */
|
|
50
58
|
reconnectAttempts = 0;
|
|
59
|
+
/** Failed visible attempts across the whole outage (every spell). */
|
|
60
|
+
totalAttempts = 0;
|
|
51
61
|
reconnectTimer = null;
|
|
52
62
|
countdownTimer = null;
|
|
53
63
|
pingTimer = null;
|
|
@@ -324,6 +334,10 @@ export class SyncClient {
|
|
|
324
334
|
this.rearmFailureTimers();
|
|
325
335
|
if (this.outageStartedAt !== null && !this.outageEscalated)
|
|
326
336
|
this.armEscalation();
|
|
337
|
+
// Each return starts the ladder afresh: failures from earlier glances
|
|
338
|
+
// (a phone shown for a second, often before its network is back) must
|
|
339
|
+
// not stretch this return's retry to 8-30 s or add up to a WARN.
|
|
340
|
+
this.reconnectAttempts = 0;
|
|
327
341
|
}
|
|
328
342
|
this.wakeConnection();
|
|
329
343
|
// Always: a socket that looks open may be dead (review S2), and a
|
|
@@ -457,6 +471,7 @@ export class SyncClient {
|
|
|
457
471
|
this.plannedClose = null;
|
|
458
472
|
this.startOutage(event.code, event.wasClean === true);
|
|
459
473
|
if (!this.isHidden()) {
|
|
474
|
+
this.noteReturnFailure(event.code);
|
|
460
475
|
this.scheduleReconnect();
|
|
461
476
|
return;
|
|
462
477
|
}
|
|
@@ -635,6 +650,9 @@ export class SyncClient {
|
|
|
635
650
|
catchupOk: false,
|
|
636
651
|
eventsOnReturn: 0,
|
|
637
652
|
resyncOnReturn: 0,
|
|
653
|
+
failures: 0,
|
|
654
|
+
firstFailure: null,
|
|
655
|
+
quickRetryUsed: false,
|
|
638
656
|
timer: this.options.runtime.setTimeout(() => this.finishReturnWindow(), RETURN_REPORT_TIMEOUT_MS),
|
|
639
657
|
};
|
|
640
658
|
}
|
|
@@ -657,6 +675,11 @@ export class SyncClient {
|
|
|
657
675
|
catchupOk: window.catchupOk,
|
|
658
676
|
eventsOnReturn: window.eventsOnReturn,
|
|
659
677
|
resyncOnReturn: window.resyncOnReturn,
|
|
678
|
+
returnFailures: window.failures,
|
|
679
|
+
firstFailureAfterReturnMs: window.firstFailure?.afterReturnMs ?? null,
|
|
680
|
+
firstFailureSocketAgeMs: window.firstFailure?.socketAgeMs ?? null,
|
|
681
|
+
firstFailureOpened: window.firstFailure?.opened ?? null,
|
|
682
|
+
firstFailureCloseCode: window.firstFailure?.closeCode ?? null,
|
|
660
683
|
sinceLastContactMs: window.sinceLastContactMs,
|
|
661
684
|
backgroundCatchups: window.spell.backgroundCatchups,
|
|
662
685
|
backgroundCatchupFailures: window.spell.backgroundCatchupFailures,
|
|
@@ -675,9 +698,37 @@ export class SyncClient {
|
|
|
675
698
|
this.countdownTimer = null;
|
|
676
699
|
this.connect();
|
|
677
700
|
}
|
|
701
|
+
/** Records a visible socket failure inside a return window. */
|
|
702
|
+
noteReturnFailure(closeCode) {
|
|
703
|
+
const window = this.returnWindow;
|
|
704
|
+
if (window === null || window.establishedAt !== null)
|
|
705
|
+
return;
|
|
706
|
+
window.failures++;
|
|
707
|
+
if (window.firstFailure !== null)
|
|
708
|
+
return;
|
|
709
|
+
const now = this.now();
|
|
710
|
+
window.firstFailure = {
|
|
711
|
+
afterReturnMs: Math.max(0, now - window.visibleAt),
|
|
712
|
+
socketAgeMs: Math.max(0, now - this.socketCreatedAt),
|
|
713
|
+
opened: this.socketOpenedAt !== null,
|
|
714
|
+
closeCode,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
/** The first failure of a return, before sync is back, retries quickly
|
|
718
|
+
* (once per return); anything else takes the ladder. */
|
|
719
|
+
takeQuickRetry() {
|
|
720
|
+
const window = this.returnWindow;
|
|
721
|
+
if (window === null || window.establishedAt !== null || window.quickRetryUsed)
|
|
722
|
+
return false;
|
|
723
|
+
window.quickRetryUsed = true;
|
|
724
|
+
return true;
|
|
725
|
+
}
|
|
678
726
|
scheduleReconnect() {
|
|
679
|
-
const
|
|
727
|
+
const ladderSeconds = RECONNECT_DELAYS_SECONDS[Math.min(this.reconnectAttempts, RECONNECT_DELAYS_SECONDS.length - 1)];
|
|
728
|
+
const delayMs = this.takeQuickRetry() ? RETURN_QUICK_RETRY_MS : ladderSeconds * 1000;
|
|
729
|
+
const delay = Math.ceil(delayMs / 1000);
|
|
680
730
|
this.reconnectAttempts++;
|
|
731
|
+
this.totalAttempts++;
|
|
681
732
|
if (this.reconnectAttempts >= PROBE_AFTER_ATTEMPTS)
|
|
682
733
|
void this.probeSession();
|
|
683
734
|
let remaining = delay;
|
|
@@ -694,7 +745,7 @@ export class SyncClient {
|
|
|
694
745
|
this.countdownTimer = null;
|
|
695
746
|
this.reconnectTimer = null;
|
|
696
747
|
this.connect();
|
|
697
|
-
},
|
|
748
|
+
}, delayMs);
|
|
698
749
|
}
|
|
699
750
|
async probeSession() {
|
|
700
751
|
if (this.halted || this.disposed || this.probeInFlight)
|
|
@@ -1060,13 +1111,15 @@ export class SyncClient {
|
|
|
1060
1111
|
if (this.outageStartedAt !== null) {
|
|
1061
1112
|
const durationMs = Math.max(0, this.options.runtime.now() - this.outageStartedAt);
|
|
1062
1113
|
// Failures are only counted (and the ladder only runs) while visible,
|
|
1063
|
-
// so `attempts` is what the
|
|
1114
|
+
// and the count restarts on each return, so `attempts` is what the
|
|
1115
|
+
// user could have waited through; `totalAttempts` spans every spell.
|
|
1064
1116
|
const attempts = this.reconnectAttempts;
|
|
1065
1117
|
const info = this.outageInfo;
|
|
1066
1118
|
const delta = info ? SyncClient.delta(info.counters, this.snapshot()) : null;
|
|
1067
1119
|
const fields = {
|
|
1068
1120
|
durationMs,
|
|
1069
1121
|
attempts,
|
|
1122
|
+
totalAttempts: this.totalAttempts,
|
|
1070
1123
|
closeCode: info?.closeCode,
|
|
1071
1124
|
wasClean: info?.wasClean,
|
|
1072
1125
|
visibleAtDrop: info?.visibleAtDrop,
|
|
@@ -1100,6 +1153,7 @@ export class SyncClient {
|
|
|
1100
1153
|
this.options.runtime.clearTimeout(this.escalateTimer);
|
|
1101
1154
|
this.escalateTimer = null;
|
|
1102
1155
|
this.reconnectAttempts = 0;
|
|
1156
|
+
this.totalAttempts = 0;
|
|
1103
1157
|
for (const [eventGuid, entry] of this.pending) {
|
|
1104
1158
|
if (this.settledPending.has(eventGuid)) {
|
|
1105
1159
|
this.retrySettlement(eventGuid);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { SyncClientState } from './client.js';
|
|
2
|
+
/** How long a quiet transition must last while visible before it shows.
|
|
3
|
+
* After a frozen phone tab returns, reconnecting took 1.5-2.1 s (reader,
|
|
4
|
+
* 2026-09-26); 3 s leaves margin over that. */
|
|
5
|
+
export declare const QUIET_TRANSITION_GRACE_MS = 3000;
|
|
6
|
+
/** A short-lived connection state with no unsynced edits behind it. */
|
|
7
|
+
export declare function isQuietTransition(status: SyncClientState, pendingCount: number): boolean;
|
|
8
|
+
/** What to show now: a quiet transition only after it has lasted the grace
|
|
9
|
+
* period of visible time (counted from when it began or the page last became
|
|
10
|
+
* visible, whichever is later; pass `visibleSince = now` while hidden); until
|
|
11
|
+
* then, whatever was shown before it (`null` = nothing yet). Anything else
|
|
12
|
+
* shows at once. */
|
|
13
|
+
export declare function statusToShow<V>(args: {
|
|
14
|
+
view: V;
|
|
15
|
+
quiet: boolean;
|
|
16
|
+
quietSince: number | null;
|
|
17
|
+
visibleSince: number;
|
|
18
|
+
lastShown: V | null;
|
|
19
|
+
now: number;
|
|
20
|
+
graceMs?: number;
|
|
21
|
+
}): V | null;
|
|
22
|
+
/** When the running grace period began (for scheduling a re-render at its end). */
|
|
23
|
+
export declare function quietGraceStart(quietSince: number | null, visibleSince: number, now: number): number;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Whether a sync status indicator should admit to a reconnect yet. Pure, so
|
|
2
|
+
// apps' pills (React or not) share one rule: a reconnect with nothing at
|
|
3
|
+
// stake is shown only once it has lasted a grace period of *visible* time,
|
|
4
|
+
// so returning to a tab doesn't flash "Connecting…"/"Offline" just as the
|
|
5
|
+
// eyes land on it (plasticine-way PERFORMANCE.md, "No updates on
|
|
6
|
+
// foreground"). Unsynced edits and errors always show at once.
|
|
7
|
+
/** How long a quiet transition must last while visible before it shows.
|
|
8
|
+
* After a frozen phone tab returns, reconnecting took 1.5-2.1 s (reader,
|
|
9
|
+
* 2026-09-26); 3 s leaves margin over that. */
|
|
10
|
+
export const QUIET_TRANSITION_GRACE_MS = 3000;
|
|
11
|
+
/** A short-lived connection state with no unsynced edits behind it. */
|
|
12
|
+
export function isQuietTransition(status, pendingCount) {
|
|
13
|
+
if (pendingCount > 0)
|
|
14
|
+
return false;
|
|
15
|
+
return (status.kind === 'starting' ||
|
|
16
|
+
status.kind === 'connecting' ||
|
|
17
|
+
status.kind === 'reconnecting' ||
|
|
18
|
+
status.kind === 'disconnected');
|
|
19
|
+
}
|
|
20
|
+
/** What to show now: a quiet transition only after it has lasted the grace
|
|
21
|
+
* period of visible time (counted from when it began or the page last became
|
|
22
|
+
* visible, whichever is later; pass `visibleSince = now` while hidden); until
|
|
23
|
+
* then, whatever was shown before it (`null` = nothing yet). Anything else
|
|
24
|
+
* shows at once. */
|
|
25
|
+
export function statusToShow(args) {
|
|
26
|
+
const { view, quiet, quietSince, visibleSince, lastShown, now } = args;
|
|
27
|
+
if (!quiet)
|
|
28
|
+
return view;
|
|
29
|
+
return now - quietGraceStart(quietSince, visibleSince, now) >= (args.graceMs ?? QUIET_TRANSITION_GRACE_MS) ? view : lastShown;
|
|
30
|
+
}
|
|
31
|
+
/** When the running grace period began (for scheduling a re-render at its end). */
|
|
32
|
+
export function quietGraceStart(quietSince, visibleSince, now) {
|
|
33
|
+
return Math.max(quietSince ?? now, visibleSince);
|
|
34
|
+
}
|
package/package.json
CHANGED