11import PartySocket from "partysocket" ;
2+ import type {
3+ ActorConnectOptions ,
4+ ActorRoom ,
5+ ActorSubscription ,
6+ } from "./actors.types.js" ;
27
3- // Module-level map: "ActorName:instanceId" → active socket
4- const activeSockets = new Map < string , PartySocket > ( ) ;
5-
6- function socketKey ( actorName : string , instanceId : string ) {
7- return `${ actorName } :${ instanceId } ` ;
8- }
9-
10- export function createActorsModule ( config : {
8+ interface ActorsConfig {
119 appId : string ;
1210 /** Current user access token, if authenticated. Rides the WS query (same
1311 * pattern as the entities socket) so the platform proxy can authenticate the
@@ -19,120 +17,126 @@ export function createActorsModule(config: {
1917 dispatcherWsUrl : string ;
2018 /** WebSocket implementation for runtimes without a global one (Node < 22). */
2119 webSocketImpl ?: unknown ;
22- } ) {
23- return new Proxy ( { } as Record < string , ActorClient > , {
24- get ( _ , actorName : string ) {
25- return {
26- subscribe (
27- instanceId : string ,
28- callback : ( data : unknown ) => void ,
29- options ?: { id ?: string } ,
30- ) : ActorSubscription {
31- const key = socketKey ( actorName , instanceId ) ;
32- // close existing if any
33- activeSockets . get ( key ) ?. close ( ) ;
20+ }
3421
35- // Connection id: caller-supplied (stable — reuse across reconnects/tabs as
36- // you see fit) or auto-generated per subscription. PartySocket sends it
37- // as ?_pk=; the platform proxy validates it and the actor sees this exact
38- // value as conn.id, stable across reconnects.
39- const connId = options ?. id ?? crypto . randomUUID ( ) ;
22+ // Heartbeat / half-open detection. PartySocket only reconnects on a browser
23+ // close/error event, so a silently-dead connection (TCP alive, no data — common
24+ // behind proxies/LBs) hangs until the OS idle timeout (~60s). Ping periodically
25+ // and force a reconnect if nothing comes back within DEAD_MS.
26+ const PING_MS = 1_000 ;
27+ const DEAD_MS = 3_000 ;
4028
41- // No pre-connect token mint: the platform proxy authenticates the
42- // connection itself, exactly like a backend-function call. `handler`
43- // carries the case-preserved actor name (the `party` path segment is
44- // lowercased by PartySocket). query as fn: re-read on every (re)connect
45- // so a login/logout between reconnects is picked up.
46- const ws = new PartySocket ( {
47- host : config . dispatcherWsUrl ,
48- party : actorName ,
49- room : instanceId ,
50- id : connId ,
51- ...( config . webSocketImpl ? { WebSocket : config . webSocketImpl as any } : { } ) ,
52- query : ( ) => {
53- const token = config . getAuthToken ( ) ;
54- return {
55- app_id : config . appId ,
56- handler : actorName ,
57- ...( token ? { token } : { } ) ,
58- ...( config . functionsVersion ? { fv : config . functionsVersion } : { } ) ,
59- } ;
60- } ,
61- } ) ;
29+ class Room {
30+ private ws : PartySocket | null = null ;
31+ private readonly listeners = new Set < ( data : unknown ) => void > ( ) ;
32+ private heartbeat : ReturnType < typeof setInterval > | null = null ;
33+ private connId : string | null = null ;
6234
63- activeSockets . set ( key , ws ) ;
35+ constructor (
36+ private readonly actorName : string ,
37+ private readonly instanceId : string ,
38+ private readonly config : ActorsConfig ,
39+ ) { }
6440
65- // Heartbeat / half-open detection. PartySocket only reconnects on a
66- // browser close/error event, so a silently-dead connection (TCP alive,
67- // no data — common behind proxies/LBs) hangs until the OS idle timeout
68- // (~60s). We ping periodically and force a reconnect if nothing comes
69- // back within DEAD_MS, cutting detection from ~60s to a few seconds.
70- const PING_MS = 1_000 ;
71- const DEAD_MS = 3_000 ;
72- let lastMsg = Date . now ( ) ;
73- const bumpAlive = ( ) => { lastMsg = Date . now ( ) ; } ;
41+ get id ( ) : string {
42+ if ( ! this . connId ) {
43+ throw new Error ( `${ this . actorName } :${ this . instanceId } : connect() before reading id` ) ;
44+ }
45+ return this . connId ;
46+ }
7447
75- ws . addEventListener ( "open" , bumpAlive ) ;
76- ws . addEventListener ( "message" , ( ev ) => {
77- bumpAlive ( ) ;
78- let data : unknown ;
79- try {
80- data = JSON . parse ( ev . data ) ;
81- } catch {
82- return ; // ignore malformed
83- }
84- // Swallow platform messages — never surface them to the app.
85- const msgType = data && typeof data === "object" ? ( data as { type ?: unknown } ) . type : undefined ;
86- if ( msgType === "__pong" ) return ;
87- callback ( data ) ;
88- } ) ;
48+ connect ( options ?: ActorConnectOptions ) : this {
49+ if ( this . ws ) return this ;
8950
90- const heartbeat = setInterval ( ( ) => {
91- if ( Date . now ( ) - lastMsg > DEAD_MS ) {
92- bumpAlive ( ) ; // avoid a reconnect storm while the new socket comes up
93- ws . reconnect ( ) ;
94- return ;
95- }
96- try {
97- ws . send ( JSON . stringify ( { type : "__ping" } ) ) ;
98- } catch {
99- // socket not open; the watchdog above will force a reconnect
100- }
101- } , PING_MS ) ;
51+ // The client picks its own conn id; it becomes _pk → the actor's conn.id.
52+ const connId = options ?. id ?? crypto . randomUUID ( ) ;
53+ this . connId = connId ;
10254
103- return {
104- id : connId , // the connection id (same value the actor sees as conn.id)
105- unsubscribe ( ) {
106- clearInterval ( heartbeat ) ;
107- activeSockets . delete ( key ) ;
108- ws . close ( ) ;
109- } ,
110- } ;
111- } ,
112- send ( instanceId : string , data : unknown ) {
113- const key = socketKey ( actorName , instanceId ) ;
114- const ws = activeSockets . get ( key ) ;
115- if ( ! ws ) throw new Error ( `No active subscription for ${ actorName } :${ instanceId } ` ) ;
116- ws . send ( JSON . stringify ( data ) ) ;
117- } ,
118- } ;
119- } ,
120- } ) ;
121- }
55+ const ws = new PartySocket ( {
56+ host : this . config . dispatcherWsUrl ,
57+ party : this . actorName ,
58+ room : this . instanceId ,
59+ id : connId ,
60+ ...( this . config . webSocketImpl ? { WebSocket : this . config . webSocketImpl as any } : { } ) ,
61+ // Re-read on every (re)connect so a login/logout between reconnects is
62+ // picked up. The platform proxy authenticates the connection itself —
63+ // no pre-connect token mint.
64+ query : ( ) => {
65+ const token = this . config . getAuthToken ( ) ;
66+ return {
67+ app_id : this . config . appId ,
68+ handler : this . actorName ,
69+ ...( token ? { token } : { } ) ,
70+ ...( this . config . functionsVersion ? { fv : this . config . functionsVersion } : { } ) ,
71+ } ;
72+ } ,
73+ } ) ;
74+ this . ws = ws ;
12275
123- /** Handle for an active actor subscription. */
124- interface ActorSubscription {
125- /** This connection's id — the same value the actor receives as `conn.id`. */
126- id : string ;
127- /** Close the subscription and its underlying socket. */
128- unsubscribe ( ) : void ;
76+ let lastMsg = Date . now ( ) ;
77+ const bumpAlive = ( ) => { lastMsg = Date . now ( ) ; } ;
78+ ws . addEventListener ( "open" , bumpAlive ) ;
79+ ws . addEventListener ( "message" , ( ev ) => {
80+ bumpAlive ( ) ;
81+ let data : unknown ;
82+ try {
83+ data = JSON . parse ( ev . data ) ;
84+ } catch {
85+ return ; // ignore malformed
86+ }
87+ const msgType = data && typeof data === "object" ? ( data as { type ?: unknown } ) . type : undefined ;
88+ if ( msgType === "__pong" ) return ; // platform message — never surface it
89+ for ( const listener of this . listeners ) listener ( data ) ;
90+ } ) ;
91+
92+ this . heartbeat = setInterval ( ( ) => {
93+ if ( Date . now ( ) - lastMsg > DEAD_MS ) {
94+ bumpAlive ( ) ; // avoid a reconnect storm while the new socket comes up
95+ ws . reconnect ( ) ;
96+ return ;
97+ }
98+ try {
99+ ws . send ( JSON . stringify ( { type : "__ping" } ) ) ;
100+ } catch {
101+ // socket not open; the watchdog above will force a reconnect
102+ }
103+ } , PING_MS ) ;
104+
105+ return this ;
106+ }
107+
108+ subscribe ( callback : ( data : unknown ) => void ) : ActorSubscription {
109+ if ( ! this . ws ) {
110+ throw new Error ( `${ this . actorName } :${ this . instanceId } : connect() before subscribe()` ) ;
111+ }
112+ this . listeners . add ( callback ) ;
113+ return {
114+ unsubscribe : ( ) => { this . listeners . delete ( callback ) ; } ,
115+ } ;
116+ }
117+
118+ send ( data : unknown ) : void {
119+ if ( ! this . ws ) {
120+ throw new Error ( `${ this . actorName } :${ this . instanceId } : connect() before send()` ) ;
121+ }
122+ this . ws . send ( JSON . stringify ( data ) ) ;
123+ }
124+
125+ close ( ) : void {
126+ if ( this . heartbeat ) {
127+ clearInterval ( this . heartbeat ) ;
128+ this . heartbeat = null ;
129+ }
130+ this . listeners . clear ( ) ;
131+ this . ws ?. close ( ) ;
132+ this . ws = null ;
133+ }
129134}
130135
131- interface ActorClient {
132- subscribe (
133- instanceId : string ,
134- callback : ( data : unknown ) => void ,
135- options ?: { id ?: string } ,
136- ) : ActorSubscription ;
137- send ( instanceId : string , data : unknown ) : void ;
136+ export function createActorsModule ( config : ActorsConfig ) {
137+ return new Proxy ( { } as Record < string , ( instanceId : string ) => ActorRoom > , {
138+ get ( _ , actorName : string ) {
139+ return ( instanceId : string ) => new Room ( actorName , instanceId , config ) as unknown as ActorRoom ;
140+ } ,
141+ } ) ;
138142}
0 commit comments