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"] }
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@lxc/protocol",
"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": { "zod": "^3.24.2" },
"devDependencies": { "typescript": "^5.8.3", "vitest": "^3.1.1" }
}
+6
View File
@@ -0,0 +1,6 @@
import { describe, expect, it } from "vitest";
import { liveEventSchema, makeEvent } from "./index.js";
describe("protocol", () => it("parses a typed event", () => {
const event = makeEvent("1", "live.enter", { viewer: { uid: "42", name: "观众" } });
expect(liveEventSchema.parse(event).type).toBe("live.enter");
}));
+34
View File
@@ -0,0 +1,34 @@
import { z } from "zod";
export const PROTOCOL_VERSION = 1 as const;
export const viewerSchema = z.object({ uid: z.string().min(1), name: z.string().min(1).max(128), avatar: z.string().url().optional() });
export type Viewer = z.infer<typeof viewerSchema>;
const liveEvents = {
"live.danmaku": z.object({ viewer: viewerSchema, text: z.string().max(500) }),
"live.gift": z.object({ viewer: viewerSchema, giftName: z.string(), battery: z.number().int().nonnegative(), quantity: z.number().int().positive(), sourceEventId: z.string() }),
"live.gift.combo": z.object({ viewer: viewerSchema, giftName: z.string(), battery: z.number().int().nonnegative(), quantity: z.number().int().positive(), comboId: z.string() }),
"live.enter": z.object({ viewer: viewerSchema }),
"live.guard.buy": z.object({ viewer: viewerSchema, guardName: z.string(), quantity: z.number().int().positive(), price: z.number().int().nonnegative() }),
"live.superchat": z.object({ viewer: viewerSchema, message: z.string(), price: z.number().int().nonnegative(), sourceEventId: z.string() }),
"live.like": z.object({ viewer: viewerSchema }),
"live.share": z.object({ viewer: viewerSchema }),
"live.unknown": z.object({ cmd: z.string(), raw: z.unknown() }),
"system.status": z.object({ connected: z.boolean(), cookieCloud: z.boolean(), replyEnabled: z.boolean(), detail: z.string().optional() }),
"system.error": z.object({ code: z.string(), message: z.string() }),
"viewer.points.updated": z.object({ viewer: viewerSchema, delta: z.number().int(), balance: z.number().int(), reason: z.enum(["gift", "wheel"]) }),
"wheel.result": z.object({ viewer: viewerSchema, category: z.string(), fallback: z.boolean(), song: z.object({ id: z.number().int(), title: z.string(), tags: z.array(z.string()) }), cost: z.literal(150), balance: z.number().int() }),
"wheel.insufficient-balance": z.object({ viewer: viewerSchema, balance: z.number().int(), cost: z.literal(150) }),
"wheel.invalid-command": z.object({ viewer: viewerSchema, message: z.string() })
} as const;
export type EventType = keyof typeof liveEvents;
export type EventPayload<T extends EventType> = z.infer<(typeof liveEvents)[T]>;
export type LiveEvent = { [T in EventType]: { version: 1; id: string; occurredAt: string; roomId: string; type: T; payload: EventPayload<T> } }[EventType];
const variants = Object.entries(liveEvents).map(([type, payload]) => z.object({ version: z.literal(1), id: z.string().uuid(), occurredAt: z.string().datetime(), roomId: z.string(), type: z.literal(type), payload }));
export const liveEventSchema = z.discriminatedUnion("type", variants as unknown as [z.ZodDiscriminatedUnionOption<"type">, ...z.ZodDiscriminatedUnionOption<"type">[]]) as unknown as z.ZodType<LiveEvent>;
export function makeEvent<T extends EventType>(roomId: string, type: T, payload: EventPayload<T>): Extract<LiveEvent, { type: T }> {
return { version: PROTOCOL_VERSION, id: crypto.randomUUID(), occurredAt: new Date().toISOString(), roomId, type, payload } as Extract<LiveEvent, { type: T }>;
}
+7
View File
@@ -0,0 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { liveEventSchema, makeEvent } from "../dist/index.js";
test("protocol parses typed event", () => {
const event = makeEvent("1", "live.enter", { viewer: { uid: "42", name: "观众" } });
assert.equal(liveEventSchema.parse(event).type, "live.enter");
});
+1
View File
@@ -0,0 +1 @@
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "declaration": true, "outDir": "dist", "strict": true, "skipLibCheck": true }, "include": ["src"] }