Skip to content

feat(actor): add actors namespace with subscribe/send#212

Draft
ImriKochWix wants to merge 35 commits into
mainfrom
feat/realtime-handler
Draft

feat(actor): add actors namespace with subscribe/send#212
ImriKochWix wants to merge 35 commits into
mainfrom
feat/realtime-handler

Conversation

@ImriKochWix

@ImriKochWix ImriKochWix commented Jun 30, 2026

Copy link
Copy Markdown

Summary

PR B of 3 for the Actor feature.

  • New src/modules/actors.ts: createActorsModule returns a Proxy so base44.actors.GameRoom works dynamically. subscribe(instanceId, cb) fetches a 30s token from the backend, opens a PartySocket to wss://dispatcher/parties/{handlerName}/{instanceId}?token=..., re-fetches token on reconnect. Returns { send, close }.
  • New src/modules/actors.types.ts: exported types ActorsModule, ActorClient, ActorSubscription
  • src/client.ts: wired actors module with getToken POST to /api/apps/{appId}/actor-token, derives dispatcherWsUrl from serverUrl (https://wss://)
  • package.json: added partysocket dependency

Depends on

PR A deployed to preview (needs the token endpoint + Dispatcher WS handling live).

🤖 Generated with Claude Code

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

claude Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error —— View job


I'll analyze this and get back to you.

@github-actions

github-actions Bot commented Jun 30, 2026

Copy link
Copy Markdown

🚀 Package Preview Available!


Install this PR's preview build with npm:

npm i @base44-preview/sdk@0.8.40-pr.212.527cb51

Prefer not to change any import paths? Install using npm alias so your code still imports @base44/sdk:

npm i "@base44/sdk@npm:@base44-preview/sdk@0.8.40-pr.212.527cb51"

Or add it to your package.json dependencies:

{
  "dependencies": {
    "@base44/sdk": "npm:@base44-preview/sdk@0.8.40-pr.212.527cb51"
  }
}

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

claude Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

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

claude Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

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

claude Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

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

claude Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

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

claude Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

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

claude Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

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

claude Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

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

claude Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

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

claude Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

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>
@ImriKochWix
ImriKochWix force-pushed the feat/realtime-handler branch from d5416b2 to d9732eb Compare July 5, 2026 08:26
@claude

claude Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

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

claude Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error —— View job


I'll analyze this and get back to you.

- __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 <noreply@anthropic.com>
@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error —— View job


I'll analyze this and get back to you.

…ocket

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 <noreply@anthropic.com>
@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error —— View job


I'll analyze this and get back to you.

…d id

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 <noreply@anthropic.com>
@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error —— View job


I'll analyze this and get back to you.

ImriKochWix and others added 2 commits July 7, 2026 15:53
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error —— View job


I'll analyze this and get back to you.

ImriKochWix and others added 2 commits July 9, 2026 11:34
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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
@ImriKochWix
ImriKochWix force-pushed the feat/realtime-handler branch from 7a7f481 to cdf4869 Compare July 9, 2026 08:38
@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error —— View job


I'll analyze this and get back to you.

ImriKochWix and others added 2 commits July 9, 2026 16:22
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 <noreply@anthropic.com>
# Conflicts:
#	.github/workflows/preview-publish.yml
@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error —— View job


I'll analyze this and get back to you.

@ImriKochWix ImriKochWix changed the title feat(realtime): add realtime namespace with subscribe/send feat(actor): add actors namespace with subscribe/send Jul 9, 2026
Matches the platform shim: handleStart runs after credential setup, so
createServiceClient is safe there and no super-call contract exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error —— View job


I'll analyze this and get back to you.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error —— View job


I'll analyze this and get back to you.

ImriKochWix and others added 4 commits July 22, 2026 12:59
…nnection

Phase 1 actors are anonymous-only: the deployed shim no longer implements the
credentialed clients, so remove their stubs from the Actor base class. Generated
actor code that calls them is now a compile error (caught at deploy typecheck)
rather than a runtime crash. handleStart doc no longer references them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The platform proxy now authorizes actor connections like backend-function
calls, so subscribe() connects directly: ?app_id + ?handler (case-preserved
actor name; the party path segment is lowercased by partysocket) + the
regular user access token when signed in (?token, same credential function
calls send; anonymous when absent) + ?fv for draft-vs-published. The
connection id goes via PartySocket's id option (?_pk=). One less HTTP
round-trip per connect; no token refresh on reconnect — the query fn is
re-read so login state stays fresh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts:
#	package-lock.json
#	package.json
@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Claude finished @ImriKochWix's task —— View job


PR Review: Actors namespace with subscribe/send

  • Read changed files
  • Analyze code quality & best practices
  • Check for bugs & issues
  • Review performance considerations
  • Review security concerns
  • Assess test coverage

Nice, clean addition overall — the Proxy-based dynamic actor access is elegant, the heartbeat/half-open detection is thoughtful, and the type-registry approach (ActorRegistry / ActorNameRegistry) is a nice DX touch. A few things worth addressing before merge:


🔴 Bugs / correctness

1. Duplicate, drifting type definitions in actors.ts
src/modules/actors.ts:123-138 re-declares ActorSubscription and ActorClient locally, while src/modules/actors.types.ts also exports them. The Proxy in actors.ts uses the local copies (which is what makes it compile), but the two definitions have already diverged:

  • The doc comment in actors.types.ts:79 shows await base44.actors.MyActor.subscribe(...) and calls it "Open a WebSocket subscription" — implying async — but subscribe is synchronous (returns ActorSubscription, not a Promise). The PR body also describes subscribe returning { send, close }, but the actual return is { id, unsubscribe }. Please reconcile the docs, PR description, and the implementation, and import the shared types from actors.types.ts in actors.ts rather than re-declaring them so they can't drift.

2. crypto.randomUUID() availabilitysrc/modules/actors.ts:39
This relies on a global crypto, which isn't guaranteed in React Native or Node < ~19 (and the SDK explicitly supports RN and older Node via webSocketImpl). Note uuid is already a dependency (used in utils/axios-client.ts). Suggest import { v4 as uuidv4 } from "uuid" for consistency and portability.

3. send() doesn't guard socket statesrc/modules/actors.ts:112-117
If called before the socket is OPEN (or during a reconnect), ws.send may throw. Consider checking readyState/buffering, or at least documenting that send can throw if the socket isn't open. Similarly, the public ActorClient.send in types says "Throws if not subscribed," but that only covers the missing-key case — a not-yet-open socket is a separate failure mode.


🟠 Design / performance

4. Module-level activeSockets map is global across all clientssrc/modules/actors.ts:4
Keying by "ActorName:instanceId" in a module-level Map means two createClient() instances (e.g. user + service role, or multi-tenant SSR) share and can clobber each other's sockets — subscribe closes "existing" sockets that may belong to a different client. Recommend scoping the map inside createActorsModule (per-client) and/or including appId in the key.

5. No cleanup hook wired for actorssrc/client.ts:233
userModules.cleanup() disconnects the entities socket and analytics, but open actor sockets aren't closed. Since sockets live in a module-global map with active setInterval heartbeats, an app that forgets to call unsubscribe() will leak sockets and timers. Consider tracking subscriptions per-client and closing them in cleanup().

6. Heartbeat interval is aggressivesrc/modules/actors.ts:70-71
PING_MS = 1000 / DEAD_MS = 3000 means a ping every second per subscription. With many concurrent rooms this is a lot of traffic and wakeups (and keeps Durable Objects from hibernating). Worth confirming these are intentional and maybe making them configurable.


🟡 Cleanup / minor

7. pstest.mjs looks like a debug scratch file — committed at repo root (+26). It uses a FakeWS to print connect URLs. This should probably be removed or moved into tests/ as a real test.

8. webSocketImpl typed as unknown then cast to anysrc/modules/actors.ts:51. Minor, but a narrower type (e.g. a WebSocket-like constructor) would be safer than as any.

9. Anonymous connectgetAuthToken returns token || getAccessToken() (client.ts:231) and the token is only read once at client-creation time via the closure over token. After setToken() is called, subscribe's query() re-reads via getAccessToken() on reconnect, but the initial token const won't reflect the new value. Confirm this is the intended precedence (it likely works since getAccessToken() reads storage, but the stale token closure is a subtle footgun).


🧪 Test coverage

There are no unit tests for the actors module, despite this being a non-trivial feature (Proxy dispatch, heartbeat/reconnect, URL derivation, anonymous vs. authed query). The repo has a good tests/unit pattern (see entities-subscribe.test.ts, socket-utils.test.ts). At minimum I'd suggest tests for:

  • Proxy returns a client per actor name; subscribe opens a socket with the correct host/party/room/query.
  • dispatcherWsUrl derivation in client.ts:89-99 (https→wss, http→ws, trailing-slash strip, appBaseUrl/window/serverUrl precedence) — pstest.mjs already probes this manually; turn it into assertions.
  • send throws when there's no active subscription.
  • unsubscribe clears the heartbeat and removes the map entry.

Overall this is close — the main blockers I'd flag are the duplicated/diverging types (#1), the crypto.randomUUID portability (#2), the global socket map (#4/#5), and removing pstest.mjs (#7). The rest are polish. Nice work! 👍
feat/realtime-handler

@github-actions github-actions Bot added the docs-draft PR has auto-drafted documentation suggestions label Jul 22, 2026
…ibe/send

Replaces the flat subscribe(roomId, cb) / send(roomId, msg) surface. A room
handle IS the connection: connect() opens the socket (required, idempotent),
subscribe() registers one of N independent listeners (returns a per-listener
unsubscribe), send() throws before connect, close() tears down socket +
heartbeat + all listeners. Fixes the old wart where a second subscribe to the
same room closed the first socket.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Claude finished @ImriKochWix's task —— View job


PR Review: Actors namespace (room-handle API)

  • Read changed files
  • Analyze code quality & best practices
  • Check for bugs & issues
  • Review performance & security
  • Assess test coverage

This is a solid iteration — the API has clearly evolved into a cleaner room-handle design (actors.GameRoom(id).connect()subscribe/send/close). Nice improvements since the last pass: the duplicated types are gone (actors.ts now imports from actors.types.ts), there's a per-client scope, docs match the sync API, and there's a real test file. A few things still worth addressing before merge.


🔴 Bugs / correctness

1. crypto.randomUUID() portabilitysrc/modules/actors.ts:52
Still relies on a global crypto, which isn't guaranteed in React Native or older Node — the very runtimes the webSocketImpl escape hatch exists to support. uuid is already a dependency (src/utils/axios-client.ts:3 uses import { v4 as uuidv4 } from "uuid"). Swap for uuidv4() for consistency and portability.

2. send() can still throw on a not-yet-open socketsrc/modules/actors.ts:118-123
connect() returns synchronously but the socket opens asynchronously, so a room.connect().send(...) right away will call ws.send before OPEN. The type doc (actors.types.ts:74) says "buffered by the socket until open" — PartySocket does buffer, but only once its internal socket exists; a send in the same tick as connect() can still throw. Worth confirming PartySocket's buffering covers this, or guarding readyState. At minimum the heartbeat's ws.send is already wrapped in try/catch (:98-102) — send() isn't.


🟠 Design / lifecycle

3. No cleanup wiring for actor socketssrc/client.ts:233-238
userModules.cleanup() disconnects the entities socket + analytics, but actor rooms aren't tracked, so their sockets and setInterval heartbeats leak if the app forgets to call room.close(). Since createActorsModule now owns state per-client, consider having it track open rooms and expose a closeAll() that cleanup() calls. (serviceRoleModules.cleanup() at :270 has no actors module, so it's fine there.)

4. Heartbeat is aggressivesrc/modules/actors.ts:26-27
PING_MS = 1000 / DEAD_MS = 3000: a ping every second per room. With many concurrent rooms that's a lot of wakeups and keeps Durable Objects from hibernating (cost). Confirm intentional; consider making it configurable or relaxing the interval.

5. close() doesn't reset connIdsrc/modules/actors.ts:125-133
After close(), this.connId is stale and this.ws = null. A subsequent connect() without options.id mints a new id, but connect() with no args after close silently reuses nothing — fine, but reading .id between close and reconnect returns the old id rather than throwing. Minor; worth a quick check that reconnect semantics are what you intend.


🟡 Cleanup / minor

6. pstest.mjs is still a debug scratch file at repo root (+26). It uses a FakeWS to print connect URLs. The real assertions now live in tests/unit/actors.test.ts, so this should be removed (or its URL-derivation checks folded into a test).

7. webSocketImpl typed unknown then as anysrc/modules/actors.ts:19,60. A narrower WebSocket-like constructor type would be safer than the as any cast.

8. getAuthToken stale-closure footgunclient.ts:231 () => token || getAccessToken(). Since it re-reads on each query() call and getAccessToken() hits storage, login/logout between reconnects is picked up correctly. But the initial token const won't reflect a later setToken(). Likely fine (storage wins), just noting the subtlety.


🧪 Test coverage

Good — tests/unit/actors.test.ts covers the important paths: Proxy dispatch, idempotent connect, throw-before-connect, multi-listener + per-listener unsubscribe, __pong swallowing, send serialization, close teardown, anonymous connect, and per-id independence. Gaps worth adding:

  • Heartbeat: with fake timers, assert a __ping is sent every PING_MS and that a stale lastMsg triggers reconnect() after DEAD_MS.
  • dispatcherWsUrl derivation in client.ts:89-99 (https→wss, http→ws, trailing-slash strip, appBaseUrl/window/serverUrl precedence) — pstest.mjs probes this manually; turn it into real assertions.
  • functionsVersion flows into the query as fv.

Blockers I'd flag: crypto.randomUUID portability (#1), missing cleanup wiring (#3), and removing pstest.mjs (#6). The rest is polish. Nice work overall. 👍

View jobfeat/realtime-handler

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs-draft PR has auto-drafted documentation suggestions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant