export type RealtimeMessageType = string; export interface RealtimeMessage { id: string; type: RealtimeMessageType; room?: string; senderId?: string; senderName?: string; sentAt: string; data: T; meta?: Record; } export interface CreateRealtimeMessageOptions { id?: string; type: string; room?: string; senderId?: string; senderName?: string; sentAt?: string | Date; data: T; meta?: Record; } export interface ParseRealtimeMessageOptions { maxBytes?: number; maxDepth?: number; allowedTypes?: readonly string[]; room?: string; } export interface RealtimePresence { userId: string; name?: string; avatar?: string; status?: "online" | "away" | "busy" | "offline"; joinedAt?: string; lastSeenAt?: string; meta?: Record; } export interface RealtimeRoomMeta { id: string; name: string; description?: string; memberCount?: number; onlineCount?: number; createdAt?: string; updatedAt?: string; private?: boolean; tags?: string[]; } const MESSAGE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$/; const MESSAGE_TYPE = /^[A-Za-z][A-Za-z0-9._:-]{0,63}$/; const ROOM_NAME = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/; const UNSAFE_KEYS = new Set(["__proto__", "prototype", "constructor"]); function randomMessageId(): string { const webCrypto = globalThis.crypto; if (!webCrypto) { throw new Error( "WRN-REALTIME-CRYPTO-UNAVAILABLE: provide an explicit message id in this runtime.", ); } if (typeof webCrypto.randomUUID === "function") return webCrypto.randomUUID(); const bytes = new Uint8Array(16); webCrypto.getRandomValues(bytes); return [...bytes].map((value) => value.toString(16).padStart(2, "0")).join(""); } function assertString(value: string | undefined, name: string, maxLength: number): void { if (value !== undefined && (value.length > maxLength || value.includes("\0"))) { throw new TypeError(`Invalid realtime ${name}.`); } } function assertSafeValue( value: unknown, maxDepth: number, depth = 0, seen = new WeakSet(), ): void { if (depth > maxDepth) throw new TypeError("Realtime payload exceeds the maximum nesting depth."); if (value === null || ["string", "number", "boolean", "undefined"].includes(typeof value)) return; if (typeof value !== "object") throw new TypeError("Realtime payload contains an unsupported value."); if (seen.has(value)) throw new TypeError("Realtime payload contains a circular reference."); seen.add(value); if (Array.isArray(value)) { if (value.length > 10_000) throw new TypeError("Realtime payload array is too large."); for (const child of value) assertSafeValue(child, maxDepth, depth + 1, seen); return; } for (const [key, child] of Object.entries(value)) { if (UNSAFE_KEYS.has(key)) throw new TypeError(`Unsafe realtime payload key: ${key}`); assertSafeValue(child, maxDepth, depth + 1, seen); } } export function assertRealtimeRoomName(name: string): string { const value = name.trim(); if (!ROOM_NAME.test(value)) throw new TypeError(`Invalid realtime room name: ${name}`); return value; } export function createRealtimeMessage( options: CreateRealtimeMessageOptions, ): RealtimeMessage { const type = options.type.trim(); if (!MESSAGE_TYPE.test(type)) throw new TypeError("Invalid realtime message type."); const id = options.id ?? randomMessageId(); if (!MESSAGE_ID.test(id)) throw new TypeError("Invalid realtime message id."); const room = options.room === undefined ? undefined : assertRealtimeRoomName(options.room); assertString(options.senderId, "sender id", 256); assertString(options.senderName, "sender name", 256); assertSafeValue(options.data, 16); if (options.meta) assertSafeValue(options.meta, 4); const sentAt = options.sentAt instanceof Date ? options.sentAt.toISOString() : (options.sentAt ?? new Date().toISOString()); if (!Number.isFinite(Date.parse(sentAt))) throw new TypeError("Invalid realtime sentAt value."); return { id, type, ...(room ? { room } : {}), ...(options.senderId ? { senderId: options.senderId } : {}), ...(options.senderName ? { senderName: options.senderName } : {}), sentAt, data: options.data, ...(options.meta ? { meta: { ...options.meta } } : {}), }; } export function isRealtimeMessage(value: unknown): value is RealtimeMessage { if (!value || typeof value !== "object") return false; const message = value as Partial; return ( typeof message.id === "string" && MESSAGE_ID.test(message.id) && typeof message.type === "string" && MESSAGE_TYPE.test(message.type) && typeof message.sentAt === "string" && Number.isFinite(Date.parse(message.sentAt)) && (message.room === undefined || (typeof message.room === "string" && ROOM_NAME.test(message.room))) && "data" in message ); } export function parseRealtimeMessage( value: string | unknown, options: ParseRealtimeMessageOptions = {}, ): RealtimeMessage { const maxBytes = options.maxBytes ?? 65_536; if (!Number.isFinite(maxBytes) || maxBytes < 1) { throw new TypeError("Realtime maxBytes must be a positive finite number."); } if (typeof value === "string" && new TextEncoder().encode(value).byteLength > maxBytes) { throw new RangeError("Realtime message exceeds the maximum size."); } const parsed = typeof value === "string" ? JSON.parse(value) : value; if (!isRealtimeMessage(parsed)) throw new TypeError("Invalid realtime message envelope."); if (options.allowedTypes && !options.allowedTypes.includes(parsed.type)) { throw new TypeError(`Realtime message type is not allowed: ${parsed.type}`); } if (options.room && parsed.room !== assertRealtimeRoomName(options.room)) { throw new TypeError("Realtime message room does not match the active room."); } assertSafeValue(parsed.data, Math.max(1, options.maxDepth ?? 16)); if (parsed.meta) assertSafeValue(parsed.meta, 4); return parsed as RealtimeMessage; } export function createPresenceEvent( action: "join" | "leave" | "update", presence: RealtimePresence, room?: string, ): RealtimeMessage<{ action: "join" | "leave" | "update"; presence: RealtimePresence }> { return createRealtimeMessage({ type: "presence", room, data: { action, presence } }); } export function createTypingEvent( userId: string, typing: boolean, options: { room?: string; name?: string } = {}, ): RealtimeMessage<{ userId: string; name?: string; typing: boolean }> { return createRealtimeMessage({ type: "typing", room: options.room, senderId: userId, senderName: options.name, data: { userId, ...(options.name ? { name: options.name } : {}), typing }, }); } export function roomMemberSummary(members: readonly RealtimePresence[]): { total: number; online: number; away: number; busy: number; } { return members.reduce( (summary, member) => { summary.total += 1; if (member.status === "away") summary.away += 1; else if (member.status === "busy") summary.busy += 1; else if (member.status !== "offline") summary.online += 1; return summary; }, { total: 0, online: 0, away: 0, busy: 0 }, ); }