@silicaclaw/cli 1.0.0-beta.2 → 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.
Files changed (79) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/INSTALL.md +36 -0
  3. package/README.md +40 -0
  4. package/apps/local-console/public/index.html +81 -63
  5. package/apps/local-console/src/server.ts +41 -21
  6. package/docs/CLOUDFLARE_RELAY.md +61 -0
  7. package/package.json +6 -1
  8. package/packages/core/dist/crypto.d.ts +6 -0
  9. package/packages/core/dist/crypto.js +50 -0
  10. package/packages/core/dist/directory.d.ts +17 -0
  11. package/packages/core/dist/directory.js +145 -0
  12. package/packages/core/dist/identity.d.ts +2 -0
  13. package/packages/core/dist/identity.js +18 -0
  14. package/packages/core/dist/index.d.ts +11 -0
  15. package/packages/core/dist/index.js +27 -0
  16. package/packages/core/dist/indexing.d.ts +6 -0
  17. package/packages/core/dist/indexing.js +43 -0
  18. package/packages/core/dist/presence.d.ts +4 -0
  19. package/packages/core/dist/presence.js +23 -0
  20. package/packages/core/dist/profile.d.ts +4 -0
  21. package/packages/core/dist/profile.js +39 -0
  22. package/packages/core/dist/publicProfileSummary.d.ts +70 -0
  23. package/packages/core/dist/publicProfileSummary.js +103 -0
  24. package/packages/core/dist/socialConfig.d.ts +99 -0
  25. package/packages/core/dist/socialConfig.js +288 -0
  26. package/packages/core/dist/socialResolver.d.ts +46 -0
  27. package/packages/core/dist/socialResolver.js +237 -0
  28. package/packages/core/dist/socialTemplate.d.ts +2 -0
  29. package/packages/core/dist/socialTemplate.js +88 -0
  30. package/packages/core/dist/types.d.ts +37 -0
  31. package/packages/core/dist/types.js +2 -0
  32. package/packages/core/src/socialConfig.ts +7 -6
  33. package/packages/core/src/socialResolver.ts +17 -5
  34. package/packages/network/dist/abstractions/messageEnvelope.d.ts +28 -0
  35. package/packages/network/dist/abstractions/messageEnvelope.js +36 -0
  36. package/packages/network/dist/abstractions/peerDiscovery.d.ts +43 -0
  37. package/packages/network/dist/abstractions/peerDiscovery.js +2 -0
  38. package/packages/network/dist/abstractions/topicCodec.d.ts +4 -0
  39. package/packages/network/dist/abstractions/topicCodec.js +2 -0
  40. package/packages/network/dist/abstractions/transport.d.ts +36 -0
  41. package/packages/network/dist/abstractions/transport.js +2 -0
  42. package/packages/network/dist/codec/jsonMessageEnvelopeCodec.d.ts +5 -0
  43. package/packages/network/dist/codec/jsonMessageEnvelopeCodec.js +24 -0
  44. package/packages/network/dist/codec/jsonTopicCodec.d.ts +5 -0
  45. package/packages/network/dist/codec/jsonTopicCodec.js +12 -0
  46. package/packages/network/dist/discovery/heartbeatPeerDiscovery.d.ts +28 -0
  47. package/packages/network/dist/discovery/heartbeatPeerDiscovery.js +144 -0
  48. package/packages/network/dist/index.d.ts +14 -0
  49. package/packages/network/dist/index.js +30 -0
  50. package/packages/network/dist/localEventBus.d.ts +9 -0
  51. package/packages/network/dist/localEventBus.js +47 -0
  52. package/packages/network/dist/mock.d.ts +8 -0
  53. package/packages/network/dist/mock.js +24 -0
  54. package/packages/network/dist/realPreview.d.ts +105 -0
  55. package/packages/network/dist/realPreview.js +327 -0
  56. package/packages/network/dist/relayPreview.d.ts +133 -0
  57. package/packages/network/dist/relayPreview.js +320 -0
  58. package/packages/network/dist/transport/udpLanBroadcastTransport.d.ts +23 -0
  59. package/packages/network/dist/transport/udpLanBroadcastTransport.js +153 -0
  60. package/packages/network/dist/types.d.ts +6 -0
  61. package/packages/network/dist/types.js +2 -0
  62. package/packages/network/dist/webrtcPreview.d.ts +163 -0
  63. package/packages/network/dist/webrtcPreview.js +844 -0
  64. package/packages/network/src/index.ts +1 -0
  65. package/packages/network/src/relayPreview.ts +425 -0
  66. package/packages/storage/dist/index.d.ts +3 -0
  67. package/packages/storage/dist/index.js +19 -0
  68. package/packages/storage/dist/jsonRepo.d.ts +7 -0
  69. package/packages/storage/dist/jsonRepo.js +29 -0
  70. package/packages/storage/dist/repos.d.ts +21 -0
  71. package/packages/storage/dist/repos.js +41 -0
  72. package/packages/storage/dist/socialRuntimeRepo.d.ts +5 -0
  73. package/packages/storage/dist/socialRuntimeRepo.js +52 -0
  74. package/packages/storage/src/socialRuntimeRepo.ts +3 -3
  75. package/packages/storage/tsconfig.json +6 -1
  76. package/scripts/quickstart.sh +286 -20
  77. package/scripts/silicaclaw-cli.mjs +271 -1
  78. package/scripts/silicaclaw-gateway.mjs +411 -0
  79. package/scripts/webrtc-signaling-server.mjs +52 -1
@@ -0,0 +1,411 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn, spawnSync } from "node:child_process";
4
+ import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = dirname(__filename);
11
+ const ROOT_DIR = resolve(__dirname, "..");
12
+
13
+ const argv = process.argv.slice(2);
14
+ const cmd = String(argv[0] || "help").toLowerCase();
15
+
16
+ function readJson(file) {
17
+ try {
18
+ return JSON.parse(String(readFileSync(file, "utf8")));
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ function isSilicaClawDir(dir) {
25
+ const pkgPath = join(dir, "package.json");
26
+ if (!existsSync(pkgPath)) return false;
27
+ const pkg = readJson(pkgPath);
28
+ if (!pkg || typeof pkg !== "object") return false;
29
+ const name = String(pkg.name || "");
30
+ if (name === "@silicaclaw/cli" || name === "silicaclaw") return true;
31
+ const scripts = pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
32
+ return Boolean(scripts.gateway || scripts["local-console"] || scripts["public-explorer"]);
33
+ }
34
+
35
+ function parseFlag(name, fallback = "") {
36
+ const prefix = `--${name}=`;
37
+ for (const item of argv) {
38
+ if (item.startsWith(prefix)) return item.slice(prefix.length);
39
+ }
40
+ return fallback;
41
+ }
42
+
43
+ function hasFlag(name) {
44
+ return argv.includes(`--${name}`);
45
+ }
46
+
47
+ function detectAppDir() {
48
+ const envDir = process.env.SILICACLAW_APP_DIR;
49
+ if (envDir && isSilicaClawDir(envDir)) {
50
+ return resolve(envDir);
51
+ }
52
+
53
+ const cwd = process.cwd();
54
+ if (isSilicaClawDir(cwd)) {
55
+ return resolve(cwd);
56
+ }
57
+
58
+ const homeCandidate = join(homedir(), "silicaclaw");
59
+ if (isSilicaClawDir(homeCandidate)) {
60
+ return resolve(homeCandidate);
61
+ }
62
+
63
+ return ROOT_DIR;
64
+ }
65
+
66
+ const APP_DIR = detectAppDir();
67
+ const STATE_DIR = join(APP_DIR, ".silicaclaw", "gateway");
68
+ const CONSOLE_PID_FILE = join(STATE_DIR, "local-console.pid");
69
+ const CONSOLE_LOG_FILE = join(STATE_DIR, "local-console.log");
70
+ const SIGNALING_PID_FILE = join(STATE_DIR, "signaling.pid");
71
+ const SIGNALING_LOG_FILE = join(STATE_DIR, "signaling.log");
72
+ const STATE_FILE = join(STATE_DIR, "state.json");
73
+
74
+ function ensureStateDir() {
75
+ mkdirSync(STATE_DIR, { recursive: true });
76
+ }
77
+
78
+ function readPid(file) {
79
+ if (!existsSync(file)) return null;
80
+ const text = String(readFileSync(file, "utf8")).trim();
81
+ const pid = Number(text);
82
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
83
+ }
84
+
85
+ function isRunning(pid) {
86
+ if (!pid) return false;
87
+ try {
88
+ process.kill(pid, 0);
89
+ return true;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ function removeFileIfExists(path) {
96
+ if (existsSync(path)) rmSync(path, { force: true });
97
+ }
98
+
99
+ async function stopPid(pid, name) {
100
+ if (!pid || !isRunning(pid)) return;
101
+ try {
102
+ process.kill(pid, "SIGTERM");
103
+ } catch {
104
+ return;
105
+ }
106
+ const start = Date.now();
107
+ while (Date.now() - start < 5000) {
108
+ if (!isRunning(pid)) return;
109
+ await new Promise((r) => setTimeout(r, 200));
110
+ }
111
+ if (isRunning(pid)) {
112
+ try {
113
+ process.kill(pid, "SIGKILL");
114
+ } catch {
115
+ // ignore
116
+ }
117
+ }
118
+ if (isRunning(pid)) {
119
+ console.error(`failed to stop ${name} (pid=${pid})`);
120
+ }
121
+ }
122
+
123
+ function parseMode(raw) {
124
+ const mode = String(raw || "local").trim().toLowerCase();
125
+ if (mode === "lan" || mode === "global-preview" || mode === "local") return mode;
126
+ return "local";
127
+ }
128
+
129
+ function adapterForMode(mode) {
130
+ if (mode === "lan") return "real-preview";
131
+ if (mode === "global-preview") return "relay-preview";
132
+ return "local-event-bus";
133
+ }
134
+
135
+ function parseUrlHostPort(url) {
136
+ try {
137
+ const u = new URL(url);
138
+ return {
139
+ host: u.hostname || "",
140
+ port: Number(u.port || 4510),
141
+ };
142
+ } catch {
143
+ return { host: "", port: 4510 };
144
+ }
145
+ }
146
+
147
+ function spawnBackground(command, args, env, logFile, pidFile) {
148
+ const outFd = openSync(logFile, "a");
149
+ const child = spawn(command, args, {
150
+ cwd: APP_DIR,
151
+ env: { ...process.env, ...env },
152
+ detached: true,
153
+ stdio: ["ignore", outFd, outFd],
154
+ });
155
+ child.unref();
156
+ writeFileSync(pidFile, String(child.pid));
157
+ return child.pid;
158
+ }
159
+
160
+ function writeState(state) {
161
+ ensureStateDir();
162
+ writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
163
+ }
164
+
165
+ function readState() {
166
+ if (!existsSync(STATE_FILE)) return null;
167
+ try {
168
+ return JSON.parse(readFileSync(STATE_FILE, "utf8"));
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ function printHelp() {
175
+ console.log(`
176
+ SilicaClaw Gateway
177
+
178
+ Usage:
179
+ silicaclaw gateway start [--mode=local|lan|global-preview] [--signaling-url=http://host:4510] [--room=silicaclaw-demo]
180
+ silicaclaw gateway stop
181
+ silicaclaw gateway restart [--mode=...]
182
+ silicaclaw gateway status
183
+ silicaclaw gateway logs [local-console|signaling]
184
+
185
+ Notes:
186
+ - Default app dir: current directory; fallback: ~/silicaclaw
187
+ - State dir: .silicaclaw/gateway
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
190
+ `.trim());
191
+ }
192
+
193
+ function showStatus() {
194
+ const localPid = readPid(CONSOLE_PID_FILE);
195
+ const sigPid = readPid(SIGNALING_PID_FILE);
196
+ const state = readState();
197
+ const payload = {
198
+ app_dir: APP_DIR,
199
+ mode: state?.mode || "unknown",
200
+ adapter: state?.adapter || "unknown",
201
+ local_console: {
202
+ pid: localPid,
203
+ running: isRunning(localPid),
204
+ log_file: CONSOLE_LOG_FILE,
205
+ },
206
+ signaling: {
207
+ pid: sigPid,
208
+ running: isRunning(sigPid),
209
+ log_file: SIGNALING_LOG_FILE,
210
+ url: state?.signaling_url || null,
211
+ room: state?.room || null,
212
+ },
213
+ updated_at: state?.updated_at || null,
214
+ };
215
+ console.log(JSON.stringify(payload, null, 2));
216
+ return payload;
217
+ }
218
+
219
+ function printConnectionSummary(status) {
220
+ if (!status?.local_console?.running) return;
221
+ console.log("");
222
+ console.log("Gateway connection summary:");
223
+ console.log(`- local-console: http://localhost:4310`);
224
+ console.log(`- mode: ${status.mode}`);
225
+ console.log(`- adapter: ${status.adapter}`);
226
+ if (status.mode === "global-preview") {
227
+ const signalingUrl = status?.signaling?.url || "http://localhost:4510";
228
+ const room = status?.signaling?.room || "silicaclaw-demo";
229
+ console.log(`- signaling: ${signalingUrl} (room=${room})`);
230
+ }
231
+ console.log(`- local-console log: ${status?.local_console?.log_file || CONSOLE_LOG_FILE}`);
232
+ console.log(`- status: silicaclaw gateway status`);
233
+ console.log(`- logs: silicaclaw gateway logs local-console`);
234
+ console.log(`- stop: silicaclaw gateway stop`);
235
+ }
236
+
237
+ function listeningProcessOnPort(port) {
238
+ try {
239
+ const pidRes = spawnSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], {
240
+ encoding: "utf8",
241
+ stdio: ["ignore", "pipe", "ignore"],
242
+ });
243
+ const pid = String(pidRes.stdout || "")
244
+ .split(/\r?\n/)
245
+ .map((s) => s.trim())
246
+ .find(Boolean);
247
+ if (!pid) return null;
248
+
249
+ const cmdRes = spawnSync("ps", ["-p", pid, "-o", "command="], {
250
+ encoding: "utf8",
251
+ stdio: ["ignore", "pipe", "ignore"],
252
+ });
253
+ const command = String(cmdRes.stdout || "").trim() || "unknown";
254
+ return { pid, command };
255
+ } catch {
256
+ return null;
257
+ }
258
+ }
259
+
260
+ function printStopSummary() {
261
+ const localListener = listeningProcessOnPort(4310);
262
+ const signalingListener = listeningProcessOnPort(4510);
263
+ console.log("");
264
+ console.log("Gateway stop summary:");
265
+ if (!localListener) {
266
+ console.log("- local-console port 4310: stopped");
267
+ } else {
268
+ console.log(`- local-console port 4310: still in use by pid=${localListener.pid}`);
269
+ console.log(` command: ${localListener.command}`);
270
+ console.log(" this is likely another process not started by gateway");
271
+ console.log(` inspect: lsof -nP -iTCP:4310 -sTCP:LISTEN`);
272
+ console.log(` stop it: kill ${localListener.pid}`);
273
+ }
274
+ if (!signalingListener) {
275
+ console.log("- signaling port 4510: stopped");
276
+ } else {
277
+ console.log(`- signaling port 4510: still in use by pid=${signalingListener.pid}`);
278
+ console.log(` command: ${signalingListener.command}`);
279
+ console.log(" this is likely another process not started by gateway");
280
+ console.log(` inspect: lsof -nP -iTCP:4510 -sTCP:LISTEN`);
281
+ console.log(` stop it: kill ${signalingListener.pid}`);
282
+ }
283
+ console.log(`- check status: silicaclaw gateway status`);
284
+ }
285
+
286
+ function tailFile(file, lines = 80) {
287
+ if (!existsSync(file)) {
288
+ console.log(`log file not found: ${file}`);
289
+ return;
290
+ }
291
+ const text = String(readFileSync(file, "utf8"));
292
+ const out = text.split(/\r?\n/).slice(-lines).join("\n");
293
+ console.log(out);
294
+ }
295
+
296
+ async function stopAll() {
297
+ const localPid = readPid(CONSOLE_PID_FILE);
298
+ const sigPid = readPid(SIGNALING_PID_FILE);
299
+ await stopPid(localPid, "local-console");
300
+ await stopPid(sigPid, "signaling");
301
+ removeFileIfExists(CONSOLE_PID_FILE);
302
+ removeFileIfExists(SIGNALING_PID_FILE);
303
+ writeState({
304
+ ...(readState() || {}),
305
+ updated_at: Date.now(),
306
+ });
307
+ console.log("gateway stopped");
308
+ }
309
+
310
+ function startAll() {
311
+ ensureStateDir();
312
+
313
+ const mode = parseMode(parseFlag("mode", process.env.NETWORK_MODE || "global-preview"));
314
+ const adapter = adapterForMode(mode);
315
+ const signalingUrl = parseFlag("signaling-url", process.env.WEBRTC_SIGNALING_URL || "http://localhost:4510");
316
+ const room = parseFlag("room", process.env.WEBRTC_ROOM || "silicaclaw-demo");
317
+ const shouldDisableSignaling = hasFlag("no-signaling");
318
+
319
+ const currentLocalPid = readPid(CONSOLE_PID_FILE);
320
+ const currentSigPid = readPid(SIGNALING_PID_FILE);
321
+ if (isRunning(currentLocalPid)) {
322
+ console.log(`local-console already running (pid=${currentLocalPid})`);
323
+ } else {
324
+ removeFileIfExists(CONSOLE_PID_FILE);
325
+ const env = {
326
+ NETWORK_ADAPTER: adapter,
327
+ NETWORK_MODE: mode,
328
+ WEBRTC_SIGNALING_URL: signalingUrl,
329
+ WEBRTC_ROOM: room,
330
+ };
331
+ const pid = spawnBackground("npm", ["run", "local-console"], env, CONSOLE_LOG_FILE, CONSOLE_PID_FILE);
332
+ console.log(`local-console started (pid=${pid})`);
333
+ }
334
+
335
+ const { host, port } = parseUrlHostPort(signalingUrl);
336
+ const shouldAutoStartSignaling =
337
+ mode === "global-preview" &&
338
+ !shouldDisableSignaling &&
339
+ (host === "localhost" || host === "127.0.0.1");
340
+
341
+ if (shouldAutoStartSignaling) {
342
+ if (isRunning(currentSigPid)) {
343
+ console.log(`signaling already running (pid=${currentSigPid})`);
344
+ } else {
345
+ removeFileIfExists(SIGNALING_PID_FILE);
346
+ const pid = spawnBackground(
347
+ "npm",
348
+ ["run", "webrtc-signaling"],
349
+ { PORT: String(port) },
350
+ SIGNALING_LOG_FILE,
351
+ SIGNALING_PID_FILE,
352
+ );
353
+ console.log(`signaling started (pid=${pid}, port=${port})`);
354
+ }
355
+ }
356
+
357
+ writeState({
358
+ app_dir: APP_DIR,
359
+ mode,
360
+ adapter,
361
+ signaling_url: signalingUrl,
362
+ room,
363
+ updated_at: Date.now(),
364
+ });
365
+ }
366
+
367
+ async function main() {
368
+ if (cmd === "help" || cmd === "-h" || cmd === "--help") {
369
+ printHelp();
370
+ return;
371
+ }
372
+ if (cmd === "status") {
373
+ showStatus();
374
+ return;
375
+ }
376
+ if (cmd === "start") {
377
+ startAll();
378
+ const status = showStatus();
379
+ printConnectionSummary(status);
380
+ return;
381
+ }
382
+ if (cmd === "stop") {
383
+ await stopAll();
384
+ showStatus();
385
+ printStopSummary();
386
+ return;
387
+ }
388
+ if (cmd === "restart") {
389
+ await stopAll();
390
+ startAll();
391
+ const status = showStatus();
392
+ printConnectionSummary(status);
393
+ return;
394
+ }
395
+ if (cmd === "logs") {
396
+ const target = String(argv[1] || "local-console");
397
+ if (target === "signaling") {
398
+ tailFile(SIGNALING_LOG_FILE);
399
+ return;
400
+ }
401
+ tailFile(CONSOLE_LOG_FILE);
402
+ return;
403
+ }
404
+ printHelp();
405
+ process.exitCode = 1;
406
+ }
407
+
408
+ main().catch((error) => {
409
+ console.error(error?.message || String(error));
410
+ process.exit(1);
411
+ });
@@ -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