215 lines
7.2 KiB
TypeScript
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 },
|
|
);
|
|
}
|