@reddb-io/client 1.9.1 → 1.10.1

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.
@@ -0,0 +1,169 @@
1
+ /**
2
+ * RedWire-over-binary-WebSocket transport for the browser (#937, ADR 0036).
3
+ *
4
+ * The browser cannot open a raw TCP socket, so it cannot speak
5
+ * RedWire-over-TCP — but it can open a binary `WebSocket`. This module
6
+ * adapts a binary WebSocket to the node-socket-shaped duplex the
7
+ * transport-agnostic codec (`./redwire-core.js`) consumes, then runs the
8
+ * exact same handshake + `stream_id` multiplex over it. The browser thus
9
+ * speaks the **same** multiplexed binary protocol as the native drivers.
10
+ *
11
+ * Imports only `./redwire-core.js` and `./protocol.js` — both free of any
12
+ * `node:` built-in — so this stays inside the browser bundle graph.
13
+ */
14
+
15
+ import { RedDBError } from './protocol.js'
16
+ import { connectRedwireOverSocket } from './redwire-core.js'
17
+
18
+ /** Server route the upgrade lands on (must match `ws_edge.rs`). */
19
+ export const REDWIRE_WS_PATH = '/redwire'
20
+
21
+ /** WebSocket subprotocol advertised on the upgrade (must match the server). */
22
+ export const REDWIRE_WS_SUBPROTOCOL = 'reddb.redwire.v1'
23
+
24
+ /** Coerce a WebSocket `message` payload into a `Uint8Array`, or null. */
25
+ function toBytes(data) {
26
+ if (data instanceof ArrayBuffer) return new Uint8Array(data)
27
+ if (ArrayBuffer.isView(data)) {
28
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
29
+ }
30
+ // Text frames are not RedWire bytes — ignore.
31
+ return null
32
+ }
33
+
34
+ /**
35
+ * Adapt a binary `WebSocket` to the node-socket-shaped duplex the RedWire
36
+ * codec expects: `.on('data'|'error'|'end'|'close', cb)`, `.write(bytes,
37
+ * cb)`, `.end()`. `FrameReader` reassembles frames from the `data`
38
+ * events; message boundaries need not align with frame boundaries since
39
+ * RedWire framing is self-delimiting.
40
+ */
41
+ export class WebSocketDuplex {
42
+ constructor(ws) {
43
+ this.ws = ws
44
+ this._handlers = { data: [], error: [], end: [], close: [] }
45
+ this._closed = false
46
+
47
+ if ('binaryType' in ws) ws.binaryType = 'arraybuffer'
48
+
49
+ ws.addEventListener('message', (ev) => {
50
+ const bytes = toBytes(ev.data)
51
+ if (bytes) this._emit('data', bytes)
52
+ })
53
+ ws.addEventListener('error', () => {
54
+ this._emit('error', new RedDBError('WS_TRANSPORT', 'redwire websocket transport error'))
55
+ })
56
+ ws.addEventListener('close', () => {
57
+ if (this._closed) return
58
+ this._closed = true
59
+ this._emit('close')
60
+ })
61
+ }
62
+
63
+ on(event, cb) {
64
+ if (this._handlers[event]) this._handlers[event].push(cb)
65
+ return this
66
+ }
67
+
68
+ _emit(event, arg) {
69
+ for (const cb of this._handlers[event] ?? []) cb(arg)
70
+ }
71
+
72
+ write(bytes, cb) {
73
+ try {
74
+ // Browser `WebSocket.send` accepts an ArrayBufferView and ships
75
+ // exactly the view's bytes as one binary message. There is no
76
+ // write-callback in the WS API, so resolve synchronously — the
77
+ // browser owns its own send buffer / backpressure.
78
+ this.ws.send(bytes)
79
+ if (cb) cb()
80
+ } catch (err) {
81
+ if (cb) cb(err)
82
+ else this._emit('error', err)
83
+ }
84
+ return true
85
+ }
86
+
87
+ end() {
88
+ this._closed = true
89
+ try {
90
+ this.ws.close()
91
+ } catch {
92
+ // already closing/closed
93
+ }
94
+ }
95
+ }
96
+
97
+ /** Resolve once the socket is OPEN; reject on early error/close. */
98
+ function waitOpen(ws) {
99
+ return new Promise((resolve, reject) => {
100
+ if (ws.readyState === 1 /* OPEN */) {
101
+ resolve()
102
+ return
103
+ }
104
+ const cleanup = () => {
105
+ ws.removeEventListener('open', onOpen)
106
+ ws.removeEventListener('error', onErr)
107
+ ws.removeEventListener('close', onClose)
108
+ }
109
+ const onOpen = () => {
110
+ cleanup()
111
+ resolve()
112
+ }
113
+ const onErr = () => {
114
+ cleanup()
115
+ reject(new RedDBError('WS_CONNECT_FAILED', 'redwire websocket failed to open'))
116
+ }
117
+ const onClose = () => {
118
+ cleanup()
119
+ reject(new RedDBError('WS_CONNECT_FAILED', 'redwire websocket closed before opening'))
120
+ }
121
+ ws.addEventListener('open', onOpen)
122
+ ws.addEventListener('error', onErr)
123
+ ws.addEventListener('close', onClose)
124
+ })
125
+ }
126
+
127
+ /**
128
+ * Open a RedWire connection over a binary WebSocket.
129
+ *
130
+ * @param {object} opts
131
+ * @param {string} [opts.url] `wss://host:port/redwire` endpoint.
132
+ * @param {{ kind: 'anonymous' } | { kind: 'bearer', token: string }} [opts.auth]
133
+ * @param {string} [opts.clientName]
134
+ * @param {string} [opts.subprotocol] Override the advertised subprotocol.
135
+ * @param {Function} [opts.WebSocketImpl] WebSocket constructor (defaults
136
+ * to `globalThis.WebSocket`); also the test seam for a mock.
137
+ * @param {object} [opts.socket] Pre-built duplex to use as-is, bypassing
138
+ * the real WS open (test seam).
139
+ * @returns {Promise<import('./redwire-core.js').RedWireClient>}
140
+ */
141
+ export async function connectRedwireWs(opts = {}) {
142
+ const { url, auth, clientName, subprotocol = REDWIRE_WS_SUBPROTOCOL } = opts
143
+
144
+ // Test seam: a caller-supplied duplex skips the live WebSocket open.
145
+ if (opts.socket) {
146
+ return await connectRedwireOverSocket(opts.socket, { auth, clientName })
147
+ }
148
+
149
+ const WS = opts.WebSocketImpl ?? globalThis.WebSocket
150
+ if (typeof WS !== 'function') {
151
+ throw new RedDBError(
152
+ 'NO_WEBSOCKET',
153
+ 'no global WebSocket in this runtime; pass opts.WebSocketImpl',
154
+ )
155
+ }
156
+ if (typeof url !== 'string' || !url.startsWith('wss://')) {
157
+ throw new RedDBError(
158
+ 'WSS_REQUIRED',
159
+ `redwire websocket requires a wss:// url, got '${url}'`,
160
+ )
161
+ }
162
+
163
+ const ws = new WS(url, subprotocol)
164
+ if ('binaryType' in ws) ws.binaryType = 'arraybuffer'
165
+ await waitOpen(ws)
166
+
167
+ const duplex = new WebSocketDuplex(ws)
168
+ return await connectRedwireOverSocket(duplex, { auth, clientName })
169
+ }