@kraki/head 0.14.4 → 0.15.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/auth-backend.d.ts +2 -3
- package/dist/local-auth-backend.d.ts +0 -1
- package/dist/local-auth-backend.js +0 -13
- package/dist/local-auth-backend.js.map +1 -1
- package/dist/pulse-hub.d.ts +92 -0
- package/dist/pulse-hub.js +194 -0
- package/dist/pulse-hub.js.map +1 -0
- package/dist/server.d.ts +43 -40
- package/dist/server.js +212 -407
- package/dist/server.js.map +1 -1
- package/dist/storage.d.ts +5 -10
- package/dist/storage.js +11 -45
- package/dist/storage.js.map +1 -1
- package/package.json +4 -3
package/dist/server.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
2
2
|
import { randomBytes, createVerify } from 'crypto';
|
|
3
3
|
import { v4 as uuid } from 'uuid';
|
|
4
|
+
import { HEAD_PULSE_TARGET } from '@kraki/protocol';
|
|
5
|
+
import { PulseHub } from './pulse-hub.js';
|
|
4
6
|
import { GitHubAuthProvider } from './auth.js';
|
|
5
7
|
import { getLogger } from './logger.js';
|
|
6
8
|
function importPublicKey(compactKey) {
|
|
@@ -17,17 +19,10 @@ const DEFAULT_VOICE_LEASE_QUOTA_SEC = 7_200;
|
|
|
17
19
|
const DEFAULT_VOICE_DAILY_QUOTA_SEC = 7_200;
|
|
18
20
|
const VALID_VOICE_RESOURCES = new Set(['voice/doubao']);
|
|
19
21
|
const DEFAULT_MAX_PAYLOAD = 10 * 1024 * 1024;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const RETRY_AFTER_MS = 5_000;
|
|
25
|
-
/** Max retries before closing the connection (forces clean reconnect). */
|
|
26
|
-
const MAX_RETRIES = 3;
|
|
27
|
-
/** How often the retry pass runs (ms). Independent of the slower 30s ping timer
|
|
28
|
-
* so detection latency tracks the recipient's ping cadence (~10s), not the
|
|
29
|
-
* liveness check cadence. */
|
|
30
|
-
const RETRY_CHECK_INTERVAL_MS = 2_500;
|
|
22
|
+
/** How often the liveness sweep runs (ms). Faster than the 30s ping timer so
|
|
23
|
+
* device_pending detection latency tracks the recipient's ping cadence
|
|
24
|
+
* (~10s), not the slower liveness check cadence. */
|
|
25
|
+
const LIVENESS_SWEEP_INTERVAL_MS = 2_500;
|
|
31
26
|
/** Grace period after ping before broadcasting device_pending (ms).
|
|
32
27
|
* Overridable via PONG_GRACE_MS environment variable. */
|
|
33
28
|
const PONG_GRACE_MS = Math.max(1_000, parseInt(process.env.PONG_GRACE_MS ?? '8000', 10) || 8_000);
|
|
@@ -52,14 +47,6 @@ function isValidAuth(msg) {
|
|
|
52
47
|
return false;
|
|
53
48
|
return true;
|
|
54
49
|
}
|
|
55
|
-
function isValidUnicast(msg) {
|
|
56
|
-
return msg.type === 'unicast' && typeof msg.to === 'string'
|
|
57
|
-
&& typeof msg.blob === 'string' && typeof msg.keys === 'object' && msg.keys !== null;
|
|
58
|
-
}
|
|
59
|
-
function isValidBroadcast(msg) {
|
|
60
|
-
return msg.type === 'broadcast'
|
|
61
|
-
&& typeof msg.blob === 'string' && typeof msg.keys === 'object' && msg.keys !== null;
|
|
62
|
-
}
|
|
63
50
|
export class HeadServer {
|
|
64
51
|
wss;
|
|
65
52
|
storage;
|
|
@@ -71,7 +58,10 @@ export class HeadServer {
|
|
|
71
58
|
userByDevice = new Map();
|
|
72
59
|
clients = new Map();
|
|
73
60
|
pingTimer = null;
|
|
74
|
-
|
|
61
|
+
livenessTimer = null;
|
|
62
|
+
/** Per-hop pulse hub — the reliable-delivery layer for every connection. */
|
|
63
|
+
pulseHub;
|
|
64
|
+
pulseTickTimer = null;
|
|
75
65
|
constructor(storage, options) {
|
|
76
66
|
this.storage = storage;
|
|
77
67
|
this.options = options;
|
|
@@ -81,12 +71,17 @@ export class HeadServer {
|
|
|
81
71
|
});
|
|
82
72
|
this.wss.on('connection', (ws, req) => this.onConnection(ws, req));
|
|
83
73
|
this.startPingInterval();
|
|
84
|
-
this.
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
74
|
+
this.startLivenessSweep();
|
|
75
|
+
// Pulse per-hop reliable delivery. Shares the Storage SQLite file.
|
|
76
|
+
this.pulseHub = new PulseHub(this.storage.rawDb, {
|
|
77
|
+
now: () => Date.now(),
|
|
78
|
+
sendPulseTo: (deviceId, pulseB64) => this.sendPulseFrameTo(deviceId, pulseB64),
|
|
79
|
+
broadcastTargets: (fromDevice) => this.pulseBroadcastTargets(fromDevice),
|
|
80
|
+
onDeliverToSelf: (fromDevice, payload) => this.deliverPulseToSelf(fromDevice, payload),
|
|
81
|
+
});
|
|
82
|
+
this.pulseHub.recoverOnBoot();
|
|
83
|
+
// Drive pulse heartbeat/liveness/durable-expiry every 5s.
|
|
84
|
+
this.pulseTickTimer = setInterval(() => this.pulseHub.tick(), 5_000);
|
|
90
85
|
}
|
|
91
86
|
startPingInterval() {
|
|
92
87
|
this.pingTimer = setInterval(() => {
|
|
@@ -110,13 +105,8 @@ export class HeadServer {
|
|
|
110
105
|
try {
|
|
111
106
|
if (ws.readyState === WebSocket.OPEN) {
|
|
112
107
|
ws.ping(); // protocol-level ping (browsers auto-respond)
|
|
113
|
-
// JSON ping for proxy keepalive
|
|
114
|
-
|
|
115
|
-
const pingMsg = { type: 'ping' };
|
|
116
|
-
if (state && state.lastReceivedRelaySeq > 0) {
|
|
117
|
-
pingMsg.ack = state.lastReceivedRelaySeq;
|
|
118
|
-
}
|
|
119
|
-
ws.send(JSON.stringify(pingMsg));
|
|
108
|
+
// JSON ping for proxy keepalive.
|
|
109
|
+
ws.send(JSON.stringify({ type: 'ping' }));
|
|
120
110
|
if (state) {
|
|
121
111
|
state.pongOverdueAt = now + PONG_GRACE_MS;
|
|
122
112
|
state.pendingLivenessBroadcast = false;
|
|
@@ -143,178 +133,19 @@ export class HeadServer {
|
|
|
143
133
|
}
|
|
144
134
|
}, HeadServer.PING_INTERVAL);
|
|
145
135
|
}
|
|
146
|
-
// ──
|
|
147
|
-
/** Run the
|
|
148
|
-
* ping.
|
|
149
|
-
*
|
|
150
|
-
|
|
151
|
-
this.
|
|
152
|
-
this.
|
|
153
|
-
},
|
|
136
|
+
// ── Liveness sweep ───────────────────────────────────────
|
|
137
|
+
/** Run the liveness sweep at a fixed cadence, independent of the slower
|
|
138
|
+
* 30s ping timer. Broadcasts device_pending when a peer's pong is overdue
|
|
139
|
+
* so other devices see the "maybe offline" state quickly. */
|
|
140
|
+
startLivenessSweep() {
|
|
141
|
+
this.livenessTimer = setInterval(() => {
|
|
142
|
+
this.runLivenessSweep();
|
|
143
|
+
}, LIVENESS_SWEEP_INTERVAL_MS);
|
|
154
144
|
}
|
|
155
|
-
/**
|
|
156
|
-
*
|
|
157
|
-
|
|
158
|
-
*
|
|
159
|
-
* If the message has incoming `relaySeq`/`ack` fields from another hop
|
|
160
|
-
* (e.g. a forwarded unicast/broadcast), they are stripped — these are
|
|
161
|
-
* per-hop and not end-to-end. */
|
|
162
|
-
trackedSend(ws, state, msg) {
|
|
163
|
-
if (ws.readyState !== WebSocket.OPEN)
|
|
164
|
-
return;
|
|
165
|
-
state.relaySeqCounter += 1;
|
|
166
|
-
const relaySeq = state.relaySeqCounter;
|
|
167
|
-
// Strip any per-hop tracking fields from the source message, then stamp ours.
|
|
168
|
-
const { relaySeq: _ignoredSeq, ack: _ignoredAck, ...rest } = msg;
|
|
169
|
-
const stamped = { ...rest, relaySeq };
|
|
170
|
-
// Piggyback our own ack of what we've received from this peer on this connection.
|
|
171
|
-
if (state.lastReceivedRelaySeq > 0) {
|
|
172
|
-
stamped.ack = state.lastReceivedRelaySeq;
|
|
173
|
-
}
|
|
174
|
-
const payload = JSON.stringify(stamped);
|
|
175
|
-
// Buffer full: evict the oldest entry silently and continue.
|
|
176
|
-
// Connection is NOT killed — peer may simply be temporarily throttled
|
|
177
|
-
// (e.g. backgrounded browser tab). The 30s protocol-level ping/pong is
|
|
178
|
-
// the source of truth for liveness, not delivery exhaustion.
|
|
179
|
-
if (state.inFlight.length >= MAX_IN_FLIGHT) {
|
|
180
|
-
getLogger().warn('In-flight buffer full, evicting oldest entry', {
|
|
181
|
-
deviceId: state.deviceId,
|
|
182
|
-
inFlight: state.inFlight.length,
|
|
183
|
-
});
|
|
184
|
-
state.inFlight.shift();
|
|
185
|
-
}
|
|
186
|
-
state.inFlight.push({
|
|
187
|
-
relaySeq,
|
|
188
|
-
payload,
|
|
189
|
-
sentAt: Date.now(),
|
|
190
|
-
retries: 0,
|
|
191
|
-
});
|
|
192
|
-
try {
|
|
193
|
-
ws.send(payload);
|
|
194
|
-
}
|
|
195
|
-
catch {
|
|
196
|
-
// Send failed — connection is likely dead. Drop the connection so
|
|
197
|
-
// the client can reconnect cleanly. Retry pass will not re-send to
|
|
198
|
-
// a dead connection.
|
|
199
|
-
if (state.deviceId)
|
|
200
|
-
this.removeConnection(state.deviceId);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
/** Track an incoming relaySeq from peer. Returns true if message is a
|
|
204
|
-
* duplicate that should be dropped silently. */
|
|
205
|
-
trackInboundRelaySeq(state, msg) {
|
|
206
|
-
if (!('relaySeq' in msg))
|
|
207
|
-
return false;
|
|
208
|
-
const seq = msg.relaySeq;
|
|
209
|
-
if (typeof seq !== 'number' || !Number.isFinite(seq) || seq <= 0)
|
|
210
|
-
return false;
|
|
211
|
-
if (state.seenRelaySeqs.has(seq)) {
|
|
212
|
-
return true; // duplicate
|
|
213
|
-
}
|
|
214
|
-
// Bound the set so it doesn't grow unbounded. Drop the oldest tracked
|
|
215
|
-
// ack window when full — at this point any duplicate older than the
|
|
216
|
-
// window would have been delivered long ago.
|
|
217
|
-
if (state.seenRelaySeqs.size >= MAX_IN_FLIGHT) {
|
|
218
|
-
const iter = state.seenRelaySeqs.values().next();
|
|
219
|
-
if (!iter.done)
|
|
220
|
-
state.seenRelaySeqs.delete(iter.value);
|
|
221
|
-
}
|
|
222
|
-
state.seenRelaySeqs.add(seq);
|
|
223
|
-
if (seq > state.lastReceivedRelaySeq) {
|
|
224
|
-
state.lastReceivedRelaySeq = seq;
|
|
225
|
-
}
|
|
226
|
-
return false;
|
|
227
|
-
}
|
|
228
|
-
/** Process an incoming ack field from any message type. Prunes the in-flight
|
|
229
|
-
* buffer and flips ackSupported on first sighting. */
|
|
230
|
-
processAck(state, msg) {
|
|
231
|
-
if (!('ack' in msg))
|
|
232
|
-
return;
|
|
233
|
-
const ack = msg.ack;
|
|
234
|
-
if (typeof ack !== 'number' || !Number.isFinite(ack))
|
|
235
|
-
return;
|
|
236
|
-
// Presence of the field at all marks the peer as ack-capable, even if
|
|
237
|
-
// ack: 0. Once flipped, it stays true for the lifetime of the connection.
|
|
238
|
-
state.ackSupported = true;
|
|
239
|
-
if (ack > state.lastAckedSeq) {
|
|
240
|
-
state.lastAckedSeq = ack;
|
|
241
|
-
// Prune everything ≤ ack from the in-flight buffer.
|
|
242
|
-
if (state.inFlight.length > 0) {
|
|
243
|
-
let firstUnacked = 0;
|
|
244
|
-
while (firstUnacked < state.inFlight.length && state.inFlight[firstUnacked].relaySeq <= ack) {
|
|
245
|
-
firstUnacked += 1;
|
|
246
|
-
}
|
|
247
|
-
if (firstUnacked > 0) {
|
|
248
|
-
state.inFlight.splice(0, firstUnacked);
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
/** Walk all connections; re-send unacked messages that have aged past
|
|
254
|
-
* RETRY_AFTER_MS. When MAX_RETRIES is exceeded, mark the entry giveUp
|
|
255
|
-
* but keep the connection alive — liveness is decided by the 30s
|
|
256
|
-
* protocol-level ping/pong, not by delivery-retry exhaustion. */
|
|
257
|
-
runRetryPass() {
|
|
258
|
-
const logger = getLogger();
|
|
145
|
+
/** Broadcast device_pending for any authenticated connection whose pong has
|
|
146
|
+
* gone overdue (and we haven't already broadcast for this ping cycle). */
|
|
147
|
+
runLivenessSweep() {
|
|
259
148
|
const now = Date.now();
|
|
260
|
-
for (const [deviceId, ws] of this.connections) {
|
|
261
|
-
const state = this.clients.get(ws);
|
|
262
|
-
if (!state)
|
|
263
|
-
continue;
|
|
264
|
-
if (!state.ackSupported)
|
|
265
|
-
continue; // Old client — skip retry
|
|
266
|
-
if (state.inFlight.length === 0)
|
|
267
|
-
continue;
|
|
268
|
-
if (ws.readyState !== WebSocket.OPEN)
|
|
269
|
-
continue;
|
|
270
|
-
state.lastRetryAt = now;
|
|
271
|
-
let sendFailed = false;
|
|
272
|
-
for (const entry of state.inFlight) {
|
|
273
|
-
if (entry.giveUp)
|
|
274
|
-
continue;
|
|
275
|
-
// Already acked? (Shouldn't normally happen — processAck prunes —
|
|
276
|
-
// but guard against races.)
|
|
277
|
-
if (entry.relaySeq <= state.lastAckedSeq)
|
|
278
|
-
continue;
|
|
279
|
-
if (now - entry.sentAt < RETRY_AFTER_MS)
|
|
280
|
-
continue;
|
|
281
|
-
if (entry.retries >= MAX_RETRIES) {
|
|
282
|
-
// Give up retrying this entry. Don't kill the connection — the
|
|
283
|
-
// peer may just be temporarily throttled (e.g. backgrounded
|
|
284
|
-
// browser tab). If the peer later catches up and acks, this
|
|
285
|
-
// entry is pruned normally. If the bytes were truly lost, the
|
|
286
|
-
// application layer's per-session replay handles content recovery.
|
|
287
|
-
logger.debug('Giving up retries for entry', {
|
|
288
|
-
deviceId,
|
|
289
|
-
relaySeq: entry.relaySeq,
|
|
290
|
-
retries: entry.retries,
|
|
291
|
-
});
|
|
292
|
-
entry.giveUp = true;
|
|
293
|
-
continue;
|
|
294
|
-
}
|
|
295
|
-
entry.retries += 1;
|
|
296
|
-
entry.sentAt = now;
|
|
297
|
-
try {
|
|
298
|
-
ws.send(entry.payload);
|
|
299
|
-
logger.debug('Retried unacked message', {
|
|
300
|
-
deviceId,
|
|
301
|
-
relaySeq: entry.relaySeq,
|
|
302
|
-
attempt: entry.retries,
|
|
303
|
-
});
|
|
304
|
-
}
|
|
305
|
-
catch {
|
|
306
|
-
// ws.send threw — the underlying socket is dead. Close it so the
|
|
307
|
-
// close handler can clean up. This is a transport failure, not a
|
|
308
|
-
// delivery-retry failure.
|
|
309
|
-
sendFailed = true;
|
|
310
|
-
break;
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
if (sendFailed) {
|
|
314
|
-
this.removeConnection(deviceId);
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
// ── Liveness uncertainty: broadcast device_pending when pong is overdue ──
|
|
318
149
|
for (const [deviceId, ws] of this.connections) {
|
|
319
150
|
const state = this.clients.get(ws);
|
|
320
151
|
if (!state?.authenticated || !state.userId)
|
|
@@ -330,7 +161,11 @@ export class HeadServer {
|
|
|
330
161
|
this.broadcastDevicePending(state.userId, deviceId);
|
|
331
162
|
}
|
|
332
163
|
}
|
|
333
|
-
|
|
164
|
+
/** Test hook: force a liveness sweep without waiting for the timer. */
|
|
165
|
+
forceLivenessSweep() {
|
|
166
|
+
this.runLivenessSweep();
|
|
167
|
+
}
|
|
168
|
+
// ── End liveness sweep ──────────────────────────────────
|
|
334
169
|
// --- Auth provider helpers ---
|
|
335
170
|
getAuthProvider() {
|
|
336
171
|
if (this.options.authProviders?.size) {
|
|
@@ -399,12 +234,6 @@ export class HeadServer {
|
|
|
399
234
|
ip,
|
|
400
235
|
pongOverdueAt: null,
|
|
401
236
|
pendingLivenessBroadcast: false,
|
|
402
|
-
relaySeqCounter: 0,
|
|
403
|
-
inFlight: [],
|
|
404
|
-
lastAckedSeq: 0,
|
|
405
|
-
ackSupported: false,
|
|
406
|
-
lastReceivedRelaySeq: 0,
|
|
407
|
-
seenRelaySeqs: new Set(),
|
|
408
237
|
};
|
|
409
238
|
const logger = getLogger();
|
|
410
239
|
this.clients.set(ws, state);
|
|
@@ -440,6 +269,7 @@ export class HeadServer {
|
|
|
440
269
|
catch { /* storage may be closed */ }
|
|
441
270
|
this.connections.delete(disconnectedDeviceId);
|
|
442
271
|
this.userByDevice.delete(disconnectedDeviceId);
|
|
272
|
+
this.pulseHub.onDeviceDisconnected(disconnectedDeviceId);
|
|
443
273
|
logger.info('Device disconnected', {
|
|
444
274
|
deviceId: disconnectedDeviceId,
|
|
445
275
|
closeCode: code,
|
|
@@ -473,13 +303,6 @@ export class HeadServer {
|
|
|
473
303
|
});
|
|
474
304
|
}
|
|
475
305
|
async onMessage(ws, state, msg) {
|
|
476
|
-
// Process ack field on every incoming message (post or pre-auth, doesn't matter —
|
|
477
|
-
// ack just prunes our outbound buffer for this peer).
|
|
478
|
-
this.processAck(state, msg);
|
|
479
|
-
// Track inbound relaySeq for sending acks back. Duplicates are dropped silently.
|
|
480
|
-
if (this.trackInboundRelaySeq(state, msg)) {
|
|
481
|
-
return;
|
|
482
|
-
}
|
|
483
306
|
// One-shot pairing token request — before auth
|
|
484
307
|
if (msg.type === 'request_pairing_token') {
|
|
485
308
|
await this.handleRequestPairingToken(ws, state, msg);
|
|
@@ -542,78 +365,43 @@ export class HeadServer {
|
|
|
542
365
|
return;
|
|
543
366
|
}
|
|
544
367
|
if (msg.type === 'unicast') {
|
|
545
|
-
|
|
546
|
-
|
|
368
|
+
// The hub (a pulse endpoint on each hop) handles reliable delivery + durable
|
|
369
|
+
// store-and-forward. The envelope MUST carry a pulse frame — the head is
|
|
370
|
+
// pulse-only; there is no raw-WS relay path.
|
|
371
|
+
if (typeof msg.pulse === 'string' && state.deviceId) {
|
|
372
|
+
this.pulseHub.onPulseEnvelope(state.deviceId, {
|
|
373
|
+
pulse: msg.pulse,
|
|
374
|
+
to: msg.to,
|
|
375
|
+
});
|
|
547
376
|
return;
|
|
548
377
|
}
|
|
549
|
-
this.
|
|
378
|
+
this.sendError(ws, 'unicast requires a pulse frame');
|
|
550
379
|
return;
|
|
551
380
|
}
|
|
552
381
|
if (msg.type === 'broadcast') {
|
|
553
|
-
|
|
554
|
-
|
|
382
|
+
// Pulse broadcast frames (control: hello/ack/heartbeat) are addressed to a
|
|
383
|
+
// specific hop via the hub too; a producer fans out per-device unicasts.
|
|
384
|
+
if (typeof msg.pulse === 'string' && state.deviceId) {
|
|
385
|
+
// A pushPreview may ride the same pulse envelope — fire it for offline
|
|
386
|
+
// devices (the reliable message itself is delivered by the hub).
|
|
387
|
+
if (msg.pushPreview)
|
|
388
|
+
this.firePushPreview(state, msg.pushPreview);
|
|
389
|
+
this.pulseHub.onPulseEnvelope(state.deviceId, { pulse: msg.pulse });
|
|
555
390
|
return;
|
|
556
391
|
}
|
|
557
|
-
this.
|
|
558
|
-
return;
|
|
559
|
-
}
|
|
560
|
-
if (msg.type === 'update_preferences') {
|
|
561
|
-
if (state.userId) {
|
|
562
|
-
const prefs = msg.preferences;
|
|
563
|
-
if (prefs && typeof prefs === 'object') {
|
|
564
|
-
this.storage.updatePreferences(state.userId, prefs);
|
|
565
|
-
// Read back the full merged preferences
|
|
566
|
-
const fullUser = this.storage.getUser(state.userId);
|
|
567
|
-
const merged = fullUser?.preferences ?? prefs;
|
|
568
|
-
const confirmation = { type: 'preferences_updated', preferences: merged };
|
|
569
|
-
// Send confirmation to sender (tracked — peer should see it)
|
|
570
|
-
this.trackedSend(ws, state, confirmation);
|
|
571
|
-
// Broadcast to all other devices of the same user
|
|
572
|
-
const userDevices = (() => {
|
|
573
|
-
try {
|
|
574
|
-
return this.storage.getDevicesByUser(state.userId);
|
|
575
|
-
}
|
|
576
|
-
catch {
|
|
577
|
-
return [];
|
|
578
|
-
}
|
|
579
|
-
})();
|
|
580
|
-
for (const d of userDevices) {
|
|
581
|
-
if (d.id === state.deviceId)
|
|
582
|
-
continue;
|
|
583
|
-
const other = this.connections.get(d.id);
|
|
584
|
-
if (!other || other.readyState !== WebSocket.OPEN)
|
|
585
|
-
continue;
|
|
586
|
-
const otherState = this.clients.get(other);
|
|
587
|
-
if (!otherState)
|
|
588
|
-
continue;
|
|
589
|
-
this.trackedSend(other, otherState, confirmation);
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
return;
|
|
594
|
-
}
|
|
595
|
-
if (msg.type === 'remove_device') {
|
|
596
|
-
this.handleRemoveDevice(ws, state, msg.deviceId);
|
|
597
|
-
return;
|
|
598
|
-
}
|
|
599
|
-
if (msg.type === 'register_push_token') {
|
|
600
|
-
this.handleRegisterPushToken(ws, state, msg);
|
|
392
|
+
this.sendError(ws, 'broadcast requires a pulse frame');
|
|
601
393
|
return;
|
|
602
394
|
}
|
|
603
|
-
|
|
604
|
-
|
|
395
|
+
// Relay-terminated control ops (update_preferences / remove_device /
|
|
396
|
+
// (un)register_push_token). Arrive via pulse deliver-to-self (deliverPulseToSelf).
|
|
397
|
+
if (this.handleControlMessage(ws, state, msg))
|
|
605
398
|
return;
|
|
606
|
-
}
|
|
607
399
|
if (msg.type === 'request_voice_lease') {
|
|
608
400
|
this.handleRequestVoiceLease(ws, state, msg);
|
|
609
401
|
return;
|
|
610
402
|
}
|
|
611
403
|
if (msg.type === 'ping') {
|
|
612
|
-
|
|
613
|
-
if (state.lastReceivedRelaySeq > 0) {
|
|
614
|
-
pongMsg.ack = state.lastReceivedRelaySeq;
|
|
615
|
-
}
|
|
616
|
-
ws.send(JSON.stringify(pongMsg));
|
|
404
|
+
ws.send(JSON.stringify({ type: 'pong' }));
|
|
617
405
|
return;
|
|
618
406
|
}
|
|
619
407
|
if (msg.type === 'pong') {
|
|
@@ -637,39 +425,39 @@ export class HeadServer {
|
|
|
637
425
|
}
|
|
638
426
|
const issuer = this.options.leaseIssuer;
|
|
639
427
|
if (!issuer) {
|
|
640
|
-
|
|
428
|
+
this.sendControlToDevice(deviceId, {
|
|
641
429
|
type: 'voice_lease_denied',
|
|
642
430
|
reason: 'not_entitled',
|
|
643
431
|
detail: 'Voice lease issuance is not enabled on this head',
|
|
644
|
-
})
|
|
432
|
+
});
|
|
645
433
|
return;
|
|
646
434
|
}
|
|
647
435
|
// Validate request shape.
|
|
648
436
|
const requestedDeviceId = typeof msg.deviceId === 'string' ? msg.deviceId : undefined;
|
|
649
437
|
const requestedResource = typeof msg.resource === 'string' ? msg.resource : undefined;
|
|
650
438
|
if (!requestedDeviceId || !requestedResource) {
|
|
651
|
-
|
|
439
|
+
this.sendControlToDevice(deviceId, {
|
|
652
440
|
type: 'voice_lease_denied',
|
|
653
441
|
reason: 'invalid_request',
|
|
654
442
|
detail: 'Missing deviceId or resource',
|
|
655
|
-
})
|
|
443
|
+
});
|
|
656
444
|
return;
|
|
657
445
|
}
|
|
658
446
|
// Lease may only be requested for the authenticated device — no proxying.
|
|
659
447
|
if (requestedDeviceId !== deviceId) {
|
|
660
|
-
|
|
448
|
+
this.sendControlToDevice(deviceId, {
|
|
661
449
|
type: 'voice_lease_denied',
|
|
662
450
|
reason: 'invalid_request',
|
|
663
451
|
detail: 'deviceId does not match authenticated device',
|
|
664
|
-
})
|
|
452
|
+
});
|
|
665
453
|
return;
|
|
666
454
|
}
|
|
667
455
|
if (!VALID_VOICE_RESOURCES.has(requestedResource)) {
|
|
668
|
-
|
|
456
|
+
this.sendControlToDevice(deviceId, {
|
|
669
457
|
type: 'voice_lease_denied',
|
|
670
458
|
reason: 'invalid_request',
|
|
671
459
|
detail: `Unknown resource: ${String(requestedResource)}`,
|
|
672
|
-
})
|
|
460
|
+
});
|
|
673
461
|
return;
|
|
674
462
|
}
|
|
675
463
|
const ttl = this.options.voiceLeaseTtlSec ?? DEFAULT_VOICE_LEASE_TTL_SEC;
|
|
@@ -681,11 +469,11 @@ export class HeadServer {
|
|
|
681
469
|
logger.info('Voice lease denied: daily quota exhausted', {
|
|
682
470
|
userId, deviceId, issuedToday, quotaPerLease, dailyCap,
|
|
683
471
|
});
|
|
684
|
-
|
|
472
|
+
this.sendControlToDevice(deviceId, {
|
|
685
473
|
type: 'voice_lease_denied',
|
|
686
474
|
reason: 'quota_exhausted',
|
|
687
475
|
detail: `Daily quota reached (${issuedToday}/${dailyCap}s)`,
|
|
688
|
-
})
|
|
476
|
+
});
|
|
689
477
|
return;
|
|
690
478
|
}
|
|
691
479
|
const lease = issuer.issue({
|
|
@@ -711,74 +499,149 @@ export class HeadServer {
|
|
|
711
499
|
logger.error('Voice lease persist failed', {
|
|
712
500
|
userId, deviceId, jti: lease.payload.jti, error: err.message,
|
|
713
501
|
});
|
|
714
|
-
|
|
502
|
+
this.sendControlToDevice(deviceId, {
|
|
715
503
|
type: 'voice_lease_denied',
|
|
716
504
|
reason: 'invalid_request',
|
|
717
505
|
detail: 'Lease persistence failed',
|
|
718
|
-
})
|
|
506
|
+
});
|
|
719
507
|
return;
|
|
720
508
|
}
|
|
721
509
|
logger.info('Voice lease issued', {
|
|
722
510
|
userId, deviceId, jti: lease.payload.jti,
|
|
723
511
|
quotaSeconds: quotaPerLease, ttlSec: ttl,
|
|
724
512
|
});
|
|
725
|
-
|
|
513
|
+
this.sendControlToDevice(deviceId, { type: 'voice_lease_grant', lease });
|
|
726
514
|
}
|
|
727
515
|
// --- Routing ---
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
516
|
+
/** Send a pulse frame (control or forwarded data) to a device's socket, as a
|
|
517
|
+
* minimal unicast envelope carrying only the `pulse` field. Returns true if
|
|
518
|
+
* the device is online. Used by the PulseHub to reach endpoints. */
|
|
519
|
+
sendPulseFrameTo(deviceId, pulseB64) {
|
|
520
|
+
const ws = this.connections.get(deviceId);
|
|
521
|
+
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
522
|
+
return false;
|
|
523
|
+
// `to` lets the receiver's own pulse layer know which stream this is; blob
|
|
524
|
+
// is empty because the payload (if any) is inside the pulse frame.
|
|
525
|
+
ws.send(JSON.stringify({ type: 'unicast', to: deviceId, pulse: pulseB64, blob: '', keys: {} }));
|
|
526
|
+
return true;
|
|
527
|
+
}
|
|
528
|
+
/** Send a head-ORIGINATED control message to a device reliably over pulse
|
|
529
|
+
* (presence, preferences confirmation, voice responses). These are plaintext
|
|
530
|
+
* head↔device control (the head has no E2E on them), so the payload is
|
|
531
|
+
* wrapped as `{from:'@head', msg}` — the receiver's pulse layer recognizes the
|
|
532
|
+
* `@head` marker and dispatches `msg` WITHOUT decrypting. Non-durable:
|
|
533
|
+
* presence/prefs are ephemeral. */
|
|
534
|
+
sendControlToDevice(deviceId, msg) {
|
|
535
|
+
const payload = Buffer.from(JSON.stringify({ from: HEAD_PULSE_TARGET, msg }), 'utf8');
|
|
536
|
+
this.pulseHub.sendToDevice(deviceId, new Uint8Array(payload));
|
|
537
|
+
}
|
|
538
|
+
/** A pulse frame addressed to HEAD_PULSE_TARGET was delivered in-order by the
|
|
539
|
+
* source device's endpoint. The payload is PLAINTEXT control JSON. Resolve the
|
|
540
|
+
* originating authenticated connection and route it through the SAME control
|
|
541
|
+
* handlers the raw-WS path uses, so auth context (state.userId/deviceId) and
|
|
542
|
+
* responses (over ws) are identical. */
|
|
543
|
+
deliverPulseToSelf(fromDevice, payload) {
|
|
544
|
+
const ws = this.connections.get(fromDevice);
|
|
545
|
+
if (!ws)
|
|
546
|
+
return; // device vanished — the control op is moot
|
|
547
|
+
const state = this.clients.get(ws);
|
|
548
|
+
if (!state?.authenticated)
|
|
738
549
|
return;
|
|
550
|
+
let msg;
|
|
551
|
+
try {
|
|
552
|
+
const parsed = JSON.parse(Buffer.from(payload).toString('utf8'));
|
|
553
|
+
if (!isValidMessage(parsed))
|
|
554
|
+
return;
|
|
555
|
+
msg = parsed;
|
|
739
556
|
}
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
// Target offline — queue for delivery on reconnect.
|
|
743
|
-
// Strip per-hop tracking fields before persisting so the queued payload
|
|
744
|
-
// doesn't carry stale relaySeq/ack from this sender→head hop.
|
|
745
|
-
const { relaySeq: _s, ack: _a, ...clean } = msg;
|
|
746
|
-
this.storage.insertPending(msg.to, senderUserId, JSON.stringify(clean));
|
|
747
|
-
logger.debug('Unicast queued for offline device', { from: state.deviceId, to: msg.to });
|
|
748
|
-
return;
|
|
557
|
+
catch {
|
|
558
|
+
return; // malformed → drop
|
|
749
559
|
}
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
// Forward via tracked path so we retry if the recipient doesn't ack.
|
|
754
|
-
this.trackedSend(targetWs, targetState, msg);
|
|
560
|
+
// handleControlMessage whitelists the 4 relay-terminated types and returns
|
|
561
|
+
// false for anything else, so the self-channel cannot tunnel unicast/auth/etc.
|
|
562
|
+
this.handleControlMessage(ws, state, msg);
|
|
755
563
|
}
|
|
756
|
-
|
|
757
|
-
|
|
564
|
+
/** The relay-terminated control ops, callable from BOTH the raw-WS dispatch
|
|
565
|
+
* (onMessage) and the pulse deliver-to-self path. Returns true if handled. */
|
|
566
|
+
handleControlMessage(ws, state, msg) {
|
|
567
|
+
switch (msg.type) {
|
|
568
|
+
case 'update_preferences':
|
|
569
|
+
this.handleUpdatePreferences(ws, state, msg);
|
|
570
|
+
return true;
|
|
571
|
+
case 'remove_device':
|
|
572
|
+
this.handleRemoveDevice(ws, state, msg.deviceId);
|
|
573
|
+
return true;
|
|
574
|
+
case 'register_push_token':
|
|
575
|
+
this.handleRegisterPushToken(ws, state, msg);
|
|
576
|
+
return true;
|
|
577
|
+
case 'unregister_push_token':
|
|
578
|
+
this.handleUnregisterPushToken(ws, state, msg);
|
|
579
|
+
return true;
|
|
580
|
+
default: return false;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
/** Persist a preferences update and echo the merged result to the sender + the
|
|
584
|
+
* user's other online devices. */
|
|
585
|
+
handleUpdatePreferences(ws, state, msg) {
|
|
586
|
+
if (!state.userId)
|
|
587
|
+
return;
|
|
588
|
+
const prefs = msg.preferences;
|
|
589
|
+
if (!prefs || typeof prefs !== 'object')
|
|
590
|
+
return;
|
|
591
|
+
this.storage.updatePreferences(state.userId, prefs);
|
|
592
|
+
// Read back the full merged preferences
|
|
593
|
+
const fullUser = this.storage.getUser(state.userId);
|
|
594
|
+
const merged = fullUser?.preferences ?? prefs;
|
|
595
|
+
const confirmation = { type: 'preferences_updated', preferences: merged };
|
|
596
|
+
// Confirm to the sender + fan out to the user's other devices — all over pulse.
|
|
597
|
+
if (state.deviceId)
|
|
598
|
+
this.sendControlToDevice(state.deviceId, confirmation);
|
|
599
|
+
const userDevices = (() => {
|
|
600
|
+
try {
|
|
601
|
+
return this.storage.getDevicesByUser(state.userId);
|
|
602
|
+
}
|
|
603
|
+
catch {
|
|
604
|
+
return [];
|
|
605
|
+
}
|
|
606
|
+
})();
|
|
607
|
+
for (const d of userDevices) {
|
|
608
|
+
if (d.id === state.deviceId)
|
|
609
|
+
continue;
|
|
610
|
+
const other = this.connections.get(d.id);
|
|
611
|
+
if (!other || other.readyState !== WebSocket.OPEN)
|
|
612
|
+
continue;
|
|
613
|
+
this.sendControlToDevice(d.id, confirmation);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
/** Fan-out targets for a pulse broadcast from `fromDevice`: all OTHER devices
|
|
617
|
+
* of the same user (the online ones the hub can forward to). */
|
|
618
|
+
pulseBroadcastTargets(fromDevice) {
|
|
619
|
+
const userId = this.userByDevice.get(fromDevice);
|
|
620
|
+
if (!userId)
|
|
621
|
+
return [];
|
|
622
|
+
const targets = [];
|
|
623
|
+
for (const device of this.storage.getDevicesByUser(userId)) {
|
|
624
|
+
if (device.id !== fromDevice)
|
|
625
|
+
targets.push(device.id);
|
|
626
|
+
}
|
|
627
|
+
return targets;
|
|
628
|
+
}
|
|
629
|
+
/** Notify offline devices from a pushPreview side-channel. The preview rides the
|
|
630
|
+
* same pulse broadcast envelope as the reliable message. */
|
|
631
|
+
firePushPreview(state, pushPreview) {
|
|
758
632
|
const senderUserId = state.userId;
|
|
759
633
|
const senderDeviceId = state.deviceId;
|
|
760
|
-
if (!senderUserId || !senderDeviceId)
|
|
634
|
+
if (!senderUserId || !senderDeviceId || !pushPreview || !this.options.pushManager)
|
|
761
635
|
return;
|
|
762
|
-
// Find all other connected devices for this user
|
|
763
|
-
const userDevices = this.storage.getDevicesByUser(senderUserId);
|
|
764
636
|
const onlineDeviceIds = [senderDeviceId];
|
|
765
|
-
for (const device of
|
|
637
|
+
for (const device of this.storage.getDevicesByUser(senderUserId)) {
|
|
766
638
|
if (device.id === senderDeviceId)
|
|
767
639
|
continue;
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
continue;
|
|
771
|
-
onlineDeviceIds.push(device.id);
|
|
772
|
-
const targetState = this.clients.get(targetWs);
|
|
773
|
-
if (!targetState)
|
|
774
|
-
continue;
|
|
775
|
-
this.trackedSend(targetWs, targetState, msg);
|
|
776
|
-
}
|
|
777
|
-
// Send push notifications to offline devices with registered tokens
|
|
778
|
-
if (msg.pushPreview && this.options.pushManager) {
|
|
779
|
-
this.options.pushManager.sendToOfflineDevices(senderUserId, onlineDeviceIds, msg.pushPreview)
|
|
780
|
-
.catch(err => logger.warn('Push notification send failed', { error: err.message }));
|
|
640
|
+
if (this.connections.get(device.id))
|
|
641
|
+
onlineDeviceIds.push(device.id);
|
|
781
642
|
}
|
|
643
|
+
this.options.pushManager.sendToOfflineDevices(senderUserId, onlineDeviceIds, pushPreview)
|
|
644
|
+
.catch(err => getLogger().warn('Push notification send failed', { error: err.message }));
|
|
782
645
|
}
|
|
783
646
|
removeConnection(deviceId) {
|
|
784
647
|
const ws = this.connections.get(deviceId);
|
|
@@ -984,6 +847,7 @@ export class HeadServer {
|
|
|
984
847
|
state.userId = user.userId;
|
|
985
848
|
this.connections.set(deviceId, ws);
|
|
986
849
|
this.userByDevice.set(deviceId, user.userId);
|
|
850
|
+
this.pulseHub.onDeviceConnected(deviceId);
|
|
987
851
|
logger.info('Device authenticated via challenge-response', { deviceId, ip: state.ip });
|
|
988
852
|
this.completeAuthHandshake(ws, {
|
|
989
853
|
deviceId,
|
|
@@ -1016,6 +880,7 @@ export class HeadServer {
|
|
|
1016
880
|
state.userId = result.userId;
|
|
1017
881
|
this.connections.set(result.deviceId, ws);
|
|
1018
882
|
this.userByDevice.set(result.deviceId, result.userId);
|
|
883
|
+
this.pulseHub.onDeviceConnected(result.deviceId);
|
|
1019
884
|
logger.info('Device authenticated (via backend)', {
|
|
1020
885
|
deviceId: result.deviceId,
|
|
1021
886
|
user: result.user.login,
|
|
@@ -1045,7 +910,6 @@ export class HeadServer {
|
|
|
1045
910
|
user: result.user,
|
|
1046
911
|
authMethod: result.authMethod,
|
|
1047
912
|
devices: result.devices,
|
|
1048
|
-
backendPendingMessages: result.pendingMessages,
|
|
1049
913
|
githubClientId: result.githubClientId,
|
|
1050
914
|
vapidPublicKey: result.vapidPublicKey,
|
|
1051
915
|
});
|
|
@@ -1084,15 +948,6 @@ export class HeadServer {
|
|
|
1084
948
|
...params.user,
|
|
1085
949
|
preferences: fullUser?.preferences ?? params.user.preferences,
|
|
1086
950
|
};
|
|
1087
|
-
// Flush locally-queued pending messages — every auth path that lands here
|
|
1088
|
-
// means a device is reconnecting, and any unicasts queued for it locally
|
|
1089
|
-
// (via handleUnicast → storage.insertPending) should be delivered now.
|
|
1090
|
-
// Combined with whatever the remote backend forwarded, if applicable.
|
|
1091
|
-
const localPending = this.flushPendingEnvelopes(params.deviceId);
|
|
1092
|
-
const pendingMessages = [
|
|
1093
|
-
...(params.backendPendingMessages ?? []),
|
|
1094
|
-
...localPending,
|
|
1095
|
-
];
|
|
1096
951
|
// Devices list. If the caller provided one (edge mode), recompute online
|
|
1097
952
|
// status against our current connections map. Otherwise derive locally.
|
|
1098
953
|
const devices = params.devices
|
|
@@ -1107,7 +962,6 @@ export class HeadServer {
|
|
|
1107
962
|
githubClientId: params.githubClientId ?? this.getGitHubClientId(),
|
|
1108
963
|
vapidPublicKey: params.vapidPublicKey ?? this.getVapidPublicKey(),
|
|
1109
964
|
relayVersion: this.options.version,
|
|
1110
|
-
...(pendingMessages.length > 0 && { pendingMessages }),
|
|
1111
965
|
...(this.getVoiceCapability() && { voice: this.getVoiceCapability() }),
|
|
1112
966
|
}));
|
|
1113
967
|
this.broadcastDeviceJoined(params.userId, params.deviceId);
|
|
@@ -1132,6 +986,7 @@ export class HeadServer {
|
|
|
1132
986
|
state.userId = user.id;
|
|
1133
987
|
this.connections.set(deviceId, ws);
|
|
1134
988
|
this.userByDevice.set(deviceId, user.id);
|
|
989
|
+
this.pulseHub.onDeviceConnected(deviceId);
|
|
1135
990
|
logger.info('Device authenticated', {
|
|
1136
991
|
deviceId,
|
|
1137
992
|
name: msg.device.name,
|
|
@@ -1262,7 +1117,6 @@ export class HeadServer {
|
|
|
1262
1117
|
return;
|
|
1263
1118
|
}
|
|
1264
1119
|
this.storage.deleteDevice(targetDeviceId);
|
|
1265
|
-
this.storage.deletePendingForDevice(targetDeviceId);
|
|
1266
1120
|
this.storage.deletePushTokensForDevice(targetDeviceId);
|
|
1267
1121
|
logger.info('Device removed', { deviceId: targetDeviceId, byDevice: state.deviceId });
|
|
1268
1122
|
// Broadcast removal to all remaining user devices
|
|
@@ -1289,7 +1143,9 @@ export class HeadServer {
|
|
|
1289
1143
|
logger.info('Pruned stale push tokens', { userId: state.userId, count: pruned });
|
|
1290
1144
|
}
|
|
1291
1145
|
logger.info('Push token registered', { deviceId: state.deviceId, provider: payload.provider });
|
|
1292
|
-
|
|
1146
|
+
if (state.deviceId) {
|
|
1147
|
+
this.sendControlToDevice(state.deviceId, { type: 'push_token_registered', payload: { provider: payload.provider } });
|
|
1148
|
+
}
|
|
1293
1149
|
}
|
|
1294
1150
|
handleUnregisterPushToken(ws, state, msg) {
|
|
1295
1151
|
const logger = getLogger();
|
|
@@ -1317,10 +1173,7 @@ export class HeadServer {
|
|
|
1317
1173
|
const ws = this.connections.get(d.id);
|
|
1318
1174
|
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
1319
1175
|
continue;
|
|
1320
|
-
|
|
1321
|
-
if (!targetState)
|
|
1322
|
-
continue;
|
|
1323
|
-
this.trackedSend(ws, targetState, { type: 'device_removed', deviceId: removedDeviceId });
|
|
1176
|
+
this.sendControlToDevice(d.id, { type: 'device_removed', deviceId: removedDeviceId });
|
|
1324
1177
|
}
|
|
1325
1178
|
}
|
|
1326
1179
|
broadcastDeviceJoined(userId, newDeviceId) {
|
|
@@ -1357,10 +1210,7 @@ export class HeadServer {
|
|
|
1357
1210
|
const ws = this.connections.get(d.id);
|
|
1358
1211
|
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
1359
1212
|
continue;
|
|
1360
|
-
|
|
1361
|
-
if (!targetState)
|
|
1362
|
-
continue;
|
|
1363
|
-
this.trackedSend(ws, targetState, { type: 'device_joined', device: summary });
|
|
1213
|
+
this.sendControlToDevice(d.id, { type: 'device_joined', device: summary });
|
|
1364
1214
|
}
|
|
1365
1215
|
}
|
|
1366
1216
|
broadcastDeviceLeft(userId, leftDeviceId) {
|
|
@@ -1377,10 +1227,7 @@ export class HeadServer {
|
|
|
1377
1227
|
const ws = this.connections.get(d.id);
|
|
1378
1228
|
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
1379
1229
|
continue;
|
|
1380
|
-
|
|
1381
|
-
if (!targetState)
|
|
1382
|
-
continue;
|
|
1383
|
-
this.trackedSend(ws, targetState, { type: 'device_left', deviceId: leftDeviceId });
|
|
1230
|
+
this.sendControlToDevice(d.id, { type: 'device_left', deviceId: leftDeviceId });
|
|
1384
1231
|
}
|
|
1385
1232
|
}
|
|
1386
1233
|
broadcastDevicePending(userId, pendingDeviceId) {
|
|
@@ -1397,10 +1244,7 @@ export class HeadServer {
|
|
|
1397
1244
|
const ws = this.connections.get(d.id);
|
|
1398
1245
|
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
1399
1246
|
continue;
|
|
1400
|
-
|
|
1401
|
-
if (!targetState)
|
|
1402
|
-
continue;
|
|
1403
|
-
this.trackedSend(ws, targetState, { type: 'device_pending', deviceId: pendingDeviceId });
|
|
1247
|
+
this.sendControlToDevice(d.id, { type: 'device_pending', deviceId: pendingDeviceId });
|
|
1404
1248
|
}
|
|
1405
1249
|
}
|
|
1406
1250
|
/** Handle pong from either protocol-level or JSON pong.
|
|
@@ -1448,24 +1292,6 @@ export class HeadServer {
|
|
|
1448
1292
|
ws.send(JSON.stringify({ type: 'server_error', message }));
|
|
1449
1293
|
}
|
|
1450
1294
|
}
|
|
1451
|
-
/** Flush pending messages from SQLite, parse into envelope objects. */
|
|
1452
|
-
flushPendingEnvelopes(deviceId) {
|
|
1453
|
-
const logger = getLogger();
|
|
1454
|
-
const rows = this.storage.flushPending(deviceId);
|
|
1455
|
-
if (rows.length === 0)
|
|
1456
|
-
return [];
|
|
1457
|
-
const envelopes = [];
|
|
1458
|
-
for (const raw of rows) {
|
|
1459
|
-
try {
|
|
1460
|
-
envelopes.push(JSON.parse(raw));
|
|
1461
|
-
}
|
|
1462
|
-
catch {
|
|
1463
|
-
logger.warn('Failed to parse pending message', { deviceId });
|
|
1464
|
-
}
|
|
1465
|
-
}
|
|
1466
|
-
logger.info('Flushed pending messages', { deviceId, count: envelopes.length });
|
|
1467
|
-
return envelopes;
|
|
1468
|
-
}
|
|
1469
1295
|
getStats() {
|
|
1470
1296
|
const onlineUserIds = new Set();
|
|
1471
1297
|
const connectionList = [];
|
|
@@ -1521,27 +1347,6 @@ export class HeadServer {
|
|
|
1521
1347
|
registeredUsers,
|
|
1522
1348
|
};
|
|
1523
1349
|
}
|
|
1524
|
-
/** Test hook: inspect delivery state for an online device. Not used in prod. */
|
|
1525
|
-
getDeliveryState(deviceId) {
|
|
1526
|
-
const ws = this.connections.get(deviceId);
|
|
1527
|
-
if (!ws)
|
|
1528
|
-
return undefined;
|
|
1529
|
-
const state = this.clients.get(ws);
|
|
1530
|
-
if (!state)
|
|
1531
|
-
return undefined;
|
|
1532
|
-
return {
|
|
1533
|
-
inFlightCount: state.inFlight.length,
|
|
1534
|
-
givenUpCount: state.inFlight.filter(e => e.giveUp).length,
|
|
1535
|
-
relaySeqCounter: state.relaySeqCounter,
|
|
1536
|
-
lastAckedSeq: state.lastAckedSeq,
|
|
1537
|
-
ackSupported: state.ackSupported,
|
|
1538
|
-
lastReceivedRelaySeq: state.lastReceivedRelaySeq,
|
|
1539
|
-
};
|
|
1540
|
-
}
|
|
1541
|
-
/** Test hook: force a retry pass without waiting for the timer. */
|
|
1542
|
-
forceRetryPass() {
|
|
1543
|
-
this.runRetryPass();
|
|
1544
|
-
}
|
|
1545
1350
|
/** Test hook: run the ping pass (set isAlive=false, send ping, start grace timer). */
|
|
1546
1351
|
forcePingPass() {
|
|
1547
1352
|
for (const [deviceId, ws] of this.connections) {
|
|
@@ -1556,11 +1361,7 @@ export class HeadServer {
|
|
|
1556
1361
|
try {
|
|
1557
1362
|
if (ws.readyState === WebSocket.OPEN) {
|
|
1558
1363
|
ws.ping();
|
|
1559
|
-
|
|
1560
|
-
if (state && state.lastReceivedRelaySeq > 0) {
|
|
1561
|
-
pingMsg.ack = state.lastReceivedRelaySeq;
|
|
1562
|
-
}
|
|
1563
|
-
ws.send(JSON.stringify(pingMsg));
|
|
1364
|
+
ws.send(JSON.stringify({ type: 'ping' }));
|
|
1564
1365
|
if (state) {
|
|
1565
1366
|
state.pongOverdueAt = Date.now() + PONG_GRACE_MS;
|
|
1566
1367
|
state.pendingLivenessBroadcast = false;
|
|
@@ -1588,7 +1389,7 @@ export class HeadServer {
|
|
|
1588
1389
|
state.pendingLivenessBroadcast = false;
|
|
1589
1390
|
}
|
|
1590
1391
|
/** Test hook: expire the pong grace timer for a specific device so the next
|
|
1591
|
-
*
|
|
1392
|
+
* liveness sweep will broadcast device_pending immediately. */
|
|
1592
1393
|
expirePongGrace(deviceId) {
|
|
1593
1394
|
const ws = this.connections.get(deviceId);
|
|
1594
1395
|
if (!ws)
|
|
@@ -1605,9 +1406,13 @@ export class HeadServer {
|
|
|
1605
1406
|
clearInterval(this.pingTimer);
|
|
1606
1407
|
this.pingTimer = null;
|
|
1607
1408
|
}
|
|
1608
|
-
if (this.
|
|
1609
|
-
clearInterval(this.
|
|
1610
|
-
this.
|
|
1409
|
+
if (this.livenessTimer) {
|
|
1410
|
+
clearInterval(this.livenessTimer);
|
|
1411
|
+
this.livenessTimer = null;
|
|
1412
|
+
}
|
|
1413
|
+
if (this.pulseTickTimer) {
|
|
1414
|
+
clearInterval(this.pulseTickTimer);
|
|
1415
|
+
this.pulseTickTimer = null;
|
|
1611
1416
|
}
|
|
1612
1417
|
for (const ws of this.clients.keys()) {
|
|
1613
1418
|
ws.close();
|