/**
* Length-prefixed JSON framing.
*
* Wire format: [4 bytes BE length][JSON payload].
*
* One frame = one JSON object. Multiple frames can be pipelined on a single
* stream — FrameDecoder handles incremental reads where a frame may arrive
* across multiple data chunks.
*/
import b4a from 'b4a'
const MAX_FRAME = 16 * 1024 * 1024 // 16 MiB hard cap to avoid runaway buffers
export function encodeFrame (obj) {
const json = JSON.stringify(obj)
const payload = b4a.from(json, 'utf8')
if (payload.length > MAX_FRAME) {
throw new Error(`Frame too large: ${payload.length} > ${MAX_FRAME}`)
}
const frame = b4a.alloc(4 + payload.length)
frame.writeUInt32BE(payload.length, 0)
payload.copy(frame, 4)
return frame
}
export class FrameDecoder {
/**
* @param {(obj: any) => void} onFrame - called once per complete frame
*/
constructor (onFrame) {
if (typeof onFrame !== 'function') throw new Error('onFrame is required')
this._onFrame = onFrame
this._buf = b4a.alloc(0)
}
/**
* Feed a chunk into the decoder. Calls onFrame for each complete frame.
* Throws if a frame exceeds MAX_FRAME or contains invalid JSON.
*/
push (chunk) {
this._buf = b4a.concat([this._buf, chunk])
while (this._buf.length >= 4) {
const len = this._buf.readUInt32BE(0)
if (len > MAX_FRAME) {
throw new Error(`Frame too large: ${len} > ${MAX_FRAME}`)
}
if (this._buf.length < 4 + len) break // wait for more bytes
const payload = this._buf.subarray(4, 4 + len)
this._buf = this._buf.subarray(4 + len)
const json = b4a.toString(payload, 'utf8')
const obj = JSON.parse(json) // throws on bad JSON
this._onFrame(obj)
}
}
/** Number of bytes currently buffered (waiting for the rest of a frame). */
get pending () { return this._buf.length }
}