@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.
@@ -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,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
- * 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
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 reports a best-effort exposure for its current assignment.
28
+ * Reads a flag and queues a best-effort exposure for its current assignment.
28
29
  *
29
- * The first use resolves identity through {@link AuthModule.me | auth.me()}
30
- * when the client has a token. Reads return the fallback while identity is
31
- * pending or could not be resolved. Await {@link ExperimentsModule.ready | ready()}
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, and retry only on a later read after failure.
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
- * Starts lazy identity resolution if needed. The returned object retains its
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 current identity lookup, including a token change during that lookup.
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; calling again retries
85
- * the lookup. Missing runtimes resolve immediately. This does not wait for a future
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@base44-preview/sdk",
3
- "version": "0.8.48-pr.280.cc2ee4a",
3
+ "version": "0.8.48-pr.280.cd0dea6",
4
4
  "description": "JavaScript SDK for Base44 API",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",