@base44-preview/sdk 0.8.48-pr.280.cc2ee4a → 0.8.48-pr.280.dac0f98
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 +42 -12
- package/dist/client.types.d.ts +8 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/modules/analytics.d.ts +28 -5
- package/dist/modules/analytics.js +99 -82
- package/dist/modules/auth.js +4 -2
- package/dist/modules/experiment-exposures.d.ts +4 -1
- package/dist/modules/experiment-exposures.js +56 -36
- package/dist/modules/experiments-config.types.d.ts +39 -0
- package/dist/modules/experiments-config.types.js +1 -0
- package/dist/modules/experiments-context.d.ts +10 -0
- package/dist/modules/experiments-context.js +44 -0
- package/dist/modules/experiments-evaluator.d.ts +11 -0
- package/dist/modules/experiments-evaluator.js +45 -0
- package/dist/modules/experiments.d.ts +5 -1
- package/dist/modules/experiments.js +21 -37
- package/dist/modules/experiments.types.d.ts +24 -14
- package/dist/utils/fetch-with-auth.js +6 -0
- package/package.json +1 -1
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
|
-
...
|
|
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:
|
|
129
|
-
|
|
130
|
-
|
|
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" ? (
|
|
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: (
|
|
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: (
|
|
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: (
|
|
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:
|
|
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
|
}
|
package/dist/client.types.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
18
|
-
|
|
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(
|
|
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;
|
|
@@ -18,34 +18,41 @@ const defaultConfiguration = {
|
|
|
18
18
|
//// shared queue for analytics events ////
|
|
19
19
|
///////////////////////////////////////////////
|
|
20
20
|
const ANALYTICS_SHARED_STATE_NAME = "analytics";
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
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();
|
|
37
41
|
/** @internal */
|
|
38
|
-
export function isAnalyticsEnabled(enabled) {
|
|
39
|
-
return enabled &&
|
|
42
|
+
export function isAnalyticsEnabled(enabled, state = analyticsSharedState) {
|
|
43
|
+
return enabled && state.config.enabled && !isReactNative;
|
|
40
44
|
}
|
|
41
|
-
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);
|
|
42
49
|
// prevent overflow of events //
|
|
43
|
-
const { maxQueueSize, throttleTime, batchSize } =
|
|
50
|
+
const { maxQueueSize, throttleTime, batchSize } = state.config;
|
|
44
51
|
// Disable analytics on React Native. It defines `window` but not `document`,
|
|
45
52
|
// so the per-callsite `typeof window` guards below aren't enough to keep it
|
|
46
53
|
// from touching `document` (e.g. `document.referrer` on init). Node/SSR is
|
|
47
54
|
// still handled by those `window` guards, so this doesn't affect it.
|
|
48
|
-
if (!isAnalyticsEnabled(enabled)) {
|
|
55
|
+
if (!isAnalyticsEnabled(enabled, state)) {
|
|
49
56
|
return {
|
|
50
57
|
track: () => { },
|
|
51
58
|
cleanup: () => { },
|
|
@@ -74,16 +81,17 @@ export const createAnalyticsModule = ({ axiosClient, serverUrl, appId, userAuthM
|
|
|
74
81
|
}
|
|
75
82
|
};
|
|
76
83
|
const flush = async (eventsData, options = {}) => {
|
|
84
|
+
var _a;
|
|
77
85
|
if (eventsData.length === 0)
|
|
78
86
|
return;
|
|
79
|
-
const sessionContext_ = await getSessionContext(userAuthModule);
|
|
80
|
-
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 }));
|
|
81
89
|
try {
|
|
82
90
|
if (!options.isBeacon || !beaconRequest(events)) {
|
|
83
91
|
await batchRequestFallback(events);
|
|
84
92
|
}
|
|
85
93
|
}
|
|
86
|
-
catch (
|
|
94
|
+
catch (_b) {
|
|
87
95
|
// do nothing
|
|
88
96
|
}
|
|
89
97
|
};
|
|
@@ -91,16 +99,25 @@ export const createAnalyticsModule = ({ axiosClient, serverUrl, appId, userAuthM
|
|
|
91
99
|
startAnalyticsProcessor(flush, {
|
|
92
100
|
throttleTime,
|
|
93
101
|
batchSize,
|
|
94
|
-
});
|
|
102
|
+
}, state);
|
|
95
103
|
};
|
|
96
104
|
const track = (params) => {
|
|
97
|
-
|
|
105
|
+
var _a;
|
|
106
|
+
if (state.requestsQueue.length >= maxQueueSize) {
|
|
98
107
|
return;
|
|
99
108
|
}
|
|
100
109
|
const intrinsicData = getEventIntrinsicData();
|
|
101
|
-
|
|
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({
|
|
102
118
|
...params,
|
|
103
119
|
...intrinsicData,
|
|
120
|
+
properties: params.properties || Object.keys(properties).length ? properties : undefined,
|
|
104
121
|
});
|
|
105
122
|
startProcessing();
|
|
106
123
|
};
|
|
@@ -108,16 +125,16 @@ export const createAnalyticsModule = ({ axiosClient, serverUrl, appId, userAuthM
|
|
|
108
125
|
startAnalyticsProcessor(flush, {
|
|
109
126
|
throttleTime,
|
|
110
127
|
batchSize,
|
|
111
|
-
});
|
|
112
|
-
clearHeartBeatProcessor = startHeartBeatProcessor(track);
|
|
113
|
-
setSessionDurationTimerStart();
|
|
128
|
+
}, state);
|
|
129
|
+
clearHeartBeatProcessor = startHeartBeatProcessor(track, state);
|
|
130
|
+
setSessionDurationTimerStart(state);
|
|
114
131
|
};
|
|
115
132
|
const onDocHidden = () => {
|
|
116
|
-
stopAnalyticsProcessor();
|
|
133
|
+
stopAnalyticsProcessor(state);
|
|
117
134
|
clearHeartBeatProcessor === null || clearHeartBeatProcessor === void 0 ? void 0 : clearHeartBeatProcessor();
|
|
118
|
-
trackSessionDurationEvent(track);
|
|
135
|
+
trackSessionDurationEvent(track, state);
|
|
119
136
|
// flush entire queue on visibility change and hope for the best //
|
|
120
|
-
const eventsData =
|
|
137
|
+
const eventsData = state.requestsQueue.splice(0);
|
|
121
138
|
flush(eventsData, { isBeacon: true });
|
|
122
139
|
};
|
|
123
140
|
const onVisibilityChange = () => {
|
|
@@ -131,7 +148,7 @@ export const createAnalyticsModule = ({ axiosClient, serverUrl, appId, userAuthM
|
|
|
131
148
|
}
|
|
132
149
|
};
|
|
133
150
|
const cleanup = () => {
|
|
134
|
-
stopAnalyticsProcessor();
|
|
151
|
+
stopAnalyticsProcessor(state);
|
|
135
152
|
clearHeartBeatProcessor === null || clearHeartBeatProcessor === void 0 ? void 0 : clearHeartBeatProcessor();
|
|
136
153
|
if (typeof window !== "undefined") {
|
|
137
154
|
window.removeEventListener("visibilitychange", onVisibilityChange);
|
|
@@ -140,9 +157,9 @@ export const createAnalyticsModule = ({ axiosClient, serverUrl, appId, userAuthM
|
|
|
140
157
|
// start the flusing process ///
|
|
141
158
|
startProcessing();
|
|
142
159
|
// start the heart beat processor //
|
|
143
|
-
clearHeartBeatProcessor = startHeartBeatProcessor(track);
|
|
160
|
+
clearHeartBeatProcessor = startHeartBeatProcessor(track, state);
|
|
144
161
|
// track the referrer event //
|
|
145
|
-
trackInitializationEvent(track);
|
|
162
|
+
trackInitializationEvent(track, state);
|
|
146
163
|
// start the visibility change listener //
|
|
147
164
|
if (typeof window !== "undefined") {
|
|
148
165
|
window.addEventListener("visibilitychange", onVisibilityChange);
|
|
@@ -152,50 +169,50 @@ export const createAnalyticsModule = ({ axiosClient, serverUrl, appId, userAuthM
|
|
|
152
169
|
cleanup,
|
|
153
170
|
};
|
|
154
171
|
};
|
|
155
|
-
function stopAnalyticsProcessor() {
|
|
156
|
-
|
|
172
|
+
function stopAnalyticsProcessor(state) {
|
|
173
|
+
state.isProcessing = false;
|
|
157
174
|
}
|
|
158
|
-
async function startAnalyticsProcessor(handleTrack, options) {
|
|
159
|
-
if (
|
|
175
|
+
async function startAnalyticsProcessor(handleTrack, options, state) {
|
|
176
|
+
if (state.isProcessing) {
|
|
160
177
|
// only one instance of the analytics processor can be running at a time //
|
|
161
178
|
return;
|
|
162
179
|
}
|
|
163
|
-
|
|
180
|
+
state.isProcessing = true;
|
|
164
181
|
const { throttleTime = 1000, batchSize = 30 } = options !== null && options !== void 0 ? options : {};
|
|
165
|
-
while (
|
|
166
|
-
|
|
167
|
-
const requests =
|
|
182
|
+
while (state.isProcessing &&
|
|
183
|
+
state.requestsQueue.length > 0) {
|
|
184
|
+
const requests = state.requestsQueue.splice(0, batchSize);
|
|
168
185
|
requests.length && (await handleTrack(requests));
|
|
169
186
|
await new Promise((resolve) => setTimeout(resolve, throttleTime));
|
|
170
187
|
}
|
|
171
|
-
|
|
188
|
+
state.isProcessing = false;
|
|
172
189
|
}
|
|
173
|
-
function startHeartBeatProcessor(track) {
|
|
190
|
+
function startHeartBeatProcessor(track, state) {
|
|
174
191
|
var _a;
|
|
175
192
|
// Browser-only, like the other automatic events here (initialization, session
|
|
176
193
|
// duration, visibility). Outside a browser this timer fired a `me()` every
|
|
177
194
|
// interval for the lifetime of a long-lived server-side client, and kept the
|
|
178
195
|
// Node event loop alive. Explicit `analytics.track()` calls still work.
|
|
179
196
|
if (typeof window === "undefined" ||
|
|
180
|
-
|
|
181
|
-
((_a =
|
|
197
|
+
state.isHeartBeatProcessing ||
|
|
198
|
+
((_a = state.config.heartBeatInterval) !== null && _a !== void 0 ? _a : 0) < 10) {
|
|
182
199
|
return () => { };
|
|
183
200
|
}
|
|
184
|
-
|
|
201
|
+
state.isHeartBeatProcessing = true;
|
|
185
202
|
const interval = setInterval(() => {
|
|
186
203
|
track({ eventName: USER_HEARTBEAT_EVENT_NAME });
|
|
187
|
-
},
|
|
204
|
+
}, state.config.heartBeatInterval);
|
|
188
205
|
return () => {
|
|
189
206
|
clearInterval(interval);
|
|
190
|
-
|
|
207
|
+
state.isHeartBeatProcessing = false;
|
|
191
208
|
};
|
|
192
209
|
}
|
|
193
|
-
function trackInitializationEvent(track) {
|
|
210
|
+
function trackInitializationEvent(track, state) {
|
|
194
211
|
if (typeof window === "undefined" ||
|
|
195
|
-
|
|
212
|
+
state.wasInitializationTracked) {
|
|
196
213
|
return;
|
|
197
214
|
}
|
|
198
|
-
|
|
215
|
+
state.wasInitializationTracked = true;
|
|
199
216
|
track({
|
|
200
217
|
eventName: ANALYTICS_INITIALIZATION_EVENT_NAME,
|
|
201
218
|
properties: {
|
|
@@ -203,20 +220,20 @@ function trackInitializationEvent(track) {
|
|
|
203
220
|
},
|
|
204
221
|
});
|
|
205
222
|
}
|
|
206
|
-
function setSessionDurationTimerStart() {
|
|
223
|
+
function setSessionDurationTimerStart(state) {
|
|
207
224
|
if (typeof window === "undefined" ||
|
|
208
|
-
|
|
225
|
+
state.sessionStartTime !== null) {
|
|
209
226
|
return;
|
|
210
227
|
}
|
|
211
|
-
|
|
228
|
+
state.sessionStartTime = new Date().toISOString();
|
|
212
229
|
}
|
|
213
|
-
function trackSessionDurationEvent(track) {
|
|
230
|
+
function trackSessionDurationEvent(track, state) {
|
|
214
231
|
if (typeof window === "undefined" ||
|
|
215
|
-
|
|
232
|
+
state.sessionStartTime === null)
|
|
216
233
|
return;
|
|
217
234
|
const sessionDuration = new Date().getTime() -
|
|
218
|
-
new Date(
|
|
219
|
-
|
|
235
|
+
new Date(state.sessionStartTime).getTime();
|
|
236
|
+
state.sessionStartTime = null;
|
|
220
237
|
track({
|
|
221
238
|
eventName: ANALYTICS_SESSION_DURATION_EVENT_NAME,
|
|
222
239
|
properties: { sessionDuration },
|
|
@@ -239,7 +256,6 @@ function transformEventDataToApiRequestData(sessionContext) {
|
|
|
239
256
|
...sessionContext,
|
|
240
257
|
});
|
|
241
258
|
}
|
|
242
|
-
let sessionContextPromise = null;
|
|
243
259
|
/**
|
|
244
260
|
* Clears the memoized analytics session context.
|
|
245
261
|
*
|
|
@@ -250,22 +266,24 @@ let sessionContextPromise = null;
|
|
|
250
266
|
*
|
|
251
267
|
* @internal
|
|
252
268
|
*/
|
|
253
|
-
export function resetAnalyticsSessionContext() {
|
|
254
|
-
|
|
255
|
-
|
|
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;
|
|
256
274
|
}
|
|
257
|
-
async function getSessionContext(userAuthModule) {
|
|
258
|
-
if (!
|
|
275
|
+
async function getSessionContext(userAuthModule, state) {
|
|
276
|
+
if (!state.sessionContext) {
|
|
259
277
|
// With no token there is no identity to resolve: `me()` can only answer 401,
|
|
260
278
|
// which the browser logs to the console before any handler here sees it. On
|
|
261
279
|
// a public page that request is the sole reason an error appears, so skip
|
|
262
280
|
// it. This is not memoized — a visitor who logs in later must still resolve.
|
|
263
281
|
if (!userAuthModule.hasToken()) {
|
|
264
|
-
return { user_id: null, session_id: getAnalyticsSessionId() };
|
|
282
|
+
return { user_id: null, session_id: getAnalyticsSessionId(state) };
|
|
265
283
|
}
|
|
266
|
-
if (!sessionContextPromise) {
|
|
267
|
-
const sessionId = getAnalyticsSessionId();
|
|
268
|
-
sessionContextPromise = userAuthModule
|
|
284
|
+
if (!state.sessionContextPromise) {
|
|
285
|
+
const sessionId = getAnalyticsSessionId(state);
|
|
286
|
+
state.sessionContextPromise = userAuthModule
|
|
269
287
|
.me()
|
|
270
288
|
.then((user) => ({
|
|
271
289
|
user_id: user.id,
|
|
@@ -276,7 +294,7 @@ async function getSessionContext(userAuthModule) {
|
|
|
276
294
|
session_id: sessionId,
|
|
277
295
|
}));
|
|
278
296
|
}
|
|
279
|
-
const pending = sessionContextPromise;
|
|
297
|
+
const pending = state.sessionContextPromise;
|
|
280
298
|
const context = await pending;
|
|
281
299
|
// Publish only if this lookup is still the current one. A reset that lands
|
|
282
300
|
// while the request is in flight nulls `sessionContextPromise`, and an
|
|
@@ -284,12 +302,12 @@ async function getSessionContext(userAuthModule) {
|
|
|
284
302
|
// for the rest of the session. The awaited value is still returned: these
|
|
285
303
|
// events were queued before the identity changed, so that is who they
|
|
286
304
|
// belong to.
|
|
287
|
-
if (sessionContextPromise === pending) {
|
|
288
|
-
|
|
305
|
+
if (state.sessionContextPromise === pending) {
|
|
306
|
+
state.sessionContext = context;
|
|
289
307
|
}
|
|
290
308
|
return context;
|
|
291
309
|
}
|
|
292
|
-
return
|
|
310
|
+
return state.sessionContext;
|
|
293
311
|
}
|
|
294
312
|
export function getAnalyticsConfigFromUrlParams() {
|
|
295
313
|
// `window.location` is absent on React Native. This runs at module load (via
|
|
@@ -311,19 +329,18 @@ export function getAnalyticsConfigFromUrlParams() {
|
|
|
311
329
|
// return the config object //
|
|
312
330
|
return { enabled: analyticsEnable === "true" };
|
|
313
331
|
}
|
|
314
|
-
//
|
|
315
|
-
|
|
316
|
-
function getFallbackSessionId() {
|
|
332
|
+
// Without persistent storage, keep the id stable within this analytics state.
|
|
333
|
+
function getFallbackSessionId(state) {
|
|
317
334
|
var _a;
|
|
318
|
-
return ((_a =
|
|
335
|
+
return ((_a = state.fallbackSessionId) !== null && _a !== void 0 ? _a : (state.fallbackSessionId = generateUuid()));
|
|
319
336
|
}
|
|
320
|
-
export function getAnalyticsSessionId() {
|
|
337
|
+
export function getAnalyticsSessionId(state = analyticsSharedState) {
|
|
321
338
|
var _a;
|
|
322
339
|
const visitorId = (_a = getExperimentsRuntime()) === null || _a === void 0 ? void 0 : _a.visitorId;
|
|
323
340
|
if (visitorId && visitorId !== "anon")
|
|
324
341
|
return visitorId;
|
|
325
342
|
if (typeof window === "undefined") {
|
|
326
|
-
return getFallbackSessionId();
|
|
343
|
+
return getFallbackSessionId(state);
|
|
327
344
|
}
|
|
328
345
|
try {
|
|
329
346
|
const sessionId = localStorage.getItem(ANALYTICS_SESSION_ID_LOCAL_STORAGE_KEY);
|
|
@@ -335,6 +352,6 @@ export function getAnalyticsSessionId() {
|
|
|
335
352
|
return sessionId;
|
|
336
353
|
}
|
|
337
354
|
catch (_b) {
|
|
338
|
-
return getFallbackSessionId();
|
|
355
|
+
return getFallbackSessionId(state);
|
|
339
356
|
}
|
|
340
357
|
}
|
package/dist/modules/auth.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
9
|
-
let
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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,9 +14,10 @@ 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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
22
|
* Visitor-keyed conversions share the injected runtime's visitor ID. When browser
|
|
22
23
|
* storage is blocked, the platform must supply a unique per-page ID; attribution
|
|
@@ -24,17 +25,17 @@ export interface ExperimentsSnapshot {
|
|
|
24
25
|
*/
|
|
25
26
|
export interface ExperimentsModule {
|
|
26
27
|
/**
|
|
27
|
-
* Reads a flag and
|
|
28
|
+
* Reads a flag and queues an acknowledged exposure for its current assignment.
|
|
28
29
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* 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.
|
|
33
33
|
*
|
|
34
34
|
* Call only where the feature is used: a read counts as exposure, not proof of
|
|
35
35
|
* visibility. Preview overrides and flags without an assignment are not tracked.
|
|
36
36
|
* Exposures respect the client's analytics setting, are deduplicated per client,
|
|
37
|
-
* experiment run, variant and identity
|
|
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.
|
|
38
39
|
*
|
|
39
40
|
* @param flagKey - Feature flag key defined in your app.
|
|
40
41
|
* @param fallback - Value for an unavailable flag or unresolved identity. Defaults to `false`.
|
|
@@ -49,7 +50,7 @@ export interface ExperimentsModule {
|
|
|
49
50
|
/**
|
|
50
51
|
* Returns the current flags and identity-loading state without tracking exposures.
|
|
51
52
|
*
|
|
52
|
-
*
|
|
53
|
+
* Observes identity resolution without starting it. The returned object retains its
|
|
53
54
|
* reference until its values change, for use with external-store subscriptions.
|
|
54
55
|
* Use {@link ExperimentsModule.isEnabled | isEnabled()} at the feature boundary
|
|
55
56
|
* to record exposure rather than displaying a variant directly from this snapshot.
|
|
@@ -61,6 +62,8 @@ export interface ExperimentsModule {
|
|
|
61
62
|
* ```
|
|
62
63
|
*/
|
|
63
64
|
getSnapshot(): ExperimentsSnapshot;
|
|
65
|
+
/** Immutable initial platform snapshot for matching server render and hydration. */
|
|
66
|
+
getServerSnapshot(): ExperimentsSnapshot;
|
|
64
67
|
/**
|
|
65
68
|
* Listens for flag or loading-state changes caused by this client's SDK auth flows.
|
|
66
69
|
*
|
|
@@ -79,10 +82,10 @@ export interface ExperimentsModule {
|
|
|
79
82
|
*/
|
|
80
83
|
subscribe(listener: () => void): () => void;
|
|
81
84
|
/**
|
|
82
|
-
* Waits for the
|
|
85
|
+
* Waits for the app's common auth initialization, including a token change.
|
|
83
86
|
*
|
|
84
|
-
* Resolves with empty flags after an identity lookup failure
|
|
85
|
-
* the
|
|
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
|
|
86
89
|
* runtime injection or for exposure delivery, and never records an exposure itself.
|
|
87
90
|
*
|
|
88
91
|
* @returns A snapshot after the current identity lookup settles.
|
|
@@ -93,4 +96,11 @@ export interface ExperimentsModule {
|
|
|
93
96
|
* ```
|
|
94
97
|
*/
|
|
95
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>;
|
|
96
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
|