@steve31415/baselib 3.32.0 → 3.33.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.
@@ -65,10 +65,17 @@ export declare class BuildUpdateMonitor {
65
65
  /** Deployment push is intentionally only a prompt to ask canonical HTTP;
66
66
  * it carries no traffic authority of its own and runs while hidden too. */
67
67
  observeDeploymentHint(): void;
68
+ /** Asks canonical HTTP for the traffic build. Nothing to ask once the page
69
+ * is reloading: the navigation aborts the request, so a check then can only
70
+ * log a false failure (digest, 2026-10-06: a page hidden and shown within
71
+ * 0.4 s ran its deferred reload on the hide and this check on the show). */
68
72
  checkNow(): Promise<void>;
69
73
  private observeTrafficBuild;
70
74
  private markAvailable;
71
75
  private maybeReload;
76
+ /** A method, not an inline test: TypeScript would keep the narrowing from
77
+ * checkNow's entry across its await. */
78
+ private isReloading;
72
79
  private justReturned;
73
80
  private setState;
74
81
  /** Polls only while visible: an interval in a hidden page would wake it
@@ -91,8 +91,12 @@ export class BuildUpdateMonitor {
91
91
  observeDeploymentHint() {
92
92
  void this.checkNow();
93
93
  }
94
+ /** Asks canonical HTTP for the traffic build. Nothing to ask once the page
95
+ * is reloading: the navigation aborts the request, so a check then can only
96
+ * log a false failure (digest, 2026-10-06: a page hidden and shown within
97
+ * 0.4 s ran its deferred reload on the hide and this check on the show). */
94
98
  async checkNow() {
95
- if (!navigator.onLine)
99
+ if (!navigator.onLine || this.isReloading())
96
100
  return;
97
101
  const lifecycle = this.lifecycle;
98
102
  const requestNumber = ++this.requestNumber;
@@ -130,7 +134,8 @@ export class BuildUpdateMonitor {
130
134
  catch (error) {
131
135
  if (lifecycle !== this.lifecycle ||
132
136
  requestNumber < this.appliedRequestNumber ||
133
- !navigator.onLine)
137
+ !navigator.onLine ||
138
+ this.isReloading())
134
139
  return;
135
140
  if (!this.checkFailure) {
136
141
  this.checkFailure = true;
@@ -185,6 +190,11 @@ export class BuildUpdateMonitor {
185
190
  else
186
191
  location.reload();
187
192
  }
193
+ /** A method, not an inline test: TypeScript would keep the narrowing from
194
+ * checkNow's entry across its await. */
195
+ isReloading() {
196
+ return this.state.kind === 'reloading';
197
+ }
188
198
  justReturned() {
189
199
  return (document.visibilityState === 'visible' &&
190
200
  this.returnedAt !== null &&
@@ -157,6 +157,8 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
157
157
  private loopEscalated;
158
158
  private reconciliationTail;
159
159
  private initialization;
160
+ /** Non-null while the first bootstrap is loading, retries included. */
161
+ private firstLoadRetry;
160
162
  private disposed;
161
163
  private hiddenSince;
162
164
  private hiddenSocketFailed;
@@ -181,6 +183,14 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
181
183
  constructor(options: SyncClientOptions<B, C, S, Summary>);
182
184
  init(): Promise<void>;
183
185
  private initialize;
186
+ /** The first bootstrap. One that gets no HTTP answer (fetch's TypeError: no
187
+ * network, or one that reaches nothing — a tab Android Chrome restored from
188
+ * its cache) is retried until it loads, while init() stays pending and the
189
+ * state says `disconnected`; any other failure rejects, as it always did
190
+ * (docs/SYNC-INIT-RECOVERY-PLAN.md). Rejects with a LifecycleAbort once
191
+ * halted, disposed, or abandoned by a failed outbox. */
192
+ private loadFirstBootstrap;
193
+ private createFirstLoadRetry;
184
194
  dispose(): void;
185
195
  submit(event: ClientEvent<C>, summary: Summary): Promise<void>;
186
196
  /** Current-tab entries only; recovered work is retried silently. */
@@ -1,3 +1,4 @@
1
+ import { FirstLoadRetry } from './first-load-retry.js';
1
2
  const RECONNECT_DELAYS_SECONDS = [1, 2, 4, 8, 16, 30];
2
3
  const PING_INTERVAL_MS = 30_000;
3
4
  const CONNECT_WATCHDOG_MS = 10_000;
@@ -91,6 +92,8 @@ export class SyncClient {
91
92
  loopEscalated = false;
92
93
  reconciliationTail = Promise.resolve();
93
94
  initialization = null;
95
+ /** Non-null while the first bootstrap is loading, retries included. */
96
+ firstLoadRetry = null;
94
97
  disposed = false;
95
98
  // Hidden-page state (docs/SYNC-BACKGROUND-PLAN.md).
96
99
  hiddenSince = null;
@@ -158,16 +161,7 @@ export class SyncClient {
158
161
  throw error;
159
162
  });
160
163
  this.options.markPerformance?.('bootstrap-start');
161
- const bootstrapPromise = this.abortable((signal) => this.options.fetchBootstrap(signal))
162
- .then((bootstrap) => {
163
- this.options.markPerformance?.('bootstrap-response');
164
- return bootstrap;
165
- })
166
- .catch((error) => {
167
- if (!isLifecycleAbort(error))
168
- this.options.logger.error('bootstrap failed', { error });
169
- throw error;
170
- });
164
+ const bootstrapPromise = this.loadFirstBootstrap();
171
165
  let recovered;
172
166
  let bootstrap;
173
167
  try {
@@ -177,6 +171,10 @@ export class SyncClient {
177
171
  }
178
172
  catch (error) {
179
173
  this.abortFetches();
174
+ this.firstLoadRetry?.stop();
175
+ // A halt or dispose is a deliberate stop, not a failed load.
176
+ if (this.halted || this.disposed)
177
+ return;
180
178
  throw isLifecycleAbort(error) ? error.reason : error;
181
179
  }
182
180
  if (this.stopBeforeStoreMutation())
@@ -202,11 +200,77 @@ export class SyncClient {
202
200
  this.installRuntimeListeners();
203
201
  this.connect();
204
202
  }
203
+ /** The first bootstrap. One that gets no HTTP answer (fetch's TypeError: no
204
+ * network, or one that reaches nothing — a tab Android Chrome restored from
205
+ * its cache) is retried until it loads, while init() stays pending and the
206
+ * state says `disconnected`; any other failure rejects, as it always did
207
+ * (docs/SYNC-INIT-RECOVERY-PLAN.md). Rejects with a LifecycleAbort once
208
+ * halted, disposed, or abandoned by a failed outbox. */
209
+ async loadFirstBootstrap() {
210
+ const retry = this.createFirstLoadRetry();
211
+ this.firstLoadRetry = retry;
212
+ try {
213
+ for (;;) {
214
+ const controller = new AbortController();
215
+ this.abortControllers.add(controller);
216
+ retry.attemptStarted(() => controller.abort());
217
+ try {
218
+ const bootstrap = await this.options.fetchBootstrap(controller.signal);
219
+ retry.attemptEnded('loaded');
220
+ this.options.markPerformance?.('bootstrap-response');
221
+ retry.recovered();
222
+ return bootstrap;
223
+ }
224
+ catch (error) {
225
+ retry.attemptEnded('failed');
226
+ if (this.halted || this.disposed || retry.stopped)
227
+ throw new LifecycleAbort(error);
228
+ // An abort here is the retry replacing a stale attempt: no answer.
229
+ if (!controller.signal.aborted && !(error instanceof TypeError)) {
230
+ this.options.logger.error('bootstrap failed', { reachedServer: true, failures: retry.failures, error });
231
+ this.publish({ kind: 'error', pending: this.pendingCount(), message: 'Failed to load' });
232
+ throw error;
233
+ }
234
+ retry.failed(error);
235
+ if (retry.failures === 1)
236
+ this.publish({ kind: 'disconnected', pending: this.pendingCount() });
237
+ }
238
+ finally {
239
+ this.abortControllers.delete(controller);
240
+ }
241
+ const proceed = await retry.next();
242
+ if (!proceed || this.stopBeforeStoreMutation())
243
+ throw new LifecycleAbort('first load stopped');
244
+ }
245
+ }
246
+ finally {
247
+ retry.stop();
248
+ if (this.firstLoadRetry === retry)
249
+ this.firstLoadRetry = null;
250
+ }
251
+ }
252
+ createFirstLoadRetry() {
253
+ const runtime = this.options.runtime;
254
+ return new FirstLoadRetry({
255
+ now: () => this.now(),
256
+ visible: () => runtime.visibility() !== 'hidden',
257
+ setTimer: (callback, delayMs) => this.setTimer(callback, delayMs),
258
+ clearTimer: (timer) => runtime.clearTimeout(timer),
259
+ onOnline: (listener) => runtime.onOnline(listener),
260
+ onVisibilityChange: (listener) => runtime.onVisibilityChange(listener),
261
+ logger: this.options.logger,
262
+ visibleDelaysMs: RECONNECT_DELAYS_SECONDS.map((seconds) => seconds * 1000),
263
+ hiddenDelayMs: this.options.backgroundCatchupMs ?? DEFAULT_BACKGROUND_CATCHUP_MS,
264
+ escalateAfterMs: OUTAGE_ESCALATE_MS,
265
+ staleAttemptMs: CONNECT_WATCHDOG_MS,
266
+ });
267
+ }
205
268
  dispose() {
206
269
  if (this.disposed)
207
270
  return;
208
271
  this.disposed = true;
209
272
  this.abortFetches();
273
+ this.firstLoadRetry?.stop();
210
274
  for (const remove of this.removeListeners.splice(0))
211
275
  remove();
212
276
  this.clearAllTimers();
@@ -1488,6 +1552,7 @@ export class SyncClient {
1488
1552
  return;
1489
1553
  this.halted = true;
1490
1554
  this.abortFetches();
1555
+ this.firstLoadRetry?.stop();
1491
1556
  this.clearAllTimers();
1492
1557
  const socket = this.socket;
1493
1558
  this.socket = null;
@@ -0,0 +1,58 @@
1
+ import type { Logger } from '../log-browser.js';
2
+ type TimeoutHandle = ReturnType<typeof globalThis.setTimeout>;
3
+ export interface FirstLoadRetryOptions {
4
+ now(): number;
5
+ visible(): boolean;
6
+ setTimer(callback: () => void, delayMs: number): TimeoutHandle;
7
+ clearTimer(timer: TimeoutHandle): void;
8
+ onOnline(listener: () => void): () => void;
9
+ onVisibilityChange(listener: () => void): () => void;
10
+ logger: Pick<Logger, 'info' | 'warn' | 'error'>;
11
+ /** The wait after each failure while visible; the last one repeats. */
12
+ visibleDelaysMs: readonly number[];
13
+ hiddenDelayMs: number;
14
+ /** Not loaded after this much visible, on-time loading: one ERROR. */
15
+ escalateAfterMs: number;
16
+ /** A trigger replaces an attempt that has run this long; an attempt this
17
+ * long is logged (INFO `bootstrap slow`). */
18
+ staleAttemptMs: number;
19
+ }
20
+ export declare class FirstLoadRetry {
21
+ private readonly options;
22
+ private failureCount;
23
+ private readonly startedAt;
24
+ /** Ladder position of the next visible wait; a return restarts it. */
25
+ private step;
26
+ private escalated;
27
+ private escalateTimer;
28
+ private waitTimer;
29
+ private wake;
30
+ private attempt;
31
+ /** A trigger arrived during the running attempt: if it fails, no wait. */
32
+ private triggered;
33
+ private stoppedFlag;
34
+ private readonly removeListeners;
35
+ /** Created as the first attempt starts. */
36
+ constructor(options: FirstLoadRetryOptions);
37
+ get failures(): number;
38
+ get stopped(): boolean;
39
+ /** `abort` cancels this attempt (its fetch's AbortSignal). */
40
+ attemptStarted(abort: () => void): void;
41
+ attemptEnded(outcome: 'loaded' | 'failed'): void;
42
+ /** An attempt failed without an HTTP answer (or was replaced as stale).
43
+ * One WARN per streak. */
44
+ failed(error: unknown): void;
45
+ /** Resolves true when the next attempt is due (timer, `online`, a return
46
+ * to visible, or a trigger during the attempt that just failed), false
47
+ * once stopped. */
48
+ next(): Promise<boolean>;
49
+ recovered(): void;
50
+ /** Removes listeners and timers and ends a running wait (`next` → false). */
51
+ stop(): void;
52
+ private trigger;
53
+ private release;
54
+ /** ERROR only once the load has lasted `escalateAfterMs` in view, on time;
55
+ * re-armed on each return. */
56
+ private armEscalation;
57
+ }
58
+ export {};
@@ -0,0 +1,167 @@
1
+ // First-load recovery (docs/SYNC-INIT-RECOVERY-PLAN.md): while the first
2
+ // bootstrap has not loaded, this decides when the next attempt is due and
3
+ // owns the streak's logs. SyncClient makes the attempts, one at a time; a
4
+ // trigger (`online`, a return to visible) never starts a second attempt next
5
+ // to a running one: it ends the wait between attempts, or marks the running
6
+ // attempt so that its failure retries at once, or, when that attempt has
7
+ // already run `staleAttemptMs`, aborts it so a fresh one starts.
8
+ /** A timer this late ran after the device slept or the page was frozen. */
9
+ const LATE_TIMER_MS = 1_000;
10
+ /** An escalation timer this late: that time was not spent loading in front
11
+ * of the user (as the socket's `sync outage ongoing`). */
12
+ const ESCALATE_LATE_TOLERANCE_MS = 5_000;
13
+ export class FirstLoadRetry {
14
+ options;
15
+ failureCount = 0;
16
+ startedAt;
17
+ /** Ladder position of the next visible wait; a return restarts it. */
18
+ step = 0;
19
+ escalated = false;
20
+ escalateTimer = null;
21
+ waitTimer = null;
22
+ wake = null;
23
+ attempt = null;
24
+ /** A trigger arrived during the running attempt: if it fails, no wait. */
25
+ triggered = false;
26
+ stoppedFlag = false;
27
+ removeListeners;
28
+ /** Created as the first attempt starts. */
29
+ constructor(options) {
30
+ this.options = options;
31
+ this.startedAt = options.now();
32
+ this.removeListeners = [
33
+ options.onOnline(() => this.trigger()),
34
+ options.onVisibilityChange(() => {
35
+ if (!this.options.visible())
36
+ return;
37
+ this.step = 0;
38
+ this.armEscalation();
39
+ this.trigger();
40
+ }),
41
+ ];
42
+ this.armEscalation();
43
+ }
44
+ get failures() {
45
+ return this.failureCount;
46
+ }
47
+ get stopped() {
48
+ return this.stoppedFlag;
49
+ }
50
+ /** `abort` cancels this attempt (its fetch's AbortSignal). */
51
+ attemptStarted(abort) {
52
+ this.attempt = { startedAt: this.options.now(), abort };
53
+ this.triggered = false;
54
+ }
55
+ attemptEnded(outcome) {
56
+ const attempt = this.attempt;
57
+ this.attempt = null;
58
+ if (attempt === null || this.stoppedFlag)
59
+ return;
60
+ const attemptMs = this.options.now() - attempt.startedAt;
61
+ if (attemptMs >= this.options.staleAttemptMs) {
62
+ this.options.logger.info('bootstrap slow', { attemptMs, outcome, failures: this.failureCount });
63
+ }
64
+ }
65
+ /** An attempt failed without an HTTP answer (or was replaced as stale).
66
+ * One WARN per streak. */
67
+ failed(error) {
68
+ this.failureCount++;
69
+ if (this.failureCount > 1)
70
+ return;
71
+ this.options.logger.warn('bootstrap failed', { reachedServer: false, hidden: !this.options.visible(), error });
72
+ }
73
+ /** Resolves true when the next attempt is due (timer, `online`, a return
74
+ * to visible, or a trigger during the attempt that just failed), false
75
+ * once stopped. */
76
+ next() {
77
+ if (this.stoppedFlag)
78
+ return Promise.resolve(false);
79
+ if (this.triggered) {
80
+ this.triggered = false;
81
+ return Promise.resolve(true);
82
+ }
83
+ const visible = this.options.visible();
84
+ const delays = this.options.visibleDelaysMs;
85
+ const delayMs = visible ? delays[Math.min(this.step, delays.length - 1)] : this.options.hiddenDelayMs;
86
+ if (visible)
87
+ this.step++;
88
+ const dueAt = this.options.now() + delayMs;
89
+ return new Promise((resolve) => {
90
+ this.wake = resolve;
91
+ this.waitTimer = this.options.setTimer(() => {
92
+ this.waitTimer = null;
93
+ // Late: the device slept or the page was frozen — a phone whose
94
+ // screen went off with the page in front sends no visibilitychange.
95
+ // Waking is a return: restart the ladder.
96
+ if (this.options.now() - dueAt >= LATE_TIMER_MS)
97
+ this.step = 0;
98
+ this.release(true);
99
+ }, delayMs);
100
+ });
101
+ }
102
+ recovered() {
103
+ if (this.failureCount > 0) {
104
+ this.options.logger.info('bootstrap recovered', {
105
+ failures: this.failureCount,
106
+ loadingForMs: Math.max(0, this.options.now() - this.startedAt),
107
+ escalated: this.escalated,
108
+ });
109
+ }
110
+ this.stop();
111
+ }
112
+ /** Removes listeners and timers and ends a running wait (`next` → false). */
113
+ stop() {
114
+ if (this.stoppedFlag)
115
+ return;
116
+ this.stoppedFlag = true;
117
+ for (const remove of this.removeListeners.splice(0))
118
+ remove();
119
+ if (this.escalateTimer !== null)
120
+ this.options.clearTimer(this.escalateTimer);
121
+ this.escalateTimer = null;
122
+ this.release(false);
123
+ }
124
+ trigger() {
125
+ if (this.wake !== null) {
126
+ this.release(true);
127
+ return;
128
+ }
129
+ const attempt = this.attempt;
130
+ if (attempt === null)
131
+ return;
132
+ this.triggered = true;
133
+ // Probably hung: on a dead path a request can wait out the OS connect
134
+ // timeout (minutes), and this trigger says the network or page changed.
135
+ if (this.options.now() - attempt.startedAt >= this.options.staleAttemptMs)
136
+ attempt.abort();
137
+ }
138
+ release(proceed) {
139
+ if (this.waitTimer !== null)
140
+ this.options.clearTimer(this.waitTimer);
141
+ this.waitTimer = null;
142
+ const wake = this.wake;
143
+ this.wake = null;
144
+ wake?.(proceed);
145
+ }
146
+ /** ERROR only once the load has lasted `escalateAfterMs` in view, on time;
147
+ * re-armed on each return. */
148
+ armEscalation() {
149
+ if (this.stoppedFlag || this.escalated || !this.options.visible())
150
+ return;
151
+ if (this.escalateTimer !== null)
152
+ this.options.clearTimer(this.escalateTimer);
153
+ const armedAt = this.options.now();
154
+ this.escalateTimer = this.options.setTimer(() => {
155
+ this.escalateTimer = null;
156
+ const lateMs = this.options.now() - armedAt - this.options.escalateAfterMs;
157
+ if (!this.options.visible() || lateMs > ESCALATE_LATE_TOLERANCE_MS)
158
+ return;
159
+ this.escalated = true;
160
+ this.options.logger.error('bootstrap still failing', {
161
+ failures: this.failureCount,
162
+ loadingForMs: Math.max(0, this.options.now() - this.startedAt),
163
+ attemptMs: this.attempt === null ? null : this.options.now() - this.attempt.startedAt,
164
+ });
165
+ }, this.options.escalateAfterMs);
166
+ }
167
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.32.0",
3
+ "version": "3.33.0",
4
4
  "description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
5
5
  "type": "module",
6
6
  "license": "MIT",