Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
cfc5dec
feat(realtime): add realtime namespace with subscribe/send
ImriKochWix Jun 30, 2026
30b4ed6
fix: add package-lock.json with partysocket, fix .npmrc syntax
ImriKochWix Jun 30, 2026
efdc8d9
feat(realtime): export RealtimeHandler type from SDK
ImriKochWix Jun 30, 2026
c84c44c
feat(realtime): typed subscribe/send with RealtimeHandlerRegistry
ImriKochWix Jun 30, 2026
c42d1e6
revert: restore .npmrc to pre-branch state
ImriKochWix Jun 30, 2026
b82ccf2
fix(realtime): preserve party+room in updateProperties to fix WebSock…
ImriKochWix Jun 30, 2026
308c687
fix(realtime): use startClosed + reconnect after token fetch
ImriKochWix Jun 30, 2026
ddb6f6d
feat(realtime): add storage getter and onStart hook to RealtimeHandler
ImriKochWix Jul 1, 2026
9260662
feat(realtime): add createServiceClient() + fix kebab party routing +…
ImriKochWix Jul 1, 2026
e1f770a
fix(realtime): revert kebab-case party conversion — dispatcher routes…
ImriKochWix Jul 1, 2026
f9a22d2
fix(realtime): add client heartbeat to detect half-open connections
ImriKochWix Jul 2, 2026
9be245c
fix(realtime): tighten heartbeat to 1s ping / 3s dead for realtime UX
ImriKochWix Jul 2, 2026
d9732eb
feat(realtime): expose connection id from subscribe
ImriKochWix Jul 5, 2026
57dc67b
feat(realtime): add managed-ticker API to RealtimeHandler type
ImriKochWix Jul 5, 2026
9f47622
fix(realtime): add conn.id to the Conn type stub
ImriKochWix Jul 5, 2026
f922705
feat(realtime): type handler on both message directions
ImriKochWix Jul 5, 2026
1939e67
feat(realtime): carry connection id inside the signed realtime token
ImriKochWix Jul 5, 2026
133f94c
feat(realtime)!: registry keys inbound/outbound -> toClient/toServer
ImriKochWix Jul 5, 2026
f4493de
feat(realtime): send Base44-Functions-Version on token requests
ImriKochWix Jul 7, 2026
95ea4dc
feat(realtime): deliver user session token in-band for createUserClient
ImriKochWix Jul 7, 2026
f6a9a48
feat(realtime): webSocketImpl option for runtimes without global WebS…
ImriKochWix Jul 7, 2026
93da902
feat(realtime): drop in-band __auth — handler acts as user by verifie…
ImriKochWix Jul 7, 2026
3e361c8
realtime: createUserClient returns anonymous client for anon connections
ImriKochWix Jul 7, 2026
32683e3
realtime: rename createUserClient -> createClientFromConnection
ImriKochWix Jul 7, 2026
241ee17
realtime: default WS to the app origin, not the API host
ImriKochWix Jul 9, 2026
cdf4869
ci: unblock preview publish + drop dead eslint directive
ImriKochWix Jul 9, 2026
9202263
refactor(sdk): rename RealtimeHandler -> Actor (realtime -> actors)
ImriKochWix Jul 9, 2026
932544b
Merge remote-tracking branch 'origin/main' into feat/realtime-handler
ImriKochWix Jul 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/preview-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 22 additions & 30 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
},
"dependencies": {
"axios": "^1.17.0",
"partysocket": "^0.0.23",
"socket.io-client": "^4.8.3",
"uuid": "^13.0.2"
},
Expand Down
118 changes: 118 additions & 0 deletions src/actor.ts
Original file line number Diff line number Diff line change
@@ -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<Send = unknown> {
/** 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<T>(key: string): Promise<T | undefined>;
put(key: string, value: unknown): Promise<void>;
delete(key: string): Promise<boolean>;
}


/**
* 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<Reg["toServer"], Reg["toClient"]> { ... }
* ```
*/
export abstract class Actor<Incoming = unknown, Outgoing = unknown> {
abstract handleConnect(conn: Conn<Outgoing>): void | Promise<void>;
abstract handleMessage(conn: Conn<Outgoing>, msg: Incoming): void | Promise<void>;
abstract handleClose(conn: Conn<Outgoing>): void | Promise<void>;
abstract handleTick(): void | Promise<void>;

onStart(): void | Promise<void> {}

/**
* 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<Outgoing>[] {
throw new Error("Actor.getConnections() is only available inside a deployed actor");
}

protected startLoop(_ms: number): Promise<void> {
throw new Error("Actor.startLoop() is only available inside a deployed actor");
}

protected stopLoop(): Promise<void> {
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");
}
}
41 changes: 41 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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/",
Expand Down Expand Up @@ -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<any, { token: string }>(
`/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) {
Expand Down
25 changes: 25 additions & 0 deletions src/client.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -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. */
Expand Down
Loading
Loading