diff --git a/.github/workflows/preview-publish.yml b/.github/workflows/preview-publish.yml index 8cd53fd..47f1445 100644 --- a/.github/workflows/preview-publish.yml +++ b/.github/workflows/preview-publish.yml @@ -23,6 +23,8 @@ jobs: registry-url: "https://registry.npmjs.org" - name: Update npm + # Pin to npm@11: npm@latest is now 12.x, which requires node >=22 and + # fails EBADENGINE on this node-20 runner. npm 11 supports node ^20.17. run: npm install -g npm@11 - name: Install dependencies diff --git a/package-lock.json b/package-lock.json index b3f2eb8..2ed5681 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", diff --git a/package.json b/package.json index 35b26c4..82f1379 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/actor.ts b/src/actor.ts new file mode 100644 index 0000000..d71c84a --- /dev/null +++ b/src/actor.ts @@ -0,0 +1,118 @@ +/** + * Type-only base class for Actors. + * + * Import and extend this in your actor files: + * import { Actor } from "@base44/sdk"; + * export class MyActor extends Actor { ... } + * + * At deploy time the bundler replaces this import with the compiled + * Cloudflare Durable Object implementation — this file provides types only. + */ + +import type { Base44Client } from "./client.types.js"; + +/** + * A single client connection. `Send` is the message type this connection accepts + * via {@link send} — the actor's *outgoing* (server→client) messages. + */ +export interface Conn { + /** Unique per-connection id (one per socket/tab), the same value the client + * receives from `subscribe()`. Use this — not userId — to identify a distinct + * client, so multiple tabs of the same user are separate connections. */ + id: string; + userId: string; + appId: string; + instanceId: string; + send(data: Send): void; + reject(code: number, reason: string): void; +} + +export interface Storage { + get(key: string): Promise; + put(key: string, value: unknown): Promise; + delete(key: string): Promise; +} + + +/** + * Base class for an Actor. + * + * @typeParam Incoming - messages this actor *receives* from clients + * (`handleMessage`'s `msg`) — the schema's `toServer` section. + * @typeParam Outgoing - messages this actor *sends* to clients + * (`conn.send`/`broadcast`) — the schema's `toClient` section. + * + * With a generated `schema.jsonc`, wire both from the registry so they can't drift + * from the client's types: + * ```ts + * type Reg = ActorRegistry["MyActor"]; + * class MyActor extends Actor { ... } + * ``` + */ +export abstract class Actor { + abstract handleConnect(conn: Conn): void | Promise; + abstract handleMessage(conn: Conn, msg: Incoming): void | Promise; + abstract handleClose(conn: Conn): void | Promise; + abstract handleTick(): void | Promise; + + 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: Outgoing): void { + throw new Error("Actor.broadcast() is only available inside a deployed actor"); + } + + protected getConnections(): Conn[] { + throw new Error("Actor.getConnections() is only available inside a deployed actor"); + } + + protected startLoop(_ms: number): Promise { + throw new Error("Actor.startLoop() is only available inside a deployed actor"); + } + + protected stopLoop(): Promise { + throw new Error("Actor.stopLoop() is only available inside a deployed actor"); + } + + protected get instanceId(): string { + throw new Error("Actor.instanceId is only available inside a deployed actor"); + } + + protected get storage(): Storage { + throw new Error("Actor.storage is only available inside a deployed actor"); + } + + /** + * SDK client acting **as the connected user** — every entity call respects + * the app's row-level security, evaluated as that user at call time. The + * default wherever a `conn` is in scope (connect/message/close). + * + * Anonymous connections get an unauthenticated client — entity calls run + * under anonymous RLS, just like any public visitor. + */ + protected createClientFromConnection(conn: Conn): Base44Client { + void conn; + throw new Error("Actor.createClientFromConnection() is only available inside a deployed actor"); + } + + /** + * Service-role SDK client — bypasses RLS. For work with **no user in scope** + * (tick, alarm, onStart). Inside handleMessage/handleConnect prefer + * `createClientFromConnection(conn)`. + */ + protected createServiceClient(): Base44Client { + throw new Error("Actor.createServiceClient() is only available inside a deployed actor"); + } +} diff --git a/src/client.ts b/src/client.ts index 5b46e80..5d1768e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -20,6 +20,7 @@ import type { CreateClientOptions, } from "./client.types.js"; import { createAnalyticsModule } from "./modules/analytics.js"; +import { createActorsModule } from "./modules/actors.js"; // Re-export client types export type { Base44Client, CreateClientConfig, CreateClientOptions }; @@ -72,11 +73,31 @@ export function createClient(config: CreateClientConfig): Base44Client { options, functionsVersion, headers: optionalHeaders, + dispatcherWsUrl, + webSocketImpl, } = config; // Normalize appBaseUrl to always be a string (empty if not provided or invalid) const normalizedAppBaseUrl = typeof appBaseUrl === "string" ? appBaseUrl : ""; + // Derive the dispatcher WebSocket URL if not explicitly provided. Default to + // the app's OWN origin (the app URL proxies /parties to the backend) so the + // socket is same-origin with the running app, not the API host: prefer an + // explicit appBaseUrl, then the browser origin, then fall back to serverUrl + // (Node/SSR, where there's no window). Convert https:// → wss:// (http → ws) + // and strip the trailing slash. + const resolvedDispatcherWsUrl = (() => { + if (dispatcherWsUrl) return dispatcherWsUrl.replace(/\/$/, ""); + const appOrigin = + normalizedAppBaseUrl || + // React Native has a bare `window` with no `location`, so guard both. + (typeof window !== "undefined" ? window.location?.origin ?? "" : ""); + return (appOrigin || serverUrl) + .replace(/\/$/, "") + .replace(/^https:\/\//, "wss://") + .replace(/^http:\/\//, "ws://"); + })(); + const socketConfig: RoomsSocketConfig = { serverUrl, mountPath: "/ws-user-apps/socket.io/", @@ -200,6 +221,26 @@ export function createClient(config: CreateClientConfig): Base44Client { appId, userAuthModule, }), + actors: createActorsModule({ + appId, + dispatcherWsUrl: resolvedDispatcherWsUrl, + webSocketImpl, + getToken: async (actorName, instanceId, connId) => { + // axiosClient interceptors unwrap response.data, so the result is the body directly. + // conn_id rides inside the signed token (not a WS query param) so it survives + // proxies that strip params; the dispatcher forwards it as the actor's conn.id. + // Base44-Functions-Version rides along (like function calls) so live apps get + // tokens for the *published* actor script and previews get the draft. + const data = await axiosClient.post( + `/apps/${appId}/actor-token`, + { handler_name: actorName, instance_id: instanceId, conn_id: connId }, + functionsVersion + ? { headers: { "Base44-Functions-Version": functionsVersion } } + : undefined + ); + return data.token; + }, + }), cleanup: () => { userModules.analytics.cleanup(); if (socket) { diff --git a/src/client.types.ts b/src/client.types.ts index 611f81b..cb8fb3b 100644 --- a/src/client.types.ts +++ b/src/client.types.ts @@ -11,6 +11,7 @@ import type { AgentsModule } from "./modules/agents.types.js"; import type { AiGatewayModule } from "./modules/ai-gateway.types.js"; import type { AppLogsModule } from "./modules/app-logs.types.js"; import type { AnalyticsModule } from "./modules/analytics.types.js"; +import type { ActorsModule } from "./modules/actors.types.js"; /** * Options for creating a Base44 client. @@ -78,6 +79,28 @@ export interface CreateClientConfig { * Additional client options. */ options?: CreateClientOptions; + /** + * Base WebSocket URL for the Cloudflare Durable Object dispatcher. + * + * Defaults to the app's own origin (`appBaseUrl`, else the browser's + * `window.location.origin`, else `serverUrl`) with `https://` replaced by + * `wss://` (or `http://` by `ws://`) — so the realtime socket is same-origin + * with the running app, which proxies `/parties` to the backend. + * Override when the dispatcher lives at a different host than the app. + */ + dispatcherWsUrl?: string; + /** + * WebSocket implementation for realtime subscriptions in environments + * without a global `WebSocket` (Node.js < 22). Browsers and Node ≥ 22 + * don't need this. + * + * @example + * ```typescript + * import WS from "ws"; + * const base44 = createClient({ appId, webSocketImpl: WS }); + * ``` + */ + webSocketImpl?: unknown; } /** @@ -94,6 +117,8 @@ export interface Base44Client { analytics: AnalyticsModule; /** {@link AppLogsModule | App logs module} for tracking app usage. */ appLogs: AppLogsModule; + /** {@link ActorsModule | Actors module} for subscribing to and sending messages via Cloudflare Durable Object-backed Actors. */ + actors: ActorsModule; /** {@link AuthModule | Auth module} for user authentication and management. */ auth: AuthModule; /** {@link UserConnectorsModule | Connectors module} for app-user OAuth flows. */ diff --git a/src/index.ts b/src/index.ts index 3c445bb..1dfb439 100644 --- a/src/index.ts +++ b/src/index.ts @@ -107,8 +107,17 @@ export type { export type { AppLogsModule } from "./modules/app-logs.types.js"; +export type { + ActorsModule, + ActorClient, + ActorNameRegistry, + ActorRegistry, +} from "./modules/actors.types.js"; + export type { SsoModule, SsoAccessTokenResponse } from "./modules/sso.types.js"; +export { Actor, type Conn } from "./actor.js"; + export type { ConnectorsModule, UserConnectorsModule, diff --git a/src/modules/actors.ts b/src/modules/actors.ts new file mode 100644 index 0000000..cc28943 --- /dev/null +++ b/src/modules/actors.ts @@ -0,0 +1,122 @@ +import PartySocket from "partysocket"; + +// Module-level map: "ActorName:instanceId" → active socket +const activeSockets = new Map(); + +function socketKey(actorName: string, instanceId: string) { + return `${actorName}:${instanceId}`; +} + +export function createActorsModule(config: { + appId: string; + 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(_, actorName: string) { + return { + subscribe( + instanceId: string, + callback: (data: unknown) => void, + options?: { id?: string }, + ): ActorSubscription { + const key = socketKey(actorName, instanceId); + // close existing if any + activeSockets.get(key)?.close(); + + // Connection id: caller-supplied (stable — reuse across reconnects/tabs as + // you see fit) or auto-generated per subscription. It travels INSIDE the + // signed actor token (never as a WS query param, which proxies strip); + // the dispatcher forwards the verified claim as partyserver's _pk, so the + // actor sees this exact value as conn.id. Reconnects re-mint the token + // with the same id, so conn.id is stable across reconnects. + 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: actorName, + room: instanceId, + ...(config.webSocketImpl ? { WebSocket: config.webSocketImpl as any } : {}), + query: () => + config.getToken(actorName, instanceId, connId).then((token) => ({ token })), + }); + + 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. + const PING_MS = 1_000; + const DEAD_MS = 3_000; + let lastMsg = Date.now(); + const bumpAlive = () => { lastMsg = Date.now(); }; + + ws.addEventListener("open", bumpAlive); + ws.addEventListener("message", (ev) => { + bumpAlive(); + let data: unknown; + try { + data = JSON.parse(ev.data); + } catch { + return; // ignore malformed + } + // Swallow platform messages — never surface them to the app. + const msgType = data && typeof data === "object" ? (data as { type?: unknown }).type : undefined; + if (msgType === "__pong") return; + callback(data); + }); + + 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 { + id: connId, // the connection id (same value the actor sees as conn.id) + unsubscribe() { + clearInterval(heartbeat); + activeSockets.delete(key); + ws.close(); + }, + }; + }, + send(instanceId: string, data: unknown) { + const key = socketKey(actorName, instanceId); + const ws = activeSockets.get(key); + if (!ws) throw new Error(`No active subscription for ${actorName}:${instanceId}`); + ws.send(JSON.stringify(data)); + }, + }; + }, + }); +} + +/** 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 ActorClient { + subscribe( + instanceId: string, + callback: (data: unknown) => void, + options?: { id?: string }, + ): 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 0000000..2c191fb --- /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;