@layers/amba-react-native 4.1.0 → 4.2.1
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/index.cjs +747 -86
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +96 -10
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +96 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +747 -86
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -90,6 +90,167 @@ var AmbaApiError = class extends Error {
|
|
|
90
90
|
}
|
|
91
91
|
};
|
|
92
92
|
|
|
93
|
+
//#endregion
|
|
94
|
+
//#region src/request-deadline.ts
|
|
95
|
+
/**
|
|
96
|
+
* Request deadlines, shared by every JS SDK target (the same file ships in
|
|
97
|
+
* `@layers/amba-web`, `@layers/amba-node` and `@layers/amba-react-native`).
|
|
98
|
+
*
|
|
99
|
+
* Every request the SDK sends runs under a deadline that covers sending it
|
|
100
|
+
* and reading the whole response. When the deadline passes, the request is
|
|
101
|
+
* aborted and fails with the SDK's retryable network error, so one request
|
|
102
|
+
* can never hold an in-flight slot (the offline outbox flush, the lazy
|
|
103
|
+
* anonymous sign-in, the session refresh, the event batch flush) for the
|
|
104
|
+
* life of the process. Some runtimes never settle a fetch they gave up on:
|
|
105
|
+
* Expo on Android drops a failed DNS lookup and the promise stays pending.
|
|
106
|
+
* The deadline wins the race even then, because it rejects on its own timer
|
|
107
|
+
* and then aborts the fetch for the runtime to clean up.
|
|
108
|
+
*
|
|
109
|
+
* Two bounds:
|
|
110
|
+
* - `requestTimeoutMs` (default 15 s): an ordinary API request.
|
|
111
|
+
* - `longRequestTimeoutMs` (default 5 min): work that legitimately runs
|
|
112
|
+
* longer. AI generation (`/v1/client/ai/*`), function invocations, and
|
|
113
|
+
* media uploads and downloads.
|
|
114
|
+
* Both grow with the bytes a request moves (see `withTransferAllowance`), so
|
|
115
|
+
* a large upload, download or event batch on a slow link has time to finish.
|
|
116
|
+
* Realtime subscriptions are bounded until their stream opens; AI response
|
|
117
|
+
* streams likewise until the response starts. Once open, a stream runs for
|
|
118
|
+
* as long as the caller keeps it.
|
|
119
|
+
*/
|
|
120
|
+
/** Default deadline for one API request, in milliseconds. */
|
|
121
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 15e3;
|
|
122
|
+
/**
|
|
123
|
+
* Default deadline for AI generation, function invocations and media
|
|
124
|
+
* transfers, in milliseconds.
|
|
125
|
+
*/
|
|
126
|
+
const DEFAULT_LONG_REQUEST_TIMEOUT_MS = 3e5;
|
|
127
|
+
/**
|
|
128
|
+
* The slowest link a deadline makes room for, in bytes per second
|
|
129
|
+
* (8 KiB/s, about 64 kbit/s). Each byte a request sends or receives beyond
|
|
130
|
+
* the first {@link TRANSFER_BYTES_WITHIN_BASE} adds the time it takes at this
|
|
131
|
+
* rate to the deadline.
|
|
132
|
+
*/
|
|
133
|
+
const MIN_TRANSFER_BYTES_PER_SECOND = 8 * 1024;
|
|
134
|
+
/**
|
|
135
|
+
* Bytes a request moves within its base deadline (64 KiB: 8 s at the
|
|
136
|
+
* slowest rate). An ordinary API request stays at exactly
|
|
137
|
+
* `requestTimeoutMs`; only larger bodies widen it.
|
|
138
|
+
*/
|
|
139
|
+
const TRANSFER_BYTES_WITHIN_BASE = 64 * 1024;
|
|
140
|
+
/** The largest delay `setTimeout` honors (about 24.8 days). */
|
|
141
|
+
const MAX_TIMEOUT_MS = 2147483647;
|
|
142
|
+
/**
|
|
143
|
+
* Read the deadlines from the configure options, applying the defaults.
|
|
144
|
+
* Throws an `Error` naming the option when a value is not a positive number
|
|
145
|
+
* of milliseconds.
|
|
146
|
+
*/
|
|
147
|
+
function resolveRequestDeadlines(options) {
|
|
148
|
+
return {
|
|
149
|
+
requestMs: readTimeout("Amba.configure: requestTimeoutMs", options.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS),
|
|
150
|
+
longMs: readTimeout("Amba.configure: longRequestTimeoutMs", options.longRequestTimeoutMs, DEFAULT_LONG_REQUEST_TIMEOUT_MS)
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Validate one timeout value; `undefined` keeps `fallback`. `label` names the
|
|
155
|
+
* option in the thrown `Error` (`Amba.configure: requestTimeoutMs`).
|
|
156
|
+
*/
|
|
157
|
+
function readTimeout(label, value, fallback) {
|
|
158
|
+
if (value === void 0) return fallback;
|
|
159
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value > MAX_TIMEOUT_MS) throw new Error(`${label} must be a positive number of milliseconds, at most ${MAX_TIMEOUT_MS}`);
|
|
160
|
+
return value;
|
|
161
|
+
}
|
|
162
|
+
/** The text a deadline failure carries, e.g. `request timed out after 15000 ms`. */
|
|
163
|
+
function timeoutMessage(timeoutMs) {
|
|
164
|
+
return `request timed out after ${timeoutMs} ms`;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* `timeoutMs` plus the time the bytes beyond the first
|
|
168
|
+
* {@link TRANSFER_BYTES_WITHIN_BASE} take at
|
|
169
|
+
* {@link MIN_TRANSFER_BYTES_PER_SECOND}, capped at the largest delay
|
|
170
|
+
* `setTimeout` honors. Unknown sizes add nothing.
|
|
171
|
+
*/
|
|
172
|
+
function withTransferAllowance(timeoutMs, bytes) {
|
|
173
|
+
const beyond = typeof bytes === "number" && Number.isFinite(bytes) ? bytes - TRANSFER_BYTES_WITHIN_BASE : 0;
|
|
174
|
+
const extra = beyond > 0 ? Math.ceil(beyond / MIN_TRANSFER_BYTES_PER_SECOND * 1e3) : 0;
|
|
175
|
+
return Math.min(MAX_TIMEOUT_MS, timeoutMs + extra);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* The size of a request body in bytes: a string's UTF-8 length, a buffer's
|
|
179
|
+
* byte length, a `Blob`'s size. `undefined` for no body or a body of unknown
|
|
180
|
+
* size (a stream).
|
|
181
|
+
*/
|
|
182
|
+
function bodyByteLength(body) {
|
|
183
|
+
if (body === void 0 || body === null) return void 0;
|
|
184
|
+
if (typeof body === "string") return typeof TextEncoder !== "undefined" ? new TextEncoder().encode(body).length : body.length;
|
|
185
|
+
if (typeof ArrayBuffer !== "undefined") {
|
|
186
|
+
if (body instanceof ArrayBuffer) return body.byteLength;
|
|
187
|
+
if (ArrayBuffer.isView(body)) return body.byteLength;
|
|
188
|
+
}
|
|
189
|
+
const size = body.size;
|
|
190
|
+
return typeof size === "number" ? size : void 0;
|
|
191
|
+
}
|
|
192
|
+
/** A response's `Content-Length`, when it states one. */
|
|
193
|
+
function contentLength(response) {
|
|
194
|
+
const raw = response.headers?.get?.("content-length");
|
|
195
|
+
if (!raw) return void 0;
|
|
196
|
+
const n = Number(raw);
|
|
197
|
+
return Number.isFinite(n) && n >= 0 ? n : void 0;
|
|
198
|
+
}
|
|
199
|
+
/** The paths under the API base URL whose requests get the long deadline. */
|
|
200
|
+
const LONG_API_PATH_PREFIXES = ["/v1/client/ai/"];
|
|
201
|
+
/** Whether a request to API `path` (starting `/v1/...`) gets the long deadline. */
|
|
202
|
+
function isLongApiPath(path) {
|
|
203
|
+
return LONG_API_PATH_PREFIXES.some((prefix) => path.startsWith(prefix));
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Race `work` against a timer of `ms`. When the timer fires first the race
|
|
207
|
+
* rejects with `onTimeout(ms)`, then `controller` is aborted so the runtime
|
|
208
|
+
* can release the connection. The rejection does not wait for `work`, so a
|
|
209
|
+
* fetch the runtime never settles still releases its caller on time. The
|
|
210
|
+
* timer is cleared as soon as either side settles.
|
|
211
|
+
*/
|
|
212
|
+
function raceDeadline(work, ms, controller, onTimeout) {
|
|
213
|
+
let timer;
|
|
214
|
+
const deadline = new Promise((_, reject) => {
|
|
215
|
+
timer = setTimeout(() => {
|
|
216
|
+
reject(onTimeout(ms));
|
|
217
|
+
try {
|
|
218
|
+
controller?.abort();
|
|
219
|
+
} catch {}
|
|
220
|
+
}, ms);
|
|
221
|
+
});
|
|
222
|
+
return Promise.race([work, deadline]).finally(() => {
|
|
223
|
+
clearTimeout(timer);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
function newController() {
|
|
227
|
+
return typeof AbortController === "function" ? new AbortController() : void 0;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Send a request and read its response under deadlines that grow with the
|
|
231
|
+
* bytes involved. Sending, until `send` resolves with the response, gets
|
|
232
|
+
* `timeoutMs` plus the allowance for the request body (and, with
|
|
233
|
+
* `fetchBuffersBody`, for the expected response bytes). Reading,
|
|
234
|
+
* `read(response)`, gets `timeoutMs` plus the allowance for the expected
|
|
235
|
+
* response bytes, else the response's `Content-Length`. One `AbortSignal`
|
|
236
|
+
* covers both phases, so a read past its deadline aborts the transfer too.
|
|
237
|
+
* Past either deadline the promise rejects with `onTimeout(ms)`.
|
|
238
|
+
*/
|
|
239
|
+
async function exchange(timeoutMs, send, read, onTimeout, sizes = {}) {
|
|
240
|
+
const controller = newController();
|
|
241
|
+
const sendMs = withTransferAllowance(timeoutMs, (sizes.requestBytes ?? 0) + (sizes.fetchBuffersBody ? sizes.responseBytes ?? 0 : 0));
|
|
242
|
+
const response = await raceDeadline(send(controller?.signal), sendMs, controller, onTimeout);
|
|
243
|
+
const readMs = withTransferAllowance(timeoutMs, sizes.responseBytes ?? contentLength(response));
|
|
244
|
+
return raceDeadline(read(response), readMs, controller, onTimeout);
|
|
245
|
+
}
|
|
246
|
+
/** `init` with `signal` set, when there is one. */
|
|
247
|
+
function withSignal(init, signal) {
|
|
248
|
+
return signal ? {
|
|
249
|
+
...init,
|
|
250
|
+
signal
|
|
251
|
+
} : init;
|
|
252
|
+
}
|
|
253
|
+
|
|
93
254
|
//#endregion
|
|
94
255
|
//#region src/http-client.ts
|
|
95
256
|
/** Default amba API endpoint. */
|
|
@@ -106,6 +267,12 @@ var AmbaHttpClient = class {
|
|
|
106
267
|
deviceOs;
|
|
107
268
|
identity;
|
|
108
269
|
fetchImpl;
|
|
270
|
+
/** The `fetchImpl` option, when one was passed (`send` resolves fetch per call otherwise). */
|
|
271
|
+
injectedFetch;
|
|
272
|
+
/** Deadline for an API request, in milliseconds. */
|
|
273
|
+
requestTimeoutMs;
|
|
274
|
+
/** Deadline for AI generation, function invocations and media transfers. */
|
|
275
|
+
longRequestTimeoutMs;
|
|
109
276
|
ensureSession;
|
|
110
277
|
onSessionRefused;
|
|
111
278
|
constructor(opts) {
|
|
@@ -119,7 +286,39 @@ var AmbaHttpClient = class {
|
|
|
119
286
|
this.identity = opts.identity;
|
|
120
287
|
this.ensureSession = opts.ensureSession;
|
|
121
288
|
this.onSessionRefused = opts.onSessionRefused;
|
|
289
|
+
this.injectedFetch = opts.fetchImpl;
|
|
122
290
|
this.fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
291
|
+
this.requestTimeoutMs = opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
292
|
+
this.longRequestTimeoutMs = opts.longRequestTimeoutMs ?? DEFAULT_LONG_REQUEST_TIMEOUT_MS;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Send a request to an absolute URL outside the API's JSON envelope (a
|
|
296
|
+
* function invocation, a CDN download, the config route that reads the
|
|
297
|
+
* `ETag`) under a deadline. `read` takes what the caller needs from the
|
|
298
|
+
* response (the body as text, a `Blob`) and runs inside the same
|
|
299
|
+
* deadline, so a body that stops arriving fails too.
|
|
300
|
+
*
|
|
301
|
+
* A transport failure rejects with `NETWORK_ERROR` and the message
|
|
302
|
+
* `<label>: <cause>`; the deadline rejects with `NETWORK_ERROR` and
|
|
303
|
+
* `<label>: request timed out after <ms> ms`. `label` defaults to
|
|
304
|
+
* `Network error` and `timeoutMs` to the request deadline; the deadline
|
|
305
|
+
* widens with the request body and with `responseBytes`, the size the
|
|
306
|
+
* caller expects to download (see `exchange`). An `AmbaApiError` that
|
|
307
|
+
* `read` throws passes through unchanged.
|
|
308
|
+
*/
|
|
309
|
+
async send(url, init, read, opts = {}) {
|
|
310
|
+
const timeoutMs = opts.timeoutMs ?? this.requestTimeoutMs;
|
|
311
|
+
const label = opts.label ?? "Network error";
|
|
312
|
+
const fetchFn = this.injectedFetch ?? globalThis.fetch;
|
|
313
|
+
try {
|
|
314
|
+
return await exchange(timeoutMs, (signal) => fetchFn(url, withSignal(init, signal)), read, (ms) => timeoutError(label, ms), {
|
|
315
|
+
requestBytes: bodyByteLength(init.body),
|
|
316
|
+
responseBytes: opts.responseBytes,
|
|
317
|
+
fetchBuffersBody: true
|
|
318
|
+
});
|
|
319
|
+
} catch (err) {
|
|
320
|
+
throw asNetworkError(err, label);
|
|
321
|
+
}
|
|
123
322
|
}
|
|
124
323
|
/** Build the absolute URL for an API path, optionally with a query string. */
|
|
125
324
|
url(path, query) {
|
|
@@ -179,14 +378,19 @@ var AmbaHttpClient = class {
|
|
|
179
378
|
headers,
|
|
180
379
|
body: body === void 0 ? void 0 : JSON.stringify(body ?? {})
|
|
181
380
|
};
|
|
381
|
+
const timeoutMs = isLongApiPath(path) ? this.longRequestTimeoutMs : this.requestTimeoutMs;
|
|
182
382
|
let response;
|
|
383
|
+
let text;
|
|
183
384
|
try {
|
|
184
|
-
response = await this.fetchImpl(url, init)
|
|
385
|
+
({response, text} = await exchange(timeoutMs, (signal) => this.fetchImpl(url, withSignal(init, signal)), async (res) => ({
|
|
386
|
+
response: res,
|
|
387
|
+
text: res.status === 204 ? "" : await res.text()
|
|
388
|
+
}), (ms) => timeoutError("Network error", ms), { requestBytes: bodyByteLength(init.body) }));
|
|
185
389
|
} catch (err) {
|
|
186
|
-
throw
|
|
390
|
+
throw asNetworkError(err, "Network error");
|
|
187
391
|
}
|
|
188
392
|
try {
|
|
189
|
-
return
|
|
393
|
+
return parseResponse(response, text);
|
|
190
394
|
} catch (err) {
|
|
191
395
|
if (!resent && storedToken && this.onSessionRefused && !path.startsWith("/v1/client/auth/") && err instanceof AmbaApiError && SESSION_REFUSED_CODES.has(err.code) && await this.recoverSession(storedToken, storedUserId)) return this.request(method, path, body, query, void 0, true);
|
|
192
396
|
throw err;
|
|
@@ -201,9 +405,21 @@ var AmbaHttpClient = class {
|
|
|
201
405
|
}
|
|
202
406
|
}
|
|
203
407
|
};
|
|
204
|
-
|
|
408
|
+
/** The `NETWORK_ERROR` a passed deadline raises. */
|
|
409
|
+
function timeoutError(label, timeoutMs) {
|
|
410
|
+
return new AmbaApiError("NETWORK_ERROR", `${label}: ${timeoutMessage(timeoutMs)}`, { timeoutMs });
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Wrap a transport failure as `NETWORK_ERROR` (`<label>: <cause>`). An
|
|
414
|
+
* `AmbaApiError` (the deadline's own, or one a `send` reader throws) passes
|
|
415
|
+
* through unchanged.
|
|
416
|
+
*/
|
|
417
|
+
function asNetworkError(err, label) {
|
|
418
|
+
if (err instanceof AmbaApiError) return err;
|
|
419
|
+
return new AmbaApiError("NETWORK_ERROR", `${label}: ${err instanceof Error ? err.message : typeof err === "string" ? err : "fetch failed"}`, err);
|
|
420
|
+
}
|
|
421
|
+
function parseResponse(response, text) {
|
|
205
422
|
if (response.status === 204) return null;
|
|
206
|
-
const text = await response.text();
|
|
207
423
|
const isJson = (response.headers.get("content-type") ?? "").includes("application/json");
|
|
208
424
|
if (!response.ok) throw mapHttpError(response, text, isJson);
|
|
209
425
|
if (text === "") return null;
|
|
@@ -899,7 +1115,16 @@ const DETERMINISTIC_ERROR_CODES = new Set([
|
|
|
899
1115
|
"NOT_FOUND",
|
|
900
1116
|
"CONFLICT",
|
|
901
1117
|
"CONSENT_NOT_GRANTED",
|
|
902
|
-
"NOT_INITIALIZED"
|
|
1118
|
+
"NOT_INITIALIZED",
|
|
1119
|
+
"INVALID_JSON",
|
|
1120
|
+
"INVALID_BODY",
|
|
1121
|
+
"INVALID_EVENT",
|
|
1122
|
+
"RESERVED_EVENT_NAME",
|
|
1123
|
+
"INVALID_IDEMPOTENCY_KEY",
|
|
1124
|
+
"INVALID_TIMESTAMP",
|
|
1125
|
+
"INVALID_PROPERTIES",
|
|
1126
|
+
"INVALID_TELEMETRY",
|
|
1127
|
+
"IDEMPOTENCY_KEY_CONFLICT"
|
|
903
1128
|
]);
|
|
904
1129
|
/**
|
|
905
1130
|
* Is this a deterministic server rejection — a coded error the server would
|
|
@@ -937,13 +1162,34 @@ function isOfflineError(err) {
|
|
|
937
1162
|
if (typeof msg === "string" && /network (error|request failed)|failed to fetch|fetch failed|load failed/i.test(msg)) return true;
|
|
938
1163
|
return !isOnline();
|
|
939
1164
|
}
|
|
940
|
-
/**
|
|
1165
|
+
/**
|
|
1166
|
+
* Generate a stable idempotency key for a queued op: `amba_obx_` and a random
|
|
1167
|
+
* UUID. The server records event keys project-wide, so the UUID comes from
|
|
1168
|
+
* the runtime's secure random source: `crypto.randomUUID`, else
|
|
1169
|
+
* `crypto.getRandomValues` (Hermes has only the latter).
|
|
1170
|
+
*/
|
|
941
1171
|
function newIdempotencyKey() {
|
|
942
1172
|
const c = typeof globalThis !== "undefined" ? globalThis.crypto : void 0;
|
|
943
1173
|
if (c && typeof c.randomUUID === "function") return `amba_obx_${c.randomUUID()}`;
|
|
1174
|
+
if (c && typeof c.getRandomValues === "function") {
|
|
1175
|
+
const b = c.getRandomValues(new Uint8Array(16));
|
|
1176
|
+
b[6] = b[6] & 15 | 64;
|
|
1177
|
+
b[8] = b[8] & 63 | 128;
|
|
1178
|
+
const h = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
|
1179
|
+
return `amba_obx_${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
|
1180
|
+
}
|
|
944
1181
|
return `amba_obx_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
|
|
945
1182
|
}
|
|
946
1183
|
/**
|
|
1184
|
+
* Whether a `track` with these properties can take the events route's keyed
|
|
1185
|
+
* path, which accepts a plain object or no properties. A JS caller can pass
|
|
1186
|
+
* something else (an array); such an event stays on the unkeyed path, which
|
|
1187
|
+
* stores it as it always has.
|
|
1188
|
+
*/
|
|
1189
|
+
function keyableProperties(properties) {
|
|
1190
|
+
return properties === void 0 || properties === null || typeof properties === "object" && !Array.isArray(properties);
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
947
1193
|
* Durable, ordered, idempotent offline outbox. Opt-in via `Amba.offline`.
|
|
948
1194
|
* Single in-flight flush is enforced so concurrent reconnect signals don't
|
|
949
1195
|
* double-replay.
|
|
@@ -1824,6 +2070,296 @@ function logListenerError(err) {
|
|
|
1824
2070
|
console.warn("[amba] onExperimentAssigned listener threw", err);
|
|
1825
2071
|
}
|
|
1826
2072
|
|
|
2073
|
+
//#endregion
|
|
2074
|
+
//#region src/remote-values.ts
|
|
2075
|
+
/** Prefix of the per-project AsyncStorage record key. */
|
|
2076
|
+
const CACHE_KEY_PREFIX = "amba_rc_v1_";
|
|
2077
|
+
/** Slot for values evaluated without a signed-in user. */
|
|
2078
|
+
const ANON_SLOT = "anon";
|
|
2079
|
+
/** Most user slots one project record keeps. */
|
|
2080
|
+
const MAX_SLOTS = 5;
|
|
2081
|
+
const TWO_32 = 4294967296;
|
|
2082
|
+
/**
|
|
2083
|
+
* 64-bit FNV-1a over the string's UTF-16 code units (low byte, then high
|
|
2084
|
+
* byte), as 16 hex digits. Computed in two 32-bit halves: some React Native
|
|
2085
|
+
* engines (JavaScriptCore on older Android builds) have no BigInt.
|
|
2086
|
+
*/
|
|
2087
|
+
function fnv1a64Hex(text) {
|
|
2088
|
+
let hi = 3421674724;
|
|
2089
|
+
let lo = 2216829733;
|
|
2090
|
+
const step = (byte) => {
|
|
2091
|
+
lo = (lo ^ byte) >>> 0;
|
|
2092
|
+
const low = lo * 435;
|
|
2093
|
+
const carry = Math.floor(low / TWO_32);
|
|
2094
|
+
const high = hi * 435 + carry + (lo << 8 >>> 0);
|
|
2095
|
+
lo = low % TWO_32;
|
|
2096
|
+
hi = high % TWO_32;
|
|
2097
|
+
};
|
|
2098
|
+
for (let i = 0; i < text.length; i++) {
|
|
2099
|
+
const unit = text.charCodeAt(i);
|
|
2100
|
+
step(unit & 255);
|
|
2101
|
+
step(unit >>> 8);
|
|
2102
|
+
}
|
|
2103
|
+
return hi.toString(16).padStart(8, "0") + lo.toString(16).padStart(8, "0");
|
|
2104
|
+
}
|
|
2105
|
+
/** The record key for a project: a hash of the base URL and client key. */
|
|
2106
|
+
function recordKey(baseUrl, clientKey) {
|
|
2107
|
+
return `${CACHE_KEY_PREFIX}${fnv1a64Hex(`${baseUrl.replace(/\/+$/, "")}\n${clientKey}`)}`;
|
|
2108
|
+
}
|
|
2109
|
+
/** The slot key for an identity: `u` + a hash of the user id, or `anon`. */
|
|
2110
|
+
function slotKey(appUserId) {
|
|
2111
|
+
return appUserId ? `u${fnv1a64Hex(appUserId)}` : ANON_SLOT;
|
|
2112
|
+
}
|
|
2113
|
+
/**
|
|
2114
|
+
* The user a config response was evaluated for, from its `ETag`:
|
|
2115
|
+
* `"<version>:<app user id>:<segments>"` for a signed-in caller, `"<version>"`
|
|
2116
|
+
* for every other one (no session, or an expired or revoked token, which the
|
|
2117
|
+
* server answers with each key's default value). `undefined` without an
|
|
2118
|
+
* `ETag`; `null` for an evaluation for nobody.
|
|
2119
|
+
*/
|
|
2120
|
+
function etagEvaluatedFor(etag) {
|
|
2121
|
+
if (!etag) return void 0;
|
|
2122
|
+
const parts = etag.trim().replace(/^W\//, "").replace(/^"|"$/g, "").split(":");
|
|
2123
|
+
return parts.length > 1 && parts[1] ? parts[1] : null;
|
|
2124
|
+
}
|
|
2125
|
+
function isPlainObject(value) {
|
|
2126
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2127
|
+
}
|
|
2128
|
+
function validSlot(raw) {
|
|
2129
|
+
if (!isPlainObject(raw)) return null;
|
|
2130
|
+
const slot = { saved_at: typeof raw["saved_at"] === "number" ? raw["saved_at"] : 0 };
|
|
2131
|
+
const config = raw["config"];
|
|
2132
|
+
if (isPlainObject(config) && isPlainObject(config["values"])) slot.config = {
|
|
2133
|
+
version: typeof config["version"] === "string" ? config["version"] : null,
|
|
2134
|
+
values: config["values"]
|
|
2135
|
+
};
|
|
2136
|
+
const flags = raw["flags"];
|
|
2137
|
+
if (Array.isArray(flags)) slot.flags = flags.filter((f) => isPlainObject(f) && typeof f["name"] === "string" && typeof f["enabled"] === "boolean");
|
|
2138
|
+
return slot.config || slot.flags ? slot : null;
|
|
2139
|
+
}
|
|
2140
|
+
function upsert(list, flag) {
|
|
2141
|
+
const i = list.findIndex((f) => f.name === flag.name);
|
|
2142
|
+
if (i >= 0) list[i] = flag;
|
|
2143
|
+
else list.push(flag);
|
|
2144
|
+
}
|
|
2145
|
+
var RemoteValuesCache = class {
|
|
2146
|
+
record = {
|
|
2147
|
+
v: 1,
|
|
2148
|
+
slots: {}
|
|
2149
|
+
};
|
|
2150
|
+
/** The slot key the held values were resolved for. */
|
|
2151
|
+
viewFor = null;
|
|
2152
|
+
config = null;
|
|
2153
|
+
flags = null;
|
|
2154
|
+
/** Bumped by every sign-out; an older ticket's response is not cached. */
|
|
2155
|
+
generation = 0;
|
|
2156
|
+
writes = Promise.resolve();
|
|
2157
|
+
constructor(store, key, defaults, currentUser) {
|
|
2158
|
+
this.store = store;
|
|
2159
|
+
this.key = key;
|
|
2160
|
+
this.defaults = defaults;
|
|
2161
|
+
this.currentUser = currentUser;
|
|
2162
|
+
}
|
|
2163
|
+
/** Read the project's record from storage. `configure()` awaits it. */
|
|
2164
|
+
async load() {
|
|
2165
|
+
let raw = null;
|
|
2166
|
+
try {
|
|
2167
|
+
raw = await this.store.read(this.key);
|
|
2168
|
+
} catch {
|
|
2169
|
+
return;
|
|
2170
|
+
}
|
|
2171
|
+
if (!raw) return;
|
|
2172
|
+
try {
|
|
2173
|
+
const parsed = JSON.parse(raw);
|
|
2174
|
+
const slots = {};
|
|
2175
|
+
if (isPlainObject(parsed) && isPlainObject(parsed["slots"])) for (const [k, v] of Object.entries(parsed["slots"])) {
|
|
2176
|
+
const slot = validSlot(v);
|
|
2177
|
+
if (slot) slots[k] = slot;
|
|
2178
|
+
}
|
|
2179
|
+
this.record = {
|
|
2180
|
+
v: 1,
|
|
2181
|
+
slots
|
|
2182
|
+
};
|
|
2183
|
+
} catch {
|
|
2184
|
+
this.record = {
|
|
2185
|
+
v: 1,
|
|
2186
|
+
slots: {}
|
|
2187
|
+
};
|
|
2188
|
+
this.persist();
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
/** Take a ticket for a request about to be sent for the current user. */
|
|
2192
|
+
ticket() {
|
|
2193
|
+
return {
|
|
2194
|
+
generation: this.generation,
|
|
2195
|
+
requestedFor: this.currentUser()
|
|
2196
|
+
};
|
|
2197
|
+
}
|
|
2198
|
+
/** The value for config `key` (fetched, cached, then default); `undefined` when none. */
|
|
2199
|
+
configValue(key) {
|
|
2200
|
+
this.ensureView();
|
|
2201
|
+
const values = this.config?.values;
|
|
2202
|
+
if (values && Object.prototype.hasOwnProperty.call(values, key)) {
|
|
2203
|
+
const v = values[key];
|
|
2204
|
+
if (v !== null && v !== void 0) return v;
|
|
2205
|
+
}
|
|
2206
|
+
return this.defaultFor(key);
|
|
2207
|
+
}
|
|
2208
|
+
/** The held assignment for flag `key`, or `null`. */
|
|
2209
|
+
flag(key) {
|
|
2210
|
+
this.ensureView();
|
|
2211
|
+
return this.flags?.find((f) => f.name === key) ?? null;
|
|
2212
|
+
}
|
|
2213
|
+
/** Whether flag `key` is on: the held assignment, then a boolean default. */
|
|
2214
|
+
flagEnabled(key) {
|
|
2215
|
+
const flag = this.flag(key);
|
|
2216
|
+
if (flag) return flag.enabled;
|
|
2217
|
+
const d = this.defaultFor(key);
|
|
2218
|
+
return typeof d === "boolean" ? d : void 0;
|
|
2219
|
+
}
|
|
2220
|
+
/**
|
|
2221
|
+
* Hold and persist a fetched bundle; returns it. A bundle the server
|
|
2222
|
+
* evaluated for nobody although the request carried a user (an expired or
|
|
2223
|
+
* revoked session) goes to the `anon` slot and leaves the user's values
|
|
2224
|
+
* alone; one evaluated for another user is not cached.
|
|
2225
|
+
*/
|
|
2226
|
+
storeConfig(ticket, bundle, etag) {
|
|
2227
|
+
if (!isPlainObject(bundle.values)) return bundle;
|
|
2228
|
+
const evaluatedFor = etagEvaluatedFor(etag);
|
|
2229
|
+
let target;
|
|
2230
|
+
if (ticket.requestedFor && evaluatedFor === null) target = ANON_SLOT;
|
|
2231
|
+
else if (typeof evaluatedFor === "string" && evaluatedFor !== ticket.requestedFor) return bundle;
|
|
2232
|
+
this.write(ticket, target, (slot, held) => {
|
|
2233
|
+
slot.config = bundle;
|
|
2234
|
+
if (held) this.config = bundle;
|
|
2235
|
+
});
|
|
2236
|
+
return bundle;
|
|
2237
|
+
}
|
|
2238
|
+
/** Hold and persist a fetched flag list (it replaces the held one). */
|
|
2239
|
+
storeFlags(ticket, flags) {
|
|
2240
|
+
this.write(ticket, void 0, (slot, held) => {
|
|
2241
|
+
slot.flags = [...flags];
|
|
2242
|
+
if (held) this.flags = [...flags];
|
|
2243
|
+
});
|
|
2244
|
+
return flags;
|
|
2245
|
+
}
|
|
2246
|
+
/** Hold and persist one flag from a single-flag lookup; returns it. */
|
|
2247
|
+
upsertFlag(ticket, flag) {
|
|
2248
|
+
this.write(ticket, void 0, (slot, held) => {
|
|
2249
|
+
this.adoptHeldFlags(slot, held);
|
|
2250
|
+
slot.flags = slot.flags ?? [];
|
|
2251
|
+
upsert(slot.flags, flag);
|
|
2252
|
+
if (held) {
|
|
2253
|
+
this.flags = this.flags ?? [];
|
|
2254
|
+
upsert(this.flags, flag);
|
|
2255
|
+
}
|
|
2256
|
+
});
|
|
2257
|
+
return flag;
|
|
2258
|
+
}
|
|
2259
|
+
/** Drop flag `key` after a single-flag lookup found it off or unknown. */
|
|
2260
|
+
removeFlag(ticket, key) {
|
|
2261
|
+
this.write(ticket, void 0, (slot, held) => {
|
|
2262
|
+
this.adoptHeldFlags(slot, held);
|
|
2263
|
+
if (slot.flags) slot.flags = slot.flags.filter((f) => f.name !== key);
|
|
2264
|
+
if (held && this.flags) this.flags = this.flags.filter((f) => f.name !== key);
|
|
2265
|
+
});
|
|
2266
|
+
}
|
|
2267
|
+
/**
|
|
2268
|
+
* Sign-out: delete the signed-out user's slot and drop the held values;
|
|
2269
|
+
* later lookups serve the `anon` slot, then the defaults.
|
|
2270
|
+
*/
|
|
2271
|
+
clearForSignOut(signedOutUser) {
|
|
2272
|
+
this.generation += 1;
|
|
2273
|
+
this.config = null;
|
|
2274
|
+
this.flags = null;
|
|
2275
|
+
this.viewFor = null;
|
|
2276
|
+
if (!signedOutUser) return;
|
|
2277
|
+
const doomed = slotKey(signedOutUser);
|
|
2278
|
+
if (this.record.slots[doomed]) {
|
|
2279
|
+
delete this.record.slots[doomed];
|
|
2280
|
+
this.persist();
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
/** Resolves when every write so far reached storage. */
|
|
2284
|
+
flushed() {
|
|
2285
|
+
return this.writes;
|
|
2286
|
+
}
|
|
2287
|
+
/**
|
|
2288
|
+
* Before a single-flag change to the current user's slot: when the slot
|
|
2289
|
+
* holds no flag list and the served list is the signed-out one, the
|
|
2290
|
+
* user's slot starts from that list, so after a relaunch the user reads
|
|
2291
|
+
* the same flags the change was made on.
|
|
2292
|
+
*/
|
|
2293
|
+
adoptHeldFlags(slot, held) {
|
|
2294
|
+
if (held && !slot.flags && this.flags) slot.flags = [...this.flags];
|
|
2295
|
+
}
|
|
2296
|
+
defaultFor(key) {
|
|
2297
|
+
if (!Object.prototype.hasOwnProperty.call(this.defaults, key)) return void 0;
|
|
2298
|
+
const v = this.defaults[key];
|
|
2299
|
+
return v === null ? void 0 : v;
|
|
2300
|
+
}
|
|
2301
|
+
/**
|
|
2302
|
+
* `held` is `true` when the slot written is the one the current identity
|
|
2303
|
+
* reads, so the served values change with it.
|
|
2304
|
+
*/
|
|
2305
|
+
write(ticket, targetOverride, apply) {
|
|
2306
|
+
const current = slotKey(this.currentUser());
|
|
2307
|
+
if (ticket.generation !== this.generation || slotKey(ticket.requestedFor) !== current) return;
|
|
2308
|
+
this.ensureView();
|
|
2309
|
+
const target = targetOverride ?? current;
|
|
2310
|
+
const existing = this.record.slots[target];
|
|
2311
|
+
const slot = existing ? {
|
|
2312
|
+
saved_at: existing.saved_at,
|
|
2313
|
+
...existing.config ? { config: existing.config } : {},
|
|
2314
|
+
...existing.flags ? { flags: [...existing.flags] } : {}
|
|
2315
|
+
} : { saved_at: 0 };
|
|
2316
|
+
apply(slot, target === current);
|
|
2317
|
+
if (!slot.config && !slot.flags) {
|
|
2318
|
+
if (!existing) return;
|
|
2319
|
+
delete this.record.slots[target];
|
|
2320
|
+
} else {
|
|
2321
|
+
slot.saved_at = Date.now();
|
|
2322
|
+
this.record.slots[target] = slot;
|
|
2323
|
+
this.prune(target);
|
|
2324
|
+
}
|
|
2325
|
+
this.persist();
|
|
2326
|
+
if (target !== current) this.viewFor = null;
|
|
2327
|
+
}
|
|
2328
|
+
/** Serve the current identity's own slot, the `anon` slot for the rest. */
|
|
2329
|
+
ensureView() {
|
|
2330
|
+
const current = slotKey(this.currentUser());
|
|
2331
|
+
if (this.viewFor === current) return;
|
|
2332
|
+
const own = this.record.slots[current];
|
|
2333
|
+
const anon = current === ANON_SLOT ? void 0 : this.record.slots[ANON_SLOT];
|
|
2334
|
+
const config = own?.config ?? anon?.config;
|
|
2335
|
+
const flags = own?.flags ?? anon?.flags;
|
|
2336
|
+
this.config = config ?? null;
|
|
2337
|
+
this.flags = flags ? [...flags] : null;
|
|
2338
|
+
this.viewFor = current;
|
|
2339
|
+
}
|
|
2340
|
+
/**
|
|
2341
|
+
* Keep the MAX_SLOTS most recently written user slots; `keep` and the
|
|
2342
|
+
* signed-out slot (read before sign-in and for every user's gaps) stay.
|
|
2343
|
+
*/
|
|
2344
|
+
prune(keep) {
|
|
2345
|
+
const keys = Object.keys(this.record.slots).filter((k) => k !== ANON_SLOT);
|
|
2346
|
+
while (keys.length > MAX_SLOTS) {
|
|
2347
|
+
let oldest = null;
|
|
2348
|
+
for (const k of keys) {
|
|
2349
|
+
if (k === keep) continue;
|
|
2350
|
+
if (oldest === null || this.record.slots[k].saved_at < this.record.slots[oldest].saved_at) oldest = k;
|
|
2351
|
+
}
|
|
2352
|
+
if (oldest === null) break;
|
|
2353
|
+
delete this.record.slots[oldest];
|
|
2354
|
+
keys.splice(keys.indexOf(oldest), 1);
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2357
|
+
persist() {
|
|
2358
|
+
const text = Object.keys(this.record.slots).length === 0 ? null : JSON.stringify(this.record);
|
|
2359
|
+
this.writes = this.writes.then(() => text === null ? this.store.delete(this.key) : this.store.write(this.key, text)).catch(() => {});
|
|
2360
|
+
}
|
|
2361
|
+
};
|
|
2362
|
+
|
|
1827
2363
|
//#endregion
|
|
1828
2364
|
//#region src/client-info.ts
|
|
1829
2365
|
/**
|
|
@@ -1924,6 +2460,7 @@ var CollectionSubscription = class {
|
|
|
1924
2460
|
return openStream({
|
|
1925
2461
|
url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/collections/${pathSegment(this.name)}${qs}`,
|
|
1926
2462
|
authHeaders: this.ctx.authHeaders,
|
|
2463
|
+
connectTimeoutMs: this.ctx.connectTimeoutMs,
|
|
1927
2464
|
onChange: cb,
|
|
1928
2465
|
options,
|
|
1929
2466
|
parse: parseRowChangeFrame
|
|
@@ -1948,6 +2485,7 @@ var ConversationSubscription = class {
|
|
|
1948
2485
|
return openStream({
|
|
1949
2486
|
url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/conversations/${pathSegment(this.conversationId)}`,
|
|
1950
2487
|
authHeaders: this.ctx.authHeaders,
|
|
2488
|
+
connectTimeoutMs: this.ctx.connectTimeoutMs,
|
|
1951
2489
|
onChange: cb,
|
|
1952
2490
|
options,
|
|
1953
2491
|
parse: parseRowChangeFrame
|
|
@@ -1972,6 +2510,7 @@ var GamificationSubscription = class {
|
|
|
1972
2510
|
return openStream({
|
|
1973
2511
|
url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/gamification`,
|
|
1974
2512
|
authHeaders: this.ctx.authHeaders,
|
|
2513
|
+
connectTimeoutMs: this.ctx.connectTimeoutMs,
|
|
1975
2514
|
onChange: cb,
|
|
1976
2515
|
options,
|
|
1977
2516
|
parse: parseGamificationFrame
|
|
@@ -2006,6 +2545,7 @@ function openStream(args) {
|
|
|
2006
2545
|
closeActive = connectOnce({
|
|
2007
2546
|
url: args.url,
|
|
2008
2547
|
headers,
|
|
2548
|
+
connectTimeoutMs: args.connectTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
|
|
2009
2549
|
onFrame: (frame) => {
|
|
2010
2550
|
const ev = args.parse(frame);
|
|
2011
2551
|
if (ev !== null) args.onChange(ev);
|
|
@@ -2076,8 +2616,13 @@ function connectXhr(args, XHR) {
|
|
|
2076
2616
|
xhr.open("GET", args.url, true);
|
|
2077
2617
|
for (const [k, v] of Object.entries(args.headers)) xhr.setRequestHeader(k, v);
|
|
2078
2618
|
xhr.setRequestHeader("Accept", "text/event-stream");
|
|
2619
|
+
const connectTimer = setTimeout(() => {
|
|
2620
|
+
if (aborted || xhr.readyState >= 2) return;
|
|
2621
|
+
args.onError(/* @__PURE__ */ new Error(`realtime connection timed out after ${args.connectTimeoutMs} ms`));
|
|
2622
|
+
}, args.connectTimeoutMs);
|
|
2079
2623
|
xhr.onreadystatechange = () => {
|
|
2080
2624
|
if (aborted) return;
|
|
2625
|
+
if (xhr.readyState >= 2) clearTimeout(connectTimer);
|
|
2081
2626
|
if (xhr.readyState === 3) {
|
|
2082
2627
|
if (xhr.status >= 400) return;
|
|
2083
2628
|
drain();
|
|
@@ -2101,10 +2646,12 @@ function connectXhr(args, XHR) {
|
|
|
2101
2646
|
try {
|
|
2102
2647
|
xhr.send();
|
|
2103
2648
|
} catch (err) {
|
|
2649
|
+
clearTimeout(connectTimer);
|
|
2104
2650
|
args.onError(err);
|
|
2105
2651
|
}
|
|
2106
2652
|
return () => {
|
|
2107
2653
|
aborted = true;
|
|
2654
|
+
clearTimeout(connectTimer);
|
|
2108
2655
|
try {
|
|
2109
2656
|
xhr.abort();
|
|
2110
2657
|
} catch {}
|
|
@@ -2119,14 +2666,14 @@ function connectFetchStream(args) {
|
|
|
2119
2666
|
let aborted = false;
|
|
2120
2667
|
(async () => {
|
|
2121
2668
|
try {
|
|
2122
|
-
const resp = await fetch(args.url, {
|
|
2669
|
+
const resp = await raceDeadline(fetch(args.url, {
|
|
2123
2670
|
method: "GET",
|
|
2124
2671
|
headers: {
|
|
2125
2672
|
...args.headers,
|
|
2126
2673
|
Accept: "text/event-stream"
|
|
2127
2674
|
},
|
|
2128
2675
|
signal: controller.signal
|
|
2129
|
-
});
|
|
2676
|
+
}), args.connectTimeoutMs, controller, (ms) => /* @__PURE__ */ new Error(`realtime connection timed out after ${ms} ms`));
|
|
2130
2677
|
if (!resp.ok || !resp.body) throw new Error(`realtime connect failed: HTTP ${resp.status}`);
|
|
2131
2678
|
const reader = resp.body.getReader();
|
|
2132
2679
|
const decoder = new TextDecoder();
|
|
@@ -2206,7 +2753,7 @@ function delay(ms) {
|
|
|
2206
2753
|
//#endregion
|
|
2207
2754
|
//#region src/index.ts
|
|
2208
2755
|
/** SDK semver — burned in at build time. Bump alongside package.json. */
|
|
2209
|
-
const SDK_VERSION = "4.1
|
|
2756
|
+
const SDK_VERSION = "4.2.1";
|
|
2210
2757
|
/**
|
|
2211
2758
|
* Default host that serves deployed functions. `Amba.functions.invoke(name)`
|
|
2212
2759
|
* targets `https://<projectId>.<DEFAULT_FUNCTIONS_HOST>/<name>` unless a
|
|
@@ -2250,6 +2797,62 @@ const authSubscribers = /* @__PURE__ */ new Set();
|
|
|
2250
2797
|
* start a new session.
|
|
2251
2798
|
*/
|
|
2252
2799
|
const experimentReporter = new ExperimentAssignmentReporter();
|
|
2800
|
+
/**
|
|
2801
|
+
* `configDefaults` from the latest `configure()` call, recorded before
|
|
2802
|
+
* configure reads the AsyncStorage cache. `config.getCached` and
|
|
2803
|
+
* `flags.isEnabled` serve them on a first render that runs while
|
|
2804
|
+
* `configure()` is still in flight.
|
|
2805
|
+
*/
|
|
2806
|
+
let _pendingConfigDefaults = null;
|
|
2807
|
+
/** Validate `AmbaConfig.configDefaults`: absent, or a plain object. */
|
|
2808
|
+
function readConfigDefaults(value) {
|
|
2809
|
+
if (value === void 0 || value === null) return null;
|
|
2810
|
+
if (typeof value !== "object" || Array.isArray(value)) throw new AmbaApiError("INVALID_CONFIG", "Amba.configure: configDefaults must be an object of config key to value");
|
|
2811
|
+
return value;
|
|
2812
|
+
}
|
|
2813
|
+
/** The pending `configDefaults` entry for `key`, ignoring inherited properties. */
|
|
2814
|
+
function pendingDefault(key) {
|
|
2815
|
+
const defaults = _pendingConfigDefaults;
|
|
2816
|
+
if (!defaults || !Object.prototype.hasOwnProperty.call(defaults, key)) return;
|
|
2817
|
+
const value = defaults[key];
|
|
2818
|
+
return value === null ? void 0 : value;
|
|
2819
|
+
}
|
|
2820
|
+
/** `"array"` for arrays, else `typeof` — the JSON kind of a value. */
|
|
2821
|
+
function jsonKind(value) {
|
|
2822
|
+
return Array.isArray(value) ? "array" : typeof value;
|
|
2823
|
+
}
|
|
2824
|
+
function configGetCached(key, defaultValue) {
|
|
2825
|
+
if (state) refreshOnFirstRead(state, "config");
|
|
2826
|
+
const raw = state ? state.remoteValues.configValue(key) : pendingDefault(key);
|
|
2827
|
+
if (raw === void 0 || raw === null) return defaultValue;
|
|
2828
|
+
if (defaultValue !== void 0 && defaultValue !== null && jsonKind(raw) !== jsonKind(defaultValue)) return defaultValue;
|
|
2829
|
+
return raw;
|
|
2830
|
+
}
|
|
2831
|
+
/** `Amba.flags.isEnabled`. */
|
|
2832
|
+
function flagsIsEnabled(key, defaultValue = false) {
|
|
2833
|
+
if (state) refreshOnFirstRead(state, "flags");
|
|
2834
|
+
const held = state ? state.remoteValues.flagEnabled(key) : pendingDefault(key);
|
|
2835
|
+
return typeof held === "boolean" ? held : defaultValue;
|
|
2836
|
+
}
|
|
2837
|
+
/** `Amba.flags.getCached`. */
|
|
2838
|
+
function flagsGetCached(key) {
|
|
2839
|
+
if (!state) return null;
|
|
2840
|
+
refreshOnFirstRead(state, "flags");
|
|
2841
|
+
return state.remoteValues.flag(key);
|
|
2842
|
+
}
|
|
2843
|
+
/**
|
|
2844
|
+
* The first on-device read of config (or flags) for each identity after
|
|
2845
|
+
* `configure()` starts one background fetch that replaces the cached values:
|
|
2846
|
+
* once at launch, and once more after a sign-in or sign-out changes who is
|
|
2847
|
+
* reading. Apps that never read cached values make no extra request. A
|
|
2848
|
+
* failure (offline) leaves the cached values in place.
|
|
2849
|
+
*/
|
|
2850
|
+
function refreshOnFirstRead(s, kind) {
|
|
2851
|
+
const identity = s.identity.getAppUserId() ?? "";
|
|
2852
|
+
if (s.refreshedFor[kind] === identity) return;
|
|
2853
|
+
s.refreshedFor[kind] = identity;
|
|
2854
|
+
Promise.resolve().then(() => kind === "config" ? Amba.config.fetch() : Amba.flags.fetch()).catch(() => {});
|
|
2855
|
+
}
|
|
2253
2856
|
function snapshotSession(user) {
|
|
2254
2857
|
if (!user) return null;
|
|
2255
2858
|
return {
|
|
@@ -2761,9 +3364,28 @@ function offlineStore(s) {
|
|
|
2761
3364
|
};
|
|
2762
3365
|
}
|
|
2763
3366
|
/**
|
|
3367
|
+
* Whether a live `track` (outbox on) carries its idempotency key:
|
|
3368
|
+
* engagement events whose properties the keyed path takes. Telemetry stays
|
|
3369
|
+
* on the unkeyed hot path, which writes no idempotency record and answers a
|
|
3370
|
+
* failed telemetry insert with 200; its replays from the outbox are keyed.
|
|
3371
|
+
*/
|
|
3372
|
+
function keyedLiveTrack(properties, telemetry) {
|
|
3373
|
+
return telemetry !== true && keyableProperties(properties);
|
|
3374
|
+
}
|
|
3375
|
+
/**
|
|
3376
|
+
* A JSON copy of `properties`: exactly what a request body serializes, and
|
|
3377
|
+
* immune to the app changing the object after `track()` returns.
|
|
3378
|
+
*/
|
|
3379
|
+
function snapshotProperties(properties) {
|
|
3380
|
+
if (properties === void 0 || properties === null) return void 0;
|
|
3381
|
+
const text = JSON.stringify(properties);
|
|
3382
|
+
return text === void 0 ? void 0 : JSON.parse(text);
|
|
3383
|
+
}
|
|
3384
|
+
/**
|
|
2764
3385
|
* Replay one queued mutation against the live API, forwarding the op's
|
|
2765
|
-
* idempotency key on collection inserts so a write that
|
|
2766
|
-
* connection dropped is never double-applied (API
|
|
3386
|
+
* idempotency key on collection inserts and tracked events so a write that
|
|
3387
|
+
* landed before the connection dropped is never double-applied (API
|
|
3388
|
+
* migration 056 for collections, `client_event_idempotency` for events).
|
|
2767
3389
|
*/
|
|
2768
3390
|
async function replayOutboxOp(s, op) {
|
|
2769
3391
|
switch (op.kind) {
|
|
@@ -2785,8 +3407,9 @@ async function replayOutboxOp(s, op) {
|
|
|
2785
3407
|
event: op.event,
|
|
2786
3408
|
properties: op.properties ?? void 0,
|
|
2787
3409
|
anonymous_id: s.identity.getAnonymousId(),
|
|
2788
|
-
timestamp: op.occurredAt ??
|
|
2789
|
-
...op.telemetry ? { telemetry: true } : {}
|
|
3410
|
+
timestamp: op.occurredAt ?? op.enqueuedAt,
|
|
3411
|
+
...op.telemetry ? { telemetry: true } : {},
|
|
3412
|
+
...keyableProperties(op.properties) ? { idempotency_key: op.idempotencyKey } : {}
|
|
2790
3413
|
});
|
|
2791
3414
|
} catch (err) {
|
|
2792
3415
|
const code = err?.code;
|
|
@@ -2868,10 +3491,13 @@ const Amba = {
|
|
|
2868
3491
|
if (!Number.isInteger(flushIntervalMs) || flushIntervalMs < 0) throw new Error("Amba.configure: events.flushIntervalMs must be a non-negative integer");
|
|
2869
3492
|
if (!Number.isInteger(maxQueue) || maxQueue < 1) throw new Error("Amba.configure: events.maxQueue must be positive");
|
|
2870
3493
|
}
|
|
3494
|
+
const deadlines = resolveRequestDeadlines(config);
|
|
2871
3495
|
const tokenStorage = config.tokenStorage ?? "secure";
|
|
2872
3496
|
if (!TOKEN_STORAGE_MODES.includes(tokenStorage)) throw new Error("Amba.configure: tokenStorage must be \"secure\", \"memory\" or \"asyncStorage\"");
|
|
2873
3497
|
const secureStorage = config.secureStorage;
|
|
2874
3498
|
if (secureStorage !== void 0 && (typeof secureStorage?.getItem !== "function" || typeof secureStorage?.setItem !== "function" || typeof secureStorage?.removeItem !== "function")) throw new Error("Amba.configure: secureStorage must provide getItem, setItem and removeItem");
|
|
3499
|
+
const configDefaults = readConfigDefaults(config.configDefaults);
|
|
3500
|
+
_pendingConfigDefaults = configDefaults;
|
|
2875
3501
|
const storagePrefix = config.storagePrefix ?? "amba";
|
|
2876
3502
|
const persistence = new AsyncStoragePersistence(storagePrefix);
|
|
2877
3503
|
if (config.events?.batching) await persistence.assertDurable();
|
|
@@ -2879,6 +3505,8 @@ const Amba = {
|
|
|
2879
3505
|
const vault = await resolveTokenVault(tokenStorage, storagePrefix, secureStorage);
|
|
2880
3506
|
const identity = await IdentityStore.create(persistence, vault);
|
|
2881
3507
|
const baseUrl = (config.apiUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
3508
|
+
const remoteValues = new RemoteValuesCache(persistence, recordKey(baseUrl, config.clientKey), configDefaults ?? {}, () => identity.getAppUserId());
|
|
3509
|
+
await remoteValues.load();
|
|
2882
3510
|
const sdkVersionHeader = `amba-sdk/${SDK_VERSION} ${`amba-react-native/${SDK_VERSION}`}`;
|
|
2883
3511
|
const http = new AmbaHttpClient({
|
|
2884
3512
|
apiKey: config.clientKey,
|
|
@@ -2889,6 +3517,8 @@ const Amba = {
|
|
|
2889
3517
|
deviceRegion: detectDeviceRegion(),
|
|
2890
3518
|
deviceOs: detectDeviceOs(),
|
|
2891
3519
|
identity,
|
|
3520
|
+
requestTimeoutMs: deadlines.requestMs,
|
|
3521
|
+
longRequestTimeoutMs: deadlines.longMs,
|
|
2892
3522
|
ensureSession: config.autoAnonymousSession === false ? void 0 : async (path) => {
|
|
2893
3523
|
if (pathSkipsLazySession(path)) return;
|
|
2894
3524
|
await ensureAnonymousSession(getState());
|
|
@@ -2912,6 +3542,11 @@ const Amba = {
|
|
|
2912
3542
|
resolvedFunctionsProjectId: null,
|
|
2913
3543
|
functionsProjectIdInflight: null,
|
|
2914
3544
|
persistence,
|
|
3545
|
+
remoteValues,
|
|
3546
|
+
refreshedFor: {
|
|
3547
|
+
config: null,
|
|
3548
|
+
flags: null
|
|
3549
|
+
},
|
|
2915
3550
|
offlineEnabled: false,
|
|
2916
3551
|
offlineQueue: null,
|
|
2917
3552
|
eventQueue: null
|
|
@@ -2964,12 +3599,14 @@ const Amba = {
|
|
|
2964
3599
|
},
|
|
2965
3600
|
collection: (name) => new CollectionSubscription({
|
|
2966
3601
|
baseUrl: getState().baseUrl,
|
|
2967
|
-
authHeaders: realtimeAuthHeaders
|
|
3602
|
+
authHeaders: realtimeAuthHeaders,
|
|
3603
|
+
connectTimeoutMs: getState().http.requestTimeoutMs
|
|
2968
3604
|
}, name),
|
|
2969
3605
|
get gamification() {
|
|
2970
3606
|
return new GamificationSubscription({
|
|
2971
3607
|
baseUrl: getState().baseUrl,
|
|
2972
|
-
authHeaders: realtimeAuthHeaders
|
|
3608
|
+
authHeaders: realtimeAuthHeaders,
|
|
3609
|
+
connectTimeoutMs: getState().http.requestTimeoutMs
|
|
2973
3610
|
});
|
|
2974
3611
|
},
|
|
2975
3612
|
events: {
|
|
@@ -2977,13 +3614,16 @@ const Amba = {
|
|
|
2977
3614
|
const s = getState();
|
|
2978
3615
|
if (s.eventQueue && !options?.immediate) return s.eventQueue.enqueue(event, properties, options?.telemetry === true);
|
|
2979
3616
|
const occurredAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3617
|
+
const props = s.offlineEnabled ? snapshotProperties(properties) : properties;
|
|
2980
3618
|
const body = {
|
|
2981
3619
|
event,
|
|
2982
|
-
properties:
|
|
3620
|
+
properties: props ?? void 0,
|
|
2983
3621
|
anonymous_id: s.identity.getAnonymousId(),
|
|
2984
3622
|
timestamp: occurredAt
|
|
2985
3623
|
};
|
|
2986
3624
|
if (options?.telemetry) body["telemetry"] = true;
|
|
3625
|
+
const idempotencyKey = s.offlineEnabled ? newIdempotencyKey() : void 0;
|
|
3626
|
+
if (idempotencyKey && keyedLiveTrack(props, options?.telemetry)) body["idempotency_key"] = idempotencyKey;
|
|
2987
3627
|
const outboxTicket = s.offlineEnabled ? getOfflineQueue(s).ticket() : void 0;
|
|
2988
3628
|
try {
|
|
2989
3629
|
await s.http.post(P.events, body);
|
|
@@ -2992,9 +3632,10 @@ const Amba = {
|
|
|
2992
3632
|
await getOfflineQueue(s).enqueue({
|
|
2993
3633
|
kind: "track",
|
|
2994
3634
|
event,
|
|
2995
|
-
properties,
|
|
3635
|
+
properties: props,
|
|
2996
3636
|
telemetry: options?.telemetry,
|
|
2997
|
-
occurredAt
|
|
3637
|
+
occurredAt,
|
|
3638
|
+
idempotencyKey
|
|
2998
3639
|
}, outboxTicket);
|
|
2999
3640
|
return;
|
|
3000
3641
|
}
|
|
@@ -3162,7 +3803,10 @@ const Amba = {
|
|
|
3162
3803
|
} catch (err) {
|
|
3163
3804
|
if (typeof console !== "undefined") console.warn("[amba] signOut server call failed", err);
|
|
3164
3805
|
}
|
|
3806
|
+
const signedOutUser = s.identity.getAppUserId();
|
|
3165
3807
|
await s.identity.clearSession(rotateAnonymousId);
|
|
3808
|
+
s.remoteValues.clearForSignOut(signedOutUser);
|
|
3809
|
+
await s.remoteValues.flushed();
|
|
3166
3810
|
try {
|
|
3167
3811
|
await getOfflineQueue(s).purge();
|
|
3168
3812
|
} catch (err) {
|
|
@@ -3453,21 +4097,25 @@ const Amba = {
|
|
|
3453
4097
|
await getState().http.delete(`${P.storage}/${encodePathSegment(assetId)}`);
|
|
3454
4098
|
},
|
|
3455
4099
|
download: async (assetId) => {
|
|
3456
|
-
const
|
|
4100
|
+
const s = getState();
|
|
4101
|
+
const assetEnv = await s.http.get(`${P.storage}/${encodePathSegment(assetId)}`);
|
|
3457
4102
|
const cdnUrl = assetEnv.data.url ?? assetEnv.data.storage_url ?? "";
|
|
3458
4103
|
if (!cdnUrl) throw new AmbaApiError("NOT_FOUND", "asset has no resolvable CDN url");
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
response
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
}
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
4104
|
+
const sizeBytes = assetEnv.data.size_bytes;
|
|
4105
|
+
const { response, blob, text } = await s.http.send(cdnUrl, { method: "GET" }, async (res) => res.ok ? {
|
|
4106
|
+
response: res,
|
|
4107
|
+
blob: await res.blob(),
|
|
4108
|
+
text: ""
|
|
4109
|
+
} : {
|
|
4110
|
+
response: res,
|
|
4111
|
+
blob: null,
|
|
4112
|
+
text: await res.text()
|
|
4113
|
+
}, {
|
|
4114
|
+
timeoutMs: s.http.longRequestTimeoutMs,
|
|
4115
|
+
responseBytes: typeof sizeBytes === "number" ? sizeBytes : void 0
|
|
4116
|
+
});
|
|
4117
|
+
if (!blob) throw mapHttpError(response, text, (response.headers.get("content-type") ?? "").includes("application/json"));
|
|
4118
|
+
return blob;
|
|
3471
4119
|
}
|
|
3472
4120
|
},
|
|
3473
4121
|
push: {
|
|
@@ -3515,27 +4163,30 @@ const Amba = {
|
|
|
3515
4163
|
functions: { invoke: async (name, body, options) => {
|
|
3516
4164
|
const s = getState();
|
|
3517
4165
|
if (!name || name.trim() === "") throw new AmbaApiError("INVALID_ARGUMENT", "Amba.functions.invoke: a function name is required.");
|
|
4166
|
+
let timeoutMs;
|
|
4167
|
+
try {
|
|
4168
|
+
timeoutMs = readTimeout("Amba.functions.invoke: timeoutMs", options?.timeoutMs, s.http.longRequestTimeoutMs);
|
|
4169
|
+
} catch (err) {
|
|
4170
|
+
throw new AmbaApiError("INVALID_ARGUMENT", err.message);
|
|
4171
|
+
}
|
|
3518
4172
|
const url = await resolveFunctionUrl(s, name);
|
|
3519
4173
|
const method = options?.method ?? "POST";
|
|
3520
4174
|
const headers = s.http.buildHeaders(options?.headers);
|
|
3521
4175
|
let requestBody;
|
|
3522
4176
|
if (body !== void 0 && method !== "GET") requestBody = JSON.stringify(body);
|
|
3523
4177
|
else delete headers["Content-Type"];
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
}
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
if (!response.ok)
|
|
3536
|
-
const ct = response.headers.get("content-type") ?? "";
|
|
3537
|
-
throw mapHttpError(response, text, ct.includes("application/json"));
|
|
3538
|
-
}
|
|
4178
|
+
const { response, text } = await s.http.send(url, {
|
|
4179
|
+
method,
|
|
4180
|
+
headers,
|
|
4181
|
+
body: requestBody
|
|
4182
|
+
}, async (res) => ({
|
|
4183
|
+
response: res,
|
|
4184
|
+
text: await res.text()
|
|
4185
|
+
}), {
|
|
4186
|
+
timeoutMs,
|
|
4187
|
+
label: `Amba.functions.invoke("${name}") network error`
|
|
4188
|
+
});
|
|
4189
|
+
if (!response.ok) throw mapHttpError(response, text, (response.headers.get("content-type") ?? "").includes("application/json"));
|
|
3539
4190
|
if (!text) return void 0;
|
|
3540
4191
|
try {
|
|
3541
4192
|
return JSON.parse(text);
|
|
@@ -3543,54 +4194,63 @@ const Amba = {
|
|
|
3543
4194
|
return text;
|
|
3544
4195
|
}
|
|
3545
4196
|
} },
|
|
3546
|
-
config: {
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
response = await
|
|
4197
|
+
config: {
|
|
4198
|
+
fetch: async () => {
|
|
4199
|
+
const s = getState();
|
|
4200
|
+
const ticket = s.remoteValues.ticket();
|
|
4201
|
+
const url = s.http.url(P.config);
|
|
4202
|
+
const headers = s.http.buildHeaders();
|
|
4203
|
+
const { response, text } = await s.http.send(url, {
|
|
3553
4204
|
method: "GET",
|
|
3554
4205
|
headers
|
|
3555
|
-
})
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
};
|
|
3581
|
-
} },
|
|
4206
|
+
}, async (res) => ({
|
|
4207
|
+
response: res,
|
|
4208
|
+
text: await res.text()
|
|
4209
|
+
}));
|
|
4210
|
+
if (!response.ok) throw mapHttpError(response, text, (response.headers.get("content-type") ?? "").includes("application/json"));
|
|
4211
|
+
let parsed = {};
|
|
4212
|
+
if (text) try {
|
|
4213
|
+
parsed = JSON.parse(text);
|
|
4214
|
+
} catch {
|
|
4215
|
+
parsed = {};
|
|
4216
|
+
}
|
|
4217
|
+
const etag = response.headers.get("etag");
|
|
4218
|
+
let version = null;
|
|
4219
|
+
if (etag) {
|
|
4220
|
+
const cleaned = etag.replace(/^"|"$/g, "");
|
|
4221
|
+
const m = cleaned.match(/^(v\d+)/);
|
|
4222
|
+
version = m ? m[1] ?? null : cleaned || null;
|
|
4223
|
+
}
|
|
4224
|
+
return s.remoteValues.storeConfig(ticket, {
|
|
4225
|
+
version,
|
|
4226
|
+
values: parsed.data ?? {}
|
|
4227
|
+
}, etag);
|
|
4228
|
+
},
|
|
4229
|
+
getCached: configGetCached
|
|
4230
|
+
},
|
|
3582
4231
|
flags: {
|
|
3583
4232
|
fetch: async () => {
|
|
3584
|
-
|
|
4233
|
+
const s = getState();
|
|
4234
|
+
const ticket = s.remoteValues.ticket();
|
|
4235
|
+
const env = await s.http.get(P.flags);
|
|
4236
|
+
return s.remoteValues.storeFlags(ticket, env.data);
|
|
3585
4237
|
},
|
|
3586
4238
|
get: async (key) => {
|
|
4239
|
+
const s = getState();
|
|
4240
|
+
const ticket = s.remoteValues.ticket();
|
|
3587
4241
|
try {
|
|
3588
|
-
|
|
4242
|
+
const env = await s.http.get(`${P.flags}/${encodePathSegment(key)}`);
|
|
4243
|
+
return s.remoteValues.upsertFlag(ticket, env.data);
|
|
3589
4244
|
} catch (err) {
|
|
3590
|
-
if (err instanceof AmbaApiError && err.code === "NOT_FOUND")
|
|
4245
|
+
if (err instanceof AmbaApiError && err.code === "NOT_FOUND") {
|
|
4246
|
+
s.remoteValues.removeFlag(ticket, key);
|
|
4247
|
+
return null;
|
|
4248
|
+
}
|
|
3591
4249
|
throw err;
|
|
3592
4250
|
}
|
|
3593
|
-
}
|
|
4251
|
+
},
|
|
4252
|
+
getCached: flagsGetCached,
|
|
4253
|
+
isEnabled: flagsIsEnabled
|
|
3594
4254
|
},
|
|
3595
4255
|
experiments: {
|
|
3596
4256
|
getVariant: async (key) => {
|
|
@@ -3770,7 +4430,8 @@ const Amba = {
|
|
|
3770
4430
|
messaging: {
|
|
3771
4431
|
conversation: (conversationId) => new ConversationSubscription({
|
|
3772
4432
|
baseUrl: getState().baseUrl,
|
|
3773
|
-
authHeaders: realtimeAuthHeaders
|
|
4433
|
+
authHeaders: realtimeAuthHeaders,
|
|
4434
|
+
connectTimeoutMs: getState().http.requestTimeoutMs
|
|
3774
4435
|
}, conversationId),
|
|
3775
4436
|
createConversation: async (request) => {
|
|
3776
4437
|
return (await getState().http.post(`${P.messaging}/conversations`, request)).data;
|