pikiloom 0.4.21 → 0.4.23
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/dashboard/dist/assets/{AgentTab-Crg9GEUv.js → AgentTab-hzdSX7gP.js} +1 -1
- package/dashboard/dist/assets/ConnectionModal-Dj2eoHWz.js +1 -0
- package/dashboard/dist/assets/{DirBrowser-Do4XzF2X.js → DirBrowser-C4DPPZ1V.js} +1 -1
- package/dashboard/dist/assets/ExtensionsTab-BdGHiEnP.js +1 -0
- package/dashboard/dist/assets/{IMAccessTab-BX90Aiuq.js → IMAccessTab-C4RlXeCP.js} +1 -1
- package/dashboard/dist/assets/{Modal-Dd5twuoE.js → Modal-D22Z00Ux.js} +1 -1
- package/dashboard/dist/assets/{Modals--Xl6dsUf.js → Modals-CHXLG4V2.js} +1 -1
- package/dashboard/dist/assets/{Select-Bp6SwWv6.js → Select-D4JIewGZ.js} +1 -1
- package/dashboard/dist/assets/{SessionPanel-CHaOUor-.js → SessionPanel-Ba93D9jh.js} +1 -1
- package/dashboard/dist/assets/{SystemTab-MVpMgFFD.js → SystemTab-M8r-o2KI.js} +1 -1
- package/dashboard/dist/assets/index-C02W4e24.js +3 -0
- package/dashboard/dist/assets/index-CyCspgtc.css +1 -0
- package/dashboard/dist/assets/{index-7pyJSn4B.js → index-p2XCgnzw.js} +17 -17
- package/dashboard/dist/assets/{shared-CxR9M3rn.js → shared-DW8iXiGd.js} +1 -1
- package/dashboard/dist/index.html +2 -2
- package/dist/agent/index.js +1 -1
- package/dist/agent/skill-installer.js +95 -0
- package/dist/catalog/skill-repos.js +12 -0
- package/dist/cli/main.js +31 -1
- package/dist/core/secrets/ref.js +8 -0
- package/dist/core/secrets/store.js +31 -11
- package/dist/dashboard/routes/extensions.js +137 -24
- package/dist/dashboard/server.js +27 -64
- package/dist/pikichannel/adapter-pikiloom.js +220 -0
- package/dist/pikichannel/code.js +35 -0
- package/dist/pikichannel/codec.js +50 -0
- package/dist/pikichannel/host.js +252 -0
- package/dist/pikichannel/protocol.js +105 -0
- package/dist/pikichannel/rendezvous-broker.js +114 -0
- package/dist/pikichannel/rendezvous-host.js +138 -0
- package/dist/pikichannel/server.js +284 -0
- package/dist/pikichannel/transport.js +49 -0
- package/dist/pikichannel/transports/webrtc-host.js +61 -0
- package/dist/pikichannel/transports/webrtc-shared.js +132 -0
- package/dist/pikichannel/transports/websocket-host.js +78 -0
- package/dist/pikichannel/web/demo.html +246 -0
- package/dist/pikichannel/web/sdk.js +361 -0
- package/package.json +4 -2
- package/dashboard/dist/assets/ExtensionsTab-j77Yqoz1.js +0 -1
- package/dashboard/dist/assets/index-BiPI4uFE.js +0 -3
- package/dashboard/dist/assets/index-dzfjF9Js.css +0 -1
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pikichannel/host.ts — the host SDK (transport- and agent-agnostic).
|
|
3
|
+
*
|
|
4
|
+
* `PikichannelHost` speaks the L2 protocol over any {@link ChannelConnection},
|
|
5
|
+
* driven by a {@link SessionSource} the embedder supplies. The host knows
|
|
6
|
+
* nothing about pikiloom: porting pikichannel to another agent runtime is a
|
|
7
|
+
* matter of implementing SessionSource (see adapter-pikiloom.ts).
|
|
8
|
+
*
|
|
9
|
+
* It owns three cross-cutting concerns the protocol requires:
|
|
10
|
+
* - Auth: a peer must pass `authenticate(token, remote)` (loopback exempt by
|
|
11
|
+
* policy) before ANY session data or control is processed.
|
|
12
|
+
* - Delta: it holds the latest full snapshot per session and emits a `full`
|
|
13
|
+
* patch on (re)subscribe / resync, deltas thereafter — O(n) per stream.
|
|
14
|
+
* - Fan-out: one delta per update is broadcast to every caught-up subscriber;
|
|
15
|
+
* a fresh subscriber gets a `full` so it shares the same baseline.
|
|
16
|
+
*/
|
|
17
|
+
import { PROTOCOL_VERSION, diffSnapshot, } from './protocol.js';
|
|
18
|
+
import { encodeServer, decodeClient } from './codec.js';
|
|
19
|
+
const SUBSCRIBE_ALL = '*';
|
|
20
|
+
export class PikichannelHost {
|
|
21
|
+
source;
|
|
22
|
+
authenticate;
|
|
23
|
+
log;
|
|
24
|
+
peers = new Set();
|
|
25
|
+
unsubscribers = [];
|
|
26
|
+
started = false;
|
|
27
|
+
/** Latest full snapshot per session — the delta baseline shared by all peers. */
|
|
28
|
+
lastFull = new Map();
|
|
29
|
+
lastSeq = new Map();
|
|
30
|
+
constructor(source, authenticate = () => true, log = () => { }) {
|
|
31
|
+
this.source = source;
|
|
32
|
+
this.authenticate = authenticate;
|
|
33
|
+
this.log = log;
|
|
34
|
+
}
|
|
35
|
+
/** Wire host-level subscriptions to the source (once). */
|
|
36
|
+
start() {
|
|
37
|
+
if (this.started)
|
|
38
|
+
return;
|
|
39
|
+
this.started = true;
|
|
40
|
+
this.unsubscribers.push(this.source.onUpdate((sessionKey, snapshot, seq) => this.onSourceUpdate(sessionKey, snapshot, seq)));
|
|
41
|
+
this.unsubscribers.push(this.source.onSessionsChanged((sessions) => this.broadcast({ type: 'sessions', sessions }, true)));
|
|
42
|
+
}
|
|
43
|
+
stop() {
|
|
44
|
+
for (const u of this.unsubscribers.splice(0)) {
|
|
45
|
+
try {
|
|
46
|
+
u();
|
|
47
|
+
}
|
|
48
|
+
catch { /* ignore */ }
|
|
49
|
+
}
|
|
50
|
+
for (const peer of this.peers) {
|
|
51
|
+
try {
|
|
52
|
+
peer.conn.close();
|
|
53
|
+
}
|
|
54
|
+
catch { /* ignore */ }
|
|
55
|
+
}
|
|
56
|
+
this.peers.clear();
|
|
57
|
+
this.lastFull.clear();
|
|
58
|
+
this.lastSeq.clear();
|
|
59
|
+
this.started = false;
|
|
60
|
+
}
|
|
61
|
+
/** Adopt a freshly-established connection from any transport binding. */
|
|
62
|
+
handleConnection(conn) {
|
|
63
|
+
if (!this.started)
|
|
64
|
+
this.start();
|
|
65
|
+
const peer = { conn, subs: new Set(), authed: false };
|
|
66
|
+
this.peers.add(peer);
|
|
67
|
+
this.log(`peer connected id=${conn.id} via=${conn.kind} remote=${conn.remote || '?'}`);
|
|
68
|
+
conn.onMessage((frame) => {
|
|
69
|
+
const msg = decodeClient(frame);
|
|
70
|
+
if (!msg)
|
|
71
|
+
return;
|
|
72
|
+
this.handleClientMessage(peer, msg).catch((err) => {
|
|
73
|
+
this.send(peer, { type: 'error', message: err?.message || String(err) });
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
conn.onClose(() => {
|
|
77
|
+
this.peers.delete(peer);
|
|
78
|
+
this.log(`peer disconnected id=${conn.id} via=${conn.kind}`);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
// -- delta baseline ------------------------------------------------------
|
|
82
|
+
onSourceUpdate(sessionKey, full, seq) {
|
|
83
|
+
const prev = this.lastFull.get(sessionKey);
|
|
84
|
+
const patch = prev ? diffSnapshot(prev, full) : { full };
|
|
85
|
+
this.lastFull.set(sessionKey, full);
|
|
86
|
+
this.lastSeq.set(sessionKey, seq);
|
|
87
|
+
const frame = { type: 'session', sessionKey, seq, patch };
|
|
88
|
+
for (const peer of this.peers) {
|
|
89
|
+
if (peer.authed && (peer.subs.has(SUBSCRIBE_ALL) || peer.subs.has(sessionKey)))
|
|
90
|
+
this.send(peer, frame);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Send the current full snapshot for a session to one peer (subscribe / resync). */
|
|
94
|
+
sendFull(peer, sessionKey) {
|
|
95
|
+
let full = this.lastFull.get(sessionKey);
|
|
96
|
+
let seq = this.lastSeq.get(sessionKey);
|
|
97
|
+
if (!full) {
|
|
98
|
+
const snap = this.source.getSnapshot(sessionKey);
|
|
99
|
+
if (!snap)
|
|
100
|
+
return;
|
|
101
|
+
full = snap.snapshot;
|
|
102
|
+
seq = snap.seq;
|
|
103
|
+
this.lastFull.set(sessionKey, full);
|
|
104
|
+
this.lastSeq.set(sessionKey, seq);
|
|
105
|
+
}
|
|
106
|
+
this.send(peer, { type: 'session', sessionKey, seq: seq || 0, patch: { full } });
|
|
107
|
+
}
|
|
108
|
+
// -- inbound -------------------------------------------------------------
|
|
109
|
+
async handleClientMessage(peer, msg) {
|
|
110
|
+
// Ping is always allowed (RTT / keepalive). Everything else is gated on auth.
|
|
111
|
+
if (msg.type === 'ping') {
|
|
112
|
+
this.send(peer, { type: 'pong', t: msg.t });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (!peer.authed) {
|
|
116
|
+
if (msg.type !== 'hello') {
|
|
117
|
+
this.send(peer, { type: 'error', message: 'not authenticated', code: 'auth' });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (!this.authenticate(msg.token, peer.conn.remote)) {
|
|
121
|
+
this.log(`peer rejected id=${peer.conn.id} remote=${peer.conn.remote || '?'} (bad/absent token)`);
|
|
122
|
+
this.send(peer, { type: 'error', message: 'unauthorized', code: 'auth' });
|
|
123
|
+
peer.conn.close();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
peer.authed = true;
|
|
127
|
+
const info = this.source.hostInfo();
|
|
128
|
+
this.send(peer, {
|
|
129
|
+
type: 'welcome',
|
|
130
|
+
v: PROTOCOL_VERSION,
|
|
131
|
+
host: {
|
|
132
|
+
name: info.name,
|
|
133
|
+
version: info.version,
|
|
134
|
+
transport: peer.conn.kind,
|
|
135
|
+
capabilities: info.capabilities,
|
|
136
|
+
authRequired: info.authRequired,
|
|
137
|
+
},
|
|
138
|
+
sessions: this.source.listSessions(),
|
|
139
|
+
});
|
|
140
|
+
if (msg.resume?.sessionKey) {
|
|
141
|
+
peer.subs.add(msg.resume.sessionKey);
|
|
142
|
+
this.sendFull(peer, msg.resume.sessionKey);
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
switch (msg.type) {
|
|
147
|
+
case 'hello':
|
|
148
|
+
return; // already authenticated; ignore duplicate hello
|
|
149
|
+
case 'subscribe': {
|
|
150
|
+
peer.subs.add(msg.sessionKey);
|
|
151
|
+
if (msg.sessionKey === SUBSCRIBE_ALL) {
|
|
152
|
+
for (const meta of this.source.listSessions())
|
|
153
|
+
this.sendFull(peer, meta.sessionKey);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
this.sendFull(peer, msg.sessionKey);
|
|
157
|
+
}
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
case 'unsubscribe':
|
|
161
|
+
peer.subs.delete(msg.sessionKey);
|
|
162
|
+
return;
|
|
163
|
+
case 'getSnapshot':
|
|
164
|
+
this.sendFull(peer, msg.sessionKey);
|
|
165
|
+
return;
|
|
166
|
+
case 'listSessions':
|
|
167
|
+
this.send(peer, { type: 'sessions', sessions: this.source.listSessions() });
|
|
168
|
+
return;
|
|
169
|
+
case 'prompt': {
|
|
170
|
+
const result = await this.source.prompt({
|
|
171
|
+
sessionKey: msg.sessionKey, prompt: msg.prompt, agent: msg.agent, workdir: msg.workdir,
|
|
172
|
+
model: msg.model, effort: msg.effort, workflow: msg.workflow, attachments: msg.attachments,
|
|
173
|
+
});
|
|
174
|
+
if (result.ok && result.sessionKey) {
|
|
175
|
+
peer.subs.add(result.sessionKey);
|
|
176
|
+
this.send(peer, { type: 'accepted', sessionKey: result.sessionKey, taskId: result.taskId || '', clientRef: msg.clientRef });
|
|
177
|
+
this.sendFull(peer, result.sessionKey);
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
this.send(peer, { type: 'error', message: result.error || 'prompt rejected', clientRef: msg.clientRef });
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
case 'stop': {
|
|
185
|
+
const r = this.source.stop(msg.sessionKey);
|
|
186
|
+
if (!r.ok)
|
|
187
|
+
this.send(peer, { type: 'error', message: r.error || 'stop failed' });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
case 'steer': {
|
|
191
|
+
const r = await this.source.steer(msg.taskId);
|
|
192
|
+
if (!r.ok)
|
|
193
|
+
this.send(peer, { type: 'error', message: r.error || 'steer failed' });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
case 'recall': {
|
|
197
|
+
const r = this.source.recall(msg.taskId);
|
|
198
|
+
if (!r.ok)
|
|
199
|
+
this.send(peer, { type: 'error', message: r.error || 'recall failed' });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
case 'interact': {
|
|
203
|
+
const r = this.source.interact(msg.promptId, msg.action, msg.value, msg.requestFreeform);
|
|
204
|
+
if (!r.ok)
|
|
205
|
+
this.send(peer, { type: 'error', message: r.error || 'interaction failed' });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
case 'request': {
|
|
209
|
+
const id = msg.id;
|
|
210
|
+
// Only the management API is tunnelable — never static, the SPA shell,
|
|
211
|
+
// or loopback-gated endpoints (e.g. /pikichannel/pair would otherwise
|
|
212
|
+
// leak the token to a remote peer via the host's own fetch).
|
|
213
|
+
if (!msg.path || !msg.path.startsWith('/api/')) {
|
|
214
|
+
this.send(peer, { type: 'response', id, status: 403, error: 'only /api/* is tunnelable' });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (!this.source.handleRequest) {
|
|
218
|
+
this.send(peer, { type: 'response', id, status: 501, error: 'tunnel not supported' });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
const r = await this.source.handleRequest({ method: msg.method, path: msg.path, headers: msg.headers, body: msg.body, encoding: msg.encoding });
|
|
223
|
+
this.send(peer, { type: 'response', id, status: r.status, headers: r.headers, body: r.body, encoding: r.encoding });
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
this.send(peer, { type: 'response', id, status: 500, error: err?.message || 'tunnel error' });
|
|
227
|
+
}
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// -- outbound ------------------------------------------------------------
|
|
233
|
+
broadcast(frame, authedOnly = false) {
|
|
234
|
+
for (const peer of this.peers)
|
|
235
|
+
if (!authedOnly || peer.authed)
|
|
236
|
+
this.send(peer, frame);
|
|
237
|
+
}
|
|
238
|
+
send(peer, frame) {
|
|
239
|
+
if (!peer.conn.isOpen())
|
|
240
|
+
return;
|
|
241
|
+
try {
|
|
242
|
+
peer.conn.send(encodeServer(frame));
|
|
243
|
+
}
|
|
244
|
+
catch { /* drop on closed pipe */ }
|
|
245
|
+
}
|
|
246
|
+
/** Number of live peers (for status / metrics). */
|
|
247
|
+
get peerCount() { return this.peers.size; }
|
|
248
|
+
/** Number of authenticated peers. */
|
|
249
|
+
get authedPeerCount() { let n = 0; for (const p of this.peers)
|
|
250
|
+
if (p.authed)
|
|
251
|
+
n++; return n; }
|
|
252
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pikichannel/protocol.ts — THE universal, agent-agnostic wire protocol (L2).
|
|
3
|
+
*
|
|
4
|
+
* This file is the single source of truth for the pikichannel contract. It is
|
|
5
|
+
* deliberately free of any pikiloom-internal or agent-specific type: a client
|
|
6
|
+
* SDK on any platform (Web / iOS / Android) only needs these shapes to speak to
|
|
7
|
+
* any pikichannel host. The browser SDK in `web/pikichannel-sdk.js` mirrors the
|
|
8
|
+
* string literals and shapes documented here — keep the two in lockstep.
|
|
9
|
+
*
|
|
10
|
+
* Layering:
|
|
11
|
+
* transport (L1) — moves opaque frames (a byte/string pipe): WebSocket, WebRTC
|
|
12
|
+
* datachannel, relay tunnel. Pluggable; see transport.ts.
|
|
13
|
+
* protocol (L2) — THIS file. The session/event semantics framed over L1.
|
|
14
|
+
*
|
|
15
|
+
* Design rules:
|
|
16
|
+
* - Every message is a flat discriminated union on `type`. No nested envelopes
|
|
17
|
+
* so clients can `switch (msg.type)` directly.
|
|
18
|
+
* - The host→client `session` event carries a {@link SnapshotPatch}: a `full`
|
|
19
|
+
* snapshot on first send / resync, then deltas (append-only suffixes for the
|
|
20
|
+
* unbounded text/reasoning fields + changed scalar/struct fields). The host
|
|
21
|
+
* remains the single source of truth — it holds the full snapshot and emits
|
|
22
|
+
* `full` on (re)subscribe or whenever a client reports a `seq` gap, so the
|
|
23
|
+
* wire is O(n) for a stream of total size n instead of O(n²).
|
|
24
|
+
* - `seq` is monotonic per session for ordering / gap detection.
|
|
25
|
+
* - Auth: a client authenticates in `hello` (token); loopback peers are exempt
|
|
26
|
+
* by host policy. No session data or control is processed before auth.
|
|
27
|
+
* - Versioned via PROTOCOL_VERSION; the handshake negotiates it.
|
|
28
|
+
*
|
|
29
|
+
* Extensibility (how this grows without forking the wire):
|
|
30
|
+
* - ADDITIVE by default: new optional fields on existing messages and brand-new
|
|
31
|
+
* `type`s are backward-compatible. Both host and client MUST ignore unknown
|
|
32
|
+
* message `type`s and unknown fields (the switch statements fall through, not
|
|
33
|
+
* throw) — so an old peer talks to a new peer safely.
|
|
34
|
+
* - Optional features are negotiated via `HostCapability` (advertised in
|
|
35
|
+
* `welcome`), not by version bumps — a client checks `host.capabilities`
|
|
36
|
+
* before using a non-core verb.
|
|
37
|
+
* - PROTOCOL_VERSION bumps only for a BREAKING change; `hello.v` lets either
|
|
38
|
+
* side detect a mismatch and degrade.
|
|
39
|
+
* - The transport (L1) is fully decoupled: a new binding (relay, WebTransport,
|
|
40
|
+
* QUIC) implements ChannelConnection and carries this protocol unchanged.
|
|
41
|
+
*/
|
|
42
|
+
export const PROTOCOL_VERSION = 1;
|
|
43
|
+
/** An empty baseline snapshot, used as the starting point before any patch. */
|
|
44
|
+
export function emptySnapshot() {
|
|
45
|
+
return { phase: 'idle', updatedAt: 0 };
|
|
46
|
+
}
|
|
47
|
+
const APPEND_FIELDS = ['text', 'reasoning'];
|
|
48
|
+
const STRUCT_FIELDS = ['plan', 'toolCalls', 'subAgents', 'usage', 'artifacts', 'interactions', 'queued'];
|
|
49
|
+
const SCALAR_FIELDS = ['phase', 'taskId', 'agent', 'model', 'effort', 'prompt', 'activity', 'error', 'incomplete', 'startedAt', 'updatedAt'];
|
|
50
|
+
/**
|
|
51
|
+
* Diff two full snapshots into a minimal patch. text/reasoning are encoded as
|
|
52
|
+
* append suffixes when the next value extends the prev (the streaming common
|
|
53
|
+
* case); any non-extension falls back to a `set`. Bounded fields are compared by
|
|
54
|
+
* value and sent whole when changed.
|
|
55
|
+
*/
|
|
56
|
+
export function diffSnapshot(prev, next) {
|
|
57
|
+
const patch = {};
|
|
58
|
+
let set;
|
|
59
|
+
const put = (k, v) => { (set ||= {})[k] = v; };
|
|
60
|
+
for (const f of APPEND_FIELDS) {
|
|
61
|
+
const a = prev[f] ?? '';
|
|
62
|
+
const b = next[f] ?? '';
|
|
63
|
+
if (a === b)
|
|
64
|
+
continue;
|
|
65
|
+
if (typeof b === 'string' && typeof a === 'string' && b.startsWith(a)) {
|
|
66
|
+
if (f === 'text')
|
|
67
|
+
patch.appendText = b.slice(a.length);
|
|
68
|
+
else
|
|
69
|
+
patch.appendReasoning = b.slice(a.length);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
put(f, b);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
for (const f of SCALAR_FIELDS) {
|
|
76
|
+
if (prev[f] !== next[f])
|
|
77
|
+
put(f, next[f]);
|
|
78
|
+
}
|
|
79
|
+
for (const f of STRUCT_FIELDS) {
|
|
80
|
+
if (JSON.stringify(prev[f]) !== JSON.stringify(next[f]))
|
|
81
|
+
put(f, next[f]);
|
|
82
|
+
}
|
|
83
|
+
if (set)
|
|
84
|
+
patch.set = set;
|
|
85
|
+
return patch;
|
|
86
|
+
}
|
|
87
|
+
/** Apply a patch onto a prior snapshot, returning the new cumulative snapshot. */
|
|
88
|
+
export function applySnapshotPatch(prev, patch) {
|
|
89
|
+
if (patch.full)
|
|
90
|
+
return patch.full;
|
|
91
|
+
const next = prev ? { ...prev } : emptySnapshot();
|
|
92
|
+
if (patch.appendText)
|
|
93
|
+
next.text = (next.text || '') + patch.appendText;
|
|
94
|
+
if (patch.appendReasoning)
|
|
95
|
+
next.reasoning = (next.reasoning || '') + patch.appendReasoning;
|
|
96
|
+
if (patch.set)
|
|
97
|
+
Object.assign(next, patch.set);
|
|
98
|
+
return next;
|
|
99
|
+
}
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Type guards (host-side convenience)
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
export function isClientMessage(value) {
|
|
104
|
+
return !!value && typeof value === 'object' && typeof value.type === 'string';
|
|
105
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pikichannel/rendezvous-broker.ts — the public signaling broker (NAT traversal).
|
|
3
|
+
*
|
|
4
|
+
* This is the "route, don't relay data" rendezvous from the original design. A
|
|
5
|
+
* pikiloom host that sits behind NAT dials OUT to a broker and registers a
|
|
6
|
+
* NodeID; a client dials the same broker by NodeID. The broker pairs them and
|
|
7
|
+
* relays ONLY signaling envelopes (SDP offer/answer + ICE candidates) — it never
|
|
8
|
+
* sees datachannel data, which flows directly P2P (DTLS-encrypted) once ICE
|
|
9
|
+
* hole-punches through. It is dependency-light (no werift) so any pikiloom can
|
|
10
|
+
* act as a broker for reachable peers, or it can be run standalone.
|
|
11
|
+
*
|
|
12
|
+
* Wire protocol (JSON over the rendezvous WebSocket):
|
|
13
|
+
* host→ {t:'register', nodeId} ← register to receive dials
|
|
14
|
+
* ← {t:'registered', nodeId} | {t:'error', message}
|
|
15
|
+
* client→{t:'dial', nodeId} ← reach a registered host
|
|
16
|
+
* ← {t:'dialed', sessionId} | {t:'error', message}
|
|
17
|
+
* broker→host {t:'open', sessionId} ← a client dialed; prepare answerer
|
|
18
|
+
* peer↔ {t:'signal', sessionId, data} ← relayed to the other end verbatim
|
|
19
|
+
* broker→peer {t:'close', sessionId} ← the other end went away
|
|
20
|
+
*/
|
|
21
|
+
import crypto from 'node:crypto';
|
|
22
|
+
import { WebSocketServer } from 'ws';
|
|
23
|
+
import { writeScopedLog } from '../core/logging.js';
|
|
24
|
+
const rlog = (msg) => writeScopedLog('pikichannel', `[rendezvous] ${msg}`, { level: 'info' });
|
|
25
|
+
export class RendezvousBroker {
|
|
26
|
+
wss = new WebSocketServer({ noServer: true });
|
|
27
|
+
hosts = new Map(); // nodeId → host socket
|
|
28
|
+
sessions = new Map(); // sessionId → pair
|
|
29
|
+
constructor() {
|
|
30
|
+
this.wss.on('connection', (ws) => this.onConnection(ws));
|
|
31
|
+
}
|
|
32
|
+
handleUpgrade(req, socket, head) {
|
|
33
|
+
this.wss.handleUpgrade(req, socket, head, (ws) => this.wss.emit('connection', ws, req));
|
|
34
|
+
}
|
|
35
|
+
stop() { this.wss.close(); }
|
|
36
|
+
get stats() { return { hosts: this.hosts.size, sessions: this.sessions.size }; }
|
|
37
|
+
send(ws, msg) {
|
|
38
|
+
if (ws.readyState === ws.OPEN) {
|
|
39
|
+
try {
|
|
40
|
+
ws.send(JSON.stringify(msg));
|
|
41
|
+
}
|
|
42
|
+
catch { /* ignore */ }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
onConnection(ws) {
|
|
46
|
+
let myNodeId = null; // set if this socket registers as a host
|
|
47
|
+
const mySessions = new Set(); // sessions this socket participates in
|
|
48
|
+
ws.on('message', (raw) => {
|
|
49
|
+
let m;
|
|
50
|
+
try {
|
|
51
|
+
m = JSON.parse(String(raw));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
switch (m?.t) {
|
|
57
|
+
case 'register': {
|
|
58
|
+
const nodeId = String(m.nodeId || '').trim();
|
|
59
|
+
if (!nodeId) {
|
|
60
|
+
this.send(ws, { t: 'error', message: 'nodeId required' });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
// Last registration wins (a host reconnecting replaces its stale socket).
|
|
64
|
+
this.hosts.set(nodeId, ws);
|
|
65
|
+
myNodeId = nodeId;
|
|
66
|
+
this.send(ws, { t: 'registered', nodeId });
|
|
67
|
+
rlog(`host registered nodeId=${nodeId} (hosts=${this.hosts.size})`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
case 'dial': {
|
|
71
|
+
const nodeId = String(m.nodeId || '').trim();
|
|
72
|
+
const host = this.hosts.get(nodeId);
|
|
73
|
+
if (!host || host.readyState !== host.OPEN) {
|
|
74
|
+
this.send(ws, { t: 'error', message: 'node offline' });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const sessionId = crypto.randomUUID();
|
|
78
|
+
this.sessions.set(sessionId, { client: ws, host, nodeId });
|
|
79
|
+
mySessions.add(sessionId);
|
|
80
|
+
this.send(ws, { t: 'dialed', sessionId });
|
|
81
|
+
this.send(host, { t: 'open', sessionId });
|
|
82
|
+
rlog(`dial nodeId=${nodeId} → session=${sessionId.slice(0, 8)}`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
case 'signal': {
|
|
86
|
+
const sessionId = String(m.sessionId || '');
|
|
87
|
+
const s = this.sessions.get(sessionId);
|
|
88
|
+
if (!s)
|
|
89
|
+
return;
|
|
90
|
+
const other = ws === s.client ? s.host : s.client;
|
|
91
|
+
mySessions.add(sessionId);
|
|
92
|
+
this.send(other, { t: 'signal', sessionId, data: m.data });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
const cleanup = () => {
|
|
98
|
+
if (myNodeId && this.hosts.get(myNodeId) === ws) {
|
|
99
|
+
this.hosts.delete(myNodeId);
|
|
100
|
+
rlog(`host gone nodeId=${myNodeId}`);
|
|
101
|
+
}
|
|
102
|
+
for (const sessionId of mySessions) {
|
|
103
|
+
const s = this.sessions.get(sessionId);
|
|
104
|
+
if (!s)
|
|
105
|
+
continue;
|
|
106
|
+
const other = ws === s.client ? s.host : s.client;
|
|
107
|
+
this.send(other, { t: 'close', sessionId });
|
|
108
|
+
this.sessions.delete(sessionId);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
ws.on('close', cleanup);
|
|
112
|
+
ws.on('error', cleanup);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pikichannel/rendezvous-host.ts — the host's OUTBOUND registrar (NAT side).
|
|
3
|
+
*
|
|
4
|
+
* A pikiloom host behind NAT can't be dialed directly, so it dials OUT to a
|
|
5
|
+
* reachable {@link RendezvousBroker} and registers its NodeID. For each client
|
|
6
|
+
* that dials that NodeID, the broker relays signaling and this client drives a
|
|
7
|
+
* shared werift answerer — the resulting datachannel is a normal pikichannel
|
|
8
|
+
* connection, indistinguishable downstream from a direct or local one. The
|
|
9
|
+
* registration auto-reconnects so the host stays reachable.
|
|
10
|
+
*
|
|
11
|
+
* Imports werift (via webrtc-shared), so the server wiring loads it only inside
|
|
12
|
+
* the guarded WebRTC dynamic import.
|
|
13
|
+
*/
|
|
14
|
+
import WebSocket from 'ws';
|
|
15
|
+
import { writeScopedLog } from '../core/logging.js';
|
|
16
|
+
import { createAnswerer } from './transports/webrtc-shared.js';
|
|
17
|
+
const rlog = (msg) => writeScopedLog('pikichannel', `[rendezvous-host] ${msg}`, { level: 'info' });
|
|
18
|
+
export class RendezvousHostClient {
|
|
19
|
+
url;
|
|
20
|
+
nodeId;
|
|
21
|
+
onConnection;
|
|
22
|
+
ws = null;
|
|
23
|
+
answerers = new Map();
|
|
24
|
+
adopted = new Set();
|
|
25
|
+
stopped = false;
|
|
26
|
+
reconnectDelay = 1000;
|
|
27
|
+
constructor(url, nodeId, onConnection) {
|
|
28
|
+
this.url = url;
|
|
29
|
+
this.nodeId = nodeId;
|
|
30
|
+
this.onConnection = onConnection;
|
|
31
|
+
}
|
|
32
|
+
start() { this.stopped = false; this.connect(); }
|
|
33
|
+
stop() {
|
|
34
|
+
this.stopped = true;
|
|
35
|
+
const ws = this.ws;
|
|
36
|
+
this.ws = null;
|
|
37
|
+
try {
|
|
38
|
+
ws?.close();
|
|
39
|
+
}
|
|
40
|
+
catch { /* ignore */ }
|
|
41
|
+
for (const a of this.answerers.values())
|
|
42
|
+
a.close();
|
|
43
|
+
this.answerers.clear();
|
|
44
|
+
this.adopted.clear();
|
|
45
|
+
}
|
|
46
|
+
connect() {
|
|
47
|
+
if (this.stopped)
|
|
48
|
+
return;
|
|
49
|
+
let ws;
|
|
50
|
+
try {
|
|
51
|
+
ws = new WebSocket(this.url);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
this.scheduleReconnect();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
this.ws = ws;
|
|
58
|
+
ws.on('open', () => {
|
|
59
|
+
this.reconnectDelay = 1000;
|
|
60
|
+
ws.send(JSON.stringify({ t: 'register', nodeId: this.nodeId }));
|
|
61
|
+
rlog(`registering nodeId=${this.nodeId} at ${this.url}`);
|
|
62
|
+
});
|
|
63
|
+
ws.on('message', (raw) => this.onMessage(raw));
|
|
64
|
+
ws.on('close', () => {
|
|
65
|
+
if (this.ws === ws)
|
|
66
|
+
this.ws = null;
|
|
67
|
+
for (const a of this.answerers.values())
|
|
68
|
+
a.close();
|
|
69
|
+
this.answerers.clear();
|
|
70
|
+
this.adopted.clear();
|
|
71
|
+
this.scheduleReconnect();
|
|
72
|
+
});
|
|
73
|
+
ws.on('error', () => { });
|
|
74
|
+
}
|
|
75
|
+
scheduleReconnect() {
|
|
76
|
+
if (this.stopped)
|
|
77
|
+
return;
|
|
78
|
+
setTimeout(() => this.connect(), this.reconnectDelay);
|
|
79
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 15_000);
|
|
80
|
+
}
|
|
81
|
+
onMessage(raw) {
|
|
82
|
+
let m;
|
|
83
|
+
try {
|
|
84
|
+
m = JSON.parse(String(raw));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
switch (m?.t) {
|
|
90
|
+
case 'registered':
|
|
91
|
+
rlog(`registered nodeId=${m.nodeId}`);
|
|
92
|
+
return;
|
|
93
|
+
case 'error':
|
|
94
|
+
rlog(`broker error: ${m.message}`);
|
|
95
|
+
return;
|
|
96
|
+
case 'open':
|
|
97
|
+
this.ensureAnswerer(m.sessionId);
|
|
98
|
+
return;
|
|
99
|
+
case 'close': {
|
|
100
|
+
const a = this.answerers.get(m.sessionId);
|
|
101
|
+
if (a) {
|
|
102
|
+
a.close();
|
|
103
|
+
this.answerers.delete(m.sessionId);
|
|
104
|
+
}
|
|
105
|
+
this.adopted.delete(m.sessionId);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
case 'signal': {
|
|
109
|
+
if (this.adopted.has(m.sessionId))
|
|
110
|
+
return; // datachannel already live; ignore late signals
|
|
111
|
+
const a = this.ensureAnswerer(m.sessionId);
|
|
112
|
+
void a.onSignal(m.data);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
ensureAnswerer(sessionId) {
|
|
118
|
+
let a = this.answerers.get(sessionId);
|
|
119
|
+
if (a)
|
|
120
|
+
return a;
|
|
121
|
+
a = createAnswerer({
|
|
122
|
+
remote: `rendezvous:${sessionId.slice(0, 8)}`,
|
|
123
|
+
log: rlog,
|
|
124
|
+
onConnection: (conn) => {
|
|
125
|
+
this.adopted.add(sessionId);
|
|
126
|
+
this.answerers.delete(sessionId); // pc now owned by the connection
|
|
127
|
+
this.onConnection(conn);
|
|
128
|
+
},
|
|
129
|
+
sendSignal: (data) => {
|
|
130
|
+
const ws = this.ws;
|
|
131
|
+
if (ws && ws.readyState === ws.OPEN)
|
|
132
|
+
ws.send(JSON.stringify({ t: 'signal', sessionId, data }));
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
this.answerers.set(sessionId, a);
|
|
136
|
+
return a;
|
|
137
|
+
}
|
|
138
|
+
}
|