@base44-preview/sdk 0.8.48-pr.280.cc2ee4a → 0.8.48-pr.280.cd0dea6
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-queue.d.ts +12 -0
- package/dist/modules/analytics-queue.js +128 -0
- package/dist/modules/analytics.d.ts +37 -5
- package/dist/modules/analytics.js +99 -154
- package/dist/modules/auth.js +4 -2
- package/dist/modules/experiment-exposures.d.ts +4 -1
- package/dist/modules/experiment-exposures.js +21 -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 +42 -14
- package/dist/utils/fetch-with-auth.js +6 -0
- package/package.json +1 -1
|
@@ -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,22 @@ export interface ExperimentsSnapshot {
|
|
|
24
25
|
*/
|
|
25
26
|
export interface ExperimentsModule {
|
|
26
27
|
/**
|
|
27
|
-
* Reads a flag and
|
|
28
|
+
* Reads a flag and queues a best-effort 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. Network and server failures retry up to
|
|
38
|
+
* three attempts within five seconds of batch delivery, preserving the event ID,
|
|
39
|
+
* timestamp and credentials. HTTP successes (including rejected measurements) and client errors
|
|
40
|
+
* are terminal. Exposures share the Analytics batch with compatible ordinary
|
|
41
|
+
* events; credentials and user/visitor identities are captured when tracking.
|
|
42
|
+
* Only exposures are retried; ordinary goals retain single-attempt delivery.
|
|
43
|
+
* On servers, use the runtime's background lifetime mechanism.
|
|
38
44
|
*
|
|
39
45
|
* @param flagKey - Feature flag key defined in your app.
|
|
40
46
|
* @param fallback - Value for an unavailable flag or unresolved identity. Defaults to `false`.
|
|
@@ -49,7 +55,7 @@ export interface ExperimentsModule {
|
|
|
49
55
|
/**
|
|
50
56
|
* Returns the current flags and identity-loading state without tracking exposures.
|
|
51
57
|
*
|
|
52
|
-
*
|
|
58
|
+
* Observes identity resolution without starting it. The returned object retains its
|
|
53
59
|
* reference until its values change, for use with external-store subscriptions.
|
|
54
60
|
* Use {@link ExperimentsModule.isEnabled | isEnabled()} at the feature boundary
|
|
55
61
|
* to record exposure rather than displaying a variant directly from this snapshot.
|
|
@@ -61,6 +67,8 @@ export interface ExperimentsModule {
|
|
|
61
67
|
* ```
|
|
62
68
|
*/
|
|
63
69
|
getSnapshot(): ExperimentsSnapshot;
|
|
70
|
+
/** Immutable initial platform snapshot for matching server render and hydration. */
|
|
71
|
+
getServerSnapshot(): ExperimentsSnapshot;
|
|
64
72
|
/**
|
|
65
73
|
* Listens for flag or loading-state changes caused by this client's SDK auth flows.
|
|
66
74
|
*
|
|
@@ -79,10 +87,10 @@ export interface ExperimentsModule {
|
|
|
79
87
|
*/
|
|
80
88
|
subscribe(listener: () => void): () => void;
|
|
81
89
|
/**
|
|
82
|
-
* Waits for the
|
|
90
|
+
* Waits for the app's common auth initialization, including a token change.
|
|
83
91
|
*
|
|
84
|
-
* Resolves with empty flags after an identity lookup failure
|
|
85
|
-
* the
|
|
92
|
+
* Resolves with empty flags after an identity lookup failure. Retrying authentication
|
|
93
|
+
* belongs to the normal auth flow. Missing runtimes resolve immediately. This does not wait for a future
|
|
86
94
|
* runtime injection or for exposure delivery, and never records an exposure itself.
|
|
87
95
|
*
|
|
88
96
|
* @returns A snapshot after the current identity lookup settles.
|
|
@@ -93,4 +101,24 @@ export interface ExperimentsModule {
|
|
|
93
101
|
* ```
|
|
94
102
|
*/
|
|
95
103
|
ready(): Promise<ExperimentsSnapshot>;
|
|
104
|
+
/**
|
|
105
|
+
* Flushes this client's queued Analytics goals and exposures without rejecting.
|
|
106
|
+
* Each delivery has a five-second total budget; exhausted or rejected events are
|
|
107
|
+
* dropped and are not retried by later reads or flushes. Settlement is not proof
|
|
108
|
+
* of ingestion, and raw storage is not exactly-once. No new exposures are created.
|
|
109
|
+
* Worker handlers should use `ctx.waitUntil(client.experiments.flush())` instead
|
|
110
|
+
* of awaiting Analytics on the application response path. Other runtimes must use
|
|
111
|
+
* their supported background lifetime mechanism; fire-and-forget alone may be cut off.
|
|
112
|
+
* Base44's legacy Cloudflare runtime exposes `globalThis.Base44.waitUntil(...)`;
|
|
113
|
+
* the newer runtime exports `waitUntil` from `base44:runtime`. Use the API provided
|
|
114
|
+
* by your deployed runtime. Deno without a background lifetime API must await flush.
|
|
115
|
+
*
|
|
116
|
+
* @returns A promise that resolves when the current batch deliveries settle.
|
|
117
|
+
* @example
|
|
118
|
+
* ```typescript
|
|
119
|
+
* // In a Worker handler with an execution context:
|
|
120
|
+
* ctx.waitUntil(base44.experiments.flush());
|
|
121
|
+
* ```
|
|
122
|
+
*/
|
|
123
|
+
flush(): Promise<void>;
|
|
96
124
|
}
|
|
@@ -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
|