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,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pikichannel/server.ts — wires pikichannel into the pikiloom dashboard server.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - build the host over the pikiloom SessionSource,
|
|
6
|
+
* - stand up BOTH transport bindings (WebSocket always; WebRTC via a guarded
|
|
7
|
+
* dynamic import so a missing/broken werift never blocks startup),
|
|
8
|
+
* - register the reference web routes (demo page + browser SDK + status),
|
|
9
|
+
* - expose `attachUpgrade(server)` so the dashboard's HTTP server can route
|
|
10
|
+
* `/pikichannel/*` upgrade requests to the right binding.
|
|
11
|
+
*
|
|
12
|
+
* Both bindings funnel into the SAME `host.handleConnection`, so a session
|
|
13
|
+
* behaves identically regardless of transport — that is the pluggability the
|
|
14
|
+
* two bindings exist to demonstrate.
|
|
15
|
+
*/
|
|
16
|
+
import crypto from 'node:crypto';
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { getConnInfo } from '@hono/node-server/conninfo';
|
|
20
|
+
import { writeScopedLog } from '../core/logging.js';
|
|
21
|
+
import { loadUserConfig, updateUserConfig } from '../core/config/user-config.js';
|
|
22
|
+
import { PikichannelHost } from './host.js';
|
|
23
|
+
import { PikiloomSessionSource } from './adapter-pikiloom.js';
|
|
24
|
+
import { WebSocketTransport } from './transports/websocket-host.js';
|
|
25
|
+
import { RendezvousBroker } from './rendezvous-broker.js';
|
|
26
|
+
/** A remote address label is loopback when it is the local host. */
|
|
27
|
+
function isLoopback(remote) {
|
|
28
|
+
if (!remote)
|
|
29
|
+
return false;
|
|
30
|
+
const r = remote.toLowerCase();
|
|
31
|
+
return r.startsWith('127.') || r.includes('127.0.0.1') || r === '::1' || r.startsWith('::1:') || r.startsWith('[::1]') || r.includes('::ffff:127.');
|
|
32
|
+
}
|
|
33
|
+
/** Constant-time token comparison. */
|
|
34
|
+
function tokenMatches(presented, expected) {
|
|
35
|
+
if (!presented || !expected)
|
|
36
|
+
return false;
|
|
37
|
+
const a = Buffer.from(presented);
|
|
38
|
+
const b = Buffer.from(expected);
|
|
39
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
40
|
+
}
|
|
41
|
+
function readWebAsset(name) {
|
|
42
|
+
const file = path.join(import.meta.dirname, 'web', name);
|
|
43
|
+
return fs.readFileSync(file, 'utf8');
|
|
44
|
+
}
|
|
45
|
+
export async function mountPikichannel(app) {
|
|
46
|
+
const log = (msg) => writeScopedLog('pikichannel', msg, { level: 'info' });
|
|
47
|
+
// -- Access token: provision on first run, persist to ~/.pikiloom/setting.json.
|
|
48
|
+
// Loopback peers (the local dashboard / same-machine demo) are exempt;
|
|
49
|
+
// remote peers must present this token in `hello`. `strict` requires the
|
|
50
|
+
// token even from loopback (defense-in-depth / testing). --
|
|
51
|
+
const cfg = loadUserConfig();
|
|
52
|
+
// Env overrides (docker / headless): PIKICHANNEL_TOKEN pins the token without
|
|
53
|
+
// persisting; PIKICHANNEL_STRICT=1 forces token even from loopback.
|
|
54
|
+
const envToken = String(process.env.PIKICHANNEL_TOKEN || '').trim();
|
|
55
|
+
let token = envToken || String(cfg.pikichannelToken || '').trim();
|
|
56
|
+
if (!token) {
|
|
57
|
+
token = crypto.randomBytes(24).toString('base64url');
|
|
58
|
+
try {
|
|
59
|
+
updateUserConfig({ pikichannelToken: token });
|
|
60
|
+
log('provisioned a new pikichannel access token');
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
log(`could not persist access token: ${err?.message}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const strict = cfg.pikichannelStrictAuth === true || process.env.PIKICHANNEL_STRICT === '1';
|
|
67
|
+
const authenticate = (presented, remote) => {
|
|
68
|
+
if (!strict && isLoopback(remote))
|
|
69
|
+
return true;
|
|
70
|
+
return tokenMatches(presented, token);
|
|
71
|
+
};
|
|
72
|
+
// -- NodeID: the stable address a remote client dials over the rendezvous. --
|
|
73
|
+
let nodeId = String(cfg.pikichannelNodeId || '').trim();
|
|
74
|
+
if (!nodeId) {
|
|
75
|
+
nodeId = crypto.randomBytes(8).toString('hex');
|
|
76
|
+
try {
|
|
77
|
+
updateUserConfig({ pikichannelNodeId: nodeId });
|
|
78
|
+
}
|
|
79
|
+
catch { /* persisted best-effort */ }
|
|
80
|
+
}
|
|
81
|
+
const rendezvousUrl = String(process.env.PIKICHANNEL_RENDEZVOUS || cfg.pikichannelRendezvous || '').trim();
|
|
82
|
+
let currentPublicHost = String(process.env.PIKICHANNEL_PUBLIC_HOST || cfg.pikichannelPublicHost || '').trim();
|
|
83
|
+
// Control-plane tunnel forwarder: replay a tunneled request against the SAME
|
|
84
|
+
// Hono router that serves /api/* locally — management logic stays single-sourced.
|
|
85
|
+
// Text responses ride as utf8; anything else (attachment bytes) as base64.
|
|
86
|
+
const forward = async (req) => {
|
|
87
|
+
const init = { method: req.method || 'GET', headers: req.headers };
|
|
88
|
+
if (req.body != null && req.method && req.method !== 'GET' && req.method !== 'HEAD') {
|
|
89
|
+
init.body = req.encoding === 'base64' ? new Uint8Array(Buffer.from(req.body, 'base64')) : req.body;
|
|
90
|
+
}
|
|
91
|
+
const res = await app.fetch(new Request(`http://pikichannel.internal${req.path}`, init));
|
|
92
|
+
const headers = {};
|
|
93
|
+
res.headers.forEach((v, k) => { headers[k] = v; });
|
|
94
|
+
const ct = (res.headers.get('content-type') || '').toLowerCase();
|
|
95
|
+
const isText = ct === '' || /json|text|javascript|xml|svg|x-ndjson|form-urlencoded/.test(ct);
|
|
96
|
+
if (isText)
|
|
97
|
+
return { status: res.status, headers, body: await res.text(), encoding: 'utf8' };
|
|
98
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
99
|
+
return { status: res.status, headers, body: buf.toString('base64'), encoding: 'base64' };
|
|
100
|
+
};
|
|
101
|
+
const source = new PikiloomSessionSource(forward);
|
|
102
|
+
const host = new PikichannelHost(source, authenticate, log);
|
|
103
|
+
host.start();
|
|
104
|
+
// -- WebSocket binding (always available) --
|
|
105
|
+
const wsTransport = new WebSocketTransport();
|
|
106
|
+
wsTransport.start((conn) => host.handleConnection(conn));
|
|
107
|
+
// -- WebRTC binding (guarded; degrades gracefully) --
|
|
108
|
+
let rtcTransport = null;
|
|
109
|
+
let webrtcError = null;
|
|
110
|
+
try {
|
|
111
|
+
const mod = await import('./transports/webrtc-host.js');
|
|
112
|
+
const t = new mod.WebRTCTransport();
|
|
113
|
+
t.start((conn) => host.handleConnection(conn));
|
|
114
|
+
rtcTransport = t;
|
|
115
|
+
log('webrtc transport ready');
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
webrtcError = err?.message || String(err);
|
|
119
|
+
log(`webrtc transport unavailable: ${webrtcError}`);
|
|
120
|
+
}
|
|
121
|
+
// -- Rendezvous broker (NAT traversal): always mounted (no werift dep), so any
|
|
122
|
+
// reachable pikiloom can broker signaling for NAT'd peers. Relays signaling
|
|
123
|
+
// only; data stays P2P. --
|
|
124
|
+
const broker = new RendezvousBroker();
|
|
125
|
+
// -- Rendezvous registrar: when enabled, this host dials OUT to a broker and
|
|
126
|
+
// registers its NodeID so remote clients can reach it through NAT. Can be
|
|
127
|
+
// toggled at RUNTIME (one-click from the dashboard) — no restart. Needs
|
|
128
|
+
// werift, so the host-client class is loaded only when WebRTC is available. --
|
|
129
|
+
let rendezvousHost = null;
|
|
130
|
+
let HostClientCtor = null;
|
|
131
|
+
let currentRendezvous = '';
|
|
132
|
+
if (rtcTransport) {
|
|
133
|
+
try {
|
|
134
|
+
HostClientCtor = (await import('./rendezvous-host.js')).RendezvousHostClient;
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
log(`rendezvous-host load failed: ${err?.message || err}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async function setRendezvous(url, persist = true) {
|
|
141
|
+
const next = (url || '').trim();
|
|
142
|
+
if (rendezvousHost) {
|
|
143
|
+
rendezvousHost.stop();
|
|
144
|
+
rendezvousHost = null;
|
|
145
|
+
}
|
|
146
|
+
currentRendezvous = '';
|
|
147
|
+
if (next) {
|
|
148
|
+
if (!HostClientCtor)
|
|
149
|
+
return { ok: false, error: 'WebRTC unavailable on this host' };
|
|
150
|
+
rendezvousHost = new HostClientCtor(next, nodeId, (conn) => host.handleConnection(conn));
|
|
151
|
+
rendezvousHost.start();
|
|
152
|
+
currentRendezvous = next;
|
|
153
|
+
}
|
|
154
|
+
if (persist) {
|
|
155
|
+
try {
|
|
156
|
+
updateUserConfig({ pikichannelRendezvous: next });
|
|
157
|
+
}
|
|
158
|
+
catch { /* best-effort */ }
|
|
159
|
+
}
|
|
160
|
+
log(`rendezvous ${next ? `enabled url=${next} nodeId=${nodeId}` : 'disabled'}`);
|
|
161
|
+
return { ok: true };
|
|
162
|
+
}
|
|
163
|
+
if (rendezvousUrl)
|
|
164
|
+
await setRendezvous(rendezvousUrl, false); // initial from env/config
|
|
165
|
+
// -- Web assets (read once; the demo + SDK double as the OSS reference client) --
|
|
166
|
+
let sdkJs = '';
|
|
167
|
+
let demoHtml = '';
|
|
168
|
+
try {
|
|
169
|
+
sdkJs = readWebAsset('sdk.js');
|
|
170
|
+
demoHtml = readWebAsset('demo.html');
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
log(`web assets missing: ${err?.message}`);
|
|
174
|
+
}
|
|
175
|
+
const serveDemo = (c) => { c.header('Cache-Control', 'no-cache'); return c.html(demoHtml || '<h1>pikichannel demo asset missing</h1>'); };
|
|
176
|
+
app.get('/pikichannel', serveDemo);
|
|
177
|
+
app.get('/pikichannel/', serveDemo);
|
|
178
|
+
// QR of a connection code (or any short string) as SVG — reuses the repo's
|
|
179
|
+
// `qrcode` dep so the browser SDK needs no QR library. Dynamic-imported to
|
|
180
|
+
// keep startup lean.
|
|
181
|
+
app.get('/pikichannel/qr', async (c) => {
|
|
182
|
+
const data = String(c.req.query('data') || '').slice(0, 2048);
|
|
183
|
+
if (!data)
|
|
184
|
+
return c.text('missing data', 400);
|
|
185
|
+
try {
|
|
186
|
+
const QRCode = (await import('qrcode')).default;
|
|
187
|
+
const svg = await QRCode.toString(data, { type: 'svg', margin: 1, errorCorrectionLevel: 'M' });
|
|
188
|
+
c.header('Content-Type', 'image/svg+xml; charset=utf-8');
|
|
189
|
+
c.header('Cache-Control', 'no-store');
|
|
190
|
+
return c.body(svg);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return c.text('qr failed', 500);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
app.get('/pikichannel/sdk.js', (c) => {
|
|
197
|
+
c.header('Content-Type', 'application/javascript; charset=utf-8');
|
|
198
|
+
c.header('Cache-Control', 'no-cache');
|
|
199
|
+
return c.body(sdkJs || '// pikichannel sdk asset missing');
|
|
200
|
+
});
|
|
201
|
+
app.get('/pikichannel/status', (c) => c.json(statusObj()));
|
|
202
|
+
// -- Pairing: hand the access token to a trusted LOCAL caller only (the
|
|
203
|
+
// dashboard / CLI on this machine). Remote callers get 403 — they must be
|
|
204
|
+
// paired out-of-band (token shown in the local dashboard, scanned as a QR). --
|
|
205
|
+
app.get('/pikichannel/pair', (c) => {
|
|
206
|
+
let remote;
|
|
207
|
+
try {
|
|
208
|
+
remote = getConnInfo(c)?.remote?.address;
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
remote = undefined;
|
|
212
|
+
}
|
|
213
|
+
if (!isLoopback(remote))
|
|
214
|
+
return c.json({ ok: false, error: 'pairing is only available from localhost' }, 403);
|
|
215
|
+
return c.json({ ok: true, token, strict, nodeId, rendezvous: currentRendezvous || null, publicHost: currentPublicHost || null, registered: !!rendezvousHost, hint: 'paste the connection code into a client' });
|
|
216
|
+
});
|
|
217
|
+
// -- Remote-access settings: configure how others reach THIS host — a public
|
|
218
|
+
// address (direct) and/or internet穿透 (rendezvous). Runtime, one-click,
|
|
219
|
+
// localhost-only (it changes who can reach this machine). --
|
|
220
|
+
app.post('/pikichannel/remote', async (c) => {
|
|
221
|
+
let remote;
|
|
222
|
+
try {
|
|
223
|
+
remote = getConnInfo(c)?.remote?.address;
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
remote = undefined;
|
|
227
|
+
}
|
|
228
|
+
if (!isLoopback(remote))
|
|
229
|
+
return c.json({ ok: false, error: 'only available from localhost' }, 403);
|
|
230
|
+
const body = await c.req.json().catch(() => ({}));
|
|
231
|
+
if ('publicHost' in body) {
|
|
232
|
+
currentPublicHost = String(body.publicHost || '').trim();
|
|
233
|
+
try {
|
|
234
|
+
updateUserConfig({ pikichannelPublicHost: currentPublicHost });
|
|
235
|
+
}
|
|
236
|
+
catch { /* best-effort */ }
|
|
237
|
+
}
|
|
238
|
+
let r = { ok: true };
|
|
239
|
+
if ('enabled' in body || 'rendezvous' in body) {
|
|
240
|
+
r = await setRendezvous(body?.enabled ? String(body?.rendezvous || '') : null);
|
|
241
|
+
}
|
|
242
|
+
return c.json({ ...r, registered: !!rendezvousHost, rendezvous: currentRendezvous || null, publicHost: currentPublicHost || null, nodeId, token });
|
|
243
|
+
});
|
|
244
|
+
function statusObj() {
|
|
245
|
+
return {
|
|
246
|
+
ok: true,
|
|
247
|
+
transports: rtcTransport ? ['websocket', 'webrtc'] : ['websocket'],
|
|
248
|
+
peers: host.peerCount,
|
|
249
|
+
authedPeers: host.authedPeerCount,
|
|
250
|
+
webrtc: !!rtcTransport,
|
|
251
|
+
webrtcError,
|
|
252
|
+
authRequired: true,
|
|
253
|
+
strict,
|
|
254
|
+
nodeId,
|
|
255
|
+
rendezvous: currentRendezvous || null,
|
|
256
|
+
publicHost: currentPublicHost || null,
|
|
257
|
+
registered: !!rendezvousHost,
|
|
258
|
+
broker: broker.stats,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
handleUpgrade(req, socket, head) {
|
|
263
|
+
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
264
|
+
if (!url.pathname.startsWith('/pikichannel/'))
|
|
265
|
+
return false; // not ours
|
|
266
|
+
if (url.pathname === '/pikichannel/ws') {
|
|
267
|
+
wsTransport.handleUpgrade(req, socket, head);
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
if (url.pathname === '/pikichannel/signal' && rtcTransport) {
|
|
271
|
+
rtcTransport.handleUpgrade(req, socket, head);
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
if (url.pathname === '/pikichannel/rendezvous') {
|
|
275
|
+
broker.handleUpgrade(req, socket, head);
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
socket.destroy(); // unknown /pikichannel/* subpath (or webrtc disabled)
|
|
279
|
+
return true;
|
|
280
|
+
},
|
|
281
|
+
status: statusObj,
|
|
282
|
+
stop() { host.stop(); wsTransport.stop(); rtcTransport?.stop(); rendezvousHost?.stop(); broker.stop(); },
|
|
283
|
+
};
|
|
284
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pikichannel/transport.ts — the L1 transport abstraction (host side).
|
|
3
|
+
*
|
|
4
|
+
* This is the seam that makes the two delivery mechanisms — WebSocket and
|
|
5
|
+
* WebRTC datachannel — interchangeable. A {@link ChannelTransport} accepts peer
|
|
6
|
+
* connections and surfaces each as a {@link ChannelConnection}: a reliable,
|
|
7
|
+
* ordered, bidirectional frame pipe. The host ({@link PikichannelHost}) drives
|
|
8
|
+
* the L2 protocol over a ChannelConnection and is wholly blind to whether the
|
|
9
|
+
* bytes travel over a TCP WebSocket or an SCTP datachannel — flip the binding,
|
|
10
|
+
* the session behaves identically. That blindness IS the comparability the two
|
|
11
|
+
* bindings are meant to demonstrate.
|
|
12
|
+
*
|
|
13
|
+
* Contract every binding must honour:
|
|
14
|
+
* - reliable + ordered delivery of whole frames (no partial frames),
|
|
15
|
+
* - bidirectional,
|
|
16
|
+
* - a single 'close' notification, exactly once,
|
|
17
|
+
* - `send()` after close is a silent no-op (never throws).
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Minimal base that implements the callback bookkeeping every binding repeats,
|
|
21
|
+
* so concrete connections only push frames in and call `emitClose()` once.
|
|
22
|
+
*/
|
|
23
|
+
export class BaseConnection {
|
|
24
|
+
remote;
|
|
25
|
+
messageCb = null;
|
|
26
|
+
closeCb = null;
|
|
27
|
+
closed = false;
|
|
28
|
+
onMessage(cb) { this.messageCb = cb; }
|
|
29
|
+
onClose(cb) {
|
|
30
|
+
this.closeCb = cb;
|
|
31
|
+
// If close already happened before the handler was attached, fire now.
|
|
32
|
+
if (this.closed)
|
|
33
|
+
cb();
|
|
34
|
+
}
|
|
35
|
+
/** Concrete bindings call this when a frame arrives. */
|
|
36
|
+
emitMessage(frame) {
|
|
37
|
+
if (this.messageCb)
|
|
38
|
+
this.messageCb(frame);
|
|
39
|
+
}
|
|
40
|
+
/** Concrete bindings call this exactly once when the pipe closes. */
|
|
41
|
+
emitClose() {
|
|
42
|
+
if (this.closed)
|
|
43
|
+
return;
|
|
44
|
+
this.closed = true;
|
|
45
|
+
if (this.closeCb)
|
|
46
|
+
this.closeCb();
|
|
47
|
+
}
|
|
48
|
+
isOpen() { return !this.closed; }
|
|
49
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pikichannel/transports/webrtc-host.ts — the DIRECT WebRTC binding (host).
|
|
3
|
+
*
|
|
4
|
+
* SDP/ICE over a same-origin `/pikichannel/signal` WebSocket, for clients that
|
|
5
|
+
* can already reach the host (localhost / LAN / public IP). The cross-NAT path —
|
|
6
|
+
* where the host dials OUT to a public broker — lives in rendezvous.ts. Both
|
|
7
|
+
* paths share the same werift answerer (webrtc-shared.ts), so a connection from
|
|
8
|
+
* either is identical downstream.
|
|
9
|
+
*
|
|
10
|
+
* werift is imported (transitively) here; the server wiring loads this module via
|
|
11
|
+
* a guarded dynamic import, so a missing/broken werift disables WebRTC while the
|
|
12
|
+
* WebSocket binding and the dashboard keep working.
|
|
13
|
+
*/
|
|
14
|
+
import { WebSocketServer } from 'ws';
|
|
15
|
+
import { writeScopedLog } from '../../core/logging.js';
|
|
16
|
+
import { createAnswerer } from './webrtc-shared.js';
|
|
17
|
+
const dlog = (msg) => writeScopedLog('pikichannel', `[webrtc] ${msg}`, { level: 'info' });
|
|
18
|
+
export class WebRTCTransport {
|
|
19
|
+
kind = 'webrtc';
|
|
20
|
+
wss = new WebSocketServer({ noServer: true });
|
|
21
|
+
onConn = null;
|
|
22
|
+
start(onConnection) {
|
|
23
|
+
this.onConn = onConnection;
|
|
24
|
+
this.wss.on('connection', (ws, req) => this.runSignaling(ws, req));
|
|
25
|
+
}
|
|
26
|
+
/** Server wiring routes a matching signaling upgrade request here. */
|
|
27
|
+
handleUpgrade(req, socket, head) {
|
|
28
|
+
this.wss.handleUpgrade(req, socket, head, (ws) => this.wss.emit('connection', ws, req));
|
|
29
|
+
}
|
|
30
|
+
stop() {
|
|
31
|
+
this.wss.close();
|
|
32
|
+
}
|
|
33
|
+
/** One signaling WebSocket brokers exactly one peer connection. */
|
|
34
|
+
runSignaling(ws, req) {
|
|
35
|
+
const remote = req.socket.remoteAddress ? `${req.socket.remoteAddress}:${req.socket.remotePort}` : undefined;
|
|
36
|
+
const answerer = createAnswerer({
|
|
37
|
+
remote,
|
|
38
|
+
log: dlog,
|
|
39
|
+
onConnection: (conn) => this.onConn?.(conn),
|
|
40
|
+
sendSignal: (data) => { if (ws.readyState === ws.OPEN)
|
|
41
|
+
ws.send(JSON.stringify(data)); },
|
|
42
|
+
});
|
|
43
|
+
ws.on('message', (raw) => {
|
|
44
|
+
let msg;
|
|
45
|
+
try {
|
|
46
|
+
msg = JSON.parse(String(raw));
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
void answerer.onSignal(msg);
|
|
52
|
+
});
|
|
53
|
+
// The signaling socket is only needed for the handshake; once the datachannel
|
|
54
|
+
// carries traffic it can close. If it drops before a channel was adopted,
|
|
55
|
+
// tear the half-open peer down.
|
|
56
|
+
const cleanup = () => { if (!answerer.isAdopted())
|
|
57
|
+
answerer.close(); };
|
|
58
|
+
ws.on('close', cleanup);
|
|
59
|
+
ws.on('error', cleanup);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pikichannel/transports/webrtc-shared.ts — shared werift answerer + connection.
|
|
3
|
+
*
|
|
4
|
+
* The host is always the WebRTC *answerer* (the browser/SDK creates the offer +
|
|
5
|
+
* datachannel). The same answerer logic drives two signaling paths:
|
|
6
|
+
* - direct (webrtc-host.ts): SDP/ICE over a same-origin `/pikichannel/signal`
|
|
7
|
+
* WebSocket — for clients that can already reach the host.
|
|
8
|
+
* - rendezvous (rendezvous.ts): SDP/ICE relayed through a public broker both
|
|
9
|
+
* peers dial OUTBOUND — the NAT-traversal path.
|
|
10
|
+
*
|
|
11
|
+
* Once the datachannel opens the bytes are pure P2P (DTLS-encrypted); signaling
|
|
12
|
+
* only brokers the handshake. `getIceServers()` is the STUN/TURN config hook:
|
|
13
|
+
* STUN by default, TURN added via PIKICHANNEL_ICE_SERVERS for symmetric NAT.
|
|
14
|
+
*/
|
|
15
|
+
import { RTCPeerConnection } from 'werift';
|
|
16
|
+
import { BaseConnection } from '../transport.js';
|
|
17
|
+
let connCounter = 0;
|
|
18
|
+
/**
|
|
19
|
+
* ICE servers for hole-punching. STUN alone covers most NATs; add a TURN relay
|
|
20
|
+
* for symmetric-NAT reliability via:
|
|
21
|
+
* PIKICHANNEL_ICE_SERVERS='[{"urls":"stun:stun.l.google.com:19302"},
|
|
22
|
+
* {"urls":"turn:turn.example.com:3478","username":"u","credential":"p"}]'
|
|
23
|
+
*/
|
|
24
|
+
export function getIceServers() {
|
|
25
|
+
const raw = process.env.PIKICHANNEL_ICE_SERVERS;
|
|
26
|
+
if (raw) {
|
|
27
|
+
try {
|
|
28
|
+
const v = JSON.parse(raw);
|
|
29
|
+
if (Array.isArray(v) && v.length)
|
|
30
|
+
return v;
|
|
31
|
+
}
|
|
32
|
+
catch { /* fall through to default */ }
|
|
33
|
+
}
|
|
34
|
+
return [{ urls: 'stun:stun.l.google.com:19302' }];
|
|
35
|
+
}
|
|
36
|
+
function coerceFrame(data) {
|
|
37
|
+
if (typeof data === 'string')
|
|
38
|
+
return data;
|
|
39
|
+
if (Buffer.isBuffer(data))
|
|
40
|
+
return data.toString('utf8');
|
|
41
|
+
try {
|
|
42
|
+
return Buffer.from(new Uint8Array(data)).toString('utf8');
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return String(data);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** A pikichannel connection riding an SCTP datachannel. */
|
|
49
|
+
export class RtcConnection extends BaseConnection {
|
|
50
|
+
channel;
|
|
51
|
+
pc;
|
|
52
|
+
id;
|
|
53
|
+
kind = 'webrtc';
|
|
54
|
+
constructor(channel, pc, remote) {
|
|
55
|
+
super();
|
|
56
|
+
this.channel = channel;
|
|
57
|
+
this.pc = pc;
|
|
58
|
+
this.id = `rtc-${++connCounter}`;
|
|
59
|
+
this.remote = remote || 'webrtc-peer';
|
|
60
|
+
channel.onmessage = (ev) => this.emitMessage(coerceFrame(ev?.data));
|
|
61
|
+
channel.onclose = () => this.close();
|
|
62
|
+
pc.connectionStateChange.subscribe((state) => {
|
|
63
|
+
if (state === 'failed' || state === 'closed' || state === 'disconnected')
|
|
64
|
+
this.close();
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
send(frame) {
|
|
68
|
+
if (this.channel.readyState === 'open') {
|
|
69
|
+
try {
|
|
70
|
+
this.channel.send(frame);
|
|
71
|
+
}
|
|
72
|
+
catch { /* drop on closing channel */ }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
isOpen() { return this.channel.readyState === 'open'; }
|
|
76
|
+
close() {
|
|
77
|
+
try {
|
|
78
|
+
this.channel.close();
|
|
79
|
+
}
|
|
80
|
+
catch { /* ignore */ }
|
|
81
|
+
try {
|
|
82
|
+
this.pc.close();
|
|
83
|
+
}
|
|
84
|
+
catch { /* ignore */ }
|
|
85
|
+
this.emitClose();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Build a werift answerer. The caller wires `sendSignal` to whatever signaling
|
|
90
|
+
* channel it has (a WS, or the rendezvous), feeds inbound signals to `onSignal`,
|
|
91
|
+
* and receives the live {@link ChannelConnection} via `onConnection` once the
|
|
92
|
+
* datachannel opens. Trickle ICE: the answer is sent immediately, candidates
|
|
93
|
+
* follow as they are gathered.
|
|
94
|
+
*/
|
|
95
|
+
export function createAnswerer(opts) {
|
|
96
|
+
const pc = new RTCPeerConnection({ iceServers: getIceServers() });
|
|
97
|
+
let adopted = false;
|
|
98
|
+
pc.onDataChannel.subscribe((channel) => {
|
|
99
|
+
adopted = true;
|
|
100
|
+
opts.onConnection(new RtcConnection(channel, pc, opts.remote));
|
|
101
|
+
});
|
|
102
|
+
pc.onIceCandidate.subscribe((candidate) => {
|
|
103
|
+
if (candidate)
|
|
104
|
+
opts.sendSignal({ kind: 'candidate', candidate: candidate.toJSON ? candidate.toJSON() : candidate });
|
|
105
|
+
});
|
|
106
|
+
return {
|
|
107
|
+
async onSignal(data) {
|
|
108
|
+
try {
|
|
109
|
+
if (data.kind === 'offer' && typeof data.sdp === 'string') {
|
|
110
|
+
await pc.setRemoteDescription({ type: 'offer', sdp: data.sdp });
|
|
111
|
+
const answer = await pc.createAnswer();
|
|
112
|
+
await pc.setLocalDescription(answer);
|
|
113
|
+
const local = pc.localDescription;
|
|
114
|
+
if (local)
|
|
115
|
+
opts.sendSignal({ kind: 'answer', type: local.type, sdp: local.sdp });
|
|
116
|
+
}
|
|
117
|
+
else if (data.kind === 'candidate' && data.candidate) {
|
|
118
|
+
await pc.addIceCandidate(data.candidate);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
opts.log?.(`answerer signal error: ${err?.message || err}`);
|
|
123
|
+
opts.sendSignal({ kind: 'error', message: err?.message || 'signaling failed' });
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
isAdopted: () => adopted,
|
|
127
|
+
close() { try {
|
|
128
|
+
pc.close();
|
|
129
|
+
}
|
|
130
|
+
catch { /* ignore */ } },
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pikichannel/transports/websocket-host.ts — the WebSocket L1 binding (host).
|
|
3
|
+
*
|
|
4
|
+
* The baseline transport: protocol frames ride a raw WebSocket. Same-machine /
|
|
5
|
+
* LAN clients (the Dashboard case) want exactly this — there is no NAT to
|
|
6
|
+
* traverse, so a TCP WebSocket is the optimal path. It is the control against
|
|
7
|
+
* which the WebRTC binding is compared.
|
|
8
|
+
*
|
|
9
|
+
* The server wiring owns upgrade routing and feeds matching upgrades to
|
|
10
|
+
* `handleUpgrade`; this class only manages the noServer WebSocketServer and
|
|
11
|
+
* wraps each socket as a {@link ChannelConnection}.
|
|
12
|
+
*/
|
|
13
|
+
import { WebSocketServer } from 'ws';
|
|
14
|
+
import { BaseConnection } from '../transport.js';
|
|
15
|
+
const WS_KEEPALIVE_MS = 25_000;
|
|
16
|
+
let connCounter = 0;
|
|
17
|
+
class WsConnection extends BaseConnection {
|
|
18
|
+
ws;
|
|
19
|
+
id;
|
|
20
|
+
kind = 'websocket';
|
|
21
|
+
constructor(ws, remote) {
|
|
22
|
+
super();
|
|
23
|
+
this.ws = ws;
|
|
24
|
+
this.id = `ws-${++connCounter}`;
|
|
25
|
+
this.remote = remote;
|
|
26
|
+
ws.on('message', (raw) => {
|
|
27
|
+
const data = raw;
|
|
28
|
+
const frame = typeof data === 'string'
|
|
29
|
+
? data
|
|
30
|
+
: Buffer.isBuffer(data)
|
|
31
|
+
? data.toString('utf8')
|
|
32
|
+
: Array.isArray(data)
|
|
33
|
+
? Buffer.concat(data).toString('utf8')
|
|
34
|
+
: Buffer.from(data).toString('utf8');
|
|
35
|
+
this.emitMessage(frame);
|
|
36
|
+
});
|
|
37
|
+
ws.on('close', () => this.emitClose());
|
|
38
|
+
ws.on('error', () => this.emitClose());
|
|
39
|
+
}
|
|
40
|
+
send(frame) {
|
|
41
|
+
if (this.ws.readyState === this.ws.OPEN)
|
|
42
|
+
this.ws.send(frame);
|
|
43
|
+
}
|
|
44
|
+
isOpen() {
|
|
45
|
+
return this.ws.readyState === this.ws.OPEN;
|
|
46
|
+
}
|
|
47
|
+
close() {
|
|
48
|
+
try {
|
|
49
|
+
this.ws.close();
|
|
50
|
+
}
|
|
51
|
+
catch { /* ignore */ }
|
|
52
|
+
this.emitClose();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export class WebSocketTransport {
|
|
56
|
+
kind = 'websocket';
|
|
57
|
+
wss = new WebSocketServer({ noServer: true });
|
|
58
|
+
onConn = null;
|
|
59
|
+
start(onConnection) {
|
|
60
|
+
this.onConn = onConnection;
|
|
61
|
+
this.wss.on('connection', (ws, req) => {
|
|
62
|
+
const remote = req.socket.remoteAddress ? `${req.socket.remoteAddress}:${req.socket.remotePort}` : undefined;
|
|
63
|
+
const keepalive = setInterval(() => {
|
|
64
|
+
if (ws.readyState === ws.OPEN)
|
|
65
|
+
ws.ping();
|
|
66
|
+
}, WS_KEEPALIVE_MS);
|
|
67
|
+
ws.on('close', () => clearInterval(keepalive));
|
|
68
|
+
this.onConn?.(new WsConnection(ws, remote));
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/** Server wiring routes a matching upgrade request here. */
|
|
72
|
+
handleUpgrade(req, socket, head) {
|
|
73
|
+
this.wss.handleUpgrade(req, socket, head, (ws) => this.wss.emit('connection', ws, req));
|
|
74
|
+
}
|
|
75
|
+
stop() {
|
|
76
|
+
this.wss.close();
|
|
77
|
+
}
|
|
78
|
+
}
|