From cfc5dec86e6c2eb9d3a250f021d7be5e2155f43b Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 11:20:54 +0300 Subject: [PATCH 01/50] feat(realtime): add realtime namespace with subscribe/send Add a `realtime` module to the Base44 JS SDK that lets users subscribe to and send messages to Cloudflare Durable Object-backed RealtimeHandlers deployed by the Base44 platform. Uses PartySocket for WebSocket transport with automatic token refresh on reconnect. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 1 + src/client.ts | 24 +++++++++++ src/client.types.ts | 10 +++++ src/index.ts | 6 +++ src/modules/realtime.ts | 79 +++++++++++++++++++++++++++++++++++ src/modules/realtime.types.ts | 50 ++++++++++++++++++++++ 6 files changed, 170 insertions(+) create mode 100644 src/modules/realtime.ts create mode 100644 src/modules/realtime.types.ts diff --git a/package.json b/package.json index 956e401f..0f1dd872 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ }, "dependencies": { "axios": "^1.17.0", + "partysocket": "^0.0.23", "socket.io-client": "^4.8.3", "uuid": "^13.0.2" }, diff --git a/src/client.ts b/src/client.ts index 3965f295..22dc9333 100644 --- a/src/client.ts +++ b/src/client.ts @@ -19,6 +19,7 @@ import type { CreateClientOptions, } from "./client.types.js"; import { createAnalyticsModule } from "./modules/analytics.js"; +import { createRealtimeModule } from "./modules/realtime.js"; // Re-export client types export type { Base44Client, CreateClientConfig, CreateClientOptions }; @@ -71,11 +72,22 @@ export function createClient(config: CreateClientConfig): Base44Client { options, functionsVersion, headers: optionalHeaders, + dispatcherWsUrl, } = 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 from serverUrl if not explicitly provided. + // Convert https:// → wss:// (or http:// → ws://) and strip trailing slash. + const resolvedDispatcherWsUrl = (() => { + if (dispatcherWsUrl) return dispatcherWsUrl.replace(/\/$/, ""); + return serverUrl + .replace(/\/$/, "") + .replace(/^https:\/\//, "wss://") + .replace(/^http:\/\//, "ws://"); + })(); + const socketConfig: RoomsSocketConfig = { serverUrl, mountPath: "/ws-user-apps/socket.io/", @@ -198,6 +210,18 @@ export function createClient(config: CreateClientConfig): Base44Client { appId, userAuthModule, }), + realtime: createRealtimeModule({ + appId, + dispatcherWsUrl: resolvedDispatcherWsUrl, + getToken: async (handlerName, instanceId) => { + // axiosClient interceptors unwrap response.data, so the result is the body directly + const data = await axiosClient.post( + `/apps/${appId}/realtime-token`, + { handler_name: handlerName, instance_id: instanceId } + ); + return data.token; + }, + }), cleanup: () => { userModules.analytics.cleanup(); if (socket) { diff --git a/src/client.types.ts b/src/client.types.ts index 44c2a6c1..934adaf1 100644 --- a/src/client.types.ts +++ b/src/client.types.ts @@ -10,6 +10,7 @@ import type { FunctionsModule } from "./modules/functions.types.js"; import type { AgentsModule } from "./modules/agents.types.js"; import type { AppLogsModule } from "./modules/app-logs.types.js"; import type { AnalyticsModule } from "./modules/analytics.types.js"; +import type { RealtimeModule } from "./modules/realtime.types.js"; /** * Options for creating a Base44 client. @@ -77,6 +78,13 @@ export interface CreateClientConfig { * Additional client options. */ options?: CreateClientOptions; + /** + * Base WebSocket URL for the Cloudflare Durable Object dispatcher. + * + * Defaults to the `serverUrl` with `https://` replaced by `wss://` (or `http://` by `ws://`). + * Override when the dispatcher lives at a different host than the API. + */ + dispatcherWsUrl?: string; } /** @@ -91,6 +99,8 @@ export interface Base44Client { analytics: AnalyticsModule; /** {@link AppLogsModule | App logs module} for tracking app usage. */ appLogs: AppLogsModule; + /** {@link RealtimeModule | Realtime module} for subscribing to and sending messages via Cloudflare Durable Object-backed RealtimeHandlers. */ + realtime: RealtimeModule; /** {@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 bc531d89..c544e76b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -102,6 +102,12 @@ export type { export type { AppLogsModule } from "./modules/app-logs.types.js"; +export type { + RealtimeModule, + RealtimeHandlerClient, + RealtimeSubscription, +} from "./modules/realtime.types.js"; + export type { SsoModule, SsoAccessTokenResponse } from "./modules/sso.types.js"; export type { diff --git a/src/modules/realtime.ts b/src/modules/realtime.ts new file mode 100644 index 00000000..bb20fc7b --- /dev/null +++ b/src/modules/realtime.ts @@ -0,0 +1,79 @@ +import PartySocket from "partysocket"; + +// Module-level map: "HandlerName:instanceId" → active socket +const activeSockets = new Map(); + +function socketKey(handlerName: string, instanceId: string) { + return `${handlerName}:${instanceId}`; +} + +export function createRealtimeModule(config: { + appId: string; + getToken(handlerName: string, instanceId: string): Promise; + dispatcherWsUrl: string; +}) { + return new Proxy({} as Record, { + get(_, handlerName: string) { + return { + async subscribe( + instanceId: string, + callback: (data: unknown) => void, + ): Promise<{ send(data: unknown): void; close(): void }> { + const key = socketKey(handlerName, instanceId); + // close existing if any + activeSockets.get(key)?.close(); + + const token = await config.getToken(handlerName, instanceId); + const ws = new PartySocket({ + host: config.dispatcherWsUrl, + party: handlerName, + room: instanceId, + query: { token }, + }); + + activeSockets.set(key, ws); + + ws.addEventListener("message", (ev) => { + try { + callback(JSON.parse(ev.data)); + } catch { + // ignore malformed + } + }); + + // Re-fetch token on reconnect + ws.addEventListener("close", async () => { + if (activeSockets.get(key) !== ws) return; // replaced + try { + const newToken = await config.getToken(handlerName, instanceId); + ws.updateProperties({ query: { token: newToken } }); + } catch { + // ignore token refresh failure + } + }); + + return { + send(data: unknown) { + ws.send(JSON.stringify(data)); + }, + close() { + activeSockets.delete(key); + ws.close(); + }, + }; + }, + send(instanceId: string, data: unknown) { + const key = socketKey(handlerName, instanceId); + const ws = activeSockets.get(key); + if (!ws) throw new Error(`No active subscription for ${handlerName}:${instanceId}`); + ws.send(JSON.stringify(data)); + }, + }; + }, + }); +} + +interface RealtimeHandler { + subscribe(instanceId: string, callback: (data: unknown) => void): Promise<{ send(data: unknown): void; close(): void }>; + send(instanceId: string, data: unknown): void; +} diff --git a/src/modules/realtime.types.ts b/src/modules/realtime.types.ts new file mode 100644 index 00000000..3d9388cf --- /dev/null +++ b/src/modules/realtime.types.ts @@ -0,0 +1,50 @@ +/** + * A subscription handle returned by {@link RealtimeHandlerClient.subscribe}. + */ +export interface RealtimeSubscription { + /** Send a message to all subscribers of this instance. */ + send(data: unknown): void; + /** Close the WebSocket connection and remove the subscription. */ + close(): void; +} + +/** + * Client for a single named RealtimeHandler. + */ +export interface RealtimeHandlerClient { + /** + * Subscribe to messages from a specific RealtimeHandler instance. + * + * @param instanceId - The instance ID of the Durable Object. + * @param callback - Called with each parsed message payload. + * @returns A subscription handle with `send` and `close` methods. + */ + subscribe( + instanceId: string, + callback: (data: unknown) => void, + ): Promise; + + /** + * Send a message to an existing active subscription. + * + * @param instanceId - The instance ID of the Durable Object. + * @param data - The data to send (will be JSON-serialized). + * @throws {Error} When no active subscription exists for this handler/instance pair. + */ + send(instanceId: string, data: unknown): void; +} + +/** + * The realtime module provides access to Cloudflare Durable Object-backed + * RealtimeHandlers deployed by the Base44 platform. + * + * Handler names are accessed as dynamic properties on this module: + * ```typescript + * const sub = await base44.realtime.MyHandler.subscribe("room-1", (msg) => { + * console.log(msg); + * }); + * sub.send({ text: "hello" }); + * sub.close(); + * ``` + */ +export type RealtimeModule = Record; From 30b4ed6a3221fe86f322a6d256b14e7792b045f2 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 13:46:02 +0300 Subject: [PATCH 02/50] fix: add package-lock.json with partysocket, fix .npmrc syntax partysocket was added to package.json but lock file was never generated. Also fixes .npmrc: was using env-var syntax (npm_config_registry=...) instead of npmrc syntax (registry=...). Co-Authored-By: Claude Sonnet 4.6 --- .npmrc | 2 +- package-lock.json | 52 ++++++++++++++++++++--------------------------- 2 files changed, 23 insertions(+), 31 deletions(-) diff --git a/.npmrc b/.npmrc index 1e86a945..214c29d1 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1 @@ -npm_config_registry=https://registry.npmjs.org \ No newline at end of file +registry=https://registry.npmjs.org/ diff --git a/package-lock.json b/package-lock.json index 5b8590bd..9539b0e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "axios": "^1.17.0", + "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", From efdc8d96a10c434dc08dc37de9dcea23857be90f Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 14:26:44 +0300 Subject: [PATCH 03/50] feat(realtime): export RealtimeHandler type from SDK Co-Authored-By: Claude Sonnet 4.6 --- src/index.ts | 2 ++ src/realtime-handler.ts | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 src/realtime-handler.ts diff --git a/src/index.ts b/src/index.ts index c544e76b..564bf5c7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -110,6 +110,8 @@ export type { export type { SsoModule, SsoAccessTokenResponse } from "./modules/sso.types.js"; +export { RealtimeHandler, type Conn } from "./realtime-handler.js"; + export type { ConnectorsModule, UserConnectorsModule, diff --git a/src/realtime-handler.ts b/src/realtime-handler.ts new file mode 100644 index 00000000..fcc7e1cc --- /dev/null +++ b/src/realtime-handler.ts @@ -0,0 +1,41 @@ +/** + * Type-only base class for Realtime Handlers. + * + * Import and extend this in your handler files: + * import { RealtimeHandler } from "@base44/sdk"; + * export class MyHandler extends RealtimeHandler { ... } + * + * At deploy time the bundler replaces this import with the compiled + * Cloudflare Durable Object implementation — this file provides types only. + */ + +export interface Conn { + userId: string; + appId: string; + instanceId: string; + send(data: unknown): void; + reject(code: number, reason: string): void; +} + +export abstract class RealtimeHandler<_State = unknown, Message = unknown> { + abstract handleConnect(conn: Conn): void | Promise; + abstract handleMessage(conn: Conn, msg: Message): void | Promise; + abstract handleClose(conn: Conn): void | Promise; + abstract handleTick(): void | Promise; + + protected broadcast(_data: unknown): void { + throw new Error("RealtimeHandler.broadcast() is only available inside a deployed handler"); + } + + protected getConnections(): Conn[] { + throw new Error("RealtimeHandler.getConnections() is only available inside a deployed handler"); + } + + protected startLoop(_ms: number): Promise { + throw new Error("RealtimeHandler.startLoop() is only available inside a deployed handler"); + } + + protected stopLoop(): Promise { + throw new Error("RealtimeHandler.stopLoop() is only available inside a deployed handler"); + } +} From c84c44c0aea218ca44860be6454788cf3e8eabb7 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 15:01:36 +0300 Subject: [PATCH 04/50] feat(realtime): typed subscribe/send with RealtimeHandlerRegistry - subscribe() now returns a sync unsubscribe function instead of Promise - send() is typed via RealtimeHandlerRegistry (user-declared message types) - Add RealtimeHandlerNameRegistry for CLI codegen (no conflict with user augmentation) - Drop RealtimeSubscription in favor of the simpler sync cleanup pattern Co-Authored-By: Claude Sonnet 4.6 --- src/index.ts | 3 +- src/modules/realtime.ts | 25 +++++------ src/modules/realtime.types.ts | 78 +++++++++++++++++++++++------------ 3 files changed, 63 insertions(+), 43 deletions(-) diff --git a/src/index.ts b/src/index.ts index 564bf5c7..e335fd10 100644 --- a/src/index.ts +++ b/src/index.ts @@ -105,7 +105,8 @@ export type { AppLogsModule } from "./modules/app-logs.types.js"; export type { RealtimeModule, RealtimeHandlerClient, - RealtimeSubscription, + RealtimeHandlerNameRegistry, + RealtimeHandlerRegistry, } from "./modules/realtime.types.js"; export type { SsoModule, SsoAccessTokenResponse } from "./modules/sso.types.js"; diff --git a/src/modules/realtime.ts b/src/modules/realtime.ts index bb20fc7b..0f321833 100644 --- a/src/modules/realtime.ts +++ b/src/modules/realtime.ts @@ -15,24 +15,24 @@ export function createRealtimeModule(config: { return new Proxy({} as Record, { get(_, handlerName: string) { return { - async subscribe( - instanceId: string, - callback: (data: unknown) => void, - ): Promise<{ send(data: unknown): void; close(): void }> { + subscribe(instanceId: string, callback: (data: unknown) => void): () => void { const key = socketKey(handlerName, instanceId); // close existing if any activeSockets.get(key)?.close(); - const token = await config.getToken(handlerName, instanceId); const ws = new PartySocket({ host: config.dispatcherWsUrl, party: handlerName, room: instanceId, - query: { token }, }); activeSockets.set(key, ws); + // Fetch token and attach on connect + config.getToken(handlerName, instanceId).then((token) => { + ws.updateProperties({ query: { token } }); + }); + ws.addEventListener("message", (ev) => { try { callback(JSON.parse(ev.data)); @@ -52,14 +52,9 @@ export function createRealtimeModule(config: { } }); - return { - send(data: unknown) { - ws.send(JSON.stringify(data)); - }, - close() { - activeSockets.delete(key); - ws.close(); - }, + return () => { + activeSockets.delete(key); + ws.close(); }; }, send(instanceId: string, data: unknown) { @@ -74,6 +69,6 @@ export function createRealtimeModule(config: { } interface RealtimeHandler { - subscribe(instanceId: string, callback: (data: unknown) => void): Promise<{ send(data: unknown): void; close(): void }>; + subscribe(instanceId: string, callback: (data: unknown) => void): () => void; send(instanceId: string, data: unknown): void; } diff --git a/src/modules/realtime.types.ts b/src/modules/realtime.types.ts index 3d9388cf..8375d3a9 100644 --- a/src/modules/realtime.types.ts +++ b/src/modules/realtime.types.ts @@ -1,37 +1,57 @@ /** - * A subscription handle returned by {@link RealtimeHandlerClient.subscribe}. + * Extend this interface to add typed `subscribe` callbacks and `send` payloads + * for your deployed RealtimeHandlers. + * + * This is separate from {@link RealtimeHandlerNameRegistry} (which is auto-generated + * by `base44 types generate`), so there are no conflicts. + * + * @example + * ```typescript + * declare module "@base44/sdk" { + * interface RealtimeHandlerRegistry { + * ChatRoom: { + * inbound: { type: "joined" | "left" | "message"; userId?: string; from?: string; text?: string }; + * outbound: { text: string }; + * }; + * } + * } + * ``` */ -export interface RealtimeSubscription { - /** Send a message to all subscribers of this instance. */ - send(data: unknown): void; - /** Close the WebSocket connection and remove the subscription. */ - close(): void; -} +export interface RealtimeHandlerRegistry {} + +/** + * Auto-populated by `base44 types generate` with the names of your deployed handlers. + * Do not edit this interface manually — use {@link RealtimeHandlerRegistry} for message types. + */ +export interface RealtimeHandlerNameRegistry {} + +type AllHandlerNames = keyof RealtimeHandlerRegistry | keyof RealtimeHandlerNameRegistry; + +type InboundFor = N extends keyof RealtimeHandlerRegistry + ? RealtimeHandlerRegistry[N] extends { inbound: infer I } + ? I + : unknown + : unknown; + +type OutboundFor = N extends keyof RealtimeHandlerRegistry + ? RealtimeHandlerRegistry[N] extends { outbound: infer O } + ? O + : unknown + : unknown; /** * Client for a single named RealtimeHandler. + * Typed automatically when the handler is registered in {@link RealtimeHandlerRegistry}. */ -export interface RealtimeHandlerClient { - /** - * Subscribe to messages from a specific RealtimeHandler instance. - * - * @param instanceId - The instance ID of the Durable Object. - * @param callback - Called with each parsed message payload. - * @returns A subscription handle with `send` and `close` methods. - */ +export interface RealtimeHandlerClient { + /** Open a WebSocket subscription. Returns a synchronous unsubscribe function. */ subscribe( instanceId: string, - callback: (data: unknown) => void, - ): Promise; + callback: (data: InboundFor) => void, + ): () => void; - /** - * Send a message to an existing active subscription. - * - * @param instanceId - The instance ID of the Durable Object. - * @param data - The data to send (will be JSON-serialized). - * @throws {Error} When no active subscription exists for this handler/instance pair. - */ - send(instanceId: string, data: unknown): void; + /** Send a message over the open socket. Throws if not subscribed. */ + send(instanceId: string, data: OutboundFor): void; } /** @@ -41,10 +61,14 @@ export interface RealtimeHandlerClient { * Handler names are accessed as dynamic properties on this module: * ```typescript * const sub = await base44.realtime.MyHandler.subscribe("room-1", (msg) => { - * console.log(msg); + * console.log(msg); // typed if MyHandler is in RealtimeHandlerRegistry * }); * sub.send({ text: "hello" }); * sub.close(); * ``` */ -export type RealtimeModule = Record; +export type RealtimeModule = { + [K in AllHandlerNames]: K extends keyof RealtimeHandlerRegistry + ? RealtimeHandlerClient + : RealtimeHandlerClient; +} & Record; From c42d1e63dfe970dcd47451be40ffb01ed8fdafd4 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 15:39:26 +0300 Subject: [PATCH 05/50] revert: restore .npmrc to pre-branch state Co-Authored-By: Claude Sonnet 4.6 --- .npmrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.npmrc b/.npmrc index 214c29d1..1e86a945 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1 @@ -registry=https://registry.npmjs.org/ +npm_config_registry=https://registry.npmjs.org \ No newline at end of file From b82ccf25c933468ab1d0a5f3ec7ee960ac9e515e Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 20:19:59 +0300 Subject: [PATCH 06/50] fix(realtime): preserve party+room in updateProperties to fix WebSocket URL partysocket@0.0.23's updateProperties only falls back for host/room/path, not party. Passing {query:{token}} dropped party, changing the URL from /parties/ChatRoom/room to /party/room. Co-Authored-By: Claude Sonnet 4.6 --- src/modules/realtime.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/realtime.ts b/src/modules/realtime.ts index 0f321833..845b9403 100644 --- a/src/modules/realtime.ts +++ b/src/modules/realtime.ts @@ -30,7 +30,7 @@ export function createRealtimeModule(config: { // Fetch token and attach on connect config.getToken(handlerName, instanceId).then((token) => { - ws.updateProperties({ query: { token } }); + ws.updateProperties({ party: handlerName, room: instanceId, query: { token } }); }); ws.addEventListener("message", (ev) => { @@ -46,7 +46,7 @@ export function createRealtimeModule(config: { if (activeSockets.get(key) !== ws) return; // replaced try { const newToken = await config.getToken(handlerName, instanceId); - ws.updateProperties({ query: { token: newToken } }); + ws.updateProperties({ party: handlerName, room: instanceId, query: { token: newToken } }); } catch { // ignore token refresh failure } From 308c68794e9daefe038512683c20b854f801c969 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 20:45:07 +0300 Subject: [PATCH 07/50] fix(realtime): use startClosed + reconnect after token fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Don't connect until we have a token — avoids initial tokenless connection being rejected and the updateProperties/reconnect timing race. Co-Authored-By: Claude Sonnet 4.6 --- src/modules/realtime.ts | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/modules/realtime.ts b/src/modules/realtime.ts index 845b9403..168db1f1 100644 --- a/src/modules/realtime.ts +++ b/src/modules/realtime.ts @@ -20,19 +20,16 @@ export function createRealtimeModule(config: { // close existing if any activeSockets.get(key)?.close(); + // startClosed: don't connect until we have a token const ws = new PartySocket({ host: config.dispatcherWsUrl, party: handlerName, room: instanceId, + startClosed: true, }); activeSockets.set(key, ws); - // Fetch token and attach on connect - config.getToken(handlerName, instanceId).then((token) => { - ws.updateProperties({ party: handlerName, room: instanceId, query: { token } }); - }); - ws.addEventListener("message", (ev) => { try { callback(JSON.parse(ev.data)); @@ -41,16 +38,20 @@ export function createRealtimeModule(config: { } }); - // Re-fetch token on reconnect - ws.addEventListener("close", async () => { - if (activeSockets.get(key) !== ws) return; // replaced + // Fetch token then open; re-fetch on every close (token expires in 30s) + const connect = async () => { + if (activeSockets.get(key) !== ws) return; try { - const newToken = await config.getToken(handlerName, instanceId); - ws.updateProperties({ party: handlerName, room: instanceId, query: { token: newToken } }); + const token = await config.getToken(handlerName, instanceId); + ws.updateProperties({ party: handlerName, room: instanceId, query: { token } }); + ws.reconnect(); } catch { - // ignore token refresh failure + // retry on next close } - }); + }; + + ws.addEventListener("close", () => connect()); + connect(); return () => { activeSockets.delete(key); From ddb6f6d88df9c946d5fd27d9590a2b2c43d8642b Mon Sep 17 00:00:00 2001 From: imrik Date: Wed, 1 Jul 2026 11:20:31 +0300 Subject: [PATCH 08/50] feat(realtime): add storage getter and onStart hook to RealtimeHandler Expose this.storage (DO KV) and onStart() lifecycle hook so handlers can persist and load state. Both are backed by the compiled shim at runtime; the stub implementations throw to surface misuse in local dev/test outside a deployed context. Co-Authored-By: Claude Sonnet 4.6 --- src/realtime-handler.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/realtime-handler.ts b/src/realtime-handler.ts index fcc7e1cc..e6756f62 100644 --- a/src/realtime-handler.ts +++ b/src/realtime-handler.ts @@ -17,12 +17,20 @@ export interface Conn { reject(code: number, reason: string): void; } +export interface Storage { + get(key: string): Promise; + put(key: string, value: unknown): Promise; + delete(key: string): Promise; +} + export abstract class RealtimeHandler<_State = unknown, Message = unknown> { abstract handleConnect(conn: Conn): void | Promise; abstract handleMessage(conn: Conn, msg: Message): void | Promise; abstract handleClose(conn: Conn): void | Promise; abstract handleTick(): void | Promise; + onStart(): void | Promise {} + protected broadcast(_data: unknown): void { throw new Error("RealtimeHandler.broadcast() is only available inside a deployed handler"); } @@ -38,4 +46,8 @@ export abstract class RealtimeHandler<_State = unknown, Message = unknown> { protected stopLoop(): Promise { throw new Error("RealtimeHandler.stopLoop() is only available inside a deployed handler"); } + + protected get storage(): Storage { + throw new Error("RealtimeHandler.storage is only available inside a deployed handler"); + } } From 92606629545a9b5600f917c7e094f702ac457298 Mon Sep 17 00:00:00 2001 From: imrik Date: Wed, 1 Jul 2026 17:00:08 +0300 Subject: [PATCH 09/50] feat(realtime): add createServiceClient() + fix kebab party routing + async token refresh --- src/modules/realtime.ts | 27 +++++++++------------------ src/realtime-handler.ts | 11 +++++++++++ 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/modules/realtime.ts b/src/modules/realtime.ts index 168db1f1..e47d6346 100644 --- a/src/modules/realtime.ts +++ b/src/modules/realtime.ts @@ -7,6 +7,11 @@ function socketKey(handlerName: string, instanceId: string) { return `${handlerName}:${instanceId}`; } +// partyserver maps binding names via camelCaseToKebabCase before routing +function toKebab(str: string): string { + return str.replace(/[A-Z]/g, (l) => `-${l.toLowerCase()}`).replace(/^-/, ""); +} + export function createRealtimeModule(config: { appId: string; getToken(handlerName: string, instanceId: string): Promise; @@ -20,12 +25,13 @@ export function createRealtimeModule(config: { // close existing if any activeSockets.get(key)?.close(); - // startClosed: don't connect until we have a token + // query as async fn: called on every (re)connect, fetches a fresh token each time + // party must be kebab-case: partyserver maps binding names via camelCaseToKebabCase const ws = new PartySocket({ host: config.dispatcherWsUrl, - party: handlerName, + party: toKebab(handlerName), room: instanceId, - startClosed: true, + query: () => config.getToken(handlerName, instanceId).then((token) => ({ token })), }); activeSockets.set(key, ws); @@ -38,21 +44,6 @@ export function createRealtimeModule(config: { } }); - // Fetch token then open; re-fetch on every close (token expires in 30s) - const connect = async () => { - if (activeSockets.get(key) !== ws) return; - try { - const token = await config.getToken(handlerName, instanceId); - ws.updateProperties({ party: handlerName, room: instanceId, query: { token } }); - ws.reconnect(); - } catch { - // retry on next close - } - }; - - ws.addEventListener("close", () => connect()); - connect(); - return () => { activeSockets.delete(key); ws.close(); diff --git a/src/realtime-handler.ts b/src/realtime-handler.ts index e6756f62..7b9f152d 100644 --- a/src/realtime-handler.ts +++ b/src/realtime-handler.ts @@ -9,6 +9,8 @@ * Cloudflare Durable Object implementation — this file provides types only. */ +import type { Base44Client } from "./client.types.js"; + export interface Conn { userId: string; appId: string; @@ -23,6 +25,7 @@ export interface Storage { delete(key: string): Promise; } + export abstract class RealtimeHandler<_State = unknown, Message = unknown> { abstract handleConnect(conn: Conn): void | Promise; abstract handleMessage(conn: Conn, msg: Message): void | Promise; @@ -47,7 +50,15 @@ export abstract class RealtimeHandler<_State = unknown, Message = unknown> { throw new Error("RealtimeHandler.stopLoop() is only available inside a deployed handler"); } + protected get instanceId(): string { + throw new Error("RealtimeHandler.instanceId is only available inside a deployed handler"); + } + protected get storage(): Storage { throw new Error("RealtimeHandler.storage is only available inside a deployed handler"); } + + protected createServiceClient(): Base44Client { + throw new Error("RealtimeHandler.createServiceClient() is only available inside a deployed handler"); + } } From e1f770aff21f353cad13bc27c7aa20f9f276ef34 Mon Sep 17 00:00:00 2001 From: imrik Date: Wed, 1 Jul 2026 17:03:53 +0300 Subject: [PATCH 10/50] =?UTF-8?q?fix(realtime):=20revert=20kebab-case=20pa?= =?UTF-8?q?rty=20conversion=20=E2=80=94=20dispatcher=20routes=20by=20JWT?= =?UTF-8?q?=20script=5Fname,=20not=20URL=20party=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/realtime.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/modules/realtime.ts b/src/modules/realtime.ts index e47d6346..0b98c2ef 100644 --- a/src/modules/realtime.ts +++ b/src/modules/realtime.ts @@ -7,11 +7,6 @@ function socketKey(handlerName: string, instanceId: string) { return `${handlerName}:${instanceId}`; } -// partyserver maps binding names via camelCaseToKebabCase before routing -function toKebab(str: string): string { - return str.replace(/[A-Z]/g, (l) => `-${l.toLowerCase()}`).replace(/^-/, ""); -} - export function createRealtimeModule(config: { appId: string; getToken(handlerName: string, instanceId: string): Promise; @@ -26,10 +21,9 @@ export function createRealtimeModule(config: { activeSockets.get(key)?.close(); // query as async fn: called on every (re)connect, fetches a fresh token each time - // party must be kebab-case: partyserver maps binding names via camelCaseToKebabCase const ws = new PartySocket({ host: config.dispatcherWsUrl, - party: toKebab(handlerName), + party: handlerName, room: instanceId, query: () => config.getToken(handlerName, instanceId).then((token) => ({ token })), }); From f9a22d21c65664899f6e8c9ea354628de35f0cc4 Mon Sep 17 00:00:00 2001 From: imrik Date: Thu, 2 Jul 2026 20:42:31 +0300 Subject: [PATCH 11/50] fix(realtime): add client heartbeat to detect half-open connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit partysocket only reconnects on a browser close/error event, so a silently dead connection (TCP alive, no data — common behind proxies/LBs) hung until the OS idle timeout (~60s), freezing the client. Add a ping/watchdog: send {"type":"__ping"} every 5s and force ws.reconnect() if nothing arrives for 12s, cutting detection from ~60s to seconds. __pong acks are swallowed. Pairs with the handler shim answering __ping so idle handlers stay proven. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/modules/realtime.ts | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/modules/realtime.ts b/src/modules/realtime.ts index 0b98c2ef..01578ae2 100644 --- a/src/modules/realtime.ts +++ b/src/modules/realtime.ts @@ -30,15 +30,47 @@ export function createRealtimeModule(config: { activeSockets.set(key, ws); + // Heartbeat / half-open detection. PartySocket only reconnects on a + // browser close/error event, so a silently-dead connection (TCP alive, + // no data — common behind proxies/LBs) hangs until the OS idle timeout + // (~60s). We ping periodically and force a reconnect if nothing comes + // back within DEAD_MS, cutting detection from ~60s to a few seconds. + // Pairs with the handler's setWebSocketAutoResponse("__ping"→"__pong"), + // so idle handlers (no app broadcasts) still keep the connection proven. + const PING_MS = 5_000; + const DEAD_MS = 12_000; + let lastMsg = Date.now(); + const bumpAlive = () => { lastMsg = Date.now(); }; + + ws.addEventListener("open", bumpAlive); ws.addEventListener("message", (ev) => { + bumpAlive(); + let data: unknown; try { - callback(JSON.parse(ev.data)); + data = JSON.parse(ev.data); } catch { - // ignore malformed + return; // ignore malformed } + // Swallow heartbeat acks — never surface them to the app. + if (data && typeof data === "object" && (data as { type?: unknown }).type === "__pong") return; + callback(data); }); + const 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 () => { + clearInterval(heartbeat); activeSockets.delete(key); ws.close(); }; From 9be245c9387e4950529caf3c904d90349024cb97 Mon Sep 17 00:00:00 2001 From: imrik Date: Thu, 2 Jul 2026 22:15:38 +0300 Subject: [PATCH 12/50] fix(realtime): tighten heartbeat to 1s ping / 3s dead for realtime UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 12s was too long for interactive apps (a frozen game). Realtime handlers push frequently, so ~2-3s of total silence reliably means a dead socket. Recover in ≤3s instead of ≤12s. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/modules/realtime.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/realtime.ts b/src/modules/realtime.ts index 01578ae2..2e3144c3 100644 --- a/src/modules/realtime.ts +++ b/src/modules/realtime.ts @@ -37,8 +37,8 @@ export function createRealtimeModule(config: { // back within DEAD_MS, cutting detection from ~60s to a few seconds. // Pairs with the handler's setWebSocketAutoResponse("__ping"→"__pong"), // so idle handlers (no app broadcasts) still keep the connection proven. - const PING_MS = 5_000; - const DEAD_MS = 12_000; + const PING_MS = 1_000; + const DEAD_MS = 3_000; let lastMsg = Date.now(); const bumpAlive = () => { lastMsg = Date.now(); }; From d9732eba39fbd7963d181d5d250d7419e50a8416 Mon Sep 17 00:00:00 2001 From: imrik Date: Sun, 5 Jul 2026 11:21:51 +0300 Subject: [PATCH 13/50] feat(realtime): expose connection id from subscribe subscribe() now returns { id, unsubscribe } instead of a bare unsubscribe fn, and accepts options.id to control the connection id (stable across reconnects/tabs if supplied, else auto-generated per connection). id matches the handler's conn.id, so clients can identify themselves without a server your_id message. BREAKING: subscribe() now returns RealtimeSubscription ({ id, unsubscribe() }) instead of a bare () => void; call sub.unsubscribe() instead of the old sub(). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/modules/realtime.ts | 34 ++++++++++++++++++++++++++++------ src/modules/realtime.types.ts | 24 ++++++++++++++++++++---- 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/src/modules/realtime.ts b/src/modules/realtime.ts index 2e3144c3..a626664b 100644 --- a/src/modules/realtime.ts +++ b/src/modules/realtime.ts @@ -15,7 +15,11 @@ export function createRealtimeModule(config: { return new Proxy({} as Record, { get(_, handlerName: string) { return { - subscribe(instanceId: string, callback: (data: unknown) => void): () => void { + subscribe( + instanceId: string, + callback: (data: unknown) => void, + options?: { id?: string }, + ): RealtimeSubscription { const key = socketKey(handlerName, instanceId); // close existing if any activeSockets.get(key)?.close(); @@ -25,6 +29,9 @@ export function createRealtimeModule(config: { host: config.dispatcherWsUrl, party: handlerName, room: instanceId, + // Connection id: caller-supplied (stable — reuse across reconnects/tabs as + // you see fit) or auto-generated per connection. Server sees it as conn.id. + id: options?.id, query: () => config.getToken(handlerName, instanceId).then((token) => ({ token })), }); @@ -69,10 +76,13 @@ export function createRealtimeModule(config: { } }, PING_MS); - return () => { - clearInterval(heartbeat); - activeSockets.delete(key); - ws.close(); + return { + id: ws.id, // the connection id (same value the handler sees as conn.id) + unsubscribe() { + clearInterval(heartbeat); + activeSockets.delete(key); + ws.close(); + }, }; }, send(instanceId: string, data: unknown) { @@ -86,7 +96,19 @@ export function createRealtimeModule(config: { }); } +/** Handle for an active realtime subscription. */ +interface RealtimeSubscription { + /** This connection's id — the same value the handler receives as `conn.id`. */ + id: string; + /** Close the subscription and its underlying socket. */ + unsubscribe(): void; +} + interface RealtimeHandler { - subscribe(instanceId: string, callback: (data: unknown) => void): () => void; + subscribe( + instanceId: string, + callback: (data: unknown) => void, + options?: { id?: string }, + ): RealtimeSubscription; send(instanceId: string, data: unknown): void; } diff --git a/src/modules/realtime.types.ts b/src/modules/realtime.types.ts index 8375d3a9..fe3c4762 100644 --- a/src/modules/realtime.types.ts +++ b/src/modules/realtime.types.ts @@ -44,16 +44,32 @@ type OutboundFor = N extends keyof RealtimeHandlerRegistry * Typed automatically when the handler is registered in {@link RealtimeHandlerRegistry}. */ export interface RealtimeHandlerClient { - /** Open a WebSocket subscription. Returns a synchronous unsubscribe function. */ + /** + * Open a WebSocket subscription. Returns a {@link RealtimeSubscription} with the + * connection `id` (same value the handler sees as `conn.id`) and an `unsubscribe()` method. + * + * Pass `options.id` to control the connection id (e.g. a stable per-tab id so a + * reconnect reuses the same server-side connection); omit it for an auto-generated + * per-connection id. + */ subscribe( instanceId: string, callback: (data: InboundFor) => void, - ): () => void; + options?: { id?: string }, + ): RealtimeSubscription; /** Send a message over the open socket. Throws if not subscribed. */ send(instanceId: string, data: OutboundFor): void; } +/** Handle for an active realtime subscription. */ +export interface RealtimeSubscription { + /** This connection's id — the same value the handler receives as `conn.id`. */ + id: string; + /** Close the subscription and its underlying socket. */ + unsubscribe(): void; +} + /** * The realtime module provides access to Cloudflare Durable Object-backed * RealtimeHandlers deployed by the Base44 platform. @@ -63,8 +79,8 @@ export interface RealtimeHandlerClient { * const sub = await base44.realtime.MyHandler.subscribe("room-1", (msg) => { * console.log(msg); // typed if MyHandler is in RealtimeHandlerRegistry * }); - * sub.send({ text: "hello" }); - * sub.close(); + * const { id, unsubscribe } = sub; + * unsubscribe(); * ``` */ export type RealtimeModule = { From 57dc67bcc314814f29f38512498669844614a2b2 Mon Sep 17 00:00:00 2001 From: imrik Date: Sun, 5 Jul 2026 11:37:36 +0300 Subject: [PATCH 14/50] feat(realtime): add managed-ticker API to RealtimeHandler type Add protected tickIntervalMs + optional shouldTick() so handlers get types for the platform-managed tick loop (implemented in the deployed shim). Opting in means no more startLoop/stopLoop bookkeeping in the handler. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/realtime-handler.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/realtime-handler.ts b/src/realtime-handler.ts index 7b9f152d..f7027419 100644 --- a/src/realtime-handler.ts +++ b/src/realtime-handler.ts @@ -34,6 +34,19 @@ export abstract class RealtimeHandler<_State = unknown, Message = unknown> { onStart(): 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: unknown): void { throw new Error("RealtimeHandler.broadcast() is only available inside a deployed handler"); } From 9f4762203819fd27b80cf0a23ee382c22c40d49b Mon Sep 17 00:00:00 2001 From: imrik Date: Sun, 5 Jul 2026 12:57:13 +0300 Subject: [PATCH 15/50] fix(realtime): add conn.id to the Conn type stub PR212 shipped the runtime conn.id (populated in the compiled shim's buildConn) but never added it to the type-only Conn interface, so handlers using conn.id failed to typecheck against the published SDK. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/realtime-handler.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/realtime-handler.ts b/src/realtime-handler.ts index f7027419..c4fac2dd 100644 --- a/src/realtime-handler.ts +++ b/src/realtime-handler.ts @@ -12,6 +12,10 @@ import type { Base44Client } from "./client.types.js"; 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; From f922705ccbec0e321c0bec9a5c421644b7155073 Mon Sep 17 00:00:00 2001 From: imrik Date: Sun, 5 Jul 2026 13:53:52 +0300 Subject: [PATCH 16/50] feat(realtime): type handler on both message directions RealtimeHandler and Conn replace the unused _State slot, so conn.send/broadcast/getConnections are typed with the handler outgoing (server->client) messages and handleMessage with the incoming (client->server) ones. Pair with the generated RealtimeHandlerRegistry: type Reg = RealtimeHandlerRegistry["MyHandler"]; class MyHandler extends RealtimeHandler {} so client and handler are typed from one schema and cannot drift. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/realtime-handler.ts | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/src/realtime-handler.ts b/src/realtime-handler.ts index c4fac2dd..c86c8160 100644 --- a/src/realtime-handler.ts +++ b/src/realtime-handler.ts @@ -11,7 +11,11 @@ import type { Base44Client } from "./client.types.js"; -export interface Conn { +/** + * A single client connection. `Send` is the message type this connection accepts + * via {@link send} — the handler'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. */ @@ -19,7 +23,7 @@ export interface Conn { userId: string; appId: string; instanceId: string; - send(data: unknown): void; + send(data: Send): void; reject(code: number, reason: string): void; } @@ -30,10 +34,25 @@ export interface Storage { } -export abstract class RealtimeHandler<_State = unknown, Message = unknown> { - abstract handleConnect(conn: Conn): void | Promise; - abstract handleMessage(conn: Conn, msg: Message): void | Promise; - abstract handleClose(conn: Conn): void | Promise; +/** + * Base class for a Realtime Handler. + * + * @typeParam Incoming - messages this handler *receives* from clients + * (`handleMessage`'s `msg`) — the client's outbound direction. + * @typeParam Outgoing - messages this handler *sends* to clients + * (`conn.send`/`broadcast`) — the client's inbound direction. + * + * With a generated `schema.jsonc`, wire both from the registry so they can't drift + * from the client's types: + * ```ts + * type Reg = RealtimeHandlerRegistry["MyHandler"]; + * class MyHandler extends RealtimeHandler { ... } + * ``` + */ +export abstract class RealtimeHandler { + abstract handleConnect(conn: Conn): void | Promise; + abstract handleMessage(conn: Conn, msg: Incoming): void | Promise; + abstract handleClose(conn: Conn): void | Promise; abstract handleTick(): void | Promise; onStart(): void | Promise {} @@ -51,11 +70,11 @@ export abstract class RealtimeHandler<_State = unknown, Message = unknown> { protected tickIntervalMs = 100; protected shouldTick?(): boolean; - protected broadcast(_data: unknown): void { + protected broadcast(_data: Outgoing): void { throw new Error("RealtimeHandler.broadcast() is only available inside a deployed handler"); } - protected getConnections(): Conn[] { + protected getConnections(): Conn[] { throw new Error("RealtimeHandler.getConnections() is only available inside a deployed handler"); } From 1939e6706def116f6d3644e74b20df1e11d10acb Mon Sep 17 00:00:00 2001 From: imrik Date: Sun, 5 Jul 2026 15:19:09 +0300 Subject: [PATCH 17/50] feat(realtime): carry connection id inside the signed realtime token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit subscribe() resolves connId (options.id or a random UUID) and sends it in the /realtime-token body instead of relying on a WS query param — proxies between the client and the dispatcher may strip every param except ?token (velino does). The backend signs it into the token claims; the dispatcher forwards the verified claim as partyserver's _pk, so the handler's conn.id equals the value subscribe() returns, synchronously and across reconnects. Co-Authored-By: Claude Fable 5 --- src/client.ts | 8 +++++--- src/modules/realtime.ts | 18 ++++++++++++------ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/client.ts b/src/client.ts index 22dc9333..bb927118 100644 --- a/src/client.ts +++ b/src/client.ts @@ -213,11 +213,13 @@ export function createClient(config: CreateClientConfig): Base44Client { realtime: createRealtimeModule({ appId, dispatcherWsUrl: resolvedDispatcherWsUrl, - getToken: async (handlerName, instanceId) => { - // axiosClient interceptors unwrap response.data, so the result is the body directly + getToken: async (handlerName, instanceId, connId) => { + // axiosClient interceptors unwrap response.data, so the result is the body directly. + // conn_id rides inside the signed token (not a WS query param) so it survives + // proxies that strip params; the dispatcher forwards it as the handler's conn.id. const data = await axiosClient.post( `/apps/${appId}/realtime-token`, - { handler_name: handlerName, instance_id: instanceId } + { handler_name: handlerName, instance_id: instanceId, conn_id: connId } ); return data.token; }, diff --git a/src/modules/realtime.ts b/src/modules/realtime.ts index a626664b..750a4bd9 100644 --- a/src/modules/realtime.ts +++ b/src/modules/realtime.ts @@ -9,7 +9,7 @@ function socketKey(handlerName: string, instanceId: string) { export function createRealtimeModule(config: { appId: string; - getToken(handlerName: string, instanceId: string): Promise; + getToken(handlerName: string, instanceId: string, connId: string): Promise; dispatcherWsUrl: string; }) { return new Proxy({} as Record, { @@ -24,15 +24,21 @@ export function createRealtimeModule(config: { // close existing if any activeSockets.get(key)?.close(); + // Connection id: caller-supplied (stable — reuse across reconnects/tabs as + // you see fit) or auto-generated per subscription. It travels INSIDE the + // signed realtime token (never as a WS query param, which proxies strip); + // the dispatcher forwards the verified claim as partyserver's _pk, so the + // handler sees this exact value as conn.id. Reconnects re-mint the token + // with the same id, so conn.id is stable across reconnects. + const connId = options?.id ?? crypto.randomUUID(); + // query as async fn: called on every (re)connect, fetches a fresh token each time const ws = new PartySocket({ host: config.dispatcherWsUrl, party: handlerName, room: instanceId, - // Connection id: caller-supplied (stable — reuse across reconnects/tabs as - // you see fit) or auto-generated per connection. Server sees it as conn.id. - id: options?.id, - query: () => config.getToken(handlerName, instanceId).then((token) => ({ token })), + query: () => + config.getToken(handlerName, instanceId, connId).then((token) => ({ token })), }); activeSockets.set(key, ws); @@ -77,7 +83,7 @@ export function createRealtimeModule(config: { }, PING_MS); return { - id: ws.id, // the connection id (same value the handler sees as conn.id) + id: connId, // the connection id (same value the handler sees as conn.id) unsubscribe() { clearInterval(heartbeat); activeSockets.delete(key); From 133f94c58a863488b4d2a9e287d5e5b256a3348f Mon Sep 17 00:00:00 2001 From: imrik Date: Mon, 6 Jul 2026 00:30:04 +0300 Subject: [PATCH 18/50] feat(realtime)!: registry keys inbound/outbound -> toClient/toServer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the renamed schema.jsonc sections (see cli). Reg["toServer"] = what the handler receives; Reg["toClient"] = what it sends — no direction flip to remember on either side. Co-Authored-By: Claude Fable 5 --- src/modules/realtime.types.ts | 16 ++++++++-------- src/realtime-handler.ts | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/modules/realtime.types.ts b/src/modules/realtime.types.ts index fe3c4762..6dbe8fc7 100644 --- a/src/modules/realtime.types.ts +++ b/src/modules/realtime.types.ts @@ -10,8 +10,8 @@ * declare module "@base44/sdk" { * interface RealtimeHandlerRegistry { * ChatRoom: { - * inbound: { type: "joined" | "left" | "message"; userId?: string; from?: string; text?: string }; - * outbound: { text: string }; + * toClient: { type: "joined" | "left" | "message"; userId?: string; from?: string; text?: string }; + * toServer: { type: "message"; text: string }; * }; * } * } @@ -27,14 +27,14 @@ export interface RealtimeHandlerNameRegistry {} type AllHandlerNames = keyof RealtimeHandlerRegistry | keyof RealtimeHandlerNameRegistry; -type InboundFor = N extends keyof RealtimeHandlerRegistry - ? RealtimeHandlerRegistry[N] extends { inbound: infer I } +type ToClientFor = N extends keyof RealtimeHandlerRegistry + ? RealtimeHandlerRegistry[N] extends { toClient: infer I } ? I : unknown : unknown; -type OutboundFor = N extends keyof RealtimeHandlerRegistry - ? RealtimeHandlerRegistry[N] extends { outbound: infer O } +type ToServerFor = N extends keyof RealtimeHandlerRegistry + ? RealtimeHandlerRegistry[N] extends { toServer: infer O } ? O : unknown : unknown; @@ -54,12 +54,12 @@ export interface RealtimeHandlerClient { */ subscribe( instanceId: string, - callback: (data: InboundFor) => void, + callback: (data: ToClientFor) => void, options?: { id?: string }, ): RealtimeSubscription; /** Send a message over the open socket. Throws if not subscribed. */ - send(instanceId: string, data: OutboundFor): void; + send(instanceId: string, data: ToServerFor): void; } /** Handle for an active realtime subscription. */ diff --git a/src/realtime-handler.ts b/src/realtime-handler.ts index c86c8160..3b976dfa 100644 --- a/src/realtime-handler.ts +++ b/src/realtime-handler.ts @@ -38,15 +38,15 @@ export interface Storage { * Base class for a Realtime Handler. * * @typeParam Incoming - messages this handler *receives* from clients - * (`handleMessage`'s `msg`) — the client's outbound direction. + * (`handleMessage`'s `msg`) — the schema's `toServer` section. * @typeParam Outgoing - messages this handler *sends* to clients - * (`conn.send`/`broadcast`) — the client's inbound direction. + * (`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 = RealtimeHandlerRegistry["MyHandler"]; - * class MyHandler extends RealtimeHandler { ... } + * class MyHandler extends RealtimeHandler { ... } * ``` */ export abstract class RealtimeHandler { From f4493de59f6e5c919d5cbb28fd825e80e0897f63 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 7 Jul 2026 09:37:03 +0300 Subject: [PATCH 19/50] feat(realtime): send Base44-Functions-Version on token requests Live apps (functionsVersion: "prod") get tokens for the published realtime script; previews get the draft. Same traffic signal function calls use. Co-Authored-By: Claude Fable 5 --- src/client.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/client.ts b/src/client.ts index bb927118..233a9645 100644 --- a/src/client.ts +++ b/src/client.ts @@ -217,9 +217,14 @@ export function createClient(config: CreateClientConfig): Base44Client { // axiosClient interceptors unwrap response.data, so the result is the body directly. // conn_id rides inside the signed token (not a WS query param) so it survives // proxies that strip params; the dispatcher forwards it as the handler's conn.id. + // Base44-Functions-Version rides along (like function calls) so live apps get + // tokens for the *published* realtime script and previews get the draft. const data = await axiosClient.post( `/apps/${appId}/realtime-token`, - { handler_name: handlerName, instance_id: instanceId, conn_id: connId } + { handler_name: handlerName, instance_id: instanceId, conn_id: connId }, + functionsVersion + ? { headers: { "Base44-Functions-Version": functionsVersion } } + : undefined ); return data.token; }, From 95ea4dc4082f2d90f0f179b6e550431cef589502 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 7 Jul 2026 10:20:58 +0300 Subject: [PATCH 20/50] feat(realtime): deliver user session token in-band for createUserClient - __auth sent on every socket open and pushed to active sockets on setToken (login/refresh); __auth_required nudges answered with the current token; platform messages never surface to the app callback - token request declares supports_inband_auth so the handler knows to wait one round-trip for the credential - RealtimeHandler type mirror gains createUserClient(conn) with docs steering RLS-respecting calls wherever a conn is in scope Co-Authored-By: Claude Fable 5 --- src/client.ts | 29 +++++++++++++++++++++++++++-- src/modules/realtime.ts | 32 ++++++++++++++++++++++++++++++-- src/realtime-handler.ts | 18 ++++++++++++++++++ 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/client.ts b/src/client.ts index 233a9645..e4f32c55 100644 --- a/src/client.ts +++ b/src/client.ts @@ -19,7 +19,7 @@ import type { CreateClientOptions, } from "./client.types.js"; import { createAnalyticsModule } from "./modules/analytics.js"; -import { createRealtimeModule } from "./modules/realtime.js"; +import { createRealtimeModule, pushUserTokenToActiveSockets } from "./modules/realtime.js"; // Re-export client types export type { Base44Client, CreateClientConfig, CreateClientOptions }; @@ -163,6 +163,22 @@ export function createClient(config: CreateClientConfig): Base44Client { } ); + // Current user session token (axios defaults are the single source of truth — + // createClient({token}) and every setToken() land there). Used for in-band + // realtime auth; read lazily so refreshes are always picked up. + const getUserToken = (): string | null => { + const h = axiosClient.defaults.headers.common?.["Authorization"]; + return typeof h === "string" && h.startsWith("Bearer ") ? h.slice(7) : null; + }; + + // Login / token refresh must reach long-lived realtime sockets too, so the + // handler-side credential never goes stale mid-connection. + const originalSetToken = userAuthModule.setToken.bind(userAuthModule); + userAuthModule.setToken = (newToken: string, saveToStorage?: boolean) => { + originalSetToken(newToken, saveToStorage); + pushUserTokenToActiveSockets(newToken); + }; + // Apply the access token before any module that may issue authenticated // requests during construction (notably analytics, which fires an init // event whose flush calls auth.me()). Without this, the first User/me @@ -213,6 +229,7 @@ export function createClient(config: CreateClientConfig): Base44Client { realtime: createRealtimeModule({ appId, dispatcherWsUrl: resolvedDispatcherWsUrl, + getUserToken, getToken: async (handlerName, instanceId, connId) => { // axiosClient interceptors unwrap response.data, so the result is the body directly. // conn_id rides inside the signed token (not a WS query param) so it survives @@ -221,7 +238,15 @@ export function createClient(config: CreateClientConfig): Base44Client { // tokens for the *published* realtime script and previews get the draft. const data = await axiosClient.post( `/apps/${appId}/realtime-token`, - { handler_name: handlerName, instance_id: instanceId, conn_id: connId }, + { + handler_name: handlerName, + instance_id: instanceId, + conn_id: connId, + // Declares "an __auth message follows right after connect" — the + // handler delays handleConnect until it arrives (signed into the + // token so old SDKs, which never send __auth, are never waited on). + supports_inband_auth: getUserToken() != null, + }, functionsVersion ? { headers: { "Base44-Functions-Version": functionsVersion } } : undefined diff --git a/src/modules/realtime.ts b/src/modules/realtime.ts index 750a4bd9..970226a4 100644 --- a/src/modules/realtime.ts +++ b/src/modules/realtime.ts @@ -7,9 +7,23 @@ function socketKey(handlerName: string, instanceId: string) { return `${handlerName}:${instanceId}`; } +/** Push a (new) user session token to every open realtime socket — called on + * login/refresh so long-lived connections keep a valid credential server-side. */ +export function pushUserTokenToActiveSockets(token: string) { + if (!token) return; + const payload = JSON.stringify({ type: "__auth", token }); + for (const ws of activeSockets.values()) { + try { ws.send(payload); } catch { /* not open — the open handler will send */ } + } +} + export function createRealtimeModule(config: { appId: string; getToken(handlerName: string, instanceId: string, connId: string): Promise; + /** Current user session token, if signed in. Sent in-band ({type:"__auth"}) + * right after every socket open — never in the URL — so the handler can act + * as this user (createUserClient / RLS). */ + getUserToken?: () => string | null; dispatcherWsUrl: string; }) { return new Proxy({} as Record, { @@ -43,6 +57,18 @@ export function createRealtimeModule(config: { activeSockets.set(key, ws); + // In-band credential delivery (Supabase-style): the user token rides the + // open socket, never the URL. Sent on every open (incl. reconnects); the + // server may also nudge with {type:"__auth_required"} (e.g. just before + // the held token expires) and we answer with the current one. + const sendAuth = () => { + const t = config.getUserToken?.(); + if (t) { + try { ws.send(JSON.stringify({ type: "__auth", token: t })); } catch { /* not open */ } + } + }; + ws.addEventListener("open", sendAuth); + // Heartbeat / half-open detection. PartySocket only reconnects on a // browser close/error event, so a silently-dead connection (TCP alive, // no data — common behind proxies/LBs) hangs until the OS idle timeout @@ -64,8 +90,10 @@ export function createRealtimeModule(config: { } catch { return; // ignore malformed } - // Swallow heartbeat acks — never surface them to the app. - if (data && typeof data === "object" && (data as { type?: unknown }).type === "__pong") return; + // Swallow platform messages — never surface them to the app. + const msgType = data && typeof data === "object" ? (data as { type?: unknown }).type : undefined; + if (msgType === "__pong") return; + if (msgType === "__auth_required") { sendAuth(); return; } callback(data); }); diff --git a/src/realtime-handler.ts b/src/realtime-handler.ts index 3b976dfa..87101730 100644 --- a/src/realtime-handler.ts +++ b/src/realtime-handler.ts @@ -94,6 +94,24 @@ export abstract class RealtimeHandler { throw new Error("RealtimeHandler.storage is only available inside a deployed handler"); } + /** + * SDK client acting **as the connected user** — every entity call respects + * the app's row-level security, evaluated as that user at call time. The + * default wherever a `conn` is in scope (connect/message/close). + * + * Throws if the connection carries no user credential (anonymous visitor, + * signed-out session, or an app SDK that predates in-band auth). + */ + protected createUserClient(conn: Conn): Base44Client { + void conn; + throw new Error("RealtimeHandler.createUserClient() is only available inside a deployed handler"); + } + + /** + * Service-role SDK client — bypasses RLS. For work with **no user in scope** + * (tick, alarm, onStart). Inside handleMessage/handleConnect prefer + * `createUserClient(conn)`. + */ protected createServiceClient(): Base44Client { throw new Error("RealtimeHandler.createServiceClient() is only available inside a deployed handler"); } From f6a9a48cd6dd4297cd4007c360fafd8cc7f1d345 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 7 Jul 2026 11:45:45 +0300 Subject: [PATCH 21/50] feat(realtime): webSocketImpl option for runtimes without global WebSocket Node < 22 has no global WebSocket, so PartySocket construction failed for any server-side realtime use. createClient({ webSocketImpl: WS }) passes the implementation through. Verified live on Node 20 with the ws package. Co-Authored-By: Claude Fable 5 --- src/client.ts | 2 ++ src/client.types.ts | 12 ++++++++++++ src/modules/realtime.ts | 4 ++++ 3 files changed, 18 insertions(+) diff --git a/src/client.ts b/src/client.ts index e4f32c55..d295ca6f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -73,6 +73,7 @@ export function createClient(config: CreateClientConfig): Base44Client { functionsVersion, headers: optionalHeaders, dispatcherWsUrl, + webSocketImpl, } = config; // Normalize appBaseUrl to always be a string (empty if not provided or invalid) @@ -229,6 +230,7 @@ export function createClient(config: CreateClientConfig): Base44Client { realtime: createRealtimeModule({ appId, dispatcherWsUrl: resolvedDispatcherWsUrl, + webSocketImpl, getUserToken, getToken: async (handlerName, instanceId, connId) => { // axiosClient interceptors unwrap response.data, so the result is the body directly. diff --git a/src/client.types.ts b/src/client.types.ts index 934adaf1..af20685d 100644 --- a/src/client.types.ts +++ b/src/client.types.ts @@ -85,6 +85,18 @@ export interface CreateClientConfig { * Override when the dispatcher lives at a different host than the API. */ 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; } /** diff --git a/src/modules/realtime.ts b/src/modules/realtime.ts index 970226a4..03774105 100644 --- a/src/modules/realtime.ts +++ b/src/modules/realtime.ts @@ -25,6 +25,8 @@ export function createRealtimeModule(config: { * as this user (createUserClient / RLS). */ getUserToken?: () => string | null; dispatcherWsUrl: string; + /** WebSocket implementation for runtimes without a global one (Node < 22). */ + webSocketImpl?: unknown; }) { return new Proxy({} as Record, { get(_, handlerName: string) { @@ -51,6 +53,8 @@ export function createRealtimeModule(config: { host: config.dispatcherWsUrl, party: handlerName, room: instanceId, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...(config.webSocketImpl ? { WebSocket: config.webSocketImpl as any } : {}), query: () => config.getToken(handlerName, instanceId, connId).then((token) => ({ token })), }); From 93da902d7c28568288e249df66ac66e5154dc99a Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 7 Jul 2026 15:43:08 +0300 Subject: [PATCH 22/50] =?UTF-8?q?feat(realtime):=20drop=20in-band=20=5F=5F?= =?UTF-8?q?auth=20=E2=80=94=20handler=20acts=20as=20user=20by=20verified?= =?UTF-8?q?=20id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handler now acts as the connected user via the service token + on-behalf-of the dispatcher-verified id, so the SDK no longer needs to deliver the user's session token over the socket. Removed pushUserTokenToActiveSockets, the __auth send + open listener, __auth_required handling, getUserToken, the setToken override, and the supports_inband_auth flag on the token request. Net removal. Co-Authored-By: Claude Fable 5 --- src/client.ts | 29 ++--------------------------- src/modules/realtime.ts | 29 ----------------------------- src/realtime-handler.ts | 3 +-- 3 files changed, 3 insertions(+), 58 deletions(-) diff --git a/src/client.ts b/src/client.ts index d295ca6f..8b093c12 100644 --- a/src/client.ts +++ b/src/client.ts @@ -19,7 +19,7 @@ import type { CreateClientOptions, } from "./client.types.js"; import { createAnalyticsModule } from "./modules/analytics.js"; -import { createRealtimeModule, pushUserTokenToActiveSockets } from "./modules/realtime.js"; +import { createRealtimeModule } from "./modules/realtime.js"; // Re-export client types export type { Base44Client, CreateClientConfig, CreateClientOptions }; @@ -164,22 +164,6 @@ export function createClient(config: CreateClientConfig): Base44Client { } ); - // Current user session token (axios defaults are the single source of truth — - // createClient({token}) and every setToken() land there). Used for in-band - // realtime auth; read lazily so refreshes are always picked up. - const getUserToken = (): string | null => { - const h = axiosClient.defaults.headers.common?.["Authorization"]; - return typeof h === "string" && h.startsWith("Bearer ") ? h.slice(7) : null; - }; - - // Login / token refresh must reach long-lived realtime sockets too, so the - // handler-side credential never goes stale mid-connection. - const originalSetToken = userAuthModule.setToken.bind(userAuthModule); - userAuthModule.setToken = (newToken: string, saveToStorage?: boolean) => { - originalSetToken(newToken, saveToStorage); - pushUserTokenToActiveSockets(newToken); - }; - // Apply the access token before any module that may issue authenticated // requests during construction (notably analytics, which fires an init // event whose flush calls auth.me()). Without this, the first User/me @@ -231,7 +215,6 @@ export function createClient(config: CreateClientConfig): Base44Client { appId, dispatcherWsUrl: resolvedDispatcherWsUrl, webSocketImpl, - getUserToken, getToken: async (handlerName, instanceId, connId) => { // axiosClient interceptors unwrap response.data, so the result is the body directly. // conn_id rides inside the signed token (not a WS query param) so it survives @@ -240,15 +223,7 @@ export function createClient(config: CreateClientConfig): Base44Client { // tokens for the *published* realtime script and previews get the draft. const data = await axiosClient.post( `/apps/${appId}/realtime-token`, - { - handler_name: handlerName, - instance_id: instanceId, - conn_id: connId, - // Declares "an __auth message follows right after connect" — the - // handler delays handleConnect until it arrives (signed into the - // token so old SDKs, which never send __auth, are never waited on). - supports_inband_auth: getUserToken() != null, - }, + { handler_name: handlerName, instance_id: instanceId, conn_id: connId }, functionsVersion ? { headers: { "Base44-Functions-Version": functionsVersion } } : undefined diff --git a/src/modules/realtime.ts b/src/modules/realtime.ts index 03774105..9fb8d9ac 100644 --- a/src/modules/realtime.ts +++ b/src/modules/realtime.ts @@ -7,23 +7,9 @@ function socketKey(handlerName: string, instanceId: string) { return `${handlerName}:${instanceId}`; } -/** Push a (new) user session token to every open realtime socket — called on - * login/refresh so long-lived connections keep a valid credential server-side. */ -export function pushUserTokenToActiveSockets(token: string) { - if (!token) return; - const payload = JSON.stringify({ type: "__auth", token }); - for (const ws of activeSockets.values()) { - try { ws.send(payload); } catch { /* not open — the open handler will send */ } - } -} - export function createRealtimeModule(config: { appId: string; getToken(handlerName: string, instanceId: string, connId: string): Promise; - /** Current user session token, if signed in. Sent in-band ({type:"__auth"}) - * right after every socket open — never in the URL — so the handler can act - * as this user (createUserClient / RLS). */ - getUserToken?: () => string | null; dispatcherWsUrl: string; /** WebSocket implementation for runtimes without a global one (Node < 22). */ webSocketImpl?: unknown; @@ -61,25 +47,11 @@ export function createRealtimeModule(config: { activeSockets.set(key, ws); - // In-band credential delivery (Supabase-style): the user token rides the - // open socket, never the URL. Sent on every open (incl. reconnects); the - // server may also nudge with {type:"__auth_required"} (e.g. just before - // the held token expires) and we answer with the current one. - const sendAuth = () => { - const t = config.getUserToken?.(); - if (t) { - try { ws.send(JSON.stringify({ type: "__auth", token: t })); } catch { /* not open */ } - } - }; - ws.addEventListener("open", sendAuth); - // Heartbeat / half-open detection. PartySocket only reconnects on a // browser close/error event, so a silently-dead connection (TCP alive, // no data — common behind proxies/LBs) hangs until the OS idle timeout // (~60s). We ping periodically and force a reconnect if nothing comes // back within DEAD_MS, cutting detection from ~60s to a few seconds. - // Pairs with the handler's setWebSocketAutoResponse("__ping"→"__pong"), - // so idle handlers (no app broadcasts) still keep the connection proven. const PING_MS = 1_000; const DEAD_MS = 3_000; let lastMsg = Date.now(); @@ -97,7 +69,6 @@ export function createRealtimeModule(config: { // Swallow platform messages — never surface them to the app. const msgType = data && typeof data === "object" ? (data as { type?: unknown }).type : undefined; if (msgType === "__pong") return; - if (msgType === "__auth_required") { sendAuth(); return; } callback(data); }); diff --git a/src/realtime-handler.ts b/src/realtime-handler.ts index 87101730..330f4051 100644 --- a/src/realtime-handler.ts +++ b/src/realtime-handler.ts @@ -99,8 +99,7 @@ export abstract class RealtimeHandler { * the app's row-level security, evaluated as that user at call time. The * default wherever a `conn` is in scope (connect/message/close). * - * Throws if the connection carries no user credential (anonymous visitor, - * signed-out session, or an app SDK that predates in-band auth). + * Throws for anonymous connections (no signed-in user to act as). */ protected createUserClient(conn: Conn): Base44Client { void conn; From 3e361c8ffd09a03598b4e4a59ee1fa9a6106fd32 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 7 Jul 2026 15:53:43 +0300 Subject: [PATCH 23/50] realtime: createUserClient returns anonymous client for anon connections Co-Authored-By: Claude Opus 4.8 --- src/realtime-handler.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/realtime-handler.ts b/src/realtime-handler.ts index 330f4051..648c978f 100644 --- a/src/realtime-handler.ts +++ b/src/realtime-handler.ts @@ -99,7 +99,8 @@ export abstract class RealtimeHandler { * the app's row-level security, evaluated as that user at call time. The * default wherever a `conn` is in scope (connect/message/close). * - * Throws for anonymous connections (no signed-in user to act as). + * Anonymous connections get an unauthenticated client — entity calls run + * under anonymous RLS, just like any public visitor. */ protected createUserClient(conn: Conn): Base44Client { void conn; From 32683e3a83520976acbe97b39d204310d2925b54 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 7 Jul 2026 17:02:33 +0300 Subject: [PATCH 24/50] realtime: rename createUserClient -> createClientFromConnection Co-Authored-By: Claude Opus 4.8 --- src/realtime-handler.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/realtime-handler.ts b/src/realtime-handler.ts index 648c978f..90827bab 100644 --- a/src/realtime-handler.ts +++ b/src/realtime-handler.ts @@ -102,15 +102,15 @@ export abstract class RealtimeHandler { * Anonymous connections get an unauthenticated client — entity calls run * under anonymous RLS, just like any public visitor. */ - protected createUserClient(conn: Conn): Base44Client { + protected createClientFromConnection(conn: Conn): Base44Client { void conn; - throw new Error("RealtimeHandler.createUserClient() is only available inside a deployed handler"); + throw new Error("RealtimeHandler.createClientFromConnection() is only available inside a deployed handler"); } /** * Service-role SDK client — bypasses RLS. For work with **no user in scope** * (tick, alarm, onStart). Inside handleMessage/handleConnect prefer - * `createUserClient(conn)`. + * `createClientFromConnection(conn)`. */ protected createServiceClient(): Base44Client { throw new Error("RealtimeHandler.createServiceClient() is only available inside a deployed handler"); From 241ee174b269efaebc20e42cbec40aa4c80a7d4f Mon Sep 17 00:00:00 2001 From: imrik Date: Thu, 9 Jul 2026 11:26:55 +0300 Subject: [PATCH 25/50] realtime: default WS to the app origin, not the API host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createClient now derives the realtime dispatcher WS URL from the app's own origin (appBaseUrl → window.location.origin → serverUrl) instead of always serverUrl, so the socket is same-origin with the running app (which proxies /parties to the backend). Explicit dispatcherWsUrl still overrides. Co-Authored-By: Claude Opus 4.8 --- src/client.ts | 14 +++++++++++--- src/client.types.ts | 7 +++++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/client.ts b/src/client.ts index 8b093c12..0bd322b5 100644 --- a/src/client.ts +++ b/src/client.ts @@ -79,11 +79,19 @@ export function createClient(config: CreateClientConfig): Base44Client { // Normalize appBaseUrl to always be a string (empty if not provided or invalid) const normalizedAppBaseUrl = typeof appBaseUrl === "string" ? appBaseUrl : ""; - // Derive the dispatcher WebSocket URL from serverUrl if not explicitly provided. - // Convert https:// → wss:// (or http:// → ws://) and strip trailing slash. + // 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(/\/$/, ""); - return serverUrl + 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://"); diff --git a/src/client.types.ts b/src/client.types.ts index af20685d..b486e374 100644 --- a/src/client.types.ts +++ b/src/client.types.ts @@ -81,8 +81,11 @@ export interface CreateClientConfig { /** * Base WebSocket URL for the Cloudflare Durable Object dispatcher. * - * Defaults to the `serverUrl` with `https://` replaced by `wss://` (or `http://` by `ws://`). - * Override when the dispatcher lives at a different host than the API. + * 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; /** From cdf4869009285a469a43f275959c58bddd52839b Mon Sep 17 00:00:00 2001 From: imrik Date: Thu, 9 Jul 2026 11:38:03 +0300 Subject: [PATCH 26/50] ci: unblock preview publish + drop dead eslint directive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - preview-publish: pin npm@11 (npm@latest is now 12.x, which needs node >=22 and fails EBADENGINE on the node-20 runner) — repo-wide breakage. - realtime.ts: remove eslint-disable for @typescript-eslint/no-explicit-any; that rule isn't registered in eslint.config.js, so the directive itself errored the Lint job. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/preview-publish.yml | 4 +++- src/modules/realtime.ts | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/preview-publish.yml b/.github/workflows/preview-publish.yml index 78634c34..47f1445c 100644 --- a/.github/workflows/preview-publish.yml +++ b/.github/workflows/preview-publish.yml @@ -23,7 +23,9 @@ jobs: registry-url: "https://registry.npmjs.org" - name: Update npm - run: npm install -g npm@latest + # 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 run: npm install diff --git a/src/modules/realtime.ts b/src/modules/realtime.ts index 9fb8d9ac..23498c86 100644 --- a/src/modules/realtime.ts +++ b/src/modules/realtime.ts @@ -39,7 +39,6 @@ export function createRealtimeModule(config: { host: config.dispatcherWsUrl, party: handlerName, room: instanceId, - // eslint-disable-next-line @typescript-eslint/no-explicit-any ...(config.webSocketImpl ? { WebSocket: config.webSocketImpl as any } : {}), query: () => config.getToken(handlerName, instanceId, connId).then((token) => ({ token })), From 92022638aa2e800f64a00325a6ab954c95964970 Mon Sep 17 00:00:00 2001 From: imrik Date: Thu, 9 Jul 2026 16:22:00 +0300 Subject: [PATCH 27/50] refactor(sdk): rename RealtimeHandler -> Actor (realtime -> actors) Rename the Durable-Object abstraction to the actor model across the SDK: - base class RealtimeHandler -> Actor (src/actor.ts) - client.realtime namespace -> client.actors; RealtimeModule -> ActorsModule; createRealtimeModule -> createActorsModule (src/modules/actors.ts) - registries/types: RealtimeHandlerClient -> ActorClient, *Registry, *Subscription - token endpoint POST /realtime-token -> /actor-token The entity-change RealtimeEvent feature and partyserver /parties/party/room terms are intentionally left unchanged. Co-Authored-By: Claude Opus 4.8 --- src/{realtime-handler.ts => actor.ts} | 38 +++++------ src/client.ts | 14 ++-- src/client.types.ts | 6 +- src/index.ts | 12 ++-- src/modules/{realtime.ts => actors.ts} | 42 ++++++------ src/modules/actors.types.ts | 90 ++++++++++++++++++++++++++ src/modules/realtime.types.ts | 90 -------------------------- 7 files changed, 146 insertions(+), 146 deletions(-) rename src/{realtime-handler.ts => actor.ts} (68%) rename src/modules/{realtime.ts => actors.ts} (74%) create mode 100644 src/modules/actors.types.ts delete mode 100644 src/modules/realtime.types.ts diff --git a/src/realtime-handler.ts b/src/actor.ts similarity index 68% rename from src/realtime-handler.ts rename to src/actor.ts index 90827bab..d71c84ab 100644 --- a/src/realtime-handler.ts +++ b/src/actor.ts @@ -1,9 +1,9 @@ /** - * Type-only base class for Realtime Handlers. + * Type-only base class for Actors. * - * Import and extend this in your handler files: - * import { RealtimeHandler } from "@base44/sdk"; - * export class MyHandler extends RealtimeHandler { ... } + * 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. @@ -13,7 +13,7 @@ import type { Base44Client } from "./client.types.js"; /** * A single client connection. `Send` is the message type this connection accepts - * via {@link send} — the handler's *outgoing* (server→client) messages. + * 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 @@ -35,21 +35,21 @@ export interface Storage { /** - * Base class for a Realtime Handler. + * Base class for an Actor. * - * @typeParam Incoming - messages this handler *receives* from clients + * @typeParam Incoming - messages this actor *receives* from clients * (`handleMessage`'s `msg`) — the schema's `toServer` section. - * @typeParam Outgoing - messages this handler *sends* to clients + * @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 = RealtimeHandlerRegistry["MyHandler"]; - * class MyHandler extends RealtimeHandler { ... } + * type Reg = ActorRegistry["MyActor"]; + * class MyActor extends Actor { ... } * ``` */ -export abstract class RealtimeHandler { +export abstract class Actor { abstract handleConnect(conn: Conn): void | Promise; abstract handleMessage(conn: Conn, msg: Incoming): void | Promise; abstract handleClose(conn: Conn): void | Promise; @@ -71,27 +71,27 @@ export abstract class RealtimeHandler { protected shouldTick?(): boolean; protected broadcast(_data: Outgoing): void { - throw new Error("RealtimeHandler.broadcast() is only available inside a deployed handler"); + throw new Error("Actor.broadcast() is only available inside a deployed actor"); } protected getConnections(): Conn[] { - throw new Error("RealtimeHandler.getConnections() is only available inside a deployed handler"); + throw new Error("Actor.getConnections() is only available inside a deployed actor"); } protected startLoop(_ms: number): Promise { - throw new Error("RealtimeHandler.startLoop() is only available inside a deployed handler"); + throw new Error("Actor.startLoop() is only available inside a deployed actor"); } protected stopLoop(): Promise { - throw new Error("RealtimeHandler.stopLoop() is only available inside a deployed handler"); + throw new Error("Actor.stopLoop() is only available inside a deployed actor"); } protected get instanceId(): string { - throw new Error("RealtimeHandler.instanceId is only available inside a deployed handler"); + throw new Error("Actor.instanceId is only available inside a deployed actor"); } protected get storage(): Storage { - throw new Error("RealtimeHandler.storage is only available inside a deployed handler"); + throw new Error("Actor.storage is only available inside a deployed actor"); } /** @@ -104,7 +104,7 @@ export abstract class RealtimeHandler { */ protected createClientFromConnection(conn: Conn): Base44Client { void conn; - throw new Error("RealtimeHandler.createClientFromConnection() is only available inside a deployed handler"); + throw new Error("Actor.createClientFromConnection() is only available inside a deployed actor"); } /** @@ -113,6 +113,6 @@ export abstract class RealtimeHandler { * `createClientFromConnection(conn)`. */ protected createServiceClient(): Base44Client { - throw new Error("RealtimeHandler.createServiceClient() is only available inside a deployed handler"); + throw new Error("Actor.createServiceClient() is only available inside a deployed actor"); } } diff --git a/src/client.ts b/src/client.ts index 0bd322b5..07352f14 100644 --- a/src/client.ts +++ b/src/client.ts @@ -19,7 +19,7 @@ import type { CreateClientOptions, } from "./client.types.js"; import { createAnalyticsModule } from "./modules/analytics.js"; -import { createRealtimeModule } from "./modules/realtime.js"; +import { createActorsModule } from "./modules/actors.js"; // Re-export client types export type { Base44Client, CreateClientConfig, CreateClientOptions }; @@ -219,19 +219,19 @@ export function createClient(config: CreateClientConfig): Base44Client { appId, userAuthModule, }), - realtime: createRealtimeModule({ + actors: createActorsModule({ appId, dispatcherWsUrl: resolvedDispatcherWsUrl, webSocketImpl, - getToken: async (handlerName, instanceId, connId) => { + getToken: async (actorName, instanceId, connId) => { // axiosClient interceptors unwrap response.data, so the result is the body directly. // conn_id rides inside the signed token (not a WS query param) so it survives - // proxies that strip params; the dispatcher forwards it as the handler's conn.id. + // proxies that strip params; the dispatcher forwards it as the actor's conn.id. // Base44-Functions-Version rides along (like function calls) so live apps get - // tokens for the *published* realtime script and previews get the draft. + // tokens for the *published* actor script and previews get the draft. const data = await axiosClient.post( - `/apps/${appId}/realtime-token`, - { handler_name: handlerName, instance_id: instanceId, conn_id: connId }, + `/apps/${appId}/actor-token`, + { handler_name: actorName, instance_id: instanceId, conn_id: connId }, functionsVersion ? { headers: { "Base44-Functions-Version": functionsVersion } } : undefined diff --git a/src/client.types.ts b/src/client.types.ts index b486e374..0de6f9fe 100644 --- a/src/client.types.ts +++ b/src/client.types.ts @@ -10,7 +10,7 @@ import type { FunctionsModule } from "./modules/functions.types.js"; import type { AgentsModule } from "./modules/agents.types.js"; import type { AppLogsModule } from "./modules/app-logs.types.js"; import type { AnalyticsModule } from "./modules/analytics.types.js"; -import type { RealtimeModule } from "./modules/realtime.types.js"; +import type { ActorsModule } from "./modules/actors.types.js"; /** * Options for creating a Base44 client. @@ -114,8 +114,8 @@ export interface Base44Client { analytics: AnalyticsModule; /** {@link AppLogsModule | App logs module} for tracking app usage. */ appLogs: AppLogsModule; - /** {@link RealtimeModule | Realtime module} for subscribing to and sending messages via Cloudflare Durable Object-backed RealtimeHandlers. */ - realtime: RealtimeModule; + /** {@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 e335fd10..0b45312b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -103,15 +103,15 @@ export type { export type { AppLogsModule } from "./modules/app-logs.types.js"; export type { - RealtimeModule, - RealtimeHandlerClient, - RealtimeHandlerNameRegistry, - RealtimeHandlerRegistry, -} from "./modules/realtime.types.js"; + ActorsModule, + ActorClient, + ActorNameRegistry, + ActorRegistry, +} from "./modules/actors.types.js"; export type { SsoModule, SsoAccessTokenResponse } from "./modules/sso.types.js"; -export { RealtimeHandler, type Conn } from "./realtime-handler.js"; +export { Actor, type Conn } from "./actor.js"; export type { ConnectorsModule, diff --git a/src/modules/realtime.ts b/src/modules/actors.ts similarity index 74% rename from src/modules/realtime.ts rename to src/modules/actors.ts index 23498c86..cc289438 100644 --- a/src/modules/realtime.ts +++ b/src/modules/actors.ts @@ -1,47 +1,47 @@ import PartySocket from "partysocket"; -// Module-level map: "HandlerName:instanceId" → active socket +// Module-level map: "ActorName:instanceId" → active socket const activeSockets = new Map(); -function socketKey(handlerName: string, instanceId: string) { - return `${handlerName}:${instanceId}`; +function socketKey(actorName: string, instanceId: string) { + return `${actorName}:${instanceId}`; } -export function createRealtimeModule(config: { +export function createActorsModule(config: { appId: string; - getToken(handlerName: string, instanceId: string, connId: string): Promise; + getToken(actorName: string, instanceId: string, connId: string): Promise; dispatcherWsUrl: string; /** WebSocket implementation for runtimes without a global one (Node < 22). */ webSocketImpl?: unknown; }) { - return new Proxy({} as Record, { - get(_, handlerName: string) { + return new Proxy({} as Record, { + get(_, actorName: string) { return { subscribe( instanceId: string, callback: (data: unknown) => void, options?: { id?: string }, - ): RealtimeSubscription { - const key = socketKey(handlerName, instanceId); + ): ActorSubscription { + const key = socketKey(actorName, instanceId); // close existing if any activeSockets.get(key)?.close(); // Connection id: caller-supplied (stable — reuse across reconnects/tabs as // you see fit) or auto-generated per subscription. It travels INSIDE the - // signed realtime token (never as a WS query param, which proxies strip); + // signed actor token (never as a WS query param, which proxies strip); // the dispatcher forwards the verified claim as partyserver's _pk, so the - // handler sees this exact value as conn.id. Reconnects re-mint the token + // actor sees this exact value as conn.id. Reconnects re-mint the token // with the same id, so conn.id is stable across reconnects. const connId = options?.id ?? crypto.randomUUID(); // query as async fn: called on every (re)connect, fetches a fresh token each time const ws = new PartySocket({ host: config.dispatcherWsUrl, - party: handlerName, + party: actorName, room: instanceId, ...(config.webSocketImpl ? { WebSocket: config.webSocketImpl as any } : {}), query: () => - config.getToken(handlerName, instanceId, connId).then((token) => ({ token })), + config.getToken(actorName, instanceId, connId).then((token) => ({ token })), }); activeSockets.set(key, ws); @@ -85,7 +85,7 @@ export function createRealtimeModule(config: { }, PING_MS); return { - id: connId, // the connection id (same value the handler sees as conn.id) + id: connId, // the connection id (same value the actor sees as conn.id) unsubscribe() { clearInterval(heartbeat); activeSockets.delete(key); @@ -94,9 +94,9 @@ export function createRealtimeModule(config: { }; }, send(instanceId: string, data: unknown) { - const key = socketKey(handlerName, instanceId); + const key = socketKey(actorName, instanceId); const ws = activeSockets.get(key); - if (!ws) throw new Error(`No active subscription for ${handlerName}:${instanceId}`); + if (!ws) throw new Error(`No active subscription for ${actorName}:${instanceId}`); ws.send(JSON.stringify(data)); }, }; @@ -104,19 +104,19 @@ export function createRealtimeModule(config: { }); } -/** Handle for an active realtime subscription. */ -interface RealtimeSubscription { - /** This connection's id — the same value the handler receives as `conn.id`. */ +/** Handle for an active actor subscription. */ +interface ActorSubscription { + /** This connection's id — the same value the actor receives as `conn.id`. */ id: string; /** Close the subscription and its underlying socket. */ unsubscribe(): void; } -interface RealtimeHandler { +interface ActorClient { subscribe( instanceId: string, callback: (data: unknown) => void, options?: { id?: string }, - ): RealtimeSubscription; + ): ActorSubscription; send(instanceId: string, data: unknown): void; } diff --git a/src/modules/actors.types.ts b/src/modules/actors.types.ts new file mode 100644 index 00000000..2c191fb4 --- /dev/null +++ b/src/modules/actors.types.ts @@ -0,0 +1,90 @@ +/** + * 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; + +/** + * Client for a single named Actor. + * Typed automatically when the actor is registered in {@link ActorRegistry}. + */ +export interface ActorClient { + /** + * Open a WebSocket subscription. Returns a {@link ActorSubscription} with the + * connection `id` (same value the actor sees as `conn.id`) and an `unsubscribe()` method. + * + * Pass `options.id` to control the connection id (e.g. a stable per-tab id so a + * reconnect reuses the same server-side connection); omit it for an auto-generated + * per-connection id. + */ + subscribe( + instanceId: string, + callback: (data: ToClientFor) => void, + options?: { id?: string }, + ): ActorSubscription; + + /** Send a message over the open socket. Throws if not subscribed. */ + send(instanceId: string, data: ToServerFor): void; +} + +/** Handle for an active actor subscription. */ +export interface ActorSubscription { + /** This connection's id — the same value the actor receives as `conn.id`. */ + id: string; + /** Close the subscription and its underlying socket. */ + unsubscribe(): void; +} + +/** + * The actors module provides access to Cloudflare Durable Object-backed + * Actors deployed by the Base44 platform. + * + * Actor names are accessed as dynamic properties on this module: + * ```typescript + * const sub = await base44.actors.MyActor.subscribe("room-1", (msg) => { + * console.log(msg); // typed if MyActor is in ActorRegistry + * }); + * const { id, unsubscribe } = sub; + * unsubscribe(); + * ``` + */ +export type ActorsModule = { + [K in AllActorNames]: K extends keyof ActorRegistry + ? ActorClient + : ActorClient; +} & Record; diff --git a/src/modules/realtime.types.ts b/src/modules/realtime.types.ts deleted file mode 100644 index 6dbe8fc7..00000000 --- a/src/modules/realtime.types.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Extend this interface to add typed `subscribe` callbacks and `send` payloads - * for your deployed RealtimeHandlers. - * - * This is separate from {@link RealtimeHandlerNameRegistry} (which is auto-generated - * by `base44 types generate`), so there are no conflicts. - * - * @example - * ```typescript - * declare module "@base44/sdk" { - * interface RealtimeHandlerRegistry { - * ChatRoom: { - * toClient: { type: "joined" | "left" | "message"; userId?: string; from?: string; text?: string }; - * toServer: { type: "message"; text: string }; - * }; - * } - * } - * ``` - */ -export interface RealtimeHandlerRegistry {} - -/** - * Auto-populated by `base44 types generate` with the names of your deployed handlers. - * Do not edit this interface manually — use {@link RealtimeHandlerRegistry} for message types. - */ -export interface RealtimeHandlerNameRegistry {} - -type AllHandlerNames = keyof RealtimeHandlerRegistry | keyof RealtimeHandlerNameRegistry; - -type ToClientFor = N extends keyof RealtimeHandlerRegistry - ? RealtimeHandlerRegistry[N] extends { toClient: infer I } - ? I - : unknown - : unknown; - -type ToServerFor = N extends keyof RealtimeHandlerRegistry - ? RealtimeHandlerRegistry[N] extends { toServer: infer O } - ? O - : unknown - : unknown; - -/** - * Client for a single named RealtimeHandler. - * Typed automatically when the handler is registered in {@link RealtimeHandlerRegistry}. - */ -export interface RealtimeHandlerClient { - /** - * Open a WebSocket subscription. Returns a {@link RealtimeSubscription} with the - * connection `id` (same value the handler sees as `conn.id`) and an `unsubscribe()` method. - * - * Pass `options.id` to control the connection id (e.g. a stable per-tab id so a - * reconnect reuses the same server-side connection); omit it for an auto-generated - * per-connection id. - */ - subscribe( - instanceId: string, - callback: (data: ToClientFor) => void, - options?: { id?: string }, - ): RealtimeSubscription; - - /** Send a message over the open socket. Throws if not subscribed. */ - send(instanceId: string, data: ToServerFor): void; -} - -/** Handle for an active realtime subscription. */ -export interface RealtimeSubscription { - /** This connection's id — the same value the handler receives as `conn.id`. */ - id: string; - /** Close the subscription and its underlying socket. */ - unsubscribe(): void; -} - -/** - * The realtime module provides access to Cloudflare Durable Object-backed - * RealtimeHandlers deployed by the Base44 platform. - * - * Handler names are accessed as dynamic properties on this module: - * ```typescript - * const sub = await base44.realtime.MyHandler.subscribe("room-1", (msg) => { - * console.log(msg); // typed if MyHandler is in RealtimeHandlerRegistry - * }); - * const { id, unsubscribe } = sub; - * unsubscribe(); - * ``` - */ -export type RealtimeModule = { - [K in AllHandlerNames]: K extends keyof RealtimeHandlerRegistry - ? RealtimeHandlerClient - : RealtimeHandlerClient; -} & Record; From 43e4e535dddc0b35f66ebbdfeaf8326240866daf Mon Sep 17 00:00:00 2001 From: imrik Date: Sun, 12 Jul 2026 18:44:45 +0300 Subject: [PATCH 28/50] feat(actor): handleStart wake hook replaces user onStart overrides Matches the platform shim: handleStart runs after credential setup, so createServiceClient is safe there and no super-call contract exists. Co-Authored-By: Claude Fable 5 --- src/actor.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/actor.ts b/src/actor.ts index d71c84ab..c323539a 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -55,7 +55,12 @@ export abstract class Actor { abstract handleClose(conn: Conn): void | Promise; abstract handleTick(): void | Promise; - onStart(): void | Promise {} + /** + * Optional wake hook: runs once when the instance starts, after the + * platform's credential setup and before any connection is handled — safe + * to use {@link createServiceClient} and load persisted state here. + */ + handleStart(): void | Promise {} /** * Managed ticker (opt-in). Override {@link shouldTick} and the platform runs @@ -109,7 +114,7 @@ export abstract class Actor { /** * Service-role SDK client — bypasses RLS. For work with **no user in scope** - * (tick, alarm, onStart). Inside handleMessage/handleConnect prefer + * (tick, alarm, handleStart). Inside handleMessage/handleConnect prefer * `createClientFromConnection(conn)`. */ protected createServiceClient(): Base44Client { From 227561d0732ef4d3f834503188e34f26d6fd11ed Mon Sep 17 00:00:00 2001 From: imrik Date: Sun, 19 Jul 2026 17:42:07 +0300 Subject: [PATCH 29/50] feat(actor): Storage.deleteAll for match-end room cleanup Co-Authored-By: Claude Fable 5 --- src/actor.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/actor.ts b/src/actor.ts index c323539a..3de0c403 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -31,6 +31,9 @@ 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; } From 76e440c8255e5d9e90e311bf8dade04cbd73125c Mon Sep 17 00:00:00 2001 From: imrik Date: Wed, 22 Jul 2026 12:59:17 +0300 Subject: [PATCH 30/50] =?UTF-8?q?feat(actors):=20phase=201=20=E2=80=94=20d?= =?UTF-8?q?rop=20createServiceClient=20/=20createClientFromConnection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 actors are anonymous-only: the deployed shim no longer implements the credentialed clients, so remove their stubs from the Actor base class. Generated actor code that calls them is now a compile error (caught at deploy typecheck) rather than a runtime crash. handleStart doc no longer references them. Co-Authored-By: Claude Opus 4.8 --- src/actor.ts | 27 ++------------------------- 1 file changed, 2 insertions(+), 25 deletions(-) diff --git a/src/actor.ts b/src/actor.ts index 3de0c403..c08042f4 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -9,7 +9,6 @@ * Cloudflare Durable Object implementation — this file provides types only. */ -import type { Base44Client } from "./client.types.js"; /** * A single client connection. `Send` is the message type this connection accepts @@ -59,9 +58,8 @@ export abstract class Actor { abstract handleTick(): void | Promise; /** - * Optional wake hook: runs once when the instance starts, after the - * platform's credential setup and before any connection is handled — safe - * to use {@link createServiceClient} and load persisted state here. + * Optional wake hook: runs once when the instance starts, before any + * connection is handled — safe to load persisted state here. */ handleStart(): void | Promise {} @@ -102,25 +100,4 @@ export abstract class Actor { throw new Error("Actor.storage is only available inside a deployed actor"); } - /** - * SDK client acting **as the connected user** — every entity call respects - * the app's row-level security, evaluated as that user at call time. The - * default wherever a `conn` is in scope (connect/message/close). - * - * Anonymous connections get an unauthenticated client — entity calls run - * under anonymous RLS, just like any public visitor. - */ - protected createClientFromConnection(conn: Conn): Base44Client { - void conn; - throw new Error("Actor.createClientFromConnection() is only available inside a deployed actor"); - } - - /** - * Service-role SDK client — bypasses RLS. For work with **no user in scope** - * (tick, alarm, handleStart). Inside handleMessage/handleConnect prefer - * `createClientFromConnection(conn)`. - */ - protected createServiceClient(): Base44Client { - throw new Error("Actor.createServiceClient() is only available inside a deployed actor"); - } } From 860daa46750f9157416db67eddd941fa0a6f186d Mon Sep 17 00:00:00 2001 From: imrik Date: Wed, 22 Jul 2026 16:46:13 +0300 Subject: [PATCH 31/50] =?UTF-8?q?feat(actors):=20connect=20without=20a=20t?= =?UTF-8?q?oken=20mint=20=E2=80=94=20auth=20rides=20the=20WS=20query?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The platform proxy now authorizes actor connections like backend-function calls, so subscribe() connects directly: ?app_id + ?handler (case-preserved actor name; the party path segment is lowercased by partysocket) + the regular user access token when signed in (?token, same credential function calls send; anonymous when absent) + ?fv for draft-vs-published. The connection id goes via PartySocket's id option (?_pk=). One less HTTP round-trip per connect; no token refresh on reconnect — the query fn is re-read so login state stays fresh. Co-Authored-By: Claude Opus 4.8 --- src/client.ts | 19 ++++--------------- src/modules/actors.ts | 34 +++++++++++++++++++++++++--------- 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/src/client.ts b/src/client.ts index 5d1768eb..67a690f5 100644 --- a/src/client.ts +++ b/src/client.ts @@ -225,21 +225,10 @@ export function createClient(config: CreateClientConfig): Base44Client { appId, dispatcherWsUrl: resolvedDispatcherWsUrl, webSocketImpl, - getToken: async (actorName, instanceId, connId) => { - // axiosClient interceptors unwrap response.data, so the result is the body directly. - // conn_id rides inside the signed token (not a WS query param) so it survives - // proxies that strip params; the dispatcher forwards it as the actor's conn.id. - // Base44-Functions-Version rides along (like function calls) so live apps get - // tokens for the *published* actor script and previews get the draft. - const data = await axiosClient.post( - `/apps/${appId}/actor-token`, - { handler_name: actorName, instance_id: instanceId, conn_id: connId }, - functionsVersion - ? { headers: { "Base44-Functions-Version": functionsVersion } } - : undefined - ); - return data.token; - }, + 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(); diff --git a/src/modules/actors.ts b/src/modules/actors.ts index cc289438..648f938c 100644 --- a/src/modules/actors.ts +++ b/src/modules/actors.ts @@ -9,7 +9,13 @@ function socketKey(actorName: string, instanceId: string) { export function createActorsModule(config: { appId: string; - getToken(actorName: string, instanceId: string, connId: string): Promise; + /** Current user access token, if authenticated. Rides the WS query (same + * pattern as the entities socket) so the platform proxy can authenticate the + * connection like a backend-function call; 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; @@ -27,21 +33,31 @@ export function createActorsModule(config: { activeSockets.get(key)?.close(); // Connection id: caller-supplied (stable — reuse across reconnects/tabs as - // you see fit) or auto-generated per subscription. It travels INSIDE the - // signed actor token (never as a WS query param, which proxies strip); - // the dispatcher forwards the verified claim as partyserver's _pk, so the - // actor sees this exact value as conn.id. Reconnects re-mint the token - // with the same id, so conn.id is stable across reconnects. + // you see fit) or auto-generated per subscription. PartySocket sends it + // as ?_pk=; the platform proxy validates it and the actor sees this exact + // value as conn.id, stable across reconnects. const connId = options?.id ?? crypto.randomUUID(); - // query as async fn: called on every (re)connect, fetches a fresh token each time + // No pre-connect token mint: the platform proxy authenticates the + // connection itself, exactly like a backend-function call. `handler` + // carries the case-preserved actor name (the `party` path segment is + // lowercased by PartySocket). query as fn: re-read on every (re)connect + // so a login/logout between reconnects is picked up. const ws = new PartySocket({ host: config.dispatcherWsUrl, party: actorName, room: instanceId, + id: connId, ...(config.webSocketImpl ? { WebSocket: config.webSocketImpl as any } : {}), - query: () => - config.getToken(actorName, instanceId, connId).then((token) => ({ token })), + query: () => { + const token = config.getAuthToken(); + return { + app_id: config.appId, + handler: actorName, + ...(token ? { token } : {}), + ...(config.functionsVersion ? { fv: config.functionsVersion } : {}), + }; + }, }); activeSockets.set(key, ws); From 3c9161666af2d5a8e0e8afd44fd64c4826b86640 Mon Sep 17 00:00:00 2001 From: imrik Date: Wed, 22 Jul 2026 16:51:42 +0300 Subject: [PATCH 32/50] chore: retrigger preview publish (dropped workflow event) From b501f2e58636e42667dab47e62fa778efe2b70fa Mon Sep 17 00:00:00 2001 From: imrik Date: Wed, 22 Jul 2026 18:59:13 +0300 Subject: [PATCH 33/50] =?UTF-8?q?feat(actors):=20room-handle=20API=20?= =?UTF-8?q?=E2=80=94=20actors.Name(id).connect()=20then=20subscribe/send?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the flat subscribe(roomId, cb) / send(roomId, msg) surface. A room handle IS the connection: connect() opens the socket (required, idempotent), subscribe() registers one of N independent listeners (returns a per-listener unsubscribe), send() throws before connect, close() tears down socket + heartbeat + all listeners. Fixes the old wart where a second subscribe to the same room closed the first socket. Co-Authored-By: Claude Opus 4.8 --- src/modules/actors.ts | 232 ++++++++++++++++++------------------ src/modules/actors.types.ts | 73 +++++++----- tests/unit/actors.test.ts | 122 +++++++++++++++++++ 3 files changed, 284 insertions(+), 143 deletions(-) create mode 100644 tests/unit/actors.test.ts diff --git a/src/modules/actors.ts b/src/modules/actors.ts index 648f938c..d29224ef 100644 --- a/src/modules/actors.ts +++ b/src/modules/actors.ts @@ -1,13 +1,11 @@ import PartySocket from "partysocket"; +import type { + ActorConnectOptions, + ActorRoom, + ActorSubscription, +} from "./actors.types.js"; -// Module-level map: "ActorName:instanceId" → active socket -const activeSockets = new Map(); - -function socketKey(actorName: string, instanceId: string) { - return `${actorName}:${instanceId}`; -} - -export function createActorsModule(config: { +interface ActorsConfig { appId: string; /** Current user access token, if authenticated. Rides the WS query (same * pattern as the entities socket) so the platform proxy can authenticate the @@ -19,120 +17,126 @@ export function createActorsModule(config: { dispatcherWsUrl: string; /** WebSocket implementation for runtimes without a global one (Node < 22). */ webSocketImpl?: unknown; -}) { - return new Proxy({} as Record, { - get(_, actorName: string) { - return { - subscribe( - instanceId: string, - callback: (data: unknown) => void, - options?: { id?: string }, - ): ActorSubscription { - const key = socketKey(actorName, instanceId); - // close existing if any - activeSockets.get(key)?.close(); +} - // Connection id: caller-supplied (stable — reuse across reconnects/tabs as - // you see fit) or auto-generated per subscription. PartySocket sends it - // as ?_pk=; the platform proxy validates it and the actor sees this exact - // value as conn.id, stable across reconnects. - const connId = options?.id ?? crypto.randomUUID(); +// Heartbeat / half-open detection. PartySocket only reconnects on a browser +// close/error event, so a silently-dead connection (TCP alive, no data — common +// behind proxies/LBs) hangs until the OS idle timeout (~60s). Ping periodically +// and force a reconnect if nothing comes back within DEAD_MS. +const PING_MS = 1_000; +const DEAD_MS = 3_000; - // No pre-connect token mint: the platform proxy authenticates the - // connection itself, exactly like a backend-function call. `handler` - // carries the case-preserved actor name (the `party` path segment is - // lowercased by PartySocket). query as fn: re-read on every (re)connect - // so a login/logout between reconnects is picked up. - const ws = new PartySocket({ - host: config.dispatcherWsUrl, - party: actorName, - room: instanceId, - id: connId, - ...(config.webSocketImpl ? { WebSocket: config.webSocketImpl as any } : {}), - query: () => { - const token = config.getAuthToken(); - return { - app_id: config.appId, - handler: actorName, - ...(token ? { token } : {}), - ...(config.functionsVersion ? { fv: config.functionsVersion } : {}), - }; - }, - }); +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; - activeSockets.set(key, ws); + constructor( + private readonly actorName: string, + private readonly instanceId: string, + private readonly config: ActorsConfig, + ) {} - // Heartbeat / half-open detection. PartySocket only reconnects on a - // browser close/error event, so a silently-dead connection (TCP alive, - // no data — common behind proxies/LBs) hangs until the OS idle timeout - // (~60s). We ping periodically and force a reconnect if nothing comes - // back within DEAD_MS, cutting detection from ~60s to a few seconds. - const PING_MS = 1_000; - const DEAD_MS = 3_000; - let lastMsg = Date.now(); - const bumpAlive = () => { lastMsg = Date.now(); }; + get id(): string { + if (!this.connId) { + throw new Error(`${this.actorName}:${this.instanceId}: connect() before reading id`); + } + return this.connId; + } - ws.addEventListener("open", bumpAlive); - ws.addEventListener("message", (ev) => { - bumpAlive(); - let data: unknown; - try { - data = JSON.parse(ev.data); - } catch { - return; // ignore malformed - } - // Swallow platform messages — never surface them to the app. - const msgType = data && typeof data === "object" ? (data as { type?: unknown }).type : undefined; - if (msgType === "__pong") return; - callback(data); - }); + connect(options?: ActorConnectOptions): this { + if (this.ws) return this; - const 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); + // The client picks its own conn id; it becomes _pk → the actor's conn.id. + const connId = options?.id ?? crypto.randomUUID(); + this.connId = connId; - return { - id: connId, // the connection id (same value the actor sees as conn.id) - unsubscribe() { - clearInterval(heartbeat); - activeSockets.delete(key); - ws.close(); - }, - }; - }, - send(instanceId: string, data: unknown) { - const key = socketKey(actorName, instanceId); - const ws = activeSockets.get(key); - if (!ws) throw new Error(`No active subscription for ${actorName}:${instanceId}`); - ws.send(JSON.stringify(data)); - }, - }; - }, - }); -} + 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 between reconnects is + // picked up. The platform proxy authenticates the connection itself — + // no pre-connect token mint. + 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; -/** Handle for an active actor subscription. */ -interface ActorSubscription { - /** This connection's id — the same value the actor receives as `conn.id`. */ - id: string; - /** Close the subscription and its underlying socket. */ - unsubscribe(): void; + 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; + } } -interface ActorClient { - subscribe( - instanceId: string, - callback: (data: unknown) => void, - options?: { id?: string }, - ): ActorSubscription; - send(instanceId: string, data: unknown): void; +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 index 2c191fb4..0282123a 100644 --- a/src/modules/actors.types.ts +++ b/src/modules/actors.types.ts @@ -39,48 +39,63 @@ type ToServerFor = N extends keyof ActorRegistry : unknown : unknown; -/** - * Client for a single named Actor. - * Typed automatically when the actor is registered in {@link ActorRegistry}. - */ -export interface ActorClient { +/** Options for {@link ActorRoom.connect}. */ +export interface ActorConnectOptions { /** - * Open a WebSocket subscription. Returns a {@link ActorSubscription} with the - * connection `id` (same value the actor sees as `conn.id`) and an `unsubscribe()` method. - * - * Pass `options.id` to control the connection id (e.g. a stable per-tab id so a - * reconnect reuses the same server-side connection); omit it for an auto-generated - * per-connection id. + * 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. */ - subscribe( - instanceId: string, - callback: (data: ToClientFor) => void, - options?: { id?: string }, - ): ActorSubscription; - - /** Send a message over the open socket. Throws if not subscribed. */ - send(instanceId: string, data: ToServerFor): void; + id?: string; } -/** Handle for an active actor subscription. */ +/** Handle for one listener registered via {@link ActorRoom.subscribe}. */ export interface ActorSubscription { - /** This connection's id — the same value the actor receives as `conn.id`. */ - id: string; - /** Close the subscription and its underlying socket. */ + /** 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. * - * Actor names are accessed as dynamic properties on this module: * ```typescript - * const sub = await base44.actors.MyActor.subscribe("room-1", (msg) => { - * console.log(msg); // typed if MyActor is in ActorRegistry - * }); - * const { id, unsubscribe } = sub; - * unsubscribe(); + * 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 = { diff --git a/tests/unit/actors.test.ts b/tests/unit/actors.test.ts new file mode 100644 index 00000000..77d5094a --- /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); + }); +}); From 8541b388faf943bdc9eee329f806223b5f4eda70 Mon Sep 17 00:00:00 2001 From: imrik Date: Thu, 23 Jul 2026 07:57:36 +0300 Subject: [PATCH 34/50] chore(actors): trim over-explanatory comments in the actors module No logic change. Co-Authored-By: Claude Opus 4.8 --- src/modules/actors.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/modules/actors.ts b/src/modules/actors.ts index d29224ef..6f2e3c41 100644 --- a/src/modules/actors.ts +++ b/src/modules/actors.ts @@ -7,9 +7,8 @@ import type { interface ActorsConfig { appId: string; - /** Current user access token, if authenticated. Rides the WS query (same - * pattern as the entities socket) so the platform proxy can authenticate the - * connection like a backend-function call; anonymous connects omit it. */ + /** 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. */ @@ -19,10 +18,8 @@ interface ActorsConfig { webSocketImpl?: unknown; } -// Heartbeat / half-open detection. PartySocket only reconnects on a browser -// close/error event, so a silently-dead connection (TCP alive, no data — common -// behind proxies/LBs) hangs until the OS idle timeout (~60s). Ping periodically -// and force a reconnect if nothing comes back within DEAD_MS. +// 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; @@ -58,9 +55,7 @@ class Room { room: this.instanceId, id: connId, ...(this.config.webSocketImpl ? { WebSocket: this.config.webSocketImpl as any } : {}), - // Re-read on every (re)connect so a login/logout between reconnects is - // picked up. The platform proxy authenticates the connection itself — - // no pre-connect token mint. + // Re-read on every (re)connect so a login/logout is picked up. query: () => { const token = this.config.getAuthToken(); return { From c961d5f3cb9bea7cb1aebd6cbe7c9e2b9bee1c84 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 28 Jul 2026 14:19:45 +0300 Subject: [PATCH 35/50] refactor(actor): drop Node<22 support, rename option to actorsWsUrl, remove stray script - Remove webSocketImpl escape hatch (Node<22 WebSocket polyfill); Node 22+ and browsers have a global WebSocket and crypto.randomUUID. - Rename the public client option dispatcherWsUrl -> actorsWsUrl (it's the app-origin URL that proxies /parties to the backend, not the CF dispatcher). - Document the __ping/__pong contract with the deployed Actor shim. - Delete pstest.mjs (dev throwaway; coverage lives in tests/unit/actors.test.ts). Co-Authored-By: Claude Opus 4.8 --- pstest.mjs | 26 -------------------------- src/client.ts | 12 +++++------- src/client.types.ts | 22 +++++----------------- src/modules/actors.ts | 10 +++++----- tests/unit/actors.test.ts | 2 +- 5 files changed, 16 insertions(+), 56 deletions(-) delete mode 100644 pstest.mjs diff --git a/pstest.mjs b/pstest.mjs deleted file mode 100644 index ed97580f..00000000 --- a/pstest.mjs +++ /dev/null @@ -1,26 +0,0 @@ -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/client.ts b/src/client.ts index 4a48d86d..39f9506a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -73,21 +73,20 @@ export function createClient(config: CreateClientConfig): Base44Client { options, functionsVersion, headers: optionalHeaders, - dispatcherWsUrl, - webSocketImpl, + actorsWsUrl, } = 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 + // Derive the Actor 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 resolvedActorsWsUrl = (() => { + if (actorsWsUrl) return actorsWsUrl.replace(/\/$/, ""); const appOrigin = normalizedAppBaseUrl || // React Native has a bare `window` with no `location`, so guard both. @@ -223,8 +222,7 @@ export function createClient(config: CreateClientConfig): Base44Client { }), actors: createActorsModule({ appId, - dispatcherWsUrl: resolvedDispatcherWsUrl, - webSocketImpl, + actorsWsUrl: resolvedActorsWsUrl, functionsVersion, // Same credential as function calls; the platform proxy authenticates the // WS connection with it (anonymous when absent) — no pre-connect token mint. diff --git a/src/client.types.ts b/src/client.types.ts index cb8fb3bf..0483d30e 100644 --- a/src/client.types.ts +++ b/src/client.types.ts @@ -80,27 +80,15 @@ export interface CreateClientConfig { */ options?: CreateClientOptions; /** - * Base WebSocket URL for the Cloudflare Durable Object dispatcher. + * Base WebSocket URL for Actor connections. * * 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. + * `wss://` (or `http://` by `ws://`) — so the Actor socket is same-origin + * with the running app, which proxies `/parties` to the backend dispatcher. + * Override only when the Actor host differs from the app origin. */ - 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; + actorsWsUrl?: string; } /** diff --git a/src/modules/actors.ts b/src/modules/actors.ts index 6f2e3c41..29764eb3 100644 --- a/src/modules/actors.ts +++ b/src/modules/actors.ts @@ -13,9 +13,7 @@ interface ActorsConfig { /** 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; + actorsWsUrl: string; } // Heartbeat / half-open detection: PartySocket only reconnects on a close/error @@ -50,11 +48,10 @@ class Room { this.connId = connId; const ws = new PartySocket({ - host: this.config.dispatcherWsUrl, + host: this.config.actorsWsUrl, 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(); @@ -91,6 +88,9 @@ class Room { return; } try { + // Server contract: the deployed Actor shim echoes __ping with __pong + // (see base44-userapp-bundler shim/actor.ts). If that ever stops, an + // idle room's lastMsg goes stale and this watchdog reconnects every DEAD_MS. ws.send(JSON.stringify({ type: "__ping" })); } catch { // socket not open; the watchdog above will force a reconnect diff --git a/tests/unit/actors.test.ts b/tests/unit/actors.test.ts index 77d5094a..faf4c9be 100644 --- a/tests/unit/actors.test.ts +++ b/tests/unit/actors.test.ts @@ -35,7 +35,7 @@ describe("Actors Module — room handle", () => { appId: "app-1", getAuthToken: () => "user-tok", functionsVersion: undefined, - dispatcherWsUrl: "wss://disp.example", + actorsWsUrl: "wss://disp.example", }; beforeEach(() => { sockets.length = 0; }); From 461840eee5df52f889edec2e71dd8b25af16641a Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 28 Jul 2026 14:23:16 +0300 Subject: [PATCH 36/50] ci: drop preview-publish.yml comment (unrelated to actors) Co-Authored-By: Claude Opus 4.8 --- .github/workflows/preview-publish.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/preview-publish.yml b/.github/workflows/preview-publish.yml index 47f1445c..8cd53fd1 100644 --- a/.github/workflows/preview-publish.yml +++ b/.github/workflows/preview-publish.yml @@ -23,8 +23,6 @@ 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 From 06f94472cdc2e8ac347564a8144a6d3893f0dc7c Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 28 Jul 2026 14:40:29 +0300 Subject: [PATCH 37/50] chore(actor): trim redundant inline comments in actors module Co-Authored-By: Claude Opus 4.8 --- src/modules/actors.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/modules/actors.ts b/src/modules/actors.ts index 29764eb3..35278ce9 100644 --- a/src/modules/actors.ts +++ b/src/modules/actors.ts @@ -74,10 +74,10 @@ class Room { try { data = JSON.parse(ev.data); } catch { - return; // ignore malformed + return; } const msgType = data && typeof data === "object" ? (data as { type?: unknown }).type : undefined; - if (msgType === "__pong") return; // platform message — never surface it + if (msgType === "__pong") return; for (const listener of this.listeners) listener(data); }); @@ -88,12 +88,11 @@ class Room { return; } try { - // Server contract: the deployed Actor shim echoes __ping with __pong - // (see base44-userapp-bundler shim/actor.ts). If that ever stops, an - // idle room's lastMsg goes stale and this watchdog reconnects every DEAD_MS. + // 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 { - // socket not open; the watchdog above will force a reconnect + // not open; the watchdog above will reconnect } }, PING_MS); From a181d61649978822e0f3f5906c0e09617b96d240 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 28 Jul 2026 14:43:04 +0300 Subject: [PATCH 38/50] chore(actor): drop redundant getAuthToken call-site comment Co-Authored-By: Claude Opus 4.8 --- src/client.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/client.ts b/src/client.ts index 39f9506a..50a3a6d8 100644 --- a/src/client.ts +++ b/src/client.ts @@ -224,8 +224,6 @@ export function createClient(config: CreateClientConfig): Base44Client { appId, actorsWsUrl: resolvedActorsWsUrl, 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: () => { From d06d15a00e7dbfdd75f0cbb479704c7d1a6ef996 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 28 Jul 2026 15:22:11 +0300 Subject: [PATCH 39/50] fix(actor): close actor rooms on client.cleanup() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createActorsModule now tracks live rooms per client and exposes closeAll(), which client.cleanup() invokes — a forgotten room.close() no longer leaks its 1s heartbeat timer (which also keeps the Node event loop alive). The Proxy get trap consults the target first so closeAll resolves instead of being read as an actor name. Co-Authored-By: Claude Opus 4.8 --- src/client.ts | 1 + src/modules/actors.ts | 28 ++++++++++++++++++++++++---- tests/unit/actors.test.ts | 18 ++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/client.ts b/src/client.ts index 50a3a6d8..99d3670f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -228,6 +228,7 @@ export function createClient(config: CreateClientConfig): Base44Client { }), cleanup: () => { userModules.analytics.cleanup(); + userModules.actors.closeAll(); if (socket) { socket.disconnect(); } diff --git a/src/modules/actors.ts b/src/modules/actors.ts index 35278ce9..bd48b86b 100644 --- a/src/modules/actors.ts +++ b/src/modules/actors.ts @@ -31,6 +31,7 @@ class Room { private readonly actorName: string, private readonly instanceId: string, private readonly config: ActorsConfig, + private readonly onClose?: () => void, ) {} get id(): string { @@ -124,13 +125,32 @@ class Room { this.listeners.clear(); this.ws?.close(); this.ws = null; + this.onClose?.(); } } 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; + // Live rooms this client opened, so client.cleanup() can reclaim any the app + // forgot to close() (each room removes itself here on close). + const rooms = new Set(); + const api = { + closeAll: () => { + for (const room of [...rooms]) room.close(); }, - }); + }; + return new Proxy( + api as typeof api & Record ActorRoom>, + { + get(target, key) { + // Own methods (closeAll) and non-string keys resolve normally; any other + // string key is an actor name → a room factory. + if (typeof key !== "string" || key in target) return Reflect.get(target, key); + return (instanceId: string) => { + const room = new Room(key, instanceId, config, () => rooms.delete(room)); + rooms.add(room); + return room as unknown as ActorRoom; + }; + }, + }, + ); } diff --git a/tests/unit/actors.test.ts b/tests/unit/actors.test.ts index faf4c9be..f37eb0ae 100644 --- a/tests/unit/actors.test.ts +++ b/tests/unit/actors.test.ts @@ -119,4 +119,22 @@ describe("Actors Module — room handle", () => { actors.GameRoom("r").connect(); expect(sockets).toHaveLength(2); }); + + test("closeAll() tears down every open room", () => { + const actors = createActorsModule(config); + actors.GameRoom("a").connect(); + actors.GameRoom("b").connect(); + expect(sockets.filter((s) => s.closed)).toHaveLength(0); + actors.closeAll(); + expect(sockets.every((s) => s.closed)).toBe(true); + }); + + test("closeAll() is safe after an individual close() and closes the rest", () => { + const actors = createActorsModule(config); + const a = actors.GameRoom("a").connect(); + actors.GameRoom("b").connect(); + a.close(); + expect(() => actors.closeAll()).not.toThrow(); + expect(sockets.every((s) => s.closed)).toBe(true); + }); }); From 543d9e6694a6b3e24d44697dbd43d160bd5ccf2e Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 28 Jul 2026 15:33:01 +0300 Subject: [PATCH 40/50] fix(actor): don't let the actors Proxy look thenable The get trap returned a room factory for `then`, so awaiting base44.actors (or passing it through a Promise chain) called the factory as a thenable and hung. Resolve `then` (and inherited/symbol keys) normally so the module isn't mistaken for a Promise. Co-Authored-By: Claude Opus 4.8 --- src/modules/actors.ts | 9 ++++++--- tests/unit/actors.test.ts | 5 +++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/modules/actors.ts b/src/modules/actors.ts index bd48b86b..176f3edc 100644 --- a/src/modules/actors.ts +++ b/src/modules/actors.ts @@ -142,9 +142,12 @@ export function createActorsModule(config: ActorsConfig) { api as typeof api & Record ActorRoom>, { get(target, key) { - // Own methods (closeAll) and non-string keys resolve normally; any other - // string key is an actor name → a room factory. - if (typeof key !== "string" || key in target) return Reflect.get(target, key); + // Own methods (closeAll), inherited/symbol keys, and `then` (so the + // module isn't mistaken for a thenable when awaited) resolve normally; + // any other string key is an actor name → a room factory. + if (key === "then" || typeof key !== "string" || key in target) { + return Reflect.get(target, key); + } return (instanceId: string) => { const room = new Room(key, instanceId, config, () => rooms.delete(room)); rooms.add(room); diff --git a/tests/unit/actors.test.ts b/tests/unit/actors.test.ts index f37eb0ae..8cf869ae 100644 --- a/tests/unit/actors.test.ts +++ b/tests/unit/actors.test.ts @@ -120,6 +120,11 @@ describe("Actors Module — room handle", () => { expect(sockets).toHaveLength(2); }); + test("module is not thenable (then must not resolve to a room factory)", () => { + const actors = createActorsModule(config) as unknown as { then?: unknown }; + expect(actors.then).toBeUndefined(); + }); + test("closeAll() tears down every open room", () => { const actors = createActorsModule(config); actors.GameRoom("a").connect(); From 973772ef752558f3d816b3ccb45d20a681bf36a4 Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 28 Jul 2026 15:35:54 +0300 Subject: [PATCH 41/50] fix(actor): clear connId on close() so id is valid only while connected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit close() left connId set, so room.id returned a stale id between close and the next connect(). Null it in close() — reading id after close now throws the same connect()-first guard. Co-Authored-By: Claude Opus 4.8 --- src/modules/actors.ts | 1 + tests/unit/actors.test.ts | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/src/modules/actors.ts b/src/modules/actors.ts index 176f3edc..0106adb8 100644 --- a/src/modules/actors.ts +++ b/src/modules/actors.ts @@ -125,6 +125,7 @@ class Room { this.listeners.clear(); this.ws?.close(); this.ws = null; + this.connId = null; this.onClose?.(); } } diff --git a/tests/unit/actors.test.ts b/tests/unit/actors.test.ts index 8cf869ae..4b22dbc3 100644 --- a/tests/unit/actors.test.ts +++ b/tests/unit/actors.test.ts @@ -107,6 +107,13 @@ describe("Actors Module — room handle", () => { expect(got).toHaveLength(0); }); + test("close() clears the id (only valid while connected)", () => { + const room = createActorsModule(config).GameRoom("r").connect({ id: "c1" }); + expect(room.id).toBe("c1"); + room.close(); + expect(() => room.id).toThrow(/connect\(\)/); + }); + test("anonymous connect omits the token", () => { const room = createActorsModule({ ...config, getAuthToken: () => null }).GameRoom("r").connect(); expect(room).toBeDefined(); From 41f7567dc8647cd4a5f5a19e1c96e13e1f00249a Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 28 Jul 2026 16:03:46 +0300 Subject: [PATCH 42/50] refactor(actor): split closeAll off the actors Proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createActorsModule returns { module, closeAll } instead of bolting closeAll onto the Proxy. The Proxy now only maps names to room factories (2-condition guard), and closeAll is a plain internal function client.cleanup() calls — dropping the api object, Object.assign, the intersection cast, and the key-in-target branch. Co-Authored-By: Claude Opus 4.8 --- src/client.ts | 16 ++++++++------- src/modules/actors.ts | 26 ++++++++++++------------- tests/unit/actors.test.ts | 41 +++++++++++++++++++++------------------ 3 files changed, 43 insertions(+), 40 deletions(-) diff --git a/src/client.ts b/src/client.ts index 99d3670f..8f53953c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -183,6 +183,13 @@ export function createClient(config: CreateClientConfig): Base44Client { } } + const actorsModule = createActorsModule({ + appId, + actorsWsUrl: resolvedActorsWsUrl, + functionsVersion, + getAuthToken: () => token || getAccessToken(), + }); + const userModules = { entities: createEntitiesModule({ axios: axiosClient, @@ -220,15 +227,10 @@ export function createClient(config: CreateClientConfig): Base44Client { appId, userAuthModule, }), - actors: createActorsModule({ - appId, - actorsWsUrl: resolvedActorsWsUrl, - functionsVersion, - getAuthToken: () => token || getAccessToken(), - }), + actors: actorsModule.module, cleanup: () => { userModules.analytics.cleanup(); - userModules.actors.closeAll(); + actorsModule.closeAll(); if (socket) { socket.disconnect(); } diff --git a/src/modules/actors.ts b/src/modules/actors.ts index 0106adb8..f9580493 100644 --- a/src/modules/actors.ts +++ b/src/modules/actors.ts @@ -134,21 +134,13 @@ export function createActorsModule(config: ActorsConfig) { // Live rooms this client opened, so client.cleanup() can reclaim any the app // forgot to close() (each room removes itself here on close). const rooms = new Set(); - const api = { - closeAll: () => { - for (const room of [...rooms]) room.close(); - }, - }; - return new Proxy( - api as typeof api & Record ActorRoom>, + const module = new Proxy( + {} as Record ActorRoom>, { - get(target, key) { - // Own methods (closeAll), inherited/symbol keys, and `then` (so the - // module isn't mistaken for a thenable when awaited) resolve normally; - // any other string key is an actor name → a room factory. - if (key === "then" || typeof key !== "string" || key in target) { - return Reflect.get(target, key); - } + 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) => { const room = new Room(key, instanceId, config, () => rooms.delete(room)); rooms.add(room); @@ -157,4 +149,10 @@ export function createActorsModule(config: ActorsConfig) { }, }, ); + return { + module, + closeAll: () => { + for (const room of [...rooms]) room.close(); + }, + }; } diff --git a/tests/unit/actors.test.ts b/tests/unit/actors.test.ts index 4b22dbc3..ac8c8cd8 100644 --- a/tests/unit/actors.test.ts +++ b/tests/unit/actors.test.ts @@ -38,10 +38,13 @@ describe("Actors Module — room handle", () => { actorsWsUrl: "wss://disp.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 actors = createActorsModule(config); + const actors = mod(); const room = actors.GameRoom("room-1").connect({ id: "conn-1" }); expect(sockets).toHaveLength(1); expect(room.id).toBe("conn-1"); @@ -52,20 +55,20 @@ describe("Actors Module — room handle", () => { }); test("connect() is idempotent", () => { - const room = createActorsModule(config).GameRoom("r").connect(); + const room = mod().GameRoom("r").connect(); room.connect(); expect(sockets).toHaveLength(1); }); test("subscribe/send/id throw before connect()", () => { - const room = createActorsModule(config).GameRoom("r"); + const room = mod().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 room = mod().GameRoom("r").connect(); const a: unknown[] = [], b: unknown[] = []; const subA = room.subscribe((m) => a.push(m)); room.subscribe((m) => b.push(m)); @@ -82,7 +85,7 @@ describe("Actors Module — room handle", () => { }); test("__pong platform messages are swallowed", () => { - const room = createActorsModule(config).GameRoom("r").connect(); + const room = mod().GameRoom("r").connect(); const got: unknown[] = []; room.subscribe((m) => got.push(m)); sockets[0].message({ type: "__pong" }); @@ -91,13 +94,13 @@ describe("Actors Module — room handle", () => { }); test("send serializes onto the socket", () => { - const room = createActorsModule(config).GameRoom("r").connect(); + const room = mod().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 room = mod().GameRoom("r").connect(); const got: unknown[] = []; room.subscribe((m) => got.push(m)); room.close(); @@ -108,45 +111,45 @@ describe("Actors Module — room handle", () => { }); test("close() clears the id (only valid while connected)", () => { - const room = createActorsModule(config).GameRoom("r").connect({ id: "c1" }); + const room = mod().GameRoom("r").connect({ id: "c1" }); expect(room.id).toBe("c1"); room.close(); expect(() => room.id).toThrow(/connect\(\)/); }); test("anonymous connect omits the token", () => { - const room = createActorsModule({ ...config, getAuthToken: () => null }).GameRoom("r").connect(); + const room = mod({ ...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); + const actors = mod(); actors.GameRoom("r").connect(); actors.GameRoom("r").connect(); expect(sockets).toHaveLength(2); }); test("module is not thenable (then must not resolve to a room factory)", () => { - const actors = createActorsModule(config) as unknown as { then?: unknown }; + const actors = mod() as unknown as { then?: unknown }; expect(actors.then).toBeUndefined(); }); test("closeAll() tears down every open room", () => { - const actors = createActorsModule(config); - actors.GameRoom("a").connect(); - actors.GameRoom("b").connect(); + const { module, closeAll } = createActorsModule(config); + module.GameRoom("a").connect(); + module.GameRoom("b").connect(); expect(sockets.filter((s) => s.closed)).toHaveLength(0); - actors.closeAll(); + closeAll(); expect(sockets.every((s) => s.closed)).toBe(true); }); test("closeAll() is safe after an individual close() and closes the rest", () => { - const actors = createActorsModule(config); - const a = actors.GameRoom("a").connect(); - actors.GameRoom("b").connect(); + const { module, closeAll } = createActorsModule(config); + const a = module.GameRoom("a").connect(); + module.GameRoom("b").connect(); a.close(); - expect(() => actors.closeAll()).not.toThrow(); + expect(() => closeAll()).not.toThrow(); expect(sockets.every((s) => s.closed)).toBe(true); }); }); From c4c2094566db9e829ca643627814cdf9212e898f Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 28 Jul 2026 16:27:25 +0300 Subject: [PATCH 43/50] test(actor): cover heartbeat, fv, and ws-url derivation; soften send doc - Extract resolveActorsWsUrl into a pure exported helper (client.ts calls it) and unit-test its precedence + protocol/trailing-slash normalization. - Add heartbeat tests (fake timers): __ping every PING_MS, reconnect after DEAD_MS of silence; and functionsVersion -> fv present/omitted. - Soften the send() doc: buffered "once connecting" (matches PartySocket). Co-Authored-By: Claude Opus 4.8 --- src/client.ts | 28 +++++++------------ src/modules/actors.ts | 20 ++++++++++++++ src/modules/actors.types.ts | 2 +- tests/unit/actors.test.ts | 55 ++++++++++++++++++++++++++++++++++++- 4 files changed, 85 insertions(+), 20 deletions(-) diff --git a/src/client.ts b/src/client.ts index 8f53953c..de5742d6 100644 --- a/src/client.ts +++ b/src/client.ts @@ -20,7 +20,7 @@ import type { CreateClientOptions, } from "./client.types.js"; import { createAnalyticsModule } from "./modules/analytics.js"; -import { createActorsModule } from "./modules/actors.js"; +import { createActorsModule, resolveActorsWsUrl } from "./modules/actors.js"; // Re-export client types export type { Base44Client, CreateClientConfig, CreateClientOptions }; @@ -79,23 +79,15 @@ export function createClient(config: CreateClientConfig): Base44Client { // Normalize appBaseUrl to always be a string (empty if not provided or invalid) const normalizedAppBaseUrl = typeof appBaseUrl === "string" ? appBaseUrl : ""; - // Derive the Actor 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 resolvedActorsWsUrl = (() => { - if (actorsWsUrl) return actorsWsUrl.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://"); - })(); + // Same-origin with the running app (it proxies /parties to the backend), not + // the API host. React Native has a bare `window` with no `location`, so guard both. + const resolvedActorsWsUrl = resolveActorsWsUrl({ + actorsWsUrl, + appBaseUrl: normalizedAppBaseUrl, + browserOrigin: + typeof window !== "undefined" ? window.location?.origin ?? "" : "", + serverUrl, + }); const socketConfig: RoomsSocketConfig = { serverUrl, diff --git a/src/modules/actors.ts b/src/modules/actors.ts index f9580493..3352d921 100644 --- a/src/modules/actors.ts +++ b/src/modules/actors.ts @@ -130,6 +130,26 @@ class Room { } } +/** + * Base WebSocket URL for Actor connections. An explicit `actorsWsUrl` wins; + * otherwise the app's own origin (`appBaseUrl`, else the browser origin, else + * `serverUrl`) with `https://`→`wss://` / `http://`→`ws://` and no trailing + * slash — the app proxies `/parties` to the backend dispatcher. + */ +export function resolveActorsWsUrl(opts: { + actorsWsUrl?: string; + appBaseUrl?: string; + browserOrigin?: string; + serverUrl: string; +}): string { + if (opts.actorsWsUrl) return opts.actorsWsUrl.replace(/\/$/, ""); + const appOrigin = opts.appBaseUrl || opts.browserOrigin || ""; + return (appOrigin || opts.serverUrl) + .replace(/\/$/, "") + .replace(/^https:\/\//, "wss://") + .replace(/^http:\/\//, "ws://"); +} + export function createActorsModule(config: ActorsConfig) { // Live rooms this client opened, so client.cleanup() can reclaim any the app // forgot to close() (each room removes itself here on close). diff --git a/src/modules/actors.types.ts b/src/modules/actors.types.ts index 0282123a..7aa4b6e1 100644 --- a/src/modules/actors.types.ts +++ b/src/modules/actors.types.ts @@ -70,7 +70,7 @@ export interface ActorRoom { /** 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 a message. Throws before {@link connect}; buffered by the socket once connecting. */ send(data: ToServerFor): void; /** Tear down the socket, heartbeat, and all listeners. */ diff --git a/tests/unit/actors.test.ts b/tests/unit/actors.test.ts index ac8c8cd8..eab5832e 100644 --- a/tests/unit/actors.test.ts +++ b/tests/unit/actors.test.ts @@ -28,7 +28,7 @@ const { sockets, FakeSocket } = vi.hoisted(() => { vi.mock("partysocket", () => ({ default: FakeSocket })); -import { createActorsModule } from "../../src/modules/actors.ts"; +import { createActorsModule, resolveActorsWsUrl } from "../../src/modules/actors.ts"; describe("Actors Module — room handle", () => { const config = { @@ -130,6 +130,29 @@ describe("Actors Module — room handle", () => { 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 a room factory)", () => { const actors = mod() as unknown as { then?: unknown }; expect(actors.then).toBeUndefined(); @@ -153,3 +176,33 @@ describe("Actors Module — room handle", () => { expect(sockets.every((s) => s.closed)).toBe(true); }); }); + +describe("resolveActorsWsUrl", () => { + test("explicit actorsWsUrl wins, trailing slash stripped", () => { + expect( + resolveActorsWsUrl({ actorsWsUrl: "wss://edge.example/", serverUrl: "https://api" }), + ).toBe("wss://edge.example"); + }); + + test("appBaseUrl over serverUrl; https → wss", () => { + expect( + resolveActorsWsUrl({ appBaseUrl: "https://app.example/", serverUrl: "https://api.example" }), + ).toBe("wss://app.example"); + }); + + test("http → ws", () => { + expect( + resolveActorsWsUrl({ appBaseUrl: "http://localhost:3000", serverUrl: "https://api" }), + ).toBe("ws://localhost:3000"); + }); + + test("browserOrigin used when no appBaseUrl", () => { + expect( + resolveActorsWsUrl({ browserOrigin: "https://tab.example", serverUrl: "https://api.example" }), + ).toBe("wss://tab.example"); + }); + + test("falls back to serverUrl (Node/SSR, no origin)", () => { + expect(resolveActorsWsUrl({ serverUrl: "https://api.example" })).toBe("wss://api.example"); + }); +}); From e75ce024312952ff8b63d9d657313860e0e604dc Mon Sep 17 00:00:00 2001 From: imrik Date: Wed, 29 Jul 2026 11:33:05 +0300 Subject: [PATCH 44/50] refactor(actor): pass serverUrl as the socket host, drop URL-resolution code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit serverUrl is the app's own origin, and PartySocket already strips the scheme, strips a trailing slash, and picks wss (ws for localhost) from the host. So the resolveActorsWsUrl helper, the actorsWsUrl client option, and the browserOrigin/appBaseUrl derivation were all redundant — pass serverUrl straight through as PartySocket's host. Co-Authored-By: Claude Opus 4.8 --- src/client.ts | 15 ++------------- src/client.types.ts | 10 ---------- src/modules/actors.ts | 26 ++++---------------------- tests/unit/actors.test.ts | 36 ++++-------------------------------- 4 files changed, 10 insertions(+), 77 deletions(-) diff --git a/src/client.ts b/src/client.ts index de5742d6..645d7bb3 100644 --- a/src/client.ts +++ b/src/client.ts @@ -20,7 +20,7 @@ import type { CreateClientOptions, } from "./client.types.js"; import { createAnalyticsModule } from "./modules/analytics.js"; -import { createActorsModule, resolveActorsWsUrl } from "./modules/actors.js"; +import { createActorsModule } from "./modules/actors.js"; // Re-export client types export type { Base44Client, CreateClientConfig, CreateClientOptions }; @@ -73,22 +73,11 @@ export function createClient(config: CreateClientConfig): Base44Client { options, functionsVersion, headers: optionalHeaders, - actorsWsUrl, } = config; // Normalize appBaseUrl to always be a string (empty if not provided or invalid) const normalizedAppBaseUrl = typeof appBaseUrl === "string" ? appBaseUrl : ""; - // Same-origin with the running app (it proxies /parties to the backend), not - // the API host. React Native has a bare `window` with no `location`, so guard both. - const resolvedActorsWsUrl = resolveActorsWsUrl({ - actorsWsUrl, - appBaseUrl: normalizedAppBaseUrl, - browserOrigin: - typeof window !== "undefined" ? window.location?.origin ?? "" : "", - serverUrl, - }); - const socketConfig: RoomsSocketConfig = { serverUrl, mountPath: "/ws-user-apps/socket.io/", @@ -177,7 +166,7 @@ export function createClient(config: CreateClientConfig): Base44Client { const actorsModule = createActorsModule({ appId, - actorsWsUrl: resolvedActorsWsUrl, + serverUrl, functionsVersion, getAuthToken: () => token || getAccessToken(), }); diff --git a/src/client.types.ts b/src/client.types.ts index 0483d30e..27e32896 100644 --- a/src/client.types.ts +++ b/src/client.types.ts @@ -79,16 +79,6 @@ export interface CreateClientConfig { * Additional client options. */ options?: CreateClientOptions; - /** - * Base WebSocket URL for Actor connections. - * - * 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 Actor socket is same-origin - * with the running app, which proxies `/parties` to the backend dispatcher. - * Override only when the Actor host differs from the app origin. - */ - actorsWsUrl?: string; } /** diff --git a/src/modules/actors.ts b/src/modules/actors.ts index 3352d921..28a140c1 100644 --- a/src/modules/actors.ts +++ b/src/modules/actors.ts @@ -13,7 +13,9 @@ interface ActorsConfig { /** Same semantics as function calls: editors with a non-prod version get the * draft actor script; everyone else gets the published one. */ functionsVersion?: string; - actorsWsUrl: string; + /** The app's own origin; PartySocket strips the scheme and connects wss (ws + * for localhost), so the socket is same-origin (the app proxies /parties). */ + serverUrl: string; } // Heartbeat / half-open detection: PartySocket only reconnects on a close/error @@ -49,7 +51,7 @@ class Room { this.connId = connId; const ws = new PartySocket({ - host: this.config.actorsWsUrl, + host: this.config.serverUrl, party: this.actorName, room: this.instanceId, id: connId, @@ -130,26 +132,6 @@ class Room { } } -/** - * Base WebSocket URL for Actor connections. An explicit `actorsWsUrl` wins; - * otherwise the app's own origin (`appBaseUrl`, else the browser origin, else - * `serverUrl`) with `https://`→`wss://` / `http://`→`ws://` and no trailing - * slash — the app proxies `/parties` to the backend dispatcher. - */ -export function resolveActorsWsUrl(opts: { - actorsWsUrl?: string; - appBaseUrl?: string; - browserOrigin?: string; - serverUrl: string; -}): string { - if (opts.actorsWsUrl) return opts.actorsWsUrl.replace(/\/$/, ""); - const appOrigin = opts.appBaseUrl || opts.browserOrigin || ""; - return (appOrigin || opts.serverUrl) - .replace(/\/$/, "") - .replace(/^https:\/\//, "wss://") - .replace(/^http:\/\//, "ws://"); -} - export function createActorsModule(config: ActorsConfig) { // Live rooms this client opened, so client.cleanup() can reclaim any the app // forgot to close() (each room removes itself here on close). diff --git a/tests/unit/actors.test.ts b/tests/unit/actors.test.ts index eab5832e..42ea0464 100644 --- a/tests/unit/actors.test.ts +++ b/tests/unit/actors.test.ts @@ -28,14 +28,14 @@ const { sockets, FakeSocket } = vi.hoisted(() => { vi.mock("partysocket", () => ({ default: FakeSocket })); -import { createActorsModule, resolveActorsWsUrl } from "../../src/modules/actors.ts"; +import { createActorsModule } from "../../src/modules/actors.ts"; describe("Actors Module — room handle", () => { const config = { appId: "app-1", getAuthToken: () => "user-tok", functionsVersion: undefined, - actorsWsUrl: "wss://disp.example", + serverUrl: "https://app.example", }; // The module (Proxy of actor names). closeAll is separate — see its own tests. @@ -52,6 +52,8 @@ describe("Actors Module — room handle", () => { 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"); + // serverUrl passes straight through as PartySocket's host (it swaps the scheme). + expect(sockets[0].opts.host).toBe("https://app.example"); }); test("connect() is idempotent", () => { @@ -176,33 +178,3 @@ describe("Actors Module — room handle", () => { expect(sockets.every((s) => s.closed)).toBe(true); }); }); - -describe("resolveActorsWsUrl", () => { - test("explicit actorsWsUrl wins, trailing slash stripped", () => { - expect( - resolveActorsWsUrl({ actorsWsUrl: "wss://edge.example/", serverUrl: "https://api" }), - ).toBe("wss://edge.example"); - }); - - test("appBaseUrl over serverUrl; https → wss", () => { - expect( - resolveActorsWsUrl({ appBaseUrl: "https://app.example/", serverUrl: "https://api.example" }), - ).toBe("wss://app.example"); - }); - - test("http → ws", () => { - expect( - resolveActorsWsUrl({ appBaseUrl: "http://localhost:3000", serverUrl: "https://api" }), - ).toBe("ws://localhost:3000"); - }); - - test("browserOrigin used when no appBaseUrl", () => { - expect( - resolveActorsWsUrl({ browserOrigin: "https://tab.example", serverUrl: "https://api.example" }), - ).toBe("wss://tab.example"); - }); - - test("falls back to serverUrl (Node/SSR, no origin)", () => { - expect(resolveActorsWsUrl({ serverUrl: "https://api.example" })).toBe("wss://api.example"); - }); -}); From 9717c67032c3293b44417fca1428826b0c70522f Mon Sep 17 00:00:00 2001 From: imrik Date: Wed, 29 Jul 2026 11:42:38 +0300 Subject: [PATCH 45/50] feat(actor): add type-only this.client getter on Actor base class Actors deployed by the bundler now expose an anonymous Base44 client as this.client. Mirror it on the type-only Actor base class (throw-stub like storage/instanceId) so actor authors get it typed as Base44Client; the runtime value is provided by the bundler shim. Co-Authored-By: Claude Fable 5 --- src/actor.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/actor.ts b/src/actor.ts index c08042f4..928be3f6 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -9,6 +9,8 @@ * 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 @@ -100,4 +102,13 @@ export abstract class Actor { 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). + * 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"); + } + } From dce51fa9bd14a80519547379680000c2abdec155 Mon Sep 17 00:00:00 2001 From: imrik Date: Wed, 29 Jul 2026 12:12:23 +0300 Subject: [PATCH 46/50] fix(actor): resolve empty/relative serverUrl to the page origin for the socket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit serverUrl is often "" (same-origin apps use a relative /api), and PartySocket can't resolve a relative host — it builds a hostless wss:///parties/... So add resolveActorsHost: use serverUrl when absolute, else fall back to the browser origin. PartySocket still owns the scheme swap and trailing-slash strip. Co-Authored-By: Claude Opus 4.8 --- src/client.ts | 9 +++++++-- src/modules/actors.ts | 18 ++++++++++++++---- tests/unit/actors.test.ts | 26 +++++++++++++++++++++++--- 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/client.ts b/src/client.ts index 645d7bb3..54bf9044 100644 --- a/src/client.ts +++ b/src/client.ts @@ -20,7 +20,7 @@ import type { CreateClientOptions, } from "./client.types.js"; import { createAnalyticsModule } from "./modules/analytics.js"; -import { createActorsModule } from "./modules/actors.js"; +import { createActorsModule, resolveActorsHost } from "./modules/actors.js"; // Re-export client types export type { Base44Client, CreateClientConfig, CreateClientOptions }; @@ -166,7 +166,12 @@ export function createClient(config: CreateClientConfig): Base44Client { const actorsModule = createActorsModule({ appId, - serverUrl, + // 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(), }); diff --git a/src/modules/actors.ts b/src/modules/actors.ts index 28a140c1..26e7ca03 100644 --- a/src/modules/actors.ts +++ b/src/modules/actors.ts @@ -13,9 +13,9 @@ interface ActorsConfig { /** Same semantics as function calls: editors with a non-prod version get the * draft actor script; everyone else gets the published one. */ functionsVersion?: string; - /** The app's own origin; PartySocket strips the scheme and connects wss (ws - * for localhost), so the socket is same-origin (the app proxies /parties). */ - serverUrl: 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 @@ -51,7 +51,7 @@ class Room { this.connId = connId; const ws = new PartySocket({ - host: this.config.serverUrl, + host: this.config.host, party: this.actorName, room: this.instanceId, id: connId, @@ -132,6 +132,16 @@ class Room { } } +/** + * 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 rooms this client opened, so client.cleanup() can reclaim any the app // forgot to close() (each room removes itself here on close). diff --git a/tests/unit/actors.test.ts b/tests/unit/actors.test.ts index 42ea0464..93f0885d 100644 --- a/tests/unit/actors.test.ts +++ b/tests/unit/actors.test.ts @@ -28,14 +28,14 @@ const { sockets, FakeSocket } = vi.hoisted(() => { vi.mock("partysocket", () => ({ default: FakeSocket })); -import { createActorsModule } from "../../src/modules/actors.ts"; +import { createActorsModule, resolveActorsHost } from "../../src/modules/actors.ts"; describe("Actors Module — room handle", () => { const config = { appId: "app-1", getAuthToken: () => "user-tok", functionsVersion: undefined, - serverUrl: "https://app.example", + host: "https://app.example", }; // The module (Proxy of actor names). closeAll is separate — see its own tests. @@ -52,7 +52,7 @@ describe("Actors Module — room handle", () => { 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"); - // serverUrl passes straight through as PartySocket's host (it swaps the scheme). + // the resolved host is handed to PartySocket (which swaps the scheme). expect(sockets[0].opts.host).toBe("https://app.example"); }); @@ -178,3 +178,23 @@ describe("Actors Module — room handle", () => { 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(""); + }); +}); From 4cb5682cc388fe49cf14378ff685fd7589fac302 Mon Sep 17 00:00:00 2001 From: imrik Date: Wed, 29 Jul 2026 12:33:09 +0300 Subject: [PATCH 47/50] chore(actor): drop duplicate blank lines in actor.ts Co-Authored-By: Claude Opus 4.8 --- src/actor.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/actor.ts b/src/actor.ts index 928be3f6..7b5cb2a9 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -11,7 +11,6 @@ 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. @@ -37,7 +36,6 @@ export interface Storage { deleteAll(): Promise; } - /** * Base class for an Actor. * @@ -110,5 +108,4 @@ export abstract class Actor { protected get client(): Base44Client { throw new Error("Actor.client is only available inside a deployed actor"); } - } From 310a333c4cd1953a340f2081dedf1dedffdea73d Mon Sep 17 00:00:00 2001 From: imrik Date: Wed, 29 Jul 2026 13:07:09 +0300 Subject: [PATCH 48/50] test(actor): assert connect() after close() opens a fresh socket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last coverage note from review — the close()->connect() re-open path. Co-Authored-By: Claude Opus 4.8 --- tests/unit/actors.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/unit/actors.test.ts b/tests/unit/actors.test.ts index 93f0885d..2c80339e 100644 --- a/tests/unit/actors.test.ts +++ b/tests/unit/actors.test.ts @@ -112,6 +112,16 @@ describe("Actors Module — room handle", () => { expect(got).toHaveLength(0); }); + test("connect() after close() opens a fresh socket with a new id", () => { + const room = mod().GameRoom("r"); + room.connect({ id: "c1" }); + expect(sockets).toHaveLength(1); + room.close(); + room.connect({ id: "c2" }); + expect(sockets).toHaveLength(2); // a new socket, not the closed one reused + expect(room.id).toBe("c2"); + }); + test("close() clears the id (only valid while connected)", () => { const room = mod().GameRoom("r").connect({ id: "c1" }); expect(room.id).toBe("c1"); From 2de08aea22ad86717b6970eb47b681915f3a7b1a Mon Sep 17 00:00:00 2001 From: imrik Date: Wed, 29 Jul 2026 14:31:57 +0300 Subject: [PATCH 49/50] docs(actor): note this.client always operates on production data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An actor runs server-side with no per-connection identity, so a Test DB preview selected in the editor doesn't apply to this.client — it's an anonymous, prod-scoped client (logged-out-visitor semantics). Document the limitation for actor authors. Co-Authored-By: Claude Fable 5 --- src/actor.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/actor.ts b/src/actor.ts index 7b5cb2a9..69fe2e7a 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -102,7 +102,9 @@ export abstract class 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). + * 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 { From db7cded85bf153c23fc698639124d138a223fa14 Mon Sep 17 00:00:00 2001 From: imrik Date: Wed, 29 Jul 2026 14:39:53 +0300 Subject: [PATCH 50/50] refactor(actor)!: connect() returns a Connection; drop the Room handle actors.X(id) now returns an ActorRef whose only method is connect(); connect() returns a Connection with { id, subscribe, send, close }. subscribe/send are only reachable from a live Connection, so the "connect() before subscribe/send" guards are gone (invalid state is unrepresentable). Renames the Room handle away entirely (ActorRoom -> Connection + ActorRef). Chained usage (actors.X(id).connect().subscribe(...)) is unchanged. Co-Authored-By: Claude Opus 4.8 --- src/index.ts | 4 ++ src/modules/actors.ts | 110 +++++++++++++++++++----------------- src/modules/actors.types.ts | 42 ++++++++------ tests/unit/actors.test.ts | 67 +++++++++------------- 4 files changed, 112 insertions(+), 111 deletions(-) diff --git a/src/index.ts b/src/index.ts index 1dfb439e..0114462b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -110,6 +110,10 @@ export type { AppLogsModule } from "./modules/app-logs.types.js"; export type { ActorsModule, ActorClient, + ActorRef, + Connection, + ActorSubscription, + ActorConnectOptions, ActorNameRegistry, ActorRegistry, } from "./modules/actors.types.js"; diff --git a/src/modules/actors.ts b/src/modules/actors.ts index 26e7ca03..c99a5ace 100644 --- a/src/modules/actors.ts +++ b/src/modules/actors.ts @@ -1,7 +1,8 @@ import PartySocket from "partysocket"; import type { ActorConnectOptions, - ActorRoom, + ActorRef, + Connection as ConnectionType, ActorSubscription, } from "./actors.types.js"; @@ -23,46 +24,40 @@ interface ActorsConfig { const PING_MS = 1_000; const DEAD_MS = 3_000; -class Room { - private ws: PartySocket | null = null; +/** + * 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; - private connId: string | null = null; + /** The client-chosen conn id — becomes _pk → the actor's conn.id. */ + readonly id: string; constructor( - private readonly actorName: string, - private readonly instanceId: string, - private readonly config: ActorsConfig, - private readonly onClose?: () => void, - ) {} - - 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; + actorName: string, + instanceId: string, + config: ActorsConfig, + options: ActorConnectOptions | undefined, + private readonly onClose: () => void, + ) { + this.id = options?.id ?? crypto.randomUUID(); const ws = new PartySocket({ - host: this.config.host, - party: this.actorName, - room: this.instanceId, - id: connId, + 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 = this.config.getAuthToken(); + const token = config.getAuthToken(); return { - app_id: this.config.appId, - handler: this.actorName, + app_id: config.appId, + handler: actorName, ...(token ? { token } : {}), - ...(this.config.functionsVersion ? { fv: this.config.functionsVersion } : {}), + ...(config.functionsVersion ? { fv: config.functionsVersion } : {}), }; }, }); @@ -98,14 +93,9 @@ class Room { // not open; the watchdog above will 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); }, @@ -113,9 +103,6 @@ class Room { } send(data: unknown): void { - if (!this.ws) { - throw new Error(`${this.actorName}:${this.instanceId}: connect() before send()`); - } this.ws.send(JSON.stringify(data)); } @@ -125,13 +112,33 @@ class Room { this.heartbeat = null; } this.listeners.clear(); - this.ws?.close(); - this.ws = null; - this.connId = null; - this.onClose?.(); + 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 @@ -143,28 +150,25 @@ export function resolveActorsHost(serverUrl: string, browserOrigin?: string): st } export function createActorsModule(config: ActorsConfig) { - // Live rooms this client opened, so client.cleanup() can reclaim any the app - // forgot to close() (each room removes itself here on close). - const rooms = new Set(); + // 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 ActorRoom>, + {} 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) => { - const room = new Room(key, instanceId, config, () => rooms.delete(room)); - rooms.add(room); - return room as unknown as ActorRoom; - }; + return (instanceId: string) => + makeActorRef(key, instanceId, config, connections); }, }, ); return { module, closeAll: () => { - for (const room of [...rooms]) room.close(); + for (const c of [...connections]) c.close(); }, }; } diff --git a/src/modules/actors.types.ts b/src/modules/actors.types.ts index 7aa4b6e1..246b56af 100644 --- a/src/modules/actors.types.ts +++ b/src/modules/actors.types.ts @@ -39,7 +39,7 @@ type ToServerFor = N extends keyof ActorRegistry : unknown : unknown; -/** Options for {@link ActorRoom.connect}. */ +/** Options for {@link ActorRef.connect}. */ export interface ActorConnectOptions { /** * The connection id — becomes the actor's `conn.id`. Supply a stable value @@ -49,28 +49,25 @@ export interface ActorConnectOptions { id?: string; } -/** Handle for one listener registered via {@link ActorRoom.subscribe}. */ +/** 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 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. + * 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 ActorRoom { - /** The connection id (the value the actor sees as `conn.id`). Throws before {@link connect}. */ +export interface Connection { + /** The connection id (the value the actor sees as `conn.id`). */ 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 once connecting. */ + /** Send a message. Buffered by the socket until it's open. */ send(data: ToServerFor): void; /** Tear down the socket, heartbeat, and all listeners. */ @@ -78,12 +75,21 @@ export interface ActorRoom { } /** - * 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 + * 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): ActorRoom; + (instanceId: string): ActorRef; } /** @@ -91,11 +97,11 @@ export interface ActorClient { * 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" }); + * 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(); - * room.close(); + * conn.close(); * ``` */ export type ActorsModule = { diff --git a/tests/unit/actors.test.ts b/tests/unit/actors.test.ts index 2c80339e..79ebed51 100644 --- a/tests/unit/actors.test.ts +++ b/tests/unit/actors.test.ts @@ -30,7 +30,7 @@ vi.mock("partysocket", () => ({ default: FakeSocket })); import { createActorsModule, resolveActorsHost } from "../../src/modules/actors.ts"; -describe("Actors Module — room handle", () => { +describe("Actors Module — connection API", () => { const config = { appId: "app-1", getAuthToken: () => "user-tok", @@ -44,10 +44,9 @@ describe("Actors Module — room handle", () => { beforeEach(() => { sockets.length = 0; }); test("connect() opens exactly one socket with the auth query", () => { - const actors = mod(); - const room = actors.GameRoom("room-1").connect({ id: "conn-1" }); + const conn = mod().GameRoom("room-1").connect({ id: "conn-1" }); expect(sockets).toHaveLength(1); - expect(room.id).toBe("conn-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"); @@ -56,24 +55,19 @@ describe("Actors Module — room handle", () => { expect(sockets[0].opts.host).toBe("https://app.example"); }); - test("connect() is idempotent", () => { - const room = mod().GameRoom("r").connect(); - room.connect(); + test("connect() is idempotent per handle", () => { + const ref = mod().GameRoom("r"); + const a = ref.connect(); + const b = ref.connect(); expect(sockets).toHaveLength(1); - }); - - test("subscribe/send/id throw before connect()", () => { - const room = mod().GameRoom("r"); - expect(() => room.subscribe(() => {})).toThrow(/connect\(\)/); - expect(() => room.send({})).toThrow(/connect\(\)/); - expect(() => room.id).toThrow(/connect\(\)/); + expect(a).toBe(b); }); test("multiple listeners all receive; unsubscribe removes only its own", () => { - const room = mod().GameRoom("r").connect(); + const conn = mod().GameRoom("r").connect(); const a: unknown[] = [], b: unknown[] = []; - const subA = room.subscribe((m) => a.push(m)); - room.subscribe((m) => b.push(m)); + 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); @@ -87,25 +81,25 @@ describe("Actors Module — room handle", () => { }); test("__pong platform messages are swallowed", () => { - const room = mod().GameRoom("r").connect(); + const conn = mod().GameRoom("r").connect(); const got: unknown[] = []; - room.subscribe((m) => got.push(m)); + 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 room = mod().GameRoom("r").connect(); - room.send({ type: "join", name: "alice" }); + 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 room = mod().GameRoom("r").connect(); + const conn = mod().GameRoom("r").connect(); const got: unknown[] = []; - room.subscribe((m) => got.push(m)); - room.close(); + 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" }); @@ -113,25 +107,18 @@ describe("Actors Module — room handle", () => { }); test("connect() after close() opens a fresh socket with a new id", () => { - const room = mod().GameRoom("r"); - room.connect({ id: "c1" }); + const ref = mod().GameRoom("r"); + const c1 = ref.connect({ id: "c1" }); expect(sockets).toHaveLength(1); - room.close(); - room.connect({ id: "c2" }); + c1.close(); + const c2 = ref.connect({ id: "c2" }); expect(sockets).toHaveLength(2); // a new socket, not the closed one reused - expect(room.id).toBe("c2"); - }); - - test("close() clears the id (only valid while connected)", () => { - const room = mod().GameRoom("r").connect({ id: "c1" }); - expect(room.id).toBe("c1"); - room.close(); - expect(() => room.id).toThrow(/connect\(\)/); + expect(c2.id).toBe("c2"); }); test("anonymous connect omits the token", () => { - const room = mod({ ...config, getAuthToken: () => null }).GameRoom("r").connect(); - expect(room).toBeDefined(); + const conn = mod({ ...config, getAuthToken: () => null }).GameRoom("r").connect(); + expect(conn).toBeDefined(); expect(sockets[0].opts.query()).not.toHaveProperty("token"); }); @@ -165,12 +152,12 @@ describe("Actors Module — room handle", () => { } }); - test("module is not thenable (then must not resolve to a room factory)", () => { + 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() tears down every open room", () => { + test("closeAll() closes every open connection", () => { const { module, closeAll } = createActorsModule(config); module.GameRoom("a").connect(); module.GameRoom("b").connect();