diff --git a/packages/core/realtime-js/src/RealtimeClient.ts b/packages/core/realtime-js/src/RealtimeClient.ts index ece630e12..e843afe7a 100755 --- a/packages/core/realtime-js/src/RealtimeClient.ts +++ b/packages/core/realtime-js/src/RealtimeClient.ts @@ -7,7 +7,9 @@ import { DEFAULT_TIMEOUT, SOCKET_STATES, TRANSPORTS, - VSN, + DEFAULT_VSN, + VSN_1_0_0, + VSN_2_0_0, WS_CLOSE_NORMAL, } from './lib/constants' @@ -70,6 +72,7 @@ export type RealtimeClientOptions = { timeout?: number heartbeatIntervalMs?: number heartbeatCallback?: (status: HeartbeatStatus) => void + vsn?: string logger?: Function encode?: Function decode?: Function @@ -109,6 +112,7 @@ export default class RealtimeClient { heartbeatCallback: (status: HeartbeatStatus) => void = noop ref: number = 0 reconnectTimer: Timer | null = null + vsn: string = DEFAULT_VSN logger: Function = noop logLevel?: LogLevel encode!: Function @@ -226,7 +230,7 @@ export default class RealtimeClient { * @returns string The URL of the websocket. */ endpointURL(): string { - return this._appendParams(this.endPoint, Object.assign({}, this.params, { vsn: VSN })) + return this._appendParams(this.endPoint, Object.assign({}, this.params, { vsn: this.vsn })) } /** @@ -811,6 +815,8 @@ export default class RealtimeClient { this.worker = options?.worker ?? false this.accessToken = options?.accessToken ?? null this.heartbeatCallback = options?.heartbeatCallback ?? noop + this.vsn = options?.vsn ?? DEFAULT_VSN + // Handle special cases if (options?.params) this.params = options.params if (options?.logger) this.logger = options.logger @@ -826,13 +832,27 @@ export default class RealtimeClient { return RECONNECT_INTERVALS[tries - 1] || DEFAULT_RECONNECT_FALLBACK }) - this.encode = - options?.encode ?? - ((payload: JSON, callback: Function) => { - return callback(JSON.stringify(payload)) - }) + switch (this.vsn) { + case VSN_1_0_0: + this.encode = + options?.encode ?? + ((payload: JSON, callback: Function) => { + return callback(JSON.stringify(payload)) + }) - this.decode = options?.decode ?? this.serializer.decode.bind(this.serializer) + this.decode = + options?.decode ?? + ((payload: string, callback: Function) => { + return callback(JSON.parse(payload)) + }) + break + case VSN_2_0_0: + this.encode = options?.encode ?? this.serializer.encode.bind(this.serializer) + this.decode = options?.decode ?? this.serializer.decode.bind(this.serializer) + break + default: + throw new Error(`Unsupported serializer version: ${this.vsn}`) + } // Handle worker setup if (this.worker) { diff --git a/packages/core/realtime-js/src/lib/constants.ts b/packages/core/realtime-js/src/lib/constants.ts index 8706a26d7..9024833fa 100755 --- a/packages/core/realtime-js/src/lib/constants.ts +++ b/packages/core/realtime-js/src/lib/constants.ts @@ -1,7 +1,10 @@ import { version } from './version' export const DEFAULT_VERSION = `realtime-js/${version}` -export const VSN: string = '1.0.0' + +export const VSN_1_0_0: string = '1.0.0' +export const VSN_2_0_0: string = '2.0.0' +export const DEFAULT_VSN: string = VSN_1_0_0 export const VERSION = version diff --git a/packages/core/realtime-js/src/lib/serializer.ts b/packages/core/realtime-js/src/lib/serializer.ts index d8d86b454..c88a3c1f6 100644 --- a/packages/core/realtime-js/src/lib/serializer.ts +++ b/packages/core/realtime-js/src/lib/serializer.ts @@ -1,16 +1,160 @@ // This file draws heavily from https://github.com/phoenixframework/phoenix/commit/cf098e9cf7a44ee6479d31d911a97d3c7430c6fe // License: https://github.com/phoenixframework/phoenix/blob/master/LICENSE.md +import { CHANNEL_EVENTS } from '../lib/constants' + +export type Msg = { + join_ref: string + ref: string + topic: string + event: string + payload: T +} export default class Serializer { HEADER_LENGTH = 1 + META_LENGTH = 4 + USER_BROADCAST_PUSH_META_LENGTH = 5 + KINDS = { push: 0, reply: 1, broadcast: 2, userBroadcastPush: 3, userBroadcast: 4 } + BINARY_ENCODING = 0 + JSON_ENCODING = 1 + BROADCAST = 'broadcast' + + encode( + msg: Msg<{ [key: string]: any } | ArrayBuffer>, + callback: (result: ArrayBuffer | string) => any + ) { + if (this._isArrayBuffer(msg.payload)) { + return callback(this._binaryEncodePush(msg as Msg)) + } + + if ( + msg.event === this.BROADCAST && + !(msg.payload instanceof ArrayBuffer) && + typeof msg.payload.event === 'string' + ) { + return callback( + this._binaryEncodeUserBroadcastPush(msg as Msg<{ event: string } & { [key: string]: any }>) + ) + } + + let payload = [msg.join_ref, msg.ref, msg.topic, msg.event, msg.payload] + return callback(JSON.stringify(payload)) + } + + private _binaryEncodePush(message: Msg) { + const { join_ref, ref, event, topic, payload } = message + const metaLength = this.META_LENGTH + join_ref.length + ref.length + topic.length + event.length + + const header = new ArrayBuffer(this.HEADER_LENGTH + metaLength) + let view = new DataView(header) + let offset = 0 + + view.setUint8(offset++, this.KINDS.push) // kind + view.setUint8(offset++, join_ref.length) + view.setUint8(offset++, ref.length) + view.setUint8(offset++, topic.length) + view.setUint8(offset++, event.length) + Array.from(join_ref, (char) => view.setUint8(offset++, char.charCodeAt(0))) + Array.from(ref, (char) => view.setUint8(offset++, char.charCodeAt(0))) + Array.from(topic, (char) => view.setUint8(offset++, char.charCodeAt(0))) + Array.from(event, (char) => view.setUint8(offset++, char.charCodeAt(0))) + + var combined = new Uint8Array(header.byteLength + payload.byteLength) + combined.set(new Uint8Array(header), 0) + combined.set(new Uint8Array(payload), header.byteLength) + + return combined.buffer + } + + private _binaryEncodeUserBroadcastPush(message: Msg<{ event: string } & { [key: string]: any }>) { + if (this._isArrayBuffer(message.payload?.payload)) { + return this._encodeBinaryUserBroadcastPush(message) + } else { + return this._encodeJsonUserBroadcastPush(message) + } + } + + private _encodeBinaryUserBroadcastPush(message: Msg<{ event: string } & { [key: string]: any }>) { + const { join_ref, ref, topic } = message + const userEvent = message.payload.event + const userPayload = message.payload?.payload ?? new ArrayBuffer(0) + + const metaLength = + this.USER_BROADCAST_PUSH_META_LENGTH + + join_ref.length + + ref.length + + topic.length + + userEvent.length + + const header = new ArrayBuffer(this.HEADER_LENGTH + metaLength) + let view = new DataView(header) + let offset = 0 + + view.setUint8(offset++, this.KINDS.userBroadcastPush) // kind + view.setUint8(offset++, join_ref.length) + view.setUint8(offset++, ref.length) + view.setUint8(offset++, topic.length) + view.setUint8(offset++, userEvent.length) + view.setUint8(offset++, this.BINARY_ENCODING) + Array.from(join_ref, (char) => view.setUint8(offset++, char.charCodeAt(0))) + Array.from(ref, (char) => view.setUint8(offset++, char.charCodeAt(0))) + Array.from(topic, (char) => view.setUint8(offset++, char.charCodeAt(0))) + Array.from(userEvent, (char) => view.setUint8(offset++, char.charCodeAt(0))) + + var combined = new Uint8Array(header.byteLength + userPayload.byteLength) + combined.set(new Uint8Array(header), 0) + combined.set(new Uint8Array(userPayload), header.byteLength) + + return combined.buffer + } + + private _encodeJsonUserBroadcastPush(message: Msg<{ event: string } & { [key: string]: any }>) { + const { join_ref, ref, topic } = message + const userEvent = message.payload.event + const userPayload = message.payload?.payload ?? {} + + const encoder = new TextEncoder() // Encodes to UTF-8 + const encodedUserPayload = encoder.encode(JSON.stringify(userPayload)).buffer + + const metaLength = + this.USER_BROADCAST_PUSH_META_LENGTH + + join_ref.length + + ref.length + + topic.length + + userEvent.length + + const header = new ArrayBuffer(this.HEADER_LENGTH + metaLength) + let view = new DataView(header) + let offset = 0 + + view.setUint8(offset++, this.KINDS.userBroadcastPush) // kind + view.setUint8(offset++, join_ref.length) + view.setUint8(offset++, ref.length) + view.setUint8(offset++, topic.length) + view.setUint8(offset++, userEvent.length) + view.setUint8(offset++, this.JSON_ENCODING) + Array.from(join_ref, (char) => view.setUint8(offset++, char.charCodeAt(0))) + Array.from(ref, (char) => view.setUint8(offset++, char.charCodeAt(0))) + Array.from(topic, (char) => view.setUint8(offset++, char.charCodeAt(0))) + Array.from(userEvent, (char) => view.setUint8(offset++, char.charCodeAt(0))) + + var combined = new Uint8Array(header.byteLength + encodedUserPayload.byteLength) + combined.set(new Uint8Array(header), 0) + combined.set(new Uint8Array(encodedUserPayload), header.byteLength) + + return combined.buffer + } decode(rawPayload: ArrayBuffer | string, callback: Function) { - if (rawPayload.constructor === ArrayBuffer) { - return callback(this._binaryDecode(rawPayload)) + if (this._isArrayBuffer(rawPayload)) { + let result = this._binaryDecode(rawPayload as ArrayBuffer) + return callback(result) } if (typeof rawPayload === 'string') { - return callback(JSON.parse(rawPayload)) + const jsonPayload = JSON.parse(rawPayload) + const [join_ref, ref, topic, event, payload] = jsonPayload + return callback({ join_ref, ref, topic, event, payload }) } return callback({}) @@ -18,9 +162,84 @@ export default class Serializer { private _binaryDecode(buffer: ArrayBuffer) { const view = new DataView(buffer) + const kind = view.getUint8(0) const decoder = new TextDecoder() + switch (kind) { + case this.KINDS.push: + return this._decodePush(buffer, view, decoder) + case this.KINDS.reply: + return this._decodeReply(buffer, view, decoder) + case this.KINDS.broadcast: + return this._decodeBroadcast(buffer, view, decoder) + case this.KINDS.userBroadcast: + return this._decodeUserBroadcast(buffer, view, decoder) + } + } + + private _decodePush( + buffer: ArrayBuffer, + view: DataView, + decoder: TextDecoder + ): { + join_ref: string + ref: null + topic: string + event: string + payload: { [key: string]: any } + } { + const joinRefSize = view.getUint8(1) + const topicSize = view.getUint8(2) + const eventSize = view.getUint8(3) + let offset = this.HEADER_LENGTH + this.META_LENGTH - 1 // pushes have no ref + const joinRef = decoder.decode(buffer.slice(offset, offset + joinRefSize)) + offset = offset + joinRefSize + const topic = decoder.decode(buffer.slice(offset, offset + topicSize)) + offset = offset + topicSize + const event = decoder.decode(buffer.slice(offset, offset + eventSize)) + offset = offset + eventSize + const data = JSON.parse(decoder.decode(buffer.slice(offset, buffer.byteLength))) + return { + join_ref: joinRef, + ref: null, + topic: topic, + event: event, + payload: data, + } + } - return this._decodeBroadcast(buffer, view, decoder) + private _decodeReply( + buffer: ArrayBuffer, + view: DataView, + decoder: TextDecoder + ): { + join_ref: string + ref: string + topic: string + event: CHANNEL_EVENTS.reply + payload: { status: string; response: { [key: string]: any } } + } { + const joinRefSize = view.getUint8(1) + const refSize = view.getUint8(2) + const topicSize = view.getUint8(3) + const eventSize = view.getUint8(4) + let offset = this.HEADER_LENGTH + this.META_LENGTH + const joinRef = decoder.decode(buffer.slice(offset, offset + joinRefSize)) + offset = offset + joinRefSize + const ref = decoder.decode(buffer.slice(offset, offset + refSize)) + offset = offset + refSize + const topic = decoder.decode(buffer.slice(offset, offset + topicSize)) + offset = offset + topicSize + const event = decoder.decode(buffer.slice(offset, offset + eventSize)) + offset = offset + eventSize + const data = JSON.parse(decoder.decode(buffer.slice(offset, buffer.byteLength))) + const payload = { status: event, response: data } + return { + join_ref: joinRef, + ref: ref, + topic: topic, + event: CHANNEL_EVENTS.reply, + payload: payload, + } } private _decodeBroadcast( @@ -28,6 +247,7 @@ export default class Serializer { view: DataView, decoder: TextDecoder ): { + join_ref: null ref: null topic: string event: string @@ -42,6 +262,52 @@ export default class Serializer { offset = offset + eventSize const data = JSON.parse(decoder.decode(buffer.slice(offset, buffer.byteLength))) - return { ref: null, topic: topic, event: event, payload: data } + return { join_ref: null, ref: null, topic: topic, event: event, payload: data } + } + + private _decodeUserBroadcast( + buffer: ArrayBuffer, + view: DataView, + decoder: TextDecoder + ): { + join_ref: null + ref: null + topic: string + event: string + payload: { [key: string]: any } + } { + const topicSize = view.getUint8(1) + const userEventSize = view.getUint8(2) + const metadataSize = view.getUint8(3) + const payloadEncoding = view.getUint8(4) + + let offset = this.HEADER_LENGTH + 4 + const topic = decoder.decode(buffer.slice(offset, offset + topicSize)) + offset = offset + topicSize + const userEvent = decoder.decode(buffer.slice(offset, offset + userEventSize)) + offset = offset + userEventSize + const metadata = decoder.decode(buffer.slice(offset, offset + metadataSize)) + offset = offset + metadataSize + + const payload = buffer.slice(offset, buffer.byteLength) + const parsedPayload = + payloadEncoding === this.JSON_ENCODING ? JSON.parse(decoder.decode(payload)) : payload + + const data: { [key: string]: any } = { + type: this.BROADCAST, + event: userEvent, + payload: parsedPayload, + } + + // Metadata is optional and always JSON encoded + if (metadataSize > 0) { + data['meta'] = JSON.parse(metadata) + } + + return { join_ref: null, ref: null, topic: topic, event: this.BROADCAST, payload: data } + } + + private _isArrayBuffer(buffer: any): boolean { + return buffer instanceof ArrayBuffer || buffer?.constructor?.name === 'ArrayBuffer' } } diff --git a/packages/core/realtime-js/test/RealtimeClient.config.test.ts b/packages/core/realtime-js/test/RealtimeClient.config.test.ts index 22d8e6d09..17e3e8a48 100644 --- a/packages/core/realtime-js/test/RealtimeClient.config.test.ts +++ b/packages/core/realtime-js/test/RealtimeClient.config.test.ts @@ -1,5 +1,5 @@ import assert from 'assert' -import { afterEach, beforeEach, describe, test } from 'vitest' +import { afterEach, beforeEach, describe, expect, test } from 'vitest' import RealtimeClient from '../src/RealtimeClient' import { setupRealtimeTest, cleanupRealtimeTest, TestSetup } from './helpers/setup' @@ -38,6 +38,20 @@ describe('endpointURL', () => { assert.equal(socket.endpointURL(), `${testSetup.url}/websocket?apikey=123456789&vsn=1.0.0`) }) + test('returns endpoint with valid vsn', () => { + const socket = new RealtimeClient(testSetup.url, { + params: { apikey: '123456789' }, + vsn: '2.0.0', + }) + assert.equal(socket.endpointURL(), `${testSetup.url}/websocket?apikey=123456789&vsn=2.0.0`) + }) + + test('errors out with unsupported version', () => { + expect( + () => new RealtimeClient(testSetup.url, { params: { apikey: '123456789' }, vsn: '4.0.0' }) + ).toThrow(/Unsupported serializer/) + }) + test('returns endpoint with no params (empty params object)', () => { const socket = new RealtimeClient(testSetup.url, { params: { apikey: '123456789' }, diff --git a/packages/core/realtime-js/test/RealtimeClient.transport.test.ts b/packages/core/realtime-js/test/RealtimeClient.transport.test.ts index ea2fe4d4f..47ebddc59 100644 --- a/packages/core/realtime-js/test/RealtimeClient.transport.test.ts +++ b/packages/core/realtime-js/test/RealtimeClient.transport.test.ts @@ -282,46 +282,6 @@ describe('custom encoder and decoder', () => { }) }) - test('decodes ArrayBuffer by default', () => { - testSetup.socket = new RealtimeClient(`wss://${testSetup.projectRef}/socket`, { - params: { apikey: '123456789' }, - }) - const buffer = new Uint8Array([ - 2, 20, 6, 114, 101, 97, 108, 116, 105, 109, 101, 58, 112, 117, 98, 108, 105, 99, 58, 116, 101, - 115, 116, 73, 78, 83, 69, 82, 84, 123, 34, 102, 111, 111, 34, 58, 34, 98, 97, 114, 34, 125, - ]).buffer - - testSetup.socket.decode(buffer, (decoded) => { - assert.deepStrictEqual(decoded, { - ref: null, - topic: 'realtime:public:test', - event: 'INSERT', - payload: { foo: 'bar' }, - }) - }) - }) - - test('decodes unexpected payload types to empty object by default', () => { - testSetup.socket = new RealtimeClient(`wss://${testSetup.projectRef}/socket`, { - params: { apikey: '123456789' }, - }) - - // Test various non-string, non-ArrayBuffer payload types that have .constructor - // This tests the fallback path on line 16 of serializer.ts - const testCases = [ - { payload: 123, description: 'number' }, - { payload: { foo: 'bar' }, description: 'object' }, - { payload: [1, 2, 3], description: 'array' }, - { payload: true, description: 'boolean' }, - ] - - testCases.forEach(({ payload, description }) => { - testSetup.socket.decode(payload as any, (decoded) => { - assert.deepStrictEqual(decoded, {}, `Expected empty object for ${description}`) - }) - }) - }) - test('allows custom decoding when using WebSocket transport', () => { let decoder = (_payload, callback) => callback('decode works') testSetup.socket = new RealtimeClient(`wss://${testSetup.projectRef}/socket`, { diff --git a/packages/core/realtime-js/test/serializer.test.ts b/packages/core/realtime-js/test/serializer.test.ts new file mode 100644 index 000000000..c75415ee6 --- /dev/null +++ b/packages/core/realtime-js/test/serializer.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, it } from 'vitest' +import Serializer from '../src/lib/serializer' +import type { Msg } from '../src/lib/serializer' + +let serializer = new Serializer() +let decoder = new TextDecoder() + +const encodeAsync = ( + serializer: Serializer, + msg: Msg +): Promise => { + return new Promise((resolve) => { + serializer.encode(msg, (result: ArrayBuffer | string) => { + resolve(result) + }) + }) +} + +const decodeAsync = ( + serializer: Serializer, + buffer: ArrayBuffer | string +): Promise> => { + return new Promise((resolve) => { + serializer.decode(buffer, (result: Msg<{ [key: string]: any }>) => { + resolve(result) + }) + }) +} + +let exampleMsg = { join_ref: '0', ref: '1', topic: 't', event: 'e', payload: { foo: 1 } } + +// \x01\x04 +let binPayload = () => { + let buffer = new ArrayBuffer(2) + new DataView(buffer).setUint8(0, 1) + new DataView(buffer).setUint8(1, 4) + return buffer +} + +describe('JSON', () => { + it('encodes', async () => { + const result = await encodeAsync(serializer, exampleMsg) + expect(result).toBe('["0","1","t","e",{"foo":1}]') + }) + + it('decodes', async () => { + const result = await decodeAsync(serializer, '["0","1","t","e",{"foo":1}]') + expect(result).toEqual(exampleMsg) + }) +}) + +describe('binary', () => { + it('encodes push', async () => { + let buffer = binPayload() + let bin = '\0\x01\x01\x01\x0101te\x01\x04' + const result = await encodeAsync(serializer, { + join_ref: '0', + ref: '1', + topic: 't', + event: 'e', + payload: buffer, + }) + expect(decoder.decode(result as ArrayBuffer)).toBe(bin) + }) + + it('encodes variable length segments', async () => { + let buffer = binPayload() + let bin = '\0\x02\x01\x03\x02101topev\x01\x04' + + const result = await encodeAsync(serializer, { + join_ref: '10', + ref: '1', + topic: 'top', + event: 'ev', + payload: buffer, + }) + expect(decoder.decode(result as ArrayBuffer)).toBe(bin) + }) + + it('encodes user broadcast push with JSON payload', async () => { + // 3 -> user_broadcast_push + // 2 join_ref length + // 1 for ref length + // 3 for topic length + // 10 for user event length + // 1 for JSON encoding + // actual join ref + // actual ref + // actual topic + // actual user event + // actual payload + let bin = '\x03\x02\x01\x03\x0a\x01101topuser-event{"a":"b"}' + + const result = await encodeAsync(serializer, { + join_ref: '10', + ref: '1', + topic: 'top', + event: 'broadcast', + payload: { + event: 'user-event', + payload: { + a: 'b', + }, + }, + }) + expect(decoder.decode(result as ArrayBuffer)).toBe(bin) + }) + + it('encodes user broadcast push with Binary payload', async () => { + // 3 -> user_broadcast_push + // 2 join_ref length + // 1 for ref length + // 3 for topic length + // 10 for user event length + // 0 for Binary encoding + // actual join ref + // actual ref + // actual topic + // actual user event + // actual payload + let bin = '\x03\x02\x01\x03\x0a\x00101topuser-event\x01\x04' + + const result = await encodeAsync(serializer, { + join_ref: '10', + ref: '1', + topic: 'top', + event: 'broadcast', + payload: { + event: 'user-event', + payload: binPayload(), + }, + }) + expect(decoder.decode(result as ArrayBuffer)).toBe(bin) + }) + + it('decodes push payload as JSON', async () => { + let bin = '\0\x03\x03\n123topsome-event{"a":"b"}' + let buffer = new TextEncoder().encode(bin).buffer + + const result = await decodeAsync(serializer, buffer) + + expect(result.join_ref).toBe('123') + expect(result.ref).toBeNull() + expect(result.topic).toBe('top') + expect(result.event).toBe('some-event') + expect(result.payload.constructor).toBe(Object) + expect(result.payload).toStrictEqual({ a: 'b' }) + }) + + it('decodes reply payload as JSON', async () => { + let bin = '\x01\x03\x02\x03\x0210012topok{"a":"b"}' + let buffer = new TextEncoder().encode(bin).buffer + + const result = await decodeAsync(serializer, buffer) + + expect(result.join_ref).toBe('100') + expect(result.ref).toBe('12') + expect(result.topic).toBe('top') + expect(result.event).toBe('phx_reply') + expect(result.payload.status).toBe('ok') + expect(result.payload.response.constructor).toBe(Object) + expect(result.payload.response).toStrictEqual({ a: 'b' }) + }) + + it('decodes broadcast payload as JSON', async () => { + let bin = '\x02\x03\ntopsome-event{"a":"b"}' + let buffer = new TextEncoder().encode(bin).buffer + + const result = await decodeAsync(serializer, buffer) + + expect(result.join_ref).toBeNull() + expect(result.ref).toBeNull() + expect(result.topic).toBe('top') + expect(result.event).toBe('some-event') + expect(result.payload.constructor).toBe(Object) + expect(result.payload).toStrictEqual({ a: 'b' }) + }) + + it('decodes user broadcast with JSON payload and no metadata', async () => { + // 4 -> user_broadcast + // 3 for topic length + // 10 for user event length + // 0 for metadata length + // 1 for JSON encoding + // actual topic + // actual user event + // (no metadata) + // actual payload + let bin = '\x04\x03\x0a\x00\x01topuser-event{"a":"b"}' + let buffer = new TextEncoder().encode(bin).buffer + + const result = await decodeAsync(serializer, buffer) + + expect(result.join_ref).toBeNull() + expect(result.ref).toBeNull() + expect(result.topic).toBe('top') + expect(result.event).toBe('broadcast') + expect(result.payload.constructor).toBe(Object) + expect(result.payload).toStrictEqual({ + type: 'broadcast', + event: 'user-event', + payload: { a: 'b' }, + }) + }) + + it('decodes user broadcast with JSON payload and metadata', async () => { + // 4 -> user_broadcast + // 3 for topic length + // 10 for user event length (\x0a) + // 17 for metadata length 17 (\x11) + // 1 for JSON encoding + // actual topic + // actual user event + // actual metadata + // actual payload + let bin = '\x04\x03\x0a\x11\x01topuser-event{"replayed":true}{"a":"b"}' + let buffer = new TextEncoder().encode(bin).buffer + ;('{"replayed":true}') + const result = await decodeAsync(serializer, buffer) + + expect(result.join_ref).toBeNull() + expect(result.ref).toBeNull() + expect(result.topic).toBe('top') + expect(result.event).toBe('broadcast') + expect(result.payload.constructor).toBe(Object) + expect(result.payload).toStrictEqual({ + type: 'broadcast', + event: 'user-event', + meta: { replayed: true }, + payload: { a: 'b' }, + }) + }) + + it('decodes user broadcast with binary payload and no metadata', async () => { + // 4 -> user_broadcast + // 3 for topic length + // 10 for user event length (\x0a) + // 0 for metadata length + // 0 for binary encoding + // actual topic + // actual user event + // (no metadata) + // actual payload + let bin = '\x04\x03\x0a\x00\x00topuser-event\x01\x04' + let buffer = new TextEncoder().encode(bin).buffer + + const result = await decodeAsync(serializer, buffer) + + expect(result.join_ref).toBeNull() + expect(result.ref).toBeNull() + expect(result.topic).toBe('top') + expect(result.event).toBe('broadcast') + expect(result.payload.constructor).toBe(Object) + expect(Object.keys(result.payload)).toHaveLength(3) + expect(result.payload.type).toBe('broadcast') + expect(result.payload.event).toBe('user-event') + expect(decoder.decode(result.payload.payload as ArrayBuffer)).toBe('\x01\x04') + }) + + it('decodes user broadcast with binary payload and metadata', async () => { + // 4 -> user_broadcast + // 3 for topic length + // 10 for user event length (\x0a) + // 17 for metadata length 17 (\x11) + // 0 for binary encoding + // actual topic + // actual user event + // actual metadata + // actual payload + let bin = '\x04\x03\x0a\x11\x00topuser-event{"replayed":true}\x01\x04' + let buffer = new TextEncoder().encode(bin).buffer + + const result = await decodeAsync(serializer, buffer) + + expect(result.join_ref).toBeNull() + expect(result.ref).toBeNull() + expect(result.topic).toBe('top') + expect(result.event).toBe('broadcast') + expect(result.payload.constructor).toBe(Object) + expect(Object.keys(result.payload)).toHaveLength(4) + expect(result.payload.type).toBe('broadcast') + expect(result.payload.event).toBe('user-event') + expect(result.payload.meta).toStrictEqual({ replayed: true }) + expect(decoder.decode(result.payload.payload as ArrayBuffer)).toBe('\x01\x04') + }) +})