@base44-preview/sdk 0.8.43-pr.259.3f82534 → 0.8.43-pr.260.d28232f

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,13 +9,6 @@
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
- }>;
19
12
  /**
20
13
  * A single client connection. `Send` is the message type this connection accepts
21
14
  * via {@link send} — the actor's *outgoing* (server→client) messages.
@@ -25,8 +18,6 @@ export interface Conn<Send = unknown> {
25
18
  * receives from `subscribe()`. Identifies a distinct client, so multiple
26
19
  * tabs are separate connections. */
27
20
  id: string;
28
- /** Identity verified by the Actor Worker. Legacy Actors may not provide it. */
29
- identity?: ActorConnectionIdentity;
30
21
  send(data: Send): void;
31
22
  reject(code: number, reason: string): void;
32
23
  }
@@ -95,10 +86,9 @@ export declare abstract class Actor<Incoming = unknown, Outgoing = unknown> {
95
86
  protected get storage(): Storage;
96
87
  /**
97
88
  * Anonymous Base44 client scoped to this actor instance — no user or service
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.
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.
102
92
  * Example: `const rows = await this.client.entities.Score.list();`
103
93
  */
104
94
  protected get client(): Base44Client;
package/dist/actor.js CHANGED
@@ -74,10 +74,9 @@ 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). 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.
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.
81
80
  * Example: `const rows = await this.client.entities.Score.list();`
82
81
  */
83
82
  get client() {
package/dist/client.js CHANGED
@@ -12,7 +12,7 @@ import { createAppLogsModule } from "./modules/app-logs.js";
12
12
  import { createUsersModule } from "./modules/users.js";
13
13
  import { RoomsSocket } from "./utils/socket-utils.js";
14
14
  import { createAnalyticsModule } from "./modules/analytics.js";
15
- import { createActorsModule, resolveActorsHost } from "./modules/actors.js";
15
+ import { createActorsModule, resolveActorsHost, } from "./modules/actors.js";
16
16
  /**
17
17
  * Creates a Base44 client.
18
18
  *
@@ -94,13 +94,6 @@ 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
- });
104
97
  const serviceRoleHeaders = {
105
98
  ...headers,
106
99
  ...(token ? { "on-behalf-of": `Bearer ${token}` } : {}),
@@ -117,6 +110,15 @@ export function createClient(config) {
117
110
  token: serviceToken,
118
111
  interceptResponses: false,
119
112
  });
113
+ // Dedicated client for actor connection-token mints: no onError (a legacy
114
+ // actor answers every mint with an expected 409 before the proxy fallback,
115
+ // which must not reach the app's error handler — the actors module forwards
116
+ // genuine failures itself via onMintError) and no constructor token
117
+ // (auth is per-request so a login/logout is picked up on every reconnect).
118
+ const actorsAxiosClient = createAxiosClient({
119
+ baseURL: `${serverUrl}/api`,
120
+ headers,
121
+ });
120
122
  const userAuthModule = createAuthModule(axiosClient, functionsAxiosClient, appId, {
121
123
  appBaseUrl: normalizedAppBaseUrl,
122
124
  serverUrl,
@@ -134,13 +136,26 @@ export function createClient(config) {
134
136
  }
135
137
  const actorsModule = createActorsModule({
136
138
  appId,
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.
139
+ // serverUrl is often relative/empty (same-origin app); the proxy-fallback
140
+ // URL needs an absolute host, so fall back to the page origin.
141
141
  host: resolveActorsHost(serverUrl, typeof window !== "undefined" ? (_a = window.location) === null || _a === void 0 ? void 0 : _a.origin : undefined),
142
142
  functionsVersion,
143
- getAuthToken: () => userAuthModule.getToken(),
143
+ getAuthToken: () => token || getAccessToken(),
144
+ mintConnectionToken: async (actorName, room, connectionId) => {
145
+ const authToken = token || getAccessToken();
146
+ return await actorsAxiosClient.post(`/apps/${appId}/actors/${encodeURIComponent(actorName)}/connection-token`, { room, connection_id: connectionId }, {
147
+ headers: {
148
+ ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
149
+ // The mint endpoint resolves draft vs published from this header;
150
+ // only the functions axios clients send it by default.
151
+ ...(functionsVersion
152
+ ? { "Base44-Functions-Version": functionsVersion }
153
+ : {}),
154
+ },
155
+ });
156
+ },
157
+ transport: options === null || options === void 0 ? void 0 : options.actorsTransport,
158
+ onMintError: options === null || options === void 0 ? void 0 : options.onError,
144
159
  });
145
160
  const userModules = {
146
161
  entities: createEntitiesModule({
@@ -15,10 +15,20 @@ 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
+ *
19
+ * Also receives {@link ActorsModule | actors} connection failures. Errors
20
+ * are usually {@linkcode Base44Error} instances — check `error.status`.
20
21
  */
21
22
  onError?: (error: Error) => void;
23
+ /**
24
+ * Forces the actors transport. `"auto"` (default) connects directly to the
25
+ * actor and falls back to the platform proxy when the app's actors don't
26
+ * support direct connections; `"proxy"` always uses the platform proxy
27
+ * (ops rollback — no connection-token calls); `"direct"` disables the
28
+ * fallback (validation environments).
29
+ * @internal
30
+ */
31
+ actorsTransport?: "auto" | "proxy" | "direct";
22
32
  }
23
33
  /**
24
34
  * Configuration for creating a Base44 client.
package/dist/index.d.ts CHANGED
@@ -12,9 +12,8 @@ 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";
16
15
  export type { SsoModule, SsoAccessTokenResponse } from "./modules/sso.types.js";
17
- export { Actor, type ActorConnectionIdentity, type Conn } from "./actor.js";
18
- export type { ConnectorsModule, UserConnectorsModule, } from "./modules/connectors.types.js";
16
+ export { Actor, type Conn } from "./actor.js";
17
+ export type { ConnectorsModule, UserConnectorsModule, ConnectorApiRequest, ConnectorApiResponse, ConnectorApiResponsePhase, } from "./modules/connectors.types.js";
19
18
  export type { CustomIntegrationsModule, CustomIntegrationCallParams, CustomIntegrationCallResponse, } from "./modules/custom-integrations.types.js";
20
19
  export type { GetAccessTokenOptions, SaveAccessTokenOptions, RemoveAccessTokenOptions, GetLoginUrlOptions, } from "./utils/auth-utils.types.js";
package/dist/index.js CHANGED
@@ -3,5 +3,4 @@ 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";
7
6
  export { Actor } from "./actor.js";
@@ -1,22 +1,47 @@
1
- import { type AxiosInstance } from "axios";
2
- import { ActorConnectionError } from "./actors.error.js";
3
1
  import type { ActorRef } from "./actors.types.js";
2
+ /** Credentials minted by the platform for one direct actor connection. */
3
+ export interface ActorConnectionCredentials {
4
+ /** Direct actor endpoint, already carrying `?_pk=<connectionId>`. */
5
+ websocket_url: string;
6
+ /** Short-lived JWT bound to (app, actor, room, connectionId); appended to
7
+ * the URL as `token=` since browsers can't set WebSocket headers. */
8
+ token: string;
9
+ }
4
10
  interface ActorsConfig {
5
11
  appId: string;
6
- connectionClient: AxiosInstance;
7
- onError?: (error: ActorConnectionError) => void;
8
- /** Current user access token, if authenticated. */
12
+ /** Current user access token, if authenticated. Rides the WS query on the
13
+ * proxy-fallback path so the platform proxy can authenticate the connection;
14
+ * anonymous connects omit it. */
9
15
  getAuthToken(): string | null | undefined;
10
16
  /** Same semantics as function calls: editors with a non-prod version get the
11
17
  * draft actor script; everyone else gets the published one. */
12
18
  functionsVersion?: string;
13
- /** Absolute host used when a legacy Actor falls back to the Apper proxy. */
19
+ /** Absolute host for the proxy-fallback URL (scheme is swapped to wss, ws
20
+ * for localhost). Resolved by {@link resolveActorsHost}. */
14
21
  host: string;
22
+ /** Mints a direct-connect credential for one (actor, room, connection).
23
+ * Called per connection attempt: the token's expiry is checked at upgrade,
24
+ * so every reconnect needs a fresh one. */
25
+ mintConnectionToken(actorName: string, room: string, connectionId: string): Promise<ActorConnectionCredentials>;
26
+ /** @internal Ops escape hatch: "proxy" never mints (legacy path only),
27
+ * "direct" never falls back. Default "auto". */
28
+ transport?: "auto" | "proxy" | "direct";
29
+ /** Called when a mint fails for a reason other than the expected
30
+ * direct→proxy fallback (which recovers by itself). Wired to the client's
31
+ * `options.onError`. */
32
+ onMintError?: (error: Error) => void;
15
33
  }
16
34
  /**
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.
35
+ * The legacy platform-proxy URL, byte-for-byte what PartySocket built before
36
+ * the direct path existed: same scheme swap (including its localhost-needs-a-
37
+ * port quirk), case-preserved party segment, `_pk` first in the query. The
38
+ * `handler` param is load-bearing — the proxy reads it for the actor name.
39
+ */
40
+ export declare function buildProxyActorUrl(rawHost: string, actorName: string, instanceId: string, connectionId: string, appId: string, token: string | null | undefined, functionsVersion?: string): string;
41
+ /**
42
+ * Absolute host for the proxy-fallback actor URL. A relative/empty `serverUrl`
43
+ * can't be dialed (same-origin apps use a relative `/api`, so `serverUrl` is
44
+ * often `""`), so fall back to the page origin.
20
45
  */
21
46
  export declare function resolveActorsHost(serverUrl: string, browserOrigin?: string): string;
22
47
  export declare function createActorsModule(config: ActorsConfig): {
@@ -1,150 +1,97 @@
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]);
14
- // Heartbeat / half-open detection: PartySocket only reconnects on a close/error
1
+ import { WebSocket as ReconnectingWebSocket } from "partysocket";
2
+ // Heartbeat / half-open detection: the socket only reconnects on a close/error
15
3
  // event, so ping periodically and force a reconnect if nothing returns in DEAD_MS.
16
4
  const PING_MS = 1000;
17
5
  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
+ // Mint responses that mean "direct can't serve this connection, the proxy can":
7
+ // 409 = legacy-family actor script, 503 = direct connections not provisioned,
8
+ // 422 = no principal (e.g. anonymous outside a browser) or an id/room only the
9
+ // proxy's looser validation accepts, 405 = a backend that predates the mint
10
+ // endpoint (its actor deploy routes catch the path via `{handler_name:path}`
11
+ // but not the POST method — and the real endpoint never 405s a POST). The
12
+ // proxy serves migrated actors too, so falling back is always safe.
13
+ const PROXY_FALLBACK_STATUSES = new Set([405, 409, 422, 503]);
14
+ // Mint responses no retry can fix (bad request / forbidden / not found): the
15
+ // connection closes instead of re-minting forever; a fresh connect() re-probes.
16
+ // 401 is deliberately absent — the auth token is re-read on every attempt, so a
17
+ // login recovers on the next retry. Disjoint from PROXY_FALLBACK_STATUSES.
18
+ const TERMINAL_MINT_STATUSES = new Set([400, 403, 404]);
19
+ /** The mint's rejection can be anything; a `Base44Error` carries a numeric
20
+ * `.status` (absent for network failures). */
21
+ function mintErrorStatus(err) {
22
+ const status = err && typeof err === "object"
23
+ ? err.status
24
+ : undefined;
25
+ return typeof status === "number" ? status : undefined;
26
+ }
27
+ function toError(err) {
28
+ return err instanceof Error ? err : new Error(String(err));
29
+ }
36
30
  /**
37
- * A connection to an actor instance. Only obtainable from {@link ActorRef.connect}.
31
+ * A live connection to an actor instance. Only obtainable from
32
+ * {@link ActorRef.connect}, so `subscribe`/`send` are always valid — the socket
33
+ * exists for this object's whole lifetime.
38
34
  */
39
35
  class Connection {
40
- constructor(actorName, instanceId, config, options, getAnonymousId, onClose) {
41
- this.actorName = actorName;
42
- this.instanceId = instanceId;
36
+ constructor(actorName, instanceId, config, options, onClose) {
37
+ var _a;
43
38
  this.onClose = onClose;
44
39
  this.listeners = new Set();
45
- this.errorListeners = new Set();
46
40
  this.heartbeat = null;
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 () => {
41
+ this.closed = false;
42
+ this.id = (_a = options === null || options === void 0 ? void 0 : options.id) !== null && _a !== void 0 ? _a : crypto.randomUUID();
43
+ // Direct-first with proxy fallback, decided per connection attempt. Once a
44
+ // mint answers with a fallback status the choice is sticky for this
45
+ // socket's lifetime (a fresh connect() after close() probes direct again,
46
+ // picking up actors migrated in the meantime). Any other mint failure
47
+ // rejects, which ReconnectingWebSocket retries with backoff — except the
48
+ // terminal statuses, which close this connection for good.
49
+ let useProxy = config.transport === "proxy";
50
+ const urlProvider = async () => {
57
51
  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);
52
+ if (this.closed)
53
+ throw new Error("Actor connection is closed");
54
+ if (!useProxy) {
55
+ try {
56
+ const { websocket_url, token } = await config.mintConnectionToken(actorName, instanceId, this.id);
57
+ const sep = websocket_url.includes("?") ? "&" : "?";
58
+ return `${websocket_url}${sep}token=${encodeURIComponent(token)}`;
76
59
  }
77
- else {
78
- this.reportAttemptFailure(connectionError);
60
+ catch (err) {
61
+ const status = mintErrorStatus(err);
62
+ const isFallback = config.transport !== "direct" &&
63
+ status !== undefined &&
64
+ PROXY_FALLBACK_STATUSES.has(status);
65
+ if (!isFallback) {
66
+ if (status !== undefined && TERMINAL_MINT_STATUSES.has(status)) {
67
+ // close() before notifying: ws.close() stops the redial the
68
+ // rethrow below would otherwise schedule, and a handler that
69
+ // immediately calls connect() gets a clean new connection.
70
+ this.close();
71
+ }
72
+ // Reported from here because the socket's error event only
73
+ // preserves `err.message`, never `.status`.
74
+ try {
75
+ (_a = config.onMintError) === null || _a === void 0 ? void 0 : _a.call(config, toError(err));
76
+ }
77
+ catch (_b) {
78
+ // an app handler must not break the dial loop or mask `err`
79
+ }
80
+ throw err;
81
+ }
82
+ useProxy = true;
79
83
  }
80
- throw connectionError;
81
84
  }
82
- }, undefined, { startClosed: true });
85
+ // Rebuilt per attempt so a login/logout is picked up on reconnect.
86
+ return buildProxyActorUrl(config.host, actorName, instanceId, this.id, config.appId, config.getAuthToken(), config.functionsVersion);
87
+ };
88
+ const ws = new ReconnectingWebSocket(urlProvider);
83
89
  this.ws = ws;
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
- });
90
+ let lastMsg = Date.now();
91
+ const bumpAlive = () => { lastMsg = Date.now(); };
92
+ ws.addEventListener("open", bumpAlive);
145
93
  ws.addEventListener("message", (ev) => {
146
- markAlive();
147
- silentReconnects = 0;
94
+ bumpAlive();
148
95
  let data;
149
96
  try {
150
97
  data = JSON.parse(ev.data);
@@ -159,176 +106,58 @@ class Connection {
159
106
  listener(data);
160
107
  });
161
108
  this.heartbeat = setInterval(() => {
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
- }
109
+ if (Date.now() - lastMsg > DEAD_MS) {
110
+ bumpAlive(); // avoid a reconnect storm while the new socket comes up
111
+ // Only kick a half-open socket (OPEN but silent). When it isn't open
112
+ // the socket is already redialing with backoff, and reconnect() would
113
+ // reset that backoff into a mint call every DEAD_MS.
114
+ if (ws.readyState === ws.OPEN)
115
+ ws.reconnect();
180
116
  return;
181
117
  }
182
118
  try {
183
119
  // The deployed shim echoes __ping → __pong (base44-userapp-bundler
184
- // shim/actor.ts); without that, an idle room reconnects on a widening
185
- // interval (DEAD_MS up to MAX_DEAD_MS).
120
+ // shim/actor.ts); without that, an idle room reconnects every DEAD_MS.
186
121
  ws.send(JSON.stringify({ type: "__ping" }));
187
122
  }
188
123
  catch (_a) {
189
- // The next watchdog tick will reconnect if the link stays half-open.
124
+ // not open; the watchdog above will reconnect
190
125
  }
191
126
  }, PING_MS);
192
- ws.reconnect();
193
127
  }
194
128
  subscribe(callback) {
195
- this.assertNotClosed();
196
129
  this.listeners.add(callback);
197
130
  return {
198
131
  unsubscribe: () => { this.listeners.delete(callback); },
199
132
  };
200
133
  }
201
134
  send(data) {
202
- this.assertNotClosed();
135
+ // after close() the socket would buffer forever (unbounded enqueue)
136
+ if (this.closed)
137
+ return;
203
138
  this.ws.send(JSON.stringify(data));
204
139
  }
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
- }
217
140
  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)
141
+ if (this.closed)
268
142
  return;
269
- this.isClosed = true;
270
- this.closedError = error;
143
+ this.closed = true;
271
144
  if (this.heartbeat) {
272
145
  clearInterval(this.heartbeat);
273
146
  this.heartbeat = null;
274
147
  }
275
148
  this.listeners.clear();
276
- this.errorListeners.clear();
277
- this.clearStability();
278
149
  this.ws.close();
279
150
  this.onClose();
280
151
  }
281
152
  }
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
- }
309
153
  /** Handle for one actor instance: `connect()` opens the socket (idempotent). */
310
- function makeActorRef(actorName, instanceId, config, getAnonymousId, connections) {
311
- assertValidInstanceId(instanceId);
154
+ function makeActorRef(actorName, instanceId, config, connections) {
312
155
  let conn = null;
313
156
  return {
314
157
  connect(options) {
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
- }
158
+ if (conn)
329
159
  return conn;
330
- }
331
- const c = new Connection(actorName, instanceId, config, options, getAnonymousId, () => {
160
+ const c = new Connection(actorName, instanceId, config, options, () => {
332
161
  connections.delete(c);
333
162
  if (conn === c)
334
163
  conn = null; // allow a fresh connect() after close
@@ -340,91 +169,53 @@ function makeActorRef(actorName, instanceId, config, getAnonymousId, connections
340
169
  };
341
170
  }
342
171
  /**
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.
172
+ * The legacy platform-proxy URL, byte-for-byte what PartySocket built before
173
+ * the direct path existed: same scheme swap (including its localhost-needs-a-
174
+ * port quirk), case-preserved party segment, `_pk` first in the query. The
175
+ * `handler` param is load-bearing — the proxy reads it for the actor name.
176
+ */
177
+ export function buildProxyActorUrl(rawHost, actorName, instanceId, connectionId, appId, token, functionsVersion) {
178
+ let host = rawHost.replace(/^(http|https|ws|wss):\/\//, "");
179
+ if (host.endsWith("/"))
180
+ host = host.slice(0, -1);
181
+ const insecure = host.startsWith("localhost:") ||
182
+ host.startsWith("127.0.0.1:") ||
183
+ host.startsWith("192.168.") ||
184
+ host.startsWith("10.") ||
185
+ (host.startsWith("172.") &&
186
+ host.split(".")[1] >= "16" &&
187
+ host.split(".")[1] <= "31") ||
188
+ host.startsWith("[::ffff:7f00:1]:");
189
+ const query = new URLSearchParams([
190
+ ["_pk", connectionId],
191
+ ["app_id", appId],
192
+ ["handler", actorName],
193
+ ]);
194
+ if (token)
195
+ query.append("token", token);
196
+ if (functionsVersion)
197
+ query.append("fv", functionsVersion);
198
+ return `${insecure ? "ws" : "wss"}://${host}/parties/${actorName}/${instanceId}?${query}`;
199
+ }
200
+ /**
201
+ * Absolute host for the proxy-fallback actor URL. A relative/empty `serverUrl`
202
+ * can't be dialed (same-origin apps use a relative `/api`, so `serverUrl` is
203
+ * often `""`), so fall back to the page origin.
346
204
  */
347
205
  export function resolveActorsHost(serverUrl, browserOrigin) {
348
206
  return serverUrl && !serverUrl.startsWith("/") ? serverUrl : browserOrigin !== null && browserOrigin !== void 0 ? browserOrigin : serverUrl;
349
207
  }
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
- }
415
208
  export function createActorsModule(config) {
416
209
  // Live connections this client opened, so client.cleanup() can reclaim any the
417
210
  // app forgot to close() (each connection removes itself here on close).
418
211
  const connections = new Set();
419
- let anonymousId;
420
- const getAnonymousId = () => anonymousId !== null && anonymousId !== void 0 ? anonymousId : (anonymousId = getAnalyticsSessionId());
421
212
  const module = new Proxy({}, {
422
213
  get(_, key) {
423
214
  // Symbols and `then` resolve to undefined (so the module isn't mistaken
424
215
  // for a thenable when awaited); any string key is an actor name.
425
216
  if (typeof key !== "string" || key === "then")
426
217
  return undefined;
427
- return (instanceId) => makeActorRef(key, instanceId, config, getAnonymousId, connections);
218
+ return (instanceId) => makeActorRef(key, instanceId, config, connections);
428
219
  },
429
220
  });
430
221
  return {
@@ -1,4 +1,3 @@
1
- import type { ActorConnectionError } from "./actors.error.js";
2
1
  /**
3
2
  * Extend this interface to add typed `subscribe` callbacks and `send` payloads
4
3
  * for your deployed Actors.
@@ -37,44 +36,34 @@ type ToServerFor<N extends string> = N extends keyof ActorRegistry ? ActorRegist
37
36
  export interface ActorConnectOptions {
38
37
  /**
39
38
  * The connection id — becomes the actor's `conn.id`. Supply a stable value
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.
39
+ * (e.g. persisted per tab) so a reconnect reuses the same server-side
40
+ * identity; omit for an auto-generated per-connection id.
43
41
  */
44
42
  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;
57
43
  }
58
- /** Handle for one connection listener. */
44
+ /** Handle for one listener registered via {@link Connection.subscribe}. */
59
45
  export interface ActorSubscription {
60
- /** Remove this listener; other listeners and the connection stay live. */
46
+ /** Remove this listener; other listeners and the socket stay live. */
61
47
  unsubscribe(): void;
62
48
  }
63
49
  /**
64
- * A connection to an actor instance, returned by {@link ActorRef.connect}.
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.
65
53
  */
66
54
  export interface Connection<N extends string = string> {
67
55
  /** The connection id (the value the actor sees as `conn.id`). */
68
56
  readonly id: string;
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. */
57
+ /** Register a message listener. Multiple are allowed; returns a per-listener unsubscribe. */
74
58
  subscribe(callback: (data: ToClientFor<N>) => void): ActorSubscription;
75
- /** Send a message. Buffered until open; throws instead of buffering after close. */
59
+ /** Send a message. Buffered by the socket until it's open; dropped after
60
+ * {@link close}. */
76
61
  send(data: ToServerFor<N>): void;
77
- /** Tear down the socket, heartbeat, and all listeners. */
62
+ /**
63
+ * Tear down the socket, heartbeat, and all listeners. Safe to call more
64
+ * than once. A connection also closes itself when it fails permanently —
65
+ * see {@link ActorRef.connect}.
66
+ */
78
67
  close(): void;
79
68
  }
80
69
  /**
@@ -83,9 +72,13 @@ export interface Connection<N extends string = string> {
83
72
  */
84
73
  export interface ActorRef<N extends string = string> {
85
74
  /**
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.
75
+ * Open the WebSocket and return the {@link Connection}. Idempotent while the
76
+ * connection is open.
77
+ *
78
+ * A connection that fails permanently (for example, the actor doesn't exist
79
+ * or the caller isn't allowed to connect) closes itself and reports the
80
+ * error to the client's `onError` handler. Call `connect()` again after
81
+ * fixing the cause to get a fresh {@link Connection}, and re-subscribe.
89
82
  */
90
83
  connect(options?: ActorConnectOptions): Connection<N>;
91
84
  }
@@ -25,6 +25,9 @@ const analyticsSharedState = getSharedInstance(ANALYTICS_SHARED_STATE_NAME, () =
25
25
  wasInitializationTracked: false,
26
26
  sessionContext: null,
27
27
  sessionStartTime: null,
28
+ // Memoized session id for when `localStorage` can't persist one — see
29
+ // getAnalyticsSessionId.
30
+ fallbackSessionId: null,
28
31
  config: {
29
32
  ...defaultConfiguration,
30
33
  ...getAnalyticsConfigFromUrlParams(),
@@ -216,9 +219,11 @@ function trackSessionDurationEvent(track) {
216
219
  });
217
220
  }
218
221
  function getEventIntrinsicData() {
222
+ var _a, _b;
219
223
  return {
220
224
  timestamp: new Date().toISOString(),
221
- pageUrl: typeof window !== "undefined" ? window.location.pathname : null,
225
+ // `window.location` is absent on React Native, so read it optionally.
226
+ pageUrl: typeof window !== "undefined" ? (_b = (_a = window.location) === null || _a === void 0 ? void 0 : _a.pathname) !== null && _b !== void 0 ? _b : null : null,
222
227
  };
223
228
  }
224
229
  function transformEventDataToApiRequestData(sessionContext) {
@@ -302,9 +307,15 @@ export function getAnalyticsConfigFromUrlParams() {
302
307
  // return the config object //
303
308
  return { enabled: analyticsEnable === "true" };
304
309
  }
310
+ // When the id can't be persisted (React Native has no `localStorage`), keep
311
+ // it stable for the process instead of minting a fresh one per call.
312
+ function getFallbackSessionId() {
313
+ var _a;
314
+ return ((_a = analyticsSharedState.fallbackSessionId) !== null && _a !== void 0 ? _a : (analyticsSharedState.fallbackSessionId = generateUuid()));
315
+ }
305
316
  export function getAnalyticsSessionId() {
306
317
  if (typeof window === "undefined") {
307
- return generateUuid();
318
+ return getFallbackSessionId();
308
319
  }
309
320
  try {
310
321
  const sessionId = localStorage.getItem(ANALYTICS_SESSION_ID_LOCAL_STORAGE_KEY);
@@ -316,6 +327,6 @@ export function getAnalyticsSessionId() {
316
327
  return sessionId;
317
328
  }
318
329
  catch (_a) {
319
- return generateUuid();
330
+ return getFallbackSessionId();
320
331
  }
321
332
  }
@@ -81,13 +81,10 @@ 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 accessToken = options.token;
84
+ let hasAccessToken = Boolean(options.token);
85
85
  return {
86
86
  hasToken() {
87
- return Boolean(accessToken);
88
- },
89
- getToken() {
90
- return accessToken;
87
+ return hasAccessToken;
91
88
  },
92
89
  // Get current user information
93
90
  async me() {
@@ -154,7 +151,7 @@ export function createAuthModule(axios, functionsAxiosClient, appId, options) {
154
151
  // flight would otherwise resolve into callers that run after the logout.
155
152
  clearPendingMe();
156
153
  resetAnalyticsSessionContext();
157
- accessToken = undefined;
154
+ hasAccessToken = false;
158
155
  // Only do the rest if in a browser environment
159
156
  if (typeof window !== "undefined") {
160
157
  // Remove token from localStorage
@@ -183,7 +180,7 @@ export function createAuthModule(axios, functionsAxiosClient, appId, options) {
183
180
  // resolved for the previous one must not be handed to later callers.
184
181
  clearPendingMe();
185
182
  resetAnalyticsSessionContext();
186
- accessToken = token;
183
+ hasAccessToken = true;
187
184
  // handle token change for axios clients
188
185
  axios.defaults.headers.common["Authorization"] = `Bearer ${token}`;
189
186
  functionsAxiosClient.defaults.headers.common["Authorization"] = `Bearer ${token}`;
@@ -537,6 +537,4 @@ 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;
542
540
  }
@@ -1,3 +1,11 @@
1
+ const CONNECTOR_API_METHODS = new Set([
2
+ "GET",
3
+ "POST",
4
+ "PUT",
5
+ "PATCH",
6
+ "DELETE",
7
+ "HEAD",
8
+ ]);
1
9
  /**
2
10
  * Creates the Connectors module for the Base44 SDK.
3
11
  *
@@ -68,6 +76,56 @@ export function createConnectorsModule(axios, appId) {
68
76
  connectionConfig: (_a = data.connection_config) !== null && _a !== void 0 ? _a : null,
69
77
  };
70
78
  },
79
+ async callApi(integrationType, request) {
80
+ assertNonEmptyString(integrationType, "Integration type");
81
+ return proxyCall(axios, `/apps/${appId}/connectors/${integrationType}/call`, request);
82
+ },
83
+ };
84
+ }
85
+ function assertNonEmptyString(value, label) {
86
+ if (!value || typeof value !== "string") {
87
+ throw new Error(`${label} is required and must be a string`);
88
+ }
89
+ }
90
+ /**
91
+ * POST a request to the connector proxy and normalize the response.
92
+ *
93
+ * The proxy reports upstream outcomes in the body rather than as HTTP status, so
94
+ * a provider 4xx/5xx arrives here as a resolved response with `success: false` —
95
+ * only Base44-side failures reject through the axios error interceptor.
96
+ *
97
+ * @internal
98
+ */
99
+ async function proxyCall(axios, url, request) {
100
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
101
+ if (!request || typeof request !== "object") {
102
+ throw new Error("Request is required and must be an object");
103
+ }
104
+ assertNonEmptyString(request.path, "Request path");
105
+ const method = (_a = request.method) !== null && _a !== void 0 ? _a : "GET";
106
+ if (!CONNECTOR_API_METHODS.has(method)) {
107
+ throw new Error("Request method must be one of GET, POST, PUT, PATCH, DELETE, or HEAD");
108
+ }
109
+ const response = await axios.post(url, {
110
+ method,
111
+ // Omitted rather than sent as null so the proxy applies the connector's
112
+ // declared default host.
113
+ ...(request.host === undefined ? {} : { host: request.host }),
114
+ path: request.path,
115
+ query: (_b = request.query) !== null && _b !== void 0 ? _b : {},
116
+ headers: (_c = request.headers) !== null && _c !== void 0 ? _c : {},
117
+ body: (_d = request.body) !== null && _d !== void 0 ? _d : null,
118
+ });
119
+ const data = response;
120
+ return {
121
+ success: data.success,
122
+ phase: data.phase,
123
+ status: (_e = data.status_code) !== null && _e !== void 0 ? _e : null,
124
+ data: data.data,
125
+ dataBase64: (_f = data.data_base64) !== null && _f !== void 0 ? _f : null,
126
+ contentType: (_g = data.content_type) !== null && _g !== void 0 ? _g : null,
127
+ headers: (_h = data.headers) !== null && _h !== void 0 ? _h : {},
128
+ creditsCharged: (_j = data.credits_charged) !== null && _j !== void 0 ? _j : 0,
71
129
  };
72
130
  }
73
131
  /**
@@ -41,6 +41,81 @@ export interface AppUserConnectorConnectionResponse {
41
41
  /** Key-value configuration for the connection, or `null` if the connector does not provide one. */
42
42
  connectionConfig: Record<string, string> | null;
43
43
  }
44
+ /**
45
+ * How far a metered connector call progressed through the Base44 proxy.
46
+ *
47
+ * Only `not_sent` proves that the provider did not execute the request.
48
+ * `timed_out` and `sent_unconfirmed` may have executed upstream, so do not
49
+ * automatically retry non-idempotent requests based on those phases.
50
+ */
51
+ export type ConnectorApiResponsePhase = "not_sent" | "responded" | "timed_out" | "sent_unconfirmed";
52
+ /**
53
+ * A request to forward to a metered connector's API through the Base44 proxy.
54
+ */
55
+ export interface ConnectorApiRequest {
56
+ /** HTTP method for the upstream request. Defaults to `'GET'`. */
57
+ method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD";
58
+ /**
59
+ * Which of the connector's API hosts to call, by the name it declares.
60
+ * Omit for its default host (the first one declared). Only relevant for
61
+ * connectors that expose more than one host.
62
+ */
63
+ host?: string;
64
+ /**
65
+ * Path relative to the connector's API root, starting with `/`, such as `'/2/tweets'`.
66
+ *
67
+ * Must not be an absolute URL. Query parameters may be included here or passed
68
+ * separately as {@link query}; either way they are forwarded and priced identically.
69
+ */
70
+ path: string;
71
+ /** Query parameters. Merged into the request URL alongside any already present in {@link path}. */
72
+ query?: Record<string, string | number | boolean | Array<string | number>>;
73
+ /** Extra request headers. Only headers the connector explicitly allows are forwarded; the rest are dropped. */
74
+ headers?: Record<string, string>;
75
+ /** JSON request body. Ignored for `GET` and `HEAD`. */
76
+ body?: unknown;
77
+ }
78
+ /**
79
+ * The upstream API's response, as returned by the Base44 connector proxy.
80
+ */
81
+ export interface ConnectorApiResponse<T = unknown> {
82
+ /** `true` only when the upstream API returned a 2xx status. Proxy and upstream errors are `false`. */
83
+ success: boolean;
84
+ /** How far the call progressed. Only `not_sent` proves the provider did not execute it. */
85
+ phase: ConnectorApiResponsePhase;
86
+ /** The upstream HTTP status code, or `null` when no response was received. */
87
+ status: number | null;
88
+ /**
89
+ * The parsed upstream response body, or proxy error details when no response
90
+ * was received. `null` when the response was binary — see {@link dataBase64}.
91
+ */
92
+ data: T;
93
+ /**
94
+ * The response body base64-encoded, for the media types the connector declares
95
+ * as binary (images, PDFs). Set instead of {@link data}, never alongside it.
96
+ */
97
+ dataBase64: string | null;
98
+ /** The response media type, set only alongside {@link dataBase64}. */
99
+ contentType: string | null;
100
+ /** The subset of upstream response headers the connector exposes, typically rate-limit counters. */
101
+ headers: Record<string, string>;
102
+ /** Integration credits billed to the workspace for this call. */
103
+ creditsCharged: number;
104
+ }
105
+ /**
106
+ * Raw proxy response shape. Mapped to {@link ConnectorApiResponse} before being returned.
107
+ * @internal
108
+ */
109
+ export interface ConnectorProxyRawResponse {
110
+ success: boolean;
111
+ phase: ConnectorApiResponsePhase;
112
+ status_code: number | null;
113
+ data: unknown;
114
+ data_base64: string | null;
115
+ content_type: string | null;
116
+ headers: Record<string, string>;
117
+ credits_charged: number;
118
+ }
44
119
  /**
45
120
  * Connectors module for managing OAuth tokens for external services.
46
121
  *
@@ -71,6 +146,18 @@ export interface AppUserConnectorConnectionResponse {
71
146
  * 3. In a backend function, call {@linkcode getCurrentAppUserConnection | getCurrentAppUserConnection()} using the service role client (`base44.asServiceRole.connectors`) with the connector ID to retrieve the app user's token.
72
147
  * 4. Use the returned `accessToken` to call the external service's API directly. Some connectors also return a `connectionConfig` with additional values such as a subdomain for building the API URL.
73
148
  *
149
+ * ## Metered connectors
150
+ *
151
+ * A few [platform connectors](#shared-connectors) are backed by paid third-party APIs that charge Base44 per call. For those, the OAuth token is **not** available to your code — {@linkcode getConnection | getConnection()} rejects with a `403`. Call them with {@linkcode callApi | callApi()} instead: Base44 attaches the credential server-side, forwards the request, and bills your workspace's integration credits for the call.
152
+ *
153
+ * This applies to platform connectors only. A workspace-registered or app user connector runs on **your own** OAuth app, so the provider invoices you directly and there is nothing for Base44 to meter — those keep normal token access via {@linkcode getWorkspaceConnection | getWorkspaceConnection()} and {@linkcode getCurrentAppUserConnection | getCurrentAppUserConnection()}.
154
+ *
155
+ * Two things to keep in mind when writing against a metered connector:
156
+ *
157
+ * - **Cost varies by endpoint, sometimes sharply.** The same connector can charge two orders of magnitude more for one endpoint than another, so avoid putting an expensive call inside a loop and batch wherever the provider supports it. Each response reports what it actually cost as `creditsCharged`.
158
+ * - **Provider and transport outcomes are returned, not thrown.** A provider `4xx`/`5xx` or a connection failure comes back as `success: false` with its `phase`; authorization, quota, and invalid proxy requests reject the promise.
159
+ * - **Only `phase: 'not_sent'` proves the provider did not execute the request.** A timeout or in-flight failure may have executed upstream, so do not automatically retry a non-idempotent call unless the provider supports an idempotency key.
160
+ *
74
161
  * ## Available connectors
75
162
  *
76
163
  * The connectors below can be used as shared connectors or as app user connectors. For a shared platform connector, pass the integration type string to {@linkcode getConnection | getConnection()}. For a connector you register in Workspace Settings with your own OAuth app, use the connector ID with {@linkcode getWorkspaceConnection | getWorkspaceConnection()} for a shared token, or with {@linkcode getCurrentAppUserConnection | getCurrentAppUserConnection()} for a per-user token.
@@ -328,6 +415,41 @@ export interface ConnectorsModule {
328
415
  * ```
329
416
  */
330
417
  getCurrentAppUserConnection(connectorId: string): Promise<AppUserConnectorConnectionResponse>;
418
+ /**
419
+ * Calls a [metered connector's](#metered-connectors) API through the Base44 proxy.
420
+ *
421
+ * Use this for a shared platform connector identified by an integration type. Base44 adds the OAuth credential to the outgoing request, forwards it, and bills the workspace for the call, so you never handle the token yourself.
422
+ *
423
+ * @param integrationType - The type of integration, such as `'x'`. See [Available connectors](#available-connectors).
424
+ * @param request - The upstream request to forward. See {@link ConnectorApiRequest}.
425
+ * @returns Promise resolving to a {@link ConnectorApiResponse}. Note that an upstream error is reported in `success` and `status`, not thrown — only Base44-side failures reject.
426
+ *
427
+ * @example
428
+ * ```typescript
429
+ * // Post to X
430
+ * const res = await base44.asServiceRole.connectors.callApi('x', {
431
+ * method: 'POST',
432
+ * path: '/2/tweets',
433
+ * body: { text: 'Shipped!' },
434
+ * });
435
+ *
436
+ * if (!res.success) {
437
+ * console.error('X rejected the post', res.status, res.data);
438
+ * }
439
+ * ```
440
+ *
441
+ * @example
442
+ * ```typescript
443
+ * // Read, with query parameters and a look at what the call cost
444
+ * const res = await base44.asServiceRole.connectors.callApi('x', {
445
+ * path: '/2/tweets/search/recent',
446
+ * query: { query: 'base44', max_results: 10 },
447
+ * });
448
+ *
449
+ * console.log(`${res.creditsCharged} credits`, res.data);
450
+ * ```
451
+ */
452
+ callApi<T = unknown>(integrationType: ConnectorIntegrationType, request: ConnectorApiRequest): Promise<ConnectorApiResponse<T>>;
331
453
  }
332
454
  /**
333
455
  * User-scoped connectors module for managing app user OAuth connections.
@@ -132,13 +132,14 @@ export function createAxiosClient({ baseURL, headers = {}, token, interceptRespo
132
132
  client.interceptors.request.use((config) => {
133
133
  // `window.location` is absent on React Native (where `window` still exists),
134
134
  // so guard on it before reading `.href`.
135
- if (typeof window !== "undefined" && window.location) {
136
- config.headers.set("X-Origin-URL", window.location.href);
135
+ if (typeof window !== "undefined") {
136
+ if (window.location) {
137
+ config.headers.set("X-Origin-URL", window.location.href);
138
+ }
137
139
  // On unauthenticated requests, attach a stable anonymous visitor id so the
138
140
  // backend can support anonymous agent access (conversation grouping + ownership).
139
141
  // Authenticated requests are identified by their Authorization header instead.
140
- if (!config.headers.get("Authorization") &&
141
- !config.headers.get("X-Base44-Anonymous-Id")) {
142
+ if (!config.headers.get("Authorization")) {
142
143
  config.headers.set("X-Base44-Anonymous-Id", getAnalyticsSessionId());
143
144
  }
144
145
  }
@@ -186,11 +187,11 @@ export function createAxiosClient({ baseURL, headers = {}, token, interceptRespo
186
187
  }
187
188
  return response.data;
188
189
  }, (error) => {
189
- var _a, _b, _c, _d, _e, _f, _g, _h;
190
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q;
190
191
  const message = ((_b = (_a = error.response) === null || _a === void 0 ? void 0 : _a.data) === null || _b === void 0 ? void 0 : _b.message) ||
191
192
  ((_d = (_c = error.response) === null || _c === void 0 ? void 0 : _c.data) === null || _d === void 0 ? void 0 : _d.detail) ||
192
193
  error.message;
193
- const base44Error = new Base44Error(message, (_e = error.response) === null || _e === void 0 ? void 0 : _e.status, (_g = (_f = error.response) === null || _f === void 0 ? void 0 : _f.data) === null || _g === void 0 ? void 0 : _g.code, (_h = error.response) === null || _h === void 0 ? void 0 : _h.data, error);
194
+ const base44Error = new Base44Error(message, (_e = error.response) === null || _e === void 0 ? void 0 : _e.status, (_m = (_h = (_g = (_f = error.response) === null || _f === void 0 ? void 0 : _f.data) === null || _g === void 0 ? void 0 : _g.code) !== null && _h !== void 0 ? _h : (_l = (_k = (_j = error.response) === null || _j === void 0 ? void 0 : _j.headers) === null || _k === void 0 ? void 0 : _k.get) === null || _l === void 0 ? void 0 : _l.call(_k, "x-base44-connector-error")) !== null && _m !== void 0 ? _m : (_p = (_o = error.response) === null || _o === void 0 ? void 0 : _o.headers) === null || _p === void 0 ? void 0 : _p["x-base44-connector-error"], (_q = error.response) === null || _q === void 0 ? void 0 : _q.data, error);
194
195
  // Log errors in development
195
196
  if (process.env.NODE_ENV !== "production") {
196
197
  safeErrorLog("[Base44 SDK Error]", base44Error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@base44-preview/sdk",
3
- "version": "0.8.43-pr.259.3f82534",
3
+ "version": "0.8.43-pr.260.d28232f",
4
4
  "description": "JavaScript SDK for Base44 API",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -1,14 +0,0 @@
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
- }
@@ -1,15 +0,0 @@
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
- }