@reddb-io/client 1.9.1 → 1.10.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/index.browser.d.ts +7 -6
- package/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/core/url.js +37 -1
- package/src/index.browser.js +23 -0
- package/src/redwire-core.js +1144 -0
- package/src/redwire-ws.js +169 -0
- package/src/redwire.js +62 -1128
|
@@ -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
|
+
}
|