@kraki/head 0.14.5 → 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/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
- // ── Delivery assurance tuning ──────────────────────────────
21
- /** Max in-flight (unacked) messages per connection before forcing reconnect. */
22
- const MAX_IN_FLIGHT = 200;
23
- /** How long to wait for an ack before re-sending a message (ms). */
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
- retryTimer = null;
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.startRetryInterval();
85
- // Expire old pending messages on startup
86
- const expired = this.storage.expirePending();
87
- if (expired > 0) {
88
- getLogger().info('Expired pending messages on startup', { count: expired });
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 — include ack so the peer can
114
- // prune its in-flight buffer even when no other traffic flows.
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
- // ── Delivery assurance ───────────────────────────────────
147
- /** Run the retry pass at a fixed cadence, independent of the slower liveness
148
- * ping. Detection latency for a dropped message is bounded by the recipient's
149
- * ping cadence (~10s) + this interval. */
150
- startRetryInterval() {
151
- this.retryTimer = setInterval(() => {
152
- this.runRetryPass();
153
- }, RETRY_CHECK_INTERVAL_MS);
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
- /** Stamp a message with relaySeq, buffer for retry, and send. Use this for
156
- * any post-auth message head expects the peer to receive reliably. Skip for
157
- * pings, pongs, errors, and pre-auth handshake messages.
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
- // ── End delivery assurance ──────────────────────────────
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
- if (!isValidUnicast(msg)) {
546
- this.sendError(ws, 'Invalid unicast: to, blob, keys required');
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.handleUnicast(ws, state, msg);
378
+ this.sendError(ws, 'unicast requires a pulse frame');
550
379
  return;
551
380
  }
552
381
  if (msg.type === 'broadcast') {
553
- if (!isValidBroadcast(msg)) {
554
- this.sendError(ws, 'Invalid broadcast: blob, keys required');
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.handleBroadcast(state, msg);
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
- if (msg.type === 'unregister_push_token') {
604
- this.handleUnregisterPushToken(ws, state, msg);
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
- const pongMsg = { type: 'pong' };
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
- ws.send(JSON.stringify({
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
- ws.send(JSON.stringify({
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
- ws.send(JSON.stringify({
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
- ws.send(JSON.stringify({
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
- ws.send(JSON.stringify({
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
- ws.send(JSON.stringify({
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
- ws.send(JSON.stringify({ type: 'voice_lease_grant', lease }));
513
+ this.sendControlToDevice(deviceId, { type: 'voice_lease_grant', lease });
726
514
  }
727
515
  // --- Routing ---
728
- handleUnicast(ws, state, msg) {
729
- const logger = getLogger();
730
- const senderUserId = state.userId;
731
- if (!senderUserId || !state.deviceId)
732
- return;
733
- // Verify target belongs to same user
734
- const targetDevice = this.storage.getDevice(msg.to);
735
- if (!targetDevice || targetDevice.userId !== senderUserId) {
736
- logger.warn('Unicast rejected: target not found or wrong user', { from: state.deviceId, to: msg.to });
737
- ws.send(JSON.stringify({ type: 'server_error', message: 'Target device not found or offline', ref: msg.ref }));
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
- const targetWs = this.connections.get(msg.to);
741
- if (!targetWs) {
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
- const targetState = this.clients.get(targetWs);
751
- if (!targetState)
752
- return;
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
- handleBroadcast(state, msg) {
757
- const logger = getLogger();
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 userDevices) {
637
+ for (const device of this.storage.getDevicesByUser(senderUserId)) {
766
638
  if (device.id === senderDeviceId)
767
639
  continue;
768
- const targetWs = this.connections.get(device.id);
769
- if (!targetWs)
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
- ws.send(JSON.stringify({ type: 'push_token_registered', payload: { provider: payload.provider } }));
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
- const targetState = this.clients.get(ws);
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
- const targetState = this.clients.get(ws);
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
- const targetState = this.clients.get(ws);
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
- const targetState = this.clients.get(ws);
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
- const pingMsg = { type: 'ping' };
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
- * retry pass will broadcast device_pending immediately. */
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.retryTimer) {
1609
- clearInterval(this.retryTimer);
1610
- this.retryTimer = null;
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();