diff --git a/package.json b/package.json index 481f224..aca6b98 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bool-sdk", - "version": "0.4.0-next.6", + "version": "0.4.0-next.7", "description": "Client SDK for apps built on Bool — gateway data access, end-user auth, the AI battery, the React auth layer, and the local-dev CLI (link, types, deploy).", "type": "module", "main": "./dist/index.js", diff --git a/src/client.ts b/src/client.ts index bbc1d22..4366168 100644 --- a/src/client.ts +++ b/src/client.ts @@ -22,7 +22,14 @@ import { createClient, type SupabaseClient } from "@supabase/supabase-js"; import { createEntitiesModule, type EntitiesModule } from "./entities.js"; import { createDoorbell, type MintResult, type RealtimeMint } from "./realtime.js"; -import { createBoolRoom, createRoomStore, type BoolRoom, type RoomMintResult } from "./room.js"; +import { + createBoolRoom, + createBoolRoomApi, + createRoomStore, + type BoolRoomApi, + type RoomStore, +} from "./room.js"; +import { createSession, type SessionChannel } from "./session.js"; /** Matches the server's append-only gateway path version. */ const GATEWAY_API = "v1"; @@ -199,9 +206,10 @@ export type BoolClient = { * `ai.stream(prompt)`. Server-side AI with no API key in the bundle. */ ai: BoolAi; /** The ephemeral lane: live cursors, presence, one-shot events — data that - * flies between the people currently in the app and is never stored. See - * BoolRoom for the surface; durable data stays on `entities`. */ - room: BoolRoom; + * flies between the people currently in the app and is never stored. + * `bool.room.useOthers()` is the app-wide room; `bool.room("game:4")` is a + * scoped one with the identical surface. Durable data stays on `entities`. */ + room: BoolRoomApi; /** This app's private Postgres schema name. */ schema: string; /** Subscribe to the app's realtime "doorbell": fires whenever any row in the @@ -678,89 +686,42 @@ export function createBoolClient(config: BoolClientConfig): BoolClient { } }; - const doorbell = createDoorbell({ - mint: mintRealtimeToken, - setAuth: async (token) => { - await db.realtime.setAuth(token); - }, - channel: (topic, opts) => { - // Adapt one supabase RealtimeChannel to the doorbell's minimal shape. - // The channel is created lazily-configured and joined in join(), so - // onBroadcast registration always precedes the subscribe. - const ch = db.channel(topic, { config: { private: opts.private } }); - return { - onBroadcast(cb) { - ch.on("broadcast", { event: "*" }, (msg) => - cb((msg as { payload?: BoolChangePayload }).payload ?? {}), - ); - }, - join(status) { - ch.subscribe((state) => status(state)); - }, - leave() { - void db.removeChannel(ch); - }, - }; - }, - }); - - const subscribeToChanges = ( - listener: (payload: BoolChangePayload) => void, - ): (() => void) => doorbell.subscribe(listener); - - // The room store shares the doorbell's wristband desk (same mint endpoint, - // same setAuth) but runs its own small lifecycle machine: a presence-only - // app has zero entity subscribers, so the room can't piggyback on the - // doorbell's ref-count. Two independent 15-minute mints per app is noise. - const roomStore = createRoomStore({ - mint: async (): Promise => { + // ONE session for everything realtime — the doorbell and every room share + // its mint, its wristband refresh, and the underlying socket. Two machines + // used to run here with duplicate lifecycles; both of the July-2026 + // production fires had to be fixed twice because of it (see session.ts). + const session = createSession({ + mint: async () => { const res = await mintRealtimeToken(); if (!res.ok) return res; return { - ok: true, + ok: true as const, token: res.mint.token, expiresIn: res.mint.expiresIn, - topic: res.mint.topics.room ?? null, + topics: { + app: res.mint.topics.app, + user: res.mint.topics.user ?? null, + room: res.mint.topics.room ?? null, + }, }; }, setAuth: async (token) => { await db.realtime.setAuth(token); }, - channel: (topic, key) => { - const ch = db.channel(topic, { - config: { - private: true, - presence: { key }, - // No server echo: the store already delivered the sender's copy - // locally and synchronously; a wire echo would double-fire. - broadcast: { self: false, ack: false }, - }, - }); + }); + + const doorbell = createDoorbell({ + session, + makeChannel: (topic) => { + // The channel is created lazily-configured and joined by the session, + // so onBroadcast registration always precedes the subscribe. + const ch = db.channel(topic, { config: { private: true } }); return { - track(state, done) { - // supabase-js RESOLVES with "ok" | "timed out" | "error" — it does not - // throw — so the result must be inspected or every failure is silent. - void ch.track(state).then( - (r) => done?.(typeof r === "string" ? r : "ok"), - () => done?.("error"), - ); - }, - onPresence(cb) { - ch.on("presence", { event: "sync" }, () => { - cb(ch.presenceState() as Record>>); - }); - }, onBroadcast(cb) { ch.on("broadcast", { event: "*" }, (msg) => cb(msg as unknown as { event: string; payload: unknown }), ); }, - send(event, payload, done) { - void ch.send({ type: "broadcast", event, payload }).then( - (r) => done?.(typeof r === "string" ? r : "ok"), - () => done?.("error"), - ); - }, join(status) { ch.subscribe((state) => status(state)); }, @@ -770,7 +731,61 @@ export function createBoolClient(config: BoolClientConfig): BoolClient { }; }, }); - const room = createBoolRoom(roomStore); + + const subscribeToChanges = ( + listener: (payload: BoolChangePayload) => void, + ): (() => void) => doorbell.subscribe(listener); + + const makeRoomChannel = (topic: string, key: string): SessionChannel => { + const ch = db.channel(topic, { + config: { + private: true, + presence: { key }, + // No server echo: the store already delivered the sender's copy + // locally and synchronously; a wire echo would double-fire. + broadcast: { self: false, ack: false }, + }, + }); + return { + track(state, done) { + // supabase-js RESOLVES with "ok" | "timed out" | "error" — it does not + // throw — so the result must be inspected or every failure is silent. + void ch.track(state).then( + (r) => done?.(typeof r === "string" ? r : "ok"), + () => done?.("error"), + ); + }, + onPresence(cb) { + ch.on("presence", { event: "sync" }, () => { + cb(ch.presenceState() as Record>>); + }); + }, + onBroadcast(cb) { + ch.on("broadcast", { event: "*" }, (msg) => + cb(msg as unknown as { event: string; payload: unknown }), + ); + }, + send(event, payload, done) { + void ch.send({ type: "broadcast", event, payload }).then( + (r) => done?.(typeof r === "string" ? r : "ok"), + () => done?.("error"), + ); + }, + join(status) { + ch.subscribe((state) => status(state)); + }, + leave() { + void db.removeChannel(ch); + }, + }; + }; + + // The default room + memoized scoped rooms, all channel groups on the one + // session: `bool.room.useOthers()` is the app-wide room, `bool.room("id")` + // is a scoped one with the identical surface. + const roomStoreFor = (roomId: string | null): RoomStore> => + createRoomStore({ session, roomId, makeChannel: makeRoomChannel }); + const room = createBoolRoomApi(createBoolRoom(roomStoreFor(null)), (id) => roomStoreFor(id)); const client: BoolClient = { db, diff --git a/src/realtime.test.ts b/src/realtime.test.ts index 7bbf714..5a030b2 100644 --- a/src/realtime.test.ts +++ b/src/realtime.test.ts @@ -1,424 +1,158 @@ import { describe, expect, test } from "bun:test"; -import { - createDoorbell, - type DoorbellDeps, - type DoorbellStatus, - type MintResult, - type RealtimeMint, -} from "./realtime"; +import { createDoorbell, type DoorbellStatus } from "./realtime"; +import { createSession, type SessionMintResult } from "./session"; import type { BoolChangePayload } from "./client"; -// The doorbell lifecycle drives everything through injected deps, so these -// tests exercise the full machine — mint, join, refresh, revocation, fallback, -// teardown — with zero sockets. +// The doorbell is a pure consumer: fan out payloads, pick topics, report +// status. Everything about the connection lifecycle (mint, refresh, backoff, +// wake, teardown, the sync-CLOSED scar) is the session's job and is tested +// once, in session.test.ts — not re-tested here. -type FakeChannel = { - topic: string; - priv: boolean; - joined: boolean; - left: boolean; - cb: ((p: BoolChangePayload) => void) | null; - status: ((s: string) => void) | null; -}; +const TOPICS = { app: "bool:app_x:app", user: "bool:app_x:user:u1", room: "bool:app_x:room" }; -function makeHarness( - opts: { mints?: MintResult[]; onStatus?: (s: DoorbellStatus) => void } = {}, -) { - const mints = [...(opts.mints ?? [])]; +function makeHarness(opts?: { mints?: SessionMintResult[] }) { + const mints = [...(opts?.mints ?? [])]; const h = { - channels: [] as FakeChannel[], - authed: [] as string[], mintCalls: 0, - scheduled: [] as { fn: () => void; ms: number; cancelled: boolean }[], - /** Run the next pending refresh timer. */ - async fire() { - const t = h.scheduled.find((s) => !s.cancelled && !(s as any).fired); - if (!t) throw new Error("nothing scheduled"); - (t as any).fired = true; - await t.fn(); - await tick(); - }, + channels: [] as Array<{ + topic: string; + joinCb: ((s: string) => void) | null; + broadcastCb: ((msg: { event: string; payload: unknown }) => void) | null; + left: boolean; + }>, + statuses: [] as DoorbellStatus[], + live: () => h.channels.filter((c) => !c.left), ding(topic: string, payload: BoolChangePayload) { - for (const ch of h.channels) { - if (ch.topic === topic && ch.joined && !ch.left) ch.cb?.(payload); + for (const ch of h.live()) { + if (ch.topic === topic) ch.broadcastCb?.({ event: "change", payload }); } }, - live: () => h.channels.filter((c) => c.joined && !c.left), - /** Simulate the tab/network coming back. */ - wake: () => { - for (const cb of [...wakeCbs]) cb(); + joinAll: async () => { + for (let i = 0; i < 50 && h.live().some((c) => !c.joinCb); i++) await Promise.resolve(); + for (const c of h.live()) c.joinCb?.("SUBSCRIBED"); + await Promise.resolve(); + }, + tick: async () => { + for (let i = 0; i < 20; i++) await Promise.resolve(); }, - wakeSubs: () => wakeCbs.size, - clock: 0, }; - const wakeCbs = new Set<() => void>(); - const deps: DoorbellDeps = { - async mint(): Promise { + + const session = createSession({ + async mint() { h.mintCalls++; - return mints.length ? mints.shift()! : { ok: false, reason: "unauthorized" }; + return mints.length + ? mints.shift()! + : { ok: true, token: `tok-${h.mintCalls}`, expiresIn: 900, topics: TOPICS }; }, - async setAuth(token) { - h.authed.push(token); - }, - channel(topic, { private: priv }) { - const ch: FakeChannel = { topic, priv, joined: false, left: false, cb: null, status: null }; - h.channels.push(ch); + setAuth: () => {}, + schedule: (fn, ms) => setTimeout(fn, Math.min(ms, 5)), + cancel: (t) => clearTimeout(t as ReturnType), + wake: () => () => {}, + }); + + const doorbell = createDoorbell({ + session, + makeChannel(topic) { + const rec = { + topic, + joinCb: null as ((s: string) => void) | null, + broadcastCb: null as ((msg: { event: string; payload: unknown }) => void) | null, + left: false, + }; + h.channels.push(rec); return { onBroadcast(cb) { - ch.cb = cb; + rec.broadcastCb = cb; }, - join(status) { - ch.joined = true; - ch.status = status; + join(cb) { + rec.joinCb = cb; }, leave() { - ch.left = true; + rec.left = true; }, }; }, - schedule(fn, ms) { - const t = { fn: fn as () => void, ms, cancelled: false }; - h.scheduled.push(t); - return t; - }, - cancel(handle) { - (handle as { cancelled: boolean }).cancelled = true; - }, - onStatus: opts.onStatus, - now: () => h.clock, - wake(cb) { - wakeCbs.add(cb); - return () => wakeCbs.delete(cb); - }, - }; - return { h, doorbell: createDoorbell(deps) }; -} - -const RAW: RealtimeMint = { - token: "tok-1", - expiresIn: 900, - topics: { app: "bool:app_x:app", user: "bool:app_x:user:u1" }, -}; -const MINT: MintResult = { ok: true, mint: RAW }; -const ok = (m: Partial): MintResult => ({ ok: true, mint: { ...RAW, ...m } }); -const refused: MintResult = { ok: false, reason: "unauthorized" }; -const down: MintResult = { ok: false, reason: "unavailable" }; -const tick = () => new Promise((r) => setTimeout(r, 0)); - -describe("createDoorbell: the private path", () => { - test("mints, presents the wristband, joins app + user rooms privately", async () => { - const { h, doorbell } = makeHarness({ mints: [MINT] }); - doorbell.subscribe(() => {}); - await tick(); - expect(h.authed).toEqual(["tok-1"]); - expect(h.live().map((c) => [c.topic, c.priv])).toEqual([ - ["bool:app_x:app", true], - ["bool:app_x:user:u1", true], - ]); + onStatus: (s) => h.statuses.push(s), }); - test("anonymous mint (no user topic) joins the app room only", async () => { - const { h, doorbell } = makeHarness({ - mints: [ok({ topics: { app: "bool:app_x:app", user: null } })], - }); - doorbell.subscribe(() => {}); - await tick(); - expect(h.live().map((c) => c.topic)).toEqual(["bool:app_x:app"]); - }); + return { h, doorbell, session }; +} - test("payloads from either room reach every listener (one shared doorbell)", async () => { - const { h, doorbell } = makeHarness({ mints: [MINT] }); +describe("doorbell: topics and fan-out", () => { + test("joins app + user rooms; payloads reach every listener via ONE doorbell", async () => { + const { h, doorbell } = makeHarness(); const a: BoolChangePayload[] = []; const b: BoolChangePayload[] = []; - doorbell.subscribe((p) => a.push(p)); - doorbell.subscribe((p) => b.push(p)); - await tick(); - expect(h.mintCalls).toBe(1); // second subscriber reuses the running doorbell + const offA = doorbell.subscribe((p) => a.push(p)); + const offB = doorbell.subscribe((p) => b.push(p)); + await h.tick(); + await h.joinAll(); + expect(h.mintCalls).toBe(1); // second subscriber reuses the running session + expect(h.live().map((c) => c.topic)).toEqual(["bool:app_x:app", "bool:app_x:user:u1"]); + h.ding("bool:app_x:app", { table: "todos", op: "INSERT", id: "1", row: { id: "1" } }); h.ding("bool:app_x:user:u1", { table: "notes", op: "UPDATE", id: "2" }); expect(a).toHaveLength(2); expect(b).toHaveLength(2); expect(a[0]!.row).toEqual({ id: "1" }); + offA(); + offB(); }); -}); -describe("createDoorbell: refresh & revocation", () => { - test("re-mints at ~75% of the TTL and re-presents the new wristband", async () => { - const { h, doorbell } = makeHarness({ mints: [MINT, ok({ token: "tok-2" })] }); - doorbell.subscribe(() => {}); - await tick(); - expect(h.scheduled[0]!.ms).toBe(900 * 1000 * 0.75); - await h.fire(); - expect(h.authed).toEqual(["tok-1", "tok-2"]); - // channels stay up — setAuth re-auths the connection, no rejoin - expect(h.live().length).toBe(2); + test("anonymous mint (no user topic) joins the app room only", async () => { + const { h, doorbell } = makeHarness({ + mints: [{ ok: true, token: "t", expiresIn: 900, topics: { ...TOPICS, user: null } }], + }); + const off = doorbell.subscribe(() => {}); + await h.tick(); + expect(h.live().map((c) => c.topic)).toEqual(["bool:app_x:app"]); + off(); }); - test("a refused re-mint (revoked access) drops the rooms and reports unauthorized", async () => { - // The TTL expiring IS the revocation mechanism: once the gateway stops - // issuing wristbands the socket must go quiet, not keep listening. - const { h, doorbell } = makeHarness({ mints: [MINT, refused] }); + test("the last unsubscribe stops delivery; late payloads reach nobody", async () => { + const { h, doorbell } = makeHarness(); const got: BoolChangePayload[] = []; - doorbell.subscribe((p) => got.push(p)); - await tick(); - await h.fire(); + const off = doorbell.subscribe((p) => got.push(p)); + await h.tick(); + await h.joinAll(); + off(); expect(h.live()).toHaveLength(0); - expect(doorbell.status()).toBe("unauthorized"); - // late payloads on a torn-down room reach nobody h.ding("bool:app_x:app", { table: "todos", op: "INSERT", id: "9" }); expect(got).toHaveLength(0); }); - - test("a nonsense TTL is clamped so refresh can't melt into a mint loop", async () => { - const { h, doorbell } = makeHarness({ mints: [ok({ expiresIn: 1 })] }); - doorbell.subscribe(() => {}); - await tick(); - expect(h.scheduled[0]!.ms).toBe(30_000); - }); }); -// There is NO public channel to fall back to — every topic is wristband-gated. -// So a failure is reported and retried, never disguised as liveness. -describe("createDoorbell: failure is surfaced, not disguised", () => { +describe("doorbell: status is surfaced, never disguised", () => { test("a refused mint reports unauthorized and joins nothing", async () => { - const { h, doorbell } = makeHarness({ mints: [refused] }); - const got: BoolChangePayload[] = []; - doorbell.subscribe((p) => got.push(p)); - await tick(); + const { h, doorbell } = makeHarness({ + mints: [{ ok: false, reason: "unauthorized" }], + }); + const off = doorbell.subscribe(() => {}); + await h.tick(); expect(h.live()).toHaveLength(0); - expect(h.authed).toHaveLength(0); expect(doorbell.status()).toBe("unauthorized"); - expect(got).toHaveLength(0); + off(); }); test("an unreachable gateway reports unavailable — NOT unauthorized", async () => { - // A public app admits every visitor, so a dropped request must never be + // A public app admits every visitor; a dropped request must never be // presented as "you aren't allowed to watch this". - const { doorbell } = makeHarness({ mints: [down] }); - doorbell.subscribe(() => {}); - await tick(); - expect(doorbell.status()).toBe("unavailable"); - }); - - test("status transitions are reported to the client", async () => { - const seen: DoorbellStatus[] = []; - const { h, doorbell } = makeHarness({ mints: [MINT] }); - // onStatus isn't part of makeHarness; assert via the accessor instead. - expect(doorbell.status()).toBe("connecting"); - doorbell.subscribe(() => {}); - await tick(); - h.channels[0]!.status!("SUBSCRIBED"); - expect(doorbell.status()).toBe("live"); - void seen; - }); - - test("retries with capped backoff instead of hot-looping", async () => { - const { h, doorbell } = makeHarness({ mints: [refused, refused, refused, refused, refused] }); - doorbell.subscribe(() => {}); - await tick(); - const delays: number[] = [h.scheduled[0]!.ms]; - for (let i = 0; i < 3; i++) { - await h.fire(); - delays.push(h.scheduled.at(-1)!.ms); - } - expect(delays).toEqual([1_000, 5_000, 15_000, 60_000]); - // and it keeps trying — a granted-later viewer eventually goes live - expect(h.mintCalls).toBeGreaterThan(1); - }); - - test("a good join resets the backoff so a later blip starts quick again", async () => { - const { h, doorbell } = makeHarness({ mints: [refused, MINT] }); - doorbell.subscribe(() => {}); - await tick(); - expect(h.scheduled[0]!.ms).toBe(1_000); - await h.fire(); // second attempt mints fine - h.channels[0]!.status!("SUBSCRIBED"); - expect(doorbell.status()).toBe("live"); - // a refused join now → backoff restarts at the first step - h.channels[0]!.status!("CHANNEL_ERROR"); - expect(h.scheduled.at(-1)!.ms).toBe(1_000); - }); - - test("a refused private join tears down and retries (wristband/policy disagree)", async () => { - const { h, doorbell } = makeHarness({ mints: [MINT] }); - doorbell.subscribe(() => {}); - await tick(); - h.channels[0]!.status!("CHANNEL_ERROR"); - expect(h.live()).toHaveLength(0); - expect(doorbell.status()).toBe("unavailable"); - }); - - test("last unsubscribe tears everything down; a late mint resolution no-ops", async () => { - let release!: (m: MintResult) => void; - const slowMint = new Promise((r) => (release = r)); - const h = { channels: [] as FakeChannel[], authed: [] as string[] }; - const doorbell = createDoorbell({ - mint: () => slowMint, - setAuth: async (t) => void h.authed.push(t), - channel(topic, { private: priv }) { - const ch: FakeChannel = { topic, priv, joined: false, left: false, cb: null, status: null }; - h.channels.push(ch); - return { - onBroadcast() {}, - join() { - ch.joined = true; - }, - leave() { - ch.left = true; - }, - }; - }, + const { h, doorbell } = makeHarness({ + mints: [{ ok: false, reason: "unavailable" }], }); const off = doorbell.subscribe(() => {}); - off(); // teardown while the mint is still in flight - release(MINT); - await tick(); - expect(h.authed).toHaveLength(0); - expect(h.channels).toHaveLength(0); - }); - - test("resubscribing after teardown starts a fresh doorbell", async () => { - const { h, doorbell } = makeHarness({ mints: [MINT, ok({ token: "tok-2" })] }); - const off = doorbell.subscribe(() => {}); - await tick(); - off(); - expect(h.live()).toHaveLength(0); - doorbell.subscribe(() => {}); - await tick(); - expect(h.authed).toEqual(["tok-1", "tok-2"]); - expect(h.live()).toHaveLength(2); - }); -}); - -describe("doorbell waking up", () => { - test("a wake reconnects immediately instead of waiting out the backoff", async () => { - // Same gap the room lane had: a backgrounded tab loses its socket, the drop - // is reported honestly, and then live entity updates stay dead for up to a - // minute of backoff on a page the user is looking at. - const { h, doorbell } = makeHarness({ mints: [{ ok: false, reason: "unavailable" }] }); - const off = doorbell.subscribe(() => {}); - await tick(); + await h.tick(); expect(doorbell.status()).toBe("unavailable"); - const before = h.mintCalls; - - // No timer is run: the reconnect must not depend on the retry rung. - h.wake(); - await tick(); - expect(h.mintCalls).toBe(before + 1); - off(); - }); - - test("a wake while live does nothing", async () => { - const { h, doorbell } = makeHarness({ mints: [MINT] }); - const off = doorbell.subscribe(() => {}); - await tick(); - h.channels[0]!.status!("SUBSCRIBED"); - expect(doorbell.status()).toBe("live"); - const before = h.mintCalls; - h.wake(); - await tick(); - expect(h.mintCalls).toBe(before); off(); }); - test("simultaneous wake signals coalesce into one reconnect", async () => { - const { h, doorbell } = makeHarness({ mints: [{ ok: false, reason: "unavailable" }] }); - const off = doorbell.subscribe(() => {}); - await tick(); - const before = h.mintCalls; - h.wake(); - h.wake(); - h.wake(); - await tick(); - expect(h.mintCalls).toBe(before + 1); - off(); - }); - - test("wake listeners are released with the last subscriber", async () => { - const { h, doorbell } = makeHarness({ mints: [{ ok: false, reason: "unavailable" }] }); - expect(h.wakeSubs()).toBe(0); - const a = doorbell.subscribe(() => {}); - const b = doorbell.subscribe(() => {}); - await tick(); - expect(h.wakeSubs()).toBe(1); // one shared doorbell, one subscription - a(); - expect(h.wakeSubs()).toBe(1); - b(); - expect(h.wakeSubs()).toBe(0); - - const before = h.mintCalls; - h.wake(); - await tick(); - expect(h.mintCalls).toBe(before); - }); - - test("the last unsubscribe NOTIFIES the status reset, it does not just assign it", async () => { - // A bare `state = "connecting"` leaves every onStatus subscriber holding - // "live" for a doorbell that has been torn down — the same stale-snapshot - // bug the room lane had, and invisible through status() alone. - const seen: DoorbellStatus[] = []; - const { h, doorbell } = makeHarness({ mints: [MINT], onStatus: (s) => seen.push(s) }); + test("status transitions flow through to the client hook", async () => { + const { h, doorbell } = makeHarness(); const off = doorbell.subscribe(() => {}); - await tick(); - h.channels[0]!.status!("SUBSCRIBED"); + await h.tick(); + await h.joinAll(); expect(doorbell.status()).toBe("live"); - seen.length = 0; - off(); - expect(doorbell.status()).toBe("connecting"); - expect(seen).toEqual(["connecting"]); // the notification, not just the field - }); -}); - -describe("doorbell surviving supabase's synchronous CLOSED", () => { - // Same production stack overflow as the room lane: removeChannel fires the - // channel's status callback with CLOSED synchronously from inside leave(), - // and the terminal-state handler responds by tearing down (which leaves). - test("a real drop does not recurse, and the doorbell still retries", async () => { - let mintCalls = 0; - const joinCbs: Array<(s: string) => void> = []; - const scheduled: Array<{ fn: () => void; cancelled: boolean }> = []; - const doorbell = createDoorbell({ - async mint(): Promise { - mintCalls++; - return MINT; - }, - setAuth: () => {}, - channel: () => { - const me = joinCbs.length; - return { - onBroadcast: () => {}, - join(cb) { - joinCbs[me] = cb; - }, - leave() { - joinCbs[me]?.("CLOSED"); // what removeChannel actually does - }, - }; - }, - schedule: (fn) => { - const t = { fn: fn as () => void, cancelled: false }; - scheduled.push(t); - return t; - }, - cancel: (h) => { - (h as { cancelled: boolean }).cancelled = true; - }, - wake: () => () => {}, - }); - const off = doorbell.subscribe(() => {}); - await tick(); - joinCbs[0]!("SUBSCRIBED"); - expect(doorbell.status()).toBe("live"); - - joinCbs[0]!("CLOSED"); // pre-fix: RangeError, maximum call stack exceeded - expect(doorbell.status()).toBe("unavailable"); - - // recovery intact: the queued retry rung re-mints (cancelled timers, - // like the orphaned refresh, must not) - const before = mintCalls; - for (const t of scheduled.splice(0)) if (!t.cancelled) t.fn(); - await tick(); - expect(mintCalls).toBe(before + 1); + expect(h.statuses).toContain("live"); off(); }); }); diff --git a/src/realtime.ts b/src/realtime.ts index f3196ad..38a9fc3 100644 --- a/src/realtime.ts +++ b/src/realtime.ts @@ -1,277 +1,113 @@ -// The private doorbell client — the SDK half of Bool's realtime design -// (platform: lib/bool-db.ts GATEWAY_GLOBAL_SETUP_SQL + the -// /_bool/v1/realtime/token plane). +// The entity doorbell — change payloads for live entity views. // -// Live updates arrive as ROW-BEARING broadcasts on PRIVATE topics. Every topic -// is private: a socket holding no gateway-minted wristband hears nothing, and -// there is no public channel to fall back to. This module owns the lifecycle so -// app code (and useEntity) just gets payloads: -// - mint the wristband from the gateway (which re-runs liveAccess/session on -// every mint — that's where access is actually decided), -// - setAuth + join the app room and, when signed in, the personal user room -// (the trigger picks disjoint audiences, so the two never duplicate), -// - re-mint at ~75% of the TTL so the socket never hits token expiry, -// - on failure, RETRY with capped backoff and report status — never pretend. -// A refused mint means the viewer isn't authorized (or the gateway is -// unreachable); either way the honest outcome is "not live", surfaced, with -// HTTP reads still working. Silently limping along on a degraded path is how -// a broken realtime layer goes unnoticed for a week. -// - share ONE doorbell across every subscriber (each entities. -// .subscribe used to open its own channel; now they ref-count this one). +// A PURE CONSUMER of the shared RealtimeSession (session.ts): this module +// contains zero connection code. It declares which topics it wants (the app +// room, plus the personal user room when the wristband names a signed-in +// user), fans incoming change payloads out to every subscriber, and reports +// its delivery status. Mint, refresh, backoff, wake and teardown live in the +// session — written once, shared with bool.room. // -// Everything effectful is injected (DoorbellDeps) so the machine is fully -// unit-testable without sockets. +// There is NO public channel and no fallback: every topic is wristband-gated, +// so a failure is reported and retried, never disguised as liveness. The old +// public compat channel cost 2× writes forever, leaked row ids on an +// anon-joinable topic, and spawned three follow-on bugs to serve nobody. +// +// Trust note: payloads on these topics are merged into entity state as +// server-trusted data. That is safe ONLY because the server's send policy +// makes the doorbell topics client-unwritable (clients may publish solely to +// the room topic family). Never weaken that policy; never join these topics +// with anything but `private: true`. import type { BoolChangePayload } from "./client.js"; -import { onWake } from "./wake.js"; +import type { + ChannelGroup, + RealtimeSession, + RealtimeTopics, + SessionChannel, + SessionStatus, +} from "./session.js"; + +export type DoorbellStatus = SessionStatus; -/** What the wristband desk returns. Topic names are SERVER-authored so naming - * lives in exactly one place. */ +/** One wristband, as the gateway mints it (/_bool/v1/realtime/token). */ export type RealtimeMint = { token: string; - /** Seconds until the token expires. */ expiresIn: number; - topics: { - app: string; - user: string | null; - /** The ephemeral bool.room topic. Absent on platforms that predate it — - * the room store then reports "unavailable" and retries. */ - room?: string | null; - }; + topics: RealtimeTopics; }; -export type DoorbellChannel = { - /** Register the broadcast payload handler (must be called before join). */ - onBroadcast(cb: (payload: BoolChangePayload) => void): void; - /** Open the channel; `status` fires with supabase-style states. */ - join(status: (state: string) => void): void; - leave(): void; -}; - -/** Why the doorbell isn't delivering, when it isn't. - * - `connecting` — first mint/join in flight - * - `live` — joined; changes are arriving - * - `unauthorized` — the gateway refused a wristband (not authorized for this - * app, or end-user session expired). Retried, since access can be granted. - * - `unavailable` — gateway unreachable, realtime misconfigured, or the join - * was refused. Retried with backoff. */ -export type DoorbellStatus = "connecting" | "live" | "unauthorized" | "unavailable"; - -/** Outcome of one mint attempt. The two failure kinds are distinguished on - * purpose: "the gateway says no" and "I couldn't reach the gateway" mean very - * different things to a viewer (and to whoever is debugging), and a public app - * — where every visitor is normally admitted — must never be told it's - * unauthorized because of a dropped request. */ export type MintResult = | { ok: true; mint: RealtimeMint } | { ok: false; reason: "unauthorized" | "unavailable" }; export type DoorbellDeps = { - /** Mint one wristband. On failure report WHICH kind (see MintResult) — the - * doorbell retries either way and never silently gives up. */ - mint(): Promise; - /** Present the wristband to the realtime connection (supabase setAuth). */ - setAuth(token: string): Promise | void; - /** Create (not join) a private channel on `topic`. */ - channel(topic: string, opts: { private: boolean }): DoorbellChannel; + session: RealtimeSession; + /** Create (not join) a private channel on `topic` with broadcast handlers + * attachable. The session owns join/teardown. */ + makeChannel(topic: string): Pick; /** Called whenever delivery state changes, so a client can surface it. */ onStatus?: (status: DoorbellStatus) => void; - /** Test seam; defaults to setTimeout/clearTimeout. */ - schedule?: (fn: () => void, ms: number) => unknown; - cancel?: (handle: unknown) => void; - now?: () => number; - /** Subscribe to tab/network wake signals; defaults to the DOM listeners. */ - wake?: (cb: () => void) => () => void; }; -// Re-mint at 75% of the TTL: early enough that a slow mint never races token -// expiry (which would close the channels underneath us), late enough that -// refresh traffic stays negligible. -const REFRESH_FRACTION = 0.75; -// A desk that answers with a nonsense TTL shouldn't melt into a mint loop. -const MIN_REFRESH_MS = 30_000; -// Retry backoff after a refused/failed mint or join: quick first, then capped so -// a long outage costs one request a minute rather than a hot loop. -const RETRY_MS = [1_000, 5_000, 15_000, 60_000]; -// `focus` and `visibilitychange` fire together on tab re-selection; coalesce so -// one wake is one reconnect. See wake.ts for why waking matters at all. -const WAKE_COALESCE_MS = 500; - export type Doorbell = { - /** Register a change listener. Starts the machinery on the first listener, - * tears it down after the last unsubscribes. Returns unsubscribe. */ + /** Register a change listener. Starts delivery on the first listener, stops + * after the last unsubscribes. Returns unsubscribe. */ subscribe(listener: (payload: BoolChangePayload) => void): () => void; /** Current delivery state — for surfacing "not live" in a UI. */ status(): DoorbellStatus; }; -export function createDoorbell(deps: DoorbellDeps): Doorbell { - const schedule = deps.schedule ?? ((fn, ms) => setTimeout(fn, ms)); - const cancel = deps.cancel ?? ((h) => clearTimeout(h as ReturnType)); - const now = deps.now ?? (() => Date.now()); - const subscribeWake = deps.wake ?? onWake; +/** Fill the SessionChannel members the doorbell never uses, so its adapter + * stays as small as what it actually does: receive broadcasts. */ +function asSessionChannel( + ch: Pick, +): SessionChannel { + return { + ...ch, + track: (_s, done) => done?.("ok"), + onPresence: () => {}, + send: (_e, _p, done) => done?.("ok"), + }; +} +export function createDoorbell(deps: DoorbellDeps): Doorbell { const listeners = new Set<(p: BoolChangePayload) => void>(); const fanout = (p: BoolChangePayload) => { for (const l of listeners) l(p); }; - // One "generation" per start(); every async continuation checks it so a - // teardown (or a restart) orphans in-flight work instead of racing it. - let gen = 0; - let channels: DoorbellChannel[] = []; - let timer: unknown = null; - let attempt = 0; let state: DoorbellStatus = "connecting"; - function setStatus(next: DoorbellStatus): void { - if (state === next) return; - state = next; - deps.onStatus?.(next); - } - - function clearTimer(): void { - if (timer !== null) { - cancel(timer); - timer = null; - } - } - - function teardownChannels(): void { - // Detach BEFORE leaving: supabase's removeChannel fires the channel's own - // status callback with CLOSED synchronously from inside leave(), and the - // terminal-state handler responds by tearing down. With the old order that - // recursed teardown → leave → CLOSED → teardown to a stack overflow on - // every real network drop — same defect as the room lane. The handler's - // membership guard is the other half. - const chs = channels; - channels = []; - for (const ch of chs) ch.leave(); - clearTimer(); - } - - /** Schedule the next attempt after a failure, with capped backoff. */ - function retry(myGen: number, why: DoorbellStatus): void { - setStatus(why); - const ms = RETRY_MS[Math.min(attempt, RETRY_MS.length - 1)]!; - attempt++; - timer = schedule(() => { - if (myGen !== gen) return; - void start(myGen); - }, ms); - } - - // ---- wake: come back the instant the tab/network does -------------------- - // Identical gap to the room lane, and identically invisible: a backgrounded - // tab loses its socket, the drop is reported honestly, and then live entity - // updates stay dead for up to a minute of backoff. See wake.ts. - let stopWake: (() => void) | null = null; - let lastWakeAt = -Infinity; - function onWakeSignal(): void { - if (listeners.size === 0 || state === "live") return; - const t = now(); - if (t - lastWakeAt < WAKE_COALESCE_MS) return; - lastWakeAt = t; - gen++; // orphan the pending retry and any in-flight start - attempt = 0; - setStatus("connecting"); - void start(gen); - } - - async function start(myGen: number): Promise { - teardownChannels(); - if (myGen !== gen) return; - - const res = await deps.mint(); - if (myGen !== gen) return; - // No wristband: the app is NOT live. Report which kind of "no" it was and - // try again — never fake liveness. - if (!res.ok) return retry(myGen, res.reason); - const m = res.mint; - - await deps.setAuth(m.token); - if (myGen !== gen) return; - + const group: ChannelGroup = { // The app room, and the personal room when the mint carried a signed-in // user. Audiences are disjoint by construction (the trigger rings an owned // row's user room INSTEAD of the app room), so no dedupe is needed. - let joined = 0; - for (const topic of [m.topics.app, m.topics.user]) { - if (!topic) continue; - const ch = deps.channel(topic, { private: true }); - ch.onBroadcast(fanout); - // Registered BEFORE join(): the status callback guards on membership, and - // a callback firing before the push would wrongly see itself as stale. - channels.push(ch); - ch.join((s) => { - // Membership (not just gen) is load-bearing: teardownChannels() empties - // the list and then leave() makes this very callback fire CLOSED, same - // tick, same gen. Without the check that re-entered the terminal branch - // and recursed teardown → leave → CLOSED → teardown to a stack - // overflow on every real network drop. - if (myGen !== gen || !channels.includes(ch)) return; - if (s === "SUBSCRIBED") { - joined++; - attempt = 0; // a good join resets the backoff - setStatus("live"); - } else if (s === "CHANNEL_ERROR" || s === "TIMED_OUT" || s === "CLOSED") { - // A refused private join means the wristband and the policy disagree - // (misconfigured database, clock skew). Retry the whole handshake. - // - // CLOSED belongs here too, and its absence was a latent bug shared - // with the room lane: when the socket drops (network blip, server - // close, token expiry) supabase-js reports a terminal state and does - // NOT auto-rejoin, so leaving one unhandled meant live updates - // stopped forever while the last reported status stayed "live". - teardownChannels(); - retry(myGen, "unavailable"); - } + topics: (t) => [t.app, t.user].filter((x): x is string => Boolean(x)), + open(topic) { + const ch = deps.makeChannel(topic); + ch.onBroadcast((msg) => { + fanout(((msg as { payload?: BoolChangePayload }).payload ?? {}) as BoolChangePayload); }); - } - if (joined === 0 && channels.length === 0) return retry(myGen, "unavailable"); - - scheduleRefresh(myGen, m.expiresIn); - } + return asSessionChannel(ch); + }, + onStatus(s) { + if (state === s) return; + state = s; + deps.onStatus?.(s); + }, + }; - function scheduleRefresh(myGen: number, expiresInSeconds: number): void { - const ms = Math.max(expiresInSeconds * 1000 * REFRESH_FRACTION, MIN_REFRESH_MS); - timer = schedule(async () => { - if (myGen !== gen) return; - const res = await deps.mint(); - if (myGen !== gen) return; - // Renewal refused — access was revoked, or the gateway is down. The TTL - // expiring is the whole revocation mechanism, so drop the rooms. - if (!res.ok) { - teardownChannels(); - return retry(myGen, res.reason); - } - await deps.setAuth(res.mint.token); // channels re-auth on the connection - if (myGen !== gen) return; - scheduleRefresh(myGen, res.mint.expiresIn); - }, ms); - } + let unregister: (() => void) | null = null; return { subscribe(listener) { listeners.add(listener); - if (listeners.size === 1) { - gen++; - attempt = 0; - setStatus("connecting"); - stopWake = subscribeWake(onWakeSignal); - void start(gen); - } + if (listeners.size === 1) unregister = deps.session.register(group); return () => { if (!listeners.delete(listener)) return; if (listeners.size === 0) { - gen++; // orphan any in-flight start/refresh - stopWake?.(); - stopWake = null; - teardownChannels(); - // setStatus, not a bare assignment: writing `state` directly leaves - // every onStatus subscriber holding the stale value, which is how a - // torn-down doorbell kept reporting "live". Same bug the room lane had. - setStatus("connecting"); + unregister?.(); + unregister = null; } }; }, diff --git a/src/room.test.ts b/src/room.test.ts index 2b71b71..d3714fa 100644 --- a/src/room.test.ts +++ b/src/room.test.ts @@ -3,11 +3,14 @@ import { colorForId, createRoomStore, throttleForPeers, - type RoomChannel, - type RoomDeps, - type RoomMintResult, - type RoomStore, + validateRoomId, } from "./room"; +import { createSession, type SessionChannel, type SessionMintResult } from "./session"; + +type RoomMintResult = + | { ok: true; token: string; expiresIn: number; topic: string | null } + | { ok: false; reason: "unauthorized" | "unavailable" }; +type RoomChannel = SessionChannel; // A fake transport: a "wire" shared by any number of stores, so multi-peer // behavior (presence sync, broadcast fan-out, self-filtering) is exercised for @@ -37,12 +40,14 @@ function makeHarness(opts?: { mint?: () => Promise; wire?: Wire; /** What the fake channel reports back from track() — supabase-js resolves - * with a STRING, and "timed out"/"error" must drive recovery. */ + * with a STRING, and "timed out"/"error" must never be silent. */ trackResult?: string; + roomId?: string | null; }) { const wire = opts?.wire ?? makeWire(); const trackResult = opts?.trackResult ?? "ok"; // Deterministic virtual clock so throttle behavior is testable exactly. + // Session and store share it — one clock drives the whole stack. let clock = 0; const timers: Array<{ at: number; fn: () => void; id: number }> = []; let nextId = 1; @@ -59,9 +64,9 @@ function makeHarness(opts?: { await flush(); } clock = target; - // Always flush, even when no timer was due: the store's handshake is a - // chain of awaits (mint race → setAuth → channel), so a caller that only - // advances the clock would otherwise observe a half-settled machine. + // Always flush, even when no timer was due: the handshake is a chain of + // awaits (mint race → setAuth → channel), so a caller that only advances + // the clock would otherwise observe a half-settled machine. await flush(); }; const flush = async () => { @@ -85,67 +90,91 @@ function makeHarness(opts?: { topic: "bool:app_x:room", })); - const deps: RoomDeps = { - mint: () => { + const schedule = (fn: () => void, ms: number) => { + const id = nextId++; + timers.push({ at: clock + ms, fn, id }); + return id; + }; + const cancel = (h: unknown) => { + const i = timers.findIndex((t) => t.id === h); + if (i !== -1) timers.splice(i, 1); + }; + const now = () => clock; + + const session = createSession({ + mint: (): Promise => { mintCalls++; - return baseMint(); + return baseMint().then((r) => + r.ok + ? { + ok: true as const, + token: r.token, + expiresIn: r.expiresIn, + topics: { app: "bool:app_x:app", user: null, room: r.topic }, + } + : r, + ); }, setAuth: () => {}, - channel: (_topic, k) => { - key = k; - const ch: RoomChannel = { - track(state, done) { - wire.presences.set(k, state); - wire.syncAll(); - done?.(trackResult); - }, - onPresence(cb) { - wire.presenceSubs.add(cb); - }, - onBroadcast(cb) { - wire.broadcastSubs.add({ selfKey: k, cb }); - }, - send(event, payload, done) { - sent.push({ event, payload }); - done?.("ok"); - // deliver to every OTHER subscriber on the wire (server self:false) - for (const sub of wire.broadcastSubs) { - if (sub.selfKey !== k) sub.cb({ event, payload }); - } - }, - join(status) { - joinCb = status; - }, - leave() { - wire.presences.delete(k); - wire.syncAll(); - }, - }; - return ch; - }, - schedule: (fn, ms) => { - const id = nextId++; - timers.push({ at: clock + ms, fn, id }); - return id; - }, - cancel: (h) => { - const i = timers.findIndex((t) => t.id === h); - if (i !== -1) timers.splice(i, 1); - }, - now: () => clock, + schedule, + cancel, + now, wake: (cb) => { wakeCbs.add(cb); return () => wakeCbs.delete(cb); }, + }); + + let lastTopic = ""; + const makeChannel = (topic: string, k: string): RoomChannel => { + lastTopic = topic; + key = k; + const ch: RoomChannel = { + track(state, done) { + wire.presences.set(k, state); + wire.syncAll(); + done?.(trackResult); + }, + onPresence(cb) { + wire.presenceSubs.add(cb); + }, + onBroadcast(cb) { + wire.broadcastSubs.add({ selfKey: k, cb }); + }, + send(event, payload, done) { + sent.push({ event, payload }); + done?.("ok"); + // deliver to every OTHER subscriber on the wire (server self:false) + for (const sub of wire.broadcastSubs) { + if (sub.selfKey !== k) sub.cb({ event, payload }); + } + }, + join(status) { + joinCb = status; + }, + leave() { + wire.presences.delete(k); + wire.syncAll(); + }, + }; + return ch; }; - const store = createRoomStore(deps); + const store = createRoomStore({ + session, + roomId: opts?.roomId ?? null, + makeChannel, + schedule, + cancel, + now, + }); return { store, wire, sent, advance, selfKey: () => key, + lastTopic: () => lastTopic, mintCalls: () => mintCalls, /** Simulate the tab/network coming back. */ wake: () => { @@ -505,15 +534,23 @@ describe("room store: recovery (the dead-but-'live' regression)", () => { expect(h.store.status()).toBe("connecting"); }); - test("a failed presence publish reconnects instead of going quiet", async () => { - // supabase-js resolves "timed out" on a dead channel — no exception to - // catch, so this is only visible if the result is inspected. + test("a failed join beacon warns but does NOT tear the room down", async () => { + // The rate-shaping lesson, pinned in BOTH directions. The old rule — + // "any failed track() forces a reconnect" — looked like healing and was: + // under load, per-move track timeouts became a reconnect storm, the UI + // flapping between "N people here" and "live view unavailable". Recovery + // that can be triggered BY load must be rate-shaped: the beacon is + // once-per-join, its failure only warns, and a genuinely dead channel + // reports its own terminal state — which the session heals (tested in + // session.test.ts and the sync-CLOSED suite below). const h = makeHarness({ trackResult: "timed out" }); const release = h.store.acquire(); await h.join(); + expect(h.store.status()).toBe("live"); // NOT torn down by the beacon result h.store.setMe({ cursor: { x: 1, y: 1 } }); await h.advance(40); - expect(h.store.status()).toBe("unavailable"); // healing, not silently dead + expect(h.store.status()).toBe("live"); // setMe rides broadcast; no track at all + expect(h.mintCalls()).toBe(1); // and no reconnect storm release(); }); @@ -659,21 +696,44 @@ describe("bool.room broadcast size limit", () => { describe("bool.room surviving supabase's synchronous CLOSED", () => { // supabase's removeChannel fires the channel's own status callback with - // CLOSED — synchronously, from inside leave(). Since the terminal-state - // handler responds to CLOSED by tearing down (which leaves), the two used to - // feed each other: teardown → leave → CLOSED → teardown → … a stack overflow - // on every real network drop. Seen in production as an endless stream of - // "Maximum call stack size exceeded" from a deployed cursor app. + // CLOSED — synchronously, from inside leave(). The pre-session machines + // recursed teardown → leave → CLOSED → teardown to a stack overflow on every + // real drop of a healthy channel (seen in production as an endless stream of + // "Maximum call stack size exceeded"). The guard lives in session.ts and is + // unit-tested there; this exercises it through the full store stack. function makeSyncCloseHarness() { let joinCb: ((s: string) => void) | null = null; let channelsMade = 0; const timers: Array<{ at: number; fn: () => void; id: number }> = []; let clock = 0; let nextId = 1; - const deps: RoomDeps = { - mint: async () => ({ ok: true, token: "t", expiresIn: 900, topic: "bool:x:room" }), + const schedule = (fn: () => void, ms: number) => { + const id = nextId++; + timers.push({ at: clock + ms, fn, id }); + return id; + }; + const cancel = (h: unknown) => { + const i = timers.findIndex((t) => t.id === h); + if (i !== -1) timers.splice(i, 1); + }; + const now = () => clock; + const session = createSession({ + mint: async () => ({ + ok: true, + token: "t", + expiresIn: 900, + topics: { app: "bool:x:app", user: null, room: "bool:x:room" }, + }), setAuth: () => {}, - channel: (): RoomChannel => { + schedule, + cancel, + now, + wake: () => () => {}, + }); + const store = createRoomStore({ + session, + roomId: null, + makeChannel: (): RoomChannel => { channelsMade++; return { track: (_s, done) => done?.("ok"), @@ -688,19 +748,10 @@ describe("bool.room surviving supabase's synchronous CLOSED", () => { }, }; }, - schedule: (fn, ms) => { - const id = nextId++; - timers.push({ at: clock + ms, fn, id }); - return id; - }, - cancel: (h) => { - const i = timers.findIndex((t) => t.id === h); - if (i !== -1) timers.splice(i, 1); - }, - now: () => clock, - wake: () => () => {}, - }; - const store = createRoomStore(deps); + schedule, + cancel, + now, + }); const advance = async (ms: number) => { const target = clock + ms; for (;;) { @@ -731,6 +782,7 @@ describe("bool.room surviving supabase's synchronous CLOSED", () => { test("a real drop does not recurse, and the room still recovers", async () => { const h = makeSyncCloseHarness(); const release = h.store.acquire(); + await h.advance(0); await h.join(); expect(h.store.status()).toBe("live"); @@ -749,6 +801,7 @@ describe("bool.room surviving supabase's synchronous CLOSED", () => { test("release() with a live channel does not recurse either", async () => { const h = makeSyncCloseHarness(); const release = h.store.acquire(); + await h.advance(0); await h.join(); release(); // teardown → leave → sync CLOSED, on the unmount path expect(h.store.status()).toBe("connecting"); @@ -825,3 +878,30 @@ describe("bool.room render economy", () => { expect(throttleForPeers(19)).toBe(48); // 20 people ≈ 21Hz — smooth, not slideshow }); }); + +describe("named rooms", () => { + test("a scoped room joins the scoped topic; the default room joins the bare one", async () => { + const base = makeHarness(); + const rd = base.store.acquire(); + await base.join(); + expect(base.lastTopic()).toBe("bool:app_x:room"); + rd(); + + const scoped = makeHarness({ roomId: "game:4" }); + const rs = scoped.store.acquire(); + await scoped.join(); + expect(scoped.lastTopic()).toBe("bool:app_x:room:game:4"); + rs(); + }); + + test("room ids are validated loudly, not normalized silently", () => { + // Silent normalization would put two "different" ids in one room. + expect(validateRoomId("game:4")).toBe("game:4"); + expect(validateRoomId("board.2-a_b")).toBe("board.2-a_b"); + expect(() => validateRoomId("")).toThrow(/1–64 characters/); + expect(() => validateRoomId("has space")).toThrow(/bool\.room\("has space"\)/); + expect(() => validateRoomId("emoji🎉")).toThrow(); + expect(() => validateRoomId("-starts-wrong")).toThrow(); + expect(() => validateRoomId("x".repeat(65))).toThrow(); + }); +}); diff --git a/src/room.ts b/src/room.ts index 07c42cb..96e4317 100644 --- a/src/room.ts +++ b/src/room.ts @@ -4,41 +4,43 @@ // everything that must survive a reload; this is for everything that only // matters while people are here together. // -// Design decisions (2026-07-29, from a three-way design review — see the -// platform's docs/2026-07-ephemeral-broadcast.md for the full record): -// - Named `room`, not `live`: "live" already means the DURABLE lane -// everywhere in this SDK (LiveEntityStore, "Live data" in the prompt), and -// a name on both sides of the durable/ephemeral split is how data ends up -// in the wrong lane. A room is who's here right now; nobody expects a room -// to survive a reload. +// This module is a PURE CONSUMER: it contains zero connection code. The +// mint/refresh/backoff/wake/teardown machinery lives in session.ts (shared +// with the entity doorbell), and this store registers one ChannelGroup per +// room. Before the split, this file carried its own copy of that machine and +// the copies drifted — see session.ts's header for the receipts. +// +// Design decisions that survived a week of production fires (full record: +// the platform's docs/2026-07-ephemeral-broadcast.md §9–§10): // - MEMBERSHIP rides Supabase presence (auto-clears on disconnect — no // hand-rolled heartbeats, no ghost cursors), but STATE rides broadcast. -// Presence is a server-side CRDT built for low-frequency state, and it -// falls over at cursor frequency: measured on real infra, 3 movers at 40Hz -// had their `track()` calls stop being acknowledged within a second (5 acks -// per mover, then 10s timeouts), while the identical 120 msg/s over -// broadcast delivered 97–100% at ~10ms, sustained. Worse, treating those -// timeouts as channel failure caused a reconnect storm — the UI flapped -// between "N people here" and "live view unavailable". So: one `track({})` -// per join for who's-here, and `setMe` state flows as throttled full-state -// broadcasts on a reserved event, fire-and-forget. Full state (not deltas) -// makes drops harmless — the next send heals everything. -// - The throttle lives HERE, not in app code: a prompt rule asking the model -// to throttle is forgettable; a setter that throttles isn't. +// Presence is a server-side CRDT built for low-frequency state and it +// froze at 3 movers × 40Hz (track acks stopped inside a second); the +// identical load over broadcast delivered 97–100% at ~10ms. So: one +// `track({})` per join for who's-here, and `setMe` state flows as +// throttled FULL-state broadcasts on the reserved `~me` event, +// fire-and-forget — drops are healed by the next send. +// - The throttle lives HERE, not in app code: a prompt rule asking the +// model to throttle is forgettable; a setter that throttles isn't. +// - Inbound coalesces to one emit per ~frame with STABLE peer identities — +// otherwise every useOthers subscriber re-renders per message, and a +// memoized cursor re-renders because someone else moved. // - Broadcast echoes to the SENDER locally and synchronously (never a round -// trip): one code path for "everyone sees the reaction, including me", -// with zero self-latency. The wire copy is filtered by sender id. +// trip): one code path for "everyone sees it, including me". // - Peer colors derive from peer ids (same hash on every client), so every // viewer agrees on everyone's color with nothing transmitted. +// - Named rooms scope the topic (`bool::room:`); the default +// room is the bare topic. Same wristband — the schema is the boundary. // -// SECURITY: this module publishes ONLY to the dedicated room topic -// (`bool::room`), never to the doorbell topics. Doorbell messages are -// merged into entity state as trusted server data; the server's send policy is -// scoped so a client cannot publish there, and this module must never try. +// SECURITY: this module publishes ONLY to the dedicated room topic family +// (`bool::room[:]`), never to the doorbell topics. Doorbell +// messages are merged into entity state as trusted server data; the server's +// send policy is scoped so a client cannot publish there, and this module +// must never try. -import { onWake } from "./wake.js"; +import type { RealtimeSession, SessionChannel, SessionStatus } from "./session.js"; -export type RoomStatus = "connecting" | "live" | "unauthorized" | "unavailable"; +export type RoomStatus = SessionStatus; /** One other person in the room. `presence` is Partial on purpose: someone who * just joined has set nothing yet, so every field read must survive absence — @@ -57,43 +59,6 @@ export type RoomEvent = { data: T; }; -/** Result of one wristband mint (same desk the doorbell uses). */ -export type RoomMintResult = - | { ok: true; token: string; expiresIn: number; topic: string | null } - | { ok: false; reason: "unauthorized" | "unavailable" }; - -/** The transport seam — everything effectful is injected so the machine and - * store are unit-testable without sockets (same discipline as realtime.ts). */ -export type RoomChannel = { - /** Publish my full presence state (Supabase `track`). Reports the outcome: - * supabase-js RESOLVES with the string "ok" | "timed out" | "error" rather - * than throwing, so a `void`-ed call swallows every failure — which is how a - * dead channel went unnoticed while the UI still said "live". */ - track(state: Record, done?: (result: string) => void): void; - /** Presence changed (sync/join/leave) — `states` is keyed by presence key. */ - onPresence(cb: (states: Record>>) => void): void; - /** Receive one broadcast envelope. */ - onBroadcast(cb: (msg: { event: string; payload: unknown }) => void): void; - /** Send one broadcast envelope. Same outcome contract as `track`. */ - send(event: string, payload: unknown, done?: (result: string) => void): void; - join(status: (state: string) => void): void; - leave(): void; -}; - -export type RoomDeps = { - mint(): Promise; - setAuth(token: string): Promise | void; - /** Create (not join) the private room channel; `key` is this tab's presence key. */ - channel(topic: string, key: string): RoomChannel; - onStatus?: (status: RoomStatus) => void; - /** Test seams; default to timers. */ - schedule?: (fn: () => void, ms: number) => unknown; - cancel?: (handle: unknown) => void; - now?: () => number; - /** Subscribe to tab/network wake signals; defaults to the DOM listeners. */ - wake?: (cb: () => void) => () => void; -}; - // State sends coalesce on a trailing edge so the FINAL position always lands. // 16ms ≈ 60/s — one send per frame, the ceiling a 60fps screen can show. const TRACK_THROTTLE_MS = 16; @@ -105,41 +70,38 @@ const ME_EVENT = "~me"; // hydrates immediately instead of waiting for everyone's next move. Jittered // so N peers don't stampede the channel in the same tick. const HYDRATE_JITTER_MS = 300; +// Inbound coalescing: a room of movers can deliver hundreds of ~me messages a +// second; one emit per ~frame is all a screen can show anyway. +const EMIT_COALESCE_MS = 16; +// Realtime rejects large messages; failing loudly here names the actual +// problem instead of a silent server-side drop. +const MAX_PAYLOAD_BYTES = 60_000; + /** Per-message fan-out is O(peers), so the whole room's wire cost grows with * peers². Scale the send interval so a big room degrades to slower cursors - * instead of a saturated channel — but gently: the first version of this curve - * (`n(n-1)` ms) throttled a 10-person room to 11Hz, and a real 9-spectator - * demo room measured p50=96ms — most of it spent waiting in our own throttle. - * "Laggy by design" is not a rate limit worth having. This curve budgets total - * fan-out at ~8000 msg/s (the tenant ceiling on Bool's user-apps projects is - * raised to 10k events/s to hold 60Hz — a fresh Supabase project defaults to - * far less, so self-hosters hit the curve sooner, not a broken room): full - * 60Hz through 10 people, ~37Hz at 15, ~21Hz at 20. */ + * instead of a saturated channel. Budgeted against the raised tenant ceiling + * on Bool's user-apps projects (10k events/s): full 60Hz through 10 people, + * ~37Hz at 15, ~21Hz at 20. */ export function throttleForPeers(othersCount: number): number { const n = othersCount + 1; const budgetMs = Math.ceil((n * (n - 1)) / 8); return Math.max(TRACK_THROTTLE_MS, budgetMs); } -// Inbound coalescing: a room of movers can deliver hundreds of ~me messages a -// second, and re-emitting per message turns every useOthers subscriber into a -// per-message re-render. One emit per ~frame is all a screen can show anyway. -const EMIT_COALESCE_MS = 16; -// Same shape/limits as the doorbell: quick first retry, capped so an outage -// costs one request a minute, refresh at 75% of the wristband TTL. -const RETRY_MS = [1_000, 5_000, 15_000, 60_000]; -// A mint that never settles would wedge the machine: no channel, no status -// change, no retry — indistinguishable from "live" to anything watching. Cap it -// and treat a stall as an ordinary unavailable, which retries. -const MINT_TIMEOUT_MS = 10_000; -const REFRESH_FRACTION = 0.75; -const MIN_REFRESH_MS = 30_000; -// Realtime rejects large messages; failing loudly here names the actual -// problem instead of a silent server-side drop. -const MAX_PAYLOAD_BYTES = 60_000; -// `focus` and `visibilitychange` fire together when a tab is re-selected, and -// `online` can pile on. Without a window, one wake would restart the handshake -// two or three times. -const WAKE_COALESCE_MS = 500; + +/** Room ids become topic suffixes, so the charset is the policy's charset. + * Loud rejection beats silent normalization: a normalized id would put two + * "different" ids in one room. */ +export const ROOM_ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.-]{0,63}$/; +export function validateRoomId(id: string): string { + if (!ROOM_ID_RE.test(id)) { + throw new Error( + `bool.room("${id}"): a room id is 1–64 characters of letters, digits, ` + + `":", "_", ".", or "-" (starting with a letter or digit). ` + + `Derive it from your own data — bool.room(\`game:\${gameId}\`).`, + ); + } + return id; +} // 12 distinguishable hues; index by a stable hash of the peer id so every // client computes the same color for the same peer. @@ -154,13 +116,10 @@ export function colorForId(id: string): string { } // The wire limit is BYTES, and `String.length` counts UTF-16 code units — so a -// string of emoji or CJK measures at roughly half to a third of what it actually -// sends. Measuring the encoded form is the only correct check; the previous -// `.length` version would wave through a payload well over the limit and let the -// server drop it silently, which is the exact failure the guard exists to name. +// string of emoji or CJK measures at roughly half to a third of what it +// actually sends. Measuring the encoded form is the only correct check. function byteLength(s: string): number { if (typeof TextEncoder !== "undefined") return new TextEncoder().encode(s).length; - // Old/exotic runtime: count UTF-8 bytes directly rather than lie. let bytes = 0; for (const ch of s) { const cp = ch.codePointAt(0)!; @@ -179,8 +138,8 @@ function newTabId(): string { export type RoomStore

> = { self: { id: string; color: string }; - /** Ref-count the machinery: first acquire connects, last release tears down. - * Every React hook acquires on mount. */ + /** Ref-count the machinery: first acquire registers with the session, last + * release unregisters. Every React hook acquires on mount. */ acquire(): () => void; status(): RoomStatus; onStatus(cb: (s: RoomStatus) => void): () => void; @@ -195,14 +154,28 @@ export type RoomStore

> = { onEvent(cb: (event: string, e: RoomEvent) => void): () => void; }; -export function createRoomStore

>(deps: RoomDeps): RoomStore

{ +export type RoomStoreDeps = { + session: RealtimeSession; + /** null = the app-wide default room; a string scopes the topic. */ + roomId: string | null; + /** Create (not join) the private room channel; `key` is this tab's presence + * key. The session joins it with its own guarded status callback. */ + makeChannel(topic: string, key: string): SessionChannel; + /** Test seams for the throttle/coalesce timers; default to timers. */ + schedule?: (fn: () => void, ms: number) => unknown; + cancel?: (handle: unknown) => void; + now?: () => number; +}; + +export function createRoomStore

>(deps: RoomStoreDeps): RoomStore

{ const schedule = deps.schedule ?? ((fn, ms) => setTimeout(fn, ms)); const cancel = deps.cancel ?? ((h) => clearTimeout(h as ReturnType)); + const now = deps.now ?? (() => Date.now()); const self = { id: newTabId(), color: "" }; self.color = colorForId(self.id); - // ---- reactive bits ------------------------------------------------------- + // ---- reactive bits -------------------------------------------------------- let others: ReadonlyArray> = []; const othersListeners = new Set<() => void>(); const emitOthers = () => { @@ -214,7 +187,6 @@ export function createRoomStore

>(deps: RoomDeps): Ro function setStatus(next: RoomStatus): void { if (state === next) return; state = next; - deps.onStatus?.(next); for (const l of statusListeners) l(next); } @@ -223,27 +195,16 @@ export function createRoomStore

>(deps: RoomDeps): Ro for (const l of eventListeners) l(event, e); }; - // ---- my state: merge + trailing-edge throttle, published over broadcast -- - const myState: Record = {}; - let trackTimer: unknown = null; - let lastTrackAt = 0; - const now = deps.now ?? (() => Date.now()); - const subscribeWake = deps.wake ?? onWake; - - // Peer state assembled from two lanes: presence gives MEMBERSHIP (who's - // connected, auto-cleared on disconnect) plus any state old-SDK peers still - // put in their presence meta; ~me broadcasts give current-SDK peers' state. - // A peer's visible presence = meta overlaid with its last ~me. Both maps are - // keyed by peer id; membership is authoritative — state without membership - // is dropped (its owner disconnected). + // ---- peers: membership (presence) ∪ state (~me broadcasts) --------------- + // Membership is authoritative — state whose owner left is dropped, which is + // what auto-clears a closed tab's cursor. Old-SDK peers still put state in + // their presence meta, so meta is overlaid under ~me state for mixed rooms. let memberMeta = new Map>(); const stateById = new Map>(); // Stable peer identities: a peer whose inputs didn't change keeps the SAME // object between rebuilds, so a memoized for a still - // person doesn't re-render because someone ELSE moved. Keyed by the exact - // references that feed the merge — a new ~me replaces the state ref, a - // presence diff replaces the meta ref. + // person doesn't re-render because someone ELSE moved. const peerCache = new Map< string, { meta: Record; state: Record | undefined; peer: RoomPeer

} @@ -253,15 +214,15 @@ export function createRoomStore

>(deps: RoomDeps): Ro const next: Array> = []; for (const [id, meta] of memberMeta) { if (id === self.id) continue; // others NEVER includes you - const state = stateById.get(id); + const peerState = stateById.get(id); const cached = peerCache.get(id); - if (cached && cached.meta === meta && cached.state === state) { + if (cached && cached.meta === meta && cached.state === peerState) { next.push(cached.peer); continue; } - const presence = { ...meta, ...state }; + const presence = { ...meta, ...peerState }; const peer: RoomPeer

= { id, color: colorForId(id), presence: presence as Partial

}; - peerCache.set(id, { meta, state, peer }); + peerCache.set(id, { meta, state: peerState, peer }); next.push(peer); } for (const id of peerCache.keys()) { @@ -292,29 +253,23 @@ export function createRoomStore

>(deps: RoomDeps): Ro }, EMIT_COALESCE_MS - elapsed); } + // ---- my state: merge + trailing-edge throttle, published over broadcast -- + const myState: Record = {}; + let channel: SessionChannel | null = null; + let joined = false; + let trackTimer: unknown = null; + let lastTrackAt = 0; + /** Publish my full state now, fire-and-forget. Full state, not a delta: * drops are healed by the next send, and a late joiner needs one message, - * not a replay. No ack — at cursor frequency, waiting on per-message acks is - * what melted the presence transport. */ + * not a replay. No ack — at cursor frequency, waiting on per-message acks + * is what melted the presence transport. */ function pushMe(): void { if (!channel || !joined) return; lastTrackAt = now(); channel.send(ME_EVENT, { f: self.id, s: { ...myState } }); } - /** A live channel turned out not to be live. Tear it down and reconnect with - * backoff, reporting the honest status on the way. Guarded so several - * failures in one generation collapse into one restart. */ - function unhealthy(why: string): void { - if (holds === 0) return; // nobody is watching; acquire() will start fresh - if (typeof console !== "undefined") { - console.warn(`bool.room: reconnecting (${why}).`); - } - gen++; - const myGen = gen; - teardown(); - retry(myGen, "unavailable"); - } function scheduleMe(): void { if (!channel || !joined) return; // replayed on join instead const throttle = throttleForPeers(others.length); @@ -340,225 +295,110 @@ export function createRoomStore

>(deps: RoomDeps): Ro }, jitter); } - // ---- connection machine (gen/backoff/refresh, doorbell-style) ------------ - let holds = 0; - let gen = 0; - let channel: RoomChannel | null = null; - let joined = false; - let timer: unknown = null; - let attempt = 0; - - function clearTimer(): void { - if (timer !== null) { - cancel(timer); - timer = null; - } - } - function teardown(): void { - // Null the reference BEFORE leaving. supabase's removeChannel fires the - // channel's own status callback with CLOSED — synchronously, from inside - // leave() — and the terminal-state handler tears down in response. With the - // old order (leave first, null after) that re-entered here while `channel` - // still pointed at the same channel: leave → CLOSED → teardown → leave → … - // a stack overflow on every real network drop. The handler's - // channel-identity guard is the other half of this fix. - const ch = channel; - channel = null; - joined = false; - ch?.leave(); - clearTimer(); - if (trackTimer !== null) { - cancel(trackTimer); - trackTimer = null; - } - if (hydrateTimer !== null) { - cancel(hydrateTimer); - hydrateTimer = null; - } - if (emitTimer !== null) { - cancel(emitTimer); - emitTimer = null; - } - memberMeta = new Map(); - stateById.clear(); - peerCache.clear(); - if (others.length > 0) { - others = []; - // Direct, not coalesced: "the room emptied" must never be absorbed into - // a cancelled trailing edge. - emitOthers(); - } - } - function retry(myGen: number, why: RoomStatus): void { - setStatus(why); - const ms = RETRY_MS[Math.min(attempt, RETRY_MS.length - 1)]!; - attempt++; - timer = schedule(() => { - if (myGen !== gen) return; - void start(myGen); - }, ms); + function clearLocalTimers(): void { + for (const t of [trackTimer, hydrateTimer, emitTimer]) if (t !== null) cancel(t); + trackTimer = hydrateTimer = emitTimer = null; } - // ---- wake: come back the instant the tab/network does -------------------- - // Waiting out the backoff after the ordinary switch-tabs-and-return case is - // what makes a working room look broken (see wake.ts). A wake resets the - // ladder and reconnects now. - let stopWake: (() => void) | null = null; - let lastWakeAt = -Infinity; - function onWakeSignal(): void { - // Nothing mounted: no connection to restore. Already live: the socket is - // fine, and one that is secretly dead reports its own close on wake and - // lands in retry() — which the NEXT wake signal, or the 1s first rung, - // picks up. Reconnecting a live room on every tab focus would churn. - if (holds === 0 || state === "live") return; - const t = now(); - if (t - lastWakeAt < WAKE_COALESCE_MS) return; - lastWakeAt = t; - gen++; // orphan the pending retry and any in-flight start - attempt = 0; - setStatus("connecting"); - void start(gen); - } - - async function start(myGen: number): Promise { - teardown(); - if (myGen !== gen) return; - - const res = await Promise.race([ - deps.mint(), - new Promise((resolve) => - schedule(() => resolve({ ok: false, reason: "unavailable" }), MINT_TIMEOUT_MS), - ), - ]); - if (myGen !== gen) return; - if (!res.ok) return retry(myGen, res.reason); - // A platform that predates the room topic can't host one — honest - // unavailability (retried: a deploy can turn it on) rather than a limp. - if (!res.topic) return retry(myGen, "unavailable"); - - await deps.setAuth(res.token); - if (myGen !== gen) return; - - const ch = deps.channel(res.topic, self.id); - ch.onPresence((states) => { - if (myGen !== gen) return; - const nextMeta = new Map>(); - let sawNewMember = false; - for (const [key, metas] of Object.entries(states)) { - // Supabase keeps one meta per connection under the key; last wins. - const meta = metas[metas.length - 1] ?? {}; - const { presence_ref: _ref, ...rest } = meta as Record; - nextMeta.set(key, rest); - if (key !== self.id && !memberMeta.has(key)) sawNewMember = true; - } - // Membership is authoritative: state whose owner left is dropped, which - // is what auto-clears a closed tab's cursor. - for (const id of stateById.keys()) { - if (!nextMeta.has(id)) stateById.delete(id); - } - memberMeta = nextMeta; - rebuildOthers(); - // Someone new arrived: re-send my state once (jittered) so they see me - // now instead of on my next move. - if (sawNewMember) scheduleHydrate(); - }); - ch.onBroadcast((msg) => { - if (myGen !== gen) return; - if (msg.event === ME_EVENT) { - const env = (msg.payload ?? {}) as { f?: string; s?: Record }; - if (!env.f || env.f === self.id) return; - // State can outrun membership by a beat (broadcast delivers before the - // presence diff); hold it either way — rebuildOthers keys off - // membership, so it shows the moment the member appears. - stateById.set(env.f, env.s ?? {}); - if (memberMeta.has(env.f)) rebuildOthers(); - return; - } - const env = (msg.payload ?? {}) as { f?: string; d?: unknown }; - if (env.f === self.id) return; // already delivered locally, synchronously - dispatch(msg.event, { from: env.f ?? "", data: env.d }); - }); - // Current BEFORE join() is registered: the status callback guards on - // channel identity, and a callback that fired before the assignment would - // wrongly see itself as stale. - channel = ch; - ch.join((s) => { - // The identity check (not just gen) is load-bearing: teardown() nulls - // `channel` and then leave() makes THIS callback fire CLOSED, same tick, - // same gen. Without the check that re-entered the terminal branch below - // and recursed teardown → leave → CLOSED → teardown to a stack overflow - // on every real network drop. - if (myGen !== gen || channel !== ch) return; - if (s === "SUBSCRIBED") { - joined = true; - attempt = 0; - setStatus("live"); - // ONE membership beacon per join — presence carries who's-here, never - // state (see the transport note at the top of this file). This is the - // only track() the store ever issues, so its failure genuinely means - // the channel is broken and healing is right; at one-per-join it can - // never melt into the reconnect storm the per-move version caused. - ch.track({}, (result) => { - if (result !== "ok" && myGen === gen) unhealthy("presence join failed"); - }); - // Join/rejoin replays my current state so a reconnect (or a late - // first join) never leaves me invisible. - pushMe(); - } else if (s === "CHANNEL_ERROR" || s === "TIMED_OUT" || s === "CLOSED") { - // CLOSED was previously unhandled, which meant a socket that dropped - // (network blip, server close, token expiry, a StrictMode teardown - // racing a re-subscribe) left the room permanently dead while the last - // reported status stayed "live". supabase-js does NOT auto-rejoin, so - // every terminal state must drive recovery. Verified against a real - // close: the status arrives as CHANNEL_ERROR "socket closed: 1005" - // with no follow-up SUBSCRIBED. - joined = false; - teardown(); - retry(myGen, "unavailable"); + // ---- the channel group (all connection logic lives in the session) ------- + const group = { + topics(t: { room: string | null }): string[] { + // A platform that predates the room lane has no room topic; returning [] + // makes the session report this group "unavailable" (retried on every + // reconnect, so a platform deploy turns it on without a reload). + if (!t.room) return []; + return [deps.roomId === null ? t.room : `${t.room}:${deps.roomId}`]; + }, + open(topic: string): SessionChannel { + const ch = deps.makeChannel(topic, self.id); + ch.onPresence((states) => { + const nextMeta = new Map>(); + let sawNewMember = false; + for (const [key, metas] of Object.entries(states)) { + // Supabase keeps one meta per connection under the key; last wins. + const meta = metas[metas.length - 1] ?? {}; + const { presence_ref: _ref, ...rest } = meta as Record; + nextMeta.set(key, rest); + if (key !== self.id && !memberMeta.has(key)) sawNewMember = true; + } + for (const id of stateById.keys()) { + if (!nextMeta.has(id)) stateById.delete(id); + } + memberMeta = nextMeta; + rebuildOthers(); + // Someone new arrived: re-send my state once (jittered) so they see + // me now instead of on my next move. + if (sawNewMember) scheduleHydrate(); + }); + ch.onBroadcast((msg) => { + if (msg.event === ME_EVENT) { + const env = (msg.payload ?? {}) as { f?: string; s?: Record }; + if (!env.f || env.f === self.id) return; + // State can outrun membership by a beat (broadcast delivers before + // the presence diff); hold it either way — rebuildOthers keys off + // membership, so it shows the moment the member appears. + stateById.set(env.f, env.s ?? {}); + if (memberMeta.has(env.f)) rebuildOthers(); + return; + } + const env = (msg.payload ?? {}) as { f?: string; d?: unknown }; + if (env.f === self.id) return; // already delivered locally, synchronously + dispatch(msg.event, { from: env.f ?? "", data: env.d }); + }); + channel = ch; + return ch; + }, + onJoin(): void { + joined = true; + // ONE membership beacon per join — presence carries who's-here, never + // state. At one-per-join a failure can't melt into the reconnect storm + // the per-move version caused; a genuinely dead channel reports its own + // terminal state, which the session heals. + channel?.track({}, (result) => { + if (result !== "ok" && typeof console !== "undefined") { + console.warn("bool.room: presence join beacon failed; peers may not see you until the next reconnect."); + } + }); + // Join/rejoin replays my current state so a reconnect (or a late first + // join) never leaves me invisible. + pushMe(); + }, + onDown(): void { + channel = null; + joined = false; + clearLocalTimers(); + memberMeta = new Map(); + stateById.clear(); + peerCache.clear(); + if (others.length > 0) { + others = []; + // Direct, not coalesced: "the room emptied" must never be absorbed + // into a cancelled trailing edge. + emitOthers(); } - }); - scheduleRefresh(myGen, res.expiresIn); - } + }, + onStatus(s: SessionStatus): void { + setStatus(s); + }, + }; - function scheduleRefresh(myGen: number, expiresInSeconds: number): void { - const ms = Math.max(expiresInSeconds * 1000 * REFRESH_FRACTION, MIN_REFRESH_MS); - const prev = timer; - timer = schedule(async () => { - if (myGen !== gen) return; - const res = await deps.mint(); - if (myGen !== gen) return; - if (!res.ok) return retry(myGen, res.reason); - await deps.setAuth(res.token); - if (myGen !== gen) return; - scheduleRefresh(myGen, res.expiresIn); - }, ms); - if (prev !== null && prev !== timer) cancel(prev); - } + // ---- public surface -------------------------------------------------------- + let holds = 0; + let unregister: (() => void) | null = null; return { self, acquire() { holds++; - if (holds === 1) { - gen++; - attempt = 0; - setStatus("connecting"); - stopWake = subscribeWake(onWakeSignal); - void start(gen); - } + if (holds === 1) unregister = deps.session.register(group); let released = false; return () => { if (released) return; released = true; holds--; if (holds === 0) { - gen++; - stopWake?.(); - stopWake = null; - teardown(); - // setStatus, never a bare assignment: a direct write leaves every - // useSyncExternalStore subscriber holding a stale snapshot, which is - // exactly how a torn-down room kept rendering "live" forever. + unregister?.(); + unregister = null; setStatus("connecting"); } }; @@ -659,6 +499,13 @@ export type BoolRoom = { useStatus(): RoomStatus; }; +/** The callable default room: `bool.room.useOthers()` is the app-wide room; + * `bool.room("game:4")` is a scoped one with the identical surface. Same id → + * same room instance (and one shared socket underneath them all). */ +export type BoolRoomApi = BoolRoom & { + (id: string): BoolRoom; +}; + type RoomHooksImpl = { useOthers(store: RoomStore>): ReadonlyArray>>; useSetMe(store: RoomStore>): (patch: Record) => void; @@ -720,3 +567,28 @@ export function createBoolRoom(store: RoomStore>): BoolR }, }; } + +/** Build the callable `bool.room`: the default room's members on the function + * itself, scoped rooms (memoized by id) on invocation. */ +export function createBoolRoomApi( + defaultRoom: BoolRoom, + storeFor: (id: string) => RoomStore>, +): BoolRoomApi { + const scoped = new Map(); + const fn = ((id: string): BoolRoom => { + validateRoomId(id); + let room = scoped.get(id); + if (!room) { + room = createBoolRoom(storeFor(id)); + scoped.set(id, room); + } + return room; + }) as BoolRoomApi; + fn.self = defaultRoom.self; + fn.useOthers = defaultRoom.useOthers; + fn.useSetMe = defaultRoom.useSetMe; + fn.broadcast = defaultRoom.broadcast; + fn.useEventListener = defaultRoom.useEventListener; + fn.useStatus = defaultRoom.useStatus; + return fn; +} diff --git a/src/session.test.ts b/src/session.test.ts new file mode 100644 index 0000000..e8f60ed --- /dev/null +++ b/src/session.test.ts @@ -0,0 +1,368 @@ +import { describe, expect, test } from "bun:test"; +import { + createSession, + type ChannelGroup, + type SessionChannel, + type SessionMintResult, + type SessionStatus, +} from "./session"; + +// One test per scar. Each of these failures happened in production during the +// week the two pre-session machines existed; the session exists so each guard +// is written (and tested) exactly once. + +const TOPICS = { app: "bool:app_x:app", user: "bool:app_x:user:u1", room: "bool:app_x:room" }; + +function makeHarness(opts?: { + mints?: SessionMintResult[]; + /** leave() fires the join callback with CLOSED synchronously — what + * supabase's removeChannel actually does (verified against the real client). */ + syncClosedOnLeave?: boolean; +}) { + const mints = [...(opts?.mints ?? [])]; + let clock = 0; + const timers: Array<{ at: number; fn: () => void; id: number }> = []; + let nextId = 1; + const wakeCbs = new Set<() => void>(); + const h = { + mintCalls: 0, + authed: [] as string[], + channels: [] as Array<{ + topic: string; + joinCb: ((s: string) => void) | null; + left: boolean; + sent: Array<{ event: string; payload: unknown }>; + }>, + statuses: [] as SessionStatus[], + clockNow: () => clock, + wake: () => { + for (const cb of [...wakeCbs]) cb(); + }, + wakeSubs: () => wakeCbs.size, + /** Run everything due within the next `ms` virtual milliseconds. */ + advance: async (ms: number) => { + const target = clock + ms; + for (;;) { + timers.sort((a, b) => a.at - b.at); + const due = timers[0]; + if (!due || due.at > target) break; + clock = due.at; + timers.shift(); + due.fn(); + for (let i = 0; i < 20; i++) await Promise.resolve(); + } + clock = target; + for (let i = 0; i < 20; i++) await Promise.resolve(); + }, + live: () => h.channels.filter((c) => !c.left), + }; + + const session = createSession({ + async mint() { + h.mintCalls++; + return mints.length + ? mints.shift()! + : { ok: true, token: `tok-${h.mintCalls}`, expiresIn: 900, topics: TOPICS }; + }, + setAuth(token) { + h.authed.push(token); + }, + onStatus: (s) => h.statuses.push(s), + schedule: (fn, ms) => { + const id = nextId++; + timers.push({ at: clock + ms, fn, id }); + return id; + }, + cancel: (handle) => { + const i = timers.findIndex((t) => t.id === handle); + if (i !== -1) timers.splice(i, 1); + }, + now: () => clock, + wake: (cb) => { + wakeCbs.add(cb); + return () => wakeCbs.delete(cb); + }, + }); + + function makeGroup( + topicsFor: (t: typeof TOPICS) => string[], + hooks?: Partial>, + ): ChannelGroup { + return { + topics: topicsFor as ChannelGroup["topics"], + open(topic): SessionChannel { + const rec = { + topic, + joinCb: null as ((s: string) => void) | null, + left: false, + sent: [] as Array<{ event: string; payload: unknown }>, + }; + h.channels.push(rec); + return { + track: (_s, done) => done?.("ok"), + onPresence: () => {}, + onBroadcast: () => {}, + send: (event, payload, done) => { + rec.sent.push({ event, payload }); + done?.("ok"); + }, + join: (cb) => { + rec.joinCb = cb; + }, + leave: () => { + rec.left = true; + if (opts?.syncClosedOnLeave) rec.joinCb?.("CLOSED"); + }, + }; + }, + ...hooks, + }; + } + + /** Drive every un-joined channel to SUBSCRIBED. */ + const joinAll = async () => { + for (let i = 0; i < 50 && h.live().some((c) => !c.joinCb); i++) await Promise.resolve(); + for (const c of h.live()) c.joinCb?.("SUBSCRIBED"); + await Promise.resolve(); + }; + + return { session, h, makeGroup, joinAll }; +} + +describe("session lifecycle", () => { + test("mints once, auths, opens every group's channels, joins them", async () => { + const { session, h, makeGroup, joinAll } = makeHarness(); + const doorbell = makeGroup((t) => [t.app, t.user!]); + const room = makeGroup((t) => [t.room!]); + const off1 = session.register(doorbell); + const off2 = session.register(room); + await h.advance(0); + await joinAll(); + expect(h.mintCalls).toBe(1); // ONE mint serves every consumer + expect(h.authed).toEqual(["tok-1"]); + expect(h.live().map((c) => c.topic)).toEqual([ + "bool:app_x:app", + "bool:app_x:user:u1", + "bool:app_x:room", + ]); + expect(session.status()).toBe("live"); + off1(); + off2(); + }); + + test("a group registered mid-flight rides the current mint (no second handshake)", async () => { + const { session, h, makeGroup, joinAll } = makeHarness(); + const off1 = session.register(makeGroup((t) => [t.app])); + await h.advance(0); + await joinAll(); + const before = h.mintCalls; + const off2 = session.register(makeGroup((t) => [`${t.room!}:board-2`])); + await h.advance(0); + expect(h.mintCalls).toBe(before); // opened a second named room for free + expect(h.live().map((c) => c.topic)).toContain("bool:app_x:room:board-2"); + off1(); + off2(); + }); + + test("a group whose topic the mint lacks reports unavailable, not a limp", async () => { + const { session, h, makeGroup, joinAll } = makeHarness({ + mints: [ + { ok: true, token: "t", expiresIn: 900, topics: { ...TOPICS, room: null } }, + ], + }); + const seen: SessionStatus[] = []; + const room = makeGroup((t) => (t.room ? [t.room] : []), { onStatus: (s) => seen.push(s) }); + const off = session.register(room); + await h.advance(0); + await joinAll(); + expect(seen.at(-1)).toBe("unavailable"); + off(); + }); + + test("unregistering one group closes only its channels", async () => { + const { session, h, makeGroup, joinAll } = makeHarness(); + const off1 = session.register(makeGroup((t) => [t.app])); + const off2 = session.register(makeGroup((t) => [t.room!])); + await h.advance(0); + await joinAll(); + off2(); + expect(h.live().map((c) => c.topic)).toEqual(["bool:app_x:app"]); + expect(session.status()).toBe("live"); // the other consumer is untouched + off1(); + }); +}); + +describe("scar: synchronous CLOSED from inside leave()", () => { + // supabase's removeChannel fires the channel's status callback with CLOSED + // synchronously. The pre-session machines recursed teardown → leave → CLOSED + // → teardown to a stack overflow on every real drop of a healthy channel. + test("a real drop does not recurse, and the session still recovers", async () => { + const { session, h, makeGroup, joinAll } = makeHarness({ syncClosedOnLeave: true }); + const off = session.register(makeGroup((t) => [t.app])); + await h.advance(0); + await joinAll(); + expect(session.status()).toBe("live"); + + h.live()[0]!.joinCb!("CLOSED"); // pre-fix: RangeError, max call stack + expect(session.status()).toBe("unavailable"); + + const channelsBefore = h.channels.length; + await h.advance(1_000); // first retry rung + expect(h.channels.length).toBe(channelsBefore + 1); // fresh channel opened + await joinAll(); + expect(session.status()).toBe("live"); + off(); + }); + + test("unregister with a live channel does not recurse either, and stays quiet", async () => { + const { session, h, makeGroup, joinAll } = makeHarness({ syncClosedOnLeave: true }); + const off = session.register(makeGroup((t) => [t.app])); + await h.advance(0); + await joinAll(); + off(); // teardown → leave → sync CLOSED, on the unmount path + const before = h.channels.length; + await h.advance(120_000); + expect(h.channels.length).toBe(before); // nothing keeps retrying + }); +}); + +describe("scar: waking up", () => { + test("a wake reconnects immediately instead of waiting out the backoff", async () => { + const { session, h, makeGroup, joinAll } = makeHarness(); + const off = session.register(makeGroup((t) => [t.app])); + await h.advance(0); + await joinAll(); + // drop, then keep dropping every fresh channel so the ladder climbs to + // the 60s rung + h.live()[0]!.joinCb!("CLOSED"); + for (let i = 0; i < 3; i++) { + await h.advance(60_000); // the pending rung fires, a fresh channel opens + const fresh = h.live()[0]; + fresh?.joinCb?.("CLOSED"); + await h.advance(0); + } + expect(session.status()).toBe("unavailable"); + const before = h.mintCalls; + h.wake(); // no clock advance: must not be waiting on any timer + await h.advance(0); + expect(h.mintCalls).toBe(before + 1); + await joinAll(); + expect(session.status()).toBe("live"); + off(); + }); + + test("a wake while live does nothing, and simultaneous wakes coalesce", async () => { + const { session, h, makeGroup, joinAll } = makeHarness(); + const off = session.register(makeGroup((t) => [t.app])); + await h.advance(0); + await joinAll(); + const atLive = h.mintCalls; + h.wake(); + await h.advance(0); + expect(h.mintCalls).toBe(atLive); // no churn on tab focus + + h.live()[0]!.joinCb!("CLOSED"); + await h.advance(0); + const beforeWakes = h.mintCalls; + h.wake(); + h.wake(); + h.wake(); // focus + visibilitychange + online, same instant + await h.advance(0); + expect(h.mintCalls).toBe(beforeWakes + 1); + off(); + }); + + test("wake listeners are released with the last group (no zombie reconnects)", async () => { + const { session, h, makeGroup } = makeHarness(); + expect(h.wakeSubs()).toBe(0); + const off1 = session.register(makeGroup((t) => [t.app])); + const off2 = session.register(makeGroup((t) => [t.room!])); + expect(h.wakeSubs()).toBe(1); // one subscription for the whole session + off1(); + expect(h.wakeSubs()).toBe(1); + off2(); + expect(h.wakeSubs()).toBe(0); + const before = h.mintCalls; + h.wake(); + await h.advance(100); + expect(h.mintCalls).toBe(before); + }); +}); + +describe("scar: the wristband desk", () => { + test("a hung mint cannot wedge the session forever", async () => { + const { session, h, makeGroup } = makeHarness({ + mints: [new Promise(() => {}) as never], // never settles + }); + // a mint that never resolves isn't representable via the mints queue; + // inject directly instead: + const s2 = createSession({ + mint: () => new Promise(() => {}), + setAuth: () => {}, + schedule: (fn, ms) => setTimeout(fn, ms / 1000), // compress time 1000× + cancel: (h2) => clearTimeout(h2 as ReturnType), + wake: () => () => {}, + }); + const off = s2.register(makeGroup((t) => [t.app])); + await new Promise((r) => setTimeout(r, 25)); // > compressed MINT_TIMEOUT + expect(s2.status()).toBe("unavailable"); + off(); + void session; + void h; + }); + + test("refresh re-auths in place — channels do NOT rejoin", async () => { + const { session, h, makeGroup, joinAll } = makeHarness(); + const off = session.register(makeGroup((t) => [t.app])); + await h.advance(0); + await joinAll(); + const channelsBefore = h.channels.length; + await h.advance(900 * 1000 * 0.75 + 1); // the refresh rung + expect(h.authed).toEqual(["tok-1", "tok-2"]); + expect(h.channels.length).toBe(channelsBefore); // same channels, new token + expect(session.status()).toBe("live"); + off(); + }); + + test("a refused re-mint drops every channel and reports unauthorized", async () => { + // The TTL expiring IS the revocation mechanism: once the desk stops + // issuing wristbands the socket must go quiet, not keep listening. + const { session, h, makeGroup, joinAll } = makeHarness({ + mints: [ + { ok: true, token: "tok-1", expiresIn: 900, topics: TOPICS }, + { ok: false, reason: "unauthorized" }, + ], + }); + const off = session.register(makeGroup((t) => [t.app, t.user!])); + await h.advance(0); + await joinAll(); + await h.advance(900 * 1000 * 0.75 + 1); + expect(h.live()).toHaveLength(0); + expect(session.status()).toBe("unauthorized"); + off(); + }); + + test("an unreachable desk reports unavailable — NEVER unauthorized", async () => { + // A public app admits every visitor; a dropped request must not be + // presented as "you aren't allowed to watch this". + const { session, h, makeGroup } = makeHarness({ + mints: [{ ok: false, reason: "unavailable" }], + }); + const off = session.register(makeGroup((t) => [t.app])); + await h.advance(0); + expect(session.status()).toBe("unavailable"); + off(); + }); + + test("the last unregister NOTIFIES the status reset (no stale live snapshot)", async () => { + const { session, h, makeGroup, joinAll } = makeHarness(); + const off = session.register(makeGroup((t) => [t.app])); + await h.advance(0); + await joinAll(); + expect(session.status()).toBe("live"); + h.statuses.length = 0; + off(); + expect(session.status()).toBe("connecting"); + expect(h.statuses).toEqual(["connecting"]); // the notification, not just the field + }); +}); diff --git a/src/session.ts b/src/session.ts new file mode 100644 index 0000000..b0432f2 --- /dev/null +++ b/src/session.ts @@ -0,0 +1,328 @@ +// RealtimeSession — the ONE connection machine for everything realtime. +// +// Before this file existed, the entity doorbell (realtime.ts) and the room +// (room.ts) each carried their own copy of the connection lifecycle: mint a +// wristband, present it, open channels, refresh before the TTL, back off on +// failure, reconnect on wake, tear down without racing yourself. The copies +// drifted, and in one 24-hour stretch (2026-07-29) the two worst production +// bugs — the synchronous-CLOSED teardown recursion and the missing +// wake-reconnect — each had to be diagnosed and fixed TWICE, once per copy. +// This file is those fixes written once. The doorbell and every room are now +// pure consumers that register a ChannelGroup and contain zero connection code. +// +// Every guard below is a scar from a specific production failure. Deleting one +// re-earns it the hard way; the comment on each names the failure it prevents. + +import { onWake } from "./wake.js"; + +export type SessionStatus = "connecting" | "live" | "unauthorized" | "unavailable"; + +/** The topics one wristband admits. `user` is null for anonymous visitors; + * `room` is null on a platform that predates the room lane. */ +export type RealtimeTopics = { + app: string; + user: string | null; + room: string | null; +}; + +export type SessionMintResult = + | { ok: true; token: string; expiresIn: number; topics: RealtimeTopics } + | { ok: false; reason: "unauthorized" | "unavailable" }; + +/** The transport seam for one channel — everything effectful is injected so + * the machine is unit-testable without sockets. Consumers create the channel + * (they know their own presence/broadcast config); the session owns join, + * teardown, and every status transition. */ +export type SessionChannel = { + track(state: Record, done?: (result: string) => void): void; + onPresence(cb: (states: Record>>) => void): void; + onBroadcast(cb: (msg: { event: string; payload: unknown }) => void): void; + send(event: string, payload: unknown, done?: (result: string) => void): void; + join(status: (state: string) => void): void; + leave(): void; +}; + +/** One consumer's channels. The doorbell is one group (app + user topics); + * every room — default or named — is its own group. Groups share the session's + * single mint/auth/socket lifecycle. */ +export type ChannelGroup = { + /** Which topics this group wants under the current mint. Return [] when the + * mint can't serve this group (e.g. no room topic on an old platform) — the + * group is reported "unavailable" and re-asked on every reconnect/refresh, + * so a platform deploy can turn it on without a reload. */ + topics(t: RealtimeTopics): string[]; + /** Create ONE channel for `topic` and attach message handlers. Do NOT join — + * the session joins with its own guarded status callback. */ + open(topic: string): SessionChannel; + /** One of this group's channels reached SUBSCRIBED (initial join or rejoin + * after a drop). Re-publish anything a fresh channel must carry (the room's + * membership beacon + current state live here). */ + onJoin?(ch: SessionChannel, topic: string): void; + /** This group's channels are gone (teardown, restart, or unregister). */ + onDown?(): void; + /** This GROUP's delivery status: the session's status, degraded to + * "connecting" until every channel this group asked for has joined, and to + * "unavailable" when the mint has no topic for it. */ + onStatus?(s: SessionStatus): void; +}; + +export type SessionDeps = { + mint(): Promise; + setAuth(token: string): Promise | void; + onStatus?: (s: SessionStatus) => void; + /** Test seams; default to timers/Date/DOM wake listeners. */ + schedule?: (fn: () => void, ms: number) => unknown; + cancel?: (handle: unknown) => void; + now?: () => number; + wake?: (cb: () => void) => () => void; +}; + +export type RealtimeSession = { + /** Add a group. Ref-counted: the first registration starts the machine, the + * last unregister stops it. Registering mid-flight opens the group's + * channels under the current mint immediately. Returns unregister. */ + register(group: ChannelGroup): () => void; + status(): SessionStatus; + onStatus(cb: (s: SessionStatus) => void): () => void; +}; + +// Backoff after a refused/failed mint or a dropped channel: quick first retry, +// capped so a long outage costs one request a minute rather than a hot loop. +const RETRY_MS = [1_000, 5_000, 15_000, 60_000]; +// A mint that never settles would wedge the machine — no channel, no status +// change, no retry — indistinguishable from "live" to anything watching. Cap +// it and treat a stall as an ordinary "unavailable", which retries. +const MINT_TIMEOUT_MS = 10_000; +// Re-mint at 75% of the wristband TTL: early enough that a slow mint never +// races token expiry (which closes channels underneath us), late enough that +// refresh traffic stays negligible. +const REFRESH_FRACTION = 0.75; +// A desk that answers with a nonsense TTL must not melt into a mint loop. +const MIN_REFRESH_MS = 30_000; +// `focus` + `visibilitychange` fire together on tab re-selection, and `online` +// can pile on; one wake must be one reconnect, not three. +const WAKE_COALESCE_MS = 500; + +type OpenChannel = { ch: SessionChannel; topic: string; joined: boolean }; + +export function createSession(deps: SessionDeps): RealtimeSession { + const schedule = deps.schedule ?? ((fn, ms) => setTimeout(fn, ms)); + const cancel = deps.cancel ?? ((h) => clearTimeout(h as ReturnType)); + const now = deps.now ?? (() => Date.now()); + const subscribeWake = deps.wake ?? onWake; + + // One "generation" per start(); every async continuation and every channel + // status callback checks it, so a teardown or restart orphans in-flight work + // instead of racing it. + let gen = 0; + let attempt = 0; + let timer: unknown = null; + let state: SessionStatus = "connecting"; + let topics: RealtimeTopics | null = null; + + const groups = new Set(); + const open = new Map(); + + const statusListeners = new Set<(s: SessionStatus) => void>(); + function setStatus(next: SessionStatus): void { + if (state === next) return; + // setStatus, never a bare assignment: a direct write leaves every + // subscriber holding the stale value — the "torn-down but still says + // live" bug, found independently in BOTH pre-session machines. + state = next; + deps.onStatus?.(next); + for (const l of statusListeners) l(next); + for (const g of groups) emitGroupStatus(g); + } + + /** A group's status is the session's, degraded by its own facts: no topic in + * the mint → unavailable; channels not all joined yet → connecting. */ + function groupStatus(g: ChannelGroup): SessionStatus { + if (state !== "live") return state; + const chans = open.get(g); + if (!chans) return "connecting"; + if (chans.length === 0) return "unavailable"; // mint has no topic for it + return chans.every((c) => c.joined) ? "live" : "connecting"; + } + function emitGroupStatus(g: ChannelGroup): void { + g.onStatus?.(groupStatus(g)); + } + + function clearTimer(): void { + if (timer !== null) { + cancel(timer); + timer = null; + } + } + + /** Close one group's channels. Detach BEFORE leaving: supabase's + * removeChannel fires the channel's own status callback with CLOSED — + * synchronously, from inside leave() — and the terminal-state handler tears + * down in response. Leave-then-detach recursed teardown → leave → CLOSED → + * teardown to a stack overflow on every real network drop (observed in + * production as an endless "Maximum call stack size exceeded"). The + * membership check in the join callback is the other half of this fix. */ + function closeGroup(g: ChannelGroup): void { + const chans = open.get(g); + if (!chans) return; + open.delete(g); + for (const c of chans) c.ch.leave(); + g.onDown?.(); + } + + function teardownAll(): void { + for (const g of [...open.keys()]) closeGroup(g); + clearTimer(); + } + + function retry(myGen: number, why: SessionStatus): void { + setStatus(why); + const ms = RETRY_MS[Math.min(attempt, RETRY_MS.length - 1)]!; + attempt++; + timer = schedule(() => { + if (myGen !== gen) return; + void start(myGen); + }, ms); + } + + /** Open one group's channels under the current mint and join them. Safe to + * call for a group registered mid-flight. */ + function openGroup(g: ChannelGroup, myGen: number): void { + if (!topics || open.has(g)) return; + const wanted = g.topics(topics); + const chans: OpenChannel[] = []; + open.set(g, chans); + for (const topic of wanted) { + const ch = g.open(topic); + const entry: OpenChannel = { ch, topic, joined: false }; + chans.push(entry); + ch.join((s) => { + // Membership, not just generation: closeGroup() empties the group's + // entry and then leave() makes THIS callback fire CLOSED in the same + // tick with the same gen. Without the check, that re-entered the + // terminal branch and recursed to a stack overflow. + if (myGen !== gen || open.get(g) !== chans) return; + if (s === "SUBSCRIBED") { + entry.joined = true; + attempt = 0; // a good join resets the backoff ladder + setStatus("live"); + emitGroupStatus(g); + g.onJoin?.(ch, topic); + } else if (s === "CHANNEL_ERROR" || s === "TIMED_OUT" || s === "CLOSED") { + // Every terminal state must drive recovery: supabase-js does NOT + // auto-rejoin, and leaving one unhandled (CLOSED, originally) meant + // a dropped socket left delivery dead forever while the last + // reported status stayed "live". One channel down restarts the + // whole session: everything shares one socket and one token, so + // partial restarts buy complexity, not resilience. + teardownAll(); + retry(myGen, "unavailable"); + } + }); + } + emitGroupStatus(g); + } + + async function start(myGen: number): Promise { + teardownAll(); + if (myGen !== gen) return; + + const res = await Promise.race([ + deps.mint(), + new Promise((resolve) => + schedule(() => resolve({ ok: false, reason: "unavailable" }), MINT_TIMEOUT_MS), + ), + ]); + if (myGen !== gen) return; + // No wristband: nothing is live. Report WHICH kind of "no" — a public app + // where every visitor is normally admitted must never be told it's + // unauthorized because of a dropped request — and retry. Never fake it. + if (!res.ok) return retry(myGen, res.reason); + + await deps.setAuth(res.token); + if (myGen !== gen) return; + + topics = res.topics; + setStatus("live"); // mint-level; groups stay "connecting" until joined + for (const g of groups) openGroup(g, myGen); + scheduleRefresh(myGen, res.expiresIn); + } + + function scheduleRefresh(myGen: number, expiresInSeconds: number): void { + const ms = Math.max(expiresInSeconds * 1000 * REFRESH_FRACTION, MIN_REFRESH_MS); + timer = schedule(async () => { + if (myGen !== gen) return; + const res = await deps.mint(); + if (myGen !== gen) return; + // Renewal refused — access revoked, or the gateway is down. The TTL + // expiring IS the revocation mechanism, so drop the channels. + if (!res.ok) { + teardownAll(); + return retry(myGen, res.reason); + } + await deps.setAuth(res.token); // channels re-auth in place, no rejoin + if (myGen !== gen) return; + topics = res.topics; + scheduleRefresh(myGen, res.expiresIn); + }, ms); + } + + // ---- wake: come back the instant the tab/network does --------------------- + // Browsers throttle background tabs and drop idle sockets; the drop is + // reported honestly, but waiting out a [1s,5s,15s,60s] ladder after the + // ordinary switch-tabs-and-return means up to a minute of "offline" on a + // page the user is actively looking at — indistinguishable from broken. + let stopWake: (() => void) | null = null; + let lastWakeAt = -Infinity; + function onWakeSignal(): void { + // Already live: the socket is fine — reconnecting on every tab focus would + // churn. A socket that is secretly dead reports its own terminal state, + // which lands in retry(), which the next wake (or the 1s rung) picks up. + if (groups.size === 0 || state === "live") return; + const t = now(); + if (t - lastWakeAt < WAKE_COALESCE_MS) return; + lastWakeAt = t; + gen++; // orphan the pending retry and any in-flight start + attempt = 0; + setStatus("connecting"); + void start(gen); + } + + return { + register(group) { + groups.add(group); + if (groups.size === 1) { + gen++; + attempt = 0; + setStatus("connecting"); + stopWake = subscribeWake(onWakeSignal); + void start(gen); + } else if (state === "live") { + // Joined mid-flight (a second room opened): ride the current mint. + openGroup(group, gen); + } else { + emitGroupStatus(group); + } + let released = false; + return () => { + if (released) return; + released = true; + if (!groups.delete(group)) return; + closeGroup(group); + if (groups.size === 0) { + gen++; // orphan any in-flight start/refresh + stopWake?.(); + stopWake = null; + teardownAll(); + topics = null; + setStatus("connecting"); + } + }; + }, + status: () => state, + onStatus(cb) { + statusListeners.add(cb); + return () => statusListeners.delete(cb); + }, + }; +}