@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 +13 -3
- package/dist/actor.js +4 -3
- package/dist/client.js +12 -3
- package/dist/client.types.d.ts +2 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/modules/actors.d.ts +9 -8
- package/dist/modules/actors.error.d.ts +14 -0
- package/dist/modules/actors.error.js +15 -0
- package/dist/modules/actors.js +342 -40
- package/dist/modules/actors.types.d.ts +30 -10
- package/dist/modules/auth.js +7 -4
- package/dist/modules/auth.types.d.ts +2 -0
- package/dist/utils/axios-client.js +2 -1
- package/package.json +1 -1
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).
|
|
90
|
-
*
|
|
91
|
-
*
|
|
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).
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
-
|
|
131
|
-
|
|
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: () =>
|
|
143
|
+
getAuthToken: () => userAuthModule.getToken(),
|
|
135
144
|
});
|
|
136
145
|
const userModules = {
|
|
137
146
|
entities: createEntitiesModule({
|
package/dist/client.types.d.ts
CHANGED
|
@@ -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";
|
package/dist/modules/actors.d.ts
CHANGED
|
@@ -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
|
-
|
|
5
|
-
|
|
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
|
|
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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
+
}
|
package/dist/modules/actors.js
CHANGED
|
@@ -1,42 +1,150 @@
|
|
|
1
|
-
import
|
|
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
|
|
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
|
-
|
|
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.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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 (
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
108
|
-
*
|
|
109
|
-
*
|
|
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)
|
|
40
|
-
* identity; omit for an
|
|
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
|
|
58
|
+
/** Handle for one connection listener. */
|
|
45
59
|
export interface ActorSubscription {
|
|
46
|
-
/** Remove this listener; other listeners and the
|
|
60
|
+
/** Remove this listener; other listeners and the connection stay live. */
|
|
47
61
|
unsubscribe(): void;
|
|
48
62
|
}
|
|
49
63
|
/**
|
|
50
|
-
* A
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
/**
|
package/dist/modules/auth.js
CHANGED
|
@@ -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
|
|
84
|
+
let accessToken = options.token;
|
|
85
85
|
return {
|
|
86
86
|
hasToken() {
|
|
87
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|