From cfc5dec86e6c2eb9d3a250f021d7be5e2155f43b Mon Sep 17 00:00:00 2001 From: imrik Date: Tue, 30 Jun 2026 11:20:54 +0300 Subject: [PATCH 01/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] =?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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] =?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/27] 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/27] 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/27] 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/27] 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/27] 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;