1- import PartySocket from "partysocket" ;
1+ import { WebSocket as ReconnectingWebSocket } from "partysocket" ;
22import type {
33 ActorConnectOptions ,
44 ActorRef ,
55 Connection as ConnectionType ,
66 ActorSubscription ,
77} from "./actors.types.js" ;
88
9+ /** Credentials minted by the platform for one direct actor connection. */
10+ export interface ActorConnectionCredentials {
11+ /** Direct actor endpoint, already carrying `?_pk=<connectionId>`. */
12+ websocket_url : string ;
13+ /** Short-lived JWT bound to (app, actor, room, connectionId); appended to
14+ * the URL as `token=` since browsers can't set WebSocket headers. */
15+ token : string ;
16+ }
17+
918interface ActorsConfig {
1019 appId : string ;
11- /** Current user access token, if authenticated. Rides the WS query so the
12- * platform proxy can authenticate the connection; anonymous connects omit it. */
20+ /** Current user access token, if authenticated. Rides the WS query on the
21+ * proxy-fallback path so the platform proxy can authenticate the connection;
22+ * anonymous connects omit it. */
1323 getAuthToken ( ) : string | null | undefined ;
1424 /** Same semantics as function calls: editors with a non-prod version get the
1525 * draft actor script; everyone else gets the published one. */
1626 functionsVersion ?: string ;
17- /** Absolute host PartySocket dials (it strips the scheme and connects wss, ws
27+ /** Absolute host for the proxy-fallback URL ( scheme is swapped to wss, ws
1828 * for localhost). Resolved by {@link resolveActorsHost}. */
1929 host : string ;
30+ /** Mints a direct-connect credential for one (actor, room, connection).
31+ * Called per connection attempt: the token's expiry is checked at upgrade,
32+ * so every reconnect needs a fresh one. */
33+ mintConnectionToken (
34+ actorName : string ,
35+ room : string ,
36+ connectionId : string ,
37+ ) : Promise < ActorConnectionCredentials > ;
38+ /** @internal Ops escape hatch: "proxy" never mints (legacy path only),
39+ * "direct" never falls back. Default "auto". */
40+ transport ?: "auto" | "proxy" | "direct" ;
41+ /** Called when a mint fails for a reason other than the expected
42+ * direct→proxy fallback (which recovers by itself). Wired to the client's
43+ * `options.onError`. */
44+ onMintError ?: ( error : Error ) => void ;
2045}
2146
22- // Heartbeat / half-open detection: PartySocket only reconnects on a close/error
47+ // Heartbeat / half-open detection: the socket only reconnects on a close/error
2348// event, so ping periodically and force a reconnect if nothing returns in DEAD_MS.
2449const PING_MS = 1_000 ;
2550const DEAD_MS = 3_000 ;
2651
52+ // Mint responses that mean "direct can't serve this connection, the proxy can":
53+ // 409 = legacy-family actor script, 503 = direct connections not provisioned,
54+ // 422 = no principal (e.g. anonymous outside a browser) or an id/room only the
55+ // proxy's looser validation accepts. The proxy serves migrated actors too, so
56+ // falling back is always safe.
57+ const PROXY_FALLBACK_STATUSES = new Set ( [ 409 , 422 , 503 ] ) ;
58+
59+ // Mint responses no retry can fix (bad request / forbidden / not found): the
60+ // connection closes instead of re-minting forever; a fresh connect() re-probes.
61+ // 401 is deliberately absent — the auth token is re-read on every attempt, so a
62+ // login recovers on the next retry. Disjoint from PROXY_FALLBACK_STATUSES.
63+ const TERMINAL_MINT_STATUSES = new Set ( [ 400 , 403 , 404 ] ) ;
64+
65+ /** The mint's rejection can be anything; a `Base44Error` carries a numeric
66+ * `.status` (absent for network failures). */
67+ function mintErrorStatus ( err : unknown ) : number | undefined {
68+ const status =
69+ err && typeof err === "object"
70+ ? ( err as { status ?: unknown } ) . status
71+ : undefined ;
72+ return typeof status === "number" ? status : undefined ;
73+ }
74+
75+ function toError ( err : unknown ) : Error {
76+ return err instanceof Error ? err : new Error ( String ( err ) ) ;
77+ }
78+
2779/**
2880 * A live connection to an actor instance. Only obtainable from
2981 * {@link ActorRef.connect}, so `subscribe`/`send` are always valid — the socket
3082 * exists for this object's whole lifetime.
3183 */
3284class Connection {
33- private readonly ws : PartySocket ;
85+ private readonly ws : ReconnectingWebSocket ;
3486 private readonly listeners = new Set < ( data : unknown ) => void > ( ) ;
3587 private heartbeat : ReturnType < typeof setInterval > | null = null ;
88+ private closed = false ;
3689 /** The client-chosen conn id — becomes _pk → the actor's conn.id. */
3790 readonly id : string ;
3891
@@ -45,22 +98,62 @@ class Connection {
4598 ) {
4699 this . id = options ?. id ?? crypto . randomUUID ( ) ;
47100
48- const ws = new PartySocket ( {
49- host : config . host ,
50- party : actorName ,
51- room : instanceId ,
52- id : this . id ,
53- // Re-read on every (re)connect so a login/logout is picked up.
54- query : ( ) => {
55- const token = config . getAuthToken ( ) ;
56- return {
57- app_id : config . appId ,
58- handler : actorName ,
59- ...( token ? { token } : { } ) ,
60- ...( config . functionsVersion ? { fv : config . functionsVersion } : { } ) ,
61- } ;
62- } ,
63- } ) ;
101+ // Direct-first with proxy fallback, decided per connection attempt. Once a
102+ // mint answers with a fallback status the choice is sticky for this
103+ // socket's lifetime (a fresh connect() after close() probes direct again,
104+ // picking up actors migrated in the meantime). Any other mint failure
105+ // rejects, which ReconnectingWebSocket retries with backoff — except the
106+ // terminal statuses, which close this connection for good.
107+ let useProxy = config . transport === "proxy" ;
108+ const urlProvider = async ( ) : Promise < string > => {
109+ if ( this . closed ) throw new Error ( "Actor connection is closed" ) ;
110+ if ( ! useProxy ) {
111+ try {
112+ const { websocket_url, token } = await config . mintConnectionToken (
113+ actorName ,
114+ instanceId ,
115+ this . id ,
116+ ) ;
117+ const sep = websocket_url . includes ( "?" ) ? "&" : "?" ;
118+ return `${ websocket_url } ${ sep } token=${ encodeURIComponent ( token ) } ` ;
119+ } catch ( err ) {
120+ const status = mintErrorStatus ( err ) ;
121+ const isFallback =
122+ config . transport !== "direct" &&
123+ status !== undefined &&
124+ PROXY_FALLBACK_STATUSES . has ( status ) ;
125+ if ( ! isFallback ) {
126+ if ( status !== undefined && TERMINAL_MINT_STATUSES . has ( status ) ) {
127+ // close() before notifying: ws.close() stops the redial the
128+ // rethrow below would otherwise schedule, and a handler that
129+ // immediately calls connect() gets a clean new connection.
130+ this . close ( ) ;
131+ }
132+ // Reported from here because the socket's error event only
133+ // preserves `err.message`, never `.status`.
134+ try {
135+ config . onMintError ?.( toError ( err ) ) ;
136+ } catch {
137+ // an app handler must not break the dial loop or mask `err`
138+ }
139+ throw err ;
140+ }
141+ useProxy = true ;
142+ }
143+ }
144+ // Rebuilt per attempt so a login/logout is picked up on reconnect.
145+ return buildProxyActorUrl (
146+ config . host ,
147+ actorName ,
148+ instanceId ,
149+ this . id ,
150+ config . appId ,
151+ config . getAuthToken ( ) ,
152+ config . functionsVersion ,
153+ ) ;
154+ } ;
155+
156+ const ws = new ReconnectingWebSocket ( urlProvider ) ;
64157 this . ws = ws ;
65158
66159 let lastMsg = Date . now ( ) ;
@@ -82,7 +175,10 @@ class Connection {
82175 this . heartbeat = setInterval ( ( ) => {
83176 if ( Date . now ( ) - lastMsg > DEAD_MS ) {
84177 bumpAlive ( ) ; // avoid a reconnect storm while the new socket comes up
85- ws . reconnect ( ) ;
178+ // Only kick a half-open socket (OPEN but silent). When it isn't open
179+ // the socket is already redialing with backoff, and reconnect() would
180+ // reset that backoff into a mint call every DEAD_MS.
181+ if ( ws . readyState === ws . OPEN ) ws . reconnect ( ) ;
86182 return ;
87183 }
88184 try {
@@ -103,10 +199,14 @@ class Connection {
103199 }
104200
105201 send ( data : unknown ) : void {
202+ // after close() the socket would buffer forever (unbounded enqueue)
203+ if ( this . closed ) return ;
106204 this . ws . send ( JSON . stringify ( data ) ) ;
107205 }
108206
109207 close ( ) : void {
208+ if ( this . closed ) return ;
209+ this . closed = true ;
110210 if ( this . heartbeat ) {
111211 clearInterval ( this . heartbeat ) ;
112212 this . heartbeat = null ;
@@ -140,10 +240,45 @@ function makeActorRef(
140240}
141241
142242/**
143- * Absolute host for the actor WebSocket. PartySocket needs an absolute host and
144- * can't resolve a relative/empty `serverUrl` (same-origin apps use a relative
145- * `/api`, so `serverUrl` is often `""`), so fall back to the page origin.
146- * PartySocket handles the scheme (https→wss, ws for localhost).
243+ * The legacy platform-proxy URL, byte-for-byte what PartySocket built before
244+ * the direct path existed: same scheme swap (including its localhost-needs-a-
245+ * port quirk), case-preserved party segment, `_pk` first in the query. The
246+ * `handler` param is load-bearing — the proxy reads it for the actor name.
247+ */
248+ export function buildProxyActorUrl (
249+ rawHost : string ,
250+ actorName : string ,
251+ instanceId : string ,
252+ connectionId : string ,
253+ appId : string ,
254+ token : string | null | undefined ,
255+ functionsVersion ?: string ,
256+ ) : string {
257+ let host = rawHost . replace ( / ^ ( h t t p | h t t p s | w s | w s s ) : \/ \/ / , "" ) ;
258+ if ( host . endsWith ( "/" ) ) host = host . slice ( 0 , - 1 ) ;
259+ const insecure =
260+ host . startsWith ( "localhost:" ) ||
261+ host . startsWith ( "127.0.0.1:" ) ||
262+ host . startsWith ( "192.168." ) ||
263+ host . startsWith ( "10." ) ||
264+ ( host . startsWith ( "172." ) &&
265+ host . split ( "." ) [ 1 ] >= "16" &&
266+ host . split ( "." ) [ 1 ] <= "31" ) ||
267+ host . startsWith ( "[::ffff:7f00:1]:" ) ;
268+ const query = new URLSearchParams ( [
269+ [ "_pk" , connectionId ] ,
270+ [ "app_id" , appId ] ,
271+ [ "handler" , actorName ] ,
272+ ] ) ;
273+ if ( token ) query . append ( "token" , token ) ;
274+ if ( functionsVersion ) query . append ( "fv" , functionsVersion ) ;
275+ return `${ insecure ? "ws" : "wss" } ://${ host } /parties/${ actorName } /${ instanceId } ?${ query } ` ;
276+ }
277+
278+ /**
279+ * Absolute host for the proxy-fallback actor URL. A relative/empty `serverUrl`
280+ * can't be dialed (same-origin apps use a relative `/api`, so `serverUrl` is
281+ * often `""`), so fall back to the page origin.
147282 */
148283export function resolveActorsHost ( serverUrl : string , browserOrigin ?: string ) : string {
149284 return serverUrl && ! serverUrl . startsWith ( "/" ) ? serverUrl : browserOrigin ?? serverUrl ;
0 commit comments