diff --git a/apps/studio/components/interfaces/Realtime/Inspector/MessagesFormatters.test.ts b/apps/studio/components/interfaces/Realtime/Inspector/MessagesFormatters.test.ts new file mode 100644 index 0000000000000..a27e18a821457 --- /dev/null +++ b/apps/studio/components/interfaces/Realtime/Inspector/MessagesFormatters.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest' + +import { formatHexdump, isBinaryPayload, withBinaryPayloadPlaceholder } from './MessagesFormatters' + +describe('isBinaryPayload', () => { + it('returns true for ArrayBuffer', () => { + expect(isBinaryPayload(new ArrayBuffer(0))).toBe(true) + expect(isBinaryPayload(new ArrayBuffer(8))).toBe(true) + }) + + it('returns true for TypedArrays', () => { + expect(isBinaryPayload(new Uint8Array([1, 2, 3]))).toBe(true) + expect(isBinaryPayload(new Int16Array(4))).toBe(true) + expect(isBinaryPayload(new Float32Array(2))).toBe(true) + }) + + it('returns true for DataView', () => { + expect(isBinaryPayload(new DataView(new ArrayBuffer(4)))).toBe(true) + }) + + it('returns true for a TypedArray view with non-zero byteOffset', () => { + const buffer = new ArrayBuffer(8) + const view = new Uint8Array(buffer, 2, 4) + expect(isBinaryPayload(view)).toBe(true) + }) + + it('returns false for nullish values', () => { + expect(isBinaryPayload(null)).toBe(false) + expect(isBinaryPayload(undefined)).toBe(false) + }) + + it('returns false for plain objects (including the legacy { type: "Buffer", data: [...] } shape)', () => { + expect(isBinaryPayload({})).toBe(false) + expect(isBinaryPayload({ type: 'Buffer', data: [1, 2, 3] })).toBe(false) + }) + + it('returns false for primitives and arrays of numbers', () => { + expect(isBinaryPayload('hello')).toBe(false) + expect(isBinaryPayload(42)).toBe(false) + expect(isBinaryPayload([1, 2, 3])).toBe(false) + }) +}) + +describe('formatHexdump', () => { + it('returns an empty string for an empty buffer', () => { + expect(formatHexdump(new ArrayBuffer(0))).toBe('') + expect(formatHexdump(new Uint8Array(0))).toBe('') + }) + + it('renders a single row for "Hello World" with offset, byte groups, and ASCII gutter', () => { + const bytes = new TextEncoder().encode('Hello World') + const expected = + '00000000 48 65 6c 6c 6f 20 57 6f 72 6c 64' + ' '.repeat(15) + ' |Hello World|' + expect(formatHexdump(bytes)).toBe(expected) + }) + + it('renders all 16 bytes in one row with dots in the gutter for all-zero buffer', () => { + const expected = + '00000000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|' + expect(formatHexdump(new Uint8Array(16))).toBe(expected) + }) + + it('renders two rows for a 17-byte buffer, padding the short last row so the gutter aligns', () => { + const bytes = new Uint8Array(17).fill(0x41) + const row1 = '00000000 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 |AAAAAAAAAAAAAAAA|' + const row2 = '00000010 41' + ' '.repeat(21) + ' ' + ' '.repeat(23) + ' |A|' + expect(formatHexdump(bytes)).toBe(`${row1}\n${row2}`) + }) + + it('increments the offset column on later rows', () => { + const bytes = new Uint8Array(32) + bytes.fill(0x41, 0, 16) // 'A' × 16 + bytes.fill(0x42, 16, 32) // 'B' × 16 + + const secondRow = formatHexdump(bytes).split('\n')[1] + const expected = + '00000010 42 42 42 42 42 42 42 42 42 42 42 42 42 42 42 42 |BBBBBBBBBBBBBBBB|' + expect(secondRow).toBe(expected) + }) + + it('replaces non-printable bytes with "." in the ASCII gutter, leaving printable bytes (including space and ~) intact', () => { + // Boundary mix: 0x00 (null) and 0x1f (just below printable) → dot; + // 0x20 (space) and 0x7e (~) → printable (lowest/highest); + // 0x7f (DEL), 0x80, 0xff → dot. + const bytes = new Uint8Array([0x00, 0x1f, 0x20, 0x41, 0x7e, 0x7f, 0x80, 0xff]) + const expected = '00000000 00 1f 20 41 7e 7f 80 ff ' + ' '.repeat(23) + ' |.. A~...|' + expect(formatHexdump(bytes)).toBe(expected) + }) + + it('respects the view window when given a TypedArray with byteOffset > 0', () => { + const buffer = new ArrayBuffer(8) + const all = new Uint8Array(buffer) + all.set([0xaa, 0xbb, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0xff]) + + const view = new Uint8Array(buffer, 2, 5) // "Hello" + const expected = + '00000000 48 65 6c 6c 6f' + ' '.repeat(9) + ' ' + ' '.repeat(23) + ' |Hello|' + expect(formatHexdump(view)).toBe(expected) + }) +}) + +describe('withBinaryPayloadPlaceholder', () => { + it('returns the same reference when the payload is not binary', () => { + const metadata = { type: 'broadcast', event: 'e', payload: { hello: 'world' } } + expect(withBinaryPayloadPlaceholder(metadata)).toBe(metadata) + }) + + it('returns null and undefined unchanged', () => { + expect(withBinaryPayloadPlaceholder(null)).toBe(null) + expect(withBinaryPayloadPlaceholder(undefined)).toBe(undefined) + }) + + it('replaces an ArrayBuffer payload with a byte-length placeholder, preserving siblings', () => { + const original = { + type: 'broadcast', + event: 'binary-test', + payload: new ArrayBuffer(11), + meta: { id: 'abc' }, + } + const result = withBinaryPayloadPlaceholder(original) + + expect(result).not.toBe(original) + expect(original.payload).toBeInstanceOf(ArrayBuffer) + expect(result).toEqual({ + type: 'broadcast', + event: 'binary-test', + payload: '', + meta: { id: 'abc' }, + }) + }) + + it('reports the view byteLength (not the underlying buffer) for a Uint8Array with byteOffset > 0', () => { + const buffer = new ArrayBuffer(16) + const view = new Uint8Array(buffer, 4, 5) + const result = withBinaryPayloadPlaceholder({ payload: view }) + + expect(result).toEqual({ payload: '' }) + }) +}) diff --git a/apps/studio/components/interfaces/Realtime/Inspector/MessagesFormatters.tsx b/apps/studio/components/interfaces/Realtime/Inspector/MessagesFormatters.tsx index 332a46bd75902..f3793ca0aa9d2 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/MessagesFormatters.tsx +++ b/apps/studio/components/interfaces/Realtime/Inspector/MessagesFormatters.tsx @@ -49,6 +49,44 @@ export const SelectionDetailedRow = ({ ) } +export function isBinaryPayload(value: unknown): value is ArrayBuffer | ArrayBufferView { + return value instanceof ArrayBuffer || ArrayBuffer.isView(value) +} + +export function withBinaryPayloadPlaceholder(metadata: T): T { + const record = metadata as Record | null | undefined + const payload = record?.payload + if (!isBinaryPayload(payload)) return metadata + return { + ...(record as Record), + payload: ``, + } as T +} + +export function formatHexdump(buffer: ArrayBuffer | ArrayBufferView): string { + const bytes = + buffer instanceof ArrayBuffer + ? new Uint8Array(buffer) + : new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength) + + const GROUP_WIDTH = 8 * 3 - 1 // 8 bytes * "xx " minus trailing space + const lines: string[] = [] + + for (let offset = 0; offset < bytes.length; offset += 16) { + const chunk = bytes.subarray(offset, offset + 16) + const hex = Array.from(chunk, (b) => b.toString(16).padStart(2, '0')) + const first = hex.slice(0, 8).join(' ').padEnd(GROUP_WIDTH, ' ') + const second = hex.slice(8, 16).join(' ').padEnd(GROUP_WIDTH, ' ') + const ascii = Array.from(chunk, (b) => + b >= 0x20 && b <= 0x7e ? String.fromCharCode(b) : '.' + ).join('') + const offsetStr = offset.toString(16).padStart(8, '0') + lines.push(`${offsetStr} ${first} ${second} |${ascii}|`) + } + + return lines.join('\n') +} + /* * JSON Syntax Highlighter * diff --git a/apps/studio/components/interfaces/Realtime/Inspector/RealtimeMessageColumnRenderer.tsx b/apps/studio/components/interfaces/Realtime/Inspector/RealtimeMessageColumnRenderer.tsx index 19800508ee0f5..bdd35356033dd 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/RealtimeMessageColumnRenderer.tsx +++ b/apps/studio/components/interfaces/Realtime/Inspector/RealtimeMessageColumnRenderer.tsx @@ -3,7 +3,7 @@ import { Column } from 'react-data-grid' import { cn, IconBroadcast, IconDatabaseChanges, IconPresence } from 'ui' import type { LogData, PreviewLogData } from './Messages.types' -import { RowLayout } from './MessagesFormatters' +import { RowLayout, withBinaryPayloadPlaceholder } from './MessagesFormatters' import { isErrorLog } from './MessagesTable' const ICONS = { @@ -35,7 +35,7 @@ export const ColumnRenderer: Column[] = [ {new Date(data.row.timestamp).toISOString()} - {JSON.stringify(data.row.metadata)} + {JSON.stringify(withBinaryPayloadPlaceholder(data.row.metadata))} ) diff --git a/apps/studio/components/interfaces/Realtime/Inspector/SelectedRealtimeMessagePanel.tsx b/apps/studio/components/interfaces/Realtime/Inspector/SelectedRealtimeMessagePanel.tsx index c215e953356d2..f4e752116f764 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/SelectedRealtimeMessagePanel.tsx +++ b/apps/studio/components/interfaces/Realtime/Inspector/SelectedRealtimeMessagePanel.tsx @@ -1,5 +1,11 @@ import type { LogData } from './Messages.types' -import { jsonSyntaxHighlight, SelectionDetailedTimestampRow } from './MessagesFormatters' +import { + formatHexdump, + isBinaryPayload, + jsonSyntaxHighlight, + SelectionDetailedTimestampRow, + withBinaryPayloadPlaceholder, +} from './MessagesFormatters' const LogsDivider = () => { return ( @@ -8,6 +14,10 @@ const LogsDivider = () => { } export const SelectedRealtimeMessagePanel = ({ log }: { log: LogData }) => { + const payload = log.metadata?.payload + const binary = isBinaryPayload(payload) + const envelope = withBinaryPayloadPlaceholder(log.metadata) + return ( <>
@@ -22,16 +32,26 @@ export const SelectedRealtimeMessagePanel = ({ log }: { log: LogData }) => {
-
-

Payload

-
-          
-
+
+
+

Payload

+
+            
+
+
+ {binary && ( +
+

Binary payload

+
+              {formatHexdump(payload as ArrayBuffer | ArrayBufferView)}
+            
+
+ )}
)