diff --git a/packages/core/realtime-js/src/lib/serializer.ts b/packages/core/realtime-js/src/lib/serializer.ts index b1cbd932a..668ade2be 100644 --- a/packages/core/realtime-js/src/lib/serializer.ts +++ b/packages/core/realtime-js/src/lib/serializer.ts @@ -10,13 +10,18 @@ export type Msg = { export default class Serializer { HEADER_LENGTH = 1 - META_LENGTH = 4 - USER_BROADCAST_PUSH_META_LENGTH = 5 + USER_BROADCAST_PUSH_META_LENGTH = 6 KINDS = { userBroadcastPush: 3, userBroadcast: 4 } BINARY_ENCODING = 0 JSON_ENCODING = 1 BROADCAST_EVENT = 'broadcast' + allowedMetadataKeys: string[] = [] + + constructor(allowedMetadataKeys?: string[] | null) { + this.allowedMetadataKeys = allowedMetadataKeys ?? [] + } + encode(msg: Msg<{ [key: string]: any }>, callback: (result: ArrayBuffer | string) => any) { if ( msg.event === this.BROADCAST_EVENT && @@ -41,57 +46,58 @@ export default class Serializer { } private _encodeBinaryUserBroadcastPush(message: Msg<{ event: string } & { [key: string]: any }>) { - const topic = message.topic - const ref = message.ref ?? '' - const joinRef = message.join_ref ?? '' - const userEvent = message.payload.event const userPayload = message.payload?.payload ?? new ArrayBuffer(0) - - const metaLength = - this.USER_BROADCAST_PUSH_META_LENGTH + - joinRef.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++, joinRef.length) - view.setUint8(offset++, ref.length) - view.setUint8(offset++, topic.length) - view.setUint8(offset++, userEvent.length) - view.setUint8(offset++, this.BINARY_ENCODING) - Array.from(joinRef, (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 + return this._encodeUserBroadcastPush(message, this.BINARY_ENCODING, userPayload) } private _encodeJsonUserBroadcastPush(message: Msg<{ event: string } & { [key: string]: any }>) { + const userPayload = message.payload?.payload ?? {} + const encoder = new TextEncoder() + const encodedUserPayload = encoder.encode(JSON.stringify(userPayload)).buffer + return this._encodeUserBroadcastPush(message, this.JSON_ENCODING, encodedUserPayload) + } + + private _encodeUserBroadcastPush( + message: Msg<{ event: string } & { [key: string]: any }>, + encodingType: number, + encodedPayload: ArrayBuffer + ) { const topic = message.topic const ref = message.ref ?? '' const joinRef = message.join_ref ?? '' 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 + // Filter metadata based on allowed keys + const rest = this.allowedMetadataKeys + ? this._pick(message.payload, this.allowedMetadataKeys) + : {} + + const metadata = Object.keys(rest).length === 0 ? '' : JSON.stringify(rest) + + // Validate lengths don't exceed uint8 max value (255) + if (joinRef.length > 255) { + throw new Error(`joinRef length ${joinRef.length} exceeds maximum of 255`) + } + if (ref.length > 255) { + throw new Error(`ref length ${ref.length} exceeds maximum of 255`) + } + if (topic.length > 255) { + throw new Error(`topic length ${topic.length} exceeds maximum of 255`) + } + if (userEvent.length > 255) { + throw new Error(`userEvent length ${userEvent.length} exceeds maximum of 255`) + } + if (metadata.length > 255) { + throw new Error(`metadata length ${metadata.length} exceeds maximum of 255`) + } const metaLength = this.USER_BROADCAST_PUSH_META_LENGTH + joinRef.length + ref.length + topic.length + - userEvent.length + userEvent.length + + metadata.length const header = new ArrayBuffer(this.HEADER_LENGTH + metaLength) let view = new DataView(header) @@ -102,15 +108,17 @@ export default class Serializer { view.setUint8(offset++, ref.length) view.setUint8(offset++, topic.length) view.setUint8(offset++, userEvent.length) - view.setUint8(offset++, this.JSON_ENCODING) + view.setUint8(offset++, metadata.length) + view.setUint8(offset++, encodingType) Array.from(joinRef, (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))) + Array.from(metadata, (char) => view.setUint8(offset++, char.charCodeAt(0))) - var combined = new Uint8Array(header.byteLength + encodedUserPayload.byteLength) + var combined = new Uint8Array(header.byteLength + encodedPayload.byteLength) combined.set(new Uint8Array(header), 0) - combined.set(new Uint8Array(encodedUserPayload), header.byteLength) + combined.set(new Uint8Array(encodedPayload), header.byteLength) return combined.buffer } @@ -185,4 +193,11 @@ export default class Serializer { private _isArrayBuffer(buffer: any): boolean { return buffer instanceof ArrayBuffer || buffer?.constructor?.name === 'ArrayBuffer' } + + private _pick(obj: Record | null | undefined, keys: string[]): Record { + if (!obj || typeof obj !== 'object') { + return {} + } + return Object.fromEntries(Object.entries(obj).filter(([key]) => keys.includes(key))) + } } diff --git a/packages/core/realtime-js/test/serializer.test.ts b/packages/core/realtime-js/test/serializer.test.ts index eb4798540..9952012d6 100644 --- a/packages/core/realtime-js/test/serializer.test.ts +++ b/packages/core/realtime-js/test/serializer.test.ts @@ -2,7 +2,6 @@ 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 = ( @@ -46,40 +45,81 @@ let binPayload = () => { describe('JSON', () => { it('encodes', async () => { + const serializer = new Serializer() const result = await encodeAsync(serializer, exampleMsg) expect(result).toBe('["0","1","t","e",{"foo":1}]') }) it('encodes missing refs', async () => { + const serializer = new Serializer() const result = await encodeAsync(serializer, missingRefExampleMsg) expect(result).toBe('[null,null,"t","e",{"foo":1}]') }) it('decodes', async () => { + const serializer = new Serializer() const result = await decodeAsync(serializer, '["0","1","t","e",{"foo":1}]') expect(result).toEqual(exampleMsg) }) it('decodes missing refs', async () => { + const serializer = new Serializer() const result = await decodeAsync(serializer, '[null,null,"t","e",{"foo":1}]') expect(result).toEqual(missingRefExampleMsg) }) }) describe('binary', () => { - it('encodes user broadcast push with JSON payload', async () => { + it('encodes user broadcast push with JSON payload no metadata', async () => { + const serializer = new Serializer() // 3 -> user_broadcast_push // 2 join_ref length // 1 for ref length // 3 for topic length // 10 for user event length + // 0 for metadata length + // 1 for JSON encoding + // actual join ref + // actual ref + // actual topic + // actual user event + // no actual metadata + // actual payload + let bin = '\x03\x02\x01\x03\x0a\x00\x01101topuser-event{"a":"b"}' + + const result = await encodeAsync(serializer, { + join_ref: '10', + ref: '1', + topic: 'top', + event: 'broadcast', + payload: { + type: 'broadcast', + event: 'user-event', + payload: { + a: 'b', + }, + }, + }) + expect(decoder.decode(result as ArrayBuffer)).toBe(bin) + }) + + it('encodes user broadcast push with JSON payload with allowed metadata', async () => { + const serializer = new Serializer(['extra']) + + // 3 -> user_broadcast_push + // 2 join_ref length + // 1 for ref length + // 3 for topic length + // 10 for user event length + // 15 for metadata length // 1 for JSON encoding // actual join ref // actual ref // actual topic // actual user event + // actual metadata // actual payload - let bin = '\x03\x02\x01\x03\x0a\x01101topuser-event{"a":"b"}' + let bin = '\x03\x02\x01\x03\x0a\x0f\x01101topuser-event{"extra":"bit"}{"a":"b"}' const result = await encodeAsync(serializer, { join_ref: '10', @@ -87,7 +127,11 @@ describe('binary', () => { topic: 'top', event: 'broadcast', payload: { + type: 'broadcast', event: 'user-event', + extra: 'bit', + // store field is not included into metadata + store: true, payload: { a: 'b', }, @@ -97,18 +141,20 @@ describe('binary', () => { }) it('encodes user broadcast push with JSON payload no refs', async () => { + const serializer = new Serializer() // 3 -> user_broadcast_push // 0 join_ref length // 0 for ref length // 3 for topic length // 10 for user event length + // 0 for metadata length // 1 for JSON encoding // actual join ref // actual ref // actual topic // actual user event // actual payload - let bin = '\x03\x00\x00\x03\x0a\x01topuser-event{"a":"b"}' + let bin = '\x03\x00\x00\x03\x0a\x00\x01topuser-event{"a":"b"}' const result = await encodeAsync(serializer, { topic: 'top', @@ -123,19 +169,129 @@ describe('binary', () => { expect(decoder.decode(result as ArrayBuffer)).toBe(bin) }) + it('throws error when joinRef exceeds 255 characters with JSON payload', async () => { + const serializer = new Serializer() + const longJoinRef = 'a'.repeat(256) + + await expect( + encodeAsync(serializer, { + join_ref: longJoinRef, + ref: '1', + topic: 'top', + event: 'broadcast', + payload: { + type: 'broadcast', + event: 'user-event', + payload: { + a: 'b', + }, + }, + }) + ).rejects.toThrow('joinRef length 256 exceeds maximum of 255') + }) + + it('throws error when ref exceeds 255 characters with JSON payload', async () => { + const serializer = new Serializer() + const longRef = 'a'.repeat(256) + + await expect( + encodeAsync(serializer, { + join_ref: '10', + ref: longRef, + topic: 'top', + event: 'broadcast', + payload: { + type: 'broadcast', + event: 'user-event', + payload: { + a: 'b', + }, + }, + }) + ).rejects.toThrow('ref length 256 exceeds maximum of 255') + }) + + it('throws error when topic exceeds 255 characters with JSON payload', async () => { + const serializer = new Serializer() + const longTopic = 'a'.repeat(256) + + await expect( + encodeAsync(serializer, { + join_ref: '10', + ref: '1', + topic: longTopic, + event: 'broadcast', + payload: { + type: 'broadcast', + event: 'user-event', + payload: { + a: 'b', + }, + }, + }) + ).rejects.toThrow('topic length 256 exceeds maximum of 255') + }) + + it('throws error when user event exceeds 255 characters with JSON payload', async () => { + const serializer = new Serializer() + const longUserEvent = 'a'.repeat(256) + + await expect( + encodeAsync(serializer, { + join_ref: '10', + ref: '1', + topic: 'top', + event: 'broadcast', + payload: { + type: 'broadcast', + event: longUserEvent, + payload: { + a: 'b', + }, + }, + }) + ).rejects.toThrow('userEvent length 256 exceeds maximum of 255') + }) + + it('throws error when metadata exceeds 255 characters with JSON payload', async () => { + const serializer = new Serializer(['extraField']) + // Create metadata that will exceed 255 chars when JSON.stringify'd + const longValue = 'a'.repeat(240) + + await expect( + encodeAsync(serializer, { + join_ref: '10', + ref: '1', + topic: 'top', + event: 'broadcast', + payload: { + type: 'broadcast', + event: 'user-event', + payload: { + a: 'b', + }, + extraField: longValue, // This will be in the metadata (rest) + }, + }) + ).rejects.toThrow('metadata length') + }) + it('encodes user broadcast push with Binary payload', async () => { + const serializer = new Serializer() // 3 -> user_broadcast_push // 2 join_ref length // 1 for ref length // 3 for topic length // 10 for user event length + // 0 for metadata length // 0 for Binary encoding // actual join ref // actual ref // actual topic // actual user event + // no actual metadata // actual payload - let bin = '\x03\x02\x01\x03\x0a\x00101topuser-event\x01\x04' + let bin = '\x03\x02\x01\x03\x0a\x00\x00101topuser-event\x01\x04' const result = await encodeAsync(serializer, { join_ref: '10', @@ -151,18 +307,21 @@ describe('binary', () => { }) it('encodes user broadcast push with Binary payload no refs', async () => { + const serializer = new Serializer() // 3 -> user_broadcast_push // 0 join_ref length // 0 for ref length // 3 for topic length // 10 for user event length + // 0 for metadata length // 0 for Binary encoding // actual join ref // actual ref // actual topic // actual user event + // no actual metadata // actual payload - let bin = '\x03\x00\x00\x03\x0a\x00topuser-event\x01\x04' + let bin = '\x03\x00\x00\x03\x0a\x00\x00topuser-event\x01\x04' const result = await encodeAsync(serializer, { topic: 'top', @@ -175,7 +334,94 @@ describe('binary', () => { expect(decoder.decode(result as ArrayBuffer)).toBe(bin) }) + it('throws error when joinRef exceeds 255 characters', async () => { + const serializer = new Serializer() + const longJoinRef = 'a'.repeat(256) + + await expect( + encodeAsync(serializer, { + topic: 'top', + event: 'broadcast', + join_ref: longJoinRef, + payload: { + event: 'user-event', + payload: binPayload(), + }, + }) + ).rejects.toThrow('joinRef length 256 exceeds maximum of 255') + }) + + it('throws error when ref exceeds 255 characters', async () => { + const serializer = new Serializer() + const longRef = 'a'.repeat(256) + + await expect( + encodeAsync(serializer, { + topic: 'top', + event: 'broadcast', + ref: longRef, + payload: { + event: 'user-event', + payload: binPayload(), + }, + }) + ).rejects.toThrow('ref length 256 exceeds maximum of 255') + }) + + it('throws error when topic exceeds 255 characters', async () => { + const serializer = new Serializer() + const longTopic = 'a'.repeat(256) + + await expect( + encodeAsync(serializer, { + topic: longTopic, + event: 'broadcast', + payload: { + event: 'user-event', + payload: binPayload(), + }, + }) + ).rejects.toThrow('topic length 256 exceeds maximum of 255') + }) + + it('throws error when user event exceeds 255 characters', async () => { + const serializer = new Serializer() + const longUserEvent = 'a'.repeat(256) + + await expect( + encodeAsync(serializer, { + topic: 'top', + event: 'broadcast', + payload: { + event: longUserEvent, + payload: binPayload(), + }, + }) + ).rejects.toThrow('userEvent length 256 exceeds maximum of 255') + }) + + it('throws error when metadata exceeds 255 characters', async () => { + const serializer = new Serializer(['extraField']) + // Create metadata that will exceed 255 chars when JSON.stringify'd + // JSON.stringify will add quotes and colons, so we need a bit less than 256 + const longValue = 'a'.repeat(240) + + await expect( + encodeAsync(serializer, { + topic: 'top', + event: 'broadcast', + payload: { + event: 'user-event', + payload: binPayload(), + extraField: longValue, // This will be in the metadata + }, + }) + ).rejects.toThrow('metadata length') + // Note: The exact length will depend on JSON.stringify output + }) + it('decodes user broadcast with JSON payload and no metadata', async () => { + const serializer = new Serializer() // 4 -> user_broadcast // 3 for topic length // 10 for user event length @@ -203,6 +449,7 @@ describe('binary', () => { }) it('decodes user broadcast with JSON payload and metadata', async () => { + const serializer = new Serializer() // 4 -> user_broadcast // 3 for topic length // 10 for user event length (\x0a) @@ -231,6 +478,7 @@ describe('binary', () => { }) it('decodes user broadcast with binary payload and no metadata', async () => { + const serializer = new Serializer() // 4 -> user_broadcast // 3 for topic length // 10 for user event length (\x0a) @@ -257,6 +505,7 @@ describe('binary', () => { }) it('decodes user broadcast with binary payload and metadata', async () => { + const serializer = new Serializer() // 4 -> user_broadcast // 3 for topic length // 10 for user event length (\x0a)