diff --git a/.github/workflows/preview-publish.yml b/.github/workflows/preview-publish.yml index 8cd53fd..47f1445 100644 --- a/.github/workflows/preview-publish.yml +++ b/.github/workflows/preview-publish.yml @@ -23,6 +23,8 @@ jobs: registry-url: "https://registry.npmjs.org" - name: Update npm + # Pin to npm@11: npm@latest is now 12.x, which requires node >=22 and + # fails EBADENGINE on this node-20 runner. npm 11 supports node ^20.17. run: npm install -g npm@11 - name: Install dependencies 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/pstest.mjs b/pstest.mjs new file mode 100644 index 0000000..ed97580 --- /dev/null +++ b/pstest.mjs @@ -0,0 +1,26 @@ +import PartySocket from "partysocket"; + +// Fake WebSocket that just records the URL PartySocket tries to open. +class FakeWS { + constructor(url) { FakeWS.lastUrl = url; this.readyState = 0; } + addEventListener(){} removeEventListener(){} close(){} send(){} +} + +function urlFor(host) { + FakeWS.lastUrl = null; + try { + new PartySocket({ host, room: "room123", party: "GameRoom", WebSocket: FakeWS, query: { token: "T" } }); + } catch (e) { return "THREW: " + e.message; } + return FakeWS.lastUrl; +} + +console.log("=== PartySocket host -> connect URL ==="); +for (const h of ["https://app.base44.app", "http://localhost:1999", "app.base44.app", "https://app.com/sub/path", "http://10.0.0.5:3000"]) { + console.log(`host=${JSON.stringify(h)}\n -> ${urlFor(h)}`); +} + +console.log("\n=== new URL(raw).origin behavior ==="); +for (const raw of ["https://app.com", "http://localhost:1999", "https://app.com/foo", "app.com", "myapp.base44.app"]) { + try { console.log(`${JSON.stringify(raw)} -> ${new URL(raw).origin}`); } + catch (e) { console.log(`${JSON.stringify(raw)} -> THREW: ${e.message}`); } +} diff --git a/src/actor.ts b/src/actor.ts new file mode 100644 index 0000000..c08042f --- /dev/null +++ b/src/actor.ts @@ -0,0 +1,103 @@ +/** + * 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. + */ + + +/** + * 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"); + } + +} diff --git a/src/client.ts b/src/client.ts index 630b8cf..4a48d86 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 } from "./modules/actors.js"; // Re-export client types export type { Base44Client, CreateClientConfig, CreateClientOptions }; @@ -72,11 +73,31 @@ export function createClient(config: CreateClientConfig): Base44Client { options, functionsVersion, headers: optionalHeaders, + dispatcherWsUrl, + webSocketImpl, } = config; // Normalize appBaseUrl to always be a string (empty if not provided or invalid) const normalizedAppBaseUrl = typeof appBaseUrl === "string" ? appBaseUrl : ""; + // Derive the dispatcher WebSocket URL if not explicitly provided. Default to + // the app's OWN origin (the app URL proxies /parties to the backend) so the + // socket is same-origin with the running app, not the API host: prefer an + // explicit appBaseUrl, then the browser origin, then fall back to serverUrl + // (Node/SSR, where there's no window). Convert https:// → wss:// (http → ws) + // and strip the trailing slash. + const resolvedDispatcherWsUrl = (() => { + if (dispatcherWsUrl) return dispatcherWsUrl.replace(/\/$/, ""); + const appOrigin = + normalizedAppBaseUrl || + // React Native has a bare `window` with no `location`, so guard both. + (typeof window !== "undefined" ? window.location?.origin ?? "" : ""); + return (appOrigin || serverUrl) + .replace(/\/$/, "") + .replace(/^https:\/\//, "wss://") + .replace(/^http:\/\//, "ws://"); + })(); + const socketConfig: RoomsSocketConfig = { serverUrl, mountPath: "/ws-user-apps/socket.io/", @@ -200,6 +221,15 @@ export function createClient(config: CreateClientConfig): Base44Client { appId, userAuthModule, }), + actors: createActorsModule({ + appId, + dispatcherWsUrl: resolvedDispatcherWsUrl, + webSocketImpl, + functionsVersion, + // Same credential as function calls; the platform proxy authenticates the + // WS connection with it (anonymous when absent) — no pre-connect token mint. + getAuthToken: () => token || getAccessToken(), + }), cleanup: () => { userModules.analytics.cleanup(); if (socket) { diff --git a/src/client.types.ts b/src/client.types.ts index 611f81b..cb8fb3b 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. @@ -78,6 +79,28 @@ export interface CreateClientConfig { * Additional client options. */ options?: CreateClientOptions; + /** + * Base WebSocket URL for the Cloudflare Durable Object dispatcher. + * + * Defaults to the app's own origin (`appBaseUrl`, else the browser's + * `window.location.origin`, else `serverUrl`) with `https://` replaced by + * `wss://` (or `http://` by `ws://`) — so the realtime socket is same-origin + * with the running app, which proxies `/parties` to the backend. + * Override when the dispatcher lives at a different host than the app. + */ + dispatcherWsUrl?: string; + /** + * WebSocket implementation for realtime subscriptions in environments + * without a global `WebSocket` (Node.js < 22). Browsers and Node ≥ 22 + * don't need this. + * + * @example + * ```typescript + * import WS from "ws"; + * const base44 = createClient({ appId, webSocketImpl: WS }); + * ``` + */ + webSocketImpl?: unknown; } /** @@ -94,6 +117,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..1dfb439 100644 --- a/src/index.ts +++ b/src/index.ts @@ -107,8 +107,17 @@ export type { export type { AppLogsModule } from "./modules/app-logs.types.js"; +export type { + ActorsModule, + ActorClient, + 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..6f2e3c4 --- /dev/null +++ b/src/modules/actors.ts @@ -0,0 +1,137 @@ +import PartySocket from "partysocket"; +import type { + ActorConnectOptions, + ActorRoom, + 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; + dispatcherWsUrl: string; + /** WebSocket implementation for runtimes without a global one (Node < 22). */ + webSocketImpl?: unknown; +} + +// 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; + +class Room { + private ws: PartySocket | null = null; + private readonly listeners = new Set<(data: unknown) => void>(); + private heartbeat: ReturnType | null = null; + private connId: string | null = null; + + constructor( + private readonly actorName: string, + private readonly instanceId: string, + private readonly config: ActorsConfig, + ) {} + + get id(): string { + if (!this.connId) { + throw new Error(`${this.actorName}:${this.instanceId}: connect() before reading id`); + } + return this.connId; + } + + connect(options?: ActorConnectOptions): this { + if (this.ws) return this; + + // The client picks its own conn id; it becomes _pk → the actor's conn.id. + const connId = options?.id ?? crypto.randomUUID(); + this.connId = connId; + + const ws = new PartySocket({ + host: this.config.dispatcherWsUrl, + party: this.actorName, + room: this.instanceId, + id: connId, + ...(this.config.webSocketImpl ? { WebSocket: this.config.webSocketImpl as any } : {}), + // Re-read on every (re)connect so a login/logout is picked up. + query: () => { + const token = this.config.getAuthToken(); + return { + app_id: this.config.appId, + handler: this.actorName, + ...(token ? { token } : {}), + ...(this.config.functionsVersion ? { fv: this.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; // ignore malformed + } + const msgType = data && typeof data === "object" ? (data as { type?: unknown }).type : undefined; + if (msgType === "__pong") return; // platform message — never surface it + 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 { + ws.send(JSON.stringify({ type: "__ping" })); + } catch { + // socket not open; the watchdog above will force a reconnect + } + }, PING_MS); + + return this; + } + + subscribe(callback: (data: unknown) => void): ActorSubscription { + if (!this.ws) { + throw new Error(`${this.actorName}:${this.instanceId}: connect() before subscribe()`); + } + this.listeners.add(callback); + return { + unsubscribe: () => { this.listeners.delete(callback); }, + }; + } + + send(data: unknown): void { + if (!this.ws) { + throw new Error(`${this.actorName}:${this.instanceId}: connect() before send()`); + } + this.ws.send(JSON.stringify(data)); + } + + close(): void { + if (this.heartbeat) { + clearInterval(this.heartbeat); + this.heartbeat = null; + } + this.listeners.clear(); + this.ws?.close(); + this.ws = null; + } +} + +export function createActorsModule(config: ActorsConfig) { + return new Proxy({} as Record ActorRoom>, { + get(_, actorName: string) { + return (instanceId: string) => new Room(actorName, instanceId, config) as unknown as ActorRoom; + }, + }); +} diff --git a/src/modules/actors.types.ts b/src/modules/actors.types.ts new file mode 100644 index 0000000..0282123 --- /dev/null +++ b/src/modules/actors.types.ts @@ -0,0 +1,105 @@ +/** + * 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 ActorRoom.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 ActorRoom.subscribe}. */ +export interface ActorSubscription { + /** Remove this listener; other listeners and the socket stay live. */ + unsubscribe(): void; +} + +/** + * A single actor room. Obtained from {@link ActorClient} (`actors.MyActor(id)`) + * and made live with {@link connect}. The handle IS the connection: one socket, + * any number of {@link subscribe} listeners. + */ +export interface ActorRoom { + /** The connection id (the value the actor sees as `conn.id`). Throws before {@link connect}. */ + readonly id: string; + + /** Open the WebSocket (required before subscribe/send). Idempotent; returns this. */ + connect(options?: ActorConnectOptions): this; + + /** Register a message listener. Multiple are allowed; returns a per-listener unsubscribe. */ + subscribe(callback: (data: ToClientFor) => void): ActorSubscription; + + /** Send a message. Throws before {@link connect}; buffered by the socket until open. */ + send(data: ToServerFor): void; + + /** Tear down the socket, heartbeat, and all listeners. */ + close(): void; +} + +/** + * Client for a single named Actor — call it with a room id to get an + * {@link ActorRoom}. Typed automatically when the actor is registered in + * {@link ActorRegistry}. + */ +export interface ActorClient { + (instanceId: string): ActorRoom; +} + +/** + * The actors module provides access to Cloudflare Durable Object-backed + * Actors deployed by the Base44 platform. + * + * ```typescript + * const room = base44.actors.MyActor("room-1").connect(); + * const sub = room.subscribe((msg) => console.log(msg)); // typed via ActorRegistry + * room.send({ type: "message", text: "hi" }); + * sub.unsubscribe(); + * room.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..77d5094 --- /dev/null +++ b/tests/unit/actors.test.ts @@ -0,0 +1,122 @@ +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 } from "../../src/modules/actors.ts"; + +describe("Actors Module — room handle", () => { + const config = { + appId: "app-1", + getAuthToken: () => "user-tok", + functionsVersion: undefined, + dispatcherWsUrl: "wss://disp.example", + }; + + beforeEach(() => { sockets.length = 0; }); + + test("connect() opens exactly one socket with the auth query", () => { + const actors = createActorsModule(config); + const room = actors.GameRoom("room-1").connect({ id: "conn-1" }); + expect(sockets).toHaveLength(1); + expect(room.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"); + }); + + test("connect() is idempotent", () => { + const room = createActorsModule(config).GameRoom("r").connect(); + room.connect(); + expect(sockets).toHaveLength(1); + }); + + test("subscribe/send/id throw before connect()", () => { + const room = createActorsModule(config).GameRoom("r"); + expect(() => room.subscribe(() => {})).toThrow(/connect\(\)/); + expect(() => room.send({})).toThrow(/connect\(\)/); + expect(() => room.id).toThrow(/connect\(\)/); + }); + + test("multiple listeners all receive; unsubscribe removes only its own", () => { + const room = createActorsModule(config).GameRoom("r").connect(); + const a: unknown[] = [], b: unknown[] = []; + const subA = room.subscribe((m) => a.push(m)); + room.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 room = createActorsModule(config).GameRoom("r").connect(); + const got: unknown[] = []; + room.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 room = createActorsModule(config).GameRoom("r").connect(); + room.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 room = createActorsModule(config).GameRoom("r").connect(); + const got: unknown[] = []; + room.subscribe((m) => got.push(m)); + room.close(); + expect(sockets[0].closed).toBe(true); + // a late message reaches nobody (listeners cleared) + sockets[0].message({ type: "tick" }); + expect(got).toHaveLength(0); + }); + + test("anonymous connect omits the token", () => { + const room = createActorsModule({ ...config, getAuthToken: () => null }).GameRoom("r").connect(); + expect(room).toBeDefined(); + expect(sockets[0].opts.query()).not.toHaveProperty("token"); + }); + + test("each GameRoom(id) is an independent connection", () => { + const actors = createActorsModule(config); + actors.GameRoom("r").connect(); + actors.GameRoom("r").connect(); + expect(sockets).toHaveLength(2); + }); +});