pikiloom 0.4.20 → 0.4.22

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 (42) hide show
  1. package/dashboard/dist/assets/{AgentTab-CDVhy5K1.js → AgentTab-G0uiUjBk.js} +1 -1
  2. package/dashboard/dist/assets/ConnectionModal-BQvV5ok2.js +1 -0
  3. package/dashboard/dist/assets/{DirBrowser-BElI1-4D.js → DirBrowser-BGacUCRb.js} +1 -1
  4. package/dashboard/dist/assets/ExtensionsTab-Bd2rjOVu.js +1 -0
  5. package/dashboard/dist/assets/{IMAccessTab-IZt_yXoG.js → IMAccessTab-BD-ZwAOb.js} +1 -1
  6. package/dashboard/dist/assets/{Modal-C1EAGSL1.js → Modal-BwmINu44.js} +1 -1
  7. package/dashboard/dist/assets/{Modals-DYUV5yR9.js → Modals-ncL7g7fW.js} +1 -1
  8. package/dashboard/dist/assets/{Select-BnsbE6Qv.js → Select-D7Iz4BLf.js} +1 -1
  9. package/dashboard/dist/assets/{SessionPanel-Ca_TVTT1.js → SessionPanel-zxiA8Pfd.js} +1 -1
  10. package/dashboard/dist/assets/{SystemTab-Dk6k2OTt.js → SystemTab-DZjEZmVB.js} +1 -1
  11. package/dashboard/dist/assets/index-CTHVuMnd.js +23 -0
  12. package/dashboard/dist/assets/index-CyCspgtc.css +1 -0
  13. package/dashboard/dist/assets/index-DEvh1R9o.js +3 -0
  14. package/dashboard/dist/assets/{shared-CZVD0MJD.js → shared-D5VHAaFg.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-BB8ipJ77.js +0 -1
  40. package/dashboard/dist/assets/index-CK-3CNRp.js +0 -3
  41. package/dashboard/dist/assets/index-CnJsD381.js +0 -23
  42. package/dashboard/dist/assets/index-dzfjF9Js.css +0 -1
@@ -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
+ }
@@ -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
+ }