@sym-bot/sym 0.1.0 → 0.2.1
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/PRD.md +1 -1
- package/README.md +237 -70
- package/TECHNICAL-SPEC.md +250 -197
- package/bin/setup-claude.sh +31 -1
- package/bin/sym-daemon.js +437 -0
- package/docs/mesh-memory-protocol.md +563 -0
- package/docs/mmp-architecture-image-prompt.txt +12 -0
- package/docs/p2p-protocol-research.md +907 -0
- package/docs/protocol-wake.md +242 -0
- package/integrations/claude-code/mcp-server.js +264 -41
- package/integrations/telegram/bot.js +418 -0
- package/lib/ipc-client.js +241 -0
- package/lib/node.js +489 -39
- package/lib/transport.js +88 -0
- package/package.json +5 -3
- package/sym-relay/Dockerfile +7 -0
- package/sym-relay/lib/logger.js +28 -0
- package/sym-relay/lib/relay.js +388 -0
- package/sym-relay/package-lock.json +40 -0
- package/sym-relay/package.json +18 -0
- package/sym-relay/render.yaml +14 -0
- package/sym-relay/server.js +67 -0
- package/.mcp.json +0 -12
package/lib/transport.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { EventEmitter } = require('events');
|
|
4
|
+
const { FrameParser, sendFrame } = require('./frame-parser');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Transport abstraction for SYM peer connections.
|
|
8
|
+
*
|
|
9
|
+
* All transports emit:
|
|
10
|
+
* 'message' (msg) — parsed JSON frame from peer
|
|
11
|
+
* 'close' () — connection closed
|
|
12
|
+
* 'error' (err) — connection error
|
|
13
|
+
*
|
|
14
|
+
* All transports implement:
|
|
15
|
+
* send(frame) — send a JSON frame to the peer
|
|
16
|
+
* close() — close the connection
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* TcpTransport — wraps a raw TCP socket with length-prefixed framing.
|
|
21
|
+
*/
|
|
22
|
+
class TcpTransport extends EventEmitter {
|
|
23
|
+
|
|
24
|
+
constructor(socket) {
|
|
25
|
+
super();
|
|
26
|
+
this._socket = socket;
|
|
27
|
+
this._parser = new FrameParser();
|
|
28
|
+
this._closed = false;
|
|
29
|
+
|
|
30
|
+
socket.on('data', (chunk) => this._parser.feed(chunk));
|
|
31
|
+
this._parser.on('message', (msg) => this.emit('message', msg));
|
|
32
|
+
this._parser.on('error', (err) => this.emit('error', err));
|
|
33
|
+
socket.on('close', () => { this._closed = true; this.emit('close'); });
|
|
34
|
+
socket.on('error', (err) => this.emit('error', err));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
send(frame) {
|
|
38
|
+
if (this._closed) return;
|
|
39
|
+
sendFrame(this._socket, frame);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
close() {
|
|
43
|
+
if (this._closed) return;
|
|
44
|
+
this._closed = true;
|
|
45
|
+
try { this._socket.destroy(); } catch {}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Drain any buffered bytes into a new transport (for handshake hand-off). */
|
|
49
|
+
get pendingBuffer() {
|
|
50
|
+
return this._parser.buffer || Buffer.alloc(0);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* RelayPeerTransport — a virtual transport for a specific peer over a shared
|
|
56
|
+
* WebSocket relay connection.
|
|
57
|
+
*
|
|
58
|
+
* Multiple peers share one WebSocket to the relay. Each RelayPeerTransport
|
|
59
|
+
* targets a specific peer nodeId via the envelope's `to` field.
|
|
60
|
+
*/
|
|
61
|
+
class RelayPeerTransport extends EventEmitter {
|
|
62
|
+
|
|
63
|
+
constructor(relayWs, targetNodeId) {
|
|
64
|
+
super();
|
|
65
|
+
this._ws = relayWs;
|
|
66
|
+
this._targetNodeId = targetNodeId;
|
|
67
|
+
this._closed = false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
send(frame) {
|
|
71
|
+
if (this._closed || !this._ws || this._ws.readyState !== 1) return;
|
|
72
|
+
this._ws.send(JSON.stringify({ to: this._targetNodeId, payload: frame }));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
close() {
|
|
76
|
+
if (this._closed) return;
|
|
77
|
+
this._closed = true;
|
|
78
|
+
this.emit('close');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Called by the relay client when the shared WebSocket closes. */
|
|
82
|
+
destroy() {
|
|
83
|
+
this._closed = true;
|
|
84
|
+
this.emit('close');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { TcpTransport, RelayPeerTransport };
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sym-bot/sym",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Local AI mesh — every agent is a sovereign node, the mesh is the agents",
|
|
5
5
|
"main": "lib/node.js",
|
|
6
6
|
"bin": {
|
|
7
|
-
"sym-mcp": "./integrations/claude-code/mcp-server.js"
|
|
7
|
+
"sym-mcp": "./integrations/claude-code/mcp-server.js",
|
|
8
|
+
"sym-daemon": "./bin/sym-daemon.js"
|
|
8
9
|
},
|
|
9
10
|
"scripts": {
|
|
10
11
|
"test": "node --test tests/*.test.js"
|
|
@@ -33,6 +34,7 @@
|
|
|
33
34
|
"dependencies": {
|
|
34
35
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
35
36
|
"bonjour-service": "^1.3.0",
|
|
36
|
-
"mesh-cognition": "^1.1.0"
|
|
37
|
+
"mesh-cognition": "^1.1.0",
|
|
38
|
+
"ws": "^8.20.0"
|
|
37
39
|
}
|
|
38
40
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const LEVELS = { error: 0, warn: 1, info: 2, debug: 3 };
|
|
4
|
+
|
|
5
|
+
class Logger {
|
|
6
|
+
|
|
7
|
+
constructor(level = 'info') {
|
|
8
|
+
this._level = LEVELS[level] ?? LEVELS.info;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
error(msg, ctx) { this._write('error', msg, ctx); }
|
|
12
|
+
warn(msg, ctx) { this._write('warn', msg, ctx); }
|
|
13
|
+
info(msg, ctx) { this._write('info', msg, ctx); }
|
|
14
|
+
debug(msg, ctx) { this._write('debug', msg, ctx); }
|
|
15
|
+
|
|
16
|
+
_write(level, msg, ctx) {
|
|
17
|
+
if (LEVELS[level] > this._level) return;
|
|
18
|
+
const ts = new Date().toISOString();
|
|
19
|
+
const prefix = `${ts} [${level.toUpperCase()}]`;
|
|
20
|
+
if (ctx) {
|
|
21
|
+
process.stdout.write(`${prefix} ${msg} ${JSON.stringify(ctx)}\n`);
|
|
22
|
+
} else {
|
|
23
|
+
process.stdout.write(`${prefix} ${msg}\n`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = { Logger };
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Logger } = require('./logger');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* SymRelay — a peer node in the MMP mesh.
|
|
7
|
+
*
|
|
8
|
+
* The relay is a full mesh participant with its own identity. It forwards
|
|
9
|
+
* frames between authenticated nodes without inspecting payloads (dumb
|
|
10
|
+
* transport), but participates in peer gossip — storing wake channels
|
|
11
|
+
* and peer metadata so that newly-connecting nodes can learn about
|
|
12
|
+
* sleeping peers they've never met.
|
|
13
|
+
*
|
|
14
|
+
* All coupling decisions remain on-device. The relay never evaluates
|
|
15
|
+
* cognitive state.
|
|
16
|
+
*
|
|
17
|
+
* MMP v0.2.0 Protocol:
|
|
18
|
+
* 1. Client connects via WebSocket
|
|
19
|
+
* 2. Client sends: { type: 'relay-auth', nodeId, name, token?, wakeChannel? }
|
|
20
|
+
* 3. Relay validates, registers, updates peer directory
|
|
21
|
+
* 4. Relay sends: { type: 'relay-peers', peers: [{ nodeId, name, wakeChannel?, offline? }] }
|
|
22
|
+
* — includes disconnected peers with wake channels (gossip)
|
|
23
|
+
* 5. Relay notifies others: { type: 'relay-peer-joined', nodeId, name }
|
|
24
|
+
* 6. Client sends: { to?: nodeId, payload: <MMP frame> }
|
|
25
|
+
* 7. Relay forwards: { from: nodeId, fromName: name, payload: <MMP frame> }
|
|
26
|
+
* 8. Relay intercepts peer-info and wake-channel payloads to update directory
|
|
27
|
+
* 9. On disconnect: { type: 'relay-peer-left', nodeId, name }
|
|
28
|
+
*/
|
|
29
|
+
class SymRelay {
|
|
30
|
+
|
|
31
|
+
constructor(opts = {}) {
|
|
32
|
+
this._token = opts.token || null;
|
|
33
|
+
this._log = opts.logger || new Logger(opts.logLevel || 'info');
|
|
34
|
+
this._pingInterval = opts.pingInterval || 30000;
|
|
35
|
+
this._pingTimeout = opts.pingTimeout || 10000;
|
|
36
|
+
|
|
37
|
+
/** Active WebSocket connections. nodeId → ConnectionState */
|
|
38
|
+
this._connections = new Map();
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Peer directory — retained across disconnects for gossip.
|
|
42
|
+
* nodeId → { name: string, wakeChannel?: WakeChannel, lastSeen: number }
|
|
43
|
+
*
|
|
44
|
+
* This is what makes the relay a gossip hub: when a new node connects,
|
|
45
|
+
* the relay shares what it knows about peers the new node has never met.
|
|
46
|
+
*/
|
|
47
|
+
this._peerDirectory = new Map();
|
|
48
|
+
this._peerDirectoryTTL = opts.peerDirectoryTTL || 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
49
|
+
|
|
50
|
+
this._pingTimer = null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Public API ──────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
get connectionCount() {
|
|
56
|
+
return this._connections.size;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get directorySize() {
|
|
60
|
+
return this._peerDirectory.size;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
start() {
|
|
64
|
+
this._pingTimer = setInterval(() => this._heartbeat(), this._pingInterval);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
stop() {
|
|
68
|
+
if (this._pingTimer) clearInterval(this._pingTimer);
|
|
69
|
+
for (const [, conn] of this._connections) {
|
|
70
|
+
conn.ws.close(1001, 'Server shutting down');
|
|
71
|
+
}
|
|
72
|
+
this._connections.clear();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Handle a new WebSocket connection. Called by the server on 'connection'.
|
|
77
|
+
*/
|
|
78
|
+
handleConnection(ws) {
|
|
79
|
+
let authenticated = false;
|
|
80
|
+
const authTimeout = setTimeout(() => {
|
|
81
|
+
if (!authenticated) {
|
|
82
|
+
this._log.warn('Auth timeout — closing connection');
|
|
83
|
+
ws.close(4001, 'Auth timeout');
|
|
84
|
+
}
|
|
85
|
+
}, 10000);
|
|
86
|
+
|
|
87
|
+
ws.on('message', (data) => {
|
|
88
|
+
let msg;
|
|
89
|
+
try { msg = JSON.parse(data.toString()); } catch {
|
|
90
|
+
this._log.warn('Invalid JSON received');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!authenticated) {
|
|
95
|
+
authenticated = this._authenticate(ws, msg, authTimeout);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
this._onMessage(msg, ws);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
ws.on('close', () => {
|
|
103
|
+
clearTimeout(authTimeout);
|
|
104
|
+
this._removeBySocket(ws);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
ws.on('error', (err) => {
|
|
108
|
+
this._log.warn(`WebSocket error: ${err.message}`);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Authentication ─────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
_authenticate(ws, msg, authTimeout) {
|
|
115
|
+
if (msg.type !== 'relay-auth' || !msg.nodeId || !msg.name) {
|
|
116
|
+
this._log.warn('Invalid auth message', { type: msg.type });
|
|
117
|
+
ws.close(4002, 'Invalid auth');
|
|
118
|
+
clearTimeout(authTimeout);
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (this._token && msg.token !== this._token) {
|
|
123
|
+
this._log.warn('Auth rejected — invalid token', { name: msg.name });
|
|
124
|
+
ws.send(JSON.stringify({ type: 'relay-error', message: 'Invalid token' }));
|
|
125
|
+
ws.close(4003, 'Invalid token');
|
|
126
|
+
clearTimeout(authTimeout);
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Replace duplicate nodeId connection
|
|
131
|
+
if (this._connections.has(msg.nodeId)) {
|
|
132
|
+
const existing = this._connections.get(msg.nodeId);
|
|
133
|
+
this._log.info('Duplicate nodeId — closing old connection', { name: msg.name });
|
|
134
|
+
existing.ws.close(4004, 'Replaced by new connection');
|
|
135
|
+
this._removeConnection(msg.nodeId);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
clearTimeout(authTimeout);
|
|
139
|
+
|
|
140
|
+
// Register active connection
|
|
141
|
+
this._connections.set(msg.nodeId, {
|
|
142
|
+
ws,
|
|
143
|
+
nodeId: msg.nodeId,
|
|
144
|
+
name: msg.name,
|
|
145
|
+
connectedAt: Date.now(),
|
|
146
|
+
lastSeen: Date.now(),
|
|
147
|
+
alive: true,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Update peer directory
|
|
151
|
+
this._updateDirectory(msg.nodeId, msg.name, msg.wakeChannel);
|
|
152
|
+
|
|
153
|
+
this._log.info(`Peer authenticated: ${msg.name} (${msg.nodeId.slice(0, 8)})`, {
|
|
154
|
+
connections: this._connections.size,
|
|
155
|
+
directory: this._peerDirectory.size,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Send peer list with gossip (connected + offline peers with wake channels)
|
|
159
|
+
ws.send(JSON.stringify({
|
|
160
|
+
type: 'relay-peers',
|
|
161
|
+
peers: this._buildPeerList(msg.nodeId),
|
|
162
|
+
}));
|
|
163
|
+
|
|
164
|
+
// Notify existing peers
|
|
165
|
+
this._broadcast(msg.nodeId, {
|
|
166
|
+
type: 'relay-peer-joined',
|
|
167
|
+
nodeId: msg.nodeId,
|
|
168
|
+
name: msg.name,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Peer Directory ──────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Update the peer directory with the latest information about a node.
|
|
178
|
+
* The directory survives disconnects — this is the gossip state.
|
|
179
|
+
*/
|
|
180
|
+
_updateDirectory(nodeId, name, wakeChannel) {
|
|
181
|
+
const existing = this._peerDirectory.get(nodeId) || {};
|
|
182
|
+
const entry = {
|
|
183
|
+
...existing,
|
|
184
|
+
name: name || existing.name,
|
|
185
|
+
lastSeen: Date.now(),
|
|
186
|
+
};
|
|
187
|
+
if (wakeChannel && this._isValidWakeChannel(wakeChannel)) {
|
|
188
|
+
entry.wakeChannel = wakeChannel;
|
|
189
|
+
}
|
|
190
|
+
this._peerDirectory.set(nodeId, entry);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Validate wake channel structure.
|
|
195
|
+
*/
|
|
196
|
+
_isValidWakeChannel(wc) {
|
|
197
|
+
return wc
|
|
198
|
+
&& typeof wc.platform === 'string'
|
|
199
|
+
&& wc.platform !== 'none'
|
|
200
|
+
&& typeof wc.token === 'string'
|
|
201
|
+
&& wc.token.length > 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Build the peer list for a newly-connected node.
|
|
206
|
+
* Includes connected peers AND offline peers with wake channels (gossip).
|
|
207
|
+
*/
|
|
208
|
+
_buildPeerList(excludeNodeId) {
|
|
209
|
+
const peers = [];
|
|
210
|
+
const seen = new Set();
|
|
211
|
+
|
|
212
|
+
// Connected peers (with wake channels from directory if available)
|
|
213
|
+
for (const [id, conn] of this._connections) {
|
|
214
|
+
if (id === excludeNodeId) continue;
|
|
215
|
+
seen.add(id);
|
|
216
|
+
const dir = this._peerDirectory.get(id);
|
|
217
|
+
const entry = { nodeId: conn.nodeId, name: conn.name };
|
|
218
|
+
if (dir?.wakeChannel) entry.wakeChannel = dir.wakeChannel;
|
|
219
|
+
peers.push(entry);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Offline peers with wake channels (the gossip value-add)
|
|
223
|
+
for (const [id, dir] of this._peerDirectory) {
|
|
224
|
+
if (id === excludeNodeId || seen.has(id)) continue;
|
|
225
|
+
if (!dir.wakeChannel) continue;
|
|
226
|
+
peers.push({
|
|
227
|
+
nodeId: id,
|
|
228
|
+
name: dir.name,
|
|
229
|
+
wakeChannel: dir.wakeChannel,
|
|
230
|
+
offline: true,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return peers;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── Message Routing ────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
_onMessage(msg, ws) {
|
|
240
|
+
const sender = this._findBySocket(ws);
|
|
241
|
+
if (!sender) return;
|
|
242
|
+
|
|
243
|
+
sender.lastSeen = Date.now();
|
|
244
|
+
|
|
245
|
+
// Relay control messages
|
|
246
|
+
if (msg.type === 'relay-pong') {
|
|
247
|
+
sender.alive = true;
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Intercept peer metadata from payloads (relay participates in gossip)
|
|
252
|
+
this._interceptPeerMetadata(sender, msg);
|
|
253
|
+
|
|
254
|
+
// Forward to specific peer or broadcast
|
|
255
|
+
if (msg.to) {
|
|
256
|
+
this._sendTo(msg.to, {
|
|
257
|
+
from: sender.nodeId,
|
|
258
|
+
fromName: sender.name,
|
|
259
|
+
payload: msg.payload,
|
|
260
|
+
});
|
|
261
|
+
} else if (msg.payload) {
|
|
262
|
+
this._broadcast(sender.nodeId, {
|
|
263
|
+
from: sender.nodeId,
|
|
264
|
+
fromName: sender.name,
|
|
265
|
+
payload: msg.payload,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Intercept MMP frames that carry peer metadata to update the directory.
|
|
272
|
+
* The relay doesn't inspect cognitive payloads — only peer discovery metadata.
|
|
273
|
+
*/
|
|
274
|
+
_interceptPeerMetadata(sender, msg) {
|
|
275
|
+
const payload = msg.payload;
|
|
276
|
+
if (!payload || typeof payload !== 'object') return;
|
|
277
|
+
|
|
278
|
+
// peer-info gossip frame
|
|
279
|
+
if (payload.type === 'peer-info' && Array.isArray(payload.peers)) {
|
|
280
|
+
for (const p of payload.peers) {
|
|
281
|
+
if (!p.nodeId || p.nodeId === sender.nodeId) continue;
|
|
282
|
+
if (p.wakeChannel && this._isValidWakeChannel(p.wakeChannel)) {
|
|
283
|
+
this._updateDirectory(p.nodeId, p.name, p.wakeChannel);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// wake-channel frame (direct declaration from sender)
|
|
289
|
+
if (payload.type === 'wake-channel' && this._isValidWakeChannel(payload)) {
|
|
290
|
+
this._updateDirectory(sender.nodeId, sender.name, {
|
|
291
|
+
platform: payload.platform,
|
|
292
|
+
token: payload.token,
|
|
293
|
+
environment: payload.environment,
|
|
294
|
+
});
|
|
295
|
+
this._log.info(`Wake channel stored: ${sender.name} (${payload.platform})`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ── Transport ──────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
_sendTo(targetNodeId, envelope) {
|
|
302
|
+
const conn = this._connections.get(targetNodeId);
|
|
303
|
+
if (!conn) return;
|
|
304
|
+
try {
|
|
305
|
+
conn.ws.send(JSON.stringify(envelope));
|
|
306
|
+
} catch (err) {
|
|
307
|
+
this._log.warn(`Send failed to ${conn.name}: ${err.message}`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
_broadcast(excludeNodeId, envelope) {
|
|
312
|
+
const data = JSON.stringify(envelope);
|
|
313
|
+
for (const [id, conn] of this._connections) {
|
|
314
|
+
if (id === excludeNodeId) continue;
|
|
315
|
+
try {
|
|
316
|
+
conn.ws.send(data);
|
|
317
|
+
} catch (err) {
|
|
318
|
+
this._log.warn(`Broadcast failed to ${conn.name}: ${err.message}`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ── Connection Management ──────────────────────────────────
|
|
324
|
+
|
|
325
|
+
_findBySocket(ws) {
|
|
326
|
+
for (const [, conn] of this._connections) {
|
|
327
|
+
if (conn.ws === ws) return conn;
|
|
328
|
+
}
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
_removeBySocket(ws) {
|
|
333
|
+
const conn = this._findBySocket(ws);
|
|
334
|
+
if (conn) this._removeConnection(conn.nodeId);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
_removeConnection(nodeId) {
|
|
338
|
+
const conn = this._connections.get(nodeId);
|
|
339
|
+
if (!conn) return;
|
|
340
|
+
|
|
341
|
+
this._connections.delete(nodeId);
|
|
342
|
+
|
|
343
|
+
// Update directory lastSeen on disconnect (keeps the entry alive for gossip)
|
|
344
|
+
const dir = this._peerDirectory.get(nodeId);
|
|
345
|
+
if (dir) dir.lastSeen = Date.now();
|
|
346
|
+
|
|
347
|
+
this._log.info(`Peer disconnected: ${conn.name} (${nodeId.slice(0, 8)})`, {
|
|
348
|
+
connections: this._connections.size,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
this._broadcast(nodeId, {
|
|
352
|
+
type: 'relay-peer-left',
|
|
353
|
+
nodeId: conn.nodeId,
|
|
354
|
+
name: conn.name,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ── Heartbeat ──────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
_heartbeat() {
|
|
361
|
+
// Expire stale directory entries (disconnected peers beyond TTL)
|
|
362
|
+
const now = Date.now();
|
|
363
|
+
for (const [nodeId, dir] of this._peerDirectory) {
|
|
364
|
+
if (!this._connections.has(nodeId) && now - dir.lastSeen > this._peerDirectoryTTL) {
|
|
365
|
+
this._peerDirectory.delete(nodeId);
|
|
366
|
+
this._log.info(`Directory expired: ${dir.name} (${nodeId.slice(0, 8)})`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Ping active connections
|
|
371
|
+
for (const [nodeId, conn] of this._connections) {
|
|
372
|
+
if (!conn.alive) {
|
|
373
|
+
this._log.info(`Heartbeat timeout: ${conn.name}`);
|
|
374
|
+
conn.ws.close(4005, 'Heartbeat timeout');
|
|
375
|
+
this._removeConnection(nodeId);
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
conn.alive = false;
|
|
379
|
+
try {
|
|
380
|
+
conn.ws.send(JSON.stringify({ type: 'relay-ping' }));
|
|
381
|
+
} catch {
|
|
382
|
+
this._removeConnection(nodeId);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
module.exports = { SymRelay };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sym-bot/relay",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"lockfileVersion": 3,
|
|
5
|
+
"requires": true,
|
|
6
|
+
"packages": {
|
|
7
|
+
"": {
|
|
8
|
+
"name": "@sym-bot/relay",
|
|
9
|
+
"version": "0.1.0",
|
|
10
|
+
"license": "Apache-2.0",
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"ws": "^8.18.0"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"node_modules/ws": {
|
|
19
|
+
"version": "8.20.0",
|
|
20
|
+
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
|
21
|
+
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=10.0.0"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"bufferutil": "^4.0.1",
|
|
28
|
+
"utf-8-validate": ">=5.0.2"
|
|
29
|
+
},
|
|
30
|
+
"peerDependenciesMeta": {
|
|
31
|
+
"bufferutil": {
|
|
32
|
+
"optional": true
|
|
33
|
+
},
|
|
34
|
+
"utf-8-validate": {
|
|
35
|
+
"optional": true
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sym-bot/relay",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "SYM mesh relay — WebSocket transport for internet-scale cognitive coupling",
|
|
5
|
+
"main": "server.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"start": "node server.js"
|
|
8
|
+
},
|
|
9
|
+
"keywords": ["sym", "mesh", "relay", "websocket", "p2p"],
|
|
10
|
+
"author": "SYM.BOT Ltd <info@sym.bot> (https://sym.bot)",
|
|
11
|
+
"license": "Apache-2.0",
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"ws": "^8.18.0"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
services:
|
|
2
|
+
- type: web
|
|
3
|
+
name: sym-relay
|
|
4
|
+
runtime: node
|
|
5
|
+
plan: free
|
|
6
|
+
buildCommand: cd sym-relay && npm ci --production
|
|
7
|
+
startCommand: cd sym-relay && node server.js
|
|
8
|
+
envVars:
|
|
9
|
+
- key: PORT
|
|
10
|
+
value: 8080
|
|
11
|
+
- key: SYM_RELAY_TOKEN
|
|
12
|
+
sync: false
|
|
13
|
+
- key: LOG_LEVEL
|
|
14
|
+
value: info
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const { WebSocketServer } = require('ws');
|
|
6
|
+
const { SymRelay } = require('./lib/relay');
|
|
7
|
+
const { Logger } = require('./lib/logger');
|
|
8
|
+
|
|
9
|
+
// ── Configuration ──────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
const PORT = parseInt(process.env.PORT, 10) || 8080;
|
|
12
|
+
const TOKEN = process.env.SYM_RELAY_TOKEN || null;
|
|
13
|
+
const LOG_LEVEL = process.env.LOG_LEVEL || 'info';
|
|
14
|
+
|
|
15
|
+
const log = new Logger(LOG_LEVEL);
|
|
16
|
+
|
|
17
|
+
// ── HTTP Server ────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const httpServer = http.createServer((req, res) => {
|
|
20
|
+
if (req.method === 'GET' && req.url === '/health') {
|
|
21
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
22
|
+
res.end(JSON.stringify({
|
|
23
|
+
status: 'ok',
|
|
24
|
+
connections: relay.connectionCount,
|
|
25
|
+
uptime: Math.floor(process.uptime()),
|
|
26
|
+
}));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
res.writeHead(404);
|
|
30
|
+
res.end();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// ── WebSocket Server ───────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
const wss = new WebSocketServer({ server: httpServer });
|
|
36
|
+
const relay = new SymRelay({ token: TOKEN, logger: log, logLevel: LOG_LEVEL });
|
|
37
|
+
|
|
38
|
+
wss.on('connection', (ws) => {
|
|
39
|
+
relay.handleConnection(ws);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
relay.start();
|
|
43
|
+
|
|
44
|
+
// ── Start ──────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
httpServer.listen(PORT, () => {
|
|
47
|
+
log.info(`SYM relay listening on port ${PORT}`, {
|
|
48
|
+
auth: TOKEN ? 'token required' : 'open',
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ── Graceful Shutdown ──────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
function shutdown(signal) {
|
|
55
|
+
log.info(`${signal} received — shutting down`);
|
|
56
|
+
relay.stop();
|
|
57
|
+
wss.close(() => {
|
|
58
|
+
httpServer.close(() => {
|
|
59
|
+
process.exit(0);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
// Force exit after 5s if graceful shutdown stalls
|
|
63
|
+
setTimeout(() => process.exit(1), 5000);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
67
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|