@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.
@@ -0,0 +1,1144 @@
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
+ QueryWithParams: 0x28,
49
+ // Output/input streaming lifecycle (PRD #759). Mirrors
50
+ // `reddb_wire::redwire::frame::MessageKind` so the JS streaming surface
51
+ // talks the same multiplexed-stream vocabulary as the Rust server.
52
+ RowDescription: 0x24,
53
+ StreamEnd: 0x25,
54
+ OpenStream: 0x29,
55
+ OpenAck: 0x2A,
56
+ StreamChunk: 0x2B,
57
+ StreamError: 0x2C,
58
+ StreamCancel: 0x2D,
59
+ })
60
+
61
+ export const Features = Object.freeze({ PARAMS: 0x0000_0001 })
62
+
63
+ export const ValueTag = Object.freeze({
64
+ Null: 0x00, Bool: 0x01, Int: 0x02, Float: 0x03, Text: 0x04,
65
+ Bytes: 0x05, Vector: 0x06, Json: 0x07, Timestamp: 0x08, Uuid: 0x09,
66
+ })
67
+
68
+ /**
69
+ * Typed value tags for the binary fast path. Identical to the
70
+ * engine-side `wire::protocol::VAL_*` table.
71
+ */
72
+ export const BinaryTag = Object.freeze({
73
+ Null: 0,
74
+ I64: 1,
75
+ F64: 2,
76
+ Text: 3,
77
+ Bool: 4,
78
+ U64: 5,
79
+ })
80
+
81
+ const KIND_NAME = Object.fromEntries(
82
+ Object.entries(MessageKind).map(([k, v]) => [v, k]),
83
+ )
84
+
85
+ export const Flags = Object.freeze({
86
+ COMPRESSED: 0b00000001,
87
+ MORE_FRAMES: 0b00000010,
88
+ })
89
+
90
+ /** zstd level for outbound compressed frames. Override via env. */
91
+ const ZSTD_LEVEL = (() => {
92
+ const env = typeof process !== 'undefined' ? process.env?.RED_REDWIRE_ZSTD_LEVEL : null
93
+ const n = env ? Number(env) : NaN
94
+ return Number.isFinite(n) && n >= 1 && n <= 22 ? n : 1
95
+ })()
96
+
97
+ /**
98
+ * Compress / decompress shim. zstd is **optional and injected** by the
99
+ * host transport rather than imported here: the Node entry (`redwire.js`)
100
+ * wires `node:zlib`; the browser entry leaves it null so compression is a
101
+ * no-op. Keeping it injected is what lets this module stay free of any
102
+ * `node:` specifier, so the exact same codec rides into the browser
103
+ * bundle (#937, ADR 0036) past the portability guard. Peers still see the
104
+ * COMPRESSED flag bit when offered; encode is a no-op until a provider is
105
+ * set.
106
+ */
107
+ let _zstdMod = null
108
+
109
+ /**
110
+ * Install the zstd implementation (e.g. `node:zlib`). Pass `null` to
111
+ * disable. Idempotent; the host transport calls this once at startup.
112
+ */
113
+ export function setZstdProvider(mod) {
114
+ _zstdMod = mod ?? null
115
+ }
116
+
117
+ /**
118
+ * Run the RedWire handshake over an already-connected byte transport and
119
+ * return a ready `RedWireClient`. The transport is any duplex exposing the
120
+ * node-socket-shaped surface this codec consumes — `.on('data'|'error'|
121
+ * 'end'|'close', cb)`, `.write(bytes, cb)`, `.end()`. The Node entry hands
122
+ * a `node:net` / `node:tls` socket; the browser entry hands a binary
123
+ * WebSocket adapter (#937, ADR 0036). The protocol logic is identical
124
+ * across transports — that decoupling is the whole point.
125
+ *
126
+ * @param {object} socket Connected duplex byte transport.
127
+ * @param {object} [opts]
128
+ * @param {{ kind: 'anonymous' } | { kind: 'bearer', token: string }} [opts.auth]
129
+ * @param {string} [opts.clientName]
130
+ * @returns {Promise<RedWireClient>}
131
+ */
132
+ export async function connectRedwireOverSocket(socket, opts = {}) {
133
+ const auth = opts.auth ?? { kind: 'anonymous' }
134
+ const reader = new FrameReader(socket)
135
+
136
+ // Discriminator + minor-version byte.
137
+ await writeAll(socket, Uint8Array.from([MAGIC, SUPPORTED_VERSION]))
138
+
139
+ // Hello.
140
+ const methods = auth.kind === 'bearer' ? ['bearer'] : ['anonymous', 'bearer']
141
+ const helloPayload = jsonBytes({
142
+ versions: [SUPPORTED_VERSION],
143
+ auth_methods: methods,
144
+ features: 0,
145
+ client_name: opts.clientName ?? `reddb-js/0.2`,
146
+ })
147
+ await writeFrame(socket, MessageKind.Hello, 1n, helloPayload)
148
+
149
+ const ack = await reader.next()
150
+ if (ack.kind === MessageKind.AuthFail) {
151
+ socket.end()
152
+ const reason = jsonReason(ack.payload) ?? 'AuthFail at HelloAck'
153
+ throw new RedDBError('AUTH_REFUSED', `redwire: ${reason}`)
154
+ }
155
+ if (ack.kind !== MessageKind.HelloAck) {
156
+ socket.end()
157
+ throw new RedDBError(
158
+ 'PROTOCOL',
159
+ `expected HelloAck, got ${KIND_NAME[ack.kind] ?? ack.kind}`,
160
+ )
161
+ }
162
+ const ackParsed = jsonOf(ack.payload)
163
+ const chosenAuth = ackParsed?.auth
164
+ if (typeof chosenAuth !== 'string') {
165
+ socket.end()
166
+ throw new RedDBError('PROTOCOL', 'HelloAck missing `auth` field')
167
+ }
168
+
169
+ // AuthResponse.
170
+ let respPayload
171
+ if (chosenAuth === 'anonymous') {
172
+ respPayload = new Uint8Array()
173
+ } else if (chosenAuth === 'bearer') {
174
+ if (auth.kind !== 'bearer') {
175
+ socket.end()
176
+ throw new RedDBError(
177
+ 'AUTH_REFUSED',
178
+ 'server demanded bearer but no token was supplied',
179
+ )
180
+ }
181
+ respPayload = jsonBytes({ token: auth.token })
182
+ } else {
183
+ socket.end()
184
+ throw new RedDBError(
185
+ 'PROTOCOL',
186
+ `server picked unsupported auth method: ${chosenAuth}`,
187
+ )
188
+ }
189
+ await writeFrame(socket, MessageKind.AuthResponse, 2n, respPayload)
190
+
191
+ const final = await reader.next()
192
+ if (final.kind === MessageKind.AuthFail) {
193
+ socket.end()
194
+ const reason = jsonReason(final.payload) ?? 'auth refused'
195
+ throw new RedDBError('AUTH_REFUSED', reason)
196
+ }
197
+ if (final.kind !== MessageKind.AuthOk) {
198
+ socket.end()
199
+ throw new RedDBError(
200
+ 'PROTOCOL',
201
+ `expected AuthOk, got ${KIND_NAME[final.kind] ?? final.kind}`,
202
+ )
203
+ }
204
+ const session = jsonOf(final.payload) ?? {}
205
+ const features = numberOr(session.features, numberOr(ackParsed?.features, 0))
206
+
207
+ return new RedWireClient(socket, reader, session, features)
208
+ }
209
+
210
+ function numberOr(v, fallback) {
211
+ return typeof v === 'number' && Number.isFinite(v) ? v : fallback
212
+ }
213
+
214
+ /**
215
+ * Returned by `connectRedwire`. Methods map 1:1 to RedWire frame
216
+ * kinds. Reuses the same `RedDB`-shaped envelope as the other
217
+ * transports so the surface above this is uniform.
218
+ */
219
+ export class RedWireClient {
220
+ constructor(socket, reader, session, serverFeatures = 0) {
221
+ this.socket = socket
222
+ this.reader = reader
223
+ this.session = session
224
+ this.serverFeatures = serverFeatures >>> 0
225
+ this.nextCorr = 1n
226
+ this.nextStream = 1
227
+ this.closed = false
228
+ }
229
+
230
+ /** Raw advertised server feature bitmask. */
231
+ features() {
232
+ return this.serverFeatures
233
+ }
234
+
235
+ /** True when server advertised `FEATURE_PARAMS` (#357). */
236
+ supportsParams() {
237
+ return (this.serverFeatures & Features.PARAMS) === Features.PARAMS
238
+ }
239
+
240
+ async call(method, params = {}) {
241
+ if (method === 'query') return this.#query(params.sql ?? '', params.params)
242
+ if (method === 'insert') return this.#insert({ collection: params.collection, payload: params.payload })
243
+ if (method === 'bulk_insert') return this.#insert({ collection: params.collection, payloads: params.payloads })
244
+ if (method === 'bulk_insert_binary') {
245
+ return this.bulkInsertBinary(params.collection, params.columns, params.rows)
246
+ }
247
+ if (method === 'get') return this.#getOrDelete(MessageKind.Get, MessageKind.Result, params)
248
+ if (method === 'delete') return this.#getOrDelete(MessageKind.Delete, MessageKind.DeleteOk, params)
249
+ if (method === 'health' || method === 'version') return this.#ping()
250
+ throw new RedDBError(
251
+ 'UNKNOWN_METHOD',
252
+ `RedWire transport doesn't bridge '${method}' yet`,
253
+ )
254
+ }
255
+
256
+ /**
257
+ * Bulk-insert via the binary fast path (frame kind 0x06).
258
+ * Same hot-loop perf as the engine's `MSG_BULK_INSERT_BINARY`
259
+ * stress tests. Each row is an array of `[tag, value]` pairs
260
+ * matching the column order; tag values come from `BinaryTag`.
261
+ *
262
+ * Example:
263
+ * client.bulkInsertBinary('users', ['name', 'age'], [
264
+ * [[BinaryTag.Text, 'alice'], [BinaryTag.I64, 30n]],
265
+ * [[BinaryTag.Text, 'bob'], [BinaryTag.I64, 25n]],
266
+ * ])
267
+ */
268
+ async bulkInsertBinary(collection, columns, rows) {
269
+ if (!Array.isArray(columns) || !Array.isArray(rows)) {
270
+ throw new TypeError('bulkInsertBinary: columns and rows must be arrays')
271
+ }
272
+ const buf = encodeBinaryBulk(collection, columns, rows)
273
+ const corr = this.#corr()
274
+ await writeFrame(this.socket, MessageKind.BulkInsertBinary, corr, buf)
275
+ const resp = await this.reader.next()
276
+ if (resp.kind === MessageKind.BulkOk) {
277
+ // v1 BulkOk body is an 8-byte little-endian count.
278
+ if (resp.payload.length < 8) {
279
+ throw new RedDBError('PROTOCOL', 'BulkOk truncated: expected 8-byte count')
280
+ }
281
+ const view = new DataView(
282
+ resp.payload.buffer,
283
+ resp.payload.byteOffset,
284
+ resp.payload.byteLength,
285
+ )
286
+ return Number(view.getBigUint64(0, true))
287
+ }
288
+ if (resp.kind === MessageKind.Error) {
289
+ throw new RedDBError('ENGINE', new TextDecoder().decode(resp.payload))
290
+ }
291
+ throw new RedDBError(
292
+ 'PROTOCOL',
293
+ `expected BulkOk/Error, got ${KIND_NAME[resp.kind] ?? resp.kind}`,
294
+ )
295
+ }
296
+
297
+ async #getOrDelete(reqKind, okKind, params) {
298
+ const corr = this.#corr()
299
+ const payload = jsonBytes({ collection: params.collection, id: String(params.id) })
300
+ await writeFrame(this.socket, reqKind, corr, payload)
301
+ const resp = await this.reader.next()
302
+ if (resp.kind === okKind) return jsonOf(resp.payload) ?? {}
303
+ if (resp.kind === MessageKind.Error) {
304
+ throw new RedDBError('ENGINE', new TextDecoder().decode(resp.payload))
305
+ }
306
+ throw new RedDBError(
307
+ 'PROTOCOL',
308
+ `expected ${KIND_NAME[okKind]}/Error, got ${KIND_NAME[resp.kind] ?? resp.kind}`,
309
+ )
310
+ }
311
+
312
+ async #insert(body) {
313
+ const corr = this.#corr()
314
+ const payload = jsonBytes(body)
315
+ await writeFrame(this.socket, MessageKind.BulkInsert, corr, payload)
316
+ const resp = await this.reader.next()
317
+ if (resp.kind === MessageKind.BulkOk) {
318
+ return jsonOf(resp.payload) ?? { affected: 0 }
319
+ }
320
+ if (resp.kind === MessageKind.Error) {
321
+ throw new RedDBError('ENGINE', new TextDecoder().decode(resp.payload))
322
+ }
323
+ throw new RedDBError(
324
+ 'PROTOCOL',
325
+ `expected BulkOk/Error, got ${KIND_NAME[resp.kind] ?? resp.kind}`,
326
+ )
327
+ }
328
+
329
+ async #query(sql, params) {
330
+ const corr = this.#corr()
331
+ const hasParams = Array.isArray(params) && params.length > 0
332
+ let kind
333
+ let payload
334
+ if (hasParams) {
335
+ if (!this.supportsParams()) {
336
+ throw new RedDBError(
337
+ 'PARAMS_UNSUPPORTED',
338
+ 'server did not advertise FEATURE_PARAMS — upgrade the server '
339
+ + 'to one that supports parameterized queries.',
340
+ )
341
+ }
342
+ kind = MessageKind.QueryWithParams
343
+ payload = encodeQueryWithParams(sql, params)
344
+ } else {
345
+ kind = isSelectQuery(sql) ? MessageKind.QueryBinary : MessageKind.Query
346
+ payload = new TextEncoder().encode(sql)
347
+ }
348
+ await writeFrame(this.socket, kind, corr, payload)
349
+ const resp = await this.reader.next()
350
+ if (resp.kind === MessageKind.Result) {
351
+ return decodeResultPayload(resp.payload)
352
+ }
353
+ if (resp.kind === MessageKind.Error) {
354
+ throw new RedDBError(
355
+ 'ENGINE',
356
+ new TextDecoder().decode(resp.payload),
357
+ )
358
+ }
359
+ throw new RedDBError(
360
+ 'PROTOCOL',
361
+ `expected Result/Error, got ${KIND_NAME[resp.kind] ?? resp.kind}`,
362
+ )
363
+ }
364
+
365
+ async #ping() {
366
+ const corr = this.#corr()
367
+ await writeFrame(this.socket, MessageKind.Ping, corr, new Uint8Array())
368
+ const resp = await this.reader.next()
369
+ if (resp.kind !== MessageKind.Pong) {
370
+ throw new RedDBError(
371
+ 'PROTOCOL',
372
+ `expected Pong, got ${KIND_NAME[resp.kind] ?? resp.kind}`,
373
+ )
374
+ }
375
+ return { ok: true }
376
+ }
377
+
378
+ /**
379
+ * Open a streaming read over RedWire. Sends `OpenStream` and returns an
380
+ * async iterable of typed frames (see streaming.js) plus a
381
+ * `cancel(reason)` that emits a `StreamCancel` for this stream_id. The
382
+ * `OpenAck` is consumed internally; rows arrive as `StreamChunk`s and
383
+ * the stream closes on `StreamEnd`. A `StreamError` rejects iteration.
384
+ *
385
+ * @param {{ sql?: string, cursor?: string }} opts
386
+ */
387
+ async streamSelect({ sql, cursor } = {}) {
388
+ if (cursor != null) {
389
+ throw new RedDBError(
390
+ 'STREAM_CURSOR_UNSUPPORTED',
391
+ 'resumable cursors are only available over the HTTP transport in this release',
392
+ )
393
+ }
394
+ const streamId = this.#stream()
395
+ const corr = this.#corr()
396
+ await this.#writeStreamFrame(MessageKind.OpenStream, corr, jsonBytes({ sql }), streamId)
397
+
398
+ const reader = this.reader
399
+ const client = this
400
+ return {
401
+ async *[Symbol.asyncIterator]() {
402
+ for (;;) {
403
+ const resp = await reader.next()
404
+ if (resp.streamId !== 0 && resp.streamId !== streamId) {
405
+ continue
406
+ }
407
+ if (resp.kind === MessageKind.OpenAck) {
408
+ continue
409
+ }
410
+ if (resp.kind === MessageKind.StreamChunk) {
411
+ const chunk = jsonOf(resp.payload) ?? {}
412
+ const rows = Array.isArray(chunk.rows) ? chunk.rows : []
413
+ for (const row of rows) {
414
+ yield { type: 'row', value: row }
415
+ }
416
+ continue
417
+ }
418
+ if (resp.kind === MessageKind.StreamEnd) {
419
+ const end = jsonOf(resp.payload) ?? {}
420
+ yield { type: 'end', value: end.stats ?? end }
421
+ return
422
+ }
423
+ if (resp.kind === MessageKind.StreamError || resp.kind === MessageKind.Error) {
424
+ const err = jsonOf(resp.payload) ?? {}
425
+ throw new RedDBError(
426
+ err.code || 'STREAM_ERROR',
427
+ err.message || new TextDecoder().decode(resp.payload),
428
+ err,
429
+ )
430
+ }
431
+ throw new RedDBError(
432
+ 'STREAM_PROTOCOL',
433
+ `unexpected frame in stream: ${KIND_NAME[resp.kind] ?? resp.kind}`,
434
+ )
435
+ }
436
+ },
437
+ async cancel(reason) {
438
+ await client.#cancelStream(streamId, reason)
439
+ },
440
+ }
441
+ }
442
+
443
+ /**
444
+ * Open a streaming write over RedWire. The `OpenStream {direction:"in"}`
445
+ * frame is sent on the first `write()` (so columns can be inferred from
446
+ * the first row); each row is shipped as a one-row `StreamChunk` and the
447
+ * terminal chunk closes the input phase. `close()` resolves with the
448
+ * server's `StreamEnd` stats.
449
+ *
450
+ * @param {{ target: string, columns?: string[] }} opts
451
+ */
452
+ async streamInput({ target, columns } = {}) {
453
+ const streamId = this.#stream()
454
+ const corr = this.#corr()
455
+ const client = this
456
+ let opened = false
457
+ let seq = 0
458
+ let cols = Array.isArray(columns) && columns.length > 0 ? columns.slice() : null
459
+
460
+ const ensureOpen = async (row) => {
461
+ if (opened) return
462
+ if (!cols) {
463
+ cols = row && typeof row === 'object' ? Object.keys(row) : null
464
+ }
465
+ if (!cols || cols.length === 0) {
466
+ throw new RedDBError(
467
+ 'INVALID_STREAM_COLUMNS',
468
+ 'inputStream() needs a non-empty column set — pass { columns } or write at least one object row',
469
+ )
470
+ }
471
+ await client.#writeStreamFrame(
472
+ MessageKind.OpenStream,
473
+ corr,
474
+ jsonBytes({ direction: 'in', target, columns: cols }),
475
+ streamId,
476
+ )
477
+ const ack = await client.reader.next()
478
+ if (ack.kind === MessageKind.StreamError || ack.kind === MessageKind.Error) {
479
+ const err = jsonOf(ack.payload) ?? {}
480
+ throw new RedDBError(err.code || 'STREAM_ERROR', err.message || 'input stream refused', err)
481
+ }
482
+ if (ack.kind !== MessageKind.OpenAck) {
483
+ throw new RedDBError(
484
+ 'STREAM_PROTOCOL',
485
+ `expected OpenAck, got ${KIND_NAME[ack.kind] ?? ack.kind}`,
486
+ )
487
+ }
488
+ opened = true
489
+ }
490
+
491
+ return {
492
+ async write(row) {
493
+ await ensureOpen(row)
494
+ await client.#writeStreamFrame(
495
+ MessageKind.StreamChunk,
496
+ corr,
497
+ jsonBytes({ seq: seq++, rows: [row], terminal: false }),
498
+ streamId,
499
+ )
500
+ },
501
+ async close() {
502
+ await ensureOpen(null)
503
+ await client.#writeStreamFrame(
504
+ MessageKind.StreamChunk,
505
+ corr,
506
+ jsonBytes({ seq: seq++, rows: [], terminal: true }),
507
+ streamId,
508
+ )
509
+ const end = await client.reader.next()
510
+ if (end.kind === MessageKind.StreamError || end.kind === MessageKind.Error) {
511
+ const err = jsonOf(end.payload) ?? {}
512
+ throw new RedDBError(err.code || 'STREAM_ERROR', err.message || 'input stream failed', err)
513
+ }
514
+ if (end.kind !== MessageKind.StreamEnd) {
515
+ throw new RedDBError(
516
+ 'STREAM_PROTOCOL',
517
+ `expected StreamEnd, got ${KIND_NAME[end.kind] ?? end.kind}`,
518
+ )
519
+ }
520
+ const parsed = jsonOf(end.payload) ?? {}
521
+ return parsed.stats ?? parsed
522
+ },
523
+ async cancel(reason) {
524
+ await client.#cancelStream(streamId, reason)
525
+ },
526
+ }
527
+ }
528
+
529
+ async #cancelStream(streamId, reason) {
530
+ if (this.closed) return
531
+ const payload = typeof reason === 'string' && reason.length > 0
532
+ ? jsonBytes({ reason })
533
+ : new Uint8Array()
534
+ try {
535
+ await this.#writeStreamFrame(MessageKind.StreamCancel, this.#corr(), payload, streamId)
536
+ } catch {
537
+ // best-effort — the socket may already be torn down.
538
+ }
539
+ }
540
+
541
+ #writeStreamFrame(kind, corr, payload, streamId) {
542
+ const buf = encodeFrame(kind, corr, payload, 0, streamId)
543
+ return writeAll(this.socket, buf)
544
+ }
545
+
546
+ async close() {
547
+ if (this.closed) return
548
+ this.closed = true
549
+ try {
550
+ const corr = this.#corr()
551
+ await writeFrame(this.socket, MessageKind.Bye, corr, new Uint8Array())
552
+ } catch {
553
+ // best-effort
554
+ }
555
+ this.socket.end()
556
+ }
557
+
558
+ #corr() {
559
+ const c = this.nextCorr
560
+ this.nextCorr = this.nextCorr + 1n
561
+ return c
562
+ }
563
+
564
+ #stream() {
565
+ const id = this.nextStream
566
+ // stream_id 0 is reserved for handshake/lifecycle frames; wrap past it.
567
+ this.nextStream = this.nextStream >= 0xffff ? 1 : this.nextStream + 1
568
+ return id
569
+ }
570
+ }
571
+
572
+ // ---------------------------------------------------------------------------
573
+ // Framing helpers
574
+ // ---------------------------------------------------------------------------
575
+
576
+ export function encodeFrame(kind, correlationId, payload, flags = 0, streamId = 0) {
577
+ if (!(payload instanceof Uint8Array)) {
578
+ payload = new Uint8Array(payload)
579
+ }
580
+ let onWire = payload
581
+ let outFlags = flags & KNOWN_FLAGS
582
+ // We compress synchronously when the flag is set AND the
583
+ // runtime ships native zstd. Async flag-flip happens at
584
+ // session level (see RedWireClient construction); per-frame
585
+ // call here is a fast Buffer roundtrip.
586
+ if (outFlags & Flags.COMPRESSED && _zstdMod && typeof _zstdMod.zstdCompressSync === 'function') {
587
+ try {
588
+ const compressed = _zstdMod.zstdCompressSync(payload, {
589
+ params: { [_zstdMod.constants?.ZSTD_c_compressionLevel ?? 100]: ZSTD_LEVEL },
590
+ })
591
+ onWire = compressed instanceof Uint8Array ? compressed : new Uint8Array(compressed)
592
+ } catch {
593
+ // Fallback: ship plaintext, drop the flag so the peer
594
+ // doesn't try to decompress.
595
+ outFlags &= ~Flags.COMPRESSED
596
+ }
597
+ }
598
+ const length = FRAME_HEADER_SIZE + onWire.length
599
+ if (length > MAX_FRAME_SIZE) {
600
+ throw new RedDBError('FRAME_TOO_LARGE', `frame ${length} > ${MAX_FRAME_SIZE}`)
601
+ }
602
+ const buf = new Uint8Array(length)
603
+ const view = new DataView(buf.buffer)
604
+ view.setUint32(0, length, true)
605
+ buf[4] = kind
606
+ buf[5] = outFlags
607
+ view.setUint16(6, streamId, true)
608
+ view.setBigUint64(8, BigInt(correlationId), true)
609
+ buf.set(onWire, FRAME_HEADER_SIZE)
610
+ return buf
611
+ }
612
+
613
+ function writeFrame(socket, kind, correlationId, payload) {
614
+ const buf = encodeFrame(kind, correlationId, payload)
615
+ return writeAll(socket, buf)
616
+ }
617
+
618
+ export function decodeFrame(buf) {
619
+ if (buf.length < FRAME_HEADER_SIZE) return null
620
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
621
+ const length = view.getUint32(0, true)
622
+ if (length < FRAME_HEADER_SIZE || length > MAX_FRAME_SIZE) {
623
+ throw new RedDBError('FRAME_INVALID_LENGTH', `length=${length}`)
624
+ }
625
+ if (buf.length < length) return null
626
+ const kind = buf[4]
627
+ const flags = buf[5]
628
+ if (flags & ~KNOWN_FLAGS) {
629
+ throw new RedDBError('FRAME_UNKNOWN_FLAGS', `flags=0x${flags.toString(16)}`)
630
+ }
631
+ const streamId = view.getUint16(6, true)
632
+ const correlationId = view.getBigUint64(8, true)
633
+ let payload = buf.slice(FRAME_HEADER_SIZE, length)
634
+ if (flags & Flags.COMPRESSED) {
635
+ if (!_zstdMod || typeof _zstdMod.zstdDecompressSync !== 'function') {
636
+ throw new RedDBError(
637
+ 'COMPRESSED_BUT_NO_ZSTD',
638
+ 'incoming frame has COMPRESSED flag but runtime has no zstd support — upgrade Node >= 22',
639
+ )
640
+ }
641
+ try {
642
+ const plain = _zstdMod.zstdDecompressSync(payload)
643
+ payload = plain instanceof Uint8Array ? plain : new Uint8Array(plain)
644
+ } catch (err) {
645
+ throw new RedDBError('FRAME_DECOMPRESS_FAILED', err.message)
646
+ }
647
+ }
648
+ return { kind, flags, streamId, correlationId, payload, consumed: length }
649
+ }
650
+
651
+ /**
652
+ * Buffered frame reader — TCP delivers byte streams, frames may
653
+ * cross or share `data` events. Maintains a rolling accumulator
654
+ * and yields one frame per `next()` call.
655
+ */
656
+ class FrameReader {
657
+ constructor(socket) {
658
+ this.chunks = []
659
+ this.totalLen = 0
660
+ this.waiters = []
661
+ this.error = null
662
+ this.eof = false
663
+ socket.on('data', (chunk) => {
664
+ // chunk is Buffer (Node) or Uint8Array (Bun/Deno)
665
+ const u8 = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk)
666
+ this.chunks.push(u8)
667
+ this.totalLen += u8.length
668
+ this.#tryDeliver()
669
+ })
670
+ socket.on('error', (err) => {
671
+ this.error = err
672
+ this.#flushWaiters()
673
+ })
674
+ socket.on('end', () => {
675
+ this.eof = true
676
+ this.#flushWaiters()
677
+ })
678
+ socket.on('close', () => {
679
+ this.eof = true
680
+ this.#flushWaiters()
681
+ })
682
+ }
683
+
684
+ next() {
685
+ if (this.error) return Promise.reject(this.error)
686
+ return new Promise((resolve, reject) => {
687
+ this.waiters.push({ resolve, reject })
688
+ this.#tryDeliver()
689
+ })
690
+ }
691
+
692
+ #tryDeliver() {
693
+ while (this.waiters.length > 0 && this.totalLen > 0) {
694
+ const flat = this.#flatten()
695
+ let frame
696
+ try {
697
+ frame = decodeFrame(flat)
698
+ } catch (err) {
699
+ const w = this.waiters.shift()
700
+ w.reject(err)
701
+ return
702
+ }
703
+ if (frame == null) {
704
+ // Need more bytes — put the flattened buffer back as a
705
+ // single chunk so we don't keep flattening repeatedly.
706
+ this.chunks = [flat]
707
+ return
708
+ }
709
+ this.chunks = [flat.subarray(frame.consumed)]
710
+ this.totalLen = this.chunks[0].length
711
+ const w = this.waiters.shift()
712
+ w.resolve(frame)
713
+ }
714
+ if (this.eof && this.waiters.length > 0 && this.totalLen === 0) {
715
+ const err = this.error ?? new RedDBError('CONNECTION_CLOSED', 'redwire: connection closed')
716
+ while (this.waiters.length > 0) {
717
+ this.waiters.shift().reject(err)
718
+ }
719
+ }
720
+ }
721
+
722
+ #flushWaiters() {
723
+ if (this.waiters.length === 0) return
724
+ if (this.totalLen > 0) {
725
+ this.#tryDeliver()
726
+ return
727
+ }
728
+ const err = this.error ?? new RedDBError('CONNECTION_CLOSED', 'redwire: connection closed')
729
+ while (this.waiters.length > 0) {
730
+ this.waiters.shift().reject(err)
731
+ }
732
+ }
733
+
734
+ #flatten() {
735
+ if (this.chunks.length === 1) return this.chunks[0]
736
+ const out = new Uint8Array(this.totalLen)
737
+ let off = 0
738
+ for (const c of this.chunks) {
739
+ out.set(c, off)
740
+ off += c.length
741
+ }
742
+ this.chunks = [out]
743
+ return out
744
+ }
745
+ }
746
+
747
+ function writeAll(socket, bytes) {
748
+ return new Promise((resolve, reject) => {
749
+ socket.write(bytes, (err) => (err ? reject(err) : resolve()))
750
+ })
751
+ }
752
+
753
+ // ---------------------------------------------------------------------------
754
+ // JSON helpers — handshake payloads use JSON for now (CBOR follow-up)
755
+ // ---------------------------------------------------------------------------
756
+
757
+ function jsonBytes(obj) {
758
+ return new TextEncoder().encode(JSON.stringify(obj))
759
+ }
760
+
761
+ function jsonOf(bytes) {
762
+ if (!bytes || bytes.length === 0) return null
763
+ try {
764
+ return JSON.parse(new TextDecoder().decode(bytes))
765
+ } catch {
766
+ return null
767
+ }
768
+ }
769
+
770
+ function isSelectQuery(sql) {
771
+ return typeof sql === 'string' && /^\s*select\b/i.test(sql)
772
+ }
773
+
774
+ export function decodeResultPayload(payload) {
775
+ const json = jsonOf(payload)
776
+ if (json) return json
777
+ return decodeBinaryResultPayload(payload)
778
+ }
779
+
780
+ function decodeBinaryResultPayload(payload) {
781
+ if (!(payload instanceof Uint8Array)) {
782
+ payload = new Uint8Array(payload)
783
+ }
784
+ const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength)
785
+ const dec = new TextDecoder()
786
+ let pos = 0
787
+
788
+ const read = (n, label) => {
789
+ if (pos + n > payload.length) {
790
+ throw new RedDBError('PROTOCOL', `Result payload truncated while reading ${label}`)
791
+ }
792
+ const start = pos
793
+ pos += n
794
+ return start
795
+ }
796
+ const readU16 = (label) => view.getUint16(read(2, label), true)
797
+ const readU32 = (label) => view.getUint32(read(4, label), true)
798
+ const readI64 = (label) => safeBigIntToJs(view.getBigInt64(read(8, label), true))
799
+ const readU64 = (label) => safeBigIntToJs(view.getBigUint64(read(8, label), true))
800
+ const readF64 = (label) => view.getFloat64(read(8, label), true)
801
+ const readText = (n, label) => dec.decode(payload.subarray(read(n, label), pos))
802
+
803
+ const columnCount = readU16('column count')
804
+ const columns = []
805
+ for (let i = 0; i < columnCount; i += 1) {
806
+ const len = readU16(`column ${i} length`)
807
+ columns.push(readText(len, `column ${i} name`))
808
+ }
809
+
810
+ const rowCount = readU32('row count')
811
+ const rows = []
812
+ for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) {
813
+ const row = {}
814
+ for (const column of columns) {
815
+ row[column] = readBinaryValue()
816
+ }
817
+ rows.push(row)
818
+ }
819
+
820
+ return {
821
+ ok: true,
822
+ statement: 'SELECT',
823
+ affected: 0,
824
+ columns,
825
+ rows,
826
+ }
827
+
828
+ function readBinaryValue() {
829
+ const tag = payload[read(1, 'value tag')]
830
+ switch (tag) {
831
+ case BinaryTag.Null:
832
+ return null
833
+ case BinaryTag.I64:
834
+ return readI64('i64 value')
835
+ case BinaryTag.U64:
836
+ return readU64('u64 value')
837
+ case BinaryTag.F64:
838
+ return readF64('f64 value')
839
+ case BinaryTag.Text: {
840
+ const len = readU32('text length')
841
+ return readText(len, 'text value')
842
+ }
843
+ case BinaryTag.Bool:
844
+ return payload[read(1, 'bool value')] !== 0
845
+ default:
846
+ throw new RedDBError('PROTOCOL', `Result payload has unknown value tag ${tag}`)
847
+ }
848
+ }
849
+ }
850
+
851
+ function safeBigIntToJs(value) {
852
+ if (
853
+ value >= BigInt(Number.MIN_SAFE_INTEGER)
854
+ && value <= BigInt(Number.MAX_SAFE_INTEGER)
855
+ ) {
856
+ return Number(value)
857
+ }
858
+ return value
859
+ }
860
+
861
+ /**
862
+ * Encode the binary bulk-insert payload body (raw, no RedWire frame
863
+ * header — the body is wrapped by the caller as a `BulkInsertBinary`
864
+ * frame).
865
+ * Layout: `[coll_len u16][coll_bytes][ncols u16]
866
+ * [(name_len u16)(name_bytes)]*ncols
867
+ * [nrows u32]
868
+ * [(tag u8)(value)]*ncols * nrows`
869
+ */
870
+ function encodeBinaryBulk(collection, columns, rows) {
871
+ const enc = new TextEncoder()
872
+ const collBytes = enc.encode(collection)
873
+ // Pre-encode column names + their length prefixes.
874
+ const colChunks = columns.map((c) => enc.encode(c))
875
+ let total = 2 + collBytes.length + 2
876
+ for (const cb of colChunks) total += 2 + cb.length
877
+ total += 4
878
+ // Estimate row size — we'll resize if needed.
879
+ for (const row of rows) {
880
+ if (!Array.isArray(row) || row.length !== columns.length) {
881
+ throw new TypeError(
882
+ `bulkInsertBinary: each row must be an array of length ${columns.length}`,
883
+ )
884
+ }
885
+ for (const cell of row) {
886
+ total += sizeOfBinaryCell(cell)
887
+ }
888
+ }
889
+ const buf = new Uint8Array(total)
890
+ const view = new DataView(buf.buffer)
891
+ let pos = 0
892
+ view.setUint16(pos, collBytes.length, true); pos += 2
893
+ buf.set(collBytes, pos); pos += collBytes.length
894
+ view.setUint16(pos, colChunks.length, true); pos += 2
895
+ for (const cb of colChunks) {
896
+ view.setUint16(pos, cb.length, true); pos += 2
897
+ buf.set(cb, pos); pos += cb.length
898
+ }
899
+ view.setUint32(pos, rows.length, true); pos += 4
900
+ for (const row of rows) {
901
+ for (const cell of row) {
902
+ pos = writeBinaryCell(buf, view, pos, cell, enc)
903
+ }
904
+ }
905
+ return buf
906
+ }
907
+
908
+ function sizeOfBinaryCell(cell) {
909
+ if (!Array.isArray(cell) || cell.length !== 2) {
910
+ throw new TypeError('bulkInsertBinary cell must be [tag, value]')
911
+ }
912
+ const [tag] = cell
913
+ switch (tag) {
914
+ case 0: return 1
915
+ case 1: return 1 + 8
916
+ case 2: return 1 + 8
917
+ case 3: {
918
+ const v = cell[1]
919
+ const bytes = typeof v === 'string' ? new TextEncoder().encode(v).length : 0
920
+ return 1 + 4 + bytes
921
+ }
922
+ case 4: return 1 + 1
923
+ case 5: return 1 + 8
924
+ default: throw new RedDBError('UNKNOWN_BINARY_TAG', `tag=${tag}`)
925
+ }
926
+ }
927
+
928
+ function writeBinaryCell(buf, view, pos, cell, enc) {
929
+ const [tag, value] = cell
930
+ buf[pos++] = tag
931
+ switch (tag) {
932
+ case 0: // Null
933
+ return pos
934
+ case 1: { // I64
935
+ const bi = typeof value === 'bigint' ? value : BigInt(value)
936
+ view.setBigInt64(pos, bi, true)
937
+ return pos + 8
938
+ }
939
+ case 2: { // F64
940
+ view.setFloat64(pos, Number(value), true)
941
+ return pos + 8
942
+ }
943
+ case 3: { // Text
944
+ const bytes = enc.encode(String(value))
945
+ view.setUint32(pos, bytes.length, true); pos += 4
946
+ buf.set(bytes, pos)
947
+ return pos + bytes.length
948
+ }
949
+ case 4: { // Bool
950
+ buf[pos] = value ? 1 : 0
951
+ return pos + 1
952
+ }
953
+ case 5: { // U64
954
+ const bi = typeof value === 'bigint' ? value : BigInt(value)
955
+ view.setBigUint64(pos, bi, true)
956
+ return pos + 8
957
+ }
958
+ default:
959
+ throw new RedDBError('UNKNOWN_BINARY_TAG', `tag=${tag}`)
960
+ }
961
+ }
962
+
963
+ // ---------------------------------------------------------------------------
964
+ // QueryWithParams payload codec — mirrors `reddb_wire::query_with_params`
965
+ // ---------------------------------------------------------------------------
966
+
967
+ const MAX_VALUE_PAYLOAD_LEN = MAX_FRAME_SIZE
968
+ const MAX_PARAM_COUNT = 65_536
969
+
970
+ /**
971
+ * Encode the `QueryWithParams` payload body.
972
+ * Layout: `[u32 sql_len LE][utf-8 sql][u32 param_count LE][N encoded values]`
973
+ */
974
+ export function encodeQueryWithParams(sql, params) {
975
+ if (typeof sql !== 'string') throw new TypeError('encodeQueryWithParams: sql must be a string')
976
+ if (!Array.isArray(params)) throw new TypeError('encodeQueryWithParams: params must be an array')
977
+ if (params.length > MAX_PARAM_COUNT) {
978
+ throw new RedDBError('PARAM_COUNT_OVER_LIMIT', `param_count ${params.length} > ${MAX_PARAM_COUNT}`)
979
+ }
980
+ const sqlBytes = new TextEncoder().encode(sql)
981
+ if (sqlBytes.length > MAX_VALUE_PAYLOAD_LEN) {
982
+ throw new RedDBError('PAYLOAD_TOO_LARGE', `sql_len ${sqlBytes.length} > ${MAX_VALUE_PAYLOAD_LEN}`)
983
+ }
984
+ const valueBlobs = params.map(encodeValue)
985
+ let total = 4 + sqlBytes.length + 4
986
+ for (const vb of valueBlobs) total += vb.length
987
+ const buf = new Uint8Array(total)
988
+ const view = new DataView(buf.buffer)
989
+ let pos = 0
990
+ view.setUint32(pos, sqlBytes.length, true); pos += 4
991
+ buf.set(sqlBytes, pos); pos += sqlBytes.length
992
+ view.setUint32(pos, valueBlobs.length, true); pos += 4
993
+ for (const vb of valueBlobs) { buf.set(vb, pos); pos += vb.length }
994
+ return buf
995
+ }
996
+
997
+ /**
998
+ * Encode a single wire `Value`. Mirrors `reddb_wire::value::encode`.
999
+ *
1000
+ * Accepts native JS values + the JSON envelopes produced by
1001
+ * `serializeParam` so the SDK can pass through a single shape:
1002
+ * - `null` / `undefined` → Null
1003
+ * - `boolean` → Bool
1004
+ * - `bigint` → Int (i64)
1005
+ * - `number` integer (safe range) → Int; otherwise → Float
1006
+ * - `string` → Text
1007
+ * - `Uint8Array` / `Buffer` → Bytes
1008
+ * - `Float32Array` / `Array<number>` → Vector (f32)
1009
+ * - `{ $bytes: <base64> }` → Bytes
1010
+ * - `{ $ts: <unix-seconds> }` → Timestamp
1011
+ * - `{ $uuid: <hyphenated> }` → Uuid
1012
+ * - other plain object/array → Json (canonical bytes)
1013
+ */
1014
+ export function encodeValue(v) {
1015
+ if (v === null || v === undefined) return Uint8Array.of(ValueTag.Null)
1016
+ if (typeof v === 'boolean') return Uint8Array.of(ValueTag.Bool, v ? 1 : 0)
1017
+ if (typeof v === 'bigint') {
1018
+ const out = new Uint8Array(1 + 8)
1019
+ out[0] = ValueTag.Int
1020
+ new DataView(out.buffer).setBigInt64(1, v, true)
1021
+ return out
1022
+ }
1023
+ if (typeof v === 'number') {
1024
+ if (Number.isInteger(v) && v >= -(2 ** 53) && v <= 2 ** 53) {
1025
+ const out = new Uint8Array(1 + 8)
1026
+ out[0] = ValueTag.Int
1027
+ new DataView(out.buffer).setBigInt64(1, BigInt(v), true)
1028
+ return out
1029
+ }
1030
+ const out = new Uint8Array(1 + 8)
1031
+ out[0] = ValueTag.Float
1032
+ new DataView(out.buffer).setFloat64(1, v, true)
1033
+ return out
1034
+ }
1035
+ if (typeof v === 'string') return encodeLenPrefixed(ValueTag.Text, new TextEncoder().encode(v))
1036
+ if (v instanceof Uint8Array) return encodeLenPrefixed(ValueTag.Bytes, v)
1037
+ if (typeof Buffer !== 'undefined' && v instanceof Buffer) {
1038
+ return encodeLenPrefixed(ValueTag.Bytes, new Uint8Array(v.buffer, v.byteOffset, v.byteLength))
1039
+ }
1040
+ if (v instanceof Float32Array) return encodeVector(v)
1041
+ if (v instanceof Float64Array) return encodeVector(Float32Array.from(v))
1042
+ if (Array.isArray(v) && v.every((x) => typeof x === 'number')) {
1043
+ return encodeVector(Float32Array.from(v))
1044
+ }
1045
+ if (typeof v === 'object') {
1046
+ const keys = Object.keys(v)
1047
+ if (keys.length === 1) {
1048
+ const k = keys[0]
1049
+ if (k === '$bytes' && typeof v.$bytes === 'string') {
1050
+ return encodeLenPrefixed(ValueTag.Bytes, base64ToBytes(v.$bytes))
1051
+ }
1052
+ if (k === '$ts' && (
1053
+ (typeof v.$ts === 'number' && Number.isFinite(v.$ts))
1054
+ || typeof v.$ts === 'string'
1055
+ )) {
1056
+ const out = new Uint8Array(1 + 8)
1057
+ out[0] = ValueTag.Timestamp
1058
+ const raw = typeof v.$ts === 'string' ? BigInt(v.$ts) : BigInt(Math.trunc(v.$ts))
1059
+ new DataView(out.buffer).setBigInt64(1, raw, true)
1060
+ return out
1061
+ }
1062
+ if (k === '$uuid' && typeof v.$uuid === 'string') {
1063
+ const bytes = parseUuidHyphenated(v.$uuid)
1064
+ const out = new Uint8Array(1 + 16)
1065
+ out[0] = ValueTag.Uuid
1066
+ out.set(bytes, 1)
1067
+ return out
1068
+ }
1069
+ }
1070
+ return encodeLenPrefixed(ValueTag.Json, new TextEncoder().encode(canonicalJson(v)))
1071
+ }
1072
+ throw new RedDBError('UNSUPPORTED_PARAM', `cannot encode param of type ${typeof v}`)
1073
+ }
1074
+
1075
+ function encodeLenPrefixed(tag, bytes) {
1076
+ if (bytes.length > MAX_VALUE_PAYLOAD_LEN) {
1077
+ throw new RedDBError('PAYLOAD_TOO_LARGE', `value len ${bytes.length} > ${MAX_VALUE_PAYLOAD_LEN}`)
1078
+ }
1079
+ const out = new Uint8Array(1 + 4 + bytes.length)
1080
+ out[0] = tag
1081
+ new DataView(out.buffer).setUint32(1, bytes.length, true)
1082
+ out.set(bytes, 5)
1083
+ return out
1084
+ }
1085
+
1086
+ function encodeVector(f32) {
1087
+ if (f32.length * 4 > MAX_VALUE_PAYLOAD_LEN) {
1088
+ throw new RedDBError('PAYLOAD_TOO_LARGE', `vector bytes ${f32.length * 4} > ${MAX_VALUE_PAYLOAD_LEN}`)
1089
+ }
1090
+ const out = new Uint8Array(1 + 4 + f32.length * 4)
1091
+ out[0] = ValueTag.Vector
1092
+ const view = new DataView(out.buffer)
1093
+ view.setUint32(1, f32.length, true)
1094
+ for (let i = 0; i < f32.length; i++) {
1095
+ view.setFloat32(5 + i * 4, f32[i], true)
1096
+ }
1097
+ return out
1098
+ }
1099
+
1100
+ function base64ToBytes(s) {
1101
+ if (typeof Buffer !== 'undefined') {
1102
+ const b = Buffer.from(s, 'base64')
1103
+ return new Uint8Array(b.buffer, b.byteOffset, b.byteLength)
1104
+ }
1105
+ // eslint-disable-next-line no-undef
1106
+ const bin = atob(s)
1107
+ const out = new Uint8Array(bin.length)
1108
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
1109
+ return out
1110
+ }
1111
+
1112
+ function parseUuidHyphenated(s) {
1113
+ const hex = s.replace(/-/g, '')
1114
+ if (hex.length !== 32 || /[^0-9a-fA-F]/.test(hex)) {
1115
+ throw new RedDBError('UUID_INVALID', `bad uuid: ${s}`)
1116
+ }
1117
+ const out = new Uint8Array(16)
1118
+ for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
1119
+ return out
1120
+ }
1121
+
1122
+ /** Stable JSON serialization with sorted keys — matches the server's
1123
+ * canonical `crate::json` output so round-tripped Json values compare
1124
+ * byte-equal. */
1125
+ function canonicalJson(v) {
1126
+ if (v === null) return 'null'
1127
+ if (typeof v === 'number') return Number.isFinite(v) ? String(v) : 'null'
1128
+ if (typeof v === 'string') return JSON.stringify(v)
1129
+ if (typeof v === 'boolean') return v ? 'true' : 'false'
1130
+ if (Array.isArray(v)) return '[' + v.map(canonicalJson).join(',') + ']'
1131
+ if (typeof v === 'object') {
1132
+ const keys = Object.keys(v).sort()
1133
+ return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(v[k])).join(',') + '}'
1134
+ }
1135
+ return 'null'
1136
+ }
1137
+
1138
+ function jsonReason(bytes) {
1139
+ const v = jsonOf(bytes)
1140
+ if (v && typeof v === 'object' && typeof v.reason === 'string') {
1141
+ return v.reason
1142
+ }
1143
+ return null
1144
+ }