@silicaclaw/cli 1.0.0-beta.20 → 1.0.0-beta.21

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.
@@ -0,0 +1,425 @@
1
+ import { randomUUID } from "crypto";
2
+ import { NetworkAdapter } from "./types";
3
+ import {
4
+ MessageEnvelopeCodec,
5
+ NetworkMessageEnvelope,
6
+ validateNetworkMessageEnvelope,
7
+ } from "./abstractions/messageEnvelope";
8
+ import { TopicCodec } from "./abstractions/topicCodec";
9
+ import { JsonMessageEnvelopeCodec } from "./codec/jsonMessageEnvelopeCodec";
10
+ import { JsonTopicCodec } from "./codec/jsonTopicCodec";
11
+
12
+ type RelayPreviewOptions = {
13
+ peerId?: string;
14
+ namespace?: string;
15
+ signalingUrl?: string;
16
+ signalingUrls?: string[];
17
+ room?: string;
18
+ seedPeers?: string[];
19
+ bootstrapHints?: string[];
20
+ bootstrapSources?: string[];
21
+ maxMessageBytes?: number;
22
+ pollIntervalMs?: number;
23
+ maxFutureDriftMs?: number;
24
+ maxPastDriftMs?: number;
25
+ };
26
+
27
+ type RelayPeer = {
28
+ peer_id: string;
29
+ status: "online";
30
+ first_seen_at: number;
31
+ last_seen_at: number;
32
+ messages_seen: number;
33
+ reconnect_attempts: number;
34
+ };
35
+
36
+ type RelayDiagnostics = {
37
+ adapter: "relay-preview";
38
+ peer_id: string;
39
+ namespace: string;
40
+ room: string;
41
+ signaling_url: string;
42
+ signaling_endpoints: string[];
43
+ bootstrap_sources: string[];
44
+ seed_peers_count: number;
45
+ bootstrap_hints_count: number;
46
+ discovery_events_total: number;
47
+ last_discovery_event_at: number;
48
+ discovery_events: Array<{
49
+ id: string;
50
+ type: string;
51
+ at: number;
52
+ peer_id?: string;
53
+ endpoint?: string;
54
+ detail?: string;
55
+ }>;
56
+ signaling_messages_sent_total: number;
57
+ signaling_messages_received_total: number;
58
+ reconnect_attempts_total: number;
59
+ active_webrtc_peers: number;
60
+ components: {
61
+ transport: string;
62
+ discovery: string;
63
+ envelope_codec: string;
64
+ topic_codec: string;
65
+ };
66
+ limits: {
67
+ max_message_bytes: number;
68
+ max_future_drift_ms: number;
69
+ max_past_drift_ms: number;
70
+ };
71
+ config: {
72
+ started: boolean;
73
+ topic_handler_count: number;
74
+ poll_interval_ms: number;
75
+ };
76
+ peers: {
77
+ total: number;
78
+ online: number;
79
+ stale: number;
80
+ items: RelayPeer[];
81
+ };
82
+ stats: {
83
+ publish_attempted: number;
84
+ publish_sent: number;
85
+ received_total: number;
86
+ delivered_total: number;
87
+ dropped_malformed: number;
88
+ dropped_oversized: number;
89
+ dropped_namespace_mismatch: number;
90
+ dropped_timestamp_future_drift: number;
91
+ dropped_timestamp_past_drift: number;
92
+ dropped_decode_failed: number;
93
+ dropped_self: number;
94
+ dropped_topic_decode_error: number;
95
+ dropped_handler_error: number;
96
+ signaling_errors: number;
97
+ invalid_signaling_payload_total: number;
98
+ duplicate_sdp_total: number;
99
+ duplicate_ice_total: number;
100
+ start_errors: number;
101
+ stop_errors: number;
102
+ received_validated: number;
103
+ };
104
+ };
105
+
106
+ function dedupe(values: string[]): string[] {
107
+ return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)));
108
+ }
109
+
110
+ export class RelayPreviewAdapter implements NetworkAdapter {
111
+ private readonly peerId: string;
112
+ private readonly namespace: string;
113
+ private readonly signalingEndpoints: string[];
114
+ private readonly room: string;
115
+ private readonly seedPeers: string[];
116
+ private readonly bootstrapHints: string[];
117
+ private readonly bootstrapSources: string[];
118
+ private readonly maxMessageBytes: number;
119
+ private readonly pollIntervalMs: number;
120
+ private readonly maxFutureDriftMs: number;
121
+ private readonly maxPastDriftMs: number;
122
+ private readonly envelopeCodec: MessageEnvelopeCodec;
123
+ private readonly topicCodec: TopicCodec;
124
+
125
+ private started = false;
126
+ private poller: NodeJS.Timeout | null = null;
127
+ private handlers = new Map<string, Set<(data: any) => void>>();
128
+ private peers = new Map<string, RelayPeer>();
129
+ private seenMessageIds = new Set<string>();
130
+ private activeEndpoint = "";
131
+ private discoveryEvents: RelayDiagnostics["discovery_events"] = [];
132
+ private discoveryEventsTotal = 0;
133
+ private lastDiscoveryEventAt = 0;
134
+ private signalingMessagesSentTotal = 0;
135
+ private signalingMessagesReceivedTotal = 0;
136
+ private reconnectAttemptsTotal = 0;
137
+
138
+ private stats: RelayDiagnostics["stats"] = {
139
+ publish_attempted: 0,
140
+ publish_sent: 0,
141
+ received_total: 0,
142
+ delivered_total: 0,
143
+ dropped_malformed: 0,
144
+ dropped_oversized: 0,
145
+ dropped_namespace_mismatch: 0,
146
+ dropped_timestamp_future_drift: 0,
147
+ dropped_timestamp_past_drift: 0,
148
+ dropped_decode_failed: 0,
149
+ dropped_self: 0,
150
+ dropped_topic_decode_error: 0,
151
+ dropped_handler_error: 0,
152
+ signaling_errors: 0,
153
+ invalid_signaling_payload_total: 0,
154
+ duplicate_sdp_total: 0,
155
+ duplicate_ice_total: 0,
156
+ start_errors: 0,
157
+ stop_errors: 0,
158
+ received_validated: 0,
159
+ };
160
+
161
+ constructor(options: RelayPreviewOptions = {}) {
162
+ this.peerId = options.peerId ?? `peer-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
163
+ this.namespace = String(options.namespace || "silicaclaw.preview").trim() || "silicaclaw.preview";
164
+ this.signalingEndpoints = dedupe(
165
+ (options.signalingUrls && options.signalingUrls.length > 0
166
+ ? options.signalingUrls
167
+ : [options.signalingUrl || "http://localhost:4510"])
168
+ );
169
+ this.activeEndpoint = this.signalingEndpoints[0] || "http://localhost:4510";
170
+ this.room = String(options.room || "silicaclaw-global-preview").trim() || "silicaclaw-global-preview";
171
+ this.seedPeers = dedupe(options.seedPeers || []);
172
+ this.bootstrapHints = dedupe(options.bootstrapHints || []);
173
+ this.bootstrapSources = dedupe(options.bootstrapSources || []);
174
+ this.maxMessageBytes = options.maxMessageBytes ?? 64 * 1024;
175
+ this.pollIntervalMs = options.pollIntervalMs ?? 2000;
176
+ this.maxFutureDriftMs = options.maxFutureDriftMs ?? 30_000;
177
+ this.maxPastDriftMs = options.maxPastDriftMs ?? 120_000;
178
+ this.envelopeCodec = new JsonMessageEnvelopeCodec();
179
+ this.topicCodec = new JsonTopicCodec();
180
+ }
181
+
182
+ async start(): Promise<void> {
183
+ if (this.started) return;
184
+ try {
185
+ await this.post("/join", { room: this.room, peer_id: this.peerId });
186
+ this.started = true;
187
+ await this.refreshPeers();
188
+ await this.pollOnce();
189
+ this.poller = setInterval(() => {
190
+ this.pollOnce().catch(() => {});
191
+ }, this.pollIntervalMs);
192
+ this.recordDiscovery("signaling_connected", { endpoint: this.activeEndpoint });
193
+ } catch (error) {
194
+ this.stats.start_errors += 1;
195
+ throw new Error(`Relay start failed: ${error instanceof Error ? error.message : String(error)}`);
196
+ }
197
+ }
198
+
199
+ async stop(): Promise<void> {
200
+ if (!this.started) return;
201
+ if (this.poller) {
202
+ clearInterval(this.poller);
203
+ this.poller = null;
204
+ }
205
+ try {
206
+ await this.post("/leave", { room: this.room, peer_id: this.peerId });
207
+ } catch {
208
+ this.stats.stop_errors += 1;
209
+ }
210
+ this.started = false;
211
+ this.recordDiscovery("signaling_disconnected", { endpoint: this.activeEndpoint });
212
+ }
213
+
214
+ async publish(topic: string, data: any): Promise<void> {
215
+ if (!this.started) return;
216
+ this.stats.publish_attempted += 1;
217
+ const envelope: NetworkMessageEnvelope = {
218
+ version: 1,
219
+ message_id: randomUUID(),
220
+ topic: `${this.namespace}:${topic}`,
221
+ source_peer_id: this.peerId,
222
+ timestamp: Date.now(),
223
+ payload: this.topicCodec.encode(topic, data),
224
+ };
225
+ const raw = this.envelopeCodec.encode(envelope);
226
+ if (raw.length > this.maxMessageBytes) {
227
+ this.stats.dropped_oversized += 1;
228
+ return;
229
+ }
230
+ await this.post("/relay/publish", { room: this.room, peer_id: this.peerId, envelope });
231
+ this.stats.publish_sent += 1;
232
+ this.signalingMessagesSentTotal += 1;
233
+ }
234
+
235
+ subscribe(topic: string, handler: (data: any) => void): void {
236
+ const key = `${this.namespace}:${topic}`;
237
+ if (!this.handlers.has(key)) {
238
+ this.handlers.set(key, new Set());
239
+ }
240
+ this.handlers.get(key)?.add(handler);
241
+ }
242
+
243
+ getDiagnostics(): RelayDiagnostics {
244
+ const peerItems = Array.from(this.peers.values()).sort((a, b) => b.last_seen_at - a.last_seen_at);
245
+ return {
246
+ adapter: "relay-preview",
247
+ peer_id: this.peerId,
248
+ namespace: this.namespace,
249
+ room: this.room,
250
+ signaling_url: this.activeEndpoint,
251
+ signaling_endpoints: this.signalingEndpoints,
252
+ bootstrap_sources: this.bootstrapSources,
253
+ seed_peers_count: this.seedPeers.length,
254
+ bootstrap_hints_count: this.bootstrapHints.length,
255
+ discovery_events_total: this.discoveryEventsTotal,
256
+ last_discovery_event_at: this.lastDiscoveryEventAt,
257
+ discovery_events: this.discoveryEvents,
258
+ signaling_messages_sent_total: this.signalingMessagesSentTotal,
259
+ signaling_messages_received_total: this.signalingMessagesReceivedTotal,
260
+ reconnect_attempts_total: this.reconnectAttemptsTotal,
261
+ active_webrtc_peers: peerItems.length,
262
+ components: {
263
+ transport: "HttpRelayTransport",
264
+ discovery: "RelayRoomPeerList",
265
+ envelope_codec: this.envelopeCodec.constructor.name,
266
+ topic_codec: this.topicCodec.constructor.name,
267
+ },
268
+ limits: {
269
+ max_message_bytes: this.maxMessageBytes,
270
+ max_future_drift_ms: this.maxFutureDriftMs,
271
+ max_past_drift_ms: this.maxPastDriftMs,
272
+ },
273
+ config: {
274
+ started: this.started,
275
+ topic_handler_count: this.handlers.size,
276
+ poll_interval_ms: this.pollIntervalMs,
277
+ },
278
+ peers: {
279
+ total: peerItems.length,
280
+ online: peerItems.length,
281
+ stale: 0,
282
+ items: peerItems,
283
+ },
284
+ stats: { ...this.stats },
285
+ };
286
+ }
287
+
288
+ private async pollOnce(): Promise<void> {
289
+ const payload = await this.get(`/relay/poll?room=${encodeURIComponent(this.room)}&peer_id=${encodeURIComponent(this.peerId)}`);
290
+ const messages = Array.isArray(payload?.messages) ? payload.messages : [];
291
+ for (const message of messages) {
292
+ this.signalingMessagesReceivedTotal += 1;
293
+ this.onEnvelope(message?.envelope);
294
+ }
295
+ await this.refreshPeers();
296
+ }
297
+
298
+ private async refreshPeers(): Promise<void> {
299
+ const payload = await this.get(`/peers?room=${encodeURIComponent(this.room)}`);
300
+ const peerIds = Array.isArray(payload?.peers) ? payload.peers.map((value: unknown) => String(value || "").trim()).filter(Boolean) : [];
301
+ const now = Date.now();
302
+ const next = new Map<string, RelayPeer>();
303
+ for (const peerId of peerIds) {
304
+ if (peerId === this.peerId) continue;
305
+ const existing = this.peers.get(peerId);
306
+ if (!existing) {
307
+ this.recordDiscovery("peer_joined", { peer_id: peerId });
308
+ }
309
+ next.set(peerId, {
310
+ peer_id: peerId,
311
+ status: "online",
312
+ first_seen_at: existing?.first_seen_at ?? now,
313
+ last_seen_at: now,
314
+ messages_seen: existing?.messages_seen ?? 0,
315
+ reconnect_attempts: existing?.reconnect_attempts ?? 0,
316
+ });
317
+ }
318
+ for (const peerId of this.peers.keys()) {
319
+ if (!next.has(peerId)) {
320
+ this.recordDiscovery("peer_removed", { peer_id: peerId });
321
+ }
322
+ }
323
+ this.peers = next;
324
+ }
325
+
326
+ private onEnvelope(envelope: unknown): void {
327
+ this.stats.received_total += 1;
328
+ const validated = validateNetworkMessageEnvelope(envelope, {
329
+ max_future_drift_ms: this.maxFutureDriftMs,
330
+ max_past_drift_ms: this.maxPastDriftMs,
331
+ });
332
+ if (!validated.ok || !validated.envelope) {
333
+ if (validated.reason === "timestamp_future_drift") {
334
+ this.stats.dropped_timestamp_future_drift += 1;
335
+ } else if (validated.reason === "timestamp_past_drift") {
336
+ this.stats.dropped_timestamp_past_drift += 1;
337
+ } else {
338
+ this.stats.dropped_malformed += 1;
339
+ }
340
+ return;
341
+ }
342
+ const message = validated.envelope;
343
+ if (message.source_peer_id === this.peerId) {
344
+ this.stats.dropped_self += 1;
345
+ return;
346
+ }
347
+ if (!message.topic.startsWith(`${this.namespace}:`)) {
348
+ this.stats.dropped_namespace_mismatch += 1;
349
+ return;
350
+ }
351
+ if (this.seenMessageIds.has(message.message_id)) {
352
+ return;
353
+ }
354
+ this.seenMessageIds.add(message.message_id);
355
+ if (this.seenMessageIds.size > 10000) {
356
+ const first = this.seenMessageIds.values().next().value;
357
+ if (first) this.seenMessageIds.delete(first);
358
+ }
359
+ this.stats.received_validated += 1;
360
+
361
+ const topicKey = message.topic;
362
+ const topic = topicKey.slice(this.namespace.length + 1);
363
+ const handlers = this.handlers.get(topicKey);
364
+ if (!handlers || handlers.size === 0) return;
365
+
366
+ const peer = this.peers.get(message.source_peer_id);
367
+ if (peer) {
368
+ peer.last_seen_at = Date.now();
369
+ peer.messages_seen += 1;
370
+ }
371
+
372
+ let payload: unknown;
373
+ try {
374
+ payload = this.topicCodec.decode(topic, message.payload);
375
+ } catch {
376
+ this.stats.dropped_topic_decode_error += 1;
377
+ return;
378
+ }
379
+ for (const handler of handlers) {
380
+ try {
381
+ handler(payload);
382
+ this.stats.delivered_total += 1;
383
+ } catch {
384
+ this.stats.dropped_handler_error += 1;
385
+ }
386
+ }
387
+ }
388
+
389
+ private recordDiscovery(type: string, extra: { peer_id?: string; endpoint?: string; detail?: string } = {}): void {
390
+ const event = {
391
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
392
+ type,
393
+ at: Date.now(),
394
+ ...extra,
395
+ };
396
+ this.discoveryEvents.unshift(event);
397
+ this.discoveryEvents = this.discoveryEvents.slice(0, 200);
398
+ this.discoveryEventsTotal += 1;
399
+ this.lastDiscoveryEventAt = event.at;
400
+ }
401
+
402
+ private async get(path: string): Promise<any> {
403
+ const endpoint = this.activeEndpoint.replace(/\/+$/, "");
404
+ const response = await fetch(`${endpoint}${path}`);
405
+ if (!response.ok) {
406
+ this.stats.signaling_errors += 1;
407
+ throw new Error(`Relay GET failed (${response.status})`);
408
+ }
409
+ return response.json();
410
+ }
411
+
412
+ private async post(path: string, body: any): Promise<any> {
413
+ const endpoint = this.activeEndpoint.replace(/\/+$/, "");
414
+ const response = await fetch(`${endpoint}${path}`, {
415
+ method: "POST",
416
+ headers: { "content-type": "application/json" },
417
+ body: JSON.stringify(body),
418
+ });
419
+ if (!response.ok) {
420
+ this.stats.signaling_errors += 1;
421
+ throw new Error(`Relay POST failed (${response.status})`);
422
+ }
423
+ return response.json();
424
+ }
425
+ }
@@ -14,13 +14,13 @@ function emptyRuntime() {
14
14
  resolved_identity: null,
15
15
  resolved_profile: null,
16
16
  resolved_network: {
17
- mode: "lan",
18
- adapter: "real-preview",
17
+ mode: "global-preview",
18
+ adapter: "relay-preview",
19
19
  namespace: "silicaclaw.preview",
20
20
  port: null,
21
21
  signaling_url: "http://localhost:4510",
22
22
  signaling_urls: [],
23
- room: "silicaclaw-room",
23
+ room: "silicaclaw-global-preview",
24
24
  seed_peers: [],
25
25
  bootstrap_hints: [],
26
26
  bootstrap_sources: [],
@@ -13,13 +13,13 @@ function emptyRuntime(): SocialRuntimeConfig {
13
13
  resolved_identity: null,
14
14
  resolved_profile: null,
15
15
  resolved_network: {
16
- mode: "lan",
17
- adapter: "real-preview",
16
+ mode: "global-preview",
17
+ adapter: "relay-preview",
18
18
  namespace: "silicaclaw.preview",
19
19
  port: null,
20
20
  signaling_url: "http://localhost:4510",
21
21
  signaling_urls: [],
22
- room: "silicaclaw-room",
22
+ room: "silicaclaw-global-preview",
23
23
  seed_peers: [],
24
24
  bootstrap_hints: [],
25
25
  bootstrap_sources: [],
@@ -5,7 +5,7 @@ INVOKE_PWD="${INIT_CWD:-$PWD}"
5
5
  ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
6
6
  WORK_DIR="$ROOT_DIR"
7
7
  IS_NPX_MODE=0
8
- DEFAULT_MODE_PICK="${QUICKSTART_DEFAULT_MODE:-1}"
8
+ DEFAULT_MODE_PICK="${QUICKSTART_DEFAULT_MODE:-3}"
9
9
  CONNECT_MODE="${QUICKSTART_CONNECT_MODE:-0}"
10
10
 
11
11
  case "$DEFAULT_MODE_PICK" in
@@ -333,9 +333,9 @@ fi
333
333
  title "选择网络模式"
334
334
  echo "1) local 单机预览(最快)"
335
335
  echo "2) lan 局域网预览(A/B 双机)"
336
- echo "3) global-preview 非局域网预览(WebRTC)"
337
- echo "提示: 不确定就直接回车(默认 local)。"
338
- echo "提示: 只有在你已有可达 signaling 地址时,才选择 3。"
336
+ echo "3) global-preview 互联网预览(Relay,推荐)"
337
+ echo "提示: 不确定就直接回车(默认 global-preview)。"
338
+ echo "提示: 互联网场景需要一个所有节点都可访问的 relay/signaling 地址。"
339
339
  if [ "$CONNECT_MODE" = "1" ]; then
340
340
  MODE_PICK="3"
341
341
  echo "connect 模式:已自动选择 global-preview。"
@@ -347,7 +347,7 @@ fi
347
347
  NETWORK_MODE="local"
348
348
  NETWORK_ADAPTER="local-event-bus"
349
349
  WEBRTC_SIGNALING_URL_VALUE=""
350
- WEBRTC_ROOM_VALUE="silicaclaw-demo"
350
+ WEBRTC_ROOM_VALUE="silicaclaw-global-preview"
351
351
  AUTO_START_SIGNALING=0
352
352
 
353
353
  case "$MODE_PICK" in
@@ -357,7 +357,7 @@ case "$MODE_PICK" in
357
357
  ;;
358
358
  3)
359
359
  NETWORK_MODE="global-preview"
360
- NETWORK_ADAPTER="webrtc-preview"
360
+ NETWORK_ADAPTER="relay-preview"
361
361
  PUBLIC_IP="$(detect_public_ip)"
362
362
  SIGNALING_DEFAULT="${WEBRTC_SIGNALING_URL:-http://localhost:4510}"
363
363
  if [ -n "$PUBLIC_IP" ]; then
@@ -374,7 +374,7 @@ case "$MODE_PICK" in
374
374
  read -r -p "请输入 signaling URL(默认 ${SIGNALING_DEFAULT}): " WEBRTC_SIGNALING_URL_INPUT || true
375
375
  WEBRTC_SIGNALING_URL_VALUE="${WEBRTC_SIGNALING_URL_INPUT:-$SIGNALING_DEFAULT}"
376
376
  if [ -z "${WEBRTC_SIGNALING_URL_VALUE:-}" ]; then
377
- echo "global-preview 必须提供 signaling URL"
377
+ echo "global-preview 必须提供公网可达的 signaling URL"
378
378
  exit 1
379
379
  fi
380
380
 
@@ -389,8 +389,8 @@ case "$MODE_PICK" in
389
389
  echo "将尝试以 PORT=$SIGNALING_PORT_VALUE 后台启动 signaling server"
390
390
  fi
391
391
 
392
- read -r -p "请输入 room(默认 silicaclaw-demo): " WEBRTC_ROOM_VALUE_INPUT || true
393
- WEBRTC_ROOM_VALUE="${WEBRTC_ROOM_VALUE_INPUT:-silicaclaw-demo}"
392
+ read -r -p "请输入 room(默认 silicaclaw-global-preview): " WEBRTC_ROOM_VALUE_INPUT || true
393
+ WEBRTC_ROOM_VALUE="${WEBRTC_ROOM_VALUE_INPUT:-silicaclaw-global-preview}"
394
394
  ;;
395
395
  *)
396
396
  NETWORK_MODE="local"
@@ -101,12 +101,12 @@ function showUpdateGuide(current, latest, beta) {
101
101
  }
102
102
  console.log("");
103
103
  console.log("Quick next commands:");
104
- console.log("1) Start local gateway");
105
- console.log(" silicaclaw start --mode=local");
104
+ console.log("1) Start internet gateway");
105
+ console.log(" silicaclaw start --mode=global-preview --signaling-url=https://your-relay.example");
106
106
  console.log("2) Check gateway status");
107
107
  console.log(" silicaclaw status");
108
108
  console.log("3) npx one-shot (no alias/global install)");
109
- console.log(" npx -y @silicaclaw/cli@beta start --mode=local");
109
+ console.log(" npx -y @silicaclaw/cli@beta start --mode=global-preview --signaling-url=https://your-relay.example");
110
110
  console.log("");
111
111
 
112
112
  const writableGlobal = canWriteGlobalPrefix();
@@ -128,7 +128,7 @@ function parseMode(raw) {
128
128
 
129
129
  function adapterForMode(mode) {
130
130
  if (mode === "lan") return "real-preview";
131
- if (mode === "global-preview") return "webrtc-preview";
131
+ if (mode === "global-preview") return "relay-preview";
132
132
  return "local-event-bus";
133
133
  }
134
134
 
@@ -185,7 +185,8 @@ Usage:
185
185
  Notes:
186
186
  - Default app dir: current directory; fallback: ~/silicaclaw
187
187
  - State dir: .silicaclaw/gateway
188
- - global-preview + localhost signaling URL will auto-start signaling server
188
+ - global-preview is internet-first and expects a publicly reachable relay/signaling URL
189
+ - global-preview + localhost signaling URL will auto-start signaling server for single-machine testing
189
190
  `.trim());
190
191
  }
191
192
 
@@ -309,7 +310,7 @@ async function stopAll() {
309
310
  function startAll() {
310
311
  ensureStateDir();
311
312
 
312
- const mode = parseMode(parseFlag("mode", process.env.NETWORK_MODE || "local"));
313
+ const mode = parseMode(parseFlag("mode", process.env.NETWORK_MODE || "global-preview"));
313
314
  const adapter = adapterForMode(mode);
314
315
  const signalingUrl = parseFlag("signaling-url", process.env.WEBRTC_SIGNALING_URL || "http://localhost:4510");
315
316
  const room = parseFlag("room", process.env.WEBRTC_ROOM || "silicaclaw-demo");
@@ -6,7 +6,7 @@ const port = Number(process.env.PORT || process.env.WEBRTC_SIGNALING_PORT || 451
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
8
 
9
- /** @type {Map<string, {peers: Map<string, {last_seen_at:number}>, queues: Map<string, any[]>, signal_fingerprints: Map<string, number>}>} */
9
+ /** @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
10
  const rooms = new Map();
11
11
 
12
12
  const counters = {
@@ -24,6 +24,7 @@ function getRoom(roomId) {
24
24
  rooms.set(id, {
25
25
  peers: new Map(),
26
26
  queues: new Map(),
27
+ relay_queues: new Map(),
27
28
  signal_fingerprints: new Map(),
28
29
  });
29
30
  }
@@ -73,6 +74,7 @@ function cleanupRoom(roomId) {
73
74
  if (peer.last_seen_at < threshold) {
74
75
  room.peers.delete(peerId);
75
76
  room.queues.delete(peerId);
77
+ room.relay_queues.delete(peerId);
76
78
  counters.stale_peers_cleaned_total += 1;
77
79
  }
78
80
  }
@@ -96,6 +98,9 @@ function touchPeer(room, peerId) {
96
98
  if (!room.queues.has(peerId)) {
97
99
  room.queues.set(peerId, []);
98
100
  }
101
+ if (!room.relay_queues.has(peerId)) {
102
+ room.relay_queues.set(peerId, []);
103
+ }
99
104
  }
100
105
 
101
106
  function isValidSignalPayload(body) {
@@ -168,6 +173,23 @@ const server = http.createServer(async (req, res) => {
168
173
  return json(res, 200, { ok: true, messages: queue });
169
174
  }
170
175
 
176
+ if (req.method === 'GET' && url.pathname === '/relay/poll') {
177
+ const roomId = String(url.searchParams.get('room') || 'silicaclaw-room');
178
+ const peerId = String(url.searchParams.get('peer_id') || '');
179
+ if (!peerId) {
180
+ counters.invalid_payload_total += 1;
181
+ return json(res, 400, { ok: false, error: 'missing_peer_id' });
182
+ }
183
+
184
+ const room = getRoom(roomId);
185
+ touchPeer(room, peerId);
186
+ cleanupRoom(roomId);
187
+
188
+ const queue = room.relay_queues.get(peerId) || [];
189
+ room.relay_queues.set(peerId, []);
190
+ return json(res, 200, { ok: true, messages: queue });
191
+ }
192
+
171
193
  if (req.method === 'POST' && url.pathname === '/join') {
172
194
  const body = await parseBody(req);
173
195
  const roomId = String(body.room || 'silicaclaw-room');
@@ -193,6 +215,7 @@ const server = http.createServer(async (req, res) => {
193
215
  if (peerId) {
194
216
  room.peers.delete(peerId);
195
217
  room.queues.delete(peerId);
218
+ room.relay_queues.delete(peerId);
196
219
  counters.leave_total += 1;
197
220
  } else {
198
221
  counters.invalid_payload_total += 1;
@@ -240,6 +263,34 @@ const server = http.createServer(async (req, res) => {
240
263
  return json(res, 200, { ok: true });
241
264
  }
242
265
 
266
+ if (req.method === 'POST' && url.pathname === '/relay/publish') {
267
+ const body = await parseBody(req);
268
+ const roomId = String(body.room || 'silicaclaw-room');
269
+ const peerId = String(body.peer_id || '');
270
+ const envelope = body.envelope;
271
+ if (!peerId || typeof envelope !== 'object' || envelope === null) {
272
+ counters.invalid_payload_total += 1;
273
+ return json(res, 400, { ok: false, error: 'invalid_relay_payload' });
274
+ }
275
+
276
+ const room = getRoom(roomId);
277
+ touchPeer(room, peerId);
278
+
279
+ for (const targetPeerId of room.peers.keys()) {
280
+ if (targetPeerId === peerId) continue;
281
+ if (!room.relay_queues.has(targetPeerId)) room.relay_queues.set(targetPeerId, []);
282
+ room.relay_queues.get(targetPeerId).push({
283
+ id: String(body.id || randomUUID()),
284
+ room: roomId,
285
+ from_peer_id: peerId,
286
+ envelope,
287
+ at: now(),
288
+ });
289
+ }
290
+
291
+ return json(res, 200, { ok: true, delivered_to: Math.max(0, room.peers.size - 1) });
292
+ }
293
+
243
294
  return json(res, 404, { ok: false, error: 'not_found' });
244
295
  });
245
296