@steve31415/baselib 3.21.0 → 3.23.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.
@@ -26,8 +26,17 @@ export interface BuildUpdateDeps {
26
26
  reload?: () => void;
27
27
  pollMs?: number;
28
28
  onTrafficBuildChange?: (buildId: string) => void;
29
+ /** Whether this document's HTML came from the browser cache without a
30
+ * request (default: `shellServedFromCache`). */
31
+ shellFromCache?: () => boolean;
29
32
  }
30
33
  export declare function pageBuildId(): string;
34
+ /** True when this page's HTML was served from the browser cache without
35
+ * reaching the server. Android Chrome does this when it restores a tab whose
36
+ * renderer it killed in the background, even for a `no-cache` shell, so the
37
+ * restored page can run a build older than current traffic (reader,
38
+ * 2026-09-26/27: 9 of 15 phone page loads). */
39
+ export declare function shellServedFromCache(): boolean;
31
40
  export declare class BuildUpdateMonitor {
32
41
  private deps;
33
42
  private state;
@@ -40,8 +49,10 @@ export declare class BuildUpdateMonitor {
40
49
  private appliedRequestNumber;
41
50
  private trafficBuild;
42
51
  private checkFailure;
43
- /** When the page last went from hidden to visible (not page load). */
52
+ /** When the page last went from hidden to visible, or was loaded visible
53
+ * from a cached shell (a restored tab is a return in all but name). */
44
54
  private returnedAt;
55
+ private restoredFromCache;
45
56
  private deferUntilHidden;
46
57
  constructor(deps: BuildUpdateDeps);
47
58
  getState: () => BuildUpdateState;
@@ -12,6 +12,19 @@ const LEGACY_BUILD = 'legacy-pre-build-id';
12
12
  export function pageBuildId() {
13
13
  return document.querySelector('meta[name="pw-build-id"]')?.content || 'development';
14
14
  }
15
+ /** True when this page's HTML was served from the browser cache without
16
+ * reaching the server. Android Chrome does this when it restores a tab whose
17
+ * renderer it killed in the background, even for a `no-cache` shell, so the
18
+ * restored page can run a build older than current traffic (reader,
19
+ * 2026-09-26/27: 9 of 15 phone page loads). */
20
+ export function shellServedFromCache() {
21
+ const entry = globalThis.performance?.getEntriesByType?.('navigation')[0];
22
+ if (!entry)
23
+ return false;
24
+ if (entry.deliveryType === 'cache')
25
+ return true;
26
+ return entry.transferSize === 0 && entry.decodedBodySize > 0;
27
+ }
15
28
  export class BuildUpdateMonitor {
16
29
  deps;
17
30
  state = { kind: 'current' };
@@ -24,8 +37,10 @@ export class BuildUpdateMonitor {
24
37
  appliedRequestNumber = 0;
25
38
  trafficBuild = null;
26
39
  checkFailure = false;
27
- /** When the page last went from hidden to visible (not page load). */
40
+ /** When the page last went from hidden to visible, or was loaded visible
41
+ * from a cached shell (a restored tab is a return in all but name). */
28
42
  returnedAt = null;
43
+ restoredFromCache = false;
29
44
  deferUntilHidden = false;
30
45
  constructor(deps) {
31
46
  this.deps = deps;
@@ -41,6 +56,13 @@ export class BuildUpdateMonitor {
41
56
  this.started = true;
42
57
  this.lifecycle += 1;
43
58
  this.stopSafety = this.deps.safety.subscribe(() => this.maybeReload());
59
+ // A tab restored from the cache shows whatever build it last loaded; a
60
+ // reload now would replace it just as it appears. Treat it as a return.
61
+ // (A hidden restore needs nothing: its reload is out of sight anyway.)
62
+ if (document.visibilityState === 'visible' && (this.deps.shellFromCache ?? shellServedFromCache)()) {
63
+ this.returnedAt = Date.now();
64
+ this.restoredFromCache = true;
65
+ }
44
66
  if (document.visibilityState === 'visible')
45
67
  this.startPolling();
46
68
  document.addEventListener('visibilitychange', this.onVisibility);
@@ -145,6 +167,7 @@ export class BuildUpdateMonitor {
145
167
  pageBuildId: this.deps.currentBuildId,
146
168
  observedBuildId: buildId,
147
169
  sinceReturnMs: Date.now() - (this.returnedAt ?? Date.now()),
170
+ restoredFromCache: this.restoredFromCache,
148
171
  });
149
172
  }
150
173
  }
@@ -191,6 +214,7 @@ export class BuildUpdateMonitor {
191
214
  onVisibility = () => {
192
215
  if (document.visibilityState === 'visible') {
193
216
  this.returnedAt = Date.now();
217
+ this.restoredFromCache = false;
194
218
  this.startPolling();
195
219
  void this.checkNow();
196
220
  }
@@ -31,6 +31,12 @@ export function createBrowserLogger(opts = {}) {
31
31
  // remains best-effort, so a browser give-up is only console-noted.
32
32
  batchSize: 20,
33
33
  fetchInit: { keepalive: true },
34
+ // Browser logs have no stdout backstop, and the ERRORs most worth
35
+ // keeping are often the ones an offline stretch causes: while the
36
+ // browser reports itself offline, failed attempts don't count, so a
37
+ // batch waits out the outage (until the page dies or the buffer cap
38
+ // evicts it) instead of being dropped after ~60 s.
39
+ isOffline: () => navigator.onLine === false,
34
40
  onGiveUp: (batch, error, reason) => console.warn(`log ship dropped a batch (${reason}): ${String(error)}`, {
35
41
  batch_id: batch.id,
36
42
  events: batch.events.length,
@@ -66,6 +72,7 @@ export function createBrowserLogger(opts = {}) {
66
72
  // Ship what's pending whenever the page may be going away.
67
73
  const flushNow = () => void logger.flush();
68
74
  addEventListener('pagehide', flushNow);
75
+ addEventListener('online', () => shipper?.resume());
69
76
  document.addEventListener('visibilitychange', () => {
70
77
  if (document.visibilityState === 'hidden')
71
78
  flushNow();
@@ -67,6 +67,13 @@ export interface ShipperOptions {
67
67
  fetchFn?: typeof fetch;
68
68
  /** Extra fetch options (browser sets keepalive). */
69
69
  fetchInit?: RequestInit;
70
+ /** True while the platform knows it has no network (the browser passes
71
+ * `navigator.onLine === false`). A failed attempt made while offline says
72
+ * nothing about the batch or the ingest service, so it is not counted
73
+ * toward maxAttempts and not reported: the batch waits and retries at the
74
+ * longest backoff delay, or at once on resume(). The signal only decides
75
+ * what a failure means — attempts still run while "offline". */
76
+ isOffline?: () => boolean;
70
77
  /** A delivery attempt failed; the batch will be retried. */
71
78
  onAttemptFailure?: (batch: ShippedBatch, error: unknown) => void;
72
79
  /** The batch is abandoned — its events will never reach Axiom. */
@@ -101,6 +108,9 @@ export declare class LogShipper {
101
108
  * — raced with one attempt timeout, so it is bounded even when the pump
102
109
  * is busy with older batches. */
103
110
  flush(): Promise<void>;
111
+ /** Retry every waiting batch now, ahead of its backoff — the browser
112
+ * calls this when it comes back online. */
113
+ resume(): void;
104
114
  /** Shutdown path: one parallel last-chance attempt for everything
105
115
  * undelivered, then a sweep that marks the rest dropped. Single-flight
106
116
  * while in flight (racing SIGTERM callers join one promise), but re-arms
package/dist/log-core.js CHANGED
@@ -111,6 +111,13 @@ export class LogShipper {
111
111
  return raceTimeout(Promise.allSettled([...this.inflight]), cap);
112
112
  return Promise.resolve();
113
113
  }
114
+ /** Retry every waiting batch now, ahead of its backoff — the browser
115
+ * calls this when it comes back online. */
116
+ resume() {
117
+ for (const b of this.pending)
118
+ b.nextAttemptAt = 0;
119
+ void this.pump();
120
+ }
114
121
  /** Shutdown path: one parallel last-chance attempt for everything
115
122
  * undelivered, then a sweep that marks the rest dropped. Single-flight
116
123
  * while in flight (racing SIGTERM callers join one promise), but re-arms
@@ -229,8 +236,15 @@ export class LogShipper {
229
236
  batch.attempting = false;
230
237
  if (batch.terminal)
231
238
  return;
232
- batch.attempt++;
233
239
  batch.settleFirst();
240
+ if (!outcome.ok && !outcome.terminal && this.opts.isOffline?.()) {
241
+ // Uncounted: the budget is for failures that could be the batch's
242
+ // or the service's, and an offline failure is neither.
243
+ batch.nextAttemptAt = Date.now() + this.retryDelaysMs[this.retryDelaysMs.length - 1];
244
+ this.schedulePump();
245
+ return;
246
+ }
247
+ batch.attempt++;
234
248
  if (outcome.ok) {
235
249
  this.remove(batch);
236
250
  if (outcome.failed > 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.21.0",
3
+ "version": "3.23.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",