162 lines
5.8 KiB
TypeScript
162 lines
5.8 KiB
TypeScript
import { test, expect } from "bun:test";
|
|
import {
|
|
defineRoom,
|
|
isRoomDefinition,
|
|
createRealtimeRegistry,
|
|
type RawSocket,
|
|
} from "../src/index.ts";
|
|
|
|
interface MockSocket extends RawSocket {
|
|
sent: Record<string, unknown>[];
|
|
}
|
|
function mockSocket(): MockSocket {
|
|
const sent: Record<string, unknown>[] = [];
|
|
return {
|
|
sent,
|
|
send(data: string) {
|
|
sent.push(JSON.parse(data) as Record<string, unknown>);
|
|
},
|
|
close() {},
|
|
};
|
|
}
|
|
|
|
test("defineRoom marks a room definition", () => {
|
|
expect(isRoomDefinition(defineRoom({}))).toBe(true);
|
|
expect(isRoomDefinition({})).toBe(false);
|
|
expect(isRoomDefinition(null)).toBe(false);
|
|
});
|
|
|
|
test("lifecycle hooks fire; broadcast reaches the whole room", async () => {
|
|
const events: string[] = [];
|
|
const def = defineRoom({
|
|
onConnect(c) {
|
|
events.push("connect");
|
|
c.broadcast({ type: "join" }); // others only
|
|
},
|
|
onMessage(c, m) {
|
|
events.push("message");
|
|
c.room.broadcast({ type: "echo", text: m.text }); // everyone incl. sender
|
|
},
|
|
onLeave(c) {
|
|
events.push("leave");
|
|
c.broadcast({ type: "left" });
|
|
},
|
|
});
|
|
const reg = createRealtimeRegistry();
|
|
const a = mockSocket();
|
|
const b = mockSocket();
|
|
await reg.open(a, { room: "/r/x", def });
|
|
await reg.open(b, { room: "/r/x", def });
|
|
expect(a.sent.some((m) => m.type === "join")).toBe(true); // A saw B join
|
|
expect(b.sent.some((m) => m.type === "join")).toBe(false); // B didn't see its own join
|
|
|
|
await reg.message(a, JSON.stringify({ text: "hi" }));
|
|
expect(a.sent.some((m) => m.type === "echo" && m.text === "hi")).toBe(true); // sender sees own
|
|
expect(b.sent.some((m) => m.type === "echo" && m.text === "hi")).toBe(true);
|
|
|
|
await reg.close(b);
|
|
expect(a.sent.some((m) => m.type === "left")).toBe(true);
|
|
expect(reg.size()).toBe(1);
|
|
expect(events).toEqual(["connect", "connect", "message", "leave"]);
|
|
});
|
|
|
|
test("to(connectionId) and toUser(user|users) target precisely", async () => {
|
|
const ids: Record<string, string> = {};
|
|
const def = defineRoom({
|
|
onConnect(c) {
|
|
c.user = c.query.as; // identify by ?as=
|
|
ids[c.query.as!] = c.id;
|
|
},
|
|
onMessage(c, m) {
|
|
if (m.toUser) c.toUser(m.toUser).send({ type: "dm", text: m.text });
|
|
if (m.toId) c.to(m.toId).send({ type: "direct", text: m.text });
|
|
},
|
|
});
|
|
const reg = createRealtimeRegistry();
|
|
const alice = mockSocket();
|
|
const bob = mockSocket();
|
|
const carol = mockSocket();
|
|
await reg.open(alice, { room: "/r", def, query: { as: "alice" } });
|
|
await reg.open(bob, { room: "/r", def, query: { as: "bob" } });
|
|
await reg.open(carol, { room: "/r", def, query: { as: "carol" } });
|
|
|
|
// single user
|
|
await reg.message(alice, JSON.stringify({ toUser: "bob", text: "hey bob" }));
|
|
expect(bob.sent.some((m) => m.type === "dm" && m.text === "hey bob")).toBe(true);
|
|
expect(carol.sent.some((m) => m.type === "dm")).toBe(false);
|
|
|
|
// selected users
|
|
await reg.message(alice, JSON.stringify({ toUser: ["bob", "carol"], text: "both" }));
|
|
expect(bob.sent.filter((m) => m.type === "dm").length).toBe(2);
|
|
expect(carol.sent.some((m) => m.text === "both")).toBe(true);
|
|
|
|
// by connection id
|
|
await reg.message(alice, JSON.stringify({ toId: ids.carol, text: "by-id" }));
|
|
expect(carol.sent.some((m) => m.type === "direct" && m.text === "by-id")).toBe(true);
|
|
});
|
|
|
|
test("rooms are isolated from each other", async () => {
|
|
const def = defineRoom({
|
|
onMessage(c, m) {
|
|
c.room.broadcast({ type: "x", text: m.text });
|
|
},
|
|
});
|
|
const reg = createRealtimeRegistry();
|
|
const a = mockSocket();
|
|
const b = mockSocket();
|
|
await reg.open(a, { room: "/room/1", def }); // dynamic room instances, one handler
|
|
await reg.open(b, { room: "/room/2", def });
|
|
await reg.message(a, JSON.stringify({ text: "one" }));
|
|
expect(a.sent.some((m) => m.text === "one")).toBe(true);
|
|
expect(b.sent.length).toBe(0); // different room, untouched
|
|
});
|
|
|
|
test("bridge relays broadcasts + toUser across registries (horizontal scaling)", async () => {
|
|
const regA = createRealtimeRegistry();
|
|
const regB = createRealtimeRegistry();
|
|
// A shared bus: each instance delivers the other's published envelopes.
|
|
regA.setBridge({ publish: (env) => regB.deliver(env) });
|
|
regB.setBridge({ publish: (env) => regA.deliver(env) });
|
|
|
|
const def = defineRoom({
|
|
onConnect(c) {
|
|
c.user = c.query.as;
|
|
},
|
|
onMessage(c, m) {
|
|
if (m.toUser) c.toUser(m.toUser).send({ type: "dm", text: m.text });
|
|
else c.room.broadcast({ type: "x", text: m.text });
|
|
},
|
|
});
|
|
const a = mockSocket();
|
|
const b = mockSocket();
|
|
await regA.open(a, { room: "/r", def, query: { as: "alice" } });
|
|
await regB.open(b, { room: "/r", def, query: { as: "bob" } }); // b is on the OTHER instance
|
|
|
|
// broadcast from A reaches B through the bridge
|
|
await regA.message(a, JSON.stringify({ text: "cross-instance" }));
|
|
expect(a.sent.some((m) => m.text === "cross-instance")).toBe(true);
|
|
expect(b.sent.some((m) => m.text === "cross-instance")).toBe(true);
|
|
|
|
// toUser bob (on instance B) from A reaches him via the bridge; alice doesn't
|
|
await regA.message(a, JSON.stringify({ toUser: "bob", text: "hi bob" }));
|
|
expect(b.sent.some((m) => m.type === "dm" && m.text === "hi bob")).toBe(true);
|
|
const aliceDms = a.sent.filter((m) => m.type === "dm").length;
|
|
expect(aliceDms).toBe(0); // not looped back / not delivered to the wrong user
|
|
});
|
|
|
|
test("room.state and count() track the live room", async () => {
|
|
const def = defineRoom({
|
|
onConnect(c) {
|
|
c.room.state.hits = ((c.room.state.hits as number) ?? 0) + 1;
|
|
c.send({ type: "welcome", online: c.room.count(), hits: c.room.state.hits });
|
|
},
|
|
});
|
|
const reg = createRealtimeRegistry();
|
|
const a = mockSocket();
|
|
const b = mockSocket();
|
|
await reg.open(a, { room: "/r", def });
|
|
await reg.open(b, { room: "/r", def });
|
|
expect(a.sent[0]).toMatchObject({ online: 1, hits: 1 });
|
|
expect(b.sent[0]).toMatchObject({ online: 2, hits: 2 });
|
|
});
|