@base44-preview/sdk 0.8.43-pr.257.a72bddf → 0.8.43-pr.259.3f82534

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/actor.d.ts CHANGED
@@ -9,6 +9,13 @@
9
9
  * Cloudflare Durable Object implementation — this file provides types only.
10
10
  */
11
11
  import type { Base44Client } from "./client";
12
+ export type ActorConnectionIdentity = Readonly<{
13
+ type: "authenticated";
14
+ userId: string;
15
+ }> | Readonly<{
16
+ type: "anonymous";
17
+ anonymousId: string;
18
+ }>;
12
19
  /**
13
20
  * A single client connection. `Send` is the message type this connection accepts
14
21
  * via {@link send} — the actor's *outgoing* (server→client) messages.
@@ -18,6 +25,8 @@ export interface Conn<Send = unknown> {
18
25
  * receives from `subscribe()`. Identifies a distinct client, so multiple
19
26
  * tabs are separate connections. */
20
27
  id: string;
28
+ /** Identity verified by the Actor Worker. Legacy Actors may not provide it. */
29
+ identity?: ActorConnectionIdentity;
21
30
  send(data: Send): void;
22
31
  reject(code: number, reason: string): void;
23
32
  }
@@ -86,9 +95,10 @@ export declare abstract class Actor<Incoming = unknown, Outgoing = unknown> {
86
95
  protected get storage(): Storage;
87
96
  /**
88
97
  * Anonymous Base44 client scoped to this actor instance — no user or service
89
- * auth, so entity access is RLS-gated (same as a logged-out visitor). Always
90
- * operates on production data: an actor runs server-side with no per-connection
91
- * identity, so a Test DB preview selected in the editor does not apply here.
98
+ * auth, so entity access is RLS-gated (same as a logged-out visitor). A
99
+ * connection's verified `identity` is context only and is never applied to this
100
+ * client. Always operates on production data, so a Test DB preview selected in
101
+ * the editor does not apply here.
92
102
  * Example: `const rows = await this.client.entities.Score.list();`
93
103
  */
94
104
  protected get client(): Base44Client;
package/dist/actor.js CHANGED
@@ -74,9 +74,10 @@ export class Actor {
74
74
  }
75
75
  /**
76
76
  * Anonymous Base44 client scoped to this actor instance — no user or service
77
- * auth, so entity access is RLS-gated (same as a logged-out visitor). Always
78
- * operates on production data: an actor runs server-side with no per-connection
79
- * identity, so a Test DB preview selected in the editor does not apply here.
77
+ * auth, so entity access is RLS-gated (same as a logged-out visitor). A
78
+ * connection's verified `identity` is context only and is never applied to this
79
+ * client. Always operates on production data, so a Test DB preview selected in
80
+ * the editor does not apply here.
80
81
  * Example: `const rows = await this.client.entities.Score.list();`
81
82
  */
82
83
  get client() {
package/dist/client.js CHANGED
@@ -94,6 +94,13 @@ export function createClient(config) {
94
94
  interceptResponses: false,
95
95
  onError: options === null || options === void 0 ? void 0 : options.onError,
96
96
  });
97
+ // Actors keeps raw responses for 409 routing and owns error reporting so
98
+ // response-validation failures use the same onError path as request failures.
99
+ const actorConnectionClient = createAxiosClient({
100
+ baseURL: `${serverUrl}/api`,
101
+ headers,
102
+ interceptResponses: false,
103
+ });
97
104
  const serviceRoleHeaders = {
98
105
  ...headers,
99
106
  ...(token ? { "on-behalf-of": `Bearer ${token}` } : {}),
@@ -127,11 +134,13 @@ export function createClient(config) {
127
134
  }
128
135
  const actorsModule = createActorsModule({
129
136
  appId,
130
- // serverUrl is often relative/empty (same-origin app); PartySocket needs an
131
- // absolute host, so fall back to the page origin.
137
+ connectionClient: actorConnectionClient,
138
+ onError: options === null || options === void 0 ? void 0 : options.onError,
139
+ // serverUrl is often relative/empty in same-origin apps, while the legacy
140
+ // WebSocket fallback needs an absolute host.
132
141
  host: resolveActorsHost(serverUrl, typeof window !== "undefined" ? (_a = window.location) === null || _a === void 0 ? void 0 : _a.origin : undefined),
133
142
  functionsVersion,
134
- getAuthToken: () => token || getAccessToken(),
143
+ getAuthToken: () => userAuthModule.getToken(),
135
144
  });
136
145
  const userModules = {
137
146
  entities: createEntitiesModule({
@@ -15,6 +15,8 @@ import type { ActorsModule } from "./modules/actors.types.js";
15
15
  export interface CreateClientOptions {
16
16
  /**
17
17
  * Optional error handler that will be called whenever an API error occurs.
18
+ * Actor connection failures are contextual `ActorConnectionError` instances.
19
+ * Retryable Actor failures may be reported more than once.
18
20
  */
19
21
  onError?: (error: Error) => void;
20
22
  }
package/dist/index.d.ts CHANGED
@@ -12,8 +12,9 @@ export type { AgentsModule, AgentName, AgentNameRegistry, AgentConversation, Age
12
12
  export type { AiGatewayModule, AiGatewayConnection, } from "./modules/ai-gateway.types.js";
13
13
  export type { AppLogsModule } from "./modules/app-logs.types.js";
14
14
  export type { ActorsModule, ActorClient, ActorRef, Connection, ActorSubscription, ActorConnectOptions, ActorNameRegistry, ActorRegistry, } from "./modules/actors.types.js";
15
+ export { ActorConnectionError } from "./modules/actors.error.js";
15
16
  export type { SsoModule, SsoAccessTokenResponse } from "./modules/sso.types.js";
16
- export { Actor, type Conn } from "./actor.js";
17
+ export { Actor, type ActorConnectionIdentity, type Conn } from "./actor.js";
17
18
  export type { ConnectorsModule, UserConnectorsModule, } from "./modules/connectors.types.js";
18
19
  export type { CustomIntegrationsModule, CustomIntegrationCallParams, CustomIntegrationCallResponse, } from "./modules/custom-integrations.types.js";
19
20
  export type { GetAccessTokenOptions, SaveAccessTokenOptions, RemoveAccessTokenOptions, GetLoginUrlOptions, } from "./utils/auth-utils.types.js";
package/dist/index.js CHANGED
@@ -3,4 +3,5 @@ import { Base44Error } from "./utils/axios-client.js";
3
3
  import { getAccessToken, saveAccessToken, removeAccessToken, getLoginUrl, } from "./utils/auth-utils.js";
4
4
  export { createClient, createClientFromRequest, Base44Error, getAccessToken, saveAccessToken, removeAccessToken, getLoginUrl, };
5
5
  export * from "./types.js";
6
+ export { ActorConnectionError } from "./modules/actors.error.js";
6
7
  export { Actor } from "./actor.js";
@@ -1,21 +1,22 @@
1
+ import { type AxiosInstance } from "axios";
2
+ import { ActorConnectionError } from "./actors.error.js";
1
3
  import type { ActorRef } from "./actors.types.js";
2
4
  interface ActorsConfig {
3
5
  appId: string;
4
- /** Current user access token, if authenticated. Rides the WS query so the
5
- * platform proxy can authenticate the connection; anonymous connects omit it. */
6
+ connectionClient: AxiosInstance;
7
+ onError?: (error: ActorConnectionError) => void;
8
+ /** Current user access token, if authenticated. */
6
9
  getAuthToken(): string | null | undefined;
7
10
  /** Same semantics as function calls: editors with a non-prod version get the
8
11
  * draft actor script; everyone else gets the published one. */
9
12
  functionsVersion?: string;
10
- /** Absolute host PartySocket dials (it strips the scheme and connects wss, ws
11
- * for localhost). Resolved by {@link resolveActorsHost}. */
13
+ /** Absolute host used when a legacy Actor falls back to the Apper proxy. */
12
14
  host: string;
13
15
  }
14
16
  /**
15
- * Absolute host for the actor WebSocket. PartySocket needs an absolute host and
16
- * can't resolve a relative/empty `serverUrl` (same-origin apps use a relative
17
- * `/api`, so `serverUrl` is often `""`), so fall back to the page origin.
18
- * PartySocket handles the scheme (https→wss, ws for localhost).
17
+ * Absolute host for the legacy Actor WebSocket. A relative/empty `serverUrl`
18
+ * cannot identify the Apper proxy, so same-origin apps fall back to the page
19
+ * origin.
19
20
  */
20
21
  export declare function resolveActorsHost(serverUrl: string, browserOrigin?: string): string;
21
22
  export declare function createActorsModule(config: ActorsConfig): {
@@ -0,0 +1,14 @@
1
+ /** Contextual error for an Actor bootstrap, WebSocket, or closed-connection failure. */
2
+ export declare class ActorConnectionError extends Error {
3
+ readonly actorName: string;
4
+ readonly instanceId: string;
5
+ readonly connectionId: string;
6
+ readonly cause: unknown;
7
+ /** HTTP response status, when the failure came from the bootstrap request. */
8
+ readonly status?: number;
9
+ /** WebSocket close code, when the far end closed the connection. */
10
+ readonly closeCode?: number;
11
+ /** WebSocket close reason, when the far end supplied one. */
12
+ readonly closeReason?: string;
13
+ constructor(actorName: string, instanceId: string, connectionId: string, cause: unknown, status?: number, closeCode?: number, closeReason?: string);
14
+ }
@@ -0,0 +1,15 @@
1
+ /** Contextual error for an Actor bootstrap, WebSocket, or closed-connection failure. */
2
+ export class ActorConnectionError extends Error {
3
+ constructor(actorName, instanceId, connectionId, cause, status, closeCode, closeReason) {
4
+ const causeMessage = cause instanceof Error ? cause.message : String(cause);
5
+ super(`Actor "${actorName}" instance "${instanceId}" connection "${connectionId}": ${causeMessage}`);
6
+ this.actorName = actorName;
7
+ this.instanceId = instanceId;
8
+ this.connectionId = connectionId;
9
+ this.cause = cause;
10
+ this.name = "ActorConnectionError";
11
+ this.status = status;
12
+ this.closeCode = closeCode;
13
+ this.closeReason = closeReason;
14
+ }
15
+ }
@@ -1,42 +1,150 @@
1
- import PartySocket from "partysocket";
1
+ import { isAxiosError } from "axios";
2
+ import PartySocket, { WebSocket as ReconnectingWebSocket } from "partysocket";
3
+ import { getAnalyticsSessionId } from "./analytics.js";
4
+ import { generateUuid } from "../utils/common.js";
5
+ import { ActorConnectionError } from "./actors.error.js";
6
+ // Both mirror the Actor Worker dispatcher's own validation (see
7
+ // workers/base44-dispatcher/src/actor-routing.ts) so a request it would reject
8
+ // fails here instead. Rejection happens on the WebSocket *upgrade*, which
9
+ // reaches the browser as an opaque 1006 with no status to act on, so failing
10
+ // fast client-side is the only way to report the real reason.
11
+ const CONNECTION_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
12
+ const INSTANCE_ID_RE = /^[\x20-\x2e\x30-\x7e]{1,256}$/;
13
+ const RETRYABLE_BOOTSTRAP_STATUSES = new Set([401, 408, 425, 429]);
2
14
  // Heartbeat / half-open detection: PartySocket only reconnects on a close/error
3
15
  // event, so ping periodically and force a reconnect if nothing returns in DEAD_MS.
4
16
  const PING_MS = 1000;
5
17
  const DEAD_MS = 3000;
18
+ // While heartbeat reconnects keep coming back silent, the dead window doubles up
19
+ // to this cap. Every heartbeat re-dial buys a connection-token POST against a
20
+ // per-app rate limit, so a link that reopens but never delivers a frame must not
21
+ // hold the DEAD_MS cadence forever. Any inbound frame resets the window.
22
+ const MAX_DEAD_MS = 60000;
23
+ // PartySocket retries forever (`maxRetries: Infinity`) and every attempt costs a
24
+ // connection-token POST, so a connection that can never be admitted needs a
25
+ // bound. Close codes cannot provide one: the dispatcher refuses the upgrade over
26
+ // HTTP (opaque 1006 on the client), and 1000 arrives both from
27
+ // `conn.reject(1000, ...)` and from a graceful worker shutdown. Counting
28
+ // attempts measures "retrying is not working" directly instead.
29
+ //
30
+ // ~25-40s under PartySocket's 1-5s base delay x1.3 growth (10s cap): long enough
31
+ // to ride out a deploy, short enough to stop a rejection loop quickly.
32
+ const MAX_CONSECUTIVE_FAILURES = 6;
33
+ // A socket that holds this long proves retrying works, so the budget resets.
34
+ // Matches PartySocket's own `minUptime`.
35
+ const STABLE_MS = 5000;
6
36
  /**
7
- * A live connection to an actor instance. Only obtainable from
8
- * {@link ActorRef.connect}, so `subscribe`/`send` are always valid — the socket
9
- * exists for this object's whole lifetime.
37
+ * A connection to an actor instance. Only obtainable from {@link ActorRef.connect}.
10
38
  */
11
39
  class Connection {
12
- constructor(actorName, instanceId, config, options, onClose) {
13
- var _a;
40
+ constructor(actorName, instanceId, config, options, getAnonymousId, onClose) {
41
+ this.actorName = actorName;
42
+ this.instanceId = instanceId;
14
43
  this.onClose = onClose;
15
44
  this.listeners = new Set();
45
+ this.errorListeners = new Set();
16
46
  this.heartbeat = null;
17
- this.id = (_a = options === null || options === void 0 ? void 0 : options.id) !== null && _a !== void 0 ? _a : crypto.randomUUID();
18
- const ws = new PartySocket({
19
- host: config.host,
20
- party: actorName,
21
- room: instanceId,
22
- id: this.id,
23
- // Re-read on every (re)connect so a login/logout is picked up.
24
- query: () => {
25
- const token = config.getAuthToken();
26
- return {
27
- app_id: config.appId,
28
- handler: actorName,
29
- ...(token ? { token } : {}),
30
- ...(config.functionsVersion ? { fv: config.functionsVersion } : {}),
31
- };
32
- },
33
- });
47
+ this.isClosed = false;
48
+ this.closedError = null;
49
+ /** Attempts since the last socket that stayed up for {@link STABLE_MS}. */
50
+ this.consecutiveFailures = 0;
51
+ this.stabilityTimer = null;
52
+ this.id = resolveConnectionId(options === null || options === void 0 ? void 0 : options.id);
53
+ this.globalOnError = config.onError;
54
+ this.addErrorListener(options === null || options === void 0 ? void 0 : options.onError);
55
+ let bootstrapErrorAwaitingSocketEvent = false;
56
+ const ws = new ReconnectingWebSocket(async () => {
57
+ var _a;
58
+ if (this.closedError)
59
+ throw this.closedError;
60
+ bootstrapErrorAwaitingSocketEvent = false;
61
+ try {
62
+ return await resolveActorWebSocketUrl(actorName, instanceId, this.id, getAnonymousId, config);
63
+ }
64
+ catch (error) {
65
+ const status = isAxiosError(error) ? (_a = error.response) === null || _a === void 0 ? void 0 : _a.status : undefined;
66
+ const connectionError = this.createError(error, status);
67
+ bootstrapErrorAwaitingSocketEvent = true;
68
+ if (isTerminalBootstrapError(error)) {
69
+ this.reportError(connectionError, true);
70
+ }
71
+ else if (status === undefined) {
72
+ // The request never reached the server (offline, DNS, aborted), so
73
+ // it consumes nothing and will succeed once connectivity returns.
74
+ // Charging the budget here would kill the connection over a tunnel.
75
+ this.reportError(connectionError);
76
+ }
77
+ else {
78
+ this.reportAttemptFailure(connectionError);
79
+ }
80
+ throw connectionError;
81
+ }
82
+ }, undefined, { startClosed: true });
34
83
  this.ws = ws;
35
- let lastMsg = Date.now();
36
- const bumpAlive = () => { lastMsg = Date.now(); };
37
- ws.addEventListener("open", bumpAlive);
84
+ let lastActivityAt = null;
85
+ let socketOpened = false;
86
+ let reconnectingForHeartbeat = false;
87
+ /** Watchdog trips with no inbound frame since: gates the one-per-outage
88
+ * silence report and widens the dead window. Reset by any message. */
89
+ let silentReconnects = 0;
90
+ let closeSequence = 0;
91
+ let suppressedCloseSequence = 0;
92
+ const markAlive = () => { lastActivityAt = Date.now(); };
93
+ const markDisconnected = () => { lastActivityAt = null; };
94
+ ws.addEventListener("open", () => {
95
+ socketOpened = true;
96
+ markAlive();
97
+ this.armStability();
98
+ });
99
+ ws.addEventListener("close", (event) => {
100
+ const closedSocketWasOpen = socketOpened;
101
+ socketOpened = false;
102
+ markDisconnected();
103
+ this.clearStability();
104
+ const currentCloseSequence = ++closeSequence;
105
+ // A heartbeat-driven reconnect is not a failed attempt: a legacy Actor
106
+ // without the __pong shim reconnects every DEAD_MS by design, so charging
107
+ // the budget for those would tear down every legacy connection.
108
+ if (this.isClosed || reconnectingForHeartbeat || !closedSocketWasOpen)
109
+ return;
110
+ // PartySocket emits a synthetic close before its error event. Defer so
111
+ // that transport errors are reported once with their real cause.
112
+ queueMicrotask(() => {
113
+ if (this.isClosed || suppressedCloseSequence === currentCloseSequence)
114
+ return;
115
+ const reason = event.reason
116
+ ? `WebSocket closed with code ${event.code}: ${event.reason}`
117
+ : `WebSocket closed with code ${event.code}`;
118
+ const connectionError = this.createError(new Error(reason), undefined, event.code, event.reason || undefined);
119
+ if (isTerminalSocketCloseCode(event.code)) {
120
+ this.reportError(connectionError, true);
121
+ }
122
+ else {
123
+ this.reportAttemptFailure(connectionError);
124
+ }
125
+ });
126
+ });
127
+ ws.addEventListener("error", (event) => {
128
+ markDisconnected();
129
+ suppressedCloseSequence = closeSequence;
130
+ if (this.isClosed)
131
+ return;
132
+ if (bootstrapErrorAwaitingSocketEvent) {
133
+ bootstrapErrorAwaitingSocketEvent = false;
134
+ return;
135
+ }
136
+ const cause = event.error instanceof Error
137
+ ? event.error
138
+ : new Error(event.message || "Actor WebSocket error");
139
+ // Also the only signal for a refused *upgrade* (bad token, unknown or
140
+ // throwing Actor): the dispatcher answers over HTTP, the socket never
141
+ // opens, and the close listener above skips it — so the budget applied
142
+ // here is what stops a permanently unroutable connection.
143
+ this.reportAttemptFailure(this.createError(cause));
144
+ });
38
145
  ws.addEventListener("message", (ev) => {
39
- bumpAlive();
146
+ markAlive();
147
+ silentReconnects = 0;
40
148
  let data;
41
149
  try {
42
150
  data = JSON.parse(ev.data);
@@ -51,48 +159,176 @@ class Connection {
51
159
  listener(data);
52
160
  });
53
161
  this.heartbeat = setInterval(() => {
54
- if (Date.now() - lastMsg > DEAD_MS) {
55
- bumpAlive(); // avoid a reconnect storm while the new socket comes up
56
- ws.reconnect();
162
+ if (lastActivityAt === null || ws.readyState !== ws.OPEN)
163
+ return;
164
+ const deadMs = Math.min(DEAD_MS * 2 ** silentReconnects, MAX_DEAD_MS);
165
+ if (Date.now() - lastActivityAt > deadMs) {
166
+ lastActivityAt = null;
167
+ // One report per silent stretch: reopen → silence → re-dial cycles are
168
+ // one outage to the app, not a new failure every window.
169
+ if (silentReconnects === 0) {
170
+ this.reportError(this.createError(new Error("Actor WebSocket stopped responding")));
171
+ }
172
+ silentReconnects += 1;
173
+ reconnectingForHeartbeat = true;
174
+ try {
175
+ ws.reconnect(1000, "heartbeat timeout");
176
+ }
177
+ finally {
178
+ reconnectingForHeartbeat = false;
179
+ }
57
180
  return;
58
181
  }
59
182
  try {
60
183
  // The deployed shim echoes __ping → __pong (base44-userapp-bundler
61
- // shim/actor.ts); without that, an idle room reconnects every DEAD_MS.
184
+ // shim/actor.ts); without that, an idle room reconnects on a widening
185
+ // interval (DEAD_MS up to MAX_DEAD_MS).
62
186
  ws.send(JSON.stringify({ type: "__ping" }));
63
187
  }
64
188
  catch (_a) {
65
- // not open; the watchdog above will reconnect
189
+ // The next watchdog tick will reconnect if the link stays half-open.
66
190
  }
67
191
  }, PING_MS);
192
+ ws.reconnect();
68
193
  }
69
194
  subscribe(callback) {
195
+ this.assertNotClosed();
70
196
  this.listeners.add(callback);
71
197
  return {
72
198
  unsubscribe: () => { this.listeners.delete(callback); },
73
199
  };
74
200
  }
75
201
  send(data) {
202
+ this.assertNotClosed();
76
203
  this.ws.send(JSON.stringify(data));
77
204
  }
205
+ get closed() {
206
+ return this.isClosed;
207
+ }
208
+ addErrorListener(listener) {
209
+ if (!listener)
210
+ return { unsubscribe: () => { } };
211
+ this.assertNotClosed();
212
+ this.errorListeners.add(listener);
213
+ return {
214
+ unsubscribe: () => { this.errorListeners.delete(listener); },
215
+ };
216
+ }
78
217
  close() {
218
+ this.teardown(this.createError(new Error("Connection is closed")));
219
+ }
220
+ assertNotClosed() {
221
+ if (!this.closedError)
222
+ return;
223
+ throw this.createError(this.closedError.cause, this.closedError.status, this.closedError.closeCode, this.closedError.closeReason);
224
+ }
225
+ createError(cause, status, closeCode, closeReason) {
226
+ return new ActorConnectionError(this.actorName, this.instanceId, this.id, cause, status, closeCode, closeReason);
227
+ }
228
+ reportError(error, terminal = false) {
229
+ const handlers = new Set(this.errorListeners);
230
+ if (this.globalOnError)
231
+ handlers.add(this.globalOnError);
232
+ if (terminal)
233
+ this.teardown(error);
234
+ for (const handler of handlers) {
235
+ try {
236
+ handler(error);
237
+ }
238
+ catch (_a) {
239
+ // Error observers must not replace the failure PartySocket receives.
240
+ }
241
+ }
242
+ }
243
+ /**
244
+ * Report a failed connection attempt, and give up once the budget is spent.
245
+ * This is the only bound on retries for failures a close code cannot classify
246
+ * — see {@link MAX_CONSECUTIVE_FAILURES}.
247
+ */
248
+ reportAttemptFailure(error) {
249
+ this.clearStability();
250
+ const exhausted = ++this.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES;
251
+ this.reportError(error, exhausted);
252
+ }
253
+ armStability() {
254
+ this.clearStability();
255
+ this.stabilityTimer = setTimeout(() => {
256
+ this.stabilityTimer = null;
257
+ this.consecutiveFailures = 0;
258
+ }, STABLE_MS);
259
+ }
260
+ clearStability() {
261
+ if (this.stabilityTimer) {
262
+ clearTimeout(this.stabilityTimer);
263
+ this.stabilityTimer = null;
264
+ }
265
+ }
266
+ teardown(error) {
267
+ if (this.isClosed)
268
+ return;
269
+ this.isClosed = true;
270
+ this.closedError = error;
79
271
  if (this.heartbeat) {
80
272
  clearInterval(this.heartbeat);
81
273
  this.heartbeat = null;
82
274
  }
83
275
  this.listeners.clear();
276
+ this.errorListeners.clear();
277
+ this.clearStability();
84
278
  this.ws.close();
85
279
  this.onClose();
86
280
  }
87
281
  }
282
+ /**
283
+ * Whether a close code proves a retry can never succeed.
284
+ *
285
+ * Only 1000 and 3000-4999 can reach us as a deliberate server-side choice — the
286
+ * WebSocket API rejects every other code passed to `close()`, so the rest are
287
+ * generated by the transport (1006 on a dropped link or a refused upgrade, 1001
288
+ * on shutdown, 1011 on a server error) and are all worth retrying.
289
+ *
290
+ * Of those two, only 3000-4999 carries app intent: `conn.reject(code, reason)`
291
+ * picks it, and the Actor's decision will not differ on the next attempt. 1000
292
+ * is ambiguous — `reject(1000, ...)` and a graceful worker shutdown are
293
+ * indistinguishable on the wire — so it is left to the attempt budget, which
294
+ * reconnects through a deploy but still gives up on a rejection loop.
295
+ */
296
+ function isTerminalSocketCloseCode(code) {
297
+ return code >= 3000 && code <= 4999;
298
+ }
299
+ function isTerminalBootstrapError(error) {
300
+ var _a;
301
+ if (!isAxiosError(error))
302
+ return true;
303
+ const status = (_a = error.response) === null || _a === void 0 ? void 0 : _a.status;
304
+ return status !== undefined
305
+ && status >= 400
306
+ && status < 500
307
+ && !RETRYABLE_BOOTSTRAP_STATUSES.has(status);
308
+ }
88
309
  /** Handle for one actor instance: `connect()` opens the socket (idempotent). */
89
- function makeActorRef(actorName, instanceId, config, connections) {
310
+ function makeActorRef(actorName, instanceId, config, getAnonymousId, connections) {
311
+ assertValidInstanceId(instanceId);
90
312
  let conn = null;
91
313
  return {
92
314
  connect(options) {
93
- if (conn)
315
+ // Before the conflict check below, so a malformed id reports as malformed
316
+ // rather than as a mismatch against the live connection's (always valid) id.
317
+ if ((options === null || options === void 0 ? void 0 : options.id) !== undefined)
318
+ assertValidConnectionId(options.id);
319
+ if (conn) {
320
+ if ((options === null || options === void 0 ? void 0 : options.id) !== undefined && options.id !== conn.id) {
321
+ throw new Error(`Actor connection is already open with id "${conn.id}"; cannot reuse it with id "${options.id}"`);
322
+ }
323
+ if (options === null || options === void 0 ? void 0 : options.onError) {
324
+ // Registering here would be unremovable: the subscription that
325
+ // addErrorListener returns has nowhere to go, so a handler recreated
326
+ // per render would accumulate for the connection's whole life.
327
+ throw new Error("Actor connection is already open; use connection.addErrorListener() to add an error handler");
328
+ }
94
329
  return conn;
95
- const c = new Connection(actorName, instanceId, config, options, () => {
330
+ }
331
+ const c = new Connection(actorName, instanceId, config, options, getAnonymousId, () => {
96
332
  connections.delete(c);
97
333
  if (conn === c)
98
334
  conn = null; // allow a fresh connect() after close
@@ -104,25 +340,91 @@ function makeActorRef(actorName, instanceId, config, connections) {
104
340
  };
105
341
  }
106
342
  /**
107
- * Absolute host for the actor WebSocket. PartySocket needs an absolute host and
108
- * can't resolve a relative/empty `serverUrl` (same-origin apps use a relative
109
- * `/api`, so `serverUrl` is often `""`), so fall back to the page origin.
110
- * PartySocket handles the scheme (https→wss, ws for localhost).
343
+ * Absolute host for the legacy Actor WebSocket. A relative/empty `serverUrl`
344
+ * cannot identify the Apper proxy, so same-origin apps fall back to the page
345
+ * origin.
111
346
  */
112
347
  export function resolveActorsHost(serverUrl, browserOrigin) {
113
348
  return serverUrl && !serverUrl.startsWith("/") ? serverUrl : browserOrigin !== null && browserOrigin !== void 0 ? browserOrigin : serverUrl;
114
349
  }
350
+ function assertValidConnectionId(id) {
351
+ if (!CONNECTION_ID_RE.test(id)) {
352
+ throw new Error("Actor connection id must be 1-64 letters, numbers, underscores, or hyphens");
353
+ }
354
+ }
355
+ function assertValidInstanceId(instanceId) {
356
+ if (!INSTANCE_ID_RE.test(instanceId)) {
357
+ throw new Error(`Actor instance id "${instanceId}" must be 1-256 printable ASCII characters and cannot contain "/"`);
358
+ }
359
+ }
360
+ function resolveConnectionId(id) {
361
+ var _a, _b, _c;
362
+ if (id !== undefined) {
363
+ assertValidConnectionId(id);
364
+ return id;
365
+ }
366
+ return (_c = (_b = (_a = globalThis.crypto) === null || _a === void 0 ? void 0 : _a.randomUUID) === null || _b === void 0 ? void 0 : _b.call(_a)) !== null && _c !== void 0 ? _c : generateUuid();
367
+ }
368
+ function legacyActorWebSocketUrl(actorName, instanceId, connectionId, authToken, config) {
369
+ const url = new URL(new PartySocket({
370
+ host: config.host,
371
+ party: actorName,
372
+ room: instanceId,
373
+ id: connectionId,
374
+ startClosed: true,
375
+ }).roomUrl);
376
+ url.searchParams.set("_pk", connectionId);
377
+ url.searchParams.set("app_id", config.appId);
378
+ url.searchParams.set("handler", actorName);
379
+ if (authToken)
380
+ url.searchParams.set("token", authToken);
381
+ if (config.functionsVersion)
382
+ url.searchParams.set("fv", config.functionsVersion);
383
+ return url.toString();
384
+ }
385
+ async function resolveActorWebSocketUrl(actorName, instanceId, connectionId, getAnonymousId, config) {
386
+ var _a, _b;
387
+ const authToken = config.getAuthToken();
388
+ const anonymousId = authToken ? null : getAnonymousId();
389
+ const response = await config.connectionClient.post(`/apps/${encodeURIComponent(config.appId)}/actors/${encodeURIComponent(actorName)}/connection-token`, { room: instanceId, connection_id: connectionId }, {
390
+ headers: {
391
+ Authorization: authToken ? `Bearer ${authToken}` : null,
392
+ "X-Base44-Anonymous-Id": anonymousId,
393
+ ...(config.functionsVersion
394
+ ? { "Base44-Functions-Version": config.functionsVersion }
395
+ : {}),
396
+ },
397
+ validateStatus: (status) => (status >= 200 && status < 300) || status === 409,
398
+ });
399
+ if (response.status === 409) {
400
+ return legacyActorWebSocketUrl(actorName, instanceId, connectionId, authToken, config);
401
+ }
402
+ const websocketUrl = (_a = response.data) === null || _a === void 0 ? void 0 : _a.websocket_url;
403
+ const token = (_b = response.data) === null || _b === void 0 ? void 0 : _b.token;
404
+ if (typeof websocketUrl !== "string" || !websocketUrl || typeof token !== "string" || !token) {
405
+ throw new Error("Invalid Actor connection response");
406
+ }
407
+ const url = new URL(websocketUrl);
408
+ if (url.protocol !== "ws:" && url.protocol !== "wss:") {
409
+ throw new Error("Invalid Actor WebSocket URL");
410
+ }
411
+ url.searchParams.set("_pk", connectionId);
412
+ url.searchParams.set("token", token);
413
+ return url.toString();
414
+ }
115
415
  export function createActorsModule(config) {
116
416
  // Live connections this client opened, so client.cleanup() can reclaim any the
117
417
  // app forgot to close() (each connection removes itself here on close).
118
418
  const connections = new Set();
419
+ let anonymousId;
420
+ const getAnonymousId = () => anonymousId !== null && anonymousId !== void 0 ? anonymousId : (anonymousId = getAnalyticsSessionId());
119
421
  const module = new Proxy({}, {
120
422
  get(_, key) {
121
423
  // Symbols and `then` resolve to undefined (so the module isn't mistaken
122
424
  // for a thenable when awaited); any string key is an actor name.
123
425
  if (typeof key !== "string" || key === "then")
124
426
  return undefined;
125
- return (instanceId) => makeActorRef(key, instanceId, config, connections);
427
+ return (instanceId) => makeActorRef(key, instanceId, config, getAnonymousId, connections);
126
428
  },
127
429
  });
128
430
  return {
@@ -1,3 +1,4 @@
1
+ import type { ActorConnectionError } from "./actors.error.js";
1
2
  /**
2
3
  * Extend this interface to add typed `subscribe` callbacks and `send` payloads
3
4
  * for your deployed Actors.
@@ -36,27 +37,42 @@ type ToServerFor<N extends string> = N extends keyof ActorRegistry ? ActorRegist
36
37
  export interface ActorConnectOptions {
37
38
  /**
38
39
  * The connection id — becomes the actor's `conn.id`. Supply a stable value
39
- * (e.g. persisted per tab) so a reconnect reuses the same server-side
40
- * identity; omit for an auto-generated per-connection id.
40
+ * (e.g. persisted per tab) containing 1–64 letters, numbers, underscores, or
41
+ * hyphens so a reconnect reuses the same server-side identity; omit for an
42
+ * auto-generated per-connection id.
41
43
  */
42
44
  id?: string;
45
+ /**
46
+ * Called when this connection's bootstrap or WebSocket fails. Retryable
47
+ * failures may be reported more than once — the connection keeps retrying
48
+ * until it either succeeds or exhausts its attempt budget, and the final
49
+ * report before it gives up is the one where {@link Connection.closed}
50
+ * becomes `true`.
51
+ *
52
+ * Accepted only on the call that creates the connection: a later
53
+ * `connect({ onError })` throws, because a handler registered then could
54
+ * never be removed. Use {@link Connection.addErrorListener} for that.
55
+ */
56
+ onError?: (error: ActorConnectionError) => void;
43
57
  }
44
- /** Handle for one listener registered via {@link Connection.subscribe}. */
58
+ /** Handle for one connection listener. */
45
59
  export interface ActorSubscription {
46
- /** Remove this listener; other listeners and the socket stay live. */
60
+ /** Remove this listener; other listeners and the connection stay live. */
47
61
  unsubscribe(): void;
48
62
  }
49
63
  /**
50
- * A live connection to an actor instance, returned by {@link ActorRef.connect}.
51
- * `subscribe`/`send` are always valid — you only get a `Connection` once the
52
- * socket has been opened, so there's no pre-connect state to guard against.
64
+ * A connection to an actor instance, returned by {@link ActorRef.connect}.
53
65
  */
54
66
  export interface Connection<N extends string = string> {
55
67
  /** The connection id (the value the actor sees as `conn.id`). */
56
68
  readonly id: string;
57
- /** Register a message listener. Multiple are allowed; returns a per-listener unsubscribe. */
69
+ /** Whether this connection has been explicitly or terminally closed. */
70
+ readonly closed: boolean;
71
+ /** Register a detachable connection-error listener. Throws if the connection has closed. */
72
+ addErrorListener(listener?: (error: ActorConnectionError) => void): ActorSubscription;
73
+ /** Register a message listener. Throws if the connection has closed. */
58
74
  subscribe(callback: (data: ToClientFor<N>) => void): ActorSubscription;
59
- /** Send a message. Buffered by the socket until it's open. */
75
+ /** Send a message. Buffered until open; throws instead of buffering after close. */
60
76
  send(data: ToServerFor<N>): void;
61
77
  /** Tear down the socket, heartbeat, and all listeners. */
62
78
  close(): void;
@@ -66,7 +82,11 @@ export interface Connection<N extends string = string> {
66
82
  * {@link connect} to open the socket and get a {@link Connection}.
67
83
  */
68
84
  export interface ActorRef<N extends string = string> {
69
- /** Open the WebSocket and return the {@link Connection}. Idempotent. */
85
+ /**
86
+ * Open the WebSocket and return the {@link Connection}. Repeated calls reuse
87
+ * the live connection; a conflicting explicit id, or an `onError` handler the
88
+ * reused connection could not later detach, throws.
89
+ */
70
90
  connect(options?: ActorConnectOptions): Connection<N>;
71
91
  }
72
92
  /**
@@ -81,10 +81,13 @@ export function createAuthModule(axios, functionsAxiosClient, appId, options) {
81
81
  // Tracked here rather than read off `axios.defaults` so the answer stays tied
82
82
  // to the identity transitions below (`setToken`, `logout`) instead of to the
83
83
  // header a caller may have set on the instance directly.
84
- let hasAccessToken = Boolean(options.token);
84
+ let accessToken = options.token;
85
85
  return {
86
86
  hasToken() {
87
- return hasAccessToken;
87
+ return Boolean(accessToken);
88
+ },
89
+ getToken() {
90
+ return accessToken;
88
91
  },
89
92
  // Get current user information
90
93
  async me() {
@@ -151,7 +154,7 @@ export function createAuthModule(axios, functionsAxiosClient, appId, options) {
151
154
  // flight would otherwise resolve into callers that run after the logout.
152
155
  clearPendingMe();
153
156
  resetAnalyticsSessionContext();
154
- hasAccessToken = false;
157
+ accessToken = undefined;
155
158
  // Only do the rest if in a browser environment
156
159
  if (typeof window !== "undefined") {
157
160
  // Remove token from localStorage
@@ -180,7 +183,7 @@ export function createAuthModule(axios, functionsAxiosClient, appId, options) {
180
183
  // resolved for the previous one must not be handed to later callers.
181
184
  clearPendingMe();
182
185
  resetAnalyticsSessionContext();
183
- hasAccessToken = true;
186
+ accessToken = token;
184
187
  // handle token change for axios clients
185
188
  axios.defaults.headers.common["Authorization"] = `Bearer ${token}`;
186
189
  functionsAxiosClient.defaults.headers.common["Authorization"] = `Bearer ${token}`;
@@ -537,4 +537,6 @@ export interface InternalAuthModule extends AuthModule {
537
537
  * could not succeed without a session, not to decide that one is valid.
538
538
  */
539
539
  hasToken(): boolean;
540
+ /** Current access token used by internal transports. */
541
+ getToken(): string | undefined;
540
542
  }
@@ -137,7 +137,8 @@ export function createAxiosClient({ baseURL, headers = {}, token, interceptRespo
137
137
  // On unauthenticated requests, attach a stable anonymous visitor id so the
138
138
  // backend can support anonymous agent access (conversation grouping + ownership).
139
139
  // Authenticated requests are identified by their Authorization header instead.
140
- if (!config.headers.get("Authorization")) {
140
+ if (!config.headers.get("Authorization") &&
141
+ !config.headers.get("X-Base44-Anonymous-Id")) {
141
142
  config.headers.set("X-Base44-Anonymous-Id", getAnalyticsSessionId());
142
143
  }
143
144
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@base44-preview/sdk",
3
- "version": "0.8.43-pr.257.a72bddf",
3
+ "version": "0.8.43-pr.259.3f82534",
4
4
  "description": "JavaScript SDK for Base44 API",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",