Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 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
43e4e53
feat(actor): handleStart wake hook replaces user onStart overrides
ImriKochWix Jul 12, 2026
227561d
feat(actor): Storage.deleteAll for match-end room cleanup
ImriKochWix Jul 19, 2026
76e440c
feat(actors): phase 1 — drop createServiceClient / createClientFromCo…
ImriKochWix Jul 22, 2026
860daa4
feat(actors): connect without a token mint — auth rides the WS query
ImriKochWix Jul 22, 2026
3c91616
chore: retrigger preview publish (dropped workflow event)
ImriKochWix Jul 22, 2026
e370e5f
Merge remote-tracking branch 'origin/main' into feat/realtime-handler
ImriKochWix Jul 22, 2026
b501f2e
feat(actors): room-handle API — actors.Name(id).connect() then subscr…
ImriKochWix Jul 22, 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.18.1",
"partysocket": "^0.0.23",
"socket.io-client": "^4.8.3",
"uuid": "^13.0.2"
},
Expand Down
26 changes: 26 additions & 0 deletions pstest.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import PartySocket from "partysocket";

// Fake WebSocket that just records the URL PartySocket tries to open.
class FakeWS {
constructor(url) { FakeWS.lastUrl = url; this.readyState = 0; }
addEventListener(){} removeEventListener(){} close(){} send(){}
}

function urlFor(host) {
FakeWS.lastUrl = null;
try {
new PartySocket({ host, room: "room123", party: "GameRoom", WebSocket: FakeWS, query: { token: "T" } });
} catch (e) { return "THREW: " + e.message; }
return FakeWS.lastUrl;
}

console.log("=== PartySocket host -> connect URL ===");
for (const h of ["https://app.base44.app", "http://localhost:1999", "app.base44.app", "https://app.com/sub/path", "http://10.0.0.5:3000"]) {
console.log(`host=${JSON.stringify(h)}\n -> ${urlFor(h)}`);
}

console.log("\n=== new URL(raw).origin behavior ===");
for (const raw of ["https://app.com", "http://localhost:1999", "https://app.com/foo", "app.com", "myapp.base44.app"]) {
try { console.log(`${JSON.stringify(raw)} -> ${new URL(raw).origin}`); }
catch (e) { console.log(`${JSON.stringify(raw)} -> THREW: ${e.message}`); }
}
103 changes: 103 additions & 0 deletions src/actor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* Type-only base class for Actors.
*
* Import and extend this in your actor files:
* import { Actor } from "@base44/sdk";
* export class MyActor extends Actor { ... }
*
* At deploy time the bundler replaces this import with the compiled
* Cloudflare Durable Object implementation — this file provides types only.
*/


/**
* A single client connection. `Send` is the message type this connection accepts
* via {@link send} — the actor's *outgoing* (server→client) messages.
*/
export interface Conn<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>;
/** Wipe the room's entire persisted storage (match-end cleanup). Safe: a
* later rejoin re-bootstraps exactly like a brand-new room. */
deleteAll(): Promise<void>;
}


/**
* 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>;

/**
* Optional wake hook: runs once when the instance starts, before any
* connection is handled — safe to load persisted state here.
*/
handleStart(): 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");
}

}
30 changes: 30 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,15 @@ export function createClient(config: CreateClientConfig): Base44Client {
appId,
userAuthModule,
}),
actors: createActorsModule({
appId,
dispatcherWsUrl: resolvedDispatcherWsUrl,
webSocketImpl,
functionsVersion,
// Same credential as function calls; the platform proxy authenticates the
// WS connection with it (anonymous when absent) — no pre-connect token mint.
getAuthToken: () => token || getAccessToken(),
}),
cleanup: () => {
userModules.analytics.cleanup();
if (socket) {
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