@johnhenry/browsermesh-transport 0.0.0

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/src/wisp.mjs ADDED
@@ -0,0 +1,348 @@
1
+ /**
2
+ * clawser-wisp-transport.mjs -- WISP Transport Adapter for WSH.
3
+ *
4
+ * Adapts a WispClient as a mesh transport that can carry WSH protocol
5
+ * messages through a WISP relay tunnel. WSH commands are serialized
6
+ * and sent over a dedicated control stream; additional streams can be
7
+ * opened for data transfer, RPC, or mesh relay connections.
8
+ *
9
+ * Implements the same interface as WebSocketTransport from
10
+ * clawser-mesh-websocket.js so it can be used interchangeably.
11
+ *
12
+ * Standalone uses beyond WSH:
13
+ * - Exposing RPC mode over a tunneled port
14
+ * - Mesh relay connections through restrictive networks
15
+ * - Future v86 guest networking
16
+ *
17
+ * Run tests:
18
+ * node --import ./web/test/_setup-globals.mjs --test web/test/clawser-wisp-transport.test.mjs
19
+ */
20
+
21
+ import { WispClient, WispStream, WISP_DATA } from './wisp-client.mjs'
22
+ import { silentCatch } from './silent-catch.mjs'
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Constants
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /** Default control stream port (WSH protocol) */
29
+ const WSH_CONTROL_PORT = 9083
30
+
31
+ /** Valid events */
32
+ const EVENTS = Object.freeze(['open', 'message', 'close', 'error', 'reconnect', 'stream'])
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // WispTransport
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /**
39
+ * WISP-backed mesh transport.
40
+ *
41
+ * Wraps a WispClient to present the same interface as WebSocketTransport.
42
+ * A control stream carries WSH messages; additional streams can be opened
43
+ * for side-channel data (file transfers, RPC tunnels, etc.).
44
+ *
45
+ * @example
46
+ * const transport = new WispTransport({
47
+ * url: 'wss://wisp-relay.example.com/',
48
+ * targetHost: 'my-server.local',
49
+ * targetPort: 9083,
50
+ * })
51
+ * await transport.connect()
52
+ * transport.send(wshMessage)
53
+ * transport.on('message', (data) => handleWshMessage(data))
54
+ *
55
+ * @example
56
+ * // Open additional streams for data transfer
57
+ * const dataStream = transport.openStream('data-host.local', 8080)
58
+ * dataStream.write(payload)
59
+ */
60
+ export class WispTransport {
61
+ /** @type {string} */
62
+ #url
63
+
64
+ /** @type {string} */
65
+ #targetHost
66
+
67
+ /** @type {number} */
68
+ #targetPort
69
+
70
+ /** @type {WispClient|null} */
71
+ #client = null
72
+
73
+ /** @type {WispStream|null} */
74
+ #controlStream = null
75
+
76
+ /** @type {string} */
77
+ #state = 'disconnected'
78
+
79
+ /** @type {boolean} */
80
+ #reconnect
81
+
82
+ /** @type {number} */
83
+ #maxReconnectAttempts
84
+
85
+ /** @type {number} */
86
+ #reconnectDelayMs
87
+
88
+ /** @type {Function} */
89
+ #WebSocketCtor
90
+
91
+ /** @type {{ open: Function[], message: Function[], close: Function[], error: Function[], reconnect: Function[], stream: Function[] }} */
92
+ #callbacks = { open: [], message: [], close: [], error: [], reconnect: [], stream: [] }
93
+
94
+ /** @type {{ messagesSent: number, messagesReceived: number, bytesIn: number, bytesOut: number, reconnects: number }} */
95
+ #stats = { messagesSent: 0, messagesReceived: 0, bytesIn: 0, bytesOut: 0, reconnects: 0 }
96
+
97
+ /**
98
+ * @param {object} opts
99
+ * @param {string} opts.url - WISP relay WebSocket URL
100
+ * @param {string} [opts.targetHost='localhost'] - Target host for the WSH control stream
101
+ * @param {number} [opts.targetPort=9083] - Target port for the WSH control stream
102
+ * @param {boolean} [opts.reconnect=true] - Enable auto-reconnect
103
+ * @param {number} [opts.maxReconnectAttempts=5] - Max reconnection attempts
104
+ * @param {number} [opts.reconnectDelayMs=1000] - Base delay between reconnects
105
+ * @param {Function} [opts._WebSocket] - Injectable WebSocket constructor (for testing)
106
+ * @param {Function} [opts._WispClient] - Injectable WispClient constructor (for testing)
107
+ */
108
+ constructor(opts = {}) {
109
+ if (!opts.url) throw new Error('url is required')
110
+ this.#url = opts.url
111
+ this.#targetHost = opts.targetHost || 'localhost'
112
+ this.#targetPort = opts.targetPort || WSH_CONTROL_PORT
113
+ this.#reconnect = opts.reconnect !== undefined ? opts.reconnect : true
114
+ this.#maxReconnectAttempts = opts.maxReconnectAttempts ?? 5
115
+ this.#reconnectDelayMs = opts.reconnectDelayMs ?? 1000
116
+ this.#WebSocketCtor = opts._WebSocket || globalThis.WebSocket
117
+
118
+ // Allow injecting a custom WispClient for testing
119
+ if (opts._WispClient) {
120
+ /** @type {Function} */
121
+ this._WispClientCtor = opts._WispClient
122
+ }
123
+ }
124
+
125
+ // -- Getters ---------------------------------------------------------------
126
+
127
+ /** Transport type identifier. */
128
+ get type() { return 'wisp' }
129
+
130
+ /** Current connection state. */
131
+ get state() { return this.#state }
132
+
133
+ /** True when transport is connected. */
134
+ get connected() { return this.#state === 'connected' }
135
+
136
+ /** WISP relay URL. */
137
+ get url() { return this.#url }
138
+
139
+ /** Target host for the control stream. */
140
+ get targetHost() { return this.#targetHost }
141
+
142
+ /** Target port for the control stream. */
143
+ get targetPort() { return this.#targetPort }
144
+
145
+ /** The underlying WispClient instance. */
146
+ get client() { return this.#client }
147
+
148
+ /** Whether auto-reconnect is enabled. */
149
+ get reconnectEnabled() { return this.#reconnect }
150
+
151
+ // -- Public API ------------------------------------------------------------
152
+
153
+ /**
154
+ * Connect to the WISP relay and open the control stream.
155
+ * @returns {Promise<void>}
156
+ */
157
+ async connect() {
158
+ if (this.#state === 'connected' || this.#state === 'connecting') {
159
+ throw new Error('Already connected or connecting')
160
+ }
161
+ this.#state = 'connecting'
162
+
163
+ try {
164
+ const ClientCtor = this._WispClientCtor || WispClient
165
+ this.#client = new ClientCtor({
166
+ url: this.#url,
167
+ reconnect: false, // we handle reconnection at this layer
168
+ _WebSocket: this.#WebSocketCtor,
169
+ })
170
+
171
+ this.#client.on('error', (err) => this._fireEvent('error', err))
172
+ this.#client.on('close', () => this.#handleClientClose())
173
+
174
+ await this.#client.connect()
175
+
176
+ // open the WSH control stream
177
+ this.#controlStream = this.#client.open(this.#targetHost, this.#targetPort)
178
+ this.#controlStream.onData((data) => {
179
+ this.#stats.messagesReceived++
180
+ this.#stats.bytesIn += data.byteLength
181
+ this._fireEvent('message', data)
182
+ })
183
+ this.#controlStream.onClose((reason) => {
184
+ // control stream closed — treat as transport close
185
+ if (this.#state === 'connected') {
186
+ this.#handleClientClose()
187
+ }
188
+ })
189
+ this.#controlStream.onError((err) => this._fireEvent('error', err))
190
+
191
+ this.#state = 'connected'
192
+ this._fireEvent('open')
193
+ } catch (err) {
194
+ this.#state = 'disconnected'
195
+ this._fireEvent('error', err)
196
+ throw err
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Send data over the WSH control stream.
202
+ *
203
+ * @param {Uint8Array|ArrayBuffer|string} data
204
+ */
205
+ send(data) {
206
+ if (!this.connected) throw new Error('Not connected')
207
+ if (!this.#controlStream || this.#controlStream.closed) {
208
+ throw new Error('Control stream is closed')
209
+ }
210
+ let bytes
211
+ if (typeof data === 'string') {
212
+ bytes = new TextEncoder().encode(data)
213
+ } else if (data instanceof ArrayBuffer) {
214
+ bytes = new Uint8Array(data)
215
+ } else {
216
+ bytes = data
217
+ }
218
+ this.#controlStream.write(bytes)
219
+ this.#stats.messagesSent++
220
+ this.#stats.bytesOut += bytes.byteLength
221
+ }
222
+
223
+ /**
224
+ * Open an additional stream through the WISP relay.
225
+ * Useful for side-channel data: file transfers, RPC tunnels, etc.
226
+ *
227
+ * @example
228
+ * const rpcStream = transport.openStream('rpc-server.local', 5000)
229
+ *
230
+ * @param {string} host - Target host
231
+ * @param {number} port - Target port
232
+ * @returns {WispStream}
233
+ */
234
+ openStream(host, port) {
235
+ if (!this.connected || !this.#client) throw new Error('Not connected')
236
+ const stream = this.#client.open(host, port)
237
+ this._fireEvent('stream', stream)
238
+ return stream
239
+ }
240
+
241
+ /**
242
+ * Close the transport and underlying WISP client.
243
+ * @param {number} [code]
244
+ * @param {string} [reason]
245
+ * @returns {Promise<void>}
246
+ */
247
+ async close(code, reason) {
248
+ if (this.#state === 'closed' || this.#state === 'disconnected') return
249
+ this.#state = 'closing'
250
+
251
+ if (this.#client) {
252
+ await this.#client.close()
253
+ }
254
+ this.#controlStream = null
255
+ this.#state = 'closed'
256
+ this._fireEvent('close')
257
+ }
258
+
259
+ /**
260
+ * Register an event listener.
261
+ * @param {string} event - One of: 'open', 'message', 'close', 'error', 'reconnect', 'stream'
262
+ * @param {Function} cb
263
+ */
264
+ on(event, cb) {
265
+ if (!EVENTS.includes(event)) throw new Error(`Unknown event: ${event}`)
266
+ this.#callbacks[event].push(cb)
267
+ }
268
+
269
+ /**
270
+ * Get transport statistics.
271
+ * @returns {object}
272
+ */
273
+ getStats() {
274
+ return { ...this.#stats }
275
+ }
276
+
277
+ /**
278
+ * Serialize to a JSON-safe object.
279
+ * @returns {object}
280
+ */
281
+ toJSON() {
282
+ return {
283
+ type: this.type,
284
+ state: this.#state,
285
+ url: this.#url,
286
+ targetHost: this.#targetHost,
287
+ targetPort: this.#targetPort,
288
+ stats: this.getStats(),
289
+ clientInfo: this.#client ? this.#client.toJSON() : null,
290
+ }
291
+ }
292
+
293
+ // -- Internal methods ------------------------------------------------------
294
+
295
+ /**
296
+ * Handle underlying client disconnect.
297
+ */
298
+ #handleClientClose() {
299
+ if (this.#state === 'closing' || this.#state === 'closed') return
300
+
301
+ this.#controlStream = null
302
+ this.#state = 'disconnected'
303
+ this._fireEvent('close')
304
+
305
+ if (this.#reconnect) {
306
+ this.#attemptReconnect()
307
+ }
308
+ }
309
+
310
+ /** @type {number} */
311
+ #reconnectAttempts = 0
312
+
313
+ /**
314
+ * Attempt reconnection with exponential backoff.
315
+ */
316
+ async #attemptReconnect() {
317
+ if (this.#reconnectAttempts >= this.#maxReconnectAttempts) return
318
+
319
+ this.#reconnectAttempts++
320
+ this.#stats.reconnects++
321
+ this._fireEvent('reconnect', { attempt: this.#reconnectAttempts })
322
+
323
+ const delay = this.#reconnectDelayMs * Math.pow(2, this.#reconnectAttempts - 1)
324
+ await new Promise(r => setTimeout(r, delay))
325
+
326
+ if (this.#state === 'closing' || this.#state === 'closed') return
327
+
328
+ try {
329
+ await this.connect()
330
+ this.#reconnectAttempts = 0
331
+ } catch {
332
+ if (this.#reconnect && this.#reconnectAttempts < this.#maxReconnectAttempts) {
333
+ this.#attemptReconnect()
334
+ }
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Fire all callbacks for a given event.
340
+ * @param {string} event
341
+ * @param {*} [data]
342
+ */
343
+ _fireEvent(event, data) {
344
+ for (const cb of this.#callbacks[event] || []) {
345
+ try { cb(data) } catch (e) { silentCatch('clawser-wisp-transport', 'swallow-listener-errors', e) }
346
+ }
347
+ }
348
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ // STATUS: implemented + unit-tested (clawser-mesh-wsh-bridge.test.mjs), but
3
+ // never instantiated in app code — `import { MeshWshBridge }` in
4
+ // clawser-pod.js has no matching `new MeshWshBridge(...)` call site anywhere.
5
+ // Not a functional replacement for clawser-kernel-wsh-bridge.js despite the
6
+ // similar name and shared mention in docs/data/networking.yaml's "WSH Bridge
7
+ // Deprecation" entry — this bridges identity key *formats*, not wsh session
8
+ // tenant/capability routing.
9
+ * clawser-mesh-wsh-bridge.js -- Bridge between WshKeyStore and MeshIdentityManager.
10
+ *
11
+ * WshKeyStore uses hex-encoded SHA-256 fingerprints.
12
+ * MeshIdentityManager uses base64url-encoded SHA-256 pod IDs.
13
+ * Both hash the same raw Ed25519 public key bytes with SHA-256.
14
+ *
15
+ * This bridge converts between the two formats and syncs keys.
16
+ *
17
+ * Run tests:
18
+ * node --import ./web/test/_setup-globals.mjs --test web/test/clawser-mesh-wsh-bridge.test.mjs
19
+ */
20
+
21
+ import {
22
+ derivePodId,
23
+ encodeBase64url,
24
+ decodeBase64url,
25
+ } from '@johnhenry/browsermesh-primitives';
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Hex <-> Base64url conversion helpers
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * Convert hex string to Uint8Array.
33
+ * @param {string} hex
34
+ * @returns {Uint8Array}
35
+ */
36
+ function hexToBytes(hex) {
37
+ const bytes = new Uint8Array(hex.length / 2);
38
+ for (let i = 0; i < hex.length; i += 2) {
39
+ bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
40
+ }
41
+ return bytes;
42
+ }
43
+
44
+ /**
45
+ * Convert Uint8Array to hex string.
46
+ * @param {Uint8Array} bytes
47
+ * @returns {string}
48
+ */
49
+ function bytesToHex(bytes) {
50
+ return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // MeshWshBridge
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /**
58
+ * Bridges WshKeyStore (hex fingerprints) with MeshIdentityManager (base64url pod IDs).
59
+ *
60
+ * Both systems use SHA-256 of the raw Ed25519 public key as their identifier,
61
+ * but encode it differently: WshKeyStore uses lowercase hex, MeshIdentityManager
62
+ * uses base64url (no padding).
63
+ */
64
+ export class MeshWshBridge {
65
+ /** @type {import('./packages/wsh/src/keystore.mjs').WshKeyStore} */
66
+ #wshKeyStore;
67
+
68
+ /** @type {import('./clawser-mesh-identity.js').MeshIdentityManager} */
69
+ #meshIdentityManager;
70
+
71
+ /**
72
+ * @param {*} wshKeyStore
73
+ * @param {*} meshIdentityManager
74
+ */
75
+ constructor(wshKeyStore, meshIdentityManager) {
76
+ if (!wshKeyStore) throw new Error('wshKeyStore is required');
77
+ if (!meshIdentityManager) throw new Error('meshIdentityManager is required');
78
+ this.#wshKeyStore = wshKeyStore;
79
+ this.#meshIdentityManager = meshIdentityManager;
80
+ }
81
+
82
+ /**
83
+ * Convert a hex fingerprint to a base64url pod ID.
84
+ * Both are SHA-256 of the same raw public key, just different encodings.
85
+ * @param {string} hex - Hex-encoded SHA-256 fingerprint
86
+ * @returns {string} Base64url-encoded pod ID
87
+ */
88
+ fingerprint2podId(hex) {
89
+ const bytes = hexToBytes(hex);
90
+ return encodeBase64url(bytes);
91
+ }
92
+
93
+ /**
94
+ * Convert a base64url pod ID to a hex fingerprint.
95
+ * @param {string} b64url - Base64url-encoded pod ID
96
+ * @returns {string} Hex-encoded fingerprint
97
+ */
98
+ podId2fingerprint(b64url) {
99
+ const bytes = decodeBase64url(b64url);
100
+ return bytesToHex(bytes);
101
+ }
102
+
103
+ /**
104
+ * Import a key from WshKeyStore into MeshIdentityManager.
105
+ * @param {string} fingerprint - Hex fingerprint from WshKeyStore
106
+ * @returns {Promise<string>} The pod ID of the imported identity
107
+ */
108
+ async importFromWsh(fingerprint) {
109
+ // Check if already imported
110
+ const podId = this.fingerprint2podId(fingerprint);
111
+ if (this.#meshIdentityManager.has(podId)) {
112
+ return podId;
113
+ }
114
+
115
+ // Find the key in WshKeyStore by listing and matching fingerprint
116
+ const keys = await this.#wshKeyStore.listKeys();
117
+ const entry = keys.find(k => k.fingerprint === fingerprint);
118
+ if (!entry) {
119
+ throw new Error(`Key with fingerprint ${fingerprint} not found in WshKeyStore`);
120
+ }
121
+
122
+ // Get the full key pair
123
+ const keyPair = await this.#wshKeyStore.getKeyPair(entry.name);
124
+
125
+ // Export private key as JWK for mesh import
126
+ const jwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
127
+
128
+ const summary = await this.#meshIdentityManager.import(jwk, `wsh:${entry.name}`, {
129
+ metadata: { source: 'wsh', wshName: entry.name, wshFingerprint: fingerprint },
130
+ });
131
+
132
+ return summary.podId;
133
+ }
134
+
135
+ /**
136
+ * Export an identity from MeshIdentityManager to WshKeyStore.
137
+ * @param {string} podId - Base64url pod ID
138
+ * @returns {Promise<string>} The hex fingerprint in WshKeyStore
139
+ */
140
+ async exportToWsh(podId) {
141
+ const fp = this.podId2fingerprint(podId);
142
+
143
+ // Check if already in WshKeyStore
144
+ const keys = await this.#wshKeyStore.listKeys();
145
+ const existing = keys.find(k => k.fingerprint === fp);
146
+ if (existing) {
147
+ return fp;
148
+ }
149
+
150
+ // Export from mesh as JWK
151
+ const jwk = await this.#meshIdentityManager.export(podId);
152
+
153
+ // Import into WshKeyStore
154
+ // WshKeyStore expects raw key import, but we have JWK
155
+ // We'll import via crypto.subtle first, then use WshKeyStore's generateKey pattern
156
+ const summary = this.#meshIdentityManager.get(podId);
157
+ const name = `mesh:${summary?.label || podId.slice(0, 8)}`;
158
+
159
+ // Import the private key as extractable
160
+ const privateKey = await crypto.subtle.importKey(
161
+ 'jwk',
162
+ jwk,
163
+ { name: 'Ed25519' },
164
+ true,
165
+ ['sign']
166
+ );
167
+
168
+ // Derive public key
169
+ const pubJwk = { ...jwk };
170
+ delete pubJwk.d;
171
+ pubJwk.key_ops = ['verify'];
172
+ const publicKey = await crypto.subtle.importKey(
173
+ 'jwk',
174
+ pubJwk,
175
+ { name: 'Ed25519' },
176
+ true,
177
+ ['verify']
178
+ );
179
+
180
+ // Store directly in WshKeyStore's internal DB
181
+ // We need to use the _put method or store via the keystore's approach
182
+ // Since WshKeyStore doesn't have a direct import method, we'll use its internal _put
183
+ if (typeof this.#wshKeyStore._put === 'function') {
184
+ await this.#wshKeyStore._ensureDb();
185
+ await this.#wshKeyStore._put({
186
+ name,
187
+ publicKey,
188
+ privateKey,
189
+ createdAt: Date.now(),
190
+ fingerprint: fp,
191
+ });
192
+ } else {
193
+ throw new Error('WshKeyStore does not support direct key import');
194
+ }
195
+
196
+ return fp;
197
+ }
198
+
199
+ /**
200
+ * Sync all keys between both stores.
201
+ * Imports from WshKeyStore into Mesh that don't exist in Mesh,
202
+ * and exports from Mesh to WshKeyStore that don't exist in Wsh.
203
+ * @returns {Promise<{imported: number, exported: number}>}
204
+ */
205
+ async syncAll() {
206
+ let imported = 0;
207
+ let exported = 0;
208
+
209
+ // Import from Wsh -> Mesh
210
+ const wshKeys = await this.#wshKeyStore.listKeys();
211
+ for (const key of wshKeys) {
212
+ const podId = this.fingerprint2podId(key.fingerprint);
213
+ if (!this.#meshIdentityManager.has(podId)) {
214
+ try {
215
+ await this.importFromWsh(key.fingerprint);
216
+ imported++;
217
+ } catch {
218
+ // Skip keys that fail to import (e.g., non-extractable)
219
+ }
220
+ }
221
+ }
222
+
223
+ // Export from Mesh -> Wsh
224
+ const meshIds = this.#meshIdentityManager.list();
225
+ for (const id of meshIds) {
226
+ const fp = this.podId2fingerprint(id.podId);
227
+ const existing = wshKeys.find(k => k.fingerprint === fp);
228
+ if (!existing) {
229
+ try {
230
+ await this.exportToWsh(id.podId);
231
+ exported++;
232
+ } catch {
233
+ // Skip keys that fail to export
234
+ }
235
+ }
236
+ }
237
+
238
+ return { imported, exported };
239
+ }
240
+ }
241
+
242
+ export { hexToBytes, bytesToHex };