Skip to content

Commit 00f9897

Browse files
committed
work with direct actors
1 parent 8c2ea8e commit 00f9897

8 files changed

Lines changed: 816 additions & 82 deletions

File tree

src/client.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ import type {
2020
CreateClientOptions,
2121
} from "./client.types.js";
2222
import { createAnalyticsModule } from "./modules/analytics.js";
23-
import { createActorsModule, resolveActorsHost } from "./modules/actors.js";
23+
import {
24+
createActorsModule,
25+
resolveActorsHost,
26+
type ActorConnectionCredentials,
27+
} from "./modules/actors.js";
2428

2529
// Re-export client types
2630
export type { Base44Client, CreateClientConfig, CreateClientOptions };
@@ -143,6 +147,16 @@ export function createClient(config: CreateClientConfig): Base44Client {
143147
interceptResponses: false,
144148
});
145149

150+
// Dedicated client for actor connection-token mints: no onError (a legacy
151+
// actor answers every mint with an expected 409 before the proxy fallback,
152+
// which must not reach the app's error handler — the actors module forwards
153+
// genuine failures itself via onMintError) and no constructor token
154+
// (auth is per-request so a login/logout is picked up on every reconnect).
155+
const actorsAxiosClient = createAxiosClient({
156+
baseURL: `${serverUrl}/api`,
157+
headers,
158+
});
159+
146160
const userAuthModule = createAuthModule(
147161
axiosClient,
148162
functionsAxiosClient,
@@ -167,14 +181,33 @@ export function createClient(config: CreateClientConfig): Base44Client {
167181

168182
const actorsModule = createActorsModule({
169183
appId,
170-
// serverUrl is often relative/empty (same-origin app); PartySocket needs an
171-
// absolute host, so fall back to the page origin.
184+
// serverUrl is often relative/empty (same-origin app); the proxy-fallback
185+
// URL needs an absolute host, so fall back to the page origin.
172186
host: resolveActorsHost(
173187
serverUrl,
174188
typeof window !== "undefined" ? window.location?.origin : undefined,
175189
),
176190
functionsVersion,
177191
getAuthToken: () => token || getAccessToken(),
192+
mintConnectionToken: async (actorName, room, connectionId) => {
193+
const authToken = token || getAccessToken();
194+
return await actorsAxiosClient.post<unknown, ActorConnectionCredentials>(
195+
`/apps/${appId}/actors/${encodeURIComponent(actorName)}/connection-token`,
196+
{ room, connection_id: connectionId },
197+
{
198+
headers: {
199+
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
200+
// The mint endpoint resolves draft vs published from this header;
201+
// only the functions axios clients send it by default.
202+
...(functionsVersion
203+
? { "Base44-Functions-Version": functionsVersion }
204+
: {}),
205+
},
206+
},
207+
);
208+
},
209+
transport: options?.actorsTransport,
210+
onMintError: options?.onError,
178211
});
179212

180213
const userModules = {

src/client.types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,20 @@ import type { ActorsModule } from "./modules/actors.types.js";
1919
export interface CreateClientOptions {
2020
/**
2121
* Optional error handler that will be called whenever an API error occurs.
22+
*
23+
* Also receives {@link ActorsModule | actors} connection failures. Errors
24+
* are usually {@linkcode Base44Error} instances — check `error.status`.
2225
*/
2326
onError?: (error: Error) => void;
27+
/**
28+
* Forces the actors transport. `"auto"` (default) connects directly to the
29+
* actor and falls back to the platform proxy when the app's actors don't
30+
* support direct connections; `"proxy"` always uses the platform proxy
31+
* (ops rollback — no connection-token calls); `"direct"` disables the
32+
* fallback (validation environments).
33+
* @internal
34+
*/
35+
actorsTransport?: "auto" | "proxy" | "direct";
2436
}
2537

2638
/**

src/modules/actors.ts

Lines changed: 162 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,91 @@
1-
import PartySocket from "partysocket";
1+
import { WebSocket as ReconnectingWebSocket } from "partysocket";
22
import 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+
918
interface 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.
2449
const PING_MS = 1_000;
2550
const 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
*/
3284
class 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(/^(http|https|ws|wss):\/\//, "");
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
*/
148283
export function resolveActorsHost(serverUrl: string, browserOrigin?: string): string {
149284
return serverUrl && !serverUrl.startsWith("/") ? serverUrl : browserOrigin ?? serverUrl;

src/modules/actors.types.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,15 @@ export interface Connection<N extends string = string> {
6767
/** Register a message listener. Multiple are allowed; returns a per-listener unsubscribe. */
6868
subscribe(callback: (data: ToClientFor<N>) => void): ActorSubscription;
6969

70-
/** Send a message. Buffered by the socket until it's open. */
70+
/** Send a message. Buffered by the socket until it's open; dropped after
71+
* {@link close}. */
7172
send(data: ToServerFor<N>): void;
7273

73-
/** Tear down the socket, heartbeat, and all listeners. */
74+
/**
75+
* Tear down the socket, heartbeat, and all listeners. Safe to call more
76+
* than once. A connection also closes itself when it fails permanently —
77+
* see {@link ActorRef.connect}.
78+
*/
7479
close(): void;
7580
}
7681

@@ -79,7 +84,15 @@ export interface Connection<N extends string = string> {
7984
* {@link connect} to open the socket and get a {@link Connection}.
8085
*/
8186
export interface ActorRef<N extends string = string> {
82-
/** Open the WebSocket and return the {@link Connection}. Idempotent. */
87+
/**
88+
* Open the WebSocket and return the {@link Connection}. Idempotent while the
89+
* connection is open.
90+
*
91+
* A connection that fails permanently (for example, the actor doesn't exist
92+
* or the caller isn't allowed to connect) closes itself and reports the
93+
* error to the client's `onError` handler. Call `connect()` again after
94+
* fixing the cause to get a fresh {@link Connection}, and re-subscribe.
95+
*/
8396
connect(options?: ActorConnectOptions): Connection<N>;
8497
}
8598

0 commit comments

Comments
 (0)