initial commit

This commit is contained in:
2026-07-14 21:31:59 -07:00
commit bd79966218
61 changed files with 19379 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@lxc/live-client",
"version": "0.1.0",
"type": "module",
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
"scripts": { "build": "tsc -p tsconfig.json", "check": "tsc -p tsconfig.json --noEmit", "test": "node --test test/*.test.mjs" },
"dependencies": { "@lxc/protocol": "*" },
"devDependencies": { "typescript": "^5.8.3", "vitest": "^3.1.1" }
}
+6
View File
@@ -0,0 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { LiveClient } from "./index.js";
describe("LiveClient", () => it("dispatches validated messages", () => {
const socket: any = { close: vi.fn() }; const client = new LiveClient({ url: "ws://test", websocketFactory: () => socket }); const fn = vi.fn(); client.on("live.enter", fn); client.connect();
socket.onmessage({ data: JSON.stringify({ version: 1, id: "a0000000-0000-4000-8000-000000000001", occurredAt: new Date().toISOString(), roomId: "1", type: "live.enter", payload: { viewer: { uid: "1", name: "a" } } }) }); expect(fn).toHaveBeenCalledOnce();
}));
+22
View File
@@ -0,0 +1,22 @@
import { liveEventSchema, type EventType, type LiveEvent } from "@lxc/protocol";
type Handler<T extends EventType = EventType> = (event: Extract<LiveEvent, { type: T }>) => void;
export interface LiveClientOptions { url: string; protocols?: string | string[]; minReconnectMs?: number; maxReconnectMs?: number; websocketFactory?: (url: string, protocols?: string | string[]) => WebSocket; }
export class LiveClient {
private ws?: WebSocket; private closed = false; private retry = 0;
private readonly typed = new Map<EventType, Set<Handler>>(); private readonly any = new Set<Handler>();
constructor(private readonly options: LiveClientOptions) {}
connect() { this.closed = false; this.open(); }
close() { this.closed = true; this.ws?.close(); }
on<T extends EventType>(type: T, handler: Handler<T>) { const set = this.typed.get(type) ?? new Set(); const untyped = handler as unknown as Handler; set.add(untyped); this.typed.set(type, set); return () => set.delete(untyped); }
onAny(handler: Handler) { this.any.add(handler); return () => this.any.delete(handler); }
private open() {
const Factory = this.options.websocketFactory ?? ((url: string, protocols?: string | string[]) => new WebSocket(url, protocols));
this.ws = Factory(this.options.url, this.options.protocols);
this.ws.onopen = () => { this.retry = 0; };
this.ws.onmessage = ({ data }) => { try { const event = liveEventSchema.parse(JSON.parse(String(data))); this.typed.get(event.type)?.forEach(fn => fn(event)); this.any.forEach(fn => fn(event)); } catch { /* reject malformed server data */ } };
this.ws.onclose = () => this.scheduleReconnect(); this.ws.onerror = () => this.ws?.close();
}
private scheduleReconnect() { if (this.closed) return; const min = this.options.minReconnectMs ?? 500; const max = this.options.maxReconnectMs ?? 10_000; const delay = Math.min(max, min * 2 ** this.retry++); setTimeout(() => this.open(), delay); }
}
@@ -0,0 +1,9 @@
import assert from "node:assert/strict";
import test from "node:test";
import { LiveClient } from "../dist/index.js";
test("client dispatches a validated server event", () => {
const socket = {}; const client = new LiveClient({ url: "ws://invalid", websocketFactory: () => socket }); let received;
client.on("live.enter", event => { received = event; }); client.connect();
socket.onmessage({ data: JSON.stringify({ version: 1, id: "a0000000-0000-4000-8000-000000000001", occurredAt: new Date().toISOString(), roomId: "1", type: "live.enter", payload: { viewer: { uid: "1", name: "A" } } }) });
assert.equal(received.payload.viewer.name, "A");
});
+1
View File
@@ -0,0 +1 @@
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "declaration": true, "outDir": "dist", "strict": true, "skipLibCheck": true }, "include": ["src"] }