Files
WRNexusJS/packages/realtime/src/messages.ts
T
Clintchiz 586a6db8ff
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s
release: WRNexusJS 0.8.0
2026-08-02 23:18:51 +05:30

215 lines
7.2 KiB
TypeScript

export type RealtimeMessageType = string;
export interface RealtimeMessage<T = unknown> {
id: string;
type: RealtimeMessageType;
room?: string;
senderId?: string;
senderName?: string;
sentAt: string;
data: T;
meta?: Record<string, string | number | boolean | null>;
}
export interface CreateRealtimeMessageOptions<T> {
id?: string;
type: string;
room?: string;
senderId?: string;
senderName?: string;
sentAt?: string | Date;
data: T;
meta?: Record<string, string | number | boolean | null>;
}
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<string, string | number | boolean | null>;
}
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<object>(),
): 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<T>(
options: CreateRealtimeMessageOptions<T>,
): RealtimeMessage<T> {
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<RealtimeMessage>;
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<T = unknown>(
value: string | unknown,
options: ParseRealtimeMessageOptions = {},
): RealtimeMessage<T> {
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<T>;
}
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 },
);
}