@base44-preview/sdk 0.8.48-pr.280.2c0d8bc → 0.8.48-pr.280.7eec9ba

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
@@ -16,6 +16,7 @@ import { RoomsSocket } from "./utils/socket-utils.js";
16
16
  import { createAnalyticsModule } from "./modules/analytics.js";
17
17
  import { createExperimentsModule } from "./modules/experiments.js";
18
18
  import { createExposureTracker } from "./modules/experiment-exposures.js";
19
+ import { EXPERIMENTS_CONTEXT_HEADER, getBrowserExperimentsContext, readExperimentsContext } from "./modules/experiments-context.js";
19
20
  import { createActorsModule, resolveActorsHost, } from "./modules/actors.js";
20
21
  /**
21
22
  * Creates a Base44 client.
@@ -55,10 +56,11 @@ import { createActorsModule, resolveActorsHost, } from "./modules/actors.js";
55
56
  * ```
56
57
  */
57
58
  export function createClient(config) {
58
- var _a, _b, _c, _d, _e;
59
+ var _a, _b, _c, _d, _e, _f, _g;
59
60
  const { serverUrl = "https://base44.app", appId, analytics, token, serviceToken, requiresAuth = false, appBaseUrl, options, functionsVersion, headers: optionalHeaders, } = config;
60
61
  // Normalize appBaseUrl to always be a string (empty if not provided or invalid)
61
62
  const normalizedAppBaseUrl = typeof appBaseUrl === "string" ? appBaseUrl : "";
63
+ const experimentsContext = (_a = config.experiments) !== null && _a !== void 0 ? _a : getBrowserExperimentsContext(appId);
62
64
  const socketConfig = {
63
65
  serverUrl,
64
66
  mountPath: "/ws-user-apps/socket.io/",
@@ -75,9 +77,14 @@ export function createClient(config) {
75
77
  }
76
78
  return socket;
77
79
  };
80
+ const { [EXPERIMENTS_CONTEXT_HEADER]: inheritedExperimentsContext, ...requestHeaders } = optionalHeaders !== null && optionalHeaders !== void 0 ? optionalHeaders : {};
78
81
  const headers = {
79
- ...optionalHeaders,
82
+ ...requestHeaders,
80
83
  "X-App-Id": String(appId),
84
+ ...(experimentsContext ? {
85
+ "Base44-Visitor-Id": experimentsContext.identity.visitorId,
86
+ "Base44-Experiment-Preview": JSON.stringify((_b = experimentsContext.preview) !== null && _b !== void 0 ? _b : {}),
87
+ } : {}),
81
88
  };
82
89
  const functionHeaders = functionsVersion
83
90
  ? {
@@ -123,13 +130,18 @@ export function createClient(config) {
123
130
  baseURL: `${serverUrl}/api`,
124
131
  headers,
125
132
  });
133
+ const exposureTracker = createExposureTracker({
134
+ axiosClient,
135
+ appId,
136
+ enabled: (_c = analytics === null || analytics === void 0 ? void 0 : analytics.enabled) !== null && _c !== void 0 ? _c : true,
137
+ source: typeof window === "undefined" ? "backend" : "browser",
138
+ pageUrl: experimentsContext === null || experimentsContext === void 0 ? void 0 : experimentsContext.pageUrl,
139
+ });
126
140
  const experiments = createExperimentsModule({
127
141
  getAuth: () => userAuthModule,
128
- trackExposure: createExposureTracker({
129
- axiosClient,
130
- appId,
131
- enabled: (_a = analytics === null || analytics === void 0 ? void 0 : analytics.enabled) !== null && _a !== void 0 ? _a : true,
132
- }).track,
142
+ trackExposure: exposureTracker.track,
143
+ flushExposures: exposureTracker.flush,
144
+ context: experimentsContext,
133
145
  });
134
146
  const userAuthModule = createAuthModule(axiosClient, functionsAxiosClient, appId, {
135
147
  appBaseUrl: normalizedAppBaseUrl,
@@ -147,11 +159,19 @@ export function createClient(config) {
147
159
  userAuthModule.setToken(accessToken);
148
160
  }
149
161
  }
162
+ if (experimentsContext) {
163
+ const { userId, status } = experimentsContext.identity;
164
+ // The document's cookie identity may differ from this client's localStorage token.
165
+ const needsClientIdentity = typeof window !== "undefined" && userAuthModule.hasToken() &&
166
+ experimentsContext.config.experiments.some((experiment) => experiment.assign_by === "user");
167
+ experiments.onAuthStateChange(status === "pending" || needsClientIdentity ? { status: "pending" } :
168
+ userId ? { status: "authenticated", userId } : { status: "anonymous" });
169
+ }
150
170
  const actorsModule = createActorsModule({
151
171
  appId,
152
172
  // serverUrl is often relative/empty (same-origin app); the proxy-fallback
153
173
  // URL needs an absolute host, so fall back to the page origin.
154
- host: resolveActorsHost(serverUrl, typeof window !== "undefined" ? (_b = window.location) === null || _b === void 0 ? void 0 : _b.origin : undefined),
174
+ host: resolveActorsHost(serverUrl, typeof window !== "undefined" ? (_d = window.location) === null || _d === void 0 ? void 0 : _d.origin : undefined),
155
175
  functionsVersion,
156
176
  getAuthToken: () => token || getAccessToken(),
157
177
  mintConnectionToken: async (actorName, room, connectionId) => {
@@ -190,7 +210,7 @@ export function createClient(config) {
190
210
  }
191
211
  return headers;
192
212
  },
193
- baseURL: (_c = functionsAxiosClient.defaults) === null || _c === void 0 ? void 0 : _c.baseURL,
213
+ baseURL: (_e = functionsAxiosClient.defaults) === null || _e === void 0 ? void 0 : _e.baseURL,
194
214
  }),
195
215
  agents: createAgentsModule({
196
216
  axios: axiosClient,
@@ -208,7 +228,9 @@ export function createClient(config) {
208
228
  serverUrl,
209
229
  appId,
210
230
  userAuthModule,
211
- enabled: (_d = analytics === null || analytics === void 0 ? void 0 : analytics.enabled) !== null && _d !== void 0 ? _d : true,
231
+ enabled: (_f = analytics === null || analytics === void 0 ? void 0 : analytics.enabled) !== null && _f !== void 0 ? _f : true,
232
+ getVisitorId: experiments.visitorId,
233
+ experimentsContext,
212
234
  }),
213
235
  actors: actorsModule.module,
214
236
  cleanup: () => {
@@ -238,7 +260,7 @@ export function createClient(config) {
238
260
  }
239
261
  return headers;
240
262
  },
241
- baseURL: (_e = serviceRoleFunctionsAxiosClient.defaults) === null || _e === void 0 ? void 0 : _e.baseURL,
263
+ baseURL: (_g = serviceRoleFunctionsAxiosClient.defaults) === null || _g === void 0 ? void 0 : _g.baseURL,
242
264
  }),
243
265
  agents: createAgentsModule({
244
266
  axios: serviceRoleAxiosClient,
@@ -281,7 +303,10 @@ export function createClient(config) {
281
303
  appId: String(appId),
282
304
  serverUrl,
283
305
  functionsVersion,
284
- platformHeaders: optionalHeaders,
306
+ platformHeaders: {
307
+ ...headers,
308
+ ...(inheritedExperimentsContext ? { [EXPERIMENTS_CONTEXT_HEADER]: inheritedExperimentsContext } : {}),
309
+ },
285
310
  }),
286
311
  /**
287
312
  * Sets a new authentication token for all subsequent requests.
@@ -433,6 +458,10 @@ export function createClientFromRequest(request) {
433
458
  }
434
459
  // Prepare additional headers to propagate
435
460
  const additionalHeaders = {};
461
+ const encodedExperiments = request.headers.get(EXPERIMENTS_CONTEXT_HEADER);
462
+ const experimentsContext = readExperimentsContext(encodedExperiments, appId);
463
+ if (experimentsContext && encodedExperiments)
464
+ additionalHeaders[EXPERIMENTS_CONTEXT_HEADER] = encodedExperiments;
436
465
  if (stateHeader) {
437
466
  additionalHeaders["Base44-State"] = stateHeader;
438
467
  }
@@ -453,5 +482,6 @@ export function createClientFromRequest(request) {
453
482
  serviceToken: serviceRoleToken,
454
483
  functionsVersion: functionsVersion !== null && functionsVersion !== void 0 ? functionsVersion : undefined,
455
484
  headers: additionalHeaders,
485
+ experiments: experimentsContext ? { ...experimentsContext, pageUrl: request.url ? new URL(request.url).pathname : "/" } : undefined,
456
486
  });
457
487
  }
@@ -10,6 +10,7 @@ import type { AppLogsModule } from "./modules/app-logs.types.js";
10
10
  import type { AppModule } from "./modules/app.types.js";
11
11
  import type { AnalyticsModule } from "./modules/analytics.types.js";
12
12
  import type { ExperimentsModule } from "./modules/experiments.types.js";
13
+ import type { ExperimentsContext } from "./modules/experiments-config.types.js";
13
14
  import type { ActorsModule } from "./modules/actors.types.js";
14
15
  import type { FetchWithAuthInit } from "./utils/fetch-with-auth.js";
15
16
  /**
@@ -80,6 +81,12 @@ export interface CreateClientConfig {
80
81
  * Omit this option to preserve the default analytics behavior.
81
82
  */
82
83
  analytics?: CreateClientAnalyticsConfig;
84
+ /**
85
+ * Platform-validated context for local flag evaluation. Request-scoped on servers.
86
+ * Automatically read from the platform bootstrap in browsers and trusted headers
87
+ * by createClientFromRequest(). Not an authorization credential.
88
+ */
89
+ experiments?: ExperimentsContext;
83
90
  /**
84
91
  * User authentication token. Used to authenticate as a specific user.
85
92
  *
@@ -135,7 +142,7 @@ export interface Base44Client {
135
142
  connectors: UserConnectorsModule;
136
143
  /** {@link EntitiesModule | Entities module} for CRUD operations on your data models. */
137
144
  entities: EntitiesModule;
138
- /** {@link ExperimentsModule | Experiments module} for browser feature flags and exposures. */
145
+ /** {@link ExperimentsModule | Experiments module} for local feature flags and exposures. */
139
146
  experiments: ExperimentsModule;
140
147
  /** {@link FunctionsModule | Functions module} for invoking custom backend functions. */
141
148
  functions: FunctionsModule;
package/dist/index.d.ts CHANGED
@@ -4,6 +4,8 @@ import { getAccessToken, saveAccessToken, removeAccessToken, getLoginUrl } from
4
4
  export { createClient, createClientFromRequest, Base44Error, getAccessToken, saveAccessToken, removeAccessToken, getLoginUrl, };
5
5
  export type { Base44Client, CreateClientAnalyticsConfig, CreateClientConfig, CreateClientOptions, Base44ErrorJSON, };
6
6
  export * from "./types.js";
7
+ export { evaluateExperiments } from "./modules/experiments-evaluator.js";
8
+ export type { ExperimentsConfig, ExperimentsContext, ExperimentsIdentity } from "./modules/experiments-config.types.js";
7
9
  export type { ExperimentsModule, ExperimentsSnapshot, } from "./modules/experiments.types.js";
8
10
  export type { DeleteManyResult, DeleteResult, EntitiesModule, EntityFilterOperators, EntityFilterQuery, EntityFilterValue, EntityHandler, EntityRecord, EntityTypeRegistry, ImportResult, RealtimeEventType, RealtimeEvent, RealtimeCallback, SortField, UpdateManyResult, } from "./modules/entities.types.js";
9
11
  export type { AuthModule, LoginResponse, RegisterParams, VerifyOtpParams, ChangePasswordParams, ResetPasswordParams, User, } from "./modules/auth.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 { evaluateExperiments } from "./modules/experiments-evaluator.js";
6
7
  export { Actor } from "./actor.js";
@@ -1,6 +1,7 @@
1
1
  import { AxiosInstance } from "axios";
2
- import { TrackEventParams, AnalyticsModuleOptions } from "./analytics.types";
2
+ import { TrackEventParams, TrackEventData, AnalyticsModuleOptions, SessionContext } from "./analytics.types";
3
3
  import type { InternalAuthModule } from "./auth.types";
4
+ import type { ExperimentsContext } from "./experiments-config.types.js";
4
5
  export declare const USER_HEARTBEAT_EVENT_NAME = "__user_heartbeat_event__";
5
6
  export declare const ANALYTICS_INITIALIZATION_EVENT_NAME = "__initialization_event__";
6
7
  export declare const ANALYTICS_SESSION_DURATION_EVENT_NAME = "__session_duration_event__";
@@ -12,10 +13,22 @@ export interface AnalyticsModuleArgs {
12
13
  appId: string;
13
14
  userAuthModule: InternalAuthModule;
14
15
  enabled: boolean;
16
+ getVisitorId?: () => string | undefined;
17
+ experimentsContext?: ExperimentsContext;
15
18
  }
16
19
  /** @internal */
17
- export declare function isAnalyticsEnabled(enabled: boolean): boolean;
18
- export declare const createAnalyticsModule: ({ axiosClient, serverUrl, appId, userAuthModule, enabled, }: AnalyticsModuleArgs) => {
20
+ export declare function isAnalyticsEnabled(enabled: boolean, state?: {
21
+ requestsQueue: TrackEventData[];
22
+ isProcessing: boolean;
23
+ isHeartBeatProcessing: boolean;
24
+ wasInitializationTracked: boolean;
25
+ sessionContext: SessionContext | null;
26
+ sessionContextPromise: Promise<SessionContext> | null;
27
+ sessionStartTime: string | null;
28
+ fallbackSessionId: string | null;
29
+ config: Required<AnalyticsModuleOptions>;
30
+ }): boolean;
31
+ export declare const createAnalyticsModule: ({ axiosClient, serverUrl, appId, userAuthModule, enabled, getVisitorId, experimentsContext, }: AnalyticsModuleArgs) => {
19
32
  track: (params: TrackEventParams) => void;
20
33
  cleanup: () => void;
21
34
  };
@@ -29,6 +42,16 @@ export declare const createAnalyticsModule: ({ axiosClient, serverUrl, appId, us
29
42
  *
30
43
  * @internal
31
44
  */
32
- export declare function resetAnalyticsSessionContext(): void;
45
+ export declare function resetAnalyticsSessionContext(axiosClient?: AxiosInstance): void;
33
46
  export declare function getAnalyticsConfigFromUrlParams(): AnalyticsModuleOptions | undefined;
34
- export declare function getAnalyticsSessionId(): string;
47
+ export declare function getAnalyticsSessionId(state?: {
48
+ requestsQueue: TrackEventData[];
49
+ isProcessing: boolean;
50
+ isHeartBeatProcessing: boolean;
51
+ wasInitializationTracked: boolean;
52
+ sessionContext: SessionContext | null;
53
+ sessionContextPromise: Promise<SessionContext> | null;
54
+ sessionStartTime: string | null;
55
+ fallbackSessionId: string | null;
56
+ config: Required<AnalyticsModuleOptions>;
57
+ }): string;
@@ -1,5 +1,6 @@
1
1
  import { getSharedInstance } from "../utils/sharedInstance.js";
2
2
  import { generateUuid, isReactNative } from "../utils/common.js";
3
+ import { getExperimentsRuntime } from "./experiments-runtime.types.js";
3
4
  export const USER_HEARTBEAT_EVENT_NAME = "__user_heartbeat_event__";
4
5
  export const ANALYTICS_INITIALIZATION_EVENT_NAME = "__initialization_event__";
5
6
  export const ANALYTICS_SESSION_DURATION_EVENT_NAME = "__session_duration_event__";
@@ -17,34 +18,41 @@ const defaultConfiguration = {
17
18
  //// shared queue for analytics events ////
18
19
  ///////////////////////////////////////////////
19
20
  const ANALYTICS_SHARED_STATE_NAME = "analytics";
20
- // shared state//
21
- const analyticsSharedState = getSharedInstance(ANALYTICS_SHARED_STATE_NAME, () => ({
22
- requestsQueue: [],
23
- isProcessing: false,
24
- isHeartBeatProcessing: false,
25
- wasInitializationTracked: false,
26
- sessionContext: null,
27
- sessionStartTime: null,
28
- // Memoized session id for when `localStorage` can't persist one — see
29
- // getAnalyticsSessionId.
30
- fallbackSessionId: null,
31
- config: {
32
- ...defaultConfiguration,
33
- ...getAnalyticsConfigFromUrlParams(),
34
- },
35
- }));
21
+ function createAnalyticsState() {
22
+ return {
23
+ requestsQueue: [],
24
+ isProcessing: false,
25
+ isHeartBeatProcessing: false,
26
+ wasInitializationTracked: false,
27
+ sessionContext: null,
28
+ sessionContextPromise: null,
29
+ sessionStartTime: null,
30
+ // Memoized session id for when `localStorage` can't persist one — see
31
+ // getAnalyticsSessionId.
32
+ fallbackSessionId: null,
33
+ config: {
34
+ ...defaultConfiguration,
35
+ ...getAnalyticsConfigFromUrlParams(),
36
+ },
37
+ };
38
+ }
39
+ const analyticsSharedState = getSharedInstance(ANALYTICS_SHARED_STATE_NAME, createAnalyticsState);
40
+ const serverAnalyticsStates = new WeakMap();
36
41
  /** @internal */
37
- export function isAnalyticsEnabled(enabled) {
38
- return enabled && analyticsSharedState.config.enabled && !isReactNative;
42
+ export function isAnalyticsEnabled(enabled, state = analyticsSharedState) {
43
+ return enabled && state.config.enabled && !isReactNative;
39
44
  }
40
- export const createAnalyticsModule = ({ axiosClient, serverUrl, appId, userAuthModule, enabled, }) => {
45
+ export const createAnalyticsModule = ({ axiosClient, serverUrl, appId, userAuthModule, enabled, getVisitorId, experimentsContext, }) => {
46
+ const state = typeof window === "undefined" ? createAnalyticsState() : analyticsSharedState;
47
+ if (typeof window === "undefined")
48
+ serverAnalyticsStates.set(axiosClient, state);
41
49
  // prevent overflow of events //
42
- const { maxQueueSize, throttleTime, batchSize } = analyticsSharedState.config;
50
+ const { maxQueueSize, throttleTime, batchSize } = state.config;
43
51
  // Disable analytics on React Native. It defines `window` but not `document`,
44
52
  // so the per-callsite `typeof window` guards below aren't enough to keep it
45
53
  // from touching `document` (e.g. `document.referrer` on init). Node/SSR is
46
54
  // still handled by those `window` guards, so this doesn't affect it.
47
- if (!isAnalyticsEnabled(enabled)) {
55
+ if (!isAnalyticsEnabled(enabled, state)) {
48
56
  return {
49
57
  track: () => { },
50
58
  cleanup: () => { },
@@ -73,16 +81,17 @@ export const createAnalyticsModule = ({ axiosClient, serverUrl, appId, userAuthM
73
81
  }
74
82
  };
75
83
  const flush = async (eventsData, options = {}) => {
84
+ var _a;
76
85
  if (eventsData.length === 0)
77
86
  return;
78
- const sessionContext_ = await getSessionContext(userAuthModule);
79
- const events = eventsData.map(transformEventDataToApiRequestData(sessionContext_));
87
+ const sessionContext_ = await getSessionContext(userAuthModule, state);
88
+ const events = eventsData.map(transformEventDataToApiRequestData({ ...sessionContext_, session_id: (_a = getVisitorId === null || getVisitorId === void 0 ? void 0 : getVisitorId()) !== null && _a !== void 0 ? _a : sessionContext_.session_id }));
80
89
  try {
81
90
  if (!options.isBeacon || !beaconRequest(events)) {
82
91
  await batchRequestFallback(events);
83
92
  }
84
93
  }
85
- catch (_a) {
94
+ catch (_b) {
86
95
  // do nothing
87
96
  }
88
97
  };
@@ -90,16 +99,25 @@ export const createAnalyticsModule = ({ axiosClient, serverUrl, appId, userAuthM
90
99
  startAnalyticsProcessor(flush, {
91
100
  throttleTime,
92
101
  batchSize,
93
- });
102
+ }, state);
94
103
  };
95
104
  const track = (params) => {
96
- if (analyticsSharedState.requestsQueue.length >= maxQueueSize) {
105
+ var _a;
106
+ if (state.requestsQueue.length >= maxQueueSize) {
97
107
  return;
98
108
  }
99
109
  const intrinsicData = getEventIntrinsicData();
100
- analyticsSharedState.requestsQueue.push({
110
+ const preview = Object.fromEntries(Object.entries((_a = experimentsContext === null || experimentsContext === void 0 ? void 0 : experimentsContext.preview) !== null && _a !== void 0 ? _a : {}).filter(([, value]) => typeof value === "boolean"));
111
+ const properties = { ...params.properties };
112
+ delete properties.__b44_experiment_preview;
113
+ if (Object.keys(preview).length) {
114
+ // Capture now: a queued event must retain its occurrence-time preview.
115
+ properties.__b44_experiment_preview = JSON.stringify(preview);
116
+ }
117
+ state.requestsQueue.push({
101
118
  ...params,
102
119
  ...intrinsicData,
120
+ properties: params.properties || Object.keys(properties).length ? properties : undefined,
103
121
  });
104
122
  startProcessing();
105
123
  };
@@ -107,16 +125,16 @@ export const createAnalyticsModule = ({ axiosClient, serverUrl, appId, userAuthM
107
125
  startAnalyticsProcessor(flush, {
108
126
  throttleTime,
109
127
  batchSize,
110
- });
111
- clearHeartBeatProcessor = startHeartBeatProcessor(track);
112
- setSessionDurationTimerStart();
128
+ }, state);
129
+ clearHeartBeatProcessor = startHeartBeatProcessor(track, state);
130
+ setSessionDurationTimerStart(state);
113
131
  };
114
132
  const onDocHidden = () => {
115
- stopAnalyticsProcessor();
133
+ stopAnalyticsProcessor(state);
116
134
  clearHeartBeatProcessor === null || clearHeartBeatProcessor === void 0 ? void 0 : clearHeartBeatProcessor();
117
- trackSessionDurationEvent(track);
135
+ trackSessionDurationEvent(track, state);
118
136
  // flush entire queue on visibility change and hope for the best //
119
- const eventsData = analyticsSharedState.requestsQueue.splice(0);
137
+ const eventsData = state.requestsQueue.splice(0);
120
138
  flush(eventsData, { isBeacon: true });
121
139
  };
122
140
  const onVisibilityChange = () => {
@@ -130,7 +148,7 @@ export const createAnalyticsModule = ({ axiosClient, serverUrl, appId, userAuthM
130
148
  }
131
149
  };
132
150
  const cleanup = () => {
133
- stopAnalyticsProcessor();
151
+ stopAnalyticsProcessor(state);
134
152
  clearHeartBeatProcessor === null || clearHeartBeatProcessor === void 0 ? void 0 : clearHeartBeatProcessor();
135
153
  if (typeof window !== "undefined") {
136
154
  window.removeEventListener("visibilitychange", onVisibilityChange);
@@ -139,9 +157,9 @@ export const createAnalyticsModule = ({ axiosClient, serverUrl, appId, userAuthM
139
157
  // start the flusing process ///
140
158
  startProcessing();
141
159
  // start the heart beat processor //
142
- clearHeartBeatProcessor = startHeartBeatProcessor(track);
160
+ clearHeartBeatProcessor = startHeartBeatProcessor(track, state);
143
161
  // track the referrer event //
144
- trackInitializationEvent(track);
162
+ trackInitializationEvent(track, state);
145
163
  // start the visibility change listener //
146
164
  if (typeof window !== "undefined") {
147
165
  window.addEventListener("visibilitychange", onVisibilityChange);
@@ -151,50 +169,50 @@ export const createAnalyticsModule = ({ axiosClient, serverUrl, appId, userAuthM
151
169
  cleanup,
152
170
  };
153
171
  };
154
- function stopAnalyticsProcessor() {
155
- analyticsSharedState.isProcessing = false;
172
+ function stopAnalyticsProcessor(state) {
173
+ state.isProcessing = false;
156
174
  }
157
- async function startAnalyticsProcessor(handleTrack, options) {
158
- if (analyticsSharedState.isProcessing) {
175
+ async function startAnalyticsProcessor(handleTrack, options, state) {
176
+ if (state.isProcessing) {
159
177
  // only one instance of the analytics processor can be running at a time //
160
178
  return;
161
179
  }
162
- analyticsSharedState.isProcessing = true;
180
+ state.isProcessing = true;
163
181
  const { throttleTime = 1000, batchSize = 30 } = options !== null && options !== void 0 ? options : {};
164
- while (analyticsSharedState.isProcessing &&
165
- analyticsSharedState.requestsQueue.length > 0) {
166
- const requests = analyticsSharedState.requestsQueue.splice(0, batchSize);
182
+ while (state.isProcessing &&
183
+ state.requestsQueue.length > 0) {
184
+ const requests = state.requestsQueue.splice(0, batchSize);
167
185
  requests.length && (await handleTrack(requests));
168
186
  await new Promise((resolve) => setTimeout(resolve, throttleTime));
169
187
  }
170
- analyticsSharedState.isProcessing = false;
188
+ state.isProcessing = false;
171
189
  }
172
- function startHeartBeatProcessor(track) {
190
+ function startHeartBeatProcessor(track, state) {
173
191
  var _a;
174
192
  // Browser-only, like the other automatic events here (initialization, session
175
193
  // duration, visibility). Outside a browser this timer fired a `me()` every
176
194
  // interval for the lifetime of a long-lived server-side client, and kept the
177
195
  // Node event loop alive. Explicit `analytics.track()` calls still work.
178
196
  if (typeof window === "undefined" ||
179
- analyticsSharedState.isHeartBeatProcessing ||
180
- ((_a = analyticsSharedState.config.heartBeatInterval) !== null && _a !== void 0 ? _a : 0) < 10) {
197
+ state.isHeartBeatProcessing ||
198
+ ((_a = state.config.heartBeatInterval) !== null && _a !== void 0 ? _a : 0) < 10) {
181
199
  return () => { };
182
200
  }
183
- analyticsSharedState.isHeartBeatProcessing = true;
201
+ state.isHeartBeatProcessing = true;
184
202
  const interval = setInterval(() => {
185
203
  track({ eventName: USER_HEARTBEAT_EVENT_NAME });
186
- }, analyticsSharedState.config.heartBeatInterval);
204
+ }, state.config.heartBeatInterval);
187
205
  return () => {
188
206
  clearInterval(interval);
189
- analyticsSharedState.isHeartBeatProcessing = false;
207
+ state.isHeartBeatProcessing = false;
190
208
  };
191
209
  }
192
- function trackInitializationEvent(track) {
210
+ function trackInitializationEvent(track, state) {
193
211
  if (typeof window === "undefined" ||
194
- analyticsSharedState.wasInitializationTracked) {
212
+ state.wasInitializationTracked) {
195
213
  return;
196
214
  }
197
- analyticsSharedState.wasInitializationTracked = true;
215
+ state.wasInitializationTracked = true;
198
216
  track({
199
217
  eventName: ANALYTICS_INITIALIZATION_EVENT_NAME,
200
218
  properties: {
@@ -202,20 +220,20 @@ function trackInitializationEvent(track) {
202
220
  },
203
221
  });
204
222
  }
205
- function setSessionDurationTimerStart() {
223
+ function setSessionDurationTimerStart(state) {
206
224
  if (typeof window === "undefined" ||
207
- analyticsSharedState.sessionStartTime !== null) {
225
+ state.sessionStartTime !== null) {
208
226
  return;
209
227
  }
210
- analyticsSharedState.sessionStartTime = new Date().toISOString();
228
+ state.sessionStartTime = new Date().toISOString();
211
229
  }
212
- function trackSessionDurationEvent(track) {
230
+ function trackSessionDurationEvent(track, state) {
213
231
  if (typeof window === "undefined" ||
214
- analyticsSharedState.sessionStartTime === null)
232
+ state.sessionStartTime === null)
215
233
  return;
216
234
  const sessionDuration = new Date().getTime() -
217
- new Date(analyticsSharedState.sessionStartTime).getTime();
218
- analyticsSharedState.sessionStartTime = null;
235
+ new Date(state.sessionStartTime).getTime();
236
+ state.sessionStartTime = null;
219
237
  track({
220
238
  eventName: ANALYTICS_SESSION_DURATION_EVENT_NAME,
221
239
  properties: { sessionDuration },
@@ -238,7 +256,6 @@ function transformEventDataToApiRequestData(sessionContext) {
238
256
  ...sessionContext,
239
257
  });
240
258
  }
241
- let sessionContextPromise = null;
242
259
  /**
243
260
  * Clears the memoized analytics session context.
244
261
  *
@@ -249,22 +266,24 @@ let sessionContextPromise = null;
249
266
  *
250
267
  * @internal
251
268
  */
252
- export function resetAnalyticsSessionContext() {
253
- analyticsSharedState.sessionContext = null;
254
- sessionContextPromise = null;
269
+ export function resetAnalyticsSessionContext(axiosClient) {
270
+ var _a;
271
+ const state = axiosClient ? (_a = serverAnalyticsStates.get(axiosClient)) !== null && _a !== void 0 ? _a : analyticsSharedState : analyticsSharedState;
272
+ state.sessionContext = null;
273
+ state.sessionContextPromise = null;
255
274
  }
256
- async function getSessionContext(userAuthModule) {
257
- if (!analyticsSharedState.sessionContext) {
275
+ async function getSessionContext(userAuthModule, state) {
276
+ if (!state.sessionContext) {
258
277
  // With no token there is no identity to resolve: `me()` can only answer 401,
259
278
  // which the browser logs to the console before any handler here sees it. On
260
279
  // a public page that request is the sole reason an error appears, so skip
261
280
  // it. This is not memoized — a visitor who logs in later must still resolve.
262
281
  if (!userAuthModule.hasToken()) {
263
- return { user_id: null, session_id: getAnalyticsSessionId() };
282
+ return { user_id: null, session_id: getAnalyticsSessionId(state) };
264
283
  }
265
- if (!sessionContextPromise) {
266
- const sessionId = getAnalyticsSessionId();
267
- sessionContextPromise = userAuthModule
284
+ if (!state.sessionContextPromise) {
285
+ const sessionId = getAnalyticsSessionId(state);
286
+ state.sessionContextPromise = userAuthModule
268
287
  .me()
269
288
  .then((user) => ({
270
289
  user_id: user.id,
@@ -275,7 +294,7 @@ async function getSessionContext(userAuthModule) {
275
294
  session_id: sessionId,
276
295
  }));
277
296
  }
278
- const pending = sessionContextPromise;
297
+ const pending = state.sessionContextPromise;
279
298
  const context = await pending;
280
299
  // Publish only if this lookup is still the current one. A reset that lands
281
300
  // while the request is in flight nulls `sessionContextPromise`, and an
@@ -283,12 +302,12 @@ async function getSessionContext(userAuthModule) {
283
302
  // for the rest of the session. The awaited value is still returned: these
284
303
  // events were queued before the identity changed, so that is who they
285
304
  // belong to.
286
- if (sessionContextPromise === pending) {
287
- analyticsSharedState.sessionContext = context;
305
+ if (state.sessionContextPromise === pending) {
306
+ state.sessionContext = context;
288
307
  }
289
308
  return context;
290
309
  }
291
- return analyticsSharedState.sessionContext;
310
+ return state.sessionContext;
292
311
  }
293
312
  export function getAnalyticsConfigFromUrlParams() {
294
313
  // `window.location` is absent on React Native. This runs at module load (via
@@ -310,15 +329,18 @@ export function getAnalyticsConfigFromUrlParams() {
310
329
  // return the config object //
311
330
  return { enabled: analyticsEnable === "true" };
312
331
  }
313
- // When the id can't be persisted (React Native has no `localStorage`), keep
314
- // it stable for the process instead of minting a fresh one per call.
315
- function getFallbackSessionId() {
332
+ // Without persistent storage, keep the id stable within this analytics state.
333
+ function getFallbackSessionId(state) {
316
334
  var _a;
317
- return ((_a = analyticsSharedState.fallbackSessionId) !== null && _a !== void 0 ? _a : (analyticsSharedState.fallbackSessionId = generateUuid()));
335
+ return ((_a = state.fallbackSessionId) !== null && _a !== void 0 ? _a : (state.fallbackSessionId = generateUuid()));
318
336
  }
319
- export function getAnalyticsSessionId() {
337
+ export function getAnalyticsSessionId(state = analyticsSharedState) {
338
+ var _a;
339
+ const visitorId = (_a = getExperimentsRuntime()) === null || _a === void 0 ? void 0 : _a.visitorId;
340
+ if (visitorId && visitorId !== "anon")
341
+ return visitorId;
320
342
  if (typeof window === "undefined") {
321
- return getFallbackSessionId();
343
+ return getFallbackSessionId(state);
322
344
  }
323
345
  try {
324
346
  const sessionId = localStorage.getItem(ANALYTICS_SESSION_ID_LOCAL_STORAGE_KEY);
@@ -329,7 +351,7 @@ export function getAnalyticsSessionId() {
329
351
  }
330
352
  return sessionId;
331
353
  }
332
- catch (_a) {
333
- return getFallbackSessionId();
354
+ catch (_b) {
355
+ return getFallbackSessionId(state);
334
356
  }
335
357
  }
@@ -177,7 +177,7 @@ export function createAuthModule(axios, functionsAxiosClient, appId, options) {
177
177
  // Drop identity resolved under the previous session: a `me()` already in
178
178
  // flight would otherwise resolve into callers that run after the logout.
179
179
  clearPendingMe();
180
- resetAnalyticsSessionContext();
180
+ resetAnalyticsSessionContext(axios);
181
181
  hasAccessToken = false;
182
182
  notifyAuthState({ status: "anonymous" });
183
183
  // Only do the rest if in a browser environment
@@ -207,7 +207,7 @@ export function createAuthModule(axios, functionsAxiosClient, appId, options) {
207
207
  // Same reasoning as in `logout`: the identity changes here, so anything
208
208
  // resolved for the previous one must not be handed to later callers.
209
209
  clearPendingMe();
210
- resetAnalyticsSessionContext();
210
+ resetAnalyticsSessionContext(axios);
211
211
  hasAccessToken = true;
212
212
  // handle token change for axios clients
213
213
  axios.defaults.headers.common["Authorization"] = `Bearer ${token}`;
@@ -239,6 +239,8 @@ export function createAuthModule(axios, functionsAxiosClient, appId, options) {
239
239
  const { access_token, user } = response;
240
240
  if (access_token) {
241
241
  this.setToken(access_token);
242
+ if (typeof (user === null || user === void 0 ? void 0 : user.id) === "string")
243
+ notifyAuthState({ status: "authenticated", userId: user.id });
242
244
  }
243
245
  return {
244
246
  access_token,
@@ -1,9 +1,11 @@
1
1
  import type { AxiosInstance } from "axios";
2
2
  /** @internal */
3
- export declare function createExposureTracker({ axiosClient, appId, enabled, }: {
3
+ export declare function createExposureTracker({ axiosClient, appId, enabled, source, pageUrl, }: {
4
4
  axiosClient: AxiosInstance;
5
5
  appId: string;
6
6
  enabled: boolean;
7
+ source?: "browser" | "backend";
8
+ pageUrl?: string;
7
9
  }): {
8
10
  track(assignment: {
9
11
  experiment_id: string;
@@ -13,4 +15,5 @@ export declare function createExposureTracker({ axiosClient, appId, enabled, }:
13
15
  visitorId: string;
14
16
  userId: string | null;
15
17
  }): void;
18
+ flush(): Promise<void>;
16
19
  };
@@ -1,45 +1,65 @@
1
+ import { v4 as uuid } from "uuid";
1
2
  import { isAnalyticsEnabled } from "./analytics.js";
2
3
  /** @internal */
3
- export function createExposureTracker({ axiosClient, appId, enabled, }) {
4
- const exposed = new Set();
4
+ export function createExposureTracker({ axiosClient, appId, enabled, source = "browser", pageUrl, }) {
5
+ const entries = new Map();
6
+ function send(entry) {
7
+ if (entry.pending)
8
+ return entry.pending;
9
+ const pending = (async () => {
10
+ for (let attempt = 0;; attempt++) {
11
+ try {
12
+ const response = await axiosClient.request({
13
+ method: "POST",
14
+ url: `/apps/${appId}/analytics/track/batch`,
15
+ headers: { Authorization: entry.authorization },
16
+ data: entry.data,
17
+ });
18
+ if (response.accepted !== 1)
19
+ throw new Error("Experiment exposure was not accepted");
20
+ entry.acknowledged = true;
21
+ return;
22
+ }
23
+ catch (error) {
24
+ if (attempt === 2)
25
+ throw error;
26
+ await new Promise((resolve) => setTimeout(resolve, attempt === 0 ? 100 : 500));
27
+ }
28
+ }
29
+ })().finally(() => { entry.pending = undefined; });
30
+ entry.pending = pending;
31
+ // Reads stay synchronous; flush() lets request handlers observe delivery failures.
32
+ void pending.catch(() => { });
33
+ return pending;
34
+ }
5
35
  return {
6
36
  track(assignment, identity) {
7
- var _a;
8
- if (typeof window === "undefined" || !isAnalyticsEnabled(enabled))
37
+ if ((source === "browser" && typeof window === "undefined") || !isAnalyticsEnabled(enabled))
9
38
  return;
10
39
  const { experiment_id, run_version, variant_key } = assignment;
11
- const key = JSON.stringify([
12
- experiment_id,
13
- run_version,
14
- variant_key,
15
- identity.userId,
16
- identity.visitorId,
17
- ]);
18
- if (exposed.has(key))
19
- return;
20
- exposed.add(key);
21
- // Pin the event's auth before an account change can alter Axios defaults.
22
- const authorization = identity.userId
23
- ? ((_a = axiosClient.defaults.headers.common.Authorization) !== null && _a !== void 0 ? _a : null)
24
- : null;
25
- void axiosClient
26
- .request({
27
- method: "POST",
28
- url: `/apps/${appId}/analytics/track/batch`,
29
- headers: { Authorization: authorization },
30
- data: {
31
- events: [
32
- {
33
- event_name: "__experiment_exposure__",
34
- timestamp: new Date().toISOString(),
35
- session_id: identity.visitorId,
36
- page_url: window.location.pathname,
37
- properties: { experiment_id, run_version, variant_key },
38
- },
39
- ],
40
- },
41
- })
42
- .catch(() => exposed.delete(key));
40
+ const key = JSON.stringify([experiment_id, run_version, variant_key, identity.userId, identity.visitorId]);
41
+ let entry = entries.get(key);
42
+ if (!entry) {
43
+ const authorization = identity.userId ? axiosClient.defaults.headers.common.Authorization : null;
44
+ entry = {
45
+ acknowledged: false,
46
+ authorization: typeof authorization === "string" ? authorization : null,
47
+ data: { events: [{
48
+ event_id: uuid(),
49
+ event_name: "__experiment_exposure__",
50
+ timestamp: new Date().toISOString(),
51
+ session_id: identity.visitorId,
52
+ page_url: pageUrl !== null && pageUrl !== void 0 ? pageUrl : (typeof window === "undefined" ? "/" : window.location.pathname),
53
+ properties: { experiment_id, run_version, variant_key, source },
54
+ }] },
55
+ };
56
+ entries.set(key, entry);
57
+ }
58
+ if (!entry.acknowledged)
59
+ void send(entry);
60
+ },
61
+ async flush() {
62
+ await Promise.all([...entries.values()].filter((entry) => !entry.acknowledged).map(send));
43
63
  },
44
64
  };
45
65
  }
@@ -0,0 +1,39 @@
1
+ import type { ExperimentsSnapshot } from "./experiments.types.js";
2
+ /** Shared versioned configuration published by the platform, never visitor-specific. */
3
+ export interface ExperimentsConfig {
4
+ v: 1;
5
+ app_id: string;
6
+ revision?: number;
7
+ flags: {
8
+ key: string;
9
+ rollout_percentage: number;
10
+ }[];
11
+ experiments: {
12
+ id: string;
13
+ flag_key: string;
14
+ run_version: number;
15
+ assign_by: "visitor" | "user";
16
+ traffic_allocation: number;
17
+ variants: {
18
+ key: string;
19
+ value: boolean;
20
+ weight: number;
21
+ }[];
22
+ }[];
23
+ }
24
+ /** Identity supplied by the platform's normal authenticated request/bootstrap path. */
25
+ export interface ExperimentsIdentity {
26
+ visitorId: string;
27
+ userId: string | null;
28
+ status?: "authenticated" | "anonymous" | "pending";
29
+ }
30
+ /** One request's or browser page's context. Never share it between server requests. */
31
+ export interface ExperimentsContext {
32
+ config: ExperimentsConfig;
33
+ identity: ExperimentsIdentity;
34
+ preview?: Readonly<Record<string, boolean>>;
35
+ /** Request pathname used for server-side exposure events. */
36
+ pageUrl?: string;
37
+ /** Exact server-rendered flags, retained for the browser's first hydration render. */
38
+ serverSnapshot?: ExperimentsSnapshot;
39
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,10 @@
1
+ import type { ExperimentsContext } from "./experiments-config.types.js";
2
+ import type { ExperimentsRuntime } from "./experiments-runtime.types.js";
3
+ /** @internal Platform ingress overwrites this header; it is not authentication. */
4
+ export declare const EXPERIMENTS_CONTEXT_HEADER = "Base44-Experiments-Context";
5
+ /** @internal */
6
+ export declare function readExperimentsContext(encoded: string | null, appId: string): ExperimentsContext | undefined;
7
+ /** @internal */
8
+ export declare function getBrowserExperimentsContext(appId: string): ExperimentsContext | undefined;
9
+ /** One independent evaluator instance for one client/request. @internal */
10
+ export declare function createExperimentsRuntime(context: ExperimentsContext): ExperimentsRuntime;
@@ -0,0 +1,44 @@
1
+ import { evaluateExperiments } from "./experiments-evaluator.js";
2
+ /** @internal Platform ingress overwrites this header; it is not authentication. */
3
+ export const EXPERIMENTS_CONTEXT_HEADER = "Base44-Experiments-Context";
4
+ /** @internal */
5
+ export function readExperimentsContext(encoded, appId) {
6
+ if (!encoded || encoded.length > 96 * 1024)
7
+ return;
8
+ try {
9
+ const bytes = Uint8Array.from(atob(encoded.replace(/-/g, "+").replace(/_/g, "/")), (character) => character.charCodeAt(0));
10
+ return matchingContext(JSON.parse(new TextDecoder().decode(bytes)), appId);
11
+ }
12
+ catch (_a) {
13
+ return;
14
+ }
15
+ }
16
+ function matchingContext(value, appId) {
17
+ var _a, _b;
18
+ return ((_a = value === null || value === void 0 ? void 0 : value.config) === null || _a === void 0 ? void 0 : _a.v) === 1 && value.config.app_id === appId &&
19
+ typeof ((_b = value.identity) === null || _b === void 0 ? void 0 : _b.visitorId) === "string" && value.identity.visitorId &&
20
+ (value.identity.userId === null || typeof value.identity.userId === "string")
21
+ ? value : undefined;
22
+ }
23
+ /** @internal */
24
+ export function getBrowserExperimentsContext(appId) {
25
+ if (typeof window === "undefined" || typeof document === "undefined")
26
+ return;
27
+ return matchingContext(window.__B44_EXPERIMENTS_BOOTSTRAP__, appId);
28
+ }
29
+ /** One independent evaluator instance for one client/request. @internal */
30
+ export function createExperimentsRuntime(context) {
31
+ const identity = { ...context.identity };
32
+ const evaluate = () => evaluateExperiments(context.config, identity, context.preview);
33
+ const runtime = {
34
+ ...evaluate(),
35
+ visitorId: identity.visitorId,
36
+ userId: identity.userId,
37
+ pendingUser: identity.status === "pending",
38
+ setUser(userId) {
39
+ identity.userId = userId;
40
+ Object.assign(runtime, evaluate(), { userId, pendingUser: false });
41
+ },
42
+ };
43
+ return runtime;
44
+ }
@@ -0,0 +1,11 @@
1
+ import type { ExperimentAssignment } from "./experiments-runtime.types.js";
2
+ import type { ExperimentsConfig, ExperimentsIdentity } from "./experiments-config.types.js";
3
+ /**
4
+ * Evaluates flags locally without storage, network, clock, or browser globals.
5
+ * The same config and identity always produce the same assignments.
6
+ * This controls presentation, never authorization or access to data.
7
+ */
8
+ export declare function evaluateExperiments(config: ExperimentsConfig, identity: ExperimentsIdentity, preview?: Readonly<Record<string, boolean>>): {
9
+ flags: Record<string, boolean>;
10
+ assignments: ExperimentAssignment[];
11
+ };
@@ -0,0 +1,45 @@
1
+ function bucket(parts) {
2
+ let hash = 0x811c9dc5;
3
+ for (const byte of new TextEncoder().encode(parts.join(":"))) {
4
+ hash = Math.imul(hash ^ byte, 0x01000193) >>> 0;
5
+ }
6
+ return hash % 100;
7
+ }
8
+ /**
9
+ * Evaluates flags locally without storage, network, clock, or browser globals.
10
+ * The same config and identity always produce the same assignments.
11
+ * This controls presentation, never authorization or access to data.
12
+ */
13
+ export function evaluateExperiments(config, identity, preview = {}) {
14
+ const flags = Object.fromEntries(config.flags.map((flag) => [
15
+ flag.key,
16
+ bucket(["rollout", config.app_id, flag.key, identity.visitorId]) < flag.rollout_percentage,
17
+ ]));
18
+ const assignments = [];
19
+ for (const experiment of config.experiments) {
20
+ if (Object.prototype.hasOwnProperty.call(preview, experiment.flag_key))
21
+ continue;
22
+ const key = experiment.assign_by === "user" ? identity.userId : identity.visitorId;
23
+ if (!key || bucket(["enroll", config.app_id, experiment.id, experiment.run_version, key]) >= experiment.traffic_allocation)
24
+ continue;
25
+ const value = bucket(["variant", config.app_id, experiment.id, experiment.run_version, key]);
26
+ let total = 0;
27
+ let variant = experiment.variants[experiment.variants.length - 1];
28
+ for (const candidate of experiment.variants) {
29
+ total += candidate.weight;
30
+ if (value < total) {
31
+ variant = candidate;
32
+ break;
33
+ }
34
+ }
35
+ flags[experiment.flag_key] = variant.value;
36
+ assignments.push({
37
+ experiment_id: experiment.id,
38
+ flag_key: experiment.flag_key,
39
+ run_version: experiment.run_version,
40
+ variant_key: variant.key,
41
+ preview: false,
42
+ });
43
+ }
44
+ return { flags: { ...flags, ...preview }, assignments };
45
+ }
@@ -1,12 +1,16 @@
1
1
  import type { AuthState, InternalAuthModule } from "./auth.types.js";
2
2
  import type { ExperimentsModule } from "./experiments.types.js";
3
3
  import type { createExposureTracker } from "./experiment-exposures.js";
4
+ import type { ExperimentsContext } from "./experiments-config.types.js";
4
5
  /** @internal */
5
- export declare function createExperimentsModule({ getAuth, trackExposure, }: {
6
+ export declare function createExperimentsModule({ getAuth, trackExposure, flushExposures, context, }: {
6
7
  getAuth: () => InternalAuthModule;
7
8
  trackExposure: ReturnType<typeof createExposureTracker>["track"];
9
+ flushExposures?: () => Promise<void>;
10
+ context?: ExperimentsContext;
8
11
  }): {
9
12
  module: ExperimentsModule;
10
13
  onAuthStateChange: (next: AuthState) => void;
14
+ visitorId: () => string | undefined;
11
15
  cleanup(): void;
12
16
  };
@@ -1,19 +1,28 @@
1
1
  import { getExperimentsRuntime, } from "./experiments-runtime.types.js";
2
+ import { createExperimentsRuntime } from "./experiments-context.js";
2
3
  const EMPTY = Object.freeze({
3
4
  flags: Object.freeze({}),
4
5
  isLoading: false,
5
6
  });
6
7
  /** @internal */
7
- export function createExperimentsModule({ getAuth, trackExposure, }) {
8
- let runtime;
9
- let state;
8
+ export function createExperimentsModule({ getAuth, trackExposure, flushExposures = async () => { }, context, }) {
9
+ var _a;
10
+ let runtime = context ? createExperimentsRuntime(context) : undefined;
11
+ let state = context
12
+ ? context.identity.status === "pending" ? { status: "pending" }
13
+ : context.identity.userId ? { status: "authenticated", userId: context.identity.userId }
14
+ : { status: "anonymous" }
15
+ : undefined;
10
16
  let snapshot = EMPTY;
11
17
  let active = false;
12
18
  let disposed = false;
13
- let generation = 0;
14
- let pending;
15
19
  const listeners = new Set();
16
20
  const readyWaiters = new Set();
21
+ const initial = (_a = context === null || context === void 0 ? void 0 : context.serverSnapshot) !== null && _a !== void 0 ? _a : (context ? {
22
+ flags: context.identity.status === "pending" ? {} : runtime.flags,
23
+ isLoading: context.identity.status === "pending",
24
+ } : EMPTY);
25
+ const serverSnapshot = Object.freeze({ ...initial, flags: Object.freeze({ ...initial.flags }) });
17
26
  function settleReady() {
18
27
  if (snapshot.isLoading)
19
28
  return;
@@ -53,25 +62,12 @@ export function createExperimentsModule({ getAuth, trackExposure, }) {
53
62
  }
54
63
  publish();
55
64
  }
56
- function resolveIdentity() {
57
- if (!runtime || pending || disposed)
58
- return;
59
- state = { status: "pending" };
60
- applyIdentity();
61
- const currentGeneration = generation;
62
- pending = getAuth()
63
- .me()
64
- .then(() => { }, () => { })
65
- .finally(() => {
66
- if (currentGeneration === generation)
67
- pending = undefined;
68
- });
69
- }
70
65
  function activate() {
71
66
  if (disposed)
72
67
  return;
73
68
  active = true;
74
- runtime = getExperimentsRuntime();
69
+ if (!context)
70
+ runtime = getExperimentsRuntime();
75
71
  if (!runtime) {
76
72
  publish();
77
73
  return;
@@ -81,23 +77,16 @@ export function createExperimentsModule({ getAuth, trackExposure, }) {
81
77
  ? { status: "pending" }
82
78
  : { status: "anonymous" };
83
79
  applyIdentity();
84
- if (state.status === "pending")
85
- resolveIdentity();
86
80
  }
87
81
  function onAuthStateChange(next) {
88
82
  if (disposed)
89
83
  return;
90
84
  state = next;
91
- if (next.status === "pending" || next.status === "anonymous") {
92
- generation++;
93
- pending = undefined;
94
- }
95
85
  if (!active)
96
86
  return;
97
- runtime = getExperimentsRuntime();
87
+ if (!context)
88
+ runtime = getExperimentsRuntime();
98
89
  applyIdentity();
99
- if (next.status === "pending")
100
- resolveIdentity();
101
90
  }
102
91
  const module = {
103
92
  isEnabled(flagKey, fallback = false) {
@@ -113,6 +102,7 @@ export function createExperimentsModule({ getAuth, trackExposure, }) {
113
102
  activate();
114
103
  return snapshot;
115
104
  },
105
+ getServerSnapshot: () => serverSnapshot,
116
106
  subscribe(listener) {
117
107
  activate();
118
108
  if (!disposed)
@@ -123,24 +113,18 @@ export function createExperimentsModule({ getAuth, trackExposure, }) {
123
113
  },
124
114
  async ready() {
125
115
  activate();
126
- if ((state === null || state === void 0 ? void 0 : state.status) === "error") {
127
- // Wait for auth.me() to release its shared, failed request before retrying.
128
- await pending;
129
- if ((state === null || state === void 0 ? void 0 : state.status) === "error")
130
- resolveIdentity();
131
- }
132
116
  if (snapshot.isLoading)
133
117
  return new Promise((resolve) => readyWaiters.add(resolve));
134
118
  return snapshot;
135
119
  },
120
+ flush: flushExposures,
136
121
  };
137
122
  return {
138
123
  module,
139
124
  onAuthStateChange,
125
+ visitorId: () => runtime === null || runtime === void 0 ? void 0 : runtime.visitorId,
140
126
  cleanup() {
141
127
  disposed = true;
142
- generation++;
143
- pending = undefined;
144
128
  runtime = undefined;
145
129
  snapshot = EMPTY;
146
130
  settleReady();
@@ -6,7 +6,7 @@ export interface ExperimentsSnapshot {
6
6
  readonly isLoading: boolean;
7
7
  }
8
8
  /**
9
- * Reads feature flags evaluated by the Base44 browser runtime.
9
+ * Evaluates feature flags locally from platform-provided configuration and identity.
10
10
  *
11
11
  * - Reads flags and reports experiment exposures when a flag is used.
12
12
  * - Synchronizes assignments with this client's SDK login, token changes, and logout.
@@ -14,26 +14,28 @@ export interface ExperimentsSnapshot {
14
14
  *
15
15
  * Available as `base44.experiments` for anonymous and signed-in app visitors,
16
16
  * not in service role mode. Use one client for the app whose runtime is on the page.
17
- * The platform must inject the Experiments runtime before this module can evaluate
18
- * flags. Without it, including on servers and Workers, reads return their fallback;
19
- * this module does not provide server-side evaluation or hydration guarantees.
17
+ * Browsers read the platform bootstrap. Servers and Workers use the request-scoped
18
+ * context passed by createClientFromRequest(), or explicit createClient options.
19
+ * Missing context returns fallbacks. For authenticated first render, the platform's
20
+ * common auth bootstrap must supply a resolved identity before mounting the app.
20
21
  * Goal conversions use the existing {@link AnalyticsModule | analytics module}.
21
- * Visitor-keyed conversion attribution requires matching runtime and analytics
22
- * visitor IDs; blocked browser storage is not currently supported for attribution.
22
+ * Visitor-keyed conversions share the injected runtime's visitor ID. When browser
23
+ * storage is blocked, the platform must supply a unique per-page ID; attribution
24
+ * then lasts for that page only, not across reloads or tabs.
23
25
  */
24
26
  export interface ExperimentsModule {
25
27
  /**
26
- * Reads a flag and reports a best-effort exposure for its current assignment.
28
+ * Reads a flag and queues an acknowledged exposure for its current assignment.
27
29
  *
28
- * The first use resolves identity through {@link AuthModule.me | auth.me()}
29
- * when the client has a token. Reads return the fallback while identity is
30
- * pending or could not be resolved. Await {@link ExperimentsModule.ready | ready()}
31
- * or subscribe to updates before displaying authenticated variants.
30
+ * Never starts an authentication request. Reads return the fallback while the
31
+ * app's normal auth initialization is pending or failed. Supply trusted bootstrap
32
+ * identity or let the app's existing auth.me()/login flow resolve it.
32
33
  *
33
34
  * Call only where the feature is used: a read counts as exposure, not proof of
34
35
  * visibility. Preview overrides and flags without an assignment are not tracked.
35
36
  * Exposures respect the client's analytics setting, are deduplicated per client,
36
- * experiment run, variant and identity, and retry only on a later read after failure.
37
+ * experiment run, variant and identity. Failed sends retry up to three attempts
38
+ * with the same event ID, timestamp and credentials. Await flush() on servers.
37
39
  *
38
40
  * @param flagKey - Feature flag key defined in your app.
39
41
  * @param fallback - Value for an unavailable flag or unresolved identity. Defaults to `false`.
@@ -48,7 +50,7 @@ export interface ExperimentsModule {
48
50
  /**
49
51
  * Returns the current flags and identity-loading state without tracking exposures.
50
52
  *
51
- * Starts lazy identity resolution if needed. The returned object retains its
53
+ * Observes identity resolution without starting it. The returned object retains its
52
54
  * reference until its values change, for use with external-store subscriptions.
53
55
  * Use {@link ExperimentsModule.isEnabled | isEnabled()} at the feature boundary
54
56
  * to record exposure rather than displaying a variant directly from this snapshot.
@@ -60,6 +62,8 @@ export interface ExperimentsModule {
60
62
  * ```
61
63
  */
62
64
  getSnapshot(): ExperimentsSnapshot;
65
+ /** Immutable initial platform snapshot for matching server render and hydration. */
66
+ getServerSnapshot(): ExperimentsSnapshot;
63
67
  /**
64
68
  * Listens for flag or loading-state changes caused by this client's SDK auth flows.
65
69
  *
@@ -78,10 +82,10 @@ export interface ExperimentsModule {
78
82
  */
79
83
  subscribe(listener: () => void): () => void;
80
84
  /**
81
- * Waits for the current identity lookup, including a token change during that lookup.
85
+ * Waits for the app's common auth initialization, including a token change.
82
86
  *
83
- * Resolves with empty flags after an identity lookup failure; calling again retries
84
- * the lookup. Missing runtimes resolve immediately. This does not wait for a future
87
+ * Resolves with empty flags after an identity lookup failure. Retrying authentication
88
+ * belongs to the normal auth flow. Missing runtimes resolve immediately. This does not wait for a future
85
89
  * runtime injection or for exposure delivery, and never records an exposure itself.
86
90
  *
87
91
  * @returns A snapshot after the current identity lookup settles.
@@ -92,4 +96,11 @@ export interface ExperimentsModule {
92
96
  * ```
93
97
  */
94
98
  ready(): Promise<ExperimentsSnapshot>;
99
+ /**
100
+ * Waits until queued exposures are acknowledged; rejects after bounded retries.
101
+ * Server/Worker handlers must await this before ending the request (or use waitUntil).
102
+ * Retries preserve event IDs but raw storage is not exactly-once. Calling again
103
+ * retries unacknowledged events with the same IDs. No new exposures are created.
104
+ */
105
+ flush(): Promise<void>;
95
106
  }
@@ -29,6 +29,7 @@ export function createFetchWithAuth({ axios, serviceRoleAxios, appId, serverUrl,
29
29
  ? header
30
30
  : null;
31
31
  };
32
+ const contextAuthorization = bearer(axios);
32
33
  return async function fetchWithAuth(path, init = {}) {
33
34
  assertOwnOriginPath(path);
34
35
  const { fetch: transport = fetch, ...requestInit } = init;
@@ -49,6 +50,11 @@ export function createFetchWithAuth({ axios, serviceRoleAxios, appId, serverUrl,
49
50
  inherit("Base44-Functions-Version", functionsVersion);
50
51
  inherit("Base44-State", inherited.get("Base44-State"));
51
52
  inherit("X-Data-Env", inherited.get("X-Data-Env"));
53
+ inherit("Base44-Visitor-Id", inherited.get("Base44-Visitor-Id"));
54
+ inherit("Base44-Experiment-Preview", inherited.get("Base44-Experiment-Preview"));
55
+ if (headers.get("Authorization") === contextAuthorization) {
56
+ inherit("Base44-Experiments-Context", inherited.get("Base44-Experiments-Context"));
57
+ }
52
58
  // The path is passed through untouched: resolving it here would need a
53
59
  // document, and a root-relative path is already what a runtime that
54
60
  // dispatches in-process (Nitro's `fetch`) expects. `host` is deliberately
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@base44-preview/sdk",
3
- "version": "0.8.48-pr.280.2c0d8bc",
3
+ "version": "0.8.48-pr.280.7eec9ba",
4
4
  "description": "JavaScript SDK for Base44 API",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",