@base44-preview/sdk 0.8.44-pr.267.4549331 → 0.8.44-pr.270.91862eb
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/client.js +29 -4
- package/dist/client.types.d.ts +29 -1
- package/dist/modules/actors.d.ts +33 -7
- package/dist/modules/actors.js +116 -23
- package/dist/modules/actors.types.d.ts +16 -3
- package/dist/modules/analytics.d.ts +13 -2
- package/dist/modules/analytics.js +147 -18
- package/dist/modules/analytics.types.d.ts +91 -0
- package/dist/utils/axios-client.js +4 -2
- package/package.json +1 -1
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
|
*
|
|
@@ -52,7 +52,7 @@ import { createActorsModule, resolveActorsHost } from "./modules/actors.js";
|
|
|
52
52
|
*/
|
|
53
53
|
export function createClient(config) {
|
|
54
54
|
var _a, _b, _c;
|
|
55
|
-
const { serverUrl = "https://base44.app", appId, token, serviceToken, requiresAuth = false, appBaseUrl, options, functionsVersion, headers: optionalHeaders, } = config;
|
|
55
|
+
const { serverUrl = "https://base44.app", appId, token, serviceToken, requiresAuth = false, appBaseUrl, options, analytics: analyticsOptions, functionsVersion, headers: optionalHeaders, } = config;
|
|
56
56
|
// Normalize appBaseUrl to always be a string (empty if not provided or invalid)
|
|
57
57
|
const normalizedAppBaseUrl = typeof appBaseUrl === "string" ? appBaseUrl : "";
|
|
58
58
|
const socketConfig = {
|
|
@@ -110,6 +110,15 @@ export function createClient(config) {
|
|
|
110
110
|
token: serviceToken,
|
|
111
111
|
interceptResponses: false,
|
|
112
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
|
+
});
|
|
113
122
|
const userAuthModule = createAuthModule(axiosClient, functionsAxiosClient, appId, {
|
|
114
123
|
appBaseUrl: normalizedAppBaseUrl,
|
|
115
124
|
serverUrl,
|
|
@@ -127,11 +136,26 @@ export function createClient(config) {
|
|
|
127
136
|
}
|
|
128
137
|
const actorsModule = createActorsModule({
|
|
129
138
|
appId,
|
|
130
|
-
// serverUrl is often relative/empty (same-origin app);
|
|
131
|
-
// absolute host, so fall back to the page origin.
|
|
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.
|
|
132
141
|
host: resolveActorsHost(serverUrl, typeof window !== "undefined" ? (_a = window.location) === null || _a === void 0 ? void 0 : _a.origin : undefined),
|
|
133
142
|
functionsVersion,
|
|
134
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,
|
|
135
159
|
});
|
|
136
160
|
const userModules = {
|
|
137
161
|
entities: createEntitiesModule({
|
|
@@ -169,6 +193,7 @@ export function createClient(config) {
|
|
|
169
193
|
serverUrl,
|
|
170
194
|
appId,
|
|
171
195
|
userAuthModule,
|
|
196
|
+
options: analyticsOptions,
|
|
172
197
|
}),
|
|
173
198
|
actors: actorsModule.module,
|
|
174
199
|
cleanup: () => {
|
package/dist/client.types.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { FunctionsModule } from "./modules/functions.types.js";
|
|
|
7
7
|
import type { AgentsModule } from "./modules/agents.types.js";
|
|
8
8
|
import type { AiGatewayModule } from "./modules/ai-gateway.types.js";
|
|
9
9
|
import type { AppLogsModule } from "./modules/app-logs.types.js";
|
|
10
|
-
import type { AnalyticsModule } from "./modules/analytics.types.js";
|
|
10
|
+
import type { AnalyticsModule, CreateClientAnalyticsOptions } from "./modules/analytics.types.js";
|
|
11
11
|
import type { ActorsModule } from "./modules/actors.types.js";
|
|
12
12
|
/**
|
|
13
13
|
* Options for creating a Base44 client.
|
|
@@ -15,8 +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
|
+
*
|
|
19
|
+
* Also receives {@link ActorsModule | actors} connection failures. Errors
|
|
20
|
+
* are usually {@linkcode Base44Error} instances — check `error.status`.
|
|
18
21
|
*/
|
|
19
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";
|
|
20
32
|
}
|
|
21
33
|
/**
|
|
22
34
|
* Configuration for creating a Base44 client.
|
|
@@ -70,6 +82,22 @@ export interface CreateClientConfig {
|
|
|
70
82
|
* @internal
|
|
71
83
|
*/
|
|
72
84
|
headers?: Record<string, string>;
|
|
85
|
+
/**
|
|
86
|
+
* Analytics configuration for this client.
|
|
87
|
+
*
|
|
88
|
+
* By default, analytics is enabled and starts as soon as the client is created: a persistent visitor ID is stored in `localStorage` and automatic events are sent.
|
|
89
|
+
*
|
|
90
|
+
* Set `consent: "pending"` to keep analytics dormant until the visitor makes a consent decision, then call {@linkcode AnalyticsModule.optIn | analytics.optIn()} or {@linkcode AnalyticsModule.optOut | analytics.optOut()}. Set `enabled: false` to turn the analytics module off entirely.
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* ```typescript
|
|
94
|
+
* const base44 = createClient({
|
|
95
|
+
* appId: 'my-app-id',
|
|
96
|
+
* analytics: { consent: 'pending' }
|
|
97
|
+
* });
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
analytics?: CreateClientAnalyticsOptions;
|
|
73
101
|
/**
|
|
74
102
|
* Additional client options.
|
|
75
103
|
*/
|
package/dist/modules/actors.d.ts
CHANGED
|
@@ -1,21 +1,47 @@
|
|
|
1
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
|
+
}
|
|
2
10
|
interface ActorsConfig {
|
|
3
11
|
appId: string;
|
|
4
|
-
/** Current user access token, if authenticated. Rides the WS query
|
|
5
|
-
* platform proxy can authenticate the connection;
|
|
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. */
|
|
6
15
|
getAuthToken(): string | null | undefined;
|
|
7
16
|
/** Same semantics as function calls: editors with a non-prod version get the
|
|
8
17
|
* draft actor script; everyone else gets the published one. */
|
|
9
18
|
functionsVersion?: string;
|
|
10
|
-
/** Absolute host
|
|
19
|
+
/** Absolute host for the proxy-fallback URL (scheme is swapped to wss, ws
|
|
11
20
|
* for localhost). Resolved by {@link resolveActorsHost}. */
|
|
12
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;
|
|
13
33
|
}
|
|
14
34
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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.
|
|
19
45
|
*/
|
|
20
46
|
export declare function resolveActorsHost(serverUrl: string, browserOrigin?: string): string;
|
|
21
47
|
export declare function createActorsModule(config: ActorsConfig): {
|
package/dist/modules/actors.js
CHANGED
|
@@ -1,8 +1,32 @@
|
|
|
1
|
-
import
|
|
2
|
-
// Heartbeat / half-open detection:
|
|
1
|
+
import { WebSocket as ReconnectingWebSocket } from "partysocket";
|
|
2
|
+
// Heartbeat / half-open detection: the socket only reconnects on a close/error
|
|
3
3
|
// event, so ping periodically and force a reconnect if nothing returns in DEAD_MS.
|
|
4
4
|
const PING_MS = 1000;
|
|
5
5
|
const DEAD_MS = 3000;
|
|
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
|
+
}
|
|
6
30
|
/**
|
|
7
31
|
* A live connection to an actor instance. Only obtainable from
|
|
8
32
|
* {@link ActorRef.connect}, so `subscribe`/`send` are always valid — the socket
|
|
@@ -14,23 +38,54 @@ class Connection {
|
|
|
14
38
|
this.onClose = onClose;
|
|
15
39
|
this.listeners = new Set();
|
|
16
40
|
this.heartbeat = null;
|
|
41
|
+
this.closed = false;
|
|
17
42
|
this.id = (_a = options === null || options === void 0 ? void 0 : options.id) !== null && _a !== void 0 ? _a : crypto.randomUUID();
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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 () => {
|
|
51
|
+
var _a;
|
|
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)}`;
|
|
59
|
+
}
|
|
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;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
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);
|
|
34
89
|
this.ws = ws;
|
|
35
90
|
let lastMsg = Date.now();
|
|
36
91
|
const bumpAlive = () => { lastMsg = Date.now(); };
|
|
@@ -53,7 +108,11 @@ class Connection {
|
|
|
53
108
|
this.heartbeat = setInterval(() => {
|
|
54
109
|
if (Date.now() - lastMsg > DEAD_MS) {
|
|
55
110
|
bumpAlive(); // avoid a reconnect storm while the new socket comes up
|
|
56
|
-
|
|
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();
|
|
57
116
|
return;
|
|
58
117
|
}
|
|
59
118
|
try {
|
|
@@ -73,9 +132,15 @@ class Connection {
|
|
|
73
132
|
};
|
|
74
133
|
}
|
|
75
134
|
send(data) {
|
|
135
|
+
// after close() the socket would buffer forever (unbounded enqueue)
|
|
136
|
+
if (this.closed)
|
|
137
|
+
return;
|
|
76
138
|
this.ws.send(JSON.stringify(data));
|
|
77
139
|
}
|
|
78
140
|
close() {
|
|
141
|
+
if (this.closed)
|
|
142
|
+
return;
|
|
143
|
+
this.closed = true;
|
|
79
144
|
if (this.heartbeat) {
|
|
80
145
|
clearInterval(this.heartbeat);
|
|
81
146
|
this.heartbeat = null;
|
|
@@ -104,10 +169,38 @@ function makeActorRef(actorName, instanceId, config, connections) {
|
|
|
104
169
|
};
|
|
105
170
|
}
|
|
106
171
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
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.
|
|
111
204
|
*/
|
|
112
205
|
export function resolveActorsHost(serverUrl, browserOrigin) {
|
|
113
206
|
return serverUrl && !serverUrl.startsWith("/") ? serverUrl : browserOrigin !== null && browserOrigin !== void 0 ? browserOrigin : serverUrl;
|
|
@@ -56,9 +56,14 @@ export interface Connection<N extends string = string> {
|
|
|
56
56
|
readonly id: string;
|
|
57
57
|
/** Register a message listener. Multiple are allowed; returns a per-listener unsubscribe. */
|
|
58
58
|
subscribe(callback: (data: ToClientFor<N>) => void): ActorSubscription;
|
|
59
|
-
/** Send a message. Buffered by the socket until it's open
|
|
59
|
+
/** Send a message. Buffered by the socket until it's open; dropped after
|
|
60
|
+
* {@link close}. */
|
|
60
61
|
send(data: ToServerFor<N>): void;
|
|
61
|
-
/**
|
|
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
|
+
*/
|
|
62
67
|
close(): void;
|
|
63
68
|
}
|
|
64
69
|
/**
|
|
@@ -66,7 +71,15 @@ export interface Connection<N extends string = string> {
|
|
|
66
71
|
* {@link connect} to open the socket and get a {@link Connection}.
|
|
67
72
|
*/
|
|
68
73
|
export interface ActorRef<N extends string = string> {
|
|
69
|
-
/**
|
|
74
|
+
/**
|
|
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.
|
|
82
|
+
*/
|
|
70
83
|
connect(options?: ActorConnectOptions): Connection<N>;
|
|
71
84
|
}
|
|
72
85
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AxiosInstance } from "axios";
|
|
2
|
-
import { TrackEventParams, AnalyticsModuleOptions } from "./analytics.types";
|
|
2
|
+
import { TrackEventParams, AnalyticsModuleOptions, AnalyticsConsentStatus, CreateClientAnalyticsOptions } from "./analytics.types";
|
|
3
3
|
import type { InternalAuthModule } from "./auth.types";
|
|
4
4
|
export declare const USER_HEARTBEAT_EVENT_NAME = "__user_heartbeat_event__";
|
|
5
5
|
export declare const ANALYTICS_INITIALIZATION_EVENT_NAME = "__initialization_event__";
|
|
@@ -11,9 +11,20 @@ export interface AnalyticsModuleArgs {
|
|
|
11
11
|
serverUrl: string;
|
|
12
12
|
appId: string;
|
|
13
13
|
userAuthModule: InternalAuthModule;
|
|
14
|
+
options?: CreateClientAnalyticsOptions;
|
|
14
15
|
}
|
|
15
|
-
|
|
16
|
+
/**
|
|
17
|
+
* The effective analytics consent status. `"granted"` when no client set one
|
|
18
|
+
* explicitly, preserving the legacy always-on behavior.
|
|
19
|
+
*
|
|
20
|
+
* @internal
|
|
21
|
+
*/
|
|
22
|
+
export declare function getAnalyticsConsentStatus(): AnalyticsConsentStatus;
|
|
23
|
+
export declare const createAnalyticsModule: ({ axiosClient, serverUrl, appId, userAuthModule, options, }: AnalyticsModuleArgs) => {
|
|
16
24
|
track: (params: TrackEventParams) => void;
|
|
25
|
+
optIn: () => void;
|
|
26
|
+
optOut: () => void;
|
|
27
|
+
getConsentStatus: typeof getAnalyticsConsentStatus;
|
|
17
28
|
cleanup: () => void;
|
|
18
29
|
};
|
|
19
30
|
/**
|
|
@@ -25,22 +25,84 @@ 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,
|
|
31
|
+
// Consent status shared by every client on the page. `null` means no
|
|
32
|
+
// client set one explicitly, which keeps the legacy behavior (granted).
|
|
33
|
+
consent: null,
|
|
28
34
|
config: {
|
|
29
35
|
...defaultConfiguration,
|
|
30
36
|
...getAnalyticsConfigFromUrlParams(),
|
|
31
37
|
},
|
|
32
38
|
}));
|
|
33
|
-
|
|
39
|
+
// Lower ranks are more restrictive. Used to merge the consent status of
|
|
40
|
+
// multiple clients created on the same page: the shared state (and therefore
|
|
41
|
+
// the shared persistent id) can only honor one status, so the most
|
|
42
|
+
// restrictive explicitly-configured one wins.
|
|
43
|
+
const CONSENT_RESTRICTIVENESS = {
|
|
44
|
+
denied: 0,
|
|
45
|
+
pending: 1,
|
|
46
|
+
granted: 2,
|
|
47
|
+
};
|
|
48
|
+
function applyInitialConsent(consent) {
|
|
49
|
+
if (!consent)
|
|
50
|
+
return;
|
|
51
|
+
const current = analyticsSharedState.consent;
|
|
52
|
+
if (current === null ||
|
|
53
|
+
CONSENT_RESTRICTIVENESS[consent] < CONSENT_RESTRICTIVENESS[current]) {
|
|
54
|
+
analyticsSharedState.consent = consent;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The effective analytics consent status. `"granted"` when no client set one
|
|
59
|
+
* explicitly, preserving the legacy always-on behavior.
|
|
60
|
+
*
|
|
61
|
+
* @internal
|
|
62
|
+
*/
|
|
63
|
+
export function getAnalyticsConsentStatus() {
|
|
34
64
|
var _a;
|
|
65
|
+
return (_a = analyticsSharedState.consent) !== null && _a !== void 0 ? _a : "granted";
|
|
66
|
+
}
|
|
67
|
+
function clearPersistedAnalyticsSessionId() {
|
|
68
|
+
if (typeof window === "undefined")
|
|
69
|
+
return;
|
|
70
|
+
try {
|
|
71
|
+
localStorage.removeItem(ANALYTICS_SESSION_ID_LOCAL_STORAGE_KEY);
|
|
72
|
+
}
|
|
73
|
+
catch (_a) {
|
|
74
|
+
// Storage unavailable — nothing was persisted, so nothing to clear.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export const createAnalyticsModule = ({ axiosClient, serverUrl, appId, userAuthModule, options, }) => {
|
|
78
|
+
var _a;
|
|
79
|
+
// Consent gates more than this module: getAnalyticsSessionId() also backs
|
|
80
|
+
// the anonymous-id HTTP header and the socket handshake, so the client's
|
|
81
|
+
// consent choice must be recorded even when the early returns below make
|
|
82
|
+
// the module itself a no-op.
|
|
83
|
+
applyInitialConsent(options === null || options === void 0 ? void 0 : options.consent);
|
|
35
84
|
// prevent overflow of events //
|
|
36
85
|
const { maxQueueSize, throttleTime, batchSize } = analyticsSharedState.config;
|
|
37
86
|
// Disable analytics on React Native. It defines `window` but not `document`,
|
|
38
87
|
// so the per-callsite `typeof window` guards below aren't enough to keep it
|
|
39
88
|
// from touching `document` (e.g. `document.referrer` on init). Node/SSR is
|
|
40
89
|
// still handled by those `window` guards, so this doesn't affect it.
|
|
41
|
-
if (!((_a = analyticsSharedState.config) === null || _a === void 0 ? void 0 : _a.enabled) ||
|
|
90
|
+
if (!((_a = analyticsSharedState.config) === null || _a === void 0 ? void 0 : _a.enabled) ||
|
|
91
|
+
(options === null || options === void 0 ? void 0 : options.enabled) === false ||
|
|
92
|
+
isReactNative) {
|
|
42
93
|
return {
|
|
43
94
|
track: () => { },
|
|
95
|
+
// Consent still matters with the event pipeline off: it decides whether
|
|
96
|
+
// the persistent id may back the anonymous-id header and socket
|
|
97
|
+
// handshake, so opting in/out has to work here too.
|
|
98
|
+
optIn: () => {
|
|
99
|
+
analyticsSharedState.consent = "granted";
|
|
100
|
+
},
|
|
101
|
+
optOut: () => {
|
|
102
|
+
analyticsSharedState.consent = "denied";
|
|
103
|
+
clearPersistedAnalyticsSessionId();
|
|
104
|
+
},
|
|
105
|
+
getConsentStatus: getAnalyticsConsentStatus,
|
|
44
106
|
cleanup: () => { },
|
|
45
107
|
};
|
|
46
108
|
}
|
|
@@ -87,6 +149,12 @@ export const createAnalyticsModule = ({ axiosClient, serverUrl, appId, userAuthM
|
|
|
87
149
|
});
|
|
88
150
|
};
|
|
89
151
|
const track = (params) => {
|
|
152
|
+
const consent = getAnalyticsConsentStatus();
|
|
153
|
+
// Denied: drop. Pending: buffer in memory (no network, no storage) so the
|
|
154
|
+
// events can be delivered if the visitor opts in later.
|
|
155
|
+
if (consent === "denied") {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
90
158
|
if (analyticsSharedState.requestsQueue.length >= maxQueueSize) {
|
|
91
159
|
return;
|
|
92
160
|
}
|
|
@@ -95,7 +163,9 @@ export const createAnalyticsModule = ({ axiosClient, serverUrl, appId, userAuthM
|
|
|
95
163
|
...params,
|
|
96
164
|
...intrinsicData,
|
|
97
165
|
});
|
|
98
|
-
|
|
166
|
+
if (consent === "granted") {
|
|
167
|
+
startProcessing();
|
|
168
|
+
}
|
|
99
169
|
};
|
|
100
170
|
const onDocVisible = () => {
|
|
101
171
|
startAnalyticsProcessor(flush, {
|
|
@@ -123,25 +193,66 @@ export const createAnalyticsModule = ({ axiosClient, serverUrl, appId, userAuthM
|
|
|
123
193
|
onDocVisible();
|
|
124
194
|
}
|
|
125
195
|
};
|
|
126
|
-
|
|
196
|
+
// Everything with a side effect beyond this module — the persistent id,
|
|
197
|
+
// automatic events, timers, network — starts in activate(), so a client
|
|
198
|
+
// created with consent "pending" or "denied" stays fully dormant until the
|
|
199
|
+
// visitor opts in.
|
|
200
|
+
let isActive = false;
|
|
201
|
+
const activate = () => {
|
|
202
|
+
if (isActive)
|
|
203
|
+
return;
|
|
204
|
+
isActive = true;
|
|
205
|
+
// start the flusing process ///
|
|
206
|
+
startProcessing();
|
|
207
|
+
// start the heart beat processor //
|
|
208
|
+
clearHeartBeatProcessor = startHeartBeatProcessor(track);
|
|
209
|
+
// track the referrer event //
|
|
210
|
+
trackInitializationEvent(track);
|
|
211
|
+
// start the visibility change listener //
|
|
212
|
+
if (typeof window !== "undefined") {
|
|
213
|
+
window.addEventListener("visibilitychange", onVisibilityChange);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
const deactivate = () => {
|
|
217
|
+
if (!isActive)
|
|
218
|
+
return;
|
|
219
|
+
isActive = false;
|
|
127
220
|
stopAnalyticsProcessor();
|
|
128
221
|
clearHeartBeatProcessor === null || clearHeartBeatProcessor === void 0 ? void 0 : clearHeartBeatProcessor();
|
|
222
|
+
clearHeartBeatProcessor = undefined;
|
|
129
223
|
if (typeof window !== "undefined") {
|
|
130
224
|
window.removeEventListener("visibilitychange", onVisibilityChange);
|
|
131
225
|
}
|
|
132
226
|
};
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
227
|
+
const optIn = () => {
|
|
228
|
+
analyticsSharedState.consent = "granted";
|
|
229
|
+
// Persist the id now rather than on the next event: this adopts the
|
|
230
|
+
// ephemeral pre-consent id (see getAnalyticsSessionId), keeping the
|
|
231
|
+
// visitor's identity continuous across the consent grant.
|
|
232
|
+
getAnalyticsSessionId();
|
|
233
|
+
activate();
|
|
234
|
+
};
|
|
235
|
+
const optOut = () => {
|
|
236
|
+
analyticsSharedState.consent = "denied";
|
|
237
|
+
deactivate();
|
|
238
|
+
// Drop anything buffered while consent was pending, and forget the
|
|
239
|
+
// identity: both the persisted id and the memoized session context.
|
|
240
|
+
analyticsSharedState.requestsQueue.length = 0;
|
|
241
|
+
analyticsSharedState.sessionStartTime = null;
|
|
242
|
+
resetAnalyticsSessionContext();
|
|
243
|
+
clearPersistedAnalyticsSessionId();
|
|
244
|
+
};
|
|
245
|
+
const cleanup = () => {
|
|
246
|
+
deactivate();
|
|
247
|
+
};
|
|
248
|
+
if (getAnalyticsConsentStatus() === "granted") {
|
|
249
|
+
activate();
|
|
142
250
|
}
|
|
143
251
|
return {
|
|
144
252
|
track,
|
|
253
|
+
optIn,
|
|
254
|
+
optOut,
|
|
255
|
+
getConsentStatus: getAnalyticsConsentStatus,
|
|
145
256
|
cleanup,
|
|
146
257
|
};
|
|
147
258
|
};
|
|
@@ -216,9 +327,11 @@ function trackSessionDurationEvent(track) {
|
|
|
216
327
|
});
|
|
217
328
|
}
|
|
218
329
|
function getEventIntrinsicData() {
|
|
330
|
+
var _a, _b;
|
|
219
331
|
return {
|
|
220
332
|
timestamp: new Date().toISOString(),
|
|
221
|
-
|
|
333
|
+
// `window.location` is absent on React Native, so read it optionally.
|
|
334
|
+
pageUrl: typeof window !== "undefined" ? (_b = (_a = window.location) === null || _a === void 0 ? void 0 : _a.pathname) !== null && _b !== void 0 ? _b : null : null,
|
|
222
335
|
};
|
|
223
336
|
}
|
|
224
337
|
function transformEventDataToApiRequestData(sessionContext) {
|
|
@@ -302,20 +415,36 @@ export function getAnalyticsConfigFromUrlParams() {
|
|
|
302
415
|
// return the config object //
|
|
303
416
|
return { enabled: analyticsEnable === "true" };
|
|
304
417
|
}
|
|
418
|
+
// When the id can't be persisted (React Native has no `localStorage`), keep
|
|
419
|
+
// it stable for the process instead of minting a fresh one per call.
|
|
420
|
+
function getFallbackSessionId() {
|
|
421
|
+
var _a;
|
|
422
|
+
return ((_a = analyticsSharedState.fallbackSessionId) !== null && _a !== void 0 ? _a : (analyticsSharedState.fallbackSessionId = generateUuid()));
|
|
423
|
+
}
|
|
305
424
|
export function getAnalyticsSessionId() {
|
|
425
|
+
var _a;
|
|
306
426
|
if (typeof window === "undefined") {
|
|
307
|
-
return
|
|
427
|
+
return getFallbackSessionId();
|
|
428
|
+
}
|
|
429
|
+
// Until consent is granted, never read or write the persistent id — hand out
|
|
430
|
+
// a per-page-load ephemeral id instead. The anonymous-id HTTP header and the
|
|
431
|
+
// socket handshake resolve their id through here too, so this single gate
|
|
432
|
+
// covers every place a persistent identifier could be minted pre-consent.
|
|
433
|
+
if (getAnalyticsConsentStatus() !== "granted") {
|
|
434
|
+
return getFallbackSessionId();
|
|
308
435
|
}
|
|
309
436
|
try {
|
|
310
437
|
const sessionId = localStorage.getItem(ANALYTICS_SESSION_ID_LOCAL_STORAGE_KEY);
|
|
311
438
|
if (!sessionId) {
|
|
312
|
-
|
|
439
|
+
// Adopt the ephemeral pre-consent id when one was handed out, so the
|
|
440
|
+
// visitor keeps a single identity across the consent grant.
|
|
441
|
+
const newSessionId = (_a = analyticsSharedState.fallbackSessionId) !== null && _a !== void 0 ? _a : generateUuid();
|
|
313
442
|
localStorage.setItem(ANALYTICS_SESSION_ID_LOCAL_STORAGE_KEY, newSessionId);
|
|
314
443
|
return newSessionId;
|
|
315
444
|
}
|
|
316
445
|
return sessionId;
|
|
317
446
|
}
|
|
318
|
-
catch (
|
|
319
|
-
return
|
|
447
|
+
catch (_b) {
|
|
448
|
+
return getFallbackSessionId();
|
|
320
449
|
}
|
|
321
450
|
}
|
|
@@ -67,6 +67,41 @@ export type AnalyticsModuleOptions = {
|
|
|
67
67
|
batchSize?: number;
|
|
68
68
|
heartBeatInterval?: number;
|
|
69
69
|
};
|
|
70
|
+
/**
|
|
71
|
+
* Consent status for analytics tracking.
|
|
72
|
+
*
|
|
73
|
+
* - `"granted"`: Analytics is fully active. The SDK persists a visitor ID in `localStorage`, sends automatic events, and delivers tracked events to the server.
|
|
74
|
+
* - `"pending"`: Analytics is dormant while waiting for a consent decision. No visitor ID is persisted and nothing is sent to the server. Events passed to {@linkcode AnalyticsModule.track | track()} are buffered in memory and delivered if consent is later granted with {@linkcode AnalyticsModule.optIn | optIn()}.
|
|
75
|
+
* - `"denied"`: Analytics is off. No visitor ID is persisted, nothing is sent to the server, and tracked events are discarded.
|
|
76
|
+
*/
|
|
77
|
+
export type AnalyticsConsentStatus = "granted" | "denied" | "pending";
|
|
78
|
+
/**
|
|
79
|
+
* Analytics configuration for {@linkcode createClient | createClient()}.
|
|
80
|
+
*
|
|
81
|
+
* Controls whether analytics runs and whether it waits for a consent decision before persisting a visitor ID or sending events.
|
|
82
|
+
*/
|
|
83
|
+
export type CreateClientAnalyticsOptions = {
|
|
84
|
+
/**
|
|
85
|
+
* Whether the analytics module is enabled.
|
|
86
|
+
*
|
|
87
|
+
* When `false`, {@linkcode AnalyticsModule.track | track()} is a no-op and no automatic events are sent, regardless of consent status.
|
|
88
|
+
*
|
|
89
|
+
* @defaultValue `true`
|
|
90
|
+
*/
|
|
91
|
+
enabled?: boolean;
|
|
92
|
+
/**
|
|
93
|
+
* Initial consent status for analytics.
|
|
94
|
+
*
|
|
95
|
+
* Defaults to `"granted"`, which preserves the SDK's original behavior: analytics starts as soon as the client is created.
|
|
96
|
+
*
|
|
97
|
+
* Set to `"pending"` when your app needs a consent decision (for example, from a cookie banner) before tracking starts. The client is still created immediately, but analytics stays dormant — no visitor ID is written to `localStorage`, no automatic events fire, and tracked events are buffered in memory. Call {@linkcode AnalyticsModule.optIn | optIn()} once consent is granted, or {@linkcode AnalyticsModule.optOut | optOut()} if it's refused.
|
|
98
|
+
*
|
|
99
|
+
* When multiple clients are created on the same page, the most restrictive explicitly-configured consent status wins (`"denied"` over `"pending"` over `"granted"`).
|
|
100
|
+
*
|
|
101
|
+
* @defaultValue `"granted"`
|
|
102
|
+
*/
|
|
103
|
+
consent?: AnalyticsConsentStatus;
|
|
104
|
+
};
|
|
70
105
|
/**
|
|
71
106
|
* Analytics module for tracking custom events in your app.
|
|
72
107
|
*
|
|
@@ -81,6 +116,10 @@ export type AnalyticsModuleOptions = {
|
|
|
81
116
|
* - Choose clear, descriptive event names in snake_case like `signup_button_click` or `purchase_completed` rather than generic names like `click`.
|
|
82
117
|
* - Include relevant context in your properties such as identifiers like `product_id`, measurements like `price`, and flags like `is_first_purchase`.
|
|
83
118
|
*
|
|
119
|
+
* ## Consent
|
|
120
|
+
*
|
|
121
|
+
* Apps that need a consent decision (for example, from a cookie banner) before tracking starts can create the client with `analytics: { consent: 'pending' }` and then call {@linkcode optIn | optIn()} or {@linkcode optOut | optOut()} once the visitor decides. See {@linkcode CreateClientAnalyticsOptions} for details.
|
|
122
|
+
*
|
|
84
123
|
* ## Authentication Modes
|
|
85
124
|
*
|
|
86
125
|
* This module is only available in user authentication mode (`base44.analytics`).
|
|
@@ -119,4 +158,56 @@ export interface AnalyticsModule {
|
|
|
119
158
|
* ```
|
|
120
159
|
*/
|
|
121
160
|
track(params: TrackEventParams): void;
|
|
161
|
+
/**
|
|
162
|
+
* Grants analytics consent and activates tracking.
|
|
163
|
+
*
|
|
164
|
+
* Use this after the visitor accepts analytics in your consent flow (for example, a cookie banner). It sets the consent status to `"granted"`, persists the visitor ID, starts automatic events, and delivers any events buffered while consent was `"pending"`.
|
|
165
|
+
*
|
|
166
|
+
* The visitor keeps a single identity across the consent grant: the temporary in-memory ID used before consent is adopted as the persistent ID.
|
|
167
|
+
*
|
|
168
|
+
* Calling this when consent is already `"granted"` has no effect.
|
|
169
|
+
*
|
|
170
|
+
* @example
|
|
171
|
+
* ```typescript
|
|
172
|
+
* // Create the client without tracking, then activate it once the
|
|
173
|
+
* // visitor accepts analytics in your consent banner.
|
|
174
|
+
* const base44 = createClient({
|
|
175
|
+
* appId: 'my-app-id',
|
|
176
|
+
* analytics: { consent: 'pending' }
|
|
177
|
+
* });
|
|
178
|
+
*
|
|
179
|
+
* onConsentBannerAccept(() => {
|
|
180
|
+
* base44.analytics.optIn();
|
|
181
|
+
* });
|
|
182
|
+
* ```
|
|
183
|
+
*/
|
|
184
|
+
optIn(): void;
|
|
185
|
+
/**
|
|
186
|
+
* Revokes analytics consent and deactivates tracking.
|
|
187
|
+
*
|
|
188
|
+
* Use this when the visitor declines analytics in your consent flow, or withdraws consent later. It sets the consent status to `"denied"`, stops automatic events, discards any buffered events, and removes the persistent visitor ID from `localStorage`.
|
|
189
|
+
*
|
|
190
|
+
* Tracking can be re-enabled later with {@linkcode optIn | optIn()}.
|
|
191
|
+
*
|
|
192
|
+
* @example
|
|
193
|
+
* ```typescript
|
|
194
|
+
* onConsentBannerDecline(() => {
|
|
195
|
+
* base44.analytics.optOut();
|
|
196
|
+
* });
|
|
197
|
+
* ```
|
|
198
|
+
*/
|
|
199
|
+
optOut(): void;
|
|
200
|
+
/**
|
|
201
|
+
* Gets the current analytics consent status.
|
|
202
|
+
*
|
|
203
|
+
* @returns The current consent status: `"granted"`, `"denied"`, or `"pending"`.
|
|
204
|
+
*
|
|
205
|
+
* @example
|
|
206
|
+
* ```typescript
|
|
207
|
+
* if (base44.analytics.getConsentStatus() === 'pending') {
|
|
208
|
+
* showConsentBanner();
|
|
209
|
+
* }
|
|
210
|
+
* ```
|
|
211
|
+
*/
|
|
212
|
+
getConsentStatus(): AnalyticsConsentStatus;
|
|
122
213
|
}
|
|
@@ -132,8 +132,10 @@ 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"
|
|
136
|
-
|
|
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.
|