@reddb-io/sdk 1.0.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.
- package/LICENSE +661 -0
- package/README.md +199 -0
- package/index.d.ts +362 -0
- package/package.json +50 -0
- package/postinstall.js +86 -0
- package/src/binary.js +66 -0
- package/src/cache.js +137 -0
- package/src/cli.js +25 -0
- package/src/config.js +66 -0
- package/src/http.js +200 -0
- package/src/index.js +432 -0
- package/src/internal/asset-fetcher/asset-name.js +37 -0
- package/src/internal/asset-fetcher/checksum.js +23 -0
- package/src/internal/asset-fetcher/download.js +89 -0
- package/src/internal/asset-fetcher/index.js +52 -0
- package/src/internal/bin-resolver/index.js +57 -0
- package/src/internal/version-compare/index.js +163 -0
- package/src/kv.js +70 -0
- package/src/protocol.js +157 -0
- package/src/redwire.js +723 -0
- package/src/spawn.js +177 -0
- package/src/url.js +271 -0
- package/src/vault.js +58 -0
package/src/redwire.js
ADDED
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RedWire client for Node / Bun / Deno.
|
|
3
|
+
*
|
|
4
|
+
* Speaks the binary TCP protocol from
|
|
5
|
+
* `docs/adr/0001-redwire-tcp-protocol.md` directly — no spawn, no
|
|
6
|
+
* HTTP. Mirrors `crates/reddb-client/src/redwire/` so the wire shape
|
|
7
|
+
* stays in lockstep across drivers.
|
|
8
|
+
*
|
|
9
|
+
* Public surface:
|
|
10
|
+
* - `connectRedwire(opts)` → returns a `RedWireClient`
|
|
11
|
+
* - `RedWireClient.query(sql)` → JSON envelope
|
|
12
|
+
* - `RedWireClient.ping()`
|
|
13
|
+
* - `RedWireClient.close()`
|
|
14
|
+
*
|
|
15
|
+
* Auth methods this cut supports: `anonymous`, `bearer`. SCRAM /
|
|
16
|
+
* mTLS / OAuth land in subsequent PRs.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { RedDBError } from './protocol.js'
|
|
20
|
+
|
|
21
|
+
const MAGIC = 0xfe
|
|
22
|
+
const SUPPORTED_VERSION = 0x01
|
|
23
|
+
const FRAME_HEADER_SIZE = 16
|
|
24
|
+
const MAX_FRAME_SIZE = 16 * 1024 * 1024
|
|
25
|
+
const KNOWN_FLAGS = 0b00000011
|
|
26
|
+
|
|
27
|
+
export const MessageKind = Object.freeze({
|
|
28
|
+
Query: 0x01,
|
|
29
|
+
Result: 0x02,
|
|
30
|
+
Error: 0x03,
|
|
31
|
+
BulkInsert: 0x04,
|
|
32
|
+
BulkOk: 0x05,
|
|
33
|
+
Hello: 0x10,
|
|
34
|
+
HelloAck: 0x11,
|
|
35
|
+
AuthRequest: 0x12,
|
|
36
|
+
AuthResponse: 0x13,
|
|
37
|
+
AuthOk: 0x14,
|
|
38
|
+
AuthFail: 0x15,
|
|
39
|
+
Bye: 0x16,
|
|
40
|
+
Ping: 0x17,
|
|
41
|
+
Pong: 0x18,
|
|
42
|
+
Get: 0x19,
|
|
43
|
+
Delete: 0x1A,
|
|
44
|
+
DeleteOk: 0x1B,
|
|
45
|
+
BulkInsertBinary: 0x06,
|
|
46
|
+
QueryBinary: 0x07,
|
|
47
|
+
BulkInsertPrevalidated: 0x08,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Typed value tags for the binary fast path. Identical to the
|
|
52
|
+
* engine-side `wire::protocol::VAL_*` table.
|
|
53
|
+
*/
|
|
54
|
+
export const BinaryTag = Object.freeze({
|
|
55
|
+
Null: 0,
|
|
56
|
+
I64: 1,
|
|
57
|
+
F64: 2,
|
|
58
|
+
Text: 3,
|
|
59
|
+
Bool: 4,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const KIND_NAME = Object.fromEntries(
|
|
63
|
+
Object.entries(MessageKind).map(([k, v]) => [v, k]),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
export const Flags = Object.freeze({
|
|
67
|
+
COMPRESSED: 0b00000001,
|
|
68
|
+
MORE_FRAMES: 0b00000010,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
/** zstd level for outbound compressed frames. Override via env. */
|
|
72
|
+
const ZSTD_LEVEL = (() => {
|
|
73
|
+
const env = typeof process !== 'undefined' ? process.env?.RED_REDWIRE_ZSTD_LEVEL : null
|
|
74
|
+
const n = env ? Number(env) : NaN
|
|
75
|
+
return Number.isFinite(n) && n >= 1 && n <= 22 ? n : 1
|
|
76
|
+
})()
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Compress / decompress shim. Tries Node 22+'s native zstd
|
|
80
|
+
* bindings first; if unavailable returns null and the codec
|
|
81
|
+
* falls back to plaintext (operators still see the
|
|
82
|
+
* COMPRESSED flag bit when peers offer it, but encode is a
|
|
83
|
+
* no-op until the runtime ships native zstd).
|
|
84
|
+
*/
|
|
85
|
+
let _zstdMod = undefined
|
|
86
|
+
async function zstdMod() {
|
|
87
|
+
if (_zstdMod !== undefined) return _zstdMod
|
|
88
|
+
try {
|
|
89
|
+
const zlib = await import('node:zlib')
|
|
90
|
+
if (typeof zlib.zstdCompressSync === 'function') {
|
|
91
|
+
_zstdMod = zlib
|
|
92
|
+
return _zstdMod
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
// node:zlib missing — Deno (no-zstd) or restricted runtime.
|
|
96
|
+
}
|
|
97
|
+
_zstdMod = null
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Open a RedWire connection.
|
|
103
|
+
*
|
|
104
|
+
* @param {object} opts
|
|
105
|
+
* @param {string} opts.host
|
|
106
|
+
* @param {number} opts.port
|
|
107
|
+
* @param {{ kind: 'anonymous' } | { kind: 'bearer', token: string }} [opts.auth]
|
|
108
|
+
* @param {string} [opts.clientName]
|
|
109
|
+
* @param {object} [opts.tls] When set, wraps the socket in TLS.
|
|
110
|
+
* @param {string|Buffer} [opts.tls.ca] Trusted CA bundle (PEM).
|
|
111
|
+
* @param {string|Buffer} [opts.tls.cert] Client cert for mTLS (PEM).
|
|
112
|
+
* @param {string|Buffer} [opts.tls.key] Client key for mTLS (PEM).
|
|
113
|
+
* @param {boolean} [opts.tls.rejectUnauthorized=true] Verify server cert.
|
|
114
|
+
* @param {string} [opts.tls.servername] SNI override.
|
|
115
|
+
* @returns {Promise<RedWireClient>}
|
|
116
|
+
*/
|
|
117
|
+
export async function connectRedwire(opts) {
|
|
118
|
+
const { host, port } = opts
|
|
119
|
+
if (typeof host !== 'string' || host.length === 0) {
|
|
120
|
+
throw new TypeError('connectRedwire: host required')
|
|
121
|
+
}
|
|
122
|
+
if (typeof port !== 'number' || port <= 0 || port > 0xffff) {
|
|
123
|
+
throw new TypeError('connectRedwire: port required (1-65535)')
|
|
124
|
+
}
|
|
125
|
+
const auth = opts.auth ?? { kind: 'anonymous' }
|
|
126
|
+
// Resolve the zstd shim once per process — this populates the
|
|
127
|
+
// shared `_zstdMod` cache so encode/decode hot paths are sync.
|
|
128
|
+
await zstdMod()
|
|
129
|
+
|
|
130
|
+
const socket = opts.tls
|
|
131
|
+
? await openTlsSocket(host, port, opts.tls)
|
|
132
|
+
: await openSocket(host, port)
|
|
133
|
+
const reader = new FrameReader(socket)
|
|
134
|
+
|
|
135
|
+
// Discriminator + minor-version byte.
|
|
136
|
+
await writeAll(socket, Uint8Array.from([MAGIC, SUPPORTED_VERSION]))
|
|
137
|
+
|
|
138
|
+
// Hello.
|
|
139
|
+
const methods = auth.kind === 'bearer' ? ['bearer'] : ['anonymous', 'bearer']
|
|
140
|
+
const helloPayload = jsonBytes({
|
|
141
|
+
versions: [SUPPORTED_VERSION],
|
|
142
|
+
auth_methods: methods,
|
|
143
|
+
features: 0,
|
|
144
|
+
client_name: opts.clientName ?? `reddb-js/0.2`,
|
|
145
|
+
})
|
|
146
|
+
await writeFrame(socket, MessageKind.Hello, 1n, helloPayload)
|
|
147
|
+
|
|
148
|
+
const ack = await reader.next()
|
|
149
|
+
if (ack.kind === MessageKind.AuthFail) {
|
|
150
|
+
socket.end()
|
|
151
|
+
const reason = jsonReason(ack.payload) ?? 'AuthFail at HelloAck'
|
|
152
|
+
throw new RedDBError('AUTH_REFUSED', `redwire: ${reason}`)
|
|
153
|
+
}
|
|
154
|
+
if (ack.kind !== MessageKind.HelloAck) {
|
|
155
|
+
socket.end()
|
|
156
|
+
throw new RedDBError(
|
|
157
|
+
'PROTOCOL',
|
|
158
|
+
`expected HelloAck, got ${KIND_NAME[ack.kind] ?? ack.kind}`,
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
const ackParsed = jsonOf(ack.payload)
|
|
162
|
+
const chosenAuth = ackParsed?.auth
|
|
163
|
+
if (typeof chosenAuth !== 'string') {
|
|
164
|
+
socket.end()
|
|
165
|
+
throw new RedDBError('PROTOCOL', 'HelloAck missing `auth` field')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// AuthResponse.
|
|
169
|
+
let respPayload
|
|
170
|
+
if (chosenAuth === 'anonymous') {
|
|
171
|
+
respPayload = new Uint8Array()
|
|
172
|
+
} else if (chosenAuth === 'bearer') {
|
|
173
|
+
if (auth.kind !== 'bearer') {
|
|
174
|
+
socket.end()
|
|
175
|
+
throw new RedDBError(
|
|
176
|
+
'AUTH_REFUSED',
|
|
177
|
+
'server demanded bearer but no token was supplied',
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
respPayload = jsonBytes({ token: auth.token })
|
|
181
|
+
} else {
|
|
182
|
+
socket.end()
|
|
183
|
+
throw new RedDBError(
|
|
184
|
+
'PROTOCOL',
|
|
185
|
+
`server picked unsupported auth method: ${chosenAuth}`,
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
await writeFrame(socket, MessageKind.AuthResponse, 2n, respPayload)
|
|
189
|
+
|
|
190
|
+
const final = await reader.next()
|
|
191
|
+
if (final.kind === MessageKind.AuthFail) {
|
|
192
|
+
socket.end()
|
|
193
|
+
const reason = jsonReason(final.payload) ?? 'auth refused'
|
|
194
|
+
throw new RedDBError('AUTH_REFUSED', reason)
|
|
195
|
+
}
|
|
196
|
+
if (final.kind !== MessageKind.AuthOk) {
|
|
197
|
+
socket.end()
|
|
198
|
+
throw new RedDBError(
|
|
199
|
+
'PROTOCOL',
|
|
200
|
+
`expected AuthOk, got ${KIND_NAME[final.kind] ?? final.kind}`,
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
const session = jsonOf(final.payload) ?? {}
|
|
204
|
+
|
|
205
|
+
return new RedWireClient(socket, reader, session)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Returned by `connectRedwire`. Methods map 1:1 to RedWire frame
|
|
210
|
+
* kinds. Reuses the same `RedDB`-shaped envelope as the other
|
|
211
|
+
* transports so the surface above this is uniform.
|
|
212
|
+
*/
|
|
213
|
+
export class RedWireClient {
|
|
214
|
+
constructor(socket, reader, session) {
|
|
215
|
+
this.socket = socket
|
|
216
|
+
this.reader = reader
|
|
217
|
+
this.session = session
|
|
218
|
+
this.nextCorr = 1n
|
|
219
|
+
this.closed = false
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async call(method, params = {}) {
|
|
223
|
+
if (method === 'query') return this.#query(params.sql ?? '')
|
|
224
|
+
if (method === 'insert') return this.#insert({ collection: params.collection, payload: params.payload })
|
|
225
|
+
if (method === 'bulk_insert') return this.#insert({ collection: params.collection, payloads: params.payloads })
|
|
226
|
+
if (method === 'bulk_insert_binary') {
|
|
227
|
+
return this.bulkInsertBinary(params.collection, params.columns, params.rows)
|
|
228
|
+
}
|
|
229
|
+
if (method === 'get') return this.#getOrDelete(MessageKind.Get, MessageKind.Result, params)
|
|
230
|
+
if (method === 'delete') return this.#getOrDelete(MessageKind.Delete, MessageKind.DeleteOk, params)
|
|
231
|
+
if (method === 'health' || method === 'version') return this.#ping()
|
|
232
|
+
throw new RedDBError(
|
|
233
|
+
'UNKNOWN_METHOD',
|
|
234
|
+
`RedWire transport doesn't bridge '${method}' yet`,
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Bulk-insert via the binary fast path (frame kind 0x06).
|
|
240
|
+
* Same hot-loop perf as the engine's `MSG_BULK_INSERT_BINARY`
|
|
241
|
+
* stress tests. Each row is an array of `[tag, value]` pairs
|
|
242
|
+
* matching the column order; tag values come from `BinaryTag`.
|
|
243
|
+
*
|
|
244
|
+
* Example:
|
|
245
|
+
* client.bulkInsertBinary('users', ['name', 'age'], [
|
|
246
|
+
* [[BinaryTag.Text, 'alice'], [BinaryTag.I64, 30n]],
|
|
247
|
+
* [[BinaryTag.Text, 'bob'], [BinaryTag.I64, 25n]],
|
|
248
|
+
* ])
|
|
249
|
+
*/
|
|
250
|
+
async bulkInsertBinary(collection, columns, rows) {
|
|
251
|
+
if (!Array.isArray(columns) || !Array.isArray(rows)) {
|
|
252
|
+
throw new TypeError('bulkInsertBinary: columns and rows must be arrays')
|
|
253
|
+
}
|
|
254
|
+
const buf = encodeBinaryBulk(collection, columns, rows)
|
|
255
|
+
const corr = this.#corr()
|
|
256
|
+
await writeFrame(this.socket, MessageKind.BulkInsertBinary, corr, buf)
|
|
257
|
+
const resp = await this.reader.next()
|
|
258
|
+
if (resp.kind === MessageKind.BulkOk) {
|
|
259
|
+
// v1 BulkOk body is an 8-byte little-endian count.
|
|
260
|
+
if (resp.payload.length < 8) {
|
|
261
|
+
throw new RedDBError('PROTOCOL', 'BulkOk truncated: expected 8-byte count')
|
|
262
|
+
}
|
|
263
|
+
const view = new DataView(
|
|
264
|
+
resp.payload.buffer,
|
|
265
|
+
resp.payload.byteOffset,
|
|
266
|
+
resp.payload.byteLength,
|
|
267
|
+
)
|
|
268
|
+
return Number(view.getBigUint64(0, true))
|
|
269
|
+
}
|
|
270
|
+
if (resp.kind === MessageKind.Error) {
|
|
271
|
+
throw new RedDBError('ENGINE', new TextDecoder().decode(resp.payload))
|
|
272
|
+
}
|
|
273
|
+
throw new RedDBError(
|
|
274
|
+
'PROTOCOL',
|
|
275
|
+
`expected BulkOk/Error, got ${KIND_NAME[resp.kind] ?? resp.kind}`,
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async #getOrDelete(reqKind, okKind, params) {
|
|
280
|
+
const corr = this.#corr()
|
|
281
|
+
const payload = jsonBytes({ collection: params.collection, id: String(params.id) })
|
|
282
|
+
await writeFrame(this.socket, reqKind, corr, payload)
|
|
283
|
+
const resp = await this.reader.next()
|
|
284
|
+
if (resp.kind === okKind) return jsonOf(resp.payload) ?? {}
|
|
285
|
+
if (resp.kind === MessageKind.Error) {
|
|
286
|
+
throw new RedDBError('ENGINE', new TextDecoder().decode(resp.payload))
|
|
287
|
+
}
|
|
288
|
+
throw new RedDBError(
|
|
289
|
+
'PROTOCOL',
|
|
290
|
+
`expected ${KIND_NAME[okKind]}/Error, got ${KIND_NAME[resp.kind] ?? resp.kind}`,
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async #insert(body) {
|
|
295
|
+
const corr = this.#corr()
|
|
296
|
+
const payload = jsonBytes(body)
|
|
297
|
+
await writeFrame(this.socket, MessageKind.BulkInsert, corr, payload)
|
|
298
|
+
const resp = await this.reader.next()
|
|
299
|
+
if (resp.kind === MessageKind.BulkOk) {
|
|
300
|
+
return jsonOf(resp.payload) ?? { affected: 0 }
|
|
301
|
+
}
|
|
302
|
+
if (resp.kind === MessageKind.Error) {
|
|
303
|
+
throw new RedDBError('ENGINE', new TextDecoder().decode(resp.payload))
|
|
304
|
+
}
|
|
305
|
+
throw new RedDBError(
|
|
306
|
+
'PROTOCOL',
|
|
307
|
+
`expected BulkOk/Error, got ${KIND_NAME[resp.kind] ?? resp.kind}`,
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async #query(sql) {
|
|
312
|
+
const corr = this.#corr()
|
|
313
|
+
const payload = new TextEncoder().encode(sql)
|
|
314
|
+
await writeFrame(this.socket, MessageKind.Query, corr, payload)
|
|
315
|
+
const resp = await this.reader.next()
|
|
316
|
+
if (resp.kind === MessageKind.Result) {
|
|
317
|
+
return jsonOf(resp.payload) ?? {}
|
|
318
|
+
}
|
|
319
|
+
if (resp.kind === MessageKind.Error) {
|
|
320
|
+
throw new RedDBError(
|
|
321
|
+
'ENGINE',
|
|
322
|
+
new TextDecoder().decode(resp.payload),
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
throw new RedDBError(
|
|
326
|
+
'PROTOCOL',
|
|
327
|
+
`expected Result/Error, got ${KIND_NAME[resp.kind] ?? resp.kind}`,
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async #ping() {
|
|
332
|
+
const corr = this.#corr()
|
|
333
|
+
await writeFrame(this.socket, MessageKind.Ping, corr, new Uint8Array())
|
|
334
|
+
const resp = await this.reader.next()
|
|
335
|
+
if (resp.kind !== MessageKind.Pong) {
|
|
336
|
+
throw new RedDBError(
|
|
337
|
+
'PROTOCOL',
|
|
338
|
+
`expected Pong, got ${KIND_NAME[resp.kind] ?? resp.kind}`,
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
return { ok: true }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async close() {
|
|
345
|
+
if (this.closed) return
|
|
346
|
+
this.closed = true
|
|
347
|
+
try {
|
|
348
|
+
const corr = this.#corr()
|
|
349
|
+
await writeFrame(this.socket, MessageKind.Bye, corr, new Uint8Array())
|
|
350
|
+
} catch {
|
|
351
|
+
// best-effort
|
|
352
|
+
}
|
|
353
|
+
this.socket.end()
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
#corr() {
|
|
357
|
+
const c = this.nextCorr
|
|
358
|
+
this.nextCorr = this.nextCorr + 1n
|
|
359
|
+
return c
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
// Framing helpers
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
function encodeFrame(kind, correlationId, payload, flags = 0, streamId = 0) {
|
|
368
|
+
if (!(payload instanceof Uint8Array)) {
|
|
369
|
+
payload = new Uint8Array(payload)
|
|
370
|
+
}
|
|
371
|
+
let onWire = payload
|
|
372
|
+
let outFlags = flags & KNOWN_FLAGS
|
|
373
|
+
// We compress synchronously when the flag is set AND the
|
|
374
|
+
// runtime ships native zstd. Async flag-flip happens at
|
|
375
|
+
// session level (see RedWireClient construction); per-frame
|
|
376
|
+
// call here is a fast Buffer roundtrip.
|
|
377
|
+
if (outFlags & Flags.COMPRESSED && _zstdMod && typeof _zstdMod.zstdCompressSync === 'function') {
|
|
378
|
+
try {
|
|
379
|
+
const compressed = _zstdMod.zstdCompressSync(payload, {
|
|
380
|
+
params: { [_zstdMod.constants?.ZSTD_c_compressionLevel ?? 100]: ZSTD_LEVEL },
|
|
381
|
+
})
|
|
382
|
+
onWire = compressed instanceof Uint8Array ? compressed : new Uint8Array(compressed)
|
|
383
|
+
} catch {
|
|
384
|
+
// Fallback: ship plaintext, drop the flag so the peer
|
|
385
|
+
// doesn't try to decompress.
|
|
386
|
+
outFlags &= ~Flags.COMPRESSED
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const length = FRAME_HEADER_SIZE + onWire.length
|
|
390
|
+
if (length > MAX_FRAME_SIZE) {
|
|
391
|
+
throw new RedDBError('FRAME_TOO_LARGE', `frame ${length} > ${MAX_FRAME_SIZE}`)
|
|
392
|
+
}
|
|
393
|
+
const buf = new Uint8Array(length)
|
|
394
|
+
const view = new DataView(buf.buffer)
|
|
395
|
+
view.setUint32(0, length, true)
|
|
396
|
+
buf[4] = kind
|
|
397
|
+
buf[5] = outFlags
|
|
398
|
+
view.setUint16(6, streamId, true)
|
|
399
|
+
view.setBigUint64(8, BigInt(correlationId), true)
|
|
400
|
+
buf.set(onWire, FRAME_HEADER_SIZE)
|
|
401
|
+
return buf
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function writeFrame(socket, kind, correlationId, payload) {
|
|
405
|
+
const buf = encodeFrame(kind, correlationId, payload)
|
|
406
|
+
return writeAll(socket, buf)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function decodeFrame(buf) {
|
|
410
|
+
if (buf.length < FRAME_HEADER_SIZE) return null
|
|
411
|
+
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
|
|
412
|
+
const length = view.getUint32(0, true)
|
|
413
|
+
if (length < FRAME_HEADER_SIZE || length > MAX_FRAME_SIZE) {
|
|
414
|
+
throw new RedDBError('FRAME_INVALID_LENGTH', `length=${length}`)
|
|
415
|
+
}
|
|
416
|
+
if (buf.length < length) return null
|
|
417
|
+
const kind = buf[4]
|
|
418
|
+
const flags = buf[5]
|
|
419
|
+
if (flags & ~KNOWN_FLAGS) {
|
|
420
|
+
throw new RedDBError('FRAME_UNKNOWN_FLAGS', `flags=0x${flags.toString(16)}`)
|
|
421
|
+
}
|
|
422
|
+
const streamId = view.getUint16(6, true)
|
|
423
|
+
const correlationId = view.getBigUint64(8, true)
|
|
424
|
+
let payload = buf.slice(FRAME_HEADER_SIZE, length)
|
|
425
|
+
if (flags & Flags.COMPRESSED) {
|
|
426
|
+
if (!_zstdMod || typeof _zstdMod.zstdDecompressSync !== 'function') {
|
|
427
|
+
throw new RedDBError(
|
|
428
|
+
'COMPRESSED_BUT_NO_ZSTD',
|
|
429
|
+
'incoming frame has COMPRESSED flag but runtime has no zstd support — upgrade Node >= 22',
|
|
430
|
+
)
|
|
431
|
+
}
|
|
432
|
+
try {
|
|
433
|
+
const plain = _zstdMod.zstdDecompressSync(payload)
|
|
434
|
+
payload = plain instanceof Uint8Array ? plain : new Uint8Array(plain)
|
|
435
|
+
} catch (err) {
|
|
436
|
+
throw new RedDBError('FRAME_DECOMPRESS_FAILED', err.message)
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return { kind, flags, streamId, correlationId, payload, consumed: length }
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Buffered frame reader — TCP delivers byte streams, frames may
|
|
444
|
+
* cross or share `data` events. Maintains a rolling accumulator
|
|
445
|
+
* and yields one frame per `next()` call.
|
|
446
|
+
*/
|
|
447
|
+
class FrameReader {
|
|
448
|
+
constructor(socket) {
|
|
449
|
+
this.chunks = []
|
|
450
|
+
this.totalLen = 0
|
|
451
|
+
this.waiters = []
|
|
452
|
+
this.error = null
|
|
453
|
+
this.eof = false
|
|
454
|
+
socket.on('data', (chunk) => {
|
|
455
|
+
// chunk is Buffer (Node) or Uint8Array (Bun/Deno)
|
|
456
|
+
const u8 = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk)
|
|
457
|
+
this.chunks.push(u8)
|
|
458
|
+
this.totalLen += u8.length
|
|
459
|
+
this.#tryDeliver()
|
|
460
|
+
})
|
|
461
|
+
socket.on('error', (err) => {
|
|
462
|
+
this.error = err
|
|
463
|
+
this.#flushWaiters()
|
|
464
|
+
})
|
|
465
|
+
socket.on('end', () => {
|
|
466
|
+
this.eof = true
|
|
467
|
+
this.#flushWaiters()
|
|
468
|
+
})
|
|
469
|
+
socket.on('close', () => {
|
|
470
|
+
this.eof = true
|
|
471
|
+
this.#flushWaiters()
|
|
472
|
+
})
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
next() {
|
|
476
|
+
if (this.error) return Promise.reject(this.error)
|
|
477
|
+
return new Promise((resolve, reject) => {
|
|
478
|
+
this.waiters.push({ resolve, reject })
|
|
479
|
+
this.#tryDeliver()
|
|
480
|
+
})
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
#tryDeliver() {
|
|
484
|
+
while (this.waiters.length > 0 && this.totalLen > 0) {
|
|
485
|
+
const flat = this.#flatten()
|
|
486
|
+
let frame
|
|
487
|
+
try {
|
|
488
|
+
frame = decodeFrame(flat)
|
|
489
|
+
} catch (err) {
|
|
490
|
+
const w = this.waiters.shift()
|
|
491
|
+
w.reject(err)
|
|
492
|
+
return
|
|
493
|
+
}
|
|
494
|
+
if (frame == null) {
|
|
495
|
+
// Need more bytes — put the flattened buffer back as a
|
|
496
|
+
// single chunk so we don't keep flattening repeatedly.
|
|
497
|
+
this.chunks = [flat]
|
|
498
|
+
return
|
|
499
|
+
}
|
|
500
|
+
this.chunks = [flat.subarray(frame.consumed)]
|
|
501
|
+
this.totalLen = this.chunks[0].length
|
|
502
|
+
const w = this.waiters.shift()
|
|
503
|
+
w.resolve(frame)
|
|
504
|
+
}
|
|
505
|
+
if (this.eof && this.waiters.length > 0 && this.totalLen === 0) {
|
|
506
|
+
const err = this.error ?? new RedDBError('CONNECTION_CLOSED', 'redwire: connection closed')
|
|
507
|
+
while (this.waiters.length > 0) {
|
|
508
|
+
this.waiters.shift().reject(err)
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
#flushWaiters() {
|
|
514
|
+
if (this.waiters.length === 0) return
|
|
515
|
+
if (this.totalLen > 0) {
|
|
516
|
+
this.#tryDeliver()
|
|
517
|
+
return
|
|
518
|
+
}
|
|
519
|
+
const err = this.error ?? new RedDBError('CONNECTION_CLOSED', 'redwire: connection closed')
|
|
520
|
+
while (this.waiters.length > 0) {
|
|
521
|
+
this.waiters.shift().reject(err)
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
#flatten() {
|
|
526
|
+
if (this.chunks.length === 1) return this.chunks[0]
|
|
527
|
+
const out = new Uint8Array(this.totalLen)
|
|
528
|
+
let off = 0
|
|
529
|
+
for (const c of this.chunks) {
|
|
530
|
+
out.set(c, off)
|
|
531
|
+
off += c.length
|
|
532
|
+
}
|
|
533
|
+
this.chunks = [out]
|
|
534
|
+
return out
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ---------------------------------------------------------------------------
|
|
539
|
+
// Socket helpers — node:net works on Node, Bun, and Deno via shim
|
|
540
|
+
// ---------------------------------------------------------------------------
|
|
541
|
+
|
|
542
|
+
async function openSocket(host, port) {
|
|
543
|
+
const { Socket } = await import('node:net')
|
|
544
|
+
return await new Promise((resolve, reject) => {
|
|
545
|
+
const sock = new Socket()
|
|
546
|
+
const onErr = (err) => {
|
|
547
|
+
sock.removeListener('connect', onOk)
|
|
548
|
+
reject(err)
|
|
549
|
+
}
|
|
550
|
+
const onOk = () => {
|
|
551
|
+
sock.removeListener('error', onErr)
|
|
552
|
+
sock.setNoDelay(true)
|
|
553
|
+
resolve(sock)
|
|
554
|
+
}
|
|
555
|
+
sock.once('error', onErr)
|
|
556
|
+
sock.once('connect', onOk)
|
|
557
|
+
sock.connect(port, host)
|
|
558
|
+
})
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async function openTlsSocket(host, port, tlsOpts) {
|
|
562
|
+
const tls = await import('node:tls')
|
|
563
|
+
const fs = await import('node:fs/promises')
|
|
564
|
+
const resolveBytes = async (input) => {
|
|
565
|
+
if (input == null) return undefined
|
|
566
|
+
if (typeof input === 'string' && input.includes('-----BEGIN')) return input
|
|
567
|
+
if (typeof input === 'string') return await fs.readFile(input)
|
|
568
|
+
return input // Buffer / Uint8Array
|
|
569
|
+
}
|
|
570
|
+
const ca = await resolveBytes(tlsOpts.ca)
|
|
571
|
+
const cert = await resolveBytes(tlsOpts.cert)
|
|
572
|
+
const key = await resolveBytes(tlsOpts.key)
|
|
573
|
+
return await new Promise((resolve, reject) => {
|
|
574
|
+
const sock = tls.connect({
|
|
575
|
+
host,
|
|
576
|
+
port,
|
|
577
|
+
ca,
|
|
578
|
+
cert,
|
|
579
|
+
key,
|
|
580
|
+
servername: tlsOpts.servername ?? host,
|
|
581
|
+
rejectUnauthorized: tlsOpts.rejectUnauthorized !== false,
|
|
582
|
+
ALPNProtocols: ['redwire/1'],
|
|
583
|
+
})
|
|
584
|
+
const onErr = (err) => {
|
|
585
|
+
sock.removeListener('secureConnect', onOk)
|
|
586
|
+
reject(err)
|
|
587
|
+
}
|
|
588
|
+
const onOk = () => {
|
|
589
|
+
sock.removeListener('error', onErr)
|
|
590
|
+
sock.setNoDelay(true)
|
|
591
|
+
resolve(sock)
|
|
592
|
+
}
|
|
593
|
+
sock.once('error', onErr)
|
|
594
|
+
sock.once('secureConnect', onOk)
|
|
595
|
+
})
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function writeAll(socket, bytes) {
|
|
599
|
+
return new Promise((resolve, reject) => {
|
|
600
|
+
socket.write(bytes, (err) => (err ? reject(err) : resolve()))
|
|
601
|
+
})
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// ---------------------------------------------------------------------------
|
|
605
|
+
// JSON helpers — handshake payloads use JSON for now (CBOR follow-up)
|
|
606
|
+
// ---------------------------------------------------------------------------
|
|
607
|
+
|
|
608
|
+
function jsonBytes(obj) {
|
|
609
|
+
return new TextEncoder().encode(JSON.stringify(obj))
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function jsonOf(bytes) {
|
|
613
|
+
if (!bytes || bytes.length === 0) return null
|
|
614
|
+
try {
|
|
615
|
+
return JSON.parse(new TextDecoder().decode(bytes))
|
|
616
|
+
} catch {
|
|
617
|
+
return null
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Encode the binary bulk-insert payload body (raw, no RedWire frame
|
|
623
|
+
* header — the body is wrapped by the caller as a `BulkInsertBinary`
|
|
624
|
+
* frame).
|
|
625
|
+
* Layout: `[coll_len u16][coll_bytes][ncols u16]
|
|
626
|
+
* [(name_len u16)(name_bytes)]*ncols
|
|
627
|
+
* [nrows u32]
|
|
628
|
+
* [(tag u8)(value)]*ncols * nrows`
|
|
629
|
+
*/
|
|
630
|
+
function encodeBinaryBulk(collection, columns, rows) {
|
|
631
|
+
const enc = new TextEncoder()
|
|
632
|
+
const collBytes = enc.encode(collection)
|
|
633
|
+
// Pre-encode column names + their length prefixes.
|
|
634
|
+
const colChunks = columns.map((c) => enc.encode(c))
|
|
635
|
+
let total = 2 + collBytes.length + 2
|
|
636
|
+
for (const cb of colChunks) total += 2 + cb.length
|
|
637
|
+
total += 4
|
|
638
|
+
// Estimate row size — we'll resize if needed.
|
|
639
|
+
for (const row of rows) {
|
|
640
|
+
if (!Array.isArray(row) || row.length !== columns.length) {
|
|
641
|
+
throw new TypeError(
|
|
642
|
+
`bulkInsertBinary: each row must be an array of length ${columns.length}`,
|
|
643
|
+
)
|
|
644
|
+
}
|
|
645
|
+
for (const cell of row) {
|
|
646
|
+
total += sizeOfBinaryCell(cell)
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
const buf = new Uint8Array(total)
|
|
650
|
+
const view = new DataView(buf.buffer)
|
|
651
|
+
let pos = 0
|
|
652
|
+
view.setUint16(pos, collBytes.length, true); pos += 2
|
|
653
|
+
buf.set(collBytes, pos); pos += collBytes.length
|
|
654
|
+
view.setUint16(pos, colChunks.length, true); pos += 2
|
|
655
|
+
for (const cb of colChunks) {
|
|
656
|
+
view.setUint16(pos, cb.length, true); pos += 2
|
|
657
|
+
buf.set(cb, pos); pos += cb.length
|
|
658
|
+
}
|
|
659
|
+
view.setUint32(pos, rows.length, true); pos += 4
|
|
660
|
+
for (const row of rows) {
|
|
661
|
+
for (const cell of row) {
|
|
662
|
+
pos = writeBinaryCell(buf, view, pos, cell, enc)
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
return buf
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function sizeOfBinaryCell(cell) {
|
|
669
|
+
if (!Array.isArray(cell) || cell.length !== 2) {
|
|
670
|
+
throw new TypeError('bulkInsertBinary cell must be [tag, value]')
|
|
671
|
+
}
|
|
672
|
+
const [tag] = cell
|
|
673
|
+
switch (tag) {
|
|
674
|
+
case 0: return 1
|
|
675
|
+
case 1: return 1 + 8
|
|
676
|
+
case 2: return 1 + 8
|
|
677
|
+
case 3: {
|
|
678
|
+
const v = cell[1]
|
|
679
|
+
const bytes = typeof v === 'string' ? new TextEncoder().encode(v).length : 0
|
|
680
|
+
return 1 + 4 + bytes
|
|
681
|
+
}
|
|
682
|
+
case 4: return 1 + 1
|
|
683
|
+
default: throw new RedDBError('UNKNOWN_BINARY_TAG', `tag=${tag}`)
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function writeBinaryCell(buf, view, pos, cell, enc) {
|
|
688
|
+
const [tag, value] = cell
|
|
689
|
+
buf[pos++] = tag
|
|
690
|
+
switch (tag) {
|
|
691
|
+
case 0: // Null
|
|
692
|
+
return pos
|
|
693
|
+
case 1: { // I64
|
|
694
|
+
const bi = typeof value === 'bigint' ? value : BigInt(value)
|
|
695
|
+
view.setBigInt64(pos, bi, true)
|
|
696
|
+
return pos + 8
|
|
697
|
+
}
|
|
698
|
+
case 2: { // F64
|
|
699
|
+
view.setFloat64(pos, Number(value), true)
|
|
700
|
+
return pos + 8
|
|
701
|
+
}
|
|
702
|
+
case 3: { // Text
|
|
703
|
+
const bytes = enc.encode(String(value))
|
|
704
|
+
view.setUint32(pos, bytes.length, true); pos += 4
|
|
705
|
+
buf.set(bytes, pos)
|
|
706
|
+
return pos + bytes.length
|
|
707
|
+
}
|
|
708
|
+
case 4: { // Bool
|
|
709
|
+
buf[pos] = value ? 1 : 0
|
|
710
|
+
return pos + 1
|
|
711
|
+
}
|
|
712
|
+
default:
|
|
713
|
+
throw new RedDBError('UNKNOWN_BINARY_TAG', `tag=${tag}`)
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function jsonReason(bytes) {
|
|
718
|
+
const v = jsonOf(bytes)
|
|
719
|
+
if (v && typeof v === 'object' && typeof v.reason === 'string') {
|
|
720
|
+
return v.reason
|
|
721
|
+
}
|
|
722
|
+
return null
|
|
723
|
+
}
|