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.
Files changed (41) hide show
  1. package/dashboard/dist/assets/{AgentTab-Crg9GEUv.js → AgentTab-hzdSX7gP.js} +1 -1
  2. package/dashboard/dist/assets/ConnectionModal-Dj2eoHWz.js +1 -0
  3. package/dashboard/dist/assets/{DirBrowser-Do4XzF2X.js → DirBrowser-C4DPPZ1V.js} +1 -1
  4. package/dashboard/dist/assets/ExtensionsTab-BdGHiEnP.js +1 -0
  5. package/dashboard/dist/assets/{IMAccessTab-BX90Aiuq.js → IMAccessTab-C4RlXeCP.js} +1 -1
  6. package/dashboard/dist/assets/{Modal-Dd5twuoE.js → Modal-D22Z00Ux.js} +1 -1
  7. package/dashboard/dist/assets/{Modals--Xl6dsUf.js → Modals-CHXLG4V2.js} +1 -1
  8. package/dashboard/dist/assets/{Select-Bp6SwWv6.js → Select-D4JIewGZ.js} +1 -1
  9. package/dashboard/dist/assets/{SessionPanel-CHaOUor-.js → SessionPanel-Ba93D9jh.js} +1 -1
  10. package/dashboard/dist/assets/{SystemTab-MVpMgFFD.js → SystemTab-M8r-o2KI.js} +1 -1
  11. package/dashboard/dist/assets/index-C02W4e24.js +3 -0
  12. package/dashboard/dist/assets/index-CyCspgtc.css +1 -0
  13. package/dashboard/dist/assets/{index-7pyJSn4B.js → index-p2XCgnzw.js} +17 -17
  14. package/dashboard/dist/assets/{shared-CxR9M3rn.js → shared-DW8iXiGd.js} +1 -1
  15. package/dashboard/dist/index.html +2 -2
  16. package/dist/agent/index.js +1 -1
  17. package/dist/agent/skill-installer.js +95 -0
  18. package/dist/catalog/skill-repos.js +12 -0
  19. package/dist/cli/main.js +31 -1
  20. package/dist/core/secrets/ref.js +8 -0
  21. package/dist/core/secrets/store.js +31 -11
  22. package/dist/dashboard/routes/extensions.js +137 -24
  23. package/dist/dashboard/server.js +27 -64
  24. package/dist/pikichannel/adapter-pikiloom.js +220 -0
  25. package/dist/pikichannel/code.js +35 -0
  26. package/dist/pikichannel/codec.js +50 -0
  27. package/dist/pikichannel/host.js +252 -0
  28. package/dist/pikichannel/protocol.js +105 -0
  29. package/dist/pikichannel/rendezvous-broker.js +114 -0
  30. package/dist/pikichannel/rendezvous-host.js +138 -0
  31. package/dist/pikichannel/server.js +284 -0
  32. package/dist/pikichannel/transport.js +49 -0
  33. package/dist/pikichannel/transports/webrtc-host.js +61 -0
  34. package/dist/pikichannel/transports/webrtc-shared.js +132 -0
  35. package/dist/pikichannel/transports/websocket-host.js +78 -0
  36. package/dist/pikichannel/web/demo.html +246 -0
  37. package/dist/pikichannel/web/sdk.js +361 -0
  38. package/package.json +4 -2
  39. package/dashboard/dist/assets/ExtensionsTab-j77Yqoz1.js +0 -1
  40. package/dashboard/dist/assets/index-BiPI4uFE.js +0 -3
  41. 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
+ }