@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/LICENSE +21 -0
- package/README.md +40 -0
- package/package.json +31 -0
- package/src/channel-relay.mjs +225 -0
- package/src/cross-origin.mjs +543 -0
- package/src/gateway.mjs +627 -0
- package/src/index.mjs +12 -0
- package/src/relay.mjs +653 -0
- package/src/silent-catch.mjs +55 -0
- package/src/streams.mjs +627 -0
- package/src/transport.mjs +357 -0
- package/src/webrtc.mjs +773 -0
- package/src/websocket.mjs +1082 -0
- package/src/webtransport.mjs +216 -0
- package/src/wisp-client.mjs +747 -0
- package/src/wisp.mjs +348 -0
- package/src/wsh-bridge.mjs +242 -0
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
import { silentCatch } from './silent-catch.mjs'
|
|
2
|
+
/**
|
|
3
|
+
* clawser-wisp.mjs -- WISP (WebSocket Internet Subprotocol) Transport.
|
|
4
|
+
*
|
|
5
|
+
* Multiplexes TCP streams over a single WebSocket connection to a WISP
|
|
6
|
+
* relay server. Each stream gets a unique 32-bit stream ID. Messages
|
|
7
|
+
* are binary frames with a type byte + stream ID + payload.
|
|
8
|
+
*
|
|
9
|
+
* WISP frame format (little-endian):
|
|
10
|
+
* [type:u8][streamId:u32][payload:...]
|
|
11
|
+
*
|
|
12
|
+
* Message types:
|
|
13
|
+
* 0x01 CONNECT — client→relay: open TCP stream (payload = host\0 + port:u16)
|
|
14
|
+
* 0x02 DATA — bidirectional: stream data
|
|
15
|
+
* 0x03 CONTINUE — relay→client: flow control (payload = buffer_remaining:u32)
|
|
16
|
+
* 0x04 CLOSE — bidirectional: close stream (payload = reason:u8)
|
|
17
|
+
* 0x05 INFO — relay→client: server info (WISP v2 extension)
|
|
18
|
+
*
|
|
19
|
+
* Can be used standalone for tunneling or as the backing transport for
|
|
20
|
+
* clawser-wisp-transport.mjs (WSH adapter).
|
|
21
|
+
*
|
|
22
|
+
* Run tests:
|
|
23
|
+
* node --import ./web/test/_setup-globals.mjs --test web/test/clawser-wisp.test.mjs
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Constants
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/** WISP frame types */
|
|
31
|
+
export const WISP_CONNECT = 0x01
|
|
32
|
+
export const WISP_DATA = 0x02
|
|
33
|
+
export const WISP_CONTINUE = 0x03
|
|
34
|
+
export const WISP_CLOSE = 0x04
|
|
35
|
+
export const WISP_INFO = 0x05
|
|
36
|
+
|
|
37
|
+
/** Close reasons */
|
|
38
|
+
export const CLOSE_REASON_NORMAL = 0x00
|
|
39
|
+
export const CLOSE_REASON_REFUSED = 0x01
|
|
40
|
+
export const CLOSE_REASON_THROTTLED = 0x02
|
|
41
|
+
export const CLOSE_REASON_UNREACHABLE = 0x03
|
|
42
|
+
export const CLOSE_REASON_TIMEOUT = 0x04
|
|
43
|
+
export const CLOSE_REASON_ERROR = 0x05
|
|
44
|
+
|
|
45
|
+
/** Client states */
|
|
46
|
+
const STATES = Object.freeze(['disconnected', 'connecting', 'connected', 'closing', 'closed'])
|
|
47
|
+
|
|
48
|
+
/** Valid event names for WispClient */
|
|
49
|
+
const CLIENT_EVENTS = Object.freeze(['open', 'close', 'error', 'reconnect', 'info'])
|
|
50
|
+
|
|
51
|
+
/** Valid event names for WispStream */
|
|
52
|
+
const STREAM_EVENTS = Object.freeze(['data', 'close', 'error', 'continue'])
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Frame encoding / decoding
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Encode a WISP frame.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* const frame = encodeFrame(WISP_DATA, 42, new Uint8Array([1, 2, 3]))
|
|
63
|
+
*
|
|
64
|
+
* @param {number} type - Frame type byte
|
|
65
|
+
* @param {number} streamId - 32-bit stream ID
|
|
66
|
+
* @param {Uint8Array} [payload] - Optional payload bytes
|
|
67
|
+
* @returns {Uint8Array}
|
|
68
|
+
*/
|
|
69
|
+
export const encodeFrame = (type, streamId, payload) => {
|
|
70
|
+
const payloadLen = payload ? payload.byteLength : 0
|
|
71
|
+
const buf = new Uint8Array(5 + payloadLen)
|
|
72
|
+
const view = new DataView(buf.buffer)
|
|
73
|
+
view.setUint8(0, type)
|
|
74
|
+
view.setUint32(1, streamId, true) // little-endian
|
|
75
|
+
if (payload) buf.set(payload, 5)
|
|
76
|
+
return buf
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Decode a WISP frame from binary data.
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* const { type, streamId, payload } = decodeFrame(data)
|
|
84
|
+
*
|
|
85
|
+
* @param {ArrayBuffer|Uint8Array} data - Raw frame bytes
|
|
86
|
+
* @returns {{ type: number, streamId: number, payload: Uint8Array }}
|
|
87
|
+
*/
|
|
88
|
+
export const decodeFrame = (data) => {
|
|
89
|
+
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data)
|
|
90
|
+
if (bytes.byteLength < 5) throw new Error('WISP frame too short')
|
|
91
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
92
|
+
const type = view.getUint8(0)
|
|
93
|
+
const streamId = view.getUint32(1, true)
|
|
94
|
+
const payload = bytes.slice(5)
|
|
95
|
+
return { type, streamId, payload }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Encode a CONNECT payload: host (null-terminated UTF-8) + port (u16 LE).
|
|
100
|
+
*
|
|
101
|
+
* @param {string} host
|
|
102
|
+
* @param {number} port
|
|
103
|
+
* @returns {Uint8Array}
|
|
104
|
+
*/
|
|
105
|
+
export const encodeConnectPayload = (host, port) => {
|
|
106
|
+
const encoder = new TextEncoder()
|
|
107
|
+
const hostBytes = encoder.encode(host)
|
|
108
|
+
const buf = new Uint8Array(hostBytes.byteLength + 1 + 2)
|
|
109
|
+
buf.set(hostBytes, 0)
|
|
110
|
+
buf[hostBytes.byteLength] = 0x00 // null terminator
|
|
111
|
+
const view = new DataView(buf.buffer)
|
|
112
|
+
view.setUint16(hostBytes.byteLength + 1, port, true)
|
|
113
|
+
return buf
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Decode a CONNECT payload.
|
|
118
|
+
*
|
|
119
|
+
* @param {Uint8Array} payload
|
|
120
|
+
* @returns {{ host: string, port: number }}
|
|
121
|
+
*/
|
|
122
|
+
export const decodeConnectPayload = (payload) => {
|
|
123
|
+
const nullIdx = payload.indexOf(0x00)
|
|
124
|
+
if (nullIdx === -1) throw new Error('Invalid CONNECT payload: no null terminator')
|
|
125
|
+
const decoder = new TextDecoder()
|
|
126
|
+
const host = decoder.decode(payload.slice(0, nullIdx))
|
|
127
|
+
const view = new DataView(payload.buffer, payload.byteOffset + nullIdx + 1, 2)
|
|
128
|
+
const port = view.getUint16(0, true)
|
|
129
|
+
return { host, port }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// WispStream
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A single multiplexed TCP stream within a WISP connection.
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* const stream = await client.connect('example.com', 80)
|
|
141
|
+
* stream.onData((data) => console.log('received', data))
|
|
142
|
+
* stream.write(new TextEncoder().encode('GET / HTTP/1.0\r\n\r\n'))
|
|
143
|
+
* stream.close()
|
|
144
|
+
*/
|
|
145
|
+
export class WispStream {
|
|
146
|
+
/** @type {number} */
|
|
147
|
+
#id
|
|
148
|
+
|
|
149
|
+
/** @type {string} */
|
|
150
|
+
#host
|
|
151
|
+
|
|
152
|
+
/** @type {number} */
|
|
153
|
+
#port
|
|
154
|
+
|
|
155
|
+
/** @type {boolean} */
|
|
156
|
+
#closed = false
|
|
157
|
+
|
|
158
|
+
/** @type {number} */
|
|
159
|
+
#bufferRemaining = 0
|
|
160
|
+
|
|
161
|
+
/** @type {Uint8Array[]} */
|
|
162
|
+
#writeQueue = []
|
|
163
|
+
|
|
164
|
+
/** @type {Function|null} */
|
|
165
|
+
#sendFrame
|
|
166
|
+
|
|
167
|
+
/** @type {{ data: Function[], close: Function[], error: Function[], continue: Function[] }} */
|
|
168
|
+
#callbacks = { data: [], close: [], error: [], continue: [] }
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* @param {number} id - Stream ID
|
|
172
|
+
* @param {string} host - Target host
|
|
173
|
+
* @param {number} port - Target port
|
|
174
|
+
* @param {Function} sendFrame - Callback to send frames via the parent client
|
|
175
|
+
*/
|
|
176
|
+
constructor(id, host, port, sendFrame) {
|
|
177
|
+
this.#id = id
|
|
178
|
+
this.#host = host
|
|
179
|
+
this.#port = port
|
|
180
|
+
this.#sendFrame = sendFrame
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Stream ID. */
|
|
184
|
+
get id() { return this.#id }
|
|
185
|
+
|
|
186
|
+
/** Target host. */
|
|
187
|
+
get host() { return this.#host }
|
|
188
|
+
|
|
189
|
+
/** Target port. */
|
|
190
|
+
get port() { return this.#port }
|
|
191
|
+
|
|
192
|
+
/** Whether the stream has been closed. */
|
|
193
|
+
get closed() { return this.#closed }
|
|
194
|
+
|
|
195
|
+
/** Remaining buffer space reported by relay. */
|
|
196
|
+
get bufferRemaining() { return this.#bufferRemaining }
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Write data to the stream.
|
|
200
|
+
*
|
|
201
|
+
* @example
|
|
202
|
+
* stream.write(new TextEncoder().encode('hello'))
|
|
203
|
+
* stream.write(new Uint8Array([0x01, 0x02]))
|
|
204
|
+
*
|
|
205
|
+
* @param {Uint8Array|ArrayBuffer|string} data
|
|
206
|
+
*/
|
|
207
|
+
write(data) {
|
|
208
|
+
if (this.#closed) throw new Error(`Stream ${this.#id} is closed`)
|
|
209
|
+
let bytes
|
|
210
|
+
if (typeof data === 'string') {
|
|
211
|
+
bytes = new TextEncoder().encode(data)
|
|
212
|
+
} else if (data instanceof ArrayBuffer) {
|
|
213
|
+
bytes = new Uint8Array(data)
|
|
214
|
+
} else {
|
|
215
|
+
bytes = data
|
|
216
|
+
}
|
|
217
|
+
this.#sendFrame(encodeFrame(WISP_DATA, this.#id, bytes))
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Register callback for incoming data.
|
|
222
|
+
* @param {(data: Uint8Array) => void} cb
|
|
223
|
+
*/
|
|
224
|
+
onData(cb) { this.#callbacks.data.push(cb) }
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Register callback for stream close.
|
|
228
|
+
* @param {(reason: number) => void} cb
|
|
229
|
+
*/
|
|
230
|
+
onClose(cb) { this.#callbacks.close.push(cb) }
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Register callback for stream errors.
|
|
234
|
+
* @param {(err: Error) => void} cb
|
|
235
|
+
*/
|
|
236
|
+
onError(cb) { this.#callbacks.error.push(cb) }
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Register callback for CONTINUE (flow control) messages.
|
|
240
|
+
* @param {(bufferRemaining: number) => void} cb
|
|
241
|
+
*/
|
|
242
|
+
onContinue(cb) { this.#callbacks.continue.push(cb) }
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Close the stream gracefully.
|
|
246
|
+
* @param {number} [reason=CLOSE_REASON_NORMAL]
|
|
247
|
+
*/
|
|
248
|
+
close(reason = CLOSE_REASON_NORMAL) {
|
|
249
|
+
if (this.#closed) return
|
|
250
|
+
this.#closed = true
|
|
251
|
+
const payload = new Uint8Array([reason])
|
|
252
|
+
this.#sendFrame(encodeFrame(WISP_CLOSE, this.#id, payload))
|
|
253
|
+
this._fireEvent('close', reason)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// -- Internal methods (called by WispClient) --------------------------------
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Handle incoming DATA frame.
|
|
260
|
+
* @param {Uint8Array} payload
|
|
261
|
+
* @internal
|
|
262
|
+
*/
|
|
263
|
+
_handleData(payload) {
|
|
264
|
+
if (this.#closed) return
|
|
265
|
+
this._fireEvent('data', payload)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Handle incoming CONTINUE frame.
|
|
270
|
+
* @param {Uint8Array} payload
|
|
271
|
+
* @internal
|
|
272
|
+
*/
|
|
273
|
+
_handleContinue(payload) {
|
|
274
|
+
if (payload.byteLength >= 4) {
|
|
275
|
+
const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength)
|
|
276
|
+
this.#bufferRemaining = view.getUint32(0, true)
|
|
277
|
+
}
|
|
278
|
+
this._fireEvent('continue', this.#bufferRemaining)
|
|
279
|
+
|
|
280
|
+
// flush write queue if buffer space available
|
|
281
|
+
while (this.#writeQueue.length > 0 && this.#bufferRemaining > 0) {
|
|
282
|
+
const queued = this.#writeQueue.shift()
|
|
283
|
+
this.#sendFrame(encodeFrame(WISP_DATA, this.#id, queued))
|
|
284
|
+
this.#bufferRemaining--
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Handle remote close.
|
|
290
|
+
* @param {number} reason
|
|
291
|
+
* @internal
|
|
292
|
+
*/
|
|
293
|
+
_handleClose(reason) {
|
|
294
|
+
this.#closed = true
|
|
295
|
+
this.#sendFrame = () => {} // no-op after close
|
|
296
|
+
this._fireEvent('close', reason)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Force-close without sending a frame (used during disconnect).
|
|
301
|
+
* @internal
|
|
302
|
+
*/
|
|
303
|
+
_forceClose() {
|
|
304
|
+
this.#closed = true
|
|
305
|
+
this.#sendFrame = () => {}
|
|
306
|
+
this._fireEvent('close', CLOSE_REASON_ERROR)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Fire all callbacks for a given event.
|
|
311
|
+
* @param {string} event
|
|
312
|
+
* @param {*} [data]
|
|
313
|
+
*/
|
|
314
|
+
_fireEvent(event, data) {
|
|
315
|
+
for (const cb of this.#callbacks[event] || []) {
|
|
316
|
+
try { cb(data) } catch (e) { silentCatch('clawser-wisp', 'swallow-listener-errors', e) }
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Serialize to a JSON-safe object.
|
|
322
|
+
* @returns {object}
|
|
323
|
+
*/
|
|
324
|
+
toJSON() {
|
|
325
|
+
return {
|
|
326
|
+
id: this.#id,
|
|
327
|
+
host: this.#host,
|
|
328
|
+
port: this.#port,
|
|
329
|
+
closed: this.#closed,
|
|
330
|
+
bufferRemaining: this.#bufferRemaining,
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
// WispClient
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* WISP client — connects to a relay server and multiplexes TCP streams.
|
|
341
|
+
*
|
|
342
|
+
* @example
|
|
343
|
+
* const client = new WispClient({
|
|
344
|
+
* url: 'wss://wisp-relay.example.com/',
|
|
345
|
+
* _WebSocket: MockWebSocket, // for testing
|
|
346
|
+
* })
|
|
347
|
+
* await client.connect()
|
|
348
|
+
*
|
|
349
|
+
* const stream = await client.open('httpbin.org', 80)
|
|
350
|
+
* stream.onData((data) => console.log(new TextDecoder().decode(data)))
|
|
351
|
+
* stream.write(new TextEncoder().encode('GET / HTTP/1.0\r\nHost: httpbin.org\r\n\r\n'))
|
|
352
|
+
*/
|
|
353
|
+
export class WispClient {
|
|
354
|
+
/** @type {string} */
|
|
355
|
+
#url
|
|
356
|
+
|
|
357
|
+
/** @type {string} */
|
|
358
|
+
#state = 'disconnected'
|
|
359
|
+
|
|
360
|
+
/** @type {object|null} */
|
|
361
|
+
#ws = null
|
|
362
|
+
|
|
363
|
+
/** @type {Function} */
|
|
364
|
+
#WebSocketCtor
|
|
365
|
+
|
|
366
|
+
/** @type {boolean} */
|
|
367
|
+
#reconnect
|
|
368
|
+
|
|
369
|
+
/** @type {number} */
|
|
370
|
+
#maxReconnectAttempts
|
|
371
|
+
|
|
372
|
+
/** @type {number} */
|
|
373
|
+
#reconnectDelayMs
|
|
374
|
+
|
|
375
|
+
/** @type {number} */
|
|
376
|
+
#reconnectAttempts = 0
|
|
377
|
+
|
|
378
|
+
/** @type {boolean} */
|
|
379
|
+
#userClosed = false
|
|
380
|
+
|
|
381
|
+
/** @type {number} */
|
|
382
|
+
#nextStreamId = 1
|
|
383
|
+
|
|
384
|
+
/** @type {Map<number, WispStream>} */
|
|
385
|
+
#streams = new Map()
|
|
386
|
+
|
|
387
|
+
/** @type {{ open: Function[], close: Function[], error: Function[], reconnect: Function[], info: Function[] }} */
|
|
388
|
+
#callbacks = { open: [], close: [], error: [], reconnect: [], info: [] }
|
|
389
|
+
|
|
390
|
+
/** @type {{ messagesSent: number, messagesReceived: number, bytesIn: number, bytesOut: number, reconnects: number, streamsOpened: number, streamsClosed: number }} */
|
|
391
|
+
#stats = {
|
|
392
|
+
messagesSent: 0,
|
|
393
|
+
messagesReceived: 0,
|
|
394
|
+
bytesIn: 0,
|
|
395
|
+
bytesOut: 0,
|
|
396
|
+
reconnects: 0,
|
|
397
|
+
streamsOpened: 0,
|
|
398
|
+
streamsClosed: 0,
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** @type {object|null} */
|
|
402
|
+
#serverInfo = null
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* @param {object} opts
|
|
406
|
+
* @param {string} opts.url - WISP relay WebSocket URL
|
|
407
|
+
* @param {boolean} [opts.reconnect=true] - Enable auto-reconnect
|
|
408
|
+
* @param {number} [opts.maxReconnectAttempts=5] - Max reconnection attempts
|
|
409
|
+
* @param {number} [opts.reconnectDelayMs=1000] - Base delay between reconnects
|
|
410
|
+
* @param {Function} [opts._WebSocket] - Injectable WebSocket constructor (for testing)
|
|
411
|
+
*/
|
|
412
|
+
constructor(opts = {}) {
|
|
413
|
+
if (!opts.url) throw new Error('url is required')
|
|
414
|
+
this.#url = opts.url
|
|
415
|
+
this.#reconnect = opts.reconnect !== undefined ? opts.reconnect : true
|
|
416
|
+
this.#maxReconnectAttempts = opts.maxReconnectAttempts ?? 5
|
|
417
|
+
this.#reconnectDelayMs = opts.reconnectDelayMs ?? 1000
|
|
418
|
+
this.#WebSocketCtor = opts._WebSocket || globalThis.WebSocket
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// -- Getters ---------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
/** WISP relay URL. */
|
|
424
|
+
get url() { return this.#url }
|
|
425
|
+
|
|
426
|
+
/** Current connection state. */
|
|
427
|
+
get state() { return this.#state }
|
|
428
|
+
|
|
429
|
+
/** True when client is connected. */
|
|
430
|
+
get connected() { return this.#state === 'connected' }
|
|
431
|
+
|
|
432
|
+
/** Number of active (non-closed) streams. */
|
|
433
|
+
get activeStreams() { return this.#streams.size }
|
|
434
|
+
|
|
435
|
+
/** Reconnection attempts since last successful connect. */
|
|
436
|
+
get reconnectAttempts() { return this.#reconnectAttempts }
|
|
437
|
+
|
|
438
|
+
/** Whether auto-reconnect is enabled. */
|
|
439
|
+
get reconnectEnabled() { return this.#reconnect }
|
|
440
|
+
|
|
441
|
+
/** Server info received from relay (WISP v2). */
|
|
442
|
+
get serverInfo() { return this.#serverInfo }
|
|
443
|
+
|
|
444
|
+
// -- Public API ------------------------------------------------------------
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Connect to the WISP relay server.
|
|
448
|
+
* @returns {Promise<void>}
|
|
449
|
+
*/
|
|
450
|
+
async connect() {
|
|
451
|
+
if (this.#state === 'connected' || this.#state === 'connecting') {
|
|
452
|
+
throw new Error('Already connected or connecting')
|
|
453
|
+
}
|
|
454
|
+
this.#userClosed = false
|
|
455
|
+
this.#state = 'connecting'
|
|
456
|
+
|
|
457
|
+
return new Promise((resolve, reject) => {
|
|
458
|
+
try {
|
|
459
|
+
this.#ws = new this.#WebSocketCtor(this.#url)
|
|
460
|
+
if (this.#ws.binaryType !== undefined) {
|
|
461
|
+
this.#ws.binaryType = 'arraybuffer'
|
|
462
|
+
}
|
|
463
|
+
} catch (err) {
|
|
464
|
+
this.#state = 'disconnected'
|
|
465
|
+
return reject(err)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const onOpen = () => {
|
|
469
|
+
cleanup()
|
|
470
|
+
this.#state = 'connected'
|
|
471
|
+
this.#reconnectAttempts = 0
|
|
472
|
+
this.#ws.addEventListener('message', this.#onMessage)
|
|
473
|
+
this.#ws.addEventListener('close', this.#onClose)
|
|
474
|
+
this.#ws.addEventListener('error', this.#onError)
|
|
475
|
+
this._fireEvent('open')
|
|
476
|
+
resolve()
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const onError = (err) => {
|
|
480
|
+
cleanup()
|
|
481
|
+
this.#state = 'disconnected'
|
|
482
|
+
this._fireEvent('error', err)
|
|
483
|
+
reject(err instanceof Error ? err : new Error('WebSocket connection failed'))
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const cleanup = () => {
|
|
487
|
+
this.#ws.removeEventListener('open', onOpen)
|
|
488
|
+
this.#ws.removeEventListener('error', onError)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
this.#ws.addEventListener('open', onOpen)
|
|
492
|
+
this.#ws.addEventListener('error', onError)
|
|
493
|
+
})
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Open a new TCP stream through the WISP relay.
|
|
498
|
+
*
|
|
499
|
+
* @example
|
|
500
|
+
* const stream = await client.open('example.com', 443)
|
|
501
|
+
*
|
|
502
|
+
* @param {string} host - Target hostname
|
|
503
|
+
* @param {number} port - Target port (1-65535)
|
|
504
|
+
* @returns {WispStream}
|
|
505
|
+
*/
|
|
506
|
+
open(host, port) {
|
|
507
|
+
if (!this.connected) throw new Error('Not connected')
|
|
508
|
+
if (!host || typeof host !== 'string') throw new Error('host is required')
|
|
509
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
510
|
+
throw new Error('port must be an integer between 1 and 65535')
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const streamId = this.#nextStreamId++
|
|
514
|
+
const sendFrame = (frame) => this.#sendRaw(frame)
|
|
515
|
+
const stream = new WispStream(streamId, host, port, sendFrame)
|
|
516
|
+
this.#streams.set(streamId, stream)
|
|
517
|
+
this.#stats.streamsOpened++
|
|
518
|
+
|
|
519
|
+
// send CONNECT frame
|
|
520
|
+
const payload = encodeConnectPayload(host, port)
|
|
521
|
+
this.#sendRaw(encodeFrame(WISP_CONNECT, streamId, payload))
|
|
522
|
+
|
|
523
|
+
return stream
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Close the client and all active streams.
|
|
528
|
+
* @returns {Promise<void>}
|
|
529
|
+
*/
|
|
530
|
+
async close() {
|
|
531
|
+
if (this.#state === 'closed' || this.#state === 'disconnected') return
|
|
532
|
+
this.#userClosed = true
|
|
533
|
+
this.#state = 'closing'
|
|
534
|
+
|
|
535
|
+
// close all streams
|
|
536
|
+
for (const stream of this.#streams.values()) {
|
|
537
|
+
if (!stream.closed) {
|
|
538
|
+
stream._forceClose()
|
|
539
|
+
this.#stats.streamsClosed++
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
this.#streams.clear()
|
|
543
|
+
|
|
544
|
+
if (this.#ws) {
|
|
545
|
+
return new Promise((resolve) => {
|
|
546
|
+
const onClose = () => {
|
|
547
|
+
this.#ws.removeEventListener('close', onClose)
|
|
548
|
+
this.#state = 'closed'
|
|
549
|
+
this._fireEvent('close')
|
|
550
|
+
resolve()
|
|
551
|
+
}
|
|
552
|
+
this.#ws.addEventListener('close', onClose)
|
|
553
|
+
this.#ws.removeEventListener('close', this.#onClose)
|
|
554
|
+
this.#ws.close(1000, 'client shutdown')
|
|
555
|
+
})
|
|
556
|
+
}
|
|
557
|
+
this.#state = 'closed'
|
|
558
|
+
this._fireEvent('close')
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Get a stream by ID.
|
|
563
|
+
* @param {number} streamId
|
|
564
|
+
* @returns {WispStream|undefined}
|
|
565
|
+
*/
|
|
566
|
+
getStream(streamId) {
|
|
567
|
+
return this.#streams.get(streamId)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Register an event listener.
|
|
572
|
+
* @param {string} event - One of: 'open', 'close', 'error', 'reconnect', 'info'
|
|
573
|
+
* @param {Function} cb
|
|
574
|
+
*/
|
|
575
|
+
on(event, cb) {
|
|
576
|
+
if (!CLIENT_EVENTS.includes(event)) throw new Error(`Unknown event: ${event}`)
|
|
577
|
+
this.#callbacks[event].push(cb)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Get client statistics.
|
|
582
|
+
* @returns {object}
|
|
583
|
+
*/
|
|
584
|
+
getStats() {
|
|
585
|
+
return { ...this.#stats }
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Serialize to a JSON-safe object.
|
|
590
|
+
* @returns {object}
|
|
591
|
+
*/
|
|
592
|
+
toJSON() {
|
|
593
|
+
return {
|
|
594
|
+
url: this.#url,
|
|
595
|
+
state: this.#state,
|
|
596
|
+
activeStreams: this.#streams.size,
|
|
597
|
+
reconnectAttempts: this.#reconnectAttempts,
|
|
598
|
+
stats: this.getStats(),
|
|
599
|
+
serverInfo: this.#serverInfo,
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// -- Internal event handlers (arrow fns for stable `this`) -----------------
|
|
604
|
+
|
|
605
|
+
/** @type {(ev: { data: * }) => void} */
|
|
606
|
+
#onMessage = (ev) => {
|
|
607
|
+
const raw = ev.data
|
|
608
|
+
this.#stats.messagesReceived++
|
|
609
|
+
|
|
610
|
+
let bytes
|
|
611
|
+
if (raw instanceof ArrayBuffer) {
|
|
612
|
+
bytes = new Uint8Array(raw)
|
|
613
|
+
} else if (raw instanceof Uint8Array) {
|
|
614
|
+
bytes = raw
|
|
615
|
+
} else {
|
|
616
|
+
// unexpected text frame — ignore
|
|
617
|
+
return
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
this.#stats.bytesIn += bytes.byteLength
|
|
621
|
+
|
|
622
|
+
let frame
|
|
623
|
+
try {
|
|
624
|
+
frame = decodeFrame(bytes)
|
|
625
|
+
} catch {
|
|
626
|
+
this._fireEvent('error', new Error('Malformed WISP frame'))
|
|
627
|
+
return
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const { type, streamId, payload } = frame
|
|
631
|
+
|
|
632
|
+
switch (type) {
|
|
633
|
+
case WISP_DATA: {
|
|
634
|
+
const stream = this.#streams.get(streamId)
|
|
635
|
+
if (stream) stream._handleData(payload)
|
|
636
|
+
break
|
|
637
|
+
}
|
|
638
|
+
case WISP_CONTINUE: {
|
|
639
|
+
const stream = this.#streams.get(streamId)
|
|
640
|
+
if (stream) stream._handleContinue(payload)
|
|
641
|
+
break
|
|
642
|
+
}
|
|
643
|
+
case WISP_CLOSE: {
|
|
644
|
+
const stream = this.#streams.get(streamId)
|
|
645
|
+
if (stream) {
|
|
646
|
+
const reason = payload.byteLength > 0 ? payload[0] : CLOSE_REASON_NORMAL
|
|
647
|
+
stream._handleClose(reason)
|
|
648
|
+
this.#streams.delete(streamId)
|
|
649
|
+
this.#stats.streamsClosed++
|
|
650
|
+
}
|
|
651
|
+
break
|
|
652
|
+
}
|
|
653
|
+
case WISP_INFO: {
|
|
654
|
+
try {
|
|
655
|
+
const decoder = new TextDecoder()
|
|
656
|
+
this.#serverInfo = JSON.parse(decoder.decode(payload))
|
|
657
|
+
this._fireEvent('info', this.#serverInfo)
|
|
658
|
+
} catch {
|
|
659
|
+
// non-JSON info — store raw
|
|
660
|
+
this.#serverInfo = payload
|
|
661
|
+
this._fireEvent('info', payload)
|
|
662
|
+
}
|
|
663
|
+
break
|
|
664
|
+
}
|
|
665
|
+
default:
|
|
666
|
+
// Unknown frame type — ignore for forward compatibility
|
|
667
|
+
break
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** @type {(ev: *) => void} */
|
|
672
|
+
#onClose = (ev) => {
|
|
673
|
+
// force-close all active streams
|
|
674
|
+
for (const stream of this.#streams.values()) {
|
|
675
|
+
if (!stream.closed) {
|
|
676
|
+
stream._forceClose()
|
|
677
|
+
this.#stats.streamsClosed++
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
this.#streams.clear()
|
|
681
|
+
|
|
682
|
+
if (this.#userClosed) {
|
|
683
|
+
this.#state = 'closed'
|
|
684
|
+
this._fireEvent('close', ev)
|
|
685
|
+
return
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
this.#state = 'disconnected'
|
|
689
|
+
this._fireEvent('close', ev)
|
|
690
|
+
if (this.#reconnect) {
|
|
691
|
+
this._handleReconnect()
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** @type {(err: *) => void} */
|
|
696
|
+
#onError = (err) => {
|
|
697
|
+
this._fireEvent('error', err)
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// -- Internal methods ------------------------------------------------------
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Send raw bytes over the WebSocket.
|
|
704
|
+
* @param {Uint8Array} data
|
|
705
|
+
*/
|
|
706
|
+
#sendRaw(data) {
|
|
707
|
+
if (!this.connected || !this.#ws) throw new Error('Not connected')
|
|
708
|
+
this.#ws.send(data)
|
|
709
|
+
this.#stats.messagesSent++
|
|
710
|
+
this.#stats.bytesOut += data.byteLength
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Attempt reconnection with exponential backoff.
|
|
715
|
+
*/
|
|
716
|
+
async _handleReconnect() {
|
|
717
|
+
if (this.#reconnectAttempts >= this.#maxReconnectAttempts) return
|
|
718
|
+
|
|
719
|
+
this.#reconnectAttempts++
|
|
720
|
+
this.#stats.reconnects++
|
|
721
|
+
this._fireEvent('reconnect', { attempt: this.#reconnectAttempts })
|
|
722
|
+
|
|
723
|
+
const delay = this.#reconnectDelayMs * Math.pow(2, this.#reconnectAttempts - 1)
|
|
724
|
+
await new Promise(r => setTimeout(r, delay))
|
|
725
|
+
|
|
726
|
+
if (this.#userClosed) return
|
|
727
|
+
|
|
728
|
+
try {
|
|
729
|
+
await this.connect()
|
|
730
|
+
} catch {
|
|
731
|
+
if (this.#reconnect && this.#reconnectAttempts < this.#maxReconnectAttempts) {
|
|
732
|
+
this._handleReconnect()
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Fire all callbacks for a given event.
|
|
739
|
+
* @param {string} event
|
|
740
|
+
* @param {*} [data]
|
|
741
|
+
*/
|
|
742
|
+
_fireEvent(event, data) {
|
|
743
|
+
for (const cb of this.#callbacks[event] || []) {
|
|
744
|
+
try { cb(data) } catch (e) { silentCatch('clawser-wisp', 'swallow-listener-errors', e) }
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|