release: WRNexusJS 0.8.0
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-02 23:18:51 +05:30
parent 87507edf59
commit 586a6db8ff
625 changed files with 243608 additions and 11210 deletions
+57
View File
@@ -0,0 +1,57 @@
import { assertRealtimeRoomName, type RealtimeMessage } from "./messages.ts";
export interface BrowserRoomConnection {
readonly name: string;
send(message: unknown): BrowserRoomConnection;
on(
type: string | ((message: unknown) => void),
callback?: (message: unknown) => void,
): BrowserRoomConnection;
close(): void;
}
export interface WrnexusRealtimeWindow extends Window {
wire?: {
room?: (name: string, query?: string) => BrowserRoomConnection;
};
}
const QUERY_KEY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
export function roomQuery(
params: Record<string, string | number | boolean | null | undefined>,
): string {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (!QUERY_KEY.test(key)) throw new TypeError(`Invalid realtime query key: ${key}`);
if (value !== undefined && value !== null) query.set(key, String(value));
}
const result = query.toString();
if (result.length > 2_048) throw new RangeError("Realtime room query exceeds 2048 characters.");
return result;
}
export function connectRoom(
name: string,
options: {
query?: Record<string, string | number | boolean | null | undefined>;
window?: WrnexusRealtimeWindow;
} = {},
): BrowserRoomConnection {
const roomName = assertRealtimeRoomName(name);
const target = options.window ?? (globalThis as unknown as WrnexusRealtimeWindow);
const factory = target.wire?.room;
if (!factory) {
throw new Error(
"WRN-REALTIME-CLIENT-NOT-READY: add a RealtimeRoom/data-room component before calling connectRoom().",
);
}
return factory(roomName, options.query ? roomQuery(options.query) : undefined);
}
export function sendRoomMessage<T>(
room: BrowserRoomConnection,
message: RealtimeMessage<T> | T,
): BrowserRoomConnection {
return room.send(message);
}
+160
View File
@@ -0,0 +1,160 @@
import { assertRealtimeRoomName, createRealtimeMessage, type RealtimeMessage } from "./messages.ts";
export interface SequencedRealtimeMessage<T = unknown> {
sequence: number;
message: RealtimeMessage<T>;
}
export interface RealtimeHistorySnapshot {
rooms: number;
messages: number;
acknowledgements: number;
oldestSequence?: number;
latestSequence?: number;
}
export interface RealtimeHistoryOptions {
limitPerRoom?: number;
maxClients?: number;
}
export interface RealtimeHistory {
publish<T>(room: string, message: RealtimeMessage<T>): SequencedRealtimeMessage<T>;
replay(room: string, afterSequence?: number, limit?: number): SequencedRealtimeMessage[];
acknowledge(room: string, clientId: string, sequence: number): void;
acknowledged(room: string, clientId: string): number;
resume(room: string, clientId: string, limit?: number): SequencedRealtimeMessage[];
snapshot(): RealtimeHistorySnapshot;
clear(room?: string): void;
}
export function createRealtimeHistory(options: RealtimeHistoryOptions = {}): RealtimeHistory {
const limitPerRoom = options.limitPerRoom ?? 100;
const maxClients = options.maxClients ?? 10_000;
if (!Number.isInteger(limitPerRoom) || limitPerRoom < 1)
throw new RangeError("realtime history limitPerRoom must be positive");
if (!Number.isInteger(maxClients) || maxClients < 1)
throw new RangeError("realtime history maxClients must be positive");
const rooms = new Map<string, SequencedRealtimeMessage[]>();
const acknowledgements = new Map<string, number>();
let sequence = 0;
const publishMonitor = () => {
const values = [...rooms.values()].flat();
(
globalThis as typeof globalThis & { __wrnexusRealtimeMonitor?: RealtimeHistorySnapshot }
).__wrnexusRealtimeMonitor = {
rooms: rooms.size,
messages: values.length,
acknowledgements: acknowledgements.size,
oldestSequence: values.length
? Math.min(...values.map((entry) => entry.sequence))
: undefined,
latestSequence: values.length
? Math.max(...values.map((entry) => entry.sequence))
: undefined,
};
};
const ackKey = (room: string, clientId: string) => `${room}\0${clientId}`;
return {
publish(roomName, message) {
const room = assertRealtimeRoomName(roomName);
if (message.room && message.room !== room)
throw new Error("WRN-REALTIME-HISTORY-ROOM: message room mismatch.");
const entry = { sequence: ++sequence, message: structuredClone(message) };
const values = rooms.get(room) ?? [];
values.push(entry);
if (values.length > limitPerRoom) values.splice(0, values.length - limitPerRoom);
rooms.set(room, values);
publishMonitor();
return structuredClone(entry);
},
replay(roomName, afterSequence = 0, limit = limitPerRoom) {
const room = assertRealtimeRoomName(roomName);
if (!Number.isInteger(afterSequence) || afterSequence < 0)
throw new RangeError("realtime replay sequence must be non-negative");
if (!Number.isInteger(limit) || limit < 1 || limit > limitPerRoom)
throw new RangeError(`realtime replay limit must be between 1 and ${limitPerRoom}`);
return (rooms.get(room) ?? [])
.filter((entry) => entry.sequence > afterSequence)
.slice(0, limit)
.map((entry) => structuredClone(entry));
},
acknowledge(roomName, clientId, value) {
const room = assertRealtimeRoomName(roomName);
if (!clientId.trim() || clientId.length > 256)
throw new TypeError("invalid realtime client id");
if (!Number.isInteger(value) || value < 0 || value > sequence)
throw new RangeError("invalid realtime acknowledgement sequence");
const key = ackKey(room, clientId);
if (!acknowledgements.has(key) && acknowledgements.size >= maxClients)
throw new Error("WRN-REALTIME-ACK-CAPACITY");
acknowledgements.set(key, Math.max(acknowledgements.get(key) ?? 0, value));
publishMonitor();
},
acknowledged(roomName, clientId) {
return acknowledgements.get(ackKey(assertRealtimeRoomName(roomName), clientId)) ?? 0;
},
resume(roomName, clientId, limit) {
const room = assertRealtimeRoomName(roomName);
return this.replay(room, this.acknowledged(room, clientId), limit);
},
snapshot() {
const values = [...rooms.values()].flat();
return {
rooms: rooms.size,
messages: values.length,
acknowledgements: acknowledgements.size,
oldestSequence: values.length
? Math.min(...values.map((entry) => entry.sequence))
: undefined,
latestSequence: values.length
? Math.max(...values.map((entry) => entry.sequence))
: undefined,
};
},
clear(roomName) {
if (!roomName) {
rooms.clear();
acknowledgements.clear();
publishMonitor();
return;
}
const room = assertRealtimeRoomName(roomName);
rooms.delete(room);
for (const key of acknowledgements.keys())
if (key.startsWith(`${room}\0`)) acknowledgements.delete(key);
publishMonitor();
},
};
}
export function createAcknowledgement(
room: string,
sequence: number,
clientId: string,
): RealtimeMessage<{ sequence: number; clientId: string }> {
return createRealtimeMessage({ type: "ack", room, data: { sequence, clientId } });
}
export function realtimeSseResponse(
stream: ReadableStream<SequencedRealtimeMessage>,
signal?: AbortSignal,
): Response {
const encoder = new TextEncoder();
const output = new TransformStream<SequencedRealtimeMessage, Uint8Array>({
transform(entry, controller) {
controller.enqueue(
encoder.encode(
`id: ${entry.sequence}\nevent: ${entry.message.type}\ndata: ${JSON.stringify(entry.message)}\n\n`,
),
);
},
});
signal?.addEventListener("abort", () => void output.writable.abort(signal.reason), {
once: true,
});
return new Response(stream.pipeThrough(output), {
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache, no-transform",
connection: "keep-alive",
},
});
}
+30
View File
@@ -0,0 +1,30 @@
export {
defineRoom,
isRoomDefinition,
createRealtimeRegistry,
bridgeRealtime,
} from "@wrnexus/core";
export type {
RealtimeBus,
RealtimeSocket,
RealtimeHandler,
RawSocket,
Room,
RoomClient,
RoomHandlers,
RoomAuthInfo,
RoomDefinition,
Target,
RealtimeRegistry,
RealtimeConnectMeta,
RealtimeBridge,
RealtimeEnvelope,
RealtimeSecurityOptions,
RealtimeRegistryOptions,
} from "@wrnexus/core";
export * from "./messages.ts";
export * from "./client.ts";
export * from "./plugin.ts";
export * from "./history.ts";
export * from "./streams.ts";
+214
View File
@@ -0,0 +1,214 @@
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 },
);
}
+25
View File
@@ -0,0 +1,25 @@
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { definePlugin } from "@wrnexus/plugin";
export interface RealtimePluginOptions {
components?: boolean;
componentDir?: string;
}
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
export function realtimeComponentsDir(): string {
return join(packageRoot, "components");
}
export function realtimePlugin(options: RealtimePluginOptions = {}) {
return definePlugin({
name: "@wrnexus/realtime",
version: "0.8.0",
componentDirs:
options.components === false ? [] : [options.componentDir ?? realtimeComponentsDir()],
});
}
export default realtimePlugin;
+98
View File
@@ -0,0 +1,98 @@
export interface DatabaseChange<T = unknown> {
table: string;
operation: "insert" | "update" | "delete";
key?: string | number;
record?: T;
occurredAt: number;
}
export interface DatabaseChangeSource {
subscribe(handler: (change: DatabaseChange) => void | Promise<void>): () => void;
}
export function databaseChangeFeed(
source: DatabaseChangeSource,
publish: (topic: string, change: DatabaseChange) => void | Promise<void>,
options: { prefix?: string; allowTables?: string[] } = {},
): () => void {
const prefix = options.prefix ?? "db";
const allowed = options.allowTables ? new Set(options.allowTables) : null;
return source.subscribe(async (change) => {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(change.table)) return;
if (allowed && !allowed.has(change.table)) return;
await publish(`${prefix}:${change.table}`, structuredClone(change));
});
}
export interface FileStreamFrame {
streamId: string;
index: number;
total: number;
bytes: Uint8Array;
}
export function frameFileStream(
streamId: string,
bytes: Uint8Array,
options: { chunkBytes?: number; maxBytes?: number } = {},
): FileStreamFrame[] {
if (!/^[A-Za-z0-9_-]{1,128}$/.test(streamId)) throw new Error("Invalid stream id");
const chunkBytes = options.chunkBytes ?? 64 * 1024;
const maxBytes = options.maxBytes ?? 25 * 1024 * 1024;
if (!Number.isInteger(chunkBytes) || chunkBytes < 1024 || chunkBytes > 1024 * 1024)
throw new RangeError("chunkBytes must be between 1KiB and 1MiB");
if (bytes.byteLength > maxBytes) throw new Error("WRN-REALTIME-FILE-LIMIT");
const total = Math.max(1, Math.ceil(bytes.byteLength / chunkBytes));
return Array.from({ length: total }, (_, index) => ({
streamId,
index,
total,
bytes: bytes.slice(index * chunkBytes, (index + 1) * chunkBytes),
}));
}
export function createFileStreamReceiver(options: { maxBytes?: number; maxStreams?: number } = {}) {
const maxBytes = options.maxBytes ?? 25 * 1024 * 1024;
const maxStreams = options.maxStreams ?? 32;
const streams = new Map<string, Map<number, Uint8Array>>();
return {
accept(frame: FileStreamFrame): Uint8Array | null {
if (
!Number.isInteger(frame.total) ||
frame.total < 1 ||
frame.total > 25_600 ||
frame.index < 0 ||
frame.index >= frame.total
)
throw new Error("Invalid file stream frame");
let parts = streams.get(frame.streamId);
if (!parts) {
if (streams.size >= maxStreams) throw new Error("WRN-REALTIME-STREAM-CAPACITY");
streams.set(frame.streamId, (parts = new Map()));
}
parts.set(frame.index, frame.bytes.slice());
const size = [...parts.values()].reduce((sum, part) => sum + part.byteLength, 0);
if (size > maxBytes) {
streams.delete(frame.streamId);
throw new Error("WRN-REALTIME-FILE-LIMIT");
}
if (parts.size !== frame.total) return null;
const output = new Uint8Array(size);
let offset = 0;
for (let index = 0; index < frame.total; index++) {
const part = parts.get(index);
if (!part) return null;
output.set(part, offset);
offset += part.byteLength;
}
streams.delete(frame.streamId);
return output;
},
snapshot: () => ({
activeStreams: streams.size,
bufferedBytes: [...streams.values()]
.flatMap((parts) => [...parts.values()])
.reduce((sum, part) => sum + part.byteLength, 0),
}),
};
}