@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 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); PartySocket needs an
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: () => {
@@ -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
  */
@@ -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 so the
5
- * platform proxy can authenticate the connection; anonymous connects omit it. */
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 PartySocket dials (it strips the scheme and connects wss, ws
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
- * Absolute host for the actor WebSocket. PartySocket needs an absolute host and
16
- * can't resolve a relative/empty `serverUrl` (same-origin apps use a relative
17
- * `/api`, so `serverUrl` is often `""`), so fall back to the page origin.
18
- * PartySocket handles the scheme (https→wss, ws for localhost).
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): {
@@ -1,8 +1,32 @@
1
- import PartySocket from "partysocket";
2
- // 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
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
- const ws = new PartySocket({
19
- host: config.host,
20
- party: actorName,
21
- room: instanceId,
22
- id: this.id,
23
- // Re-read on every (re)connect so a login/logout is picked up.
24
- query: () => {
25
- const token = config.getAuthToken();
26
- return {
27
- app_id: config.appId,
28
- handler: actorName,
29
- ...(token ? { token } : {}),
30
- ...(config.functionsVersion ? { fv: config.functionsVersion } : {}),
31
- };
32
- },
33
- });
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
- ws.reconnect();
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
- * Absolute host for the actor WebSocket. PartySocket needs an absolute host and
108
- * can't resolve a relative/empty `serverUrl` (same-origin apps use a relative
109
- * `/api`, so `serverUrl` is often `""`), so fall back to the page origin.
110
- * PartySocket handles the scheme (https→wss, ws for localhost).
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
- /** 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
+ */
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
- /** Open the WebSocket and return the {@link Connection}. Idempotent. */
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
- export declare const createAnalyticsModule: ({ axiosClient, serverUrl, appId, userAuthModule, }: AnalyticsModuleArgs) => {
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
- export const createAnalyticsModule = ({ axiosClient, serverUrl, appId, userAuthModule, }) => {
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) || isReactNative) {
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
- startProcessing();
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
- const cleanup = () => {
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
- // start the flusing process ///
134
- startProcessing();
135
- // start the heart beat processor //
136
- clearHeartBeatProcessor = startHeartBeatProcessor(track);
137
- // track the referrer event //
138
- trackInitializationEvent(track);
139
- // start the visibility change listener //
140
- if (typeof window !== "undefined") {
141
- window.addEventListener("visibilitychange", onVisibilityChange);
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
- pageUrl: typeof window !== "undefined" ? window.location.pathname : null,
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 generateUuid();
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
- const newSessionId = generateUuid();
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 (_a) {
319
- return generateUuid();
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" && 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@base44-preview/sdk",
3
- "version": "0.8.44-pr.267.4549331",
3
+ "version": "0.8.44-pr.270.91862eb",
4
4
  "description": "JavaScript SDK for Base44 API",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",