@silicaclaw/cli 1.0.0-beta.24 → 1.0.0-beta.25

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/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## v1.0 beta - 2026-03-18
4
4
 
5
+ ### Beta 25
6
+
7
+ - relay load reduction:
8
+ - default relay poll interval increased to reduce request pressure
9
+ - peer refresh interval increased to reduce extra room lookups
10
+ - request timeout and retry behavior tightened to avoid stacked in-flight polls
11
+ - poll responses now reuse embedded peer lists to avoid separate `/peers` calls
12
+ - relay durability improvements:
13
+ - Cloudflare relay now throttles peer heartbeat writes
14
+ - local signaling preview server now mirrors the same lower-write behavior
15
+ - presence cost tuning:
16
+ - default broadcast interval increased
17
+ - default presence TTL increased to keep nodes visible without aggressive rebroadcasting
18
+
5
19
  ### Beta 24
6
20
 
7
21
  - command install UX:
@@ -51,8 +51,8 @@ import {
51
51
  import { CacheRepo, IdentityRepo, LogRepo, ProfileRepo, SocialRuntimeRepo } from "@silicaclaw/storage";
52
52
  import { registerSocialRoutes } from "./socialRoutes";
53
53
 
54
- const BROADCAST_INTERVAL_MS = 10_000;
55
- const PRESENCE_TTL_MS = Number(process.env.PRESENCE_TTL_MS || 30_000);
54
+ const BROADCAST_INTERVAL_MS = Number(process.env.BROADCAST_INTERVAL_MS || 20_000);
55
+ const PRESENCE_TTL_MS = Number(process.env.PRESENCE_TTL_MS || 90_000);
56
56
  const NETWORK_MAX_MESSAGE_BYTES = Number(process.env.NETWORK_MAX_MESSAGE_BYTES || 64 * 1024);
57
57
  const NETWORK_DEDUPE_WINDOW_MS = Number(process.env.NETWORK_DEDUPE_WINDOW_MS || 90_000);
58
58
  const NETWORK_DEDUPE_MAX_ENTRIES = Number(process.env.NETWORK_DEDUPE_MAX_ENTRIES || 10_000);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@silicaclaw/cli",
3
- "version": "1.0.0-beta.24",
3
+ "version": "1.0.0-beta.25",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -12,6 +12,8 @@ type RelayPreviewOptions = {
12
12
  pollIntervalMs?: number;
13
13
  maxFutureDriftMs?: number;
14
14
  maxPastDriftMs?: number;
15
+ requestTimeoutMs?: number;
16
+ peerRefreshIntervalMs?: number;
15
17
  };
16
18
  type RelayPeer = {
17
19
  peer_id: string;
@@ -102,6 +104,7 @@ type RelayDiagnostics = {
102
104
  peers_refresh_attempted: number;
103
105
  peers_refresh_succeeded: number;
104
106
  publish_succeeded: number;
107
+ poll_skipped_inflight: number;
105
108
  };
106
109
  };
107
110
  export declare class RelayPreviewAdapter implements NetworkAdapter {
@@ -116,6 +119,8 @@ export declare class RelayPreviewAdapter implements NetworkAdapter {
116
119
  private readonly pollIntervalMs;
117
120
  private readonly maxFutureDriftMs;
118
121
  private readonly maxPastDriftMs;
122
+ private readonly requestTimeoutMs;
123
+ private readonly peerRefreshIntervalMs;
119
124
  private readonly envelopeCodec;
120
125
  private readonly topicCodec;
121
126
  private started;
@@ -137,6 +142,8 @@ export declare class RelayPreviewAdapter implements NetworkAdapter {
137
142
  private lastPeerRefreshAt;
138
143
  private lastErrorAt;
139
144
  private lastError;
145
+ private pollInFlight;
146
+ private currentPollDelayMs;
140
147
  private stats;
141
148
  constructor(options?: RelayPreviewOptions);
142
149
  start(): Promise<void>;
@@ -153,5 +160,7 @@ export declare class RelayPreviewAdapter implements NetworkAdapter {
153
160
  private get;
154
161
  private post;
155
162
  private requestJson;
163
+ private updatePeersFromList;
164
+ private scheduleNextPoll;
156
165
  }
157
166
  export {};
@@ -20,6 +20,8 @@ class RelayPreviewAdapter {
20
20
  pollIntervalMs;
21
21
  maxFutureDriftMs;
22
22
  maxPastDriftMs;
23
+ requestTimeoutMs;
24
+ peerRefreshIntervalMs;
23
25
  envelopeCodec;
24
26
  topicCodec;
25
27
  started = false;
@@ -41,6 +43,8 @@ class RelayPreviewAdapter {
41
43
  lastPeerRefreshAt = 0;
42
44
  lastErrorAt = 0;
43
45
  lastError = null;
46
+ pollInFlight = false;
47
+ currentPollDelayMs = 0;
44
48
  stats = {
45
49
  publish_attempted: 0,
46
50
  publish_sent: 0,
@@ -69,6 +73,7 @@ class RelayPreviewAdapter {
69
73
  peers_refresh_attempted: 0,
70
74
  peers_refresh_succeeded: 0,
71
75
  publish_succeeded: 0,
76
+ poll_skipped_inflight: 0,
72
77
  };
73
78
  constructor(options = {}) {
74
79
  this.peerId = options.peerId ?? `peer-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
@@ -83,11 +88,14 @@ class RelayPreviewAdapter {
83
88
  this.bootstrapHints = dedupe(options.bootstrapHints || []);
84
89
  this.bootstrapSources = dedupe(options.bootstrapSources || []);
85
90
  this.maxMessageBytes = options.maxMessageBytes ?? 64 * 1024;
86
- this.pollIntervalMs = options.pollIntervalMs ?? 2000;
91
+ this.pollIntervalMs = options.pollIntervalMs ?? 5000;
87
92
  this.maxFutureDriftMs = options.maxFutureDriftMs ?? 30_000;
88
93
  this.maxPastDriftMs = options.maxPastDriftMs ?? 120_000;
94
+ this.requestTimeoutMs = options.requestTimeoutMs ?? 4000;
95
+ this.peerRefreshIntervalMs = options.peerRefreshIntervalMs ?? 30_000;
89
96
  this.envelopeCodec = new jsonMessageEnvelopeCodec_1.JsonMessageEnvelopeCodec();
90
97
  this.topicCodec = new jsonTopicCodec_1.JsonTopicCodec();
98
+ this.currentPollDelayMs = this.pollIntervalMs;
91
99
  }
92
100
  async start() {
93
101
  if (this.started)
@@ -97,9 +105,7 @@ class RelayPreviewAdapter {
97
105
  this.started = true;
98
106
  await this.refreshPeers();
99
107
  await this.pollOnce();
100
- this.poller = setInterval(() => {
101
- this.pollOnce().catch(() => { });
102
- }, this.pollIntervalMs);
108
+ this.scheduleNextPoll(this.pollIntervalMs);
103
109
  this.recordDiscovery("signaling_connected", { endpoint: this.activeEndpoint });
104
110
  }
105
111
  catch (error) {
@@ -111,7 +117,7 @@ class RelayPreviewAdapter {
111
117
  if (!this.started)
112
118
  return;
113
119
  if (this.poller) {
114
- clearInterval(this.poller);
120
+ clearTimeout(this.poller);
115
121
  this.poller = null;
116
122
  }
117
123
  try {
@@ -206,51 +212,48 @@ class RelayPreviewAdapter {
206
212
  };
207
213
  }
208
214
  async pollOnce() {
215
+ if (this.pollInFlight) {
216
+ this.stats.poll_skipped_inflight += 1;
217
+ return;
218
+ }
219
+ this.pollInFlight = true;
209
220
  await this.maybeRefreshJoin("poll");
210
221
  this.stats.poll_attempted += 1;
211
- const payload = await this.get(`/relay/poll?room=${encodeURIComponent(this.room)}&peer_id=${encodeURIComponent(this.peerId)}`);
212
- this.lastPollAt = Date.now();
213
- this.stats.poll_succeeded += 1;
214
- const messages = Array.isArray(payload?.messages) ? payload.messages : [];
215
- for (const message of messages) {
216
- this.signalingMessagesReceivedTotal += 1;
217
- this.onEnvelope(message?.envelope);
222
+ try {
223
+ const payload = await this.get(`/relay/poll?room=${encodeURIComponent(this.room)}&peer_id=${encodeURIComponent(this.peerId)}`);
224
+ this.lastPollAt = Date.now();
225
+ this.stats.poll_succeeded += 1;
226
+ this.currentPollDelayMs = this.pollIntervalMs;
227
+ const messages = Array.isArray(payload?.messages) ? payload.messages : [];
228
+ for (const message of messages) {
229
+ this.signalingMessagesReceivedTotal += 1;
230
+ this.onEnvelope(message?.envelope);
231
+ }
232
+ if (Array.isArray(payload?.peers)) {
233
+ this.updatePeersFromList(payload.peers);
234
+ }
235
+ else if (!this.lastPeerRefreshAt || Date.now() - this.lastPeerRefreshAt >= this.peerRefreshIntervalMs) {
236
+ await this.refreshPeers();
237
+ }
238
+ }
239
+ catch (error) {
240
+ this.currentPollDelayMs = Math.min(15_000, Math.max(this.pollIntervalMs, this.currentPollDelayMs * 2));
241
+ throw error;
242
+ }
243
+ finally {
244
+ this.pollInFlight = false;
245
+ if (this.started) {
246
+ this.scheduleNextPoll(this.currentPollDelayMs);
247
+ }
218
248
  }
219
- await this.refreshPeers();
220
249
  }
221
250
  async refreshPeers() {
222
251
  this.stats.peers_refresh_attempted += 1;
223
252
  const payload = await this.get(`/peers?room=${encodeURIComponent(this.room)}`);
224
253
  this.lastPeerRefreshAt = Date.now();
225
254
  this.stats.peers_refresh_succeeded += 1;
226
- const peerIds = Array.isArray(payload?.peers) ? payload.peers.map((value) => String(value || "").trim()).filter(Boolean) : [];
227
- if (!peerIds.includes(this.peerId)) {
228
- await this.joinRoom("self_missing_from_peers");
229
- }
230
- const now = Date.now();
231
- const next = new Map();
232
- for (const peerId of peerIds) {
233
- if (peerId === this.peerId)
234
- continue;
235
- const existing = this.peers.get(peerId);
236
- if (!existing) {
237
- this.recordDiscovery("peer_joined", { peer_id: peerId });
238
- }
239
- next.set(peerId, {
240
- peer_id: peerId,
241
- status: "online",
242
- first_seen_at: existing?.first_seen_at ?? now,
243
- last_seen_at: now,
244
- messages_seen: existing?.messages_seen ?? 0,
245
- reconnect_attempts: existing?.reconnect_attempts ?? 0,
246
- });
247
- }
248
- for (const peerId of this.peers.keys()) {
249
- if (!next.has(peerId)) {
250
- this.recordDiscovery("peer_removed", { peer_id: peerId });
251
- }
252
- }
253
- this.peers = next;
255
+ const peerIds = Array.isArray(payload?.peers) ? payload.peers : [];
256
+ this.updatePeersFromList(peerIds);
254
257
  }
255
258
  onEnvelope(envelope) {
256
259
  this.stats.received_total += 1;
@@ -337,7 +340,7 @@ class RelayPreviewAdapter {
337
340
  this.recordDiscovery("join_ok", { endpoint: this.activeEndpoint, detail: reason });
338
341
  }
339
342
  async maybeRefreshJoin(reason) {
340
- if (!this.lastJoinAt || Date.now() - this.lastJoinAt > Math.max(10_000, this.pollIntervalMs * 4)) {
343
+ if (!this.lastJoinAt || Date.now() - this.lastJoinAt > Math.max(45_000, this.pollIntervalMs * 6)) {
341
344
  await this.joinRoom(reason);
342
345
  }
343
346
  }
@@ -355,11 +358,15 @@ class RelayPreviewAdapter {
355
358
  if (!endpoint)
356
359
  continue;
357
360
  try {
361
+ const controller = new AbortController();
362
+ const timeout = setTimeout(() => controller.abort(), this.requestTimeoutMs);
358
363
  const response = await fetch(`${endpoint}${path}`, {
359
364
  method,
360
365
  headers: method === "POST" ? { "content-type": "application/json" } : undefined,
361
366
  body: method === "POST" ? JSON.stringify(body) : undefined,
367
+ signal: controller.signal,
362
368
  });
369
+ clearTimeout(timeout);
363
370
  if (!response.ok) {
364
371
  throw new Error(`${method} ${path} failed (${response.status})`);
365
372
  }
@@ -380,5 +387,44 @@ class RelayPreviewAdapter {
380
387
  }
381
388
  throw new Error(errors.join(" | "));
382
389
  }
390
+ updatePeersFromList(values) {
391
+ const peerIds = values.map((value) => String(value || "").trim()).filter(Boolean);
392
+ if (!peerIds.includes(this.peerId)) {
393
+ void this.joinRoom("self_missing_from_peers").catch(() => { });
394
+ }
395
+ const now = Date.now();
396
+ const next = new Map();
397
+ for (const peerId of peerIds) {
398
+ if (peerId === this.peerId)
399
+ continue;
400
+ const existing = this.peers.get(peerId);
401
+ if (!existing) {
402
+ this.recordDiscovery("peer_joined", { peer_id: peerId });
403
+ }
404
+ next.set(peerId, {
405
+ peer_id: peerId,
406
+ status: "online",
407
+ first_seen_at: existing?.first_seen_at ?? now,
408
+ last_seen_at: now,
409
+ messages_seen: existing?.messages_seen ?? 0,
410
+ reconnect_attempts: existing?.reconnect_attempts ?? 0,
411
+ });
412
+ }
413
+ for (const peerId of this.peers.keys()) {
414
+ if (!next.has(peerId)) {
415
+ this.recordDiscovery("peer_removed", { peer_id: peerId });
416
+ }
417
+ }
418
+ this.peers = next;
419
+ }
420
+ scheduleNextPoll(delayMs) {
421
+ if (this.poller) {
422
+ clearTimeout(this.poller);
423
+ }
424
+ const jitterMs = Math.floor(Math.random() * 400);
425
+ this.poller = setTimeout(() => {
426
+ this.pollOnce().catch(() => { });
427
+ }, Math.max(1000, delayMs + jitterMs));
428
+ }
383
429
  }
384
430
  exports.RelayPreviewAdapter = RelayPreviewAdapter;
@@ -22,6 +22,8 @@ type RelayPreviewOptions = {
22
22
  pollIntervalMs?: number;
23
23
  maxFutureDriftMs?: number;
24
24
  maxPastDriftMs?: number;
25
+ requestTimeoutMs?: number;
26
+ peerRefreshIntervalMs?: number;
25
27
  };
26
28
 
27
29
  type RelayPeer = {
@@ -114,6 +116,7 @@ type RelayDiagnostics = {
114
116
  peers_refresh_attempted: number;
115
117
  peers_refresh_succeeded: number;
116
118
  publish_succeeded: number;
119
+ poll_skipped_inflight: number;
117
120
  };
118
121
  };
119
122
 
@@ -133,6 +136,8 @@ export class RelayPreviewAdapter implements NetworkAdapter {
133
136
  private readonly pollIntervalMs: number;
134
137
  private readonly maxFutureDriftMs: number;
135
138
  private readonly maxPastDriftMs: number;
139
+ private readonly requestTimeoutMs: number;
140
+ private readonly peerRefreshIntervalMs: number;
136
141
  private readonly envelopeCodec: MessageEnvelopeCodec;
137
142
  private readonly topicCodec: TopicCodec;
138
143
 
@@ -155,6 +160,8 @@ export class RelayPreviewAdapter implements NetworkAdapter {
155
160
  private lastPeerRefreshAt = 0;
156
161
  private lastErrorAt = 0;
157
162
  private lastError: string | null = null;
163
+ private pollInFlight = false;
164
+ private currentPollDelayMs = 0;
158
165
 
159
166
  private stats: RelayDiagnostics["stats"] = {
160
167
  publish_attempted: 0,
@@ -184,6 +191,7 @@ export class RelayPreviewAdapter implements NetworkAdapter {
184
191
  peers_refresh_attempted: 0,
185
192
  peers_refresh_succeeded: 0,
186
193
  publish_succeeded: 0,
194
+ poll_skipped_inflight: 0,
187
195
  };
188
196
 
189
197
  constructor(options: RelayPreviewOptions = {}) {
@@ -201,11 +209,14 @@ export class RelayPreviewAdapter implements NetworkAdapter {
201
209
  this.bootstrapHints = dedupe(options.bootstrapHints || []);
202
210
  this.bootstrapSources = dedupe(options.bootstrapSources || []);
203
211
  this.maxMessageBytes = options.maxMessageBytes ?? 64 * 1024;
204
- this.pollIntervalMs = options.pollIntervalMs ?? 2000;
212
+ this.pollIntervalMs = options.pollIntervalMs ?? 5000;
205
213
  this.maxFutureDriftMs = options.maxFutureDriftMs ?? 30_000;
206
214
  this.maxPastDriftMs = options.maxPastDriftMs ?? 120_000;
215
+ this.requestTimeoutMs = options.requestTimeoutMs ?? 4000;
216
+ this.peerRefreshIntervalMs = options.peerRefreshIntervalMs ?? 30_000;
207
217
  this.envelopeCodec = new JsonMessageEnvelopeCodec();
208
218
  this.topicCodec = new JsonTopicCodec();
219
+ this.currentPollDelayMs = this.pollIntervalMs;
209
220
  }
210
221
 
211
222
  async start(): Promise<void> {
@@ -215,9 +226,7 @@ export class RelayPreviewAdapter implements NetworkAdapter {
215
226
  this.started = true;
216
227
  await this.refreshPeers();
217
228
  await this.pollOnce();
218
- this.poller = setInterval(() => {
219
- this.pollOnce().catch(() => {});
220
- }, this.pollIntervalMs);
229
+ this.scheduleNextPoll(this.pollIntervalMs);
221
230
  this.recordDiscovery("signaling_connected", { endpoint: this.activeEndpoint });
222
231
  } catch (error) {
223
232
  this.stats.start_errors += 1;
@@ -228,7 +237,7 @@ export class RelayPreviewAdapter implements NetworkAdapter {
228
237
  async stop(): Promise<void> {
229
238
  if (!this.started) return;
230
239
  if (this.poller) {
231
- clearInterval(this.poller);
240
+ clearTimeout(this.poller);
232
241
  this.poller = null;
233
242
  }
234
243
  try {
@@ -325,17 +334,37 @@ export class RelayPreviewAdapter implements NetworkAdapter {
325
334
  }
326
335
 
327
336
  private async pollOnce(): Promise<void> {
337
+ if (this.pollInFlight) {
338
+ this.stats.poll_skipped_inflight += 1;
339
+ return;
340
+ }
341
+ this.pollInFlight = true;
328
342
  await this.maybeRefreshJoin("poll");
329
343
  this.stats.poll_attempted += 1;
330
- const payload = await this.get(`/relay/poll?room=${encodeURIComponent(this.room)}&peer_id=${encodeURIComponent(this.peerId)}`);
331
- this.lastPollAt = Date.now();
332
- this.stats.poll_succeeded += 1;
333
- const messages = Array.isArray(payload?.messages) ? payload.messages : [];
334
- for (const message of messages) {
335
- this.signalingMessagesReceivedTotal += 1;
336
- this.onEnvelope(message?.envelope);
344
+ try {
345
+ const payload = await this.get(`/relay/poll?room=${encodeURIComponent(this.room)}&peer_id=${encodeURIComponent(this.peerId)}`);
346
+ this.lastPollAt = Date.now();
347
+ this.stats.poll_succeeded += 1;
348
+ this.currentPollDelayMs = this.pollIntervalMs;
349
+ const messages = Array.isArray(payload?.messages) ? payload.messages : [];
350
+ for (const message of messages) {
351
+ this.signalingMessagesReceivedTotal += 1;
352
+ this.onEnvelope(message?.envelope);
353
+ }
354
+ if (Array.isArray(payload?.peers)) {
355
+ this.updatePeersFromList(payload.peers);
356
+ } else if (!this.lastPeerRefreshAt || Date.now() - this.lastPeerRefreshAt >= this.peerRefreshIntervalMs) {
357
+ await this.refreshPeers();
358
+ }
359
+ } catch (error) {
360
+ this.currentPollDelayMs = Math.min(15_000, Math.max(this.pollIntervalMs, this.currentPollDelayMs * 2));
361
+ throw error;
362
+ } finally {
363
+ this.pollInFlight = false;
364
+ if (this.started) {
365
+ this.scheduleNextPoll(this.currentPollDelayMs);
366
+ }
337
367
  }
338
- await this.refreshPeers();
339
368
  }
340
369
 
341
370
  private async refreshPeers(): Promise<void> {
@@ -343,33 +372,8 @@ export class RelayPreviewAdapter implements NetworkAdapter {
343
372
  const payload = await this.get(`/peers?room=${encodeURIComponent(this.room)}`);
344
373
  this.lastPeerRefreshAt = Date.now();
345
374
  this.stats.peers_refresh_succeeded += 1;
346
- const peerIds = Array.isArray(payload?.peers) ? payload.peers.map((value: unknown) => String(value || "").trim()).filter(Boolean) : [];
347
- if (!peerIds.includes(this.peerId)) {
348
- await this.joinRoom("self_missing_from_peers");
349
- }
350
- const now = Date.now();
351
- const next = new Map<string, RelayPeer>();
352
- for (const peerId of peerIds) {
353
- if (peerId === this.peerId) continue;
354
- const existing = this.peers.get(peerId);
355
- if (!existing) {
356
- this.recordDiscovery("peer_joined", { peer_id: peerId });
357
- }
358
- next.set(peerId, {
359
- peer_id: peerId,
360
- status: "online",
361
- first_seen_at: existing?.first_seen_at ?? now,
362
- last_seen_at: now,
363
- messages_seen: existing?.messages_seen ?? 0,
364
- reconnect_attempts: existing?.reconnect_attempts ?? 0,
365
- });
366
- }
367
- for (const peerId of this.peers.keys()) {
368
- if (!next.has(peerId)) {
369
- this.recordDiscovery("peer_removed", { peer_id: peerId });
370
- }
371
- }
372
- this.peers = next;
375
+ const peerIds = Array.isArray(payload?.peers) ? payload.peers : [];
376
+ this.updatePeersFromList(peerIds);
373
377
  }
374
378
 
375
379
  private onEnvelope(envelope: unknown): void {
@@ -457,7 +461,7 @@ export class RelayPreviewAdapter implements NetworkAdapter {
457
461
  }
458
462
 
459
463
  private async maybeRefreshJoin(reason: string): Promise<void> {
460
- if (!this.lastJoinAt || Date.now() - this.lastJoinAt > Math.max(10_000, this.pollIntervalMs * 4)) {
464
+ if (!this.lastJoinAt || Date.now() - this.lastJoinAt > Math.max(45_000, this.pollIntervalMs * 6)) {
461
465
  await this.joinRoom(reason);
462
466
  }
463
467
  }
@@ -477,11 +481,15 @@ export class RelayPreviewAdapter implements NetworkAdapter {
477
481
  const endpoint = this.signalingEndpoints[index]?.replace(/\/+$/, "");
478
482
  if (!endpoint) continue;
479
483
  try {
484
+ const controller = new AbortController();
485
+ const timeout = setTimeout(() => controller.abort(), this.requestTimeoutMs);
480
486
  const response = await fetch(`${endpoint}${path}`, {
481
487
  method,
482
488
  headers: method === "POST" ? { "content-type": "application/json" } : undefined,
483
489
  body: method === "POST" ? JSON.stringify(body) : undefined,
490
+ signal: controller.signal,
484
491
  });
492
+ clearTimeout(timeout);
485
493
  if (!response.ok) {
486
494
  throw new Error(`${method} ${path} failed (${response.status})`);
487
495
  }
@@ -501,4 +509,44 @@ export class RelayPreviewAdapter implements NetworkAdapter {
501
509
  }
502
510
  throw new Error(errors.join(" | "));
503
511
  }
512
+
513
+ private updatePeersFromList(values: unknown[]): void {
514
+ const peerIds = values.map((value) => String(value || "").trim()).filter(Boolean);
515
+ if (!peerIds.includes(this.peerId)) {
516
+ void this.joinRoom("self_missing_from_peers").catch(() => {});
517
+ }
518
+ const now = Date.now();
519
+ const next = new Map<string, RelayPeer>();
520
+ for (const peerId of peerIds) {
521
+ if (peerId === this.peerId) continue;
522
+ const existing = this.peers.get(peerId);
523
+ if (!existing) {
524
+ this.recordDiscovery("peer_joined", { peer_id: peerId });
525
+ }
526
+ next.set(peerId, {
527
+ peer_id: peerId,
528
+ status: "online",
529
+ first_seen_at: existing?.first_seen_at ?? now,
530
+ last_seen_at: now,
531
+ messages_seen: existing?.messages_seen ?? 0,
532
+ reconnect_attempts: existing?.reconnect_attempts ?? 0,
533
+ });
534
+ }
535
+ for (const peerId of this.peers.keys()) {
536
+ if (!next.has(peerId)) {
537
+ this.recordDiscovery("peer_removed", { peer_id: peerId });
538
+ }
539
+ }
540
+ this.peers = next;
541
+ }
542
+
543
+ private scheduleNextPoll(delayMs: number): void {
544
+ if (this.poller) {
545
+ clearTimeout(this.poller);
546
+ }
547
+ const jitterMs = Math.floor(Math.random() * 400);
548
+ this.poller = setTimeout(() => {
549
+ this.pollOnce().catch(() => {});
550
+ }, Math.max(1000, delayMs + jitterMs));
551
+ }
504
552
  }
@@ -5,6 +5,7 @@ import { randomUUID, createHash } from 'crypto';
5
5
  const port = Number(process.env.PORT || process.env.WEBRTC_SIGNALING_PORT || 4510);
6
6
  const PEER_STALE_MS = Number(process.env.WEBRTC_SIGNALING_PEER_STALE_MS || 120000);
7
7
  const SIGNAL_DEDUPE_WINDOW_MS = Number(process.env.WEBRTC_SIGNALING_DEDUPE_WINDOW_MS || 60000);
8
+ const TOUCH_WRITE_INTERVAL_MS = Number(process.env.WEBRTC_SIGNALING_TOUCH_WRITE_INTERVAL_MS || 30000);
8
9
 
9
10
  /** @type {Map<string, {peers: Map<string, {last_seen_at:number}>, queues: Map<string, any[]>, relay_queues: Map<string, any[]>, signal_fingerprints: Map<string, number>}>} */
10
11
  const rooms = new Map();
@@ -90,10 +91,13 @@ function cleanupRoom(roomId) {
90
91
  }
91
92
 
92
93
  function touchPeer(room, peerId) {
94
+ const ts = now();
95
+ const previous = room.peers.get(peerId)?.last_seen_at || 0;
96
+ const shouldWrite = !previous || ts - previous >= TOUCH_WRITE_INTERVAL_MS;
93
97
  if (!room.peers.has(peerId)) {
94
- room.peers.set(peerId, { last_seen_at: now() });
98
+ room.peers.set(peerId, { last_seen_at: ts });
95
99
  } else {
96
- room.peers.get(peerId).last_seen_at = now();
100
+ room.peers.get(peerId).last_seen_at = shouldWrite ? ts : previous;
97
101
  }
98
102
  if (!room.queues.has(peerId)) {
99
103
  room.queues.set(peerId, []);
@@ -101,6 +105,7 @@ function touchPeer(room, peerId) {
101
105
  if (!room.relay_queues.has(peerId)) {
102
106
  room.relay_queues.set(peerId, []);
103
107
  }
108
+ return shouldWrite;
104
109
  }
105
110
 
106
111
  function isValidSignalPayload(body) {
@@ -198,7 +203,7 @@ const server = http.createServer(async (req, res) => {
198
203
 
199
204
  const queue = room.queues.get(peerId) || [];
200
205
  room.queues.set(peerId, []);
201
- return json(res, 200, { ok: true, messages: queue });
206
+ return json(res, 200, { ok: true, messages: queue, peers: Array.from(room.peers.keys()) });
202
207
  }
203
208
 
204
209
  if (req.method === 'GET' && url.pathname === '/relay/poll') {
@@ -215,7 +220,7 @@ const server = http.createServer(async (req, res) => {
215
220
 
216
221
  const queue = room.relay_queues.get(peerId) || [];
217
222
  room.relay_queues.set(peerId, []);
218
- return json(res, 200, { ok: true, messages: queue });
223
+ return json(res, 200, { ok: true, messages: queue, peers: Array.from(room.peers.keys()) });
219
224
  }
220
225
 
221
226
  if (req.method === 'POST' && url.pathname === '/join') {