diff --git a/package-lock.json b/package-lock.json index 38ede08..1fec8fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "axios": "^1.18.1", + "partysocket": "^0.0.23", "socket.io-client": "^4.8.3", "uuid": "^13.0.2" }, @@ -722,9 +723,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -742,9 +740,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -762,9 +757,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -782,9 +774,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -802,9 +791,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -822,9 +808,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2704,6 +2687,18 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-6.0.2.tgz", + "integrity": "sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -3879,9 +3874,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3903,9 +3895,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3927,9 +3916,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3951,9 +3937,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4454,6 +4437,15 @@ "node": ">=6" } }, + "node_modules/partysocket": { + "version": "0.0.23", + "resolved": "https://registry.npmjs.org/partysocket/-/partysocket-0.0.23.tgz", + "integrity": "sha512-S43vtjJ///wvzxf0Pw8yD4HVGUDiJSmP1tJQjEzYUG6L7Id63+1CTcgZ0FXTy5BGHe8CuKNyO6bYd4LpOcy4QQ==", + "license": "ISC", + "dependencies": { + "event-target-shim": "^6.0.2" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", diff --git a/package.json b/package.json index ccc94c8..cbd4b42 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ }, "dependencies": { "axios": "^1.18.1", + "partysocket": "^0.0.23", "socket.io-client": "^4.8.3", "uuid": "^13.0.2" }, diff --git a/src/actor.ts b/src/actor.ts new file mode 100644 index 0000000..69fe2e7 --- /dev/null +++ b/src/actor.ts @@ -0,0 +1,113 @@ +/** + * Type-only base class for Actors. + * + * Import and extend this in your actor files: + * import { Actor } from "@base44/sdk"; + * export class MyActor extends Actor { ... } + * + * At deploy time the bundler replaces this import with the compiled + * Cloudflare Durable Object implementation — this file provides types only. + */ + +import type { Base44Client } from "./client"; + +/** + * A single client connection. `Send` is the message type this connection accepts + * via {@link send} — the actor's *outgoing* (server→client) messages. + */ +export interface Conn { + /** Unique per-connection id (one per socket/tab), the same value the client + * receives from `subscribe()`. Use this — not userId — to identify a distinct + * client, so multiple tabs of the same user are separate connections. */ + id: string; + userId: string; + appId: string; + instanceId: string; + send(data: Send): void; + reject(code: number, reason: string): void; +} + +export interface Storage { + get(key: string): Promise; + put(key: string, value: unknown): Promise; + delete(key: string): Promise; + /** Wipe the room's entire persisted storage (match-end cleanup). Safe: a + * later rejoin re-bootstraps exactly like a brand-new room. */ + deleteAll(): Promise; +} + +/** + * Base class for an Actor. + * + * @typeParam Incoming - messages this actor *receives* from clients + * (`handleMessage`'s `msg`) — the schema's `toServer` section. + * @typeParam Outgoing - messages this actor *sends* to clients + * (`conn.send`/`broadcast`) — the schema's `toClient` section. + * + * With a generated `schema.jsonc`, wire both from the registry so they can't drift + * from the client's types: + * ```ts + * type Reg = ActorRegistry["MyActor"]; + * class MyActor extends Actor { ... } + * ``` + */ +export abstract class Actor { + abstract handleConnect(conn: Conn): void | Promise; + abstract handleMessage(conn: Conn, msg: Incoming): void | Promise; + abstract handleClose(conn: Conn): void | Promise; + abstract handleTick(): void | Promise; + + /** + * Optional wake hook: runs once when the instance starts, before any + * connection is handled — safe to load persisted state here. + */ + handleStart(): void | Promise {} + + /** + * Managed ticker (opt-in). Override {@link shouldTick} and the platform runs + * {@link handleTick} on a timer of {@link tickIntervalMs} while it returns true, + * and stops (letting the Durable Object hibernate — no compute cost) when it + * returns false. The platform owns scheduling, rescheduling, self-heal, and + * error-safety — you don't call {@link startLoop}/{@link stopLoop}. + * + * Re-evaluated after every connect/message/close and on every tick, so keep it + * cheap and pure (no async, no side effects). Example: `return this.players >= 2`. + */ + protected tickIntervalMs = 100; + protected shouldTick?(): boolean; + + protected broadcast(_data: Outgoing): void { + throw new Error("Actor.broadcast() is only available inside a deployed actor"); + } + + protected getConnections(): Conn[] { + throw new Error("Actor.getConnections() is only available inside a deployed actor"); + } + + protected startLoop(_ms: number): Promise { + throw new Error("Actor.startLoop() is only available inside a deployed actor"); + } + + protected stopLoop(): Promise { + throw new Error("Actor.stopLoop() is only available inside a deployed actor"); + } + + protected get instanceId(): string { + throw new Error("Actor.instanceId is only available inside a deployed actor"); + } + + protected get storage(): Storage { + throw new Error("Actor.storage is only available inside a deployed actor"); + } + + /** + * Anonymous Base44 client scoped to this actor instance — no user or service + * auth, so entity access is RLS-gated (same as a logged-out visitor). Always + * operates on production data: an actor runs server-side with no per-connection + * identity, so a Test DB preview selected in the editor does not apply here. + * Example: `const rows = await this.client.entities.Score.list();` + */ + protected get client(): Base44Client { + throw new Error("Actor.client is only available inside a deployed actor"); + } +} diff --git a/src/client.ts b/src/client.ts index 630b8cf..54bf904 100644 --- a/src/client.ts +++ b/src/client.ts @@ -20,6 +20,7 @@ import type { CreateClientOptions, } from "./client.types.js"; import { createAnalyticsModule } from "./modules/analytics.js"; +import { createActorsModule, resolveActorsHost } from "./modules/actors.js"; // Re-export client types export type { Base44Client, CreateClientConfig, CreateClientOptions }; @@ -163,6 +164,18 @@ export function createClient(config: CreateClientConfig): Base44Client { } } + const actorsModule = createActorsModule({ + appId, + // serverUrl is often relative/empty (same-origin app); PartySocket needs an + // absolute host, so fall back to the page origin. + host: resolveActorsHost( + serverUrl, + typeof window !== "undefined" ? window.location?.origin : undefined, + ), + functionsVersion, + getAuthToken: () => token || getAccessToken(), + }); + const userModules = { entities: createEntitiesModule({ axios: axiosClient, @@ -200,8 +213,10 @@ export function createClient(config: CreateClientConfig): Base44Client { appId, userAuthModule, }), + actors: actorsModule.module, cleanup: () => { userModules.analytics.cleanup(); + actorsModule.closeAll(); if (socket) { socket.disconnect(); } diff --git a/src/client.types.ts b/src/client.types.ts index 611f81b..27e3289 100644 --- a/src/client.types.ts +++ b/src/client.types.ts @@ -11,6 +11,7 @@ import type { AgentsModule } from "./modules/agents.types.js"; import type { AiGatewayModule } from "./modules/ai-gateway.types.js"; import type { AppLogsModule } from "./modules/app-logs.types.js"; import type { AnalyticsModule } from "./modules/analytics.types.js"; +import type { ActorsModule } from "./modules/actors.types.js"; /** * Options for creating a Base44 client. @@ -94,6 +95,8 @@ export interface Base44Client { analytics: AnalyticsModule; /** {@link AppLogsModule | App logs module} for tracking app usage. */ appLogs: AppLogsModule; + /** {@link ActorsModule | Actors module} for subscribing to and sending messages via Cloudflare Durable Object-backed Actors. */ + actors: ActorsModule; /** {@link AuthModule | Auth module} for user authentication and management. */ auth: AuthModule; /** {@link UserConnectorsModule | Connectors module} for app-user OAuth flows. */ diff --git a/src/index.ts b/src/index.ts index 3c445bb..0114462 100644 --- a/src/index.ts +++ b/src/index.ts @@ -107,8 +107,21 @@ export type { export type { AppLogsModule } from "./modules/app-logs.types.js"; +export type { + ActorsModule, + ActorClient, + ActorRef, + Connection, + ActorSubscription, + ActorConnectOptions, + ActorNameRegistry, + ActorRegistry, +} from "./modules/actors.types.js"; + export type { SsoModule, SsoAccessTokenResponse } from "./modules/sso.types.js"; +export { Actor, type Conn } from "./actor.js"; + export type { ConnectorsModule, UserConnectorsModule, diff --git a/src/modules/actors.ts b/src/modules/actors.ts new file mode 100644 index 0000000..c99a5ac --- /dev/null +++ b/src/modules/actors.ts @@ -0,0 +1,174 @@ +import PartySocket from "partysocket"; +import type { + ActorConnectOptions, + ActorRef, + Connection as ConnectionType, + ActorSubscription, +} from "./actors.types.js"; + +interface ActorsConfig { + appId: string; + /** Current user access token, if authenticated. Rides the WS query so the + * platform proxy can authenticate the connection; anonymous connects omit it. */ + getAuthToken(): string | null | undefined; + /** Same semantics as function calls: editors with a non-prod version get the + * draft actor script; everyone else gets the published one. */ + functionsVersion?: string; + /** Absolute host PartySocket dials (it strips the scheme and connects wss, ws + * for localhost). Resolved by {@link resolveActorsHost}. */ + host: string; +} + +// Heartbeat / half-open detection: PartySocket only reconnects on a close/error +// event, so ping periodically and force a reconnect if nothing returns in DEAD_MS. +const PING_MS = 1_000; +const DEAD_MS = 3_000; + +/** + * A live connection to an actor instance. Only obtainable from + * {@link ActorRef.connect}, so `subscribe`/`send` are always valid — the socket + * exists for this object's whole lifetime. + */ +class Connection { + private readonly ws: PartySocket; + private readonly listeners = new Set<(data: unknown) => void>(); + private heartbeat: ReturnType | null = null; + /** The client-chosen conn id — becomes _pk → the actor's conn.id. */ + readonly id: string; + + constructor( + actorName: string, + instanceId: string, + config: ActorsConfig, + options: ActorConnectOptions | undefined, + private readonly onClose: () => void, + ) { + this.id = options?.id ?? crypto.randomUUID(); + + const ws = new PartySocket({ + host: config.host, + party: actorName, + room: instanceId, + id: this.id, + // Re-read on every (re)connect so a login/logout is picked up. + query: () => { + const token = config.getAuthToken(); + return { + app_id: config.appId, + handler: actorName, + ...(token ? { token } : {}), + ...(config.functionsVersion ? { fv: config.functionsVersion } : {}), + }; + }, + }); + this.ws = ws; + + let lastMsg = Date.now(); + const bumpAlive = () => { lastMsg = Date.now(); }; + ws.addEventListener("open", bumpAlive); + ws.addEventListener("message", (ev) => { + bumpAlive(); + let data: unknown; + try { + data = JSON.parse(ev.data); + } catch { + return; + } + const msgType = data && typeof data === "object" ? (data as { type?: unknown }).type : undefined; + if (msgType === "__pong") return; + for (const listener of this.listeners) listener(data); + }); + + this.heartbeat = setInterval(() => { + if (Date.now() - lastMsg > DEAD_MS) { + bumpAlive(); // avoid a reconnect storm while the new socket comes up + ws.reconnect(); + return; + } + try { + // The deployed shim echoes __ping → __pong (base44-userapp-bundler + // shim/actor.ts); without that, an idle room reconnects every DEAD_MS. + ws.send(JSON.stringify({ type: "__ping" })); + } catch { + // not open; the watchdog above will reconnect + } + }, PING_MS); + } + + subscribe(callback: (data: unknown) => void): ActorSubscription { + this.listeners.add(callback); + return { + unsubscribe: () => { this.listeners.delete(callback); }, + }; + } + + send(data: unknown): void { + this.ws.send(JSON.stringify(data)); + } + + close(): void { + if (this.heartbeat) { + clearInterval(this.heartbeat); + this.heartbeat = null; + } + this.listeners.clear(); + this.ws.close(); + this.onClose(); + } +} + +/** Handle for one actor instance: `connect()` opens the socket (idempotent). */ +function makeActorRef( + actorName: string, + instanceId: string, + config: ActorsConfig, + connections: Set, +): ActorRef { + let conn: Connection | null = null; + return { + connect(options?: ActorConnectOptions) { + if (conn) return conn as unknown as ConnectionType; + const c = new Connection(actorName, instanceId, config, options, () => { + connections.delete(c); + if (conn === c) conn = null; // allow a fresh connect() after close + }); + conn = c; + connections.add(c); + return c as unknown as ConnectionType; + }, + }; +} + +/** + * Absolute host for the actor WebSocket. PartySocket needs an absolute host and + * can't resolve a relative/empty `serverUrl` (same-origin apps use a relative + * `/api`, so `serverUrl` is often `""`), so fall back to the page origin. + * PartySocket handles the scheme (https→wss, ws for localhost). + */ +export function resolveActorsHost(serverUrl: string, browserOrigin?: string): string { + return serverUrl && !serverUrl.startsWith("/") ? serverUrl : browserOrigin ?? serverUrl; +} + +export function createActorsModule(config: ActorsConfig) { + // Live connections this client opened, so client.cleanup() can reclaim any the + // app forgot to close() (each connection removes itself here on close). + const connections = new Set(); + const module = new Proxy( + {} as Record ActorRef>, + { + get(_, key) { + // Symbols and `then` resolve to undefined (so the module isn't mistaken + // for a thenable when awaited); any string key is an actor name. + if (typeof key !== "string" || key === "then") return undefined; + return (instanceId: string) => + makeActorRef(key, instanceId, config, connections); + }, + }, + ); + return { + module, + closeAll: () => { + for (const c of [...connections]) c.close(); + }, + }; +} diff --git a/src/modules/actors.types.ts b/src/modules/actors.types.ts new file mode 100644 index 0000000..246b56a --- /dev/null +++ b/src/modules/actors.types.ts @@ -0,0 +1,111 @@ +/** + * Extend this interface to add typed `subscribe` callbacks and `send` payloads + * for your deployed Actors. + * + * This is separate from {@link ActorNameRegistry} (which is auto-generated + * by `base44 types generate`), so there are no conflicts. + * + * @example + * ```typescript + * declare module "@base44/sdk" { + * interface ActorRegistry { + * ChatRoom: { + * toClient: { type: "joined" | "left" | "message"; userId?: string; from?: string; text?: string }; + * toServer: { type: "message"; text: string }; + * }; + * } + * } + * ``` + */ +export interface ActorRegistry {} + +/** + * Auto-populated by `base44 types generate` with the names of your deployed actors. + * Do not edit this interface manually — use {@link ActorRegistry} for message types. + */ +export interface ActorNameRegistry {} + +type AllActorNames = keyof ActorRegistry | keyof ActorNameRegistry; + +type ToClientFor = N extends keyof ActorRegistry + ? ActorRegistry[N] extends { toClient: infer I } + ? I + : unknown + : unknown; + +type ToServerFor = N extends keyof ActorRegistry + ? ActorRegistry[N] extends { toServer: infer O } + ? O + : unknown + : unknown; + +/** Options for {@link ActorRef.connect}. */ +export interface ActorConnectOptions { + /** + * The connection id — becomes the actor's `conn.id`. Supply a stable value + * (e.g. persisted per tab) so a reconnect reuses the same server-side + * identity; omit for an auto-generated per-connection id. + */ + id?: string; +} + +/** Handle for one listener registered via {@link Connection.subscribe}. */ +export interface ActorSubscription { + /** Remove this listener; other listeners and the socket stay live. */ + unsubscribe(): void; +} + +/** + * A live connection to an actor instance, returned by {@link ActorRef.connect}. + * `subscribe`/`send` are always valid — you only get a `Connection` once the + * socket has been opened, so there's no pre-connect state to guard against. + */ +export interface Connection { + /** The connection id (the value the actor sees as `conn.id`). */ + readonly id: string; + + /** Register a message listener. Multiple are allowed; returns a per-listener unsubscribe. */ + subscribe(callback: (data: ToClientFor) => void): ActorSubscription; + + /** Send a message. Buffered by the socket until it's open. */ + send(data: ToServerFor): void; + + /** Tear down the socket, heartbeat, and all listeners. */ + close(): void; +} + +/** + * A handle to one actor instance — `base44.actors.MyActor(id)`. Call + * {@link connect} to open the socket and get a {@link Connection}. + */ +export interface ActorRef { + /** Open the WebSocket and return the {@link Connection}. Idempotent. */ + connect(options?: ActorConnectOptions): Connection; +} + +/** + * Client for a single named Actor — call it with an instance id to get an + * {@link ActorRef}. Typed automatically when the actor is registered in + * {@link ActorRegistry}. + */ +export interface ActorClient { + (instanceId: string): ActorRef; +} + +/** + * The actors module provides access to Cloudflare Durable Object-backed + * Actors deployed by the Base44 platform. + * + * ```typescript + * const conn = base44.actors.MyActor("room-1").connect(); + * const sub = conn.subscribe((msg) => console.log(msg)); // typed via ActorRegistry + * conn.send({ type: "message", text: "hi" }); + * sub.unsubscribe(); + * conn.close(); + * ``` + */ +export type ActorsModule = { + [K in AllActorNames]: K extends keyof ActorRegistry + ? ActorClient + : ActorClient; +} & Record; diff --git a/tests/unit/actors.test.ts b/tests/unit/actors.test.ts new file mode 100644 index 0000000..79ebed5 --- /dev/null +++ b/tests/unit/actors.test.ts @@ -0,0 +1,197 @@ +import { describe, test, expect, vi, beforeEach } from "vitest"; + +// Mock PartySocket with a controllable fake so we can drive open/message events +// and assert connect/subscribe/send/close behavior without a real socket. +// vi.hoisted so the class/registry exist before the hoisted vi.mock factory runs. +const { sockets, FakeSocket } = vi.hoisted(() => { + class FakeSocket { + opts: any; + sent: string[] = []; + closed = false; + private handlers: Record void)[]> = {}; + constructor(opts: any) { + this.opts = opts; + sockets.push(this); + } + addEventListener(type: string, fn: (ev: any) => void) { + (this.handlers[type] ??= []).push(fn); + } + send(data: string) { this.sent.push(data); } + close() { this.closed = true; } + reconnect() {} + emit(type: string, ev: any) { (this.handlers[type] ?? []).forEach((h) => h(ev)); } + message(obj: unknown) { this.emit("message", { data: JSON.stringify(obj) }); } + } + const sockets: InstanceType[] = []; + return { sockets, FakeSocket }; +}); + +vi.mock("partysocket", () => ({ default: FakeSocket })); + +import { createActorsModule, resolveActorsHost } from "../../src/modules/actors.ts"; + +describe("Actors Module — connection API", () => { + const config = { + appId: "app-1", + getAuthToken: () => "user-tok", + functionsVersion: undefined, + host: "https://app.example", + }; + + // The module (Proxy of actor names). closeAll is separate — see its own tests. + const mod = (c: typeof config = config) => createActorsModule(c).module; + + beforeEach(() => { sockets.length = 0; }); + + test("connect() opens exactly one socket with the auth query", () => { + const conn = mod().GameRoom("room-1").connect({ id: "conn-1" }); + expect(sockets).toHaveLength(1); + expect(conn.id).toBe("conn-1"); + const q = sockets[0].opts.query(); + expect(q).toMatchObject({ app_id: "app-1", handler: "GameRoom", token: "user-tok" }); + expect(sockets[0].opts.room).toBe("room-1"); + expect(sockets[0].opts.id).toBe("conn-1"); + // the resolved host is handed to PartySocket (which swaps the scheme). + expect(sockets[0].opts.host).toBe("https://app.example"); + }); + + test("connect() is idempotent per handle", () => { + const ref = mod().GameRoom("r"); + const a = ref.connect(); + const b = ref.connect(); + expect(sockets).toHaveLength(1); + expect(a).toBe(b); + }); + + test("multiple listeners all receive; unsubscribe removes only its own", () => { + const conn = mod().GameRoom("r").connect(); + const a: unknown[] = [], b: unknown[] = []; + const subA = conn.subscribe((m) => a.push(m)); + conn.subscribe((m) => b.push(m)); + + sockets[0].message({ type: "tick", n: 1 }); + expect(a).toHaveLength(1); + expect(b).toHaveLength(1); + + subA.unsubscribe(); + sockets[0].message({ type: "tick", n: 2 }); + expect(a).toHaveLength(1); // stopped + expect(b).toHaveLength(2); // still live + expect(sockets[0].closed).toBe(false); // socket stays open + }); + + test("__pong platform messages are swallowed", () => { + const conn = mod().GameRoom("r").connect(); + const got: unknown[] = []; + conn.subscribe((m) => got.push(m)); + sockets[0].message({ type: "__pong" }); + sockets[0].message({ type: "tick" }); + expect(got).toEqual([{ type: "tick" }]); + }); + + test("send serializes onto the socket", () => { + const conn = mod().GameRoom("r").connect(); + conn.send({ type: "join", name: "alice" }); + expect(sockets[0].sent).toContain(JSON.stringify({ type: "join", name: "alice" })); + }); + + test("close() tears down socket and all listeners", () => { + const conn = mod().GameRoom("r").connect(); + const got: unknown[] = []; + conn.subscribe((m) => got.push(m)); + conn.close(); + expect(sockets[0].closed).toBe(true); + // a late message reaches nobody (listeners cleared) + sockets[0].message({ type: "tick" }); + expect(got).toHaveLength(0); + }); + + test("connect() after close() opens a fresh socket with a new id", () => { + const ref = mod().GameRoom("r"); + const c1 = ref.connect({ id: "c1" }); + expect(sockets).toHaveLength(1); + c1.close(); + const c2 = ref.connect({ id: "c2" }); + expect(sockets).toHaveLength(2); // a new socket, not the closed one reused + expect(c2.id).toBe("c2"); + }); + + test("anonymous connect omits the token", () => { + const conn = mod({ ...config, getAuthToken: () => null }).GameRoom("r").connect(); + expect(conn).toBeDefined(); + expect(sockets[0].opts.query()).not.toHaveProperty("token"); + }); + + test("each GameRoom(id) is an independent connection", () => { + const actors = mod(); + actors.GameRoom("r").connect(); + actors.GameRoom("r").connect(); + expect(sockets).toHaveLength(2); + }); + + test("functionsVersion rides the query as fv when set, omitted when unset", () => { + mod().GameRoom("r").connect(); + expect(sockets[0].opts.query()).not.toHaveProperty("fv"); + mod({ ...config, functionsVersion: "draft" }).GameRoom("r2").connect(); + expect(sockets[1].opts.query()).toMatchObject({ fv: "draft" }); + }); + + test("heartbeat pings periodically and reconnects when the link goes silent", () => { + vi.useFakeTimers(); + try { + mod().GameRoom("r").connect(); + const ws = sockets[0]; + const reconnect = vi.spyOn(ws, "reconnect"); + vi.advanceTimersByTime(1000); // one PING_MS tick, still within DEAD_MS + expect(ws.sent).toContain(JSON.stringify({ type: "__ping" })); + expect(reconnect).not.toHaveBeenCalled(); + vi.advanceTimersByTime(4000); // no inbound message → exceed DEAD_MS + expect(reconnect).toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + test("module is not thenable (then must not resolve to an actor factory)", () => { + const actors = mod() as unknown as { then?: unknown }; + expect(actors.then).toBeUndefined(); + }); + + test("closeAll() closes every open connection", () => { + const { module, closeAll } = createActorsModule(config); + module.GameRoom("a").connect(); + module.GameRoom("b").connect(); + expect(sockets.filter((s) => s.closed)).toHaveLength(0); + closeAll(); + expect(sockets.every((s) => s.closed)).toBe(true); + }); + + test("closeAll() is safe after an individual close() and closes the rest", () => { + const { module, closeAll } = createActorsModule(config); + const a = module.GameRoom("a").connect(); + module.GameRoom("b").connect(); + a.close(); + expect(() => closeAll()).not.toThrow(); + expect(sockets.every((s) => s.closed)).toBe(true); + }); +}); + +describe("resolveActorsHost", () => { + test("absolute serverUrl is used as-is", () => { + expect(resolveActorsHost("https://api.example", "https://tab.example")).toBe( + "https://api.example", + ); + }); + + test("empty serverUrl falls back to the browser origin", () => { + expect(resolveActorsHost("", "https://tab.example")).toBe("https://tab.example"); + }); + + test("relative serverUrl (/api) falls back to the browser origin", () => { + expect(resolveActorsHost("/api", "https://tab.example")).toBe("https://tab.example"); + }); + + test("no origin available (non-browser) returns the serverUrl unchanged", () => { + expect(resolveActorsHost("", undefined)).toBe(""); + }); +});