Disclosure: this is an AI finding, made while implementing CRDT document sync
over Socket.IO. The diagnosis and the suggested patch below were produced and
verified by an AI agent; a human reviewed them before filing.
@socket.io/bun-engine@0.1.1 decides whether a payload is binary using
Buffer.isBuffer alone. Any binary payload that is not a Node Buffer — a
plain Uint8Array, a DataView, an ArrayBuffer — falls through to the text
arm and is emitted as PACKET_TYPES.get(type) + String(data).
dist/parser.js:
encodePacket({ type, data }, supportsBinary) {
if (Buffer.isBuffer(data)) {
return supportsBinary ? data : "b" + data.toString("base64");
} else {
return PACKET_TYPES.get(type) + (data || ""); // <- binary lands here
}
}
Buffer.isBuffer(new Uint8Array()) is false, so a Socket.IO packet carrying a
Uint8Array attachment goes out as "4" + "1,2,3,...". The client is mid
binary reconstruction and throws:
Error: got plaintext data when reconstructing a packet
at add (socket.io-parser/build/esm-debug/index.js:132)
The ack never arrives and the emit is lost, with no error on the server side.
That last part is what makes it expensive to find: the sender looks healthy.
Reproduction
Any server-side socket.emit('x', new Uint8Array([1, 2, 3]), ack) while running
on bun-engine. It shows up immediately with a CRDT library — json-crdt's
Model.toBinary() and Patch.toBinary() both return a plain Uint8Array, so
every document and every patch is affected.
Why Node's engine.io is unaffected
engine.io-parser checks data instanceof ArrayBuffer || isView(data), which
covers every typed array. Code that works on the Node adapter therefore breaks
silently when moved to this one.
Suggested fix
Mirror engine.io-parser's check, and normalise before the base64 arm —
toString('base64') on a bare Uint8Array yields "1,2,3", so that arm needs
a real Buffer too:
const binary = Buffer.isBuffer(data)
? data
: data instanceof ArrayBuffer
? Buffer.from(data)
: ArrayBuffer.isView(data)
? Buffer.from(data.buffer, data.byteOffset, data.byteLength)
: null;
if (binary) {
return supportsBinary ? binary : "b" + binary.toString("base64");
}
Buffer.from(view.buffer, view.byteOffset, view.byteLength) is zero-copy and
correct for a view over a larger buffer, which a bare Buffer.from(view) is
not.
Verified against bun 1.4.0, socket.io@4.8.3, engine.io-client@6.6.6, with
the patch applied and removed.
@socket.io/bun-engine@0.1.1decides whether a payload is binary usingBuffer.isBufferalone. Any binary payload that is not a NodeBuffer— aplain
Uint8Array, aDataView, anArrayBuffer— falls through to the textarm and is emitted as
PACKET_TYPES.get(type) + String(data).dist/parser.js:Buffer.isBuffer(new Uint8Array())isfalse, so a Socket.IO packet carrying aUint8Arrayattachment goes out as"4" + "1,2,3,...". The client is midbinary reconstruction and throws:
The ack never arrives and the emit is lost, with no error on the server side.
That last part is what makes it expensive to find: the sender looks healthy.
Reproduction
Any server-side
socket.emit('x', new Uint8Array([1, 2, 3]), ack)while runningon
bun-engine. It shows up immediately with a CRDT library —json-crdt'sModel.toBinary()andPatch.toBinary()both return a plainUint8Array, soevery document and every patch is affected.
Why Node's engine.io is unaffected
engine.io-parserchecksdata instanceof ArrayBuffer || isView(data), whichcovers every typed array. Code that works on the Node adapter therefore breaks
silently when moved to this one.
Suggested fix
Mirror
engine.io-parser's check, and normalise before the base64 arm —toString('base64')on a bareUint8Arrayyields"1,2,3", so that arm needsa real
Buffertoo:Buffer.from(view.buffer, view.byteOffset, view.byteLength)is zero-copy andcorrect for a view over a larger buffer, which a bare
Buffer.from(view)isnot.
Verified against bun 1.4.0,
socket.io@4.8.3,engine.io-client@6.6.6, withthe patch applied and removed.