release: WRNexusJS 0.8.0
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
# @wrnexus/realtime
|
||||
|
||||
Typed rooms, secure message envelopes, browser room helpers, presence utilities, and complete realtime UI blocks for WRNexusJS.
|
||||
|
||||
## Server rooms
|
||||
|
||||
```ts
|
||||
import { defineRoom } from "@wrnexus/realtime";
|
||||
|
||||
export default defineRoom("support", {
|
||||
async authorize(context) {
|
||||
return Boolean(context.user);
|
||||
},
|
||||
message(client, message) {
|
||||
client.broadcast(message);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The package re-exports the hardened realtime registry from `@wrnexus/core`, including authentication, origin checks, quotas, message-size limits, schema validation hooks, and room authorization.
|
||||
|
||||
## Messages and browser helpers
|
||||
|
||||
```ts
|
||||
import {
|
||||
createRealtimeMessage,
|
||||
createPresenceEvent,
|
||||
createTypingEvent,
|
||||
connectRoom,
|
||||
sendRoomMessage,
|
||||
} from "@wrnexus/realtime";
|
||||
|
||||
const room = connectRoom("support", { query: { ticket: "T-100" } });
|
||||
sendRoomMessage(
|
||||
room,
|
||||
createRealtimeMessage({
|
||||
type: "message",
|
||||
room: "support",
|
||||
data: { text: "Hello" },
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
Message IDs use Web Crypto. A runtime without secure randomness must provide an explicit message ID.
|
||||
|
||||
## Components
|
||||
|
||||
Enable `realtimePlugin()` and use:
|
||||
|
||||
- `<RealtimeRoom />`
|
||||
- `<RealtimeMessageBubble />`
|
||||
- `<MessageComposer />`
|
||||
- `<RoomStatus />`
|
||||
- `<RoomMeta />`
|
||||
- `<PresenceList />`
|
||||
- `<TypingIndicator />`
|
||||
|
||||
These package-owned blocks compose existing `@wrnexus/ui` components such as `Card`, `Alert`, `Avatar`, `Badge`, `Button`, `Input`, and `ChatBubble`.
|
||||
|
||||
Incoming messages can be bounded and constrained:
|
||||
|
||||
```ts
|
||||
const message = parseRealtimeMessage(rawMessage, {
|
||||
maxBytes: 64 * 1024,
|
||||
maxDepth: 12,
|
||||
allowedTypes: ["message", "typing", "presence"],
|
||||
room: "support",
|
||||
});
|
||||
```
|
||||
|
||||
The parser rejects oversized payloads, circular/unsupported values, unsafe object keys, invalid message types, invalid room names, and room mismatches.
|
||||
|
||||
## Replay, acknowledgements, SSE, and monitoring
|
||||
|
||||
`createRealtimeHistory()` keeps a bounded sequenced log per room. Clients acknowledge a
|
||||
sequence and `resume(room, clientId)` returns only missed events. A snapshot exposes room,
|
||||
message, acknowledgement, and sequence counts for monitoring without exposing payloads.
|
||||
|
||||
```ts
|
||||
const history = createRealtimeHistory({ limitPerRoom: 100 });
|
||||
const entry = history.publish("support", message);
|
||||
history.acknowledge("support", clientId, entry.sequence);
|
||||
const missed = history.resume("support", clientId);
|
||||
```
|
||||
|
||||
`realtimeSseResponse(stream, signal)` converts the same sequenced envelope into standards-based
|
||||
Server-Sent Events with event IDs, event types, JSON data, cancellation, and no-cache headers.
|
||||
@@ -0,0 +1,27 @@
|
||||
component RealtimeMessageBubble {
|
||||
props {
|
||||
id: string = ""
|
||||
text: string = ""
|
||||
author: string = ""
|
||||
avatarSrc: string = ""
|
||||
avatarFallback: string = ""
|
||||
timestamp: string = ""
|
||||
status: string = ""
|
||||
direction: string = "incoming"
|
||||
color: string = "primary"
|
||||
size: string = "default"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
view {
|
||||
<ChatBubble
|
||||
{...attrs}
|
||||
items='{[{ id: id, text: text, author: author, avatarSrc: avatarSrc, avatarFallback: avatarFallback, timestamp: timestamp, status: status, direction: direction }]}'
|
||||
showAvatars='{avatarSrc != "" || avatarFallback != ""}'
|
||||
showMetadata='{timestamp != "" || status != ""}'
|
||||
color='{color}'
|
||||
size='{size}'
|
||||
class='{class}'
|
||||
/>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
component MessageComposer {
|
||||
props {
|
||||
name: string = "text"
|
||||
type: string = "message"
|
||||
placeholder: string = "Write a message…"
|
||||
sendLabel: string = "Send"
|
||||
disabled: boolean = false
|
||||
color: string = "primary"
|
||||
size: string = "md"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
view {
|
||||
<form data-room-send class='flex items-end gap-2 {class}'>
|
||||
<input type="hidden" name="type" value='{type}' />
|
||||
<Textarea
|
||||
name='{name}'
|
||||
placeholder='{placeholder}'
|
||||
rows="2"
|
||||
disabled='{disabled}'
|
||||
color='{color}'
|
||||
size='{size}'
|
||||
data-room-reset
|
||||
class="min-w-0 flex-1"
|
||||
/>
|
||||
<Button type="submit" label='{sendLabel}' icon="icon-[lucide--send]" color='{color}' size='{size}' disabled='{disabled}' />
|
||||
</form>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
component PresenceList {
|
||||
props {
|
||||
members: unknown[] = []
|
||||
title: string = "People"
|
||||
emptyLabel: string = "No one is online."
|
||||
color: string = "primary"
|
||||
size: string = "md"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
view {
|
||||
<Card title='{title}' color='{color}' size='{size}' empty='{members.length == 0}' emptyTitle='{emptyLabel}' class='{class}'>
|
||||
<div class="space-y-3">
|
||||
{#each members as member}
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar src='{member.avatar || ""}' fallback='{member.name || member.userId || "?"}' size="sm" color='{color}' />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="m-0 truncate text-sm font-semibold">{member.name || member.userId}</p>
|
||||
<p class="m-0 text-xs text-[var(--wire-color-muted)]">{member.status || "online"}</p>
|
||||
</div>
|
||||
<Badge label='{member.status || "online"}' color='{member.status == "busy" ? "danger" : member.status == "away" ? "warning" : "success"}' size="sm" variant="soft" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
component RealtimeRoom {
|
||||
props {
|
||||
room: string = ""
|
||||
user: string = ""
|
||||
title: string = "Conversation"
|
||||
description: string = ""
|
||||
statusLabel: string = "Connecting…"
|
||||
emptyLabel: string = "No messages yet."
|
||||
color: string = "primary"
|
||||
size: string = "md"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
view {
|
||||
<Card
|
||||
{...attrs}
|
||||
title='{title}'
|
||||
description='{description}'
|
||||
color='{color}'
|
||||
size='{size}'
|
||||
class='{class}'
|
||||
>
|
||||
<section data-room='{room}' data-room-user='{user}' class="flex min-h-72 flex-col gap-4">
|
||||
<RoomStatus label='{statusLabel}' color='{color}' size='{size}' />
|
||||
<div data-room-log role="log" aria-live="polite" class="flex min-h-40 flex-1 flex-col gap-3 overflow-y-auto rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-surface-2)] p-3">
|
||||
<p data-room-empty class="m-auto text-sm text-[var(--wire-color-muted)]">{emptyLabel}</p>
|
||||
<slot></slot>
|
||||
</div>
|
||||
<slot name="composer"></slot>
|
||||
</section>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
component RoomMeta {
|
||||
props {
|
||||
name: string = "Room"
|
||||
description: string = ""
|
||||
memberCount: number = 0
|
||||
onlineCount: number = 0
|
||||
private: boolean = false
|
||||
color: string = "primary"
|
||||
size: string = "md"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
view {
|
||||
<Card title='{name}' description='{description}' color='{color}' size='{size}' class='{class}'>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Badge label='{onlineCount + " online"}' color="success" variant="soft" size="sm" />
|
||||
<Badge label='{memberCount + " members"}' color='{color}' variant="soft" size="sm" />
|
||||
{#if private}<Badge label="Private" icon="icon-[lucide--lock]" color="warning" variant="soft" size="sm" />{/if}
|
||||
</div>
|
||||
<slot></slot>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
component RoomStatus {
|
||||
props {
|
||||
label: string = "Connecting…"
|
||||
connectedLabel: string = "Connected"
|
||||
disconnectedLabel: string = "Disconnected"
|
||||
errorLabel: string = "Connection error"
|
||||
color: string = "primary"
|
||||
size: string = "sm"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
view {
|
||||
<div class='flex items-center justify-between gap-3 {class}'>
|
||||
<Badge label="Live" color='{color}' size='{size}' variant="soft" />
|
||||
<span
|
||||
data-room-status
|
||||
data-room-status-class="inline-flex items-center gap-2 text-xs font-medium text-[var(--wire-color-muted)]"
|
||||
class="inline-flex items-center gap-2 text-xs font-medium text-[var(--wire-color-muted)]"
|
||||
aria-live="polite"
|
||||
>{label}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
component TypingIndicator {
|
||||
props {
|
||||
names: unknown[] = []
|
||||
label: string = "Typing…"
|
||||
color: string = "primary"
|
||||
size: string = "sm"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
view {
|
||||
{#if names.length > 0}
|
||||
<Alert
|
||||
title='{names.length == 1 ? names[0] + " is typing" : names.length + " people are typing"}'
|
||||
description='{label}'
|
||||
icon="icon-[lucide--message-circle-more]"
|
||||
color='{color}'
|
||||
size='{size}'
|
||||
variant="soft"
|
||||
class='{class}'
|
||||
/>
|
||||
{/if}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@wrnexus/realtime",
|
||||
"version": "0.8.0",
|
||||
"description": "Typed realtime rooms, message helpers, presence utilities, package UI blocks, and WRNexusJS integration.",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"files": [
|
||||
"src",
|
||||
"components",
|
||||
"README.md"
|
||||
],
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./plugin": "./src/plugin.ts",
|
||||
"./components/*": "./components/*"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check": "bun run typecheck && bun run test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
"@wrnexus/plugin": "workspace:*",
|
||||
"@wrnexus/ui": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"@wrnexus/syntax": "workspace:*",
|
||||
"typescript": "^5.9.2"
|
||||
},
|
||||
"wrnexus": {
|
||||
"plugin": {
|
||||
"plugin": "./src/plugin.ts",
|
||||
"export": "default",
|
||||
"factory": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
createAcknowledgement,
|
||||
createRealtimeHistory,
|
||||
createRealtimeMessage,
|
||||
realtimeSseResponse,
|
||||
} from "../src/index.ts";
|
||||
|
||||
describe("realtime replay and acknowledgements", () => {
|
||||
test("bounds room history and resumes after a client acknowledgement", () => {
|
||||
const history = createRealtimeHistory({ limitPerRoom: 2 });
|
||||
const entries = [1, 2, 3].map((value) =>
|
||||
history.publish(
|
||||
"chat",
|
||||
createRealtimeMessage({ type: "message", room: "chat", data: { value } }),
|
||||
),
|
||||
);
|
||||
expect(history.replay("chat").map((entry) => entry.message.data)).toEqual([
|
||||
{ value: 2 },
|
||||
{ value: 3 },
|
||||
]);
|
||||
history.acknowledge("chat", "client-1", entries[1]!.sequence);
|
||||
expect(history.resume("chat", "client-1")).toHaveLength(1);
|
||||
expect(history.snapshot()).toMatchObject({ rooms: 1, messages: 2, acknowledgements: 1 });
|
||||
});
|
||||
test("acknowledgements are monotonic and reject impossible values", () => {
|
||||
const history = createRealtimeHistory();
|
||||
const entry = history.publish("chat", createRealtimeMessage({ type: "message", data: {} }));
|
||||
history.acknowledge("chat", "client", entry.sequence);
|
||||
history.acknowledge("chat", "client", 0);
|
||||
expect(history.acknowledged("chat", "client")).toBe(entry.sequence);
|
||||
expect(() => history.acknowledge("chat", "client", entry.sequence + 1)).toThrow();
|
||||
expect(createAcknowledgement("chat", entry.sequence, "client").type).toBe("ack");
|
||||
});
|
||||
test("encodes sequenced events as SSE", async () => {
|
||||
const message = createRealtimeMessage({ type: "notice", room: "chat", data: { ok: true } });
|
||||
const response = realtimeSseResponse(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue({ sequence: 7, message });
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(response.headers.get("content-type")).toContain("text/event-stream");
|
||||
expect(await response.text()).toContain("id: 7\nevent: notice\ndata:");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
createPresenceEvent,
|
||||
createRealtimeMessage,
|
||||
createTypingEvent,
|
||||
parseRealtimeMessage,
|
||||
roomMemberSummary,
|
||||
roomQuery,
|
||||
} from "../src/index.ts";
|
||||
|
||||
test("creates and parses typed realtime messages", () => {
|
||||
const message = createRealtimeMessage({
|
||||
id: "message-1",
|
||||
type: "message",
|
||||
room: "support/general",
|
||||
data: { text: "Hi" },
|
||||
sentAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
expect(parseRealtimeMessage(JSON.stringify(message), { room: "support/general" })).toEqual(
|
||||
message,
|
||||
);
|
||||
});
|
||||
|
||||
test("builds presence and typing events", () => {
|
||||
expect(createPresenceEvent("join", { userId: "u1" }).type).toBe("presence");
|
||||
expect(createTypingEvent("u1", true).data.typing).toBe(true);
|
||||
});
|
||||
|
||||
test("summarizes room members and query parameters", () => {
|
||||
expect(
|
||||
roomMemberSummary([
|
||||
{ userId: "1", status: "online" },
|
||||
{ userId: "2", status: "away" },
|
||||
]),
|
||||
).toEqual({ total: 2, online: 1, away: 1, busy: 0 });
|
||||
expect(roomQuery({ user: "u1", page: 2, active: true })).toBe("user=u1&page=2&active=true");
|
||||
});
|
||||
|
||||
test("rejects oversized, unsafe, mismatched, and unknown messages", () => {
|
||||
const message = createRealtimeMessage({
|
||||
id: "message-2",
|
||||
type: "message",
|
||||
room: "support",
|
||||
data: { text: "Hi" },
|
||||
});
|
||||
expect(() =>
|
||||
parseRealtimeMessage(JSON.stringify(message), { allowedTypes: ["presence"] }),
|
||||
).toThrow("not allowed");
|
||||
expect(() => parseRealtimeMessage(JSON.stringify(message), { room: "other" })).toThrow(
|
||||
"does not match",
|
||||
);
|
||||
expect(() => parseRealtimeMessage(JSON.stringify(message), { maxBytes: 16 })).toThrow(
|
||||
"maximum size",
|
||||
);
|
||||
expect(() => roomQuery({ "bad key": "x" })).toThrow("query key");
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createFileStreamReceiver, databaseChangeFeed, frameFileStream } from "../src/index.ts";
|
||||
|
||||
test("database feeds publish only allowed safe table changes", async () => {
|
||||
let handler: ((change: any) => void) | undefined;
|
||||
const published: string[] = [];
|
||||
databaseChangeFeed(
|
||||
{
|
||||
subscribe(next) {
|
||||
handler = next;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
(topic) => {
|
||||
published.push(topic);
|
||||
},
|
||||
{ allowTables: ["users"] },
|
||||
);
|
||||
await handler!({ table: "users", operation: "update", occurredAt: 1 });
|
||||
await handler!({ table: "secrets", operation: "update", occurredAt: 1 });
|
||||
await handler!({ table: "users;drop", operation: "delete", occurredAt: 1 });
|
||||
expect(published).toEqual(["db:users"]);
|
||||
});
|
||||
|
||||
test("file streams reassemble out of order and enforce bounds", () => {
|
||||
const bytes = new TextEncoder().encode("x".repeat(3000));
|
||||
const frames = frameFileStream("upload-1", bytes, { chunkBytes: 1024 });
|
||||
const receiver = createFileStreamReceiver({ maxBytes: 4000 });
|
||||
expect(receiver.accept(frames[2]!)).toBeNull();
|
||||
expect(receiver.accept(frames[0]!)).toBeNull();
|
||||
expect(receiver.accept(frames[1]!)).toEqual(bytes);
|
||||
expect(() => frameFileStream("bad/id", bytes)).toThrow("Invalid stream id");
|
||||
expect(() => frameFileStream("large", bytes, { maxBytes: 10 })).toThrow("FILE-LIMIT");
|
||||
});
|
||||
Reference in New Issue
Block a user