@steve31415/baselib 3.52.0 → 3.54.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/hub/server.d.ts +4 -1
- package/dist/hub/server.js +10 -3
- package/dist/rum.d.ts +13 -0
- package/dist/rum.js +149 -1
- package/dist/socket-age.d.ts +22 -0
- package/dist/socket-age.js +102 -0
- package/dist/sync-browser/client.d.ts +23 -1
- package/dist/sync-browser/client.js +185 -21
- package/package.json +5 -1
package/dist/hub/server.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { UpgradeWebSocket } from 'hono/ws';
|
|
|
3
3
|
import type pg from 'pg';
|
|
4
4
|
import { type RequireIdentityOptions } from '../auth.js';
|
|
5
5
|
import { type Logger } from '../log.js';
|
|
6
|
+
import { type SocketAgeCapOptions } from '../socket-age.js';
|
|
6
7
|
import type { Grants } from '../s2s.js';
|
|
7
8
|
import { type DbBroadcastBridgeOptions } from '../sync/db-bridge.js';
|
|
8
9
|
/** The hub database's one table (the hub repo's first migration carries the
|
|
@@ -28,7 +29,7 @@ export declare const HUB_PUBLISHES_IN_FLIGHT_MAX = 16;
|
|
|
28
29
|
/** The largest frame a browser may send (its frames are `connect` and `ping`). */
|
|
29
30
|
export declare const HUB_SOCKET_MAX_PAYLOAD = 4096;
|
|
30
31
|
/** A socket that has sent nothing (clients ping every 30 s) for this long is
|
|
31
|
-
* closed: a half-dead socket would otherwise hold a slot
|
|
32
|
+
* closed: a half-dead socket would otherwise hold a slot until the age cap. */
|
|
32
33
|
export declare const HUB_SOCKET_IDLE_MS = 180000;
|
|
33
34
|
export interface HubOptions {
|
|
34
35
|
pool: pg.Pool;
|
|
@@ -68,6 +69,8 @@ export interface HubOptions {
|
|
|
68
69
|
idleMs: number;
|
|
69
70
|
sweepMs: number;
|
|
70
71
|
};
|
|
72
|
+
/** Tests: the socket age cap (default 300 s, then 5 s to complete the close). */
|
|
73
|
+
socketAge?: Pick<SocketAgeCapOptions, 'maxAgeMs' | 'graceMs'>;
|
|
71
74
|
}
|
|
72
75
|
export interface Hub {
|
|
73
76
|
/** Start the cross-copy bridge. Call before the server accepts
|
package/dist/hub/server.js
CHANGED
|
@@ -24,12 +24,14 @@
|
|
|
24
24
|
// caller, for the dark-run curl); answers on a
|
|
25
25
|
// `work` hint, else 204 after `wait` seconds
|
|
26
26
|
// GET /api/ws?app=<app> browser WebSocket, fleet cookie, Origin
|
|
27
|
-
// must be that app's page
|
|
27
|
+
// must be that app's page; closed (1012) at
|
|
28
|
+
// 300 s of age, clients roll at ~270 s
|
|
28
29
|
import { createHash } from 'node:crypto';
|
|
29
30
|
import { createMiddleware } from 'hono/factory';
|
|
30
31
|
import { requireIdentity } from '../auth.js';
|
|
31
32
|
import { forwardedClientIp } from '../http.js';
|
|
32
33
|
import { serializeError } from '../log.js';
|
|
34
|
+
import { capSocketAge } from '../socket-age.js';
|
|
33
35
|
import { DbBroadcastBridge } from '../sync/db-bridge.js';
|
|
34
36
|
import { checkHint, isAppName } from './types.js';
|
|
35
37
|
/** The hub database's one table (the hub repo's first migration carries the
|
|
@@ -65,7 +67,7 @@ export const HUB_PUBLISHES_IN_FLIGHT_MAX = 16;
|
|
|
65
67
|
/** The largest frame a browser may send (its frames are `connect` and `ping`). */
|
|
66
68
|
export const HUB_SOCKET_MAX_PAYLOAD = 4096;
|
|
67
69
|
/** A socket that has sent nothing (clients ping every 30 s) for this long is
|
|
68
|
-
* closed: a half-dead socket would otherwise hold a slot
|
|
70
|
+
* closed: a half-dead socket would otherwise hold a slot until the age cap. */
|
|
69
71
|
export const HUB_SOCKET_IDLE_MS = 180_000;
|
|
70
72
|
const HUB_SOCKET_SWEEP_MS = 30_000;
|
|
71
73
|
const TOKEN_FORMAT = /^hubt_[A-Za-z0-9_-]{43}$/;
|
|
@@ -381,7 +383,12 @@ export function createHub(app, opts) {
|
|
|
381
383
|
return c.body(null, 400); // no one reads it
|
|
382
384
|
await next();
|
|
383
385
|
});
|
|
384
|
-
app.get('/api/ws', originCheck, socketIdentity,
|
|
386
|
+
app.get('/api/ws', originCheck, socketIdentity,
|
|
387
|
+
// Every socket closes (1012) at 300 s of age, like the apps' own sockets:
|
|
388
|
+
// a socket on a retired hub copy holds that copy, and its slot under
|
|
389
|
+
// --max-instances, until it ends (PW docs/OPERATIONS.md, "Socket
|
|
390
|
+
// lifetime"). SIGTERM comes only after the last socket ends.
|
|
391
|
+
capSocketAge(opts.upgradeWebSocket, { logger, ...opts.socketAge })((c) => {
|
|
385
392
|
const appName = c.req.query('app');
|
|
386
393
|
const refusal = refusals.get(c);
|
|
387
394
|
const id = c.var.identity;
|
package/dist/rum.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type MetricWithAttribution } from 'web-vitals/attribution';
|
|
1
2
|
export interface RumOptions {
|
|
2
3
|
app?: string;
|
|
3
4
|
dataset?: string;
|
|
@@ -6,4 +7,16 @@ export interface RumOptions {
|
|
|
6
7
|
/** Defaults to the page's pw-build-id meta tag. */
|
|
7
8
|
buildId?: string;
|
|
8
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* The same-origin resource of one initiator type (`script`, `fetch`) that
|
|
12
|
+
* finished last before `before` (ms since navigation start) — for a single-page
|
|
13
|
+
* app, the entry script and the first data fetch that first paint waited on.
|
|
14
|
+
* Times are ms since navigation start; `bytes` 0 means served from cache.
|
|
15
|
+
*/
|
|
16
|
+
export declare function lastResourceBefore(entries: readonly PerformanceResourceTiming[], initiatorType: string, before: number): Record<string, string | number | undefined> | undefined;
|
|
17
|
+
/**
|
|
18
|
+
* Flat breakdown fields for one metric (all durations in whole ms). Kept to a
|
|
19
|
+
* fixed, small set of columns per metric — never keyed by data.
|
|
20
|
+
*/
|
|
21
|
+
export declare function breakdownFields(m: MetricWithAttribution): Record<string, unknown>;
|
|
9
22
|
export declare function initRum(opts?: RumOptions): void;
|
package/dist/rum.js
CHANGED
|
@@ -4,12 +4,150 @@
|
|
|
4
4
|
// no-op. Metrics finalize on tab-hide, so the shipper flushes there with
|
|
5
5
|
// keepalive fetch.
|
|
6
6
|
//
|
|
7
|
+
// Each row also carries the reporting `device` and a few flat breakdown
|
|
8
|
+
// fields from web-vitals' attribution build, so a slow load can be split
|
|
9
|
+
// into its phases from the row alone (connection setup vs server wait vs
|
|
10
|
+
// the app's script and first data fetch) instead of being reconstructed by
|
|
11
|
+
// lining server request logs up against client timestamps (Reader, 2026-11-03:
|
|
12
|
+
// a 5 s /nyt load on the phone with 0.15 s of server time, explained only by
|
|
13
|
+
// that reconstruction).
|
|
14
|
+
//
|
|
7
15
|
// Browser-only module — do not import server-side.
|
|
8
|
-
import { onCLS, onFCP, onINP, onLCP, onTTFB } from 'web-vitals';
|
|
16
|
+
import { onCLS, onFCP, onINP, onLCP, onTTFB, } from 'web-vitals/attribution';
|
|
9
17
|
import { LogShipper } from './log-core.js';
|
|
18
|
+
import { deviceLabel } from './log-browser.js';
|
|
10
19
|
function metaContent(name) {
|
|
11
20
|
return document.querySelector(`meta[name="${name}"]`)?.content || undefined;
|
|
12
21
|
}
|
|
22
|
+
const ms = (n) => (n === undefined || !Number.isFinite(n) ? undefined : Math.round(n));
|
|
23
|
+
const clip = (s, max = 120) => (s === undefined ? undefined : s.slice(0, max));
|
|
24
|
+
/** Path (no origin, no query) of a same-origin resource URL, else the URL clipped. */
|
|
25
|
+
function resourcePath(url) {
|
|
26
|
+
try {
|
|
27
|
+
const u = new URL(url, location.href);
|
|
28
|
+
return u.origin === location.origin ? u.pathname : clip(url, 200);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return clip(url, 200);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The same-origin resource of one initiator type (`script`, `fetch`) that
|
|
36
|
+
* finished last before `before` (ms since navigation start) — for a single-page
|
|
37
|
+
* app, the entry script and the first data fetch that first paint waited on.
|
|
38
|
+
* Times are ms since navigation start; `bytes` 0 means served from cache.
|
|
39
|
+
*/
|
|
40
|
+
export function lastResourceBefore(entries, initiatorType, before) {
|
|
41
|
+
let pick;
|
|
42
|
+
for (const e of entries) {
|
|
43
|
+
if (e.initiatorType !== initiatorType || e.responseEnd <= 0 || e.responseEnd > before)
|
|
44
|
+
continue;
|
|
45
|
+
let sameOrigin = false;
|
|
46
|
+
try {
|
|
47
|
+
sameOrigin = new URL(e.name, location.href).origin === location.origin;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
sameOrigin = false;
|
|
51
|
+
}
|
|
52
|
+
if (!sameOrigin)
|
|
53
|
+
continue;
|
|
54
|
+
if (!pick || e.responseEnd > pick.responseEnd)
|
|
55
|
+
pick = e;
|
|
56
|
+
}
|
|
57
|
+
if (!pick)
|
|
58
|
+
return undefined;
|
|
59
|
+
return {
|
|
60
|
+
path: resourcePath(pick.name),
|
|
61
|
+
start_ms: ms(pick.startTime),
|
|
62
|
+
first_byte_ms: ms(pick.responseStart > 0 ? pick.responseStart : undefined),
|
|
63
|
+
end_ms: ms(pick.responseEnd),
|
|
64
|
+
bytes: pick.transferSize,
|
|
65
|
+
protocol: pick.nextHopProtocol || undefined,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function prefixed(prefix, fields) {
|
|
69
|
+
const out = {};
|
|
70
|
+
if (!fields)
|
|
71
|
+
return out;
|
|
72
|
+
for (const [k, v] of Object.entries(fields))
|
|
73
|
+
if (v !== undefined)
|
|
74
|
+
out[`${prefix}_${k}`] = v;
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
function resourceEntries() {
|
|
78
|
+
try {
|
|
79
|
+
return performance.getEntriesByType('resource');
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Flat breakdown fields for one metric (all durations in whole ms). Kept to a
|
|
87
|
+
* fixed, small set of columns per metric — never keyed by data.
|
|
88
|
+
*/
|
|
89
|
+
export function breakdownFields(m) {
|
|
90
|
+
const fields = {};
|
|
91
|
+
switch (m.name) {
|
|
92
|
+
case 'TTFB': {
|
|
93
|
+
const a = m.attribution;
|
|
94
|
+
const nav = a.navigationEntry;
|
|
95
|
+
Object.assign(fields, {
|
|
96
|
+
waiting_ms: ms(a.waitingDuration),
|
|
97
|
+
cache_ms: ms(a.cacheDuration),
|
|
98
|
+
dns_ms: ms(a.dnsDuration),
|
|
99
|
+
connect_ms: ms(a.connectionDuration),
|
|
100
|
+
request_ms: ms(a.requestDuration),
|
|
101
|
+
protocol: nav?.nextHopProtocol || undefined,
|
|
102
|
+
});
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
case 'FCP': {
|
|
106
|
+
const a = m.attribution;
|
|
107
|
+
Object.assign(fields, {
|
|
108
|
+
ttfb_ms: ms(a.timeToFirstByte),
|
|
109
|
+
after_ttfb_ms: ms(a.firstByteToFCP),
|
|
110
|
+
load_state: a.loadState,
|
|
111
|
+
});
|
|
112
|
+
const entries = resourceEntries();
|
|
113
|
+
Object.assign(fields, prefixed('script', lastResourceBefore(entries, 'script', m.value)));
|
|
114
|
+
Object.assign(fields, prefixed('fetch', lastResourceBefore(entries, 'fetch', m.value)));
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
case 'LCP': {
|
|
118
|
+
const a = m.attribution;
|
|
119
|
+
Object.assign(fields, {
|
|
120
|
+
ttfb_ms: ms(a.timeToFirstByte),
|
|
121
|
+
resource_delay_ms: ms(a.resourceLoadDelay),
|
|
122
|
+
resource_ms: ms(a.resourceLoadDuration),
|
|
123
|
+
render_delay_ms: ms(a.elementRenderDelay),
|
|
124
|
+
target: clip(a.target),
|
|
125
|
+
});
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
case 'INP': {
|
|
129
|
+
const a = m.attribution;
|
|
130
|
+
Object.assign(fields, {
|
|
131
|
+
input_delay_ms: ms(a.inputDelay),
|
|
132
|
+
processing_ms: ms(a.processingDuration),
|
|
133
|
+
presentation_ms: ms(a.presentationDelay),
|
|
134
|
+
interaction_type: a.interactionType,
|
|
135
|
+
target: clip(a.interactionTarget),
|
|
136
|
+
load_state: a.loadState,
|
|
137
|
+
});
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
case 'CLS': {
|
|
141
|
+
const a = m.attribution;
|
|
142
|
+
Object.assign(fields, { target: clip(a.largestShiftTarget), load_state: a.loadState });
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
for (const k of Object.keys(fields))
|
|
147
|
+
if (fields[k] === undefined)
|
|
148
|
+
delete fields[k];
|
|
149
|
+
return fields;
|
|
150
|
+
}
|
|
13
151
|
export function initRum(opts = {}) {
|
|
14
152
|
const app = opts.app ?? metaContent('pw-log-app') ?? 'unknown';
|
|
15
153
|
const token = opts.token ?? metaContent('pw-log-token');
|
|
@@ -18,6 +156,7 @@ export function initRum(opts = {}) {
|
|
|
18
156
|
const buildId = opts.buildId ?? metaContent('pw-build-id');
|
|
19
157
|
const dataset = opts.dataset ?? 'rum';
|
|
20
158
|
const baseUrl = (opts.baseUrl ?? 'https://api.axiom.co').replace(/\/$/, '');
|
|
159
|
+
const device = deviceLabel(navigator.userAgent, navigator.maxTouchPoints ?? 0);
|
|
21
160
|
const shipper = new LogShipper({
|
|
22
161
|
url: `${baseUrl}/v1/datasets/${dataset}/ingest`,
|
|
23
162
|
token,
|
|
@@ -25,6 +164,13 @@ export function initRum(opts = {}) {
|
|
|
25
164
|
intervalMs: 5000,
|
|
26
165
|
});
|
|
27
166
|
const report = (m) => {
|
|
167
|
+
let breakdown = {};
|
|
168
|
+
try {
|
|
169
|
+
breakdown = breakdownFields(m);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// Diagnostics only: a browser quirk in attribution never costs the metric.
|
|
173
|
+
}
|
|
28
174
|
shipper.enqueue({
|
|
29
175
|
_time: new Date().toISOString(),
|
|
30
176
|
app,
|
|
@@ -41,7 +187,9 @@ export function initRum(opts = {}) {
|
|
|
41
187
|
metric_id: m.id,
|
|
42
188
|
navigation_type: m.navigationType,
|
|
43
189
|
path: location.pathname,
|
|
190
|
+
device,
|
|
44
191
|
...(buildId ? { build_id: buildId } : {}),
|
|
192
|
+
...breakdown,
|
|
45
193
|
});
|
|
46
194
|
};
|
|
47
195
|
onLCP(report);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { UpgradeWebSocket } from 'hono/ws';
|
|
2
|
+
import type { Logger } from './log-core.js';
|
|
3
|
+
/** Sockets older than this are closed by the server. */
|
|
4
|
+
export declare const SOCKET_MAX_AGE_MS = 300000;
|
|
5
|
+
/** After the close frame, how long the client gets to answer it before the
|
|
6
|
+
* connection is terminated. A frozen tab answers nothing, and the open TCP
|
|
7
|
+
* connection is what holds the instance. */
|
|
8
|
+
export declare const SOCKET_CLOSE_GRACE_MS = 5000;
|
|
9
|
+
/** "Service restart": clients treat it as a planned roll and reconnect at once. */
|
|
10
|
+
export declare const CLOSE_SERVICE_RESTART = 1012;
|
|
11
|
+
export interface SocketAgeCapOptions {
|
|
12
|
+
logger: Pick<Logger, 'info'>;
|
|
13
|
+
/** Tests. */
|
|
14
|
+
maxAgeMs?: number;
|
|
15
|
+
graceMs?: number;
|
|
16
|
+
}
|
|
17
|
+
/** `upgradeWebSocket`, with every socket it opens closed at `maxAgeMs` of age
|
|
18
|
+
* (1012), and terminated `graceMs` later if the client hasn't completed the
|
|
19
|
+
* close. One INFO line per cut: `socket age cap`, with the socket's path, its
|
|
20
|
+
* age and whether it had to be terminated. A socket the route's own onOpen
|
|
21
|
+
* closed (a refused identity) is left alone. */
|
|
22
|
+
export declare function capSocketAge(upgradeWebSocket: UpgradeWebSocket, options: SocketAgeCapOptions): UpgradeWebSocket;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// A cap on WebSocket age, applied by wrapping a Hono `upgradeWebSocket`.
|
|
2
|
+
//
|
|
3
|
+
// An open socket is an open request, and Cloud Run keeps a retired instance
|
|
4
|
+
// alive (and counted against --max-instances) until its last request ends.
|
|
5
|
+
// On 2026-11-02 two such instances held radar's only slot and every request
|
|
6
|
+
// got 429 for 14 minutes (PW docs/LESSONS.md). Clients replace their sockets
|
|
7
|
+
// at about 270 s; this cut closes whatever didn't (a frozen tab, a sleeping
|
|
8
|
+
// laptop, an old client) at 300 s of age, in code rather than through the
|
|
9
|
+
// service's --timeout, which must stay long enough for long background
|
|
10
|
+
// routes (PW docs/OPERATIONS.md, "Socket lifetime").
|
|
11
|
+
/** Sockets older than this are closed by the server. */
|
|
12
|
+
export const SOCKET_MAX_AGE_MS = 300_000;
|
|
13
|
+
/** After the close frame, how long the client gets to answer it before the
|
|
14
|
+
* connection is terminated. A frozen tab answers nothing, and the open TCP
|
|
15
|
+
* connection is what holds the instance. */
|
|
16
|
+
export const SOCKET_CLOSE_GRACE_MS = 5_000;
|
|
17
|
+
/** "Service restart": clients treat it as a planned roll and reconnect at once. */
|
|
18
|
+
export const CLOSE_SERVICE_RESTART = 1012;
|
|
19
|
+
const WS_OPEN = 1;
|
|
20
|
+
/** `upgradeWebSocket`, with every socket it opens closed at `maxAgeMs` of age
|
|
21
|
+
* (1012), and terminated `graceMs` later if the client hasn't completed the
|
|
22
|
+
* close. One INFO line per cut: `socket age cap`, with the socket's path, its
|
|
23
|
+
* age and whether it had to be terminated. A socket the route's own onOpen
|
|
24
|
+
* closed (a refused identity) is left alone. */
|
|
25
|
+
export function capSocketAge(upgradeWebSocket, options) {
|
|
26
|
+
const maxAgeMs = options.maxAgeMs ?? SOCKET_MAX_AGE_MS;
|
|
27
|
+
const graceMs = options.graceMs ?? SOCKET_CLOSE_GRACE_MS;
|
|
28
|
+
const cap = (events, c) => withAgeCap(events, c.req.path, maxAgeMs, graceMs, options.logger);
|
|
29
|
+
// Both of UpgradeWebSocket's call forms: (createEvents, options) returning
|
|
30
|
+
// middleware, and (c, events, options) upgrading at once.
|
|
31
|
+
function wrapped(first, second, third) {
|
|
32
|
+
if (typeof first === 'function') {
|
|
33
|
+
const createEvents = first;
|
|
34
|
+
return upgradeWebSocket(async (c) => cap(await createEvents(c), c), second);
|
|
35
|
+
}
|
|
36
|
+
return upgradeWebSocket(first, cap(second, first), third);
|
|
37
|
+
}
|
|
38
|
+
return wrapped;
|
|
39
|
+
}
|
|
40
|
+
function withAgeCap(events, path, maxAgeMs, graceMs, logger) {
|
|
41
|
+
let openedAt = 0;
|
|
42
|
+
let ageTimer = null;
|
|
43
|
+
let graceTimer = null;
|
|
44
|
+
let cutAt = null;
|
|
45
|
+
let logged = false;
|
|
46
|
+
const clearTimers = () => {
|
|
47
|
+
if (ageTimer !== null)
|
|
48
|
+
clearTimeout(ageTimer);
|
|
49
|
+
if (graceTimer !== null)
|
|
50
|
+
clearTimeout(graceTimer);
|
|
51
|
+
ageTimer = graceTimer = null;
|
|
52
|
+
};
|
|
53
|
+
const logCut = (terminated) => {
|
|
54
|
+
if (cutAt === null || logged)
|
|
55
|
+
return;
|
|
56
|
+
logged = true;
|
|
57
|
+
logger.info('socket age cap', { path, ageMs: Math.round(cutAt - openedAt), terminated });
|
|
58
|
+
};
|
|
59
|
+
const cut = (ws) => {
|
|
60
|
+
ageTimer = null;
|
|
61
|
+
// Node starts a timer's countdown from the event loop's cached time, not
|
|
62
|
+
// from the setTimeout call, so it can fire a little before `maxAgeMs` has
|
|
63
|
+
// really passed since onOpen (the age test saw 299 ms of a 300 ms cap,
|
|
64
|
+
// 2 runs in 30). Re-arm for the remainder: a socket is never cut young.
|
|
65
|
+
const remaining = openedAt + maxAgeMs - performance.now();
|
|
66
|
+
if (remaining > 0) {
|
|
67
|
+
ageTimer = setTimeout(() => cut(ws), remaining);
|
|
68
|
+
ageTimer.unref?.();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
cutAt = performance.now();
|
|
72
|
+
try {
|
|
73
|
+
ws.close(CLOSE_SERVICE_RESTART, 'socket age limit');
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// Already closing: its close handler logs the cut.
|
|
77
|
+
}
|
|
78
|
+
graceTimer = setTimeout(() => {
|
|
79
|
+
graceTimer = null;
|
|
80
|
+
const raw = ws.raw;
|
|
81
|
+
raw?.terminate?.();
|
|
82
|
+
logCut(true);
|
|
83
|
+
}, graceMs);
|
|
84
|
+
graceTimer.unref?.();
|
|
85
|
+
};
|
|
86
|
+
return {
|
|
87
|
+
...events,
|
|
88
|
+
onOpen: (evt, ws) => {
|
|
89
|
+
events.onOpen?.(evt, ws);
|
|
90
|
+
if (ws.readyState !== WS_OPEN)
|
|
91
|
+
return;
|
|
92
|
+
openedAt = performance.now();
|
|
93
|
+
ageTimer = setTimeout(() => cut(ws), maxAgeMs);
|
|
94
|
+
ageTimer.unref?.();
|
|
95
|
+
},
|
|
96
|
+
onClose: (evt, ws) => {
|
|
97
|
+
clearTimers();
|
|
98
|
+
logCut(false);
|
|
99
|
+
events.onClose?.(evt, ws);
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -101,6 +101,10 @@ export interface SyncClientOptions<B, C extends EventBody, S extends EventBody,
|
|
|
101
101
|
onBuildObserved?(buildId: string): void;
|
|
102
102
|
onBuildChanged?(): void;
|
|
103
103
|
markPerformance?(name: 'outbox-start' | 'outbox-ready' | 'bootstrap-start' | 'bootstrap-response' | 'store-ready'): void;
|
|
104
|
+
/** The socket's planned life: it is replaced (make-before-break) this long
|
|
105
|
+
* after it opens. Default 270 s ±10%, jittered per socket so tabs restored
|
|
106
|
+
* together don't roll together; an explicit value is used exactly. Servers
|
|
107
|
+
* cut sockets at 300 s (PW docs/OPERATIONS.md, "Socket lifetime"). */
|
|
104
108
|
reconnectBeforeMs?: number;
|
|
105
109
|
/** While hidden and disconnected, one HTTP catch-up per this interval
|
|
106
110
|
* (default 5 min). */
|
|
@@ -173,7 +177,7 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
|
|
|
173
177
|
private connectReplyTimer;
|
|
174
178
|
private socketCreatedHidden;
|
|
175
179
|
private socketOpenedAt;
|
|
176
|
-
private
|
|
180
|
+
private candidate;
|
|
177
181
|
private outageCloseCode;
|
|
178
182
|
private outageInfo;
|
|
179
183
|
private lastContactAt;
|
|
@@ -242,6 +246,23 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
|
|
|
242
246
|
* connects at once. */
|
|
243
247
|
private replaceSocket;
|
|
244
248
|
private connect;
|
|
249
|
+
/** The live socket's message and close handling; a rollover's replacement
|
|
250
|
+
* gets it when it takes over. */
|
|
251
|
+
private attachLiveHandlers;
|
|
252
|
+
private startLiveTimers;
|
|
253
|
+
private armRollover;
|
|
254
|
+
private rolloverDelayMs;
|
|
255
|
+
/** Make-before-break, as notes' document sockets do: a replacement connects
|
|
256
|
+
* while the live socket keeps serving (sends, echoes, state), and takes over
|
|
257
|
+
* when the server answers its connect. Nothing the user sees changes, even
|
|
258
|
+
* with an edit unsent. The replacement's handshake re-runs the server's
|
|
259
|
+
* identity check; a 4403 halts as on any socket. If it fails, the live socket
|
|
260
|
+
* keeps serving until the server's age cap closes it (1012). */
|
|
261
|
+
private startRollover;
|
|
262
|
+
private promoteCandidate;
|
|
263
|
+
/** Abandons a rollover's replacement; `failure` logs why and tries again
|
|
264
|
+
* after another socket life (the server's age cap usually comes first). */
|
|
265
|
+
private dropCandidate;
|
|
245
266
|
/** Hidden and disconnected: no WebSocket retries; HTTP catch-ups on a slow
|
|
246
267
|
* cadence instead (docs/SYNC-BACKGROUND-PLAN.md). */
|
|
247
268
|
private enterDormant;
|
|
@@ -308,6 +329,7 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
|
|
|
308
329
|
private scheduleResyncFailureRetry;
|
|
309
330
|
private cancelResyncFailureRetry;
|
|
310
331
|
private sendConnect;
|
|
332
|
+
private connectMessage;
|
|
311
333
|
private sendRaw;
|
|
312
334
|
private trySend;
|
|
313
335
|
private flushViaBeacon;
|
|
@@ -5,7 +5,16 @@ const PING_INTERVAL_MS = 30_000;
|
|
|
5
5
|
// times in 25 minutes on 10-19, but none of those handshakes ever reached
|
|
6
6
|
// the load balancer, so a longer limit would only have delayed the retry.
|
|
7
7
|
const CONNECT_WATCHDOG_MS = 10_000;
|
|
8
|
-
|
|
8
|
+
/** A socket that holds a retired Cloud Run instance keeps that instance
|
|
9
|
+
* alive and counted against --max-instances (radar, 2026-11-02: 14 minutes of
|
|
10
|
+
* 429s). Rolling at 270 s, under the servers' 300 s cut, bounds that hold. */
|
|
11
|
+
const DEFAULT_RECONNECT_BEFORE_MS = 270_000;
|
|
12
|
+
/** ± this fraction of the default rollover, per socket: every fleet app shares
|
|
13
|
+
* the load balancer's IP, and Chrome queues handshakes per IP. */
|
|
14
|
+
const ROLLOVER_JITTER = 0.1;
|
|
15
|
+
/** "Service restart": the server's planned close (its socket age cap, or a
|
|
16
|
+
* hub copy stopping). Handled like our own rollover. */
|
|
17
|
+
const CLOSE_SERVICE_RESTART = 1012;
|
|
9
18
|
const FAILURE_TIMEOUT_MS = 60_000;
|
|
10
19
|
// ERROR only after a sustained foreground outage. The fleet is single-user
|
|
11
20
|
// and triage runs daily, so detection latency is cheap; short client-network
|
|
@@ -44,6 +53,14 @@ const SLOW_HANDSHAKE_MS = 3_000;
|
|
|
44
53
|
const STALL_MIN_MS = 1_000;
|
|
45
54
|
const SOCKET_CONNECTING = 0;
|
|
46
55
|
const SOCKET_OPEN = 1;
|
|
56
|
+
function messageType(raw) {
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(raw)?.type;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
47
64
|
class LifecycleAbort {
|
|
48
65
|
reason;
|
|
49
66
|
constructor(reason) {
|
|
@@ -112,7 +129,7 @@ export class SyncClient {
|
|
|
112
129
|
connectReplyTimer = null;
|
|
113
130
|
socketCreatedHidden = false;
|
|
114
131
|
socketOpenedAt = null;
|
|
115
|
-
|
|
132
|
+
candidate = null;
|
|
116
133
|
outageCloseCode = 1006;
|
|
117
134
|
outageInfo = null;
|
|
118
135
|
lastContactAt = null;
|
|
@@ -280,6 +297,7 @@ export class SyncClient {
|
|
|
280
297
|
for (const remove of this.removeListeners.splice(0))
|
|
281
298
|
remove();
|
|
282
299
|
this.clearAllTimers();
|
|
300
|
+
this.dropCandidate();
|
|
283
301
|
const socket = this.socket;
|
|
284
302
|
this.socket = null;
|
|
285
303
|
if (socket) {
|
|
@@ -555,6 +573,7 @@ export class SyncClient {
|
|
|
555
573
|
const previous = this.socket;
|
|
556
574
|
this.socket = null;
|
|
557
575
|
this.clearConnectionTimers();
|
|
576
|
+
this.dropCandidate();
|
|
558
577
|
if (previous) {
|
|
559
578
|
previous.close();
|
|
560
579
|
this.detachSocket(previous);
|
|
@@ -614,13 +633,13 @@ export class SyncClient {
|
|
|
614
633
|
}
|
|
615
634
|
this.cancelBackgroundTick();
|
|
616
635
|
this.sendConnect();
|
|
617
|
-
this.
|
|
618
|
-
this.rolloverTimer = this.setTimer(() => {
|
|
619
|
-
this.options.logger.info('ws rollover', {});
|
|
620
|
-
this.plannedClose = socket;
|
|
621
|
-
socket.close();
|
|
622
|
-
}, this.options.reconnectBeforeMs ?? DEFAULT_RECONNECT_BEFORE_MS);
|
|
636
|
+
this.startLiveTimers();
|
|
623
637
|
};
|
|
638
|
+
this.attachLiveHandlers(socket, () => killedByWatchdog);
|
|
639
|
+
}
|
|
640
|
+
/** The live socket's message and close handling; a rollover's replacement
|
|
641
|
+
* gets it when it takes over. */
|
|
642
|
+
attachLiveHandlers(socket, killedByWatchdog) {
|
|
624
643
|
socket.onmessage = (event) => {
|
|
625
644
|
if (this.socket !== socket)
|
|
626
645
|
return;
|
|
@@ -638,7 +657,7 @@ export class SyncClient {
|
|
|
638
657
|
if (this.halted || this.disposed)
|
|
639
658
|
return;
|
|
640
659
|
const socketAgeMs = this.now() - this.socketCreatedAt;
|
|
641
|
-
if (this.socketOpenedAt === null && !killedByWatchdog && socketAgeMs >= SLOW_HANDSHAKE_MS) {
|
|
660
|
+
if (this.socketOpenedAt === null && !killedByWatchdog() && socketAgeMs >= SLOW_HANDSHAKE_MS) {
|
|
642
661
|
this.options.logger.info('sync connect failed', {
|
|
643
662
|
socketAgeMs,
|
|
644
663
|
closeCode: event.code,
|
|
@@ -651,20 +670,26 @@ export class SyncClient {
|
|
|
651
670
|
this.halt();
|
|
652
671
|
return;
|
|
653
672
|
}
|
|
654
|
-
const planned = this.plannedClose === socket;
|
|
655
|
-
this.plannedClose = null;
|
|
656
673
|
const openForMs = this.socketOpenedAt === null ? null : Math.max(0, this.now() - this.socketOpenedAt);
|
|
674
|
+
// Ended before its replacement took over: start afresh rather than wait
|
|
675
|
+
// on a handshake begun for a handover.
|
|
676
|
+
const rolling = this.candidate !== null;
|
|
677
|
+
this.dropCandidate();
|
|
678
|
+
if (event.code === CLOSE_SERVICE_RESTART) {
|
|
679
|
+
// The server's planned close (its age cap: a tab whose own rollover
|
|
680
|
+
// didn't run, such as a frozen one): reconnect at once, as after our
|
|
681
|
+
// own rollover, with no outage and no ladder step. A failure of that
|
|
682
|
+
// reconnect is an ordinary outage.
|
|
683
|
+
this.options.logger.info('ws closed by server', { closeCode: event.code, openForMs, rolling });
|
|
684
|
+
this.connectNow();
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
657
687
|
this.startOutage(event.code, event.wasClean === true, openForMs);
|
|
658
688
|
if (!this.isHidden()) {
|
|
659
689
|
this.noteReturnFailure(event.code);
|
|
660
690
|
this.scheduleReconnect();
|
|
661
691
|
return;
|
|
662
692
|
}
|
|
663
|
-
if (planned) {
|
|
664
|
-
// Our own rollover: this spell's sockets have lasted; reconnect once.
|
|
665
|
-
this.connectNow();
|
|
666
|
-
return;
|
|
667
|
-
}
|
|
668
693
|
const openedAt = this.socketOpenedAt;
|
|
669
694
|
if (this.socketCreatedHidden && (openedAt === null || this.now() - openedAt < HIDDEN_SOCKET_MIN_LIFE_MS)) {
|
|
670
695
|
this.hiddenSocketFailed = true;
|
|
@@ -672,6 +697,145 @@ export class SyncClient {
|
|
|
672
697
|
this.enterDormant();
|
|
673
698
|
};
|
|
674
699
|
}
|
|
700
|
+
startLiveTimers() {
|
|
701
|
+
this.pingTimer = this.setRepeating(() => this.sendRaw({ type: 'ping' }), PING_INTERVAL_MS);
|
|
702
|
+
this.armRollover();
|
|
703
|
+
}
|
|
704
|
+
armRollover() {
|
|
705
|
+
this.rolloverTimer = this.setTimer(() => {
|
|
706
|
+
this.rolloverTimer = null;
|
|
707
|
+
this.startRollover();
|
|
708
|
+
}, this.rolloverDelayMs());
|
|
709
|
+
}
|
|
710
|
+
rolloverDelayMs() {
|
|
711
|
+
if (this.options.reconnectBeforeMs !== undefined)
|
|
712
|
+
return this.options.reconnectBeforeMs;
|
|
713
|
+
const factor = 1 - ROLLOVER_JITTER + 2 * ROLLOVER_JITTER * Math.random();
|
|
714
|
+
return Math.round(DEFAULT_RECONNECT_BEFORE_MS * factor);
|
|
715
|
+
}
|
|
716
|
+
/** Make-before-break, as notes' document sockets do: a replacement connects
|
|
717
|
+
* while the live socket keeps serving (sends, echoes, state), and takes over
|
|
718
|
+
* when the server answers its connect. Nothing the user sees changes, even
|
|
719
|
+
* with an edit unsent. The replacement's handshake re-runs the server's
|
|
720
|
+
* identity check; a 4403 halts as on any socket. If it fails, the live socket
|
|
721
|
+
* keeps serving until the server's age cap closes it (1012). */
|
|
722
|
+
startRollover() {
|
|
723
|
+
if (this.halted || this.disposed || this.candidate !== null || this.socket?.readyState !== SOCKET_OPEN)
|
|
724
|
+
return;
|
|
725
|
+
let socket;
|
|
726
|
+
try {
|
|
727
|
+
socket = this.options.runtime.createSocket(this.options.socketUrl());
|
|
728
|
+
}
|
|
729
|
+
catch (error) {
|
|
730
|
+
this.options.logger.warn('socket creation failed', { error });
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
const candidate = {
|
|
734
|
+
socket,
|
|
735
|
+
createdAt: this.now(),
|
|
736
|
+
createdHidden: this.isHidden(),
|
|
737
|
+
openedAt: null,
|
|
738
|
+
watchdog: null,
|
|
739
|
+
early: [],
|
|
740
|
+
};
|
|
741
|
+
this.candidate = candidate;
|
|
742
|
+
candidate.watchdog = this.setTimer(() => {
|
|
743
|
+
candidate.watchdog = null;
|
|
744
|
+
if (this.candidate === candidate)
|
|
745
|
+
this.dropCandidate({ reason: 'timeout' });
|
|
746
|
+
}, CONNECT_WATCHDOG_MS);
|
|
747
|
+
socket.onopen = () => {
|
|
748
|
+
if (this.candidate !== candidate) {
|
|
749
|
+
socket.close();
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
if (candidate.watchdog !== null)
|
|
753
|
+
this.options.runtime.clearTimeout(candidate.watchdog);
|
|
754
|
+
candidate.watchdog = null;
|
|
755
|
+
candidate.openedAt = this.now();
|
|
756
|
+
try {
|
|
757
|
+
socket.send(JSON.stringify(this.connectMessage()));
|
|
758
|
+
}
|
|
759
|
+
catch {
|
|
760
|
+
// Its close handler abandons it.
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
socket.onmessage = (event) => {
|
|
764
|
+
if (this.candidate !== candidate)
|
|
765
|
+
return;
|
|
766
|
+
const raw = String(event.data);
|
|
767
|
+
const type = messageType(raw);
|
|
768
|
+
if (type !== 'catchup' && type !== 'reload') {
|
|
769
|
+
candidate.early.push(raw);
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
this.promoteCandidate(candidate);
|
|
773
|
+
this.lastContactAt = this.now();
|
|
774
|
+
this.handleMessage(raw);
|
|
775
|
+
for (const early of candidate.early) {
|
|
776
|
+
if (this.socket !== socket)
|
|
777
|
+
return;
|
|
778
|
+
this.handleMessage(early);
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
socket.onerror = () => { };
|
|
782
|
+
socket.onclose = (event) => {
|
|
783
|
+
if (this.candidate !== candidate)
|
|
784
|
+
return;
|
|
785
|
+
if (event.code === CLOSE_NO_USER) {
|
|
786
|
+
this.dropCandidate();
|
|
787
|
+
this.options.authState.markBlocked();
|
|
788
|
+
this.halt();
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
this.dropCandidate({ closeCode: event.code });
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
promoteCandidate(candidate) {
|
|
795
|
+
this.candidate = null;
|
|
796
|
+
if (candidate.watchdog !== null)
|
|
797
|
+
this.options.runtime.clearTimeout(candidate.watchdog);
|
|
798
|
+
const now = this.now();
|
|
799
|
+
const previous = this.socket;
|
|
800
|
+
const previousOpenedAt = this.socketOpenedAt;
|
|
801
|
+
this.clearConnectionTimers();
|
|
802
|
+
if (previous) {
|
|
803
|
+
this.detachSocket(previous);
|
|
804
|
+
previous.close();
|
|
805
|
+
}
|
|
806
|
+
this.socket = candidate.socket;
|
|
807
|
+
this.socketCreatedAt = candidate.createdAt;
|
|
808
|
+
this.socketCreatedHidden = candidate.createdHidden;
|
|
809
|
+
this.socketOpenedAt = candidate.openedAt;
|
|
810
|
+
candidate.socket.onopen = null;
|
|
811
|
+
this.attachLiveHandlers(candidate.socket, () => false);
|
|
812
|
+
this.startLiveTimers();
|
|
813
|
+
this.options.logger.info('ws rollover', {
|
|
814
|
+
openForMs: previousOpenedAt === null ? null : Math.max(0, now - previousOpenedAt),
|
|
815
|
+
handoverMs: Math.max(0, now - candidate.createdAt),
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
/** Abandons a rollover's replacement; `failure` logs why and tries again
|
|
819
|
+
* after another socket life (the server's age cap usually comes first). */
|
|
820
|
+
dropCandidate(failure) {
|
|
821
|
+
const candidate = this.candidate;
|
|
822
|
+
if (candidate === null)
|
|
823
|
+
return;
|
|
824
|
+
this.candidate = null;
|
|
825
|
+
if (candidate.watchdog !== null)
|
|
826
|
+
this.options.runtime.clearTimeout(candidate.watchdog);
|
|
827
|
+
this.detachSocket(candidate.socket);
|
|
828
|
+
candidate.socket.close();
|
|
829
|
+
if (failure) {
|
|
830
|
+
this.options.logger.info('ws rollover failed', {
|
|
831
|
+
...failure,
|
|
832
|
+
opened: candidate.openedAt !== null,
|
|
833
|
+
socketAgeMs: Math.max(0, this.now() - candidate.createdAt),
|
|
834
|
+
});
|
|
835
|
+
if (this.socket?.readyState === SOCKET_OPEN && this.rolloverTimer === null)
|
|
836
|
+
this.armRollover();
|
|
837
|
+
}
|
|
838
|
+
}
|
|
675
839
|
/** Hidden and disconnected: no WebSocket retries; HTTP catch-ups on a slow
|
|
676
840
|
* cadence instead (docs/SYNC-BACKGROUND-PLAN.md). */
|
|
677
841
|
enterDormant() {
|
|
@@ -1510,11 +1674,10 @@ export class SyncClient {
|
|
|
1510
1674
|
this.resyncFailureTimer = null;
|
|
1511
1675
|
}
|
|
1512
1676
|
sendConnect() {
|
|
1513
|
-
this.sendRaw(
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
});
|
|
1677
|
+
this.sendRaw(this.connectMessage());
|
|
1678
|
+
}
|
|
1679
|
+
connectMessage() {
|
|
1680
|
+
return { type: 'connect', clientGuid: this.options.clientGuid, lastSeq: this.options.store.currentSeq() };
|
|
1518
1681
|
}
|
|
1519
1682
|
sendRaw(message) {
|
|
1520
1683
|
if (this.socket?.readyState !== SOCKET_OPEN)
|
|
@@ -1605,6 +1768,7 @@ export class SyncClient {
|
|
|
1605
1768
|
this.abortFetches();
|
|
1606
1769
|
this.firstLoadRetry?.stop();
|
|
1607
1770
|
this.clearAllTimers();
|
|
1771
|
+
this.dropCandidate();
|
|
1608
1772
|
const socket = this.socket;
|
|
1609
1773
|
this.socket = null;
|
|
1610
1774
|
if (socket) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steve31415/baselib",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.54.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",
|
|
@@ -69,6 +69,10 @@
|
|
|
69
69
|
"types": "./dist/hub/index.d.ts",
|
|
70
70
|
"default": "./dist/hub/index.js"
|
|
71
71
|
},
|
|
72
|
+
"./socket-age": {
|
|
73
|
+
"types": "./dist/socket-age.d.ts",
|
|
74
|
+
"default": "./dist/socket-age.js"
|
|
75
|
+
},
|
|
72
76
|
"./app-update": {
|
|
73
77
|
"types": "./dist/app-update/index.d.ts",
|
|
74
78
|
"default": "./dist/app-update/index.js"
|