feat(actor): add actors namespace with subscribe/send - #212
Conversation
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 <noreply@anthropic.com>
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
🚀 Package Preview Available!Install this PR's preview build with npm: npm i @base44-preview/sdk@0.8.40-pr.212.6649074Prefer not to change any import paths? Install using npm alias so your code still imports npm i "@base44/sdk@npm:@base44-preview/sdk@0.8.40-pr.212.6649074"Or add it to your {
"dependencies": {
"@base44/sdk": "npm:@base44-preview/sdk@0.8.40-pr.212.6649074"
}
}
Preview published to npm registry — try new features instantly! |
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 <noreply@anthropic.com>
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
- subscribe() now returns a sync unsubscribe function instead of Promise<RealtimeSubscription> - 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 <noreply@anthropic.com>
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
…et 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 <noreply@anthropic.com>
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
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 <noreply@anthropic.com>
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
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 <noreply@anthropic.com>
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
… async token refresh
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
… by JWT script_name, not URL party name
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
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) <noreply@anthropic.com>
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
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) <noreply@anthropic.com>
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
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) <noreply@anthropic.com>
d5416b2 to
d9732eb
Compare
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
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) <noreply@anthropic.com>
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
|
Claude finished @ImriKochWix's task —— View job PR Review: Actors namespace (room-handle API)
Clean, well-documented iteration. The room-handle API reads nicely, the type registry ( 🟠 Design / lifecycle1. No cleanup wiring for actor rooms — the main functional risk — // actors.ts
export function createActorsModule(config: ActorsConfig) {
const rooms = new Set<Room>();
const module = new Proxy(/* ... */ {
get(_, actorName: string) {
if (typeof actorName !== "string") return undefined; // see #4
return (instanceId: string) => {
const room = new Room(actorName, instanceId, config);
rooms.add(room); // and remove on close()
return room as unknown as ActorRoom;
};
},
});
// attach closeAll() for client.cleanup() to call
}2. 3. Heartbeat is aggressive — 🟡 Minor4. Proxy 5. 6. 🔒 SecurityNothing concerning. The token rides the WS 🧪 Test coverageSolid baseline in
Blocker I'd flag: cleanup wiring (#1) — a forgotten View job • |
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Claude finished @ImriKochWix's task —— View job PR Review: Actors namespace (room-handle API)
Clean, well-documented feature. The room-handle API reads nicely, the type registry ( 🟠 Design / lifecycle1. No cleanup wiring for actor rooms — the main functional risk — export function createActorsModule(config: ActorsConfig) {
const rooms = new Set<Room>();
const module = new Proxy({} as Record<string, (instanceId: string) => ActorRoom>, {
get(_, actorName: string) {
if (typeof actorName !== "string") return undefined; // see #4
return (instanceId: string) => {
const room = new Room(actorName, instanceId, config, () => rooms.delete(room));
rooms.add(room);
return room as unknown as ActorRoom;
};
},
});
return Object.assign(module, { closeAll: () => { for (const r of rooms) r.close(); rooms.clear(); } });
}…then call 2. 3. Heartbeat is aggressive — 🟡 Minor4. Proxy 5. 6. 🔒 SecurityNothing concerning. The token rides the WS 🧪 Test coverageSolid baseline in
Blocker I'd flag: cleanup wiring (#1) — a forgotten View job • |
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Claude finished @ImriKochWix's task —— View job PR Review: Actors namespace (room-handle API)
Clean, well-documented feature. The room-handle API reads nicely, the type registry ( 🟠 Design / lifecycle1. No cleanup wiring for actor rooms — the main functional risk — export function createActorsModule(config: ActorsConfig) {
const rooms = new Set<Room>();
const module = new Proxy({} as Record<string, (instanceId: string) => ActorRoom>, {
get(_, actorName: string) {
if (typeof actorName !== "string") return undefined; // see #4
return (instanceId: string) => {
const room = new Room(actorName, instanceId, config, () => rooms.delete(room));
rooms.add(room);
return room as unknown as ActorRoom;
};
},
});
return Object.assign(module, {
closeAll: () => { for (const r of rooms) r.close(); rooms.clear(); },
});
}…then call 2. 3. Heartbeat is aggressive — 🟡 Minor4. Proxy 5. 6. 🔒 SecurityNothing concerning. The token rides the WS 🧪 Test coverageSolid baseline in
Blocker I'd flag: cleanup wiring (#1) — a forgotten View job • |
createActorsModule now tracks live rooms per client and exposes closeAll(), which client.cleanup() invokes — a forgotten room.close() no longer leaks its 1s heartbeat timer (which also keeps the Node event loop alive). The Proxy get trap consults the target first so closeAll resolves instead of being read as an actor name. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The get trap returned a room factory for `then`, so awaiting base44.actors (or passing it through a Promise chain) called the factory as a thenable and hung. Resolve `then` (and inherited/symbol keys) normally so the module isn't mistaken for a Promise. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
close() left connId set, so room.id returned a stale id between close and the next connect(). Null it in close() — reading id after close now throws the same connect()-first guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks — walked all six:
Test coverage: |
|
Claude finished @ImriKochWix's task —— View job PR Review: Actors namespace (room-handle API)
Thanks for walking all six — I re-read the current tree and can confirm the fixes landed cleanly. This is in good shape to merge. 🟢 Verified fixes since last pass
🟡 Minor (non-blocking, optional)1. 2. 🧪 Test coverageStrong baseline in
None of these block merge. Verdict: the one functional blocker from prior passes (cleanup wiring) is now fixed and tested. Remaining items are optional polish. Nice work getting this over the line. 👍 View job • |
createActorsModule returns { module, closeAll } instead of bolting closeAll onto
the Proxy. The Proxy now only maps names to room factories (2-condition guard),
and closeAll is a plain internal function client.cleanup() calls — dropping the
api object, Object.assign, the intersection cast, and the key-in-target branch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Claude finished @ImriKochWix's task —— View job PR Review: Actors namespace (room-handle API)
Re-reviewed the current tree. All prior blockers are resolved and the code reads cleanly. This is in good shape to merge — remaining notes are optional polish. 🟢 Verified fixes from prior passes
🟡 Minor (non-blocking, optional)1. 2. Heartbeat cost — 3. 🔒 SecurityNothing concerning. The token rides the WS 🧪 Test coverageStrong baseline in
Verdict: No blockers. The one functional risk from earlier passes (cleanup leak) is fixed and tested. Nice work getting this over the line. 👍 View job • |
- Extract resolveActorsWsUrl into a pure exported helper (client.ts calls it) and unit-test its precedence + protocol/trailing-slash normalization. - Add heartbeat tests (fake timers): __ping every PING_MS, reconnect after DEAD_MS of silence; and functionsVersion -> fv present/omitted. - Soften the send() doc: buffered "once connecting" (matches PartySocket). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Claude finished @ImriKochWix's task —— View job PR Review: Actors namespace (room-handle API)
Re-reviewed the current tree. This has come a long way — all prior blockers are resolved, and the new commits since the last pass ( 🟢 Verified since last pass
🟡 Minor (non-blocking, optional)1. 2. 3. Heartbeat cost — 4. 🔒 SecurityNothing concerning. The token rides the WS 🧪 Test coverageStrong — Verdict: No blockers. Every functional risk from prior passes is fixed and tested; the remaining items are polish/optional. Nice work getting this over the line. 👍 View job • |
…on code serverUrl is the app's own origin, and PartySocket already strips the scheme, strips a trailing slash, and picks wss (ws for localhost) from the host. So the resolveActorsWsUrl helper, the actorsWsUrl client option, and the browserOrigin/appBaseUrl derivation were all redundant — pass serverUrl straight through as PartySocket's host. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Claude finished @ImriKochWix's task —— View job PR Review: Actors namespace (room-handle API)
Re-reviewed the current tree. The recent commits simplified things nicely: 🟢 State of prior items
🟠 Notes on the serverUrl refactor1. PR body is now stale. The description still mentions 2. 🟡 Minor (non-blocking, optional — carried over)3. 4. Heartbeat cost — 5. 🔒 SecurityNothing concerning. Token rides the WS 🧪 Test coverageStrong — Verdict: No blockers. The serverUrl simplification is clean and well-tested. Main ask is refreshing the PR description (and the |
Actors deployed by the bundler now expose an anonymous Base44 client as this.client. Mirror it on the type-only Actor base class (throw-stub like storage/instanceId) so actor authors get it typed as Base44Client; the runtime value is provided by the bundler shim. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Claude finished @ImriKochWix's task —— View job PR Review: Actors namespace (room-handle API)
Re-reviewed the current tree. The two new commits since the last pass — 🟢 State of prior items
🟠 Doc / naming reconciliation (from the serverUrl refactor)1. PR body is now stale. The description still mentions the 2. 🟡 Minor (non-blocking, optional — carried over)3. 4. Heartbeat cost — 5. 6. Duplicate blank lines in 🔒 SecurityNothing concerning. Token rides the WS 🧪 Test coverageStrong — Verdict: No blockers. The serverUrl simplification is clean and well-tested. Main ask is refreshing the PR description and the View job • |
…he socket serverUrl is often "" (same-origin apps use a relative /api), and PartySocket can't resolve a relative host — it builds a hostless wss:///parties/... So add resolveActorsHost: use serverUrl when absolute, else fall back to the browser origin. PartySocket still owns the scheme swap and trailing-slash strip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Claude finished @ImriKochWix's task —— View job PR Review: Actors namespace (room-handle API)
Re-reviewed the current tree. The recent commits are clean: 🟢 State of prior items
🟠 Small correctness note1. 🟠 Doc / naming reconciliation (carried over)2. PR body is stale. The description still mentions the 3. Comment vs. value source — 🟡 Minor (non-blocking, optional — carried over)4. 5. Heartbeat cost — 6. 7. Duplicate blank lines in 🔒 SecurityNothing concerning. Token rides the WS 🧪 Test coverageStrong — Verdict: No blockers. The View job • |
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Claude finished @ImriKochWix's task —— View job PR Review: Actors namespace (room-handle API)
Re-reviewed the current tree. The code is clean and all prior functional blockers remain resolved. The recent commits ( 🟢 State of prior items (all verified resolved)
🟠 Small correctness note (carried over)1. 🟠 Doc / naming reconciliation (carried over — still worth doing)2. PR body is stale. The description still mentions the 3. Comment vs. value source — 🟡 Minor (non-blocking, optional — carried over)4. 5. Heartbeat cost — 6. 🔒 SecurityNothing concerning. Token rides the WS 🧪 Test coverageStrong — Verdict: No blockers. Every functional risk from prior passes is fixed and tested; the remaining items are polish/optional. The main ask remains refreshing the PR description and the View job • |
Closes the last coverage note from review — the close()->connect() re-open path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Claude finished @ImriKochWix's task —— View job PR Review: Actors namespace (room-handle API)
Re-reviewed the current tree. This is clean and ready to merge — every functional blocker from the prior passes is resolved and tested. Notes below are polish/doc reconciliation only. 🟢 State of prior items (all verified resolved)
🟠 Small correctness note (carried over)1. 🟠 Doc / naming reconciliation (carried over — still worth doing)2. PR body is stale. The description still mentions 3. Comment vs. value source — 🟡 Minor (non-blocking, optional — carried over)4. 5. Heartbeat cost — 6. 🔒 SecurityNothing concerning. Token rides the WS 🧪 Test coverageStrong and complete — Verdict: No blockers. The one functional risk from earlier passes (cleanup leak) is fixed and tested, and the previously-open coverage gaps are now closed. Remaining items are optional polish — mainly reconciling the PR description and the View job • |
An actor runs server-side with no per-connection identity, so a Test DB preview selected in the editor doesn't apply to this.client — it's an anonymous, prod-scoped client (logged-out-visitor semantics). Document the limitation for actor authors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Claude finished @ImriKochWix's task —— View job PR Review: Actors namespace (room-handle API)
Re-reviewed the current tree. The code is clean and every functional blocker from prior passes is resolved and tested. Below I address @netanelgilad's inline naming question, then the remaining polish items. 💬 Re: @netanelgilad's question — "shouldn't we call this
|
actors.X(id) now returns an ActorRef whose only method is connect(); connect()
returns a Connection with { id, subscribe, send, close }. subscribe/send are only
reachable from a live Connection, so the "connect() before subscribe/send" guards
are gone (invalid state is unrepresentable). Renames the Room handle away entirely
(ActorRoom -> Connection + ActorRef). Chained usage
(actors.X(id).connect().subscribe(...)) is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Claude finished @ImriKochWix's task —— View job PR Review: Actors namespace (connect → Connection API)
Re-reviewed the current tree. The API has been refactored since the last pass into a cleaner two-step model: 💬 Re: @netanelgilad — "shouldn't we call this
|
Summary
PR B of 3 for the Actor feature — the client SDK surface.
src/modules/actors.ts—createActorsModulereturns{ module, closeAll }.moduleis aProxysobase44.actors.GameRoom(instanceId)works for any deployed actor name; it returns a room handle:.connect(opts?)— opens onePartySocket(idempotent). The client picks its own conn id (opts.id, elsecrypto.randomUUID()); it becomes the actor'sconn.id, so a reconnect with the same id keeps the same server-side identity..subscribe(cb)→{ unsubscribe };.send(data);.close().query; the platform proxy authenticates it. No pre-connect token mint.Proxygetreturnsundefinedforthen/symbol keys so the module isn't mistaken for a thenable.closeAll()(internal) closes every live room;client.cleanup()calls it so a forgottenroom.close()doesn't leak its heartbeat timer.src/actor.ts— type-onlyActor<Incoming, Outgoing>base class +Conn/Storage/clienttypes (the bundler swaps the runtime at deploy).src/modules/actors.types.ts—ActorRegistry(hand-authored message types) andActorNameRegistry(auto-generated bybase44 types generate), plusActorRoom/ActorSubscription/ActorConnectOptions.src/client.ts— wires theactorsmodule. The socket host isresolveActorsHost(serverUrl, window?.location?.origin): an absoluteserverUrlis used as-is, and an empty/relativeserverUrl(same-origin apps use a relative/api) falls back to the page origin. PartySocket owns the scheme (https→wss,wsfor localhost) and trailing-slash strip, so there's no manual URL munging.package.json— addspartysocket. Requires Node 22+ / a browser (globalWebSocket+crypto.randomUUID).Depends on
PR A deployed to preview (the app-origin
/partiesproxy + Dispatcher WS handling must be live).🤖 Generated with Claude Code