@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.cjs
CHANGED
|
@@ -84,6 +84,167 @@ var AmbaApiError = class extends Error {
|
|
|
84
84
|
}
|
|
85
85
|
};
|
|
86
86
|
|
|
87
|
+
//#endregion
|
|
88
|
+
//#region src/request-deadline.ts
|
|
89
|
+
/**
|
|
90
|
+
* Request deadlines, shared by every JS SDK target (the same file ships in
|
|
91
|
+
* `@layers/amba-web`, `@layers/amba-node` and `@layers/amba-react-native`).
|
|
92
|
+
*
|
|
93
|
+
* Every request the SDK sends runs under a deadline that covers sending it
|
|
94
|
+
* and reading the whole response. When the deadline passes, the request is
|
|
95
|
+
* aborted and fails with the SDK's retryable network error, so one request
|
|
96
|
+
* can never hold an in-flight slot (the offline outbox flush, the lazy
|
|
97
|
+
* anonymous sign-in, the session refresh, the event batch flush) for the
|
|
98
|
+
* life of the process. Some runtimes never settle a fetch they gave up on:
|
|
99
|
+
* Expo on Android drops a failed DNS lookup and the promise stays pending.
|
|
100
|
+
* The deadline wins the race even then, because it rejects on its own timer
|
|
101
|
+
* and then aborts the fetch for the runtime to clean up.
|
|
102
|
+
*
|
|
103
|
+
* Two bounds:
|
|
104
|
+
* - `requestTimeoutMs` (default 15 s): an ordinary API request.
|
|
105
|
+
* - `longRequestTimeoutMs` (default 5 min): work that legitimately runs
|
|
106
|
+
* longer. AI generation (`/v1/client/ai/*`), function invocations, and
|
|
107
|
+
* media uploads and downloads.
|
|
108
|
+
* Both grow with the bytes a request moves (see `withTransferAllowance`), so
|
|
109
|
+
* a large upload, download or event batch on a slow link has time to finish.
|
|
110
|
+
* Realtime subscriptions are bounded until their stream opens; AI response
|
|
111
|
+
* streams likewise until the response starts. Once open, a stream runs for
|
|
112
|
+
* as long as the caller keeps it.
|
|
113
|
+
*/
|
|
114
|
+
/** Default deadline for one API request, in milliseconds. */
|
|
115
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 15e3;
|
|
116
|
+
/**
|
|
117
|
+
* Default deadline for AI generation, function invocations and media
|
|
118
|
+
* transfers, in milliseconds.
|
|
119
|
+
*/
|
|
120
|
+
const DEFAULT_LONG_REQUEST_TIMEOUT_MS = 3e5;
|
|
121
|
+
/**
|
|
122
|
+
* The slowest link a deadline makes room for, in bytes per second
|
|
123
|
+
* (8 KiB/s, about 64 kbit/s). Each byte a request sends or receives beyond
|
|
124
|
+
* the first {@link TRANSFER_BYTES_WITHIN_BASE} adds the time it takes at this
|
|
125
|
+
* rate to the deadline.
|
|
126
|
+
*/
|
|
127
|
+
const MIN_TRANSFER_BYTES_PER_SECOND = 8 * 1024;
|
|
128
|
+
/**
|
|
129
|
+
* Bytes a request moves within its base deadline (64 KiB: 8 s at the
|
|
130
|
+
* slowest rate). An ordinary API request stays at exactly
|
|
131
|
+
* `requestTimeoutMs`; only larger bodies widen it.
|
|
132
|
+
*/
|
|
133
|
+
const TRANSFER_BYTES_WITHIN_BASE = 64 * 1024;
|
|
134
|
+
/** The largest delay `setTimeout` honors (about 24.8 days). */
|
|
135
|
+
const MAX_TIMEOUT_MS = 2147483647;
|
|
136
|
+
/**
|
|
137
|
+
* Read the deadlines from the configure options, applying the defaults.
|
|
138
|
+
* Throws an `Error` naming the option when a value is not a positive number
|
|
139
|
+
* of milliseconds.
|
|
140
|
+
*/
|
|
141
|
+
function resolveRequestDeadlines(options) {
|
|
142
|
+
return {
|
|
143
|
+
requestMs: readTimeout("Amba.configure: requestTimeoutMs", options.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS),
|
|
144
|
+
longMs: readTimeout("Amba.configure: longRequestTimeoutMs", options.longRequestTimeoutMs, DEFAULT_LONG_REQUEST_TIMEOUT_MS)
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Validate one timeout value; `undefined` keeps `fallback`. `label` names the
|
|
149
|
+
* option in the thrown `Error` (`Amba.configure: requestTimeoutMs`).
|
|
150
|
+
*/
|
|
151
|
+
function readTimeout(label, value, fallback) {
|
|
152
|
+
if (value === void 0) return fallback;
|
|
153
|
+
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}`);
|
|
154
|
+
return value;
|
|
155
|
+
}
|
|
156
|
+
/** The text a deadline failure carries, e.g. `request timed out after 15000 ms`. */
|
|
157
|
+
function timeoutMessage(timeoutMs) {
|
|
158
|
+
return `request timed out after ${timeoutMs} ms`;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* `timeoutMs` plus the time the bytes beyond the first
|
|
162
|
+
* {@link TRANSFER_BYTES_WITHIN_BASE} take at
|
|
163
|
+
* {@link MIN_TRANSFER_BYTES_PER_SECOND}, capped at the largest delay
|
|
164
|
+
* `setTimeout` honors. Unknown sizes add nothing.
|
|
165
|
+
*/
|
|
166
|
+
function withTransferAllowance(timeoutMs, bytes) {
|
|
167
|
+
const beyond = typeof bytes === "number" && Number.isFinite(bytes) ? bytes - TRANSFER_BYTES_WITHIN_BASE : 0;
|
|
168
|
+
const extra = beyond > 0 ? Math.ceil(beyond / MIN_TRANSFER_BYTES_PER_SECOND * 1e3) : 0;
|
|
169
|
+
return Math.min(MAX_TIMEOUT_MS, timeoutMs + extra);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The size of a request body in bytes: a string's UTF-8 length, a buffer's
|
|
173
|
+
* byte length, a `Blob`'s size. `undefined` for no body or a body of unknown
|
|
174
|
+
* size (a stream).
|
|
175
|
+
*/
|
|
176
|
+
function bodyByteLength(body) {
|
|
177
|
+
if (body === void 0 || body === null) return void 0;
|
|
178
|
+
if (typeof body === "string") return typeof TextEncoder !== "undefined" ? new TextEncoder().encode(body).length : body.length;
|
|
179
|
+
if (typeof ArrayBuffer !== "undefined") {
|
|
180
|
+
if (body instanceof ArrayBuffer) return body.byteLength;
|
|
181
|
+
if (ArrayBuffer.isView(body)) return body.byteLength;
|
|
182
|
+
}
|
|
183
|
+
const size = body.size;
|
|
184
|
+
return typeof size === "number" ? size : void 0;
|
|
185
|
+
}
|
|
186
|
+
/** A response's `Content-Length`, when it states one. */
|
|
187
|
+
function contentLength(response) {
|
|
188
|
+
const raw = response.headers?.get?.("content-length");
|
|
189
|
+
if (!raw) return void 0;
|
|
190
|
+
const n = Number(raw);
|
|
191
|
+
return Number.isFinite(n) && n >= 0 ? n : void 0;
|
|
192
|
+
}
|
|
193
|
+
/** The paths under the API base URL whose requests get the long deadline. */
|
|
194
|
+
const LONG_API_PATH_PREFIXES = ["/v1/client/ai/"];
|
|
195
|
+
/** Whether a request to API `path` (starting `/v1/...`) gets the long deadline. */
|
|
196
|
+
function isLongApiPath(path) {
|
|
197
|
+
return LONG_API_PATH_PREFIXES.some((prefix) => path.startsWith(prefix));
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Race `work` against a timer of `ms`. When the timer fires first the race
|
|
201
|
+
* rejects with `onTimeout(ms)`, then `controller` is aborted so the runtime
|
|
202
|
+
* can release the connection. The rejection does not wait for `work`, so a
|
|
203
|
+
* fetch the runtime never settles still releases its caller on time. The
|
|
204
|
+
* timer is cleared as soon as either side settles.
|
|
205
|
+
*/
|
|
206
|
+
function raceDeadline(work, ms, controller, onTimeout) {
|
|
207
|
+
let timer;
|
|
208
|
+
const deadline = new Promise((_, reject) => {
|
|
209
|
+
timer = setTimeout(() => {
|
|
210
|
+
reject(onTimeout(ms));
|
|
211
|
+
try {
|
|
212
|
+
controller?.abort();
|
|
213
|
+
} catch {}
|
|
214
|
+
}, ms);
|
|
215
|
+
});
|
|
216
|
+
return Promise.race([work, deadline]).finally(() => {
|
|
217
|
+
clearTimeout(timer);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
function newController() {
|
|
221
|
+
return typeof AbortController === "function" ? new AbortController() : void 0;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Send a request and read its response under deadlines that grow with the
|
|
225
|
+
* bytes involved. Sending, until `send` resolves with the response, gets
|
|
226
|
+
* `timeoutMs` plus the allowance for the request body (and, with
|
|
227
|
+
* `fetchBuffersBody`, for the expected response bytes). Reading,
|
|
228
|
+
* `read(response)`, gets `timeoutMs` plus the allowance for the expected
|
|
229
|
+
* response bytes, else the response's `Content-Length`. One `AbortSignal`
|
|
230
|
+
* covers both phases, so a read past its deadline aborts the transfer too.
|
|
231
|
+
* Past either deadline the promise rejects with `onTimeout(ms)`.
|
|
232
|
+
*/
|
|
233
|
+
async function exchange(timeoutMs, send, read, onTimeout, sizes = {}) {
|
|
234
|
+
const controller = newController();
|
|
235
|
+
const sendMs = withTransferAllowance(timeoutMs, (sizes.requestBytes ?? 0) + (sizes.fetchBuffersBody ? sizes.responseBytes ?? 0 : 0));
|
|
236
|
+
const response = await raceDeadline(send(controller?.signal), sendMs, controller, onTimeout);
|
|
237
|
+
const readMs = withTransferAllowance(timeoutMs, sizes.responseBytes ?? contentLength(response));
|
|
238
|
+
return raceDeadline(read(response), readMs, controller, onTimeout);
|
|
239
|
+
}
|
|
240
|
+
/** `init` with `signal` set, when there is one. */
|
|
241
|
+
function withSignal(init, signal) {
|
|
242
|
+
return signal ? {
|
|
243
|
+
...init,
|
|
244
|
+
signal
|
|
245
|
+
} : init;
|
|
246
|
+
}
|
|
247
|
+
|
|
87
248
|
//#endregion
|
|
88
249
|
//#region src/http-client.ts
|
|
89
250
|
/** Default amba API endpoint. */
|
|
@@ -100,6 +261,12 @@ var AmbaHttpClient = class {
|
|
|
100
261
|
deviceOs;
|
|
101
262
|
identity;
|
|
102
263
|
fetchImpl;
|
|
264
|
+
/** The `fetchImpl` option, when one was passed (`send` resolves fetch per call otherwise). */
|
|
265
|
+
injectedFetch;
|
|
266
|
+
/** Deadline for an API request, in milliseconds. */
|
|
267
|
+
requestTimeoutMs;
|
|
268
|
+
/** Deadline for AI generation, function invocations and media transfers. */
|
|
269
|
+
longRequestTimeoutMs;
|
|
103
270
|
ensureSession;
|
|
104
271
|
onSessionRefused;
|
|
105
272
|
constructor(opts) {
|
|
@@ -113,7 +280,39 @@ var AmbaHttpClient = class {
|
|
|
113
280
|
this.identity = opts.identity;
|
|
114
281
|
this.ensureSession = opts.ensureSession;
|
|
115
282
|
this.onSessionRefused = opts.onSessionRefused;
|
|
283
|
+
this.injectedFetch = opts.fetchImpl;
|
|
116
284
|
this.fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
285
|
+
this.requestTimeoutMs = opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
286
|
+
this.longRequestTimeoutMs = opts.longRequestTimeoutMs ?? DEFAULT_LONG_REQUEST_TIMEOUT_MS;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Send a request to an absolute URL outside the API's JSON envelope (a
|
|
290
|
+
* function invocation, a CDN download, the config route that reads the
|
|
291
|
+
* `ETag`) under a deadline. `read` takes what the caller needs from the
|
|
292
|
+
* response (the body as text, a `Blob`) and runs inside the same
|
|
293
|
+
* deadline, so a body that stops arriving fails too.
|
|
294
|
+
*
|
|
295
|
+
* A transport failure rejects with `NETWORK_ERROR` and the message
|
|
296
|
+
* `<label>: <cause>`; the deadline rejects with `NETWORK_ERROR` and
|
|
297
|
+
* `<label>: request timed out after <ms> ms`. `label` defaults to
|
|
298
|
+
* `Network error` and `timeoutMs` to the request deadline; the deadline
|
|
299
|
+
* widens with the request body and with `responseBytes`, the size the
|
|
300
|
+
* caller expects to download (see `exchange`). An `AmbaApiError` that
|
|
301
|
+
* `read` throws passes through unchanged.
|
|
302
|
+
*/
|
|
303
|
+
async send(url, init, read, opts = {}) {
|
|
304
|
+
const timeoutMs = opts.timeoutMs ?? this.requestTimeoutMs;
|
|
305
|
+
const label = opts.label ?? "Network error";
|
|
306
|
+
const fetchFn = this.injectedFetch ?? globalThis.fetch;
|
|
307
|
+
try {
|
|
308
|
+
return await exchange(timeoutMs, (signal) => fetchFn(url, withSignal(init, signal)), read, (ms) => timeoutError(label, ms), {
|
|
309
|
+
requestBytes: bodyByteLength(init.body),
|
|
310
|
+
responseBytes: opts.responseBytes,
|
|
311
|
+
fetchBuffersBody: true
|
|
312
|
+
});
|
|
313
|
+
} catch (err) {
|
|
314
|
+
throw asNetworkError(err, label);
|
|
315
|
+
}
|
|
117
316
|
}
|
|
118
317
|
/** Build the absolute URL for an API path, optionally with a query string. */
|
|
119
318
|
url(path, query) {
|
|
@@ -173,14 +372,19 @@ var AmbaHttpClient = class {
|
|
|
173
372
|
headers,
|
|
174
373
|
body: body === void 0 ? void 0 : JSON.stringify(body ?? {})
|
|
175
374
|
};
|
|
375
|
+
const timeoutMs = isLongApiPath(path) ? this.longRequestTimeoutMs : this.requestTimeoutMs;
|
|
176
376
|
let response;
|
|
377
|
+
let text;
|
|
177
378
|
try {
|
|
178
|
-
response = await this.fetchImpl(url, init)
|
|
379
|
+
({response, text} = await exchange(timeoutMs, (signal) => this.fetchImpl(url, withSignal(init, signal)), async (res) => ({
|
|
380
|
+
response: res,
|
|
381
|
+
text: res.status === 204 ? "" : await res.text()
|
|
382
|
+
}), (ms) => timeoutError("Network error", ms), { requestBytes: bodyByteLength(init.body) }));
|
|
179
383
|
} catch (err) {
|
|
180
|
-
throw
|
|
384
|
+
throw asNetworkError(err, "Network error");
|
|
181
385
|
}
|
|
182
386
|
try {
|
|
183
|
-
return
|
|
387
|
+
return parseResponse(response, text);
|
|
184
388
|
} catch (err) {
|
|
185
389
|
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);
|
|
186
390
|
throw err;
|
|
@@ -195,9 +399,21 @@ var AmbaHttpClient = class {
|
|
|
195
399
|
}
|
|
196
400
|
}
|
|
197
401
|
};
|
|
198
|
-
|
|
402
|
+
/** The `NETWORK_ERROR` a passed deadline raises. */
|
|
403
|
+
function timeoutError(label, timeoutMs) {
|
|
404
|
+
return new AmbaApiError("NETWORK_ERROR", `${label}: ${timeoutMessage(timeoutMs)}`, { timeoutMs });
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Wrap a transport failure as `NETWORK_ERROR` (`<label>: <cause>`). An
|
|
408
|
+
* `AmbaApiError` (the deadline's own, or one a `send` reader throws) passes
|
|
409
|
+
* through unchanged.
|
|
410
|
+
*/
|
|
411
|
+
function asNetworkError(err, label) {
|
|
412
|
+
if (err instanceof AmbaApiError) return err;
|
|
413
|
+
return new AmbaApiError("NETWORK_ERROR", `${label}: ${err instanceof Error ? err.message : typeof err === "string" ? err : "fetch failed"}`, err);
|
|
414
|
+
}
|
|
415
|
+
function parseResponse(response, text) {
|
|
199
416
|
if (response.status === 204) return null;
|
|
200
|
-
const text = await response.text();
|
|
201
417
|
const isJson = (response.headers.get("content-type") ?? "").includes("application/json");
|
|
202
418
|
if (!response.ok) throw mapHttpError(response, text, isJson);
|
|
203
419
|
if (text === "") return null;
|
|
@@ -893,7 +1109,16 @@ const DETERMINISTIC_ERROR_CODES = new Set([
|
|
|
893
1109
|
"NOT_FOUND",
|
|
894
1110
|
"CONFLICT",
|
|
895
1111
|
"CONSENT_NOT_GRANTED",
|
|
896
|
-
"NOT_INITIALIZED"
|
|
1112
|
+
"NOT_INITIALIZED",
|
|
1113
|
+
"INVALID_JSON",
|
|
1114
|
+
"INVALID_BODY",
|
|
1115
|
+
"INVALID_EVENT",
|
|
1116
|
+
"RESERVED_EVENT_NAME",
|
|
1117
|
+
"INVALID_IDEMPOTENCY_KEY",
|
|
1118
|
+
"INVALID_TIMESTAMP",
|
|
1119
|
+
"INVALID_PROPERTIES",
|
|
1120
|
+
"INVALID_TELEMETRY",
|
|
1121
|
+
"IDEMPOTENCY_KEY_CONFLICT"
|
|
897
1122
|
]);
|
|
898
1123
|
/**
|
|
899
1124
|
* Is this a deterministic server rejection — a coded error the server would
|
|
@@ -931,13 +1156,34 @@ function isOfflineError(err) {
|
|
|
931
1156
|
if (typeof msg === "string" && /network (error|request failed)|failed to fetch|fetch failed|load failed/i.test(msg)) return true;
|
|
932
1157
|
return !isOnline();
|
|
933
1158
|
}
|
|
934
|
-
/**
|
|
1159
|
+
/**
|
|
1160
|
+
* Generate a stable idempotency key for a queued op: `amba_obx_` and a random
|
|
1161
|
+
* UUID. The server records event keys project-wide, so the UUID comes from
|
|
1162
|
+
* the runtime's secure random source: `crypto.randomUUID`, else
|
|
1163
|
+
* `crypto.getRandomValues` (Hermes has only the latter).
|
|
1164
|
+
*/
|
|
935
1165
|
function newIdempotencyKey() {
|
|
936
1166
|
const c = typeof globalThis !== "undefined" ? globalThis.crypto : void 0;
|
|
937
1167
|
if (c && typeof c.randomUUID === "function") return `amba_obx_${c.randomUUID()}`;
|
|
1168
|
+
if (c && typeof c.getRandomValues === "function") {
|
|
1169
|
+
const b = c.getRandomValues(new Uint8Array(16));
|
|
1170
|
+
b[6] = b[6] & 15 | 64;
|
|
1171
|
+
b[8] = b[8] & 63 | 128;
|
|
1172
|
+
const h = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
|
1173
|
+
return `amba_obx_${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
|
1174
|
+
}
|
|
938
1175
|
return `amba_obx_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
|
|
939
1176
|
}
|
|
940
1177
|
/**
|
|
1178
|
+
* Whether a `track` with these properties can take the events route's keyed
|
|
1179
|
+
* path, which accepts a plain object or no properties. A JS caller can pass
|
|
1180
|
+
* something else (an array); such an event stays on the unkeyed path, which
|
|
1181
|
+
* stores it as it always has.
|
|
1182
|
+
*/
|
|
1183
|
+
function keyableProperties(properties) {
|
|
1184
|
+
return properties === void 0 || properties === null || typeof properties === "object" && !Array.isArray(properties);
|
|
1185
|
+
}
|
|
1186
|
+
/**
|
|
941
1187
|
* Durable, ordered, idempotent offline outbox. Opt-in via `Amba.offline`.
|
|
942
1188
|
* Single in-flight flush is enforced so concurrent reconnect signals don't
|
|
943
1189
|
* double-replay.
|
|
@@ -1818,6 +2064,296 @@ function logListenerError(err) {
|
|
|
1818
2064
|
console.warn("[amba] onExperimentAssigned listener threw", err);
|
|
1819
2065
|
}
|
|
1820
2066
|
|
|
2067
|
+
//#endregion
|
|
2068
|
+
//#region src/remote-values.ts
|
|
2069
|
+
/** Prefix of the per-project AsyncStorage record key. */
|
|
2070
|
+
const CACHE_KEY_PREFIX = "amba_rc_v1_";
|
|
2071
|
+
/** Slot for values evaluated without a signed-in user. */
|
|
2072
|
+
const ANON_SLOT = "anon";
|
|
2073
|
+
/** Most user slots one project record keeps. */
|
|
2074
|
+
const MAX_SLOTS = 5;
|
|
2075
|
+
const TWO_32 = 4294967296;
|
|
2076
|
+
/**
|
|
2077
|
+
* 64-bit FNV-1a over the string's UTF-16 code units (low byte, then high
|
|
2078
|
+
* byte), as 16 hex digits. Computed in two 32-bit halves: some React Native
|
|
2079
|
+
* engines (JavaScriptCore on older Android builds) have no BigInt.
|
|
2080
|
+
*/
|
|
2081
|
+
function fnv1a64Hex(text) {
|
|
2082
|
+
let hi = 3421674724;
|
|
2083
|
+
let lo = 2216829733;
|
|
2084
|
+
const step = (byte) => {
|
|
2085
|
+
lo = (lo ^ byte) >>> 0;
|
|
2086
|
+
const low = lo * 435;
|
|
2087
|
+
const carry = Math.floor(low / TWO_32);
|
|
2088
|
+
const high = hi * 435 + carry + (lo << 8 >>> 0);
|
|
2089
|
+
lo = low % TWO_32;
|
|
2090
|
+
hi = high % TWO_32;
|
|
2091
|
+
};
|
|
2092
|
+
for (let i = 0; i < text.length; i++) {
|
|
2093
|
+
const unit = text.charCodeAt(i);
|
|
2094
|
+
step(unit & 255);
|
|
2095
|
+
step(unit >>> 8);
|
|
2096
|
+
}
|
|
2097
|
+
return hi.toString(16).padStart(8, "0") + lo.toString(16).padStart(8, "0");
|
|
2098
|
+
}
|
|
2099
|
+
/** The record key for a project: a hash of the base URL and client key. */
|
|
2100
|
+
function recordKey(baseUrl, clientKey) {
|
|
2101
|
+
return `${CACHE_KEY_PREFIX}${fnv1a64Hex(`${baseUrl.replace(/\/+$/, "")}\n${clientKey}`)}`;
|
|
2102
|
+
}
|
|
2103
|
+
/** The slot key for an identity: `u` + a hash of the user id, or `anon`. */
|
|
2104
|
+
function slotKey(appUserId) {
|
|
2105
|
+
return appUserId ? `u${fnv1a64Hex(appUserId)}` : ANON_SLOT;
|
|
2106
|
+
}
|
|
2107
|
+
/**
|
|
2108
|
+
* The user a config response was evaluated for, from its `ETag`:
|
|
2109
|
+
* `"<version>:<app user id>:<segments>"` for a signed-in caller, `"<version>"`
|
|
2110
|
+
* for every other one (no session, or an expired or revoked token, which the
|
|
2111
|
+
* server answers with each key's default value). `undefined` without an
|
|
2112
|
+
* `ETag`; `null` for an evaluation for nobody.
|
|
2113
|
+
*/
|
|
2114
|
+
function etagEvaluatedFor(etag) {
|
|
2115
|
+
if (!etag) return void 0;
|
|
2116
|
+
const parts = etag.trim().replace(/^W\//, "").replace(/^"|"$/g, "").split(":");
|
|
2117
|
+
return parts.length > 1 && parts[1] ? parts[1] : null;
|
|
2118
|
+
}
|
|
2119
|
+
function isPlainObject(value) {
|
|
2120
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2121
|
+
}
|
|
2122
|
+
function validSlot(raw) {
|
|
2123
|
+
if (!isPlainObject(raw)) return null;
|
|
2124
|
+
const slot = { saved_at: typeof raw["saved_at"] === "number" ? raw["saved_at"] : 0 };
|
|
2125
|
+
const config = raw["config"];
|
|
2126
|
+
if (isPlainObject(config) && isPlainObject(config["values"])) slot.config = {
|
|
2127
|
+
version: typeof config["version"] === "string" ? config["version"] : null,
|
|
2128
|
+
values: config["values"]
|
|
2129
|
+
};
|
|
2130
|
+
const flags = raw["flags"];
|
|
2131
|
+
if (Array.isArray(flags)) slot.flags = flags.filter((f) => isPlainObject(f) && typeof f["name"] === "string" && typeof f["enabled"] === "boolean");
|
|
2132
|
+
return slot.config || slot.flags ? slot : null;
|
|
2133
|
+
}
|
|
2134
|
+
function upsert(list, flag) {
|
|
2135
|
+
const i = list.findIndex((f) => f.name === flag.name);
|
|
2136
|
+
if (i >= 0) list[i] = flag;
|
|
2137
|
+
else list.push(flag);
|
|
2138
|
+
}
|
|
2139
|
+
var RemoteValuesCache = class {
|
|
2140
|
+
record = {
|
|
2141
|
+
v: 1,
|
|
2142
|
+
slots: {}
|
|
2143
|
+
};
|
|
2144
|
+
/** The slot key the held values were resolved for. */
|
|
2145
|
+
viewFor = null;
|
|
2146
|
+
config = null;
|
|
2147
|
+
flags = null;
|
|
2148
|
+
/** Bumped by every sign-out; an older ticket's response is not cached. */
|
|
2149
|
+
generation = 0;
|
|
2150
|
+
writes = Promise.resolve();
|
|
2151
|
+
constructor(store, key, defaults, currentUser) {
|
|
2152
|
+
this.store = store;
|
|
2153
|
+
this.key = key;
|
|
2154
|
+
this.defaults = defaults;
|
|
2155
|
+
this.currentUser = currentUser;
|
|
2156
|
+
}
|
|
2157
|
+
/** Read the project's record from storage. `configure()` awaits it. */
|
|
2158
|
+
async load() {
|
|
2159
|
+
let raw = null;
|
|
2160
|
+
try {
|
|
2161
|
+
raw = await this.store.read(this.key);
|
|
2162
|
+
} catch {
|
|
2163
|
+
return;
|
|
2164
|
+
}
|
|
2165
|
+
if (!raw) return;
|
|
2166
|
+
try {
|
|
2167
|
+
const parsed = JSON.parse(raw);
|
|
2168
|
+
const slots = {};
|
|
2169
|
+
if (isPlainObject(parsed) && isPlainObject(parsed["slots"])) for (const [k, v] of Object.entries(parsed["slots"])) {
|
|
2170
|
+
const slot = validSlot(v);
|
|
2171
|
+
if (slot) slots[k] = slot;
|
|
2172
|
+
}
|
|
2173
|
+
this.record = {
|
|
2174
|
+
v: 1,
|
|
2175
|
+
slots
|
|
2176
|
+
};
|
|
2177
|
+
} catch {
|
|
2178
|
+
this.record = {
|
|
2179
|
+
v: 1,
|
|
2180
|
+
slots: {}
|
|
2181
|
+
};
|
|
2182
|
+
this.persist();
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
/** Take a ticket for a request about to be sent for the current user. */
|
|
2186
|
+
ticket() {
|
|
2187
|
+
return {
|
|
2188
|
+
generation: this.generation,
|
|
2189
|
+
requestedFor: this.currentUser()
|
|
2190
|
+
};
|
|
2191
|
+
}
|
|
2192
|
+
/** The value for config `key` (fetched, cached, then default); `undefined` when none. */
|
|
2193
|
+
configValue(key) {
|
|
2194
|
+
this.ensureView();
|
|
2195
|
+
const values = this.config?.values;
|
|
2196
|
+
if (values && Object.prototype.hasOwnProperty.call(values, key)) {
|
|
2197
|
+
const v = values[key];
|
|
2198
|
+
if (v !== null && v !== void 0) return v;
|
|
2199
|
+
}
|
|
2200
|
+
return this.defaultFor(key);
|
|
2201
|
+
}
|
|
2202
|
+
/** The held assignment for flag `key`, or `null`. */
|
|
2203
|
+
flag(key) {
|
|
2204
|
+
this.ensureView();
|
|
2205
|
+
return this.flags?.find((f) => f.name === key) ?? null;
|
|
2206
|
+
}
|
|
2207
|
+
/** Whether flag `key` is on: the held assignment, then a boolean default. */
|
|
2208
|
+
flagEnabled(key) {
|
|
2209
|
+
const flag = this.flag(key);
|
|
2210
|
+
if (flag) return flag.enabled;
|
|
2211
|
+
const d = this.defaultFor(key);
|
|
2212
|
+
return typeof d === "boolean" ? d : void 0;
|
|
2213
|
+
}
|
|
2214
|
+
/**
|
|
2215
|
+
* Hold and persist a fetched bundle; returns it. A bundle the server
|
|
2216
|
+
* evaluated for nobody although the request carried a user (an expired or
|
|
2217
|
+
* revoked session) goes to the `anon` slot and leaves the user's values
|
|
2218
|
+
* alone; one evaluated for another user is not cached.
|
|
2219
|
+
*/
|
|
2220
|
+
storeConfig(ticket, bundle, etag) {
|
|
2221
|
+
if (!isPlainObject(bundle.values)) return bundle;
|
|
2222
|
+
const evaluatedFor = etagEvaluatedFor(etag);
|
|
2223
|
+
let target;
|
|
2224
|
+
if (ticket.requestedFor && evaluatedFor === null) target = ANON_SLOT;
|
|
2225
|
+
else if (typeof evaluatedFor === "string" && evaluatedFor !== ticket.requestedFor) return bundle;
|
|
2226
|
+
this.write(ticket, target, (slot, held) => {
|
|
2227
|
+
slot.config = bundle;
|
|
2228
|
+
if (held) this.config = bundle;
|
|
2229
|
+
});
|
|
2230
|
+
return bundle;
|
|
2231
|
+
}
|
|
2232
|
+
/** Hold and persist a fetched flag list (it replaces the held one). */
|
|
2233
|
+
storeFlags(ticket, flags) {
|
|
2234
|
+
this.write(ticket, void 0, (slot, held) => {
|
|
2235
|
+
slot.flags = [...flags];
|
|
2236
|
+
if (held) this.flags = [...flags];
|
|
2237
|
+
});
|
|
2238
|
+
return flags;
|
|
2239
|
+
}
|
|
2240
|
+
/** Hold and persist one flag from a single-flag lookup; returns it. */
|
|
2241
|
+
upsertFlag(ticket, flag) {
|
|
2242
|
+
this.write(ticket, void 0, (slot, held) => {
|
|
2243
|
+
this.adoptHeldFlags(slot, held);
|
|
2244
|
+
slot.flags = slot.flags ?? [];
|
|
2245
|
+
upsert(slot.flags, flag);
|
|
2246
|
+
if (held) {
|
|
2247
|
+
this.flags = this.flags ?? [];
|
|
2248
|
+
upsert(this.flags, flag);
|
|
2249
|
+
}
|
|
2250
|
+
});
|
|
2251
|
+
return flag;
|
|
2252
|
+
}
|
|
2253
|
+
/** Drop flag `key` after a single-flag lookup found it off or unknown. */
|
|
2254
|
+
removeFlag(ticket, key) {
|
|
2255
|
+
this.write(ticket, void 0, (slot, held) => {
|
|
2256
|
+
this.adoptHeldFlags(slot, held);
|
|
2257
|
+
if (slot.flags) slot.flags = slot.flags.filter((f) => f.name !== key);
|
|
2258
|
+
if (held && this.flags) this.flags = this.flags.filter((f) => f.name !== key);
|
|
2259
|
+
});
|
|
2260
|
+
}
|
|
2261
|
+
/**
|
|
2262
|
+
* Sign-out: delete the signed-out user's slot and drop the held values;
|
|
2263
|
+
* later lookups serve the `anon` slot, then the defaults.
|
|
2264
|
+
*/
|
|
2265
|
+
clearForSignOut(signedOutUser) {
|
|
2266
|
+
this.generation += 1;
|
|
2267
|
+
this.config = null;
|
|
2268
|
+
this.flags = null;
|
|
2269
|
+
this.viewFor = null;
|
|
2270
|
+
if (!signedOutUser) return;
|
|
2271
|
+
const doomed = slotKey(signedOutUser);
|
|
2272
|
+
if (this.record.slots[doomed]) {
|
|
2273
|
+
delete this.record.slots[doomed];
|
|
2274
|
+
this.persist();
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
/** Resolves when every write so far reached storage. */
|
|
2278
|
+
flushed() {
|
|
2279
|
+
return this.writes;
|
|
2280
|
+
}
|
|
2281
|
+
/**
|
|
2282
|
+
* Before a single-flag change to the current user's slot: when the slot
|
|
2283
|
+
* holds no flag list and the served list is the signed-out one, the
|
|
2284
|
+
* user's slot starts from that list, so after a relaunch the user reads
|
|
2285
|
+
* the same flags the change was made on.
|
|
2286
|
+
*/
|
|
2287
|
+
adoptHeldFlags(slot, held) {
|
|
2288
|
+
if (held && !slot.flags && this.flags) slot.flags = [...this.flags];
|
|
2289
|
+
}
|
|
2290
|
+
defaultFor(key) {
|
|
2291
|
+
if (!Object.prototype.hasOwnProperty.call(this.defaults, key)) return void 0;
|
|
2292
|
+
const v = this.defaults[key];
|
|
2293
|
+
return v === null ? void 0 : v;
|
|
2294
|
+
}
|
|
2295
|
+
/**
|
|
2296
|
+
* `held` is `true` when the slot written is the one the current identity
|
|
2297
|
+
* reads, so the served values change with it.
|
|
2298
|
+
*/
|
|
2299
|
+
write(ticket, targetOverride, apply) {
|
|
2300
|
+
const current = slotKey(this.currentUser());
|
|
2301
|
+
if (ticket.generation !== this.generation || slotKey(ticket.requestedFor) !== current) return;
|
|
2302
|
+
this.ensureView();
|
|
2303
|
+
const target = targetOverride ?? current;
|
|
2304
|
+
const existing = this.record.slots[target];
|
|
2305
|
+
const slot = existing ? {
|
|
2306
|
+
saved_at: existing.saved_at,
|
|
2307
|
+
...existing.config ? { config: existing.config } : {},
|
|
2308
|
+
...existing.flags ? { flags: [...existing.flags] } : {}
|
|
2309
|
+
} : { saved_at: 0 };
|
|
2310
|
+
apply(slot, target === current);
|
|
2311
|
+
if (!slot.config && !slot.flags) {
|
|
2312
|
+
if (!existing) return;
|
|
2313
|
+
delete this.record.slots[target];
|
|
2314
|
+
} else {
|
|
2315
|
+
slot.saved_at = Date.now();
|
|
2316
|
+
this.record.slots[target] = slot;
|
|
2317
|
+
this.prune(target);
|
|
2318
|
+
}
|
|
2319
|
+
this.persist();
|
|
2320
|
+
if (target !== current) this.viewFor = null;
|
|
2321
|
+
}
|
|
2322
|
+
/** Serve the current identity's own slot, the `anon` slot for the rest. */
|
|
2323
|
+
ensureView() {
|
|
2324
|
+
const current = slotKey(this.currentUser());
|
|
2325
|
+
if (this.viewFor === current) return;
|
|
2326
|
+
const own = this.record.slots[current];
|
|
2327
|
+
const anon = current === ANON_SLOT ? void 0 : this.record.slots[ANON_SLOT];
|
|
2328
|
+
const config = own?.config ?? anon?.config;
|
|
2329
|
+
const flags = own?.flags ?? anon?.flags;
|
|
2330
|
+
this.config = config ?? null;
|
|
2331
|
+
this.flags = flags ? [...flags] : null;
|
|
2332
|
+
this.viewFor = current;
|
|
2333
|
+
}
|
|
2334
|
+
/**
|
|
2335
|
+
* Keep the MAX_SLOTS most recently written user slots; `keep` and the
|
|
2336
|
+
* signed-out slot (read before sign-in and for every user's gaps) stay.
|
|
2337
|
+
*/
|
|
2338
|
+
prune(keep) {
|
|
2339
|
+
const keys = Object.keys(this.record.slots).filter((k) => k !== ANON_SLOT);
|
|
2340
|
+
while (keys.length > MAX_SLOTS) {
|
|
2341
|
+
let oldest = null;
|
|
2342
|
+
for (const k of keys) {
|
|
2343
|
+
if (k === keep) continue;
|
|
2344
|
+
if (oldest === null || this.record.slots[k].saved_at < this.record.slots[oldest].saved_at) oldest = k;
|
|
2345
|
+
}
|
|
2346
|
+
if (oldest === null) break;
|
|
2347
|
+
delete this.record.slots[oldest];
|
|
2348
|
+
keys.splice(keys.indexOf(oldest), 1);
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
persist() {
|
|
2352
|
+
const text = Object.keys(this.record.slots).length === 0 ? null : JSON.stringify(this.record);
|
|
2353
|
+
this.writes = this.writes.then(() => text === null ? this.store.delete(this.key) : this.store.write(this.key, text)).catch(() => {});
|
|
2354
|
+
}
|
|
2355
|
+
};
|
|
2356
|
+
|
|
1821
2357
|
//#endregion
|
|
1822
2358
|
//#region src/client-info.ts
|
|
1823
2359
|
/**
|
|
@@ -1918,6 +2454,7 @@ var CollectionSubscription = class {
|
|
|
1918
2454
|
return openStream({
|
|
1919
2455
|
url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/collections/${pathSegment(this.name)}${qs}`,
|
|
1920
2456
|
authHeaders: this.ctx.authHeaders,
|
|
2457
|
+
connectTimeoutMs: this.ctx.connectTimeoutMs,
|
|
1921
2458
|
onChange: cb,
|
|
1922
2459
|
options,
|
|
1923
2460
|
parse: parseRowChangeFrame
|
|
@@ -1942,6 +2479,7 @@ var ConversationSubscription = class {
|
|
|
1942
2479
|
return openStream({
|
|
1943
2480
|
url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/conversations/${pathSegment(this.conversationId)}`,
|
|
1944
2481
|
authHeaders: this.ctx.authHeaders,
|
|
2482
|
+
connectTimeoutMs: this.ctx.connectTimeoutMs,
|
|
1945
2483
|
onChange: cb,
|
|
1946
2484
|
options,
|
|
1947
2485
|
parse: parseRowChangeFrame
|
|
@@ -1966,6 +2504,7 @@ var GamificationSubscription = class {
|
|
|
1966
2504
|
return openStream({
|
|
1967
2505
|
url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/gamification`,
|
|
1968
2506
|
authHeaders: this.ctx.authHeaders,
|
|
2507
|
+
connectTimeoutMs: this.ctx.connectTimeoutMs,
|
|
1969
2508
|
onChange: cb,
|
|
1970
2509
|
options,
|
|
1971
2510
|
parse: parseGamificationFrame
|
|
@@ -2000,6 +2539,7 @@ function openStream(args) {
|
|
|
2000
2539
|
closeActive = connectOnce({
|
|
2001
2540
|
url: args.url,
|
|
2002
2541
|
headers,
|
|
2542
|
+
connectTimeoutMs: args.connectTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
|
|
2003
2543
|
onFrame: (frame) => {
|
|
2004
2544
|
const ev = args.parse(frame);
|
|
2005
2545
|
if (ev !== null) args.onChange(ev);
|
|
@@ -2070,8 +2610,13 @@ function connectXhr(args, XHR) {
|
|
|
2070
2610
|
xhr.open("GET", args.url, true);
|
|
2071
2611
|
for (const [k, v] of Object.entries(args.headers)) xhr.setRequestHeader(k, v);
|
|
2072
2612
|
xhr.setRequestHeader("Accept", "text/event-stream");
|
|
2613
|
+
const connectTimer = setTimeout(() => {
|
|
2614
|
+
if (aborted || xhr.readyState >= 2) return;
|
|
2615
|
+
args.onError(/* @__PURE__ */ new Error(`realtime connection timed out after ${args.connectTimeoutMs} ms`));
|
|
2616
|
+
}, args.connectTimeoutMs);
|
|
2073
2617
|
xhr.onreadystatechange = () => {
|
|
2074
2618
|
if (aborted) return;
|
|
2619
|
+
if (xhr.readyState >= 2) clearTimeout(connectTimer);
|
|
2075
2620
|
if (xhr.readyState === 3) {
|
|
2076
2621
|
if (xhr.status >= 400) return;
|
|
2077
2622
|
drain();
|
|
@@ -2095,10 +2640,12 @@ function connectXhr(args, XHR) {
|
|
|
2095
2640
|
try {
|
|
2096
2641
|
xhr.send();
|
|
2097
2642
|
} catch (err) {
|
|
2643
|
+
clearTimeout(connectTimer);
|
|
2098
2644
|
args.onError(err);
|
|
2099
2645
|
}
|
|
2100
2646
|
return () => {
|
|
2101
2647
|
aborted = true;
|
|
2648
|
+
clearTimeout(connectTimer);
|
|
2102
2649
|
try {
|
|
2103
2650
|
xhr.abort();
|
|
2104
2651
|
} catch {}
|
|
@@ -2113,14 +2660,14 @@ function connectFetchStream(args) {
|
|
|
2113
2660
|
let aborted = false;
|
|
2114
2661
|
(async () => {
|
|
2115
2662
|
try {
|
|
2116
|
-
const resp = await fetch(args.url, {
|
|
2663
|
+
const resp = await raceDeadline(fetch(args.url, {
|
|
2117
2664
|
method: "GET",
|
|
2118
2665
|
headers: {
|
|
2119
2666
|
...args.headers,
|
|
2120
2667
|
Accept: "text/event-stream"
|
|
2121
2668
|
},
|
|
2122
2669
|
signal: controller.signal
|
|
2123
|
-
});
|
|
2670
|
+
}), args.connectTimeoutMs, controller, (ms) => /* @__PURE__ */ new Error(`realtime connection timed out after ${ms} ms`));
|
|
2124
2671
|
if (!resp.ok || !resp.body) throw new Error(`realtime connect failed: HTTP ${resp.status}`);
|
|
2125
2672
|
const reader = resp.body.getReader();
|
|
2126
2673
|
const decoder = new TextDecoder();
|
|
@@ -2200,7 +2747,7 @@ function delay(ms) {
|
|
|
2200
2747
|
//#endregion
|
|
2201
2748
|
//#region src/index.ts
|
|
2202
2749
|
/** SDK semver — burned in at build time. Bump alongside package.json. */
|
|
2203
|
-
const SDK_VERSION = "4.1
|
|
2750
|
+
const SDK_VERSION = "4.2.1";
|
|
2204
2751
|
/**
|
|
2205
2752
|
* Default host that serves deployed functions. `Amba.functions.invoke(name)`
|
|
2206
2753
|
* targets `https://<projectId>.<DEFAULT_FUNCTIONS_HOST>/<name>` unless a
|
|
@@ -2244,6 +2791,62 @@ const authSubscribers = /* @__PURE__ */ new Set();
|
|
|
2244
2791
|
* start a new session.
|
|
2245
2792
|
*/
|
|
2246
2793
|
const experimentReporter = new ExperimentAssignmentReporter();
|
|
2794
|
+
/**
|
|
2795
|
+
* `configDefaults` from the latest `configure()` call, recorded before
|
|
2796
|
+
* configure reads the AsyncStorage cache. `config.getCached` and
|
|
2797
|
+
* `flags.isEnabled` serve them on a first render that runs while
|
|
2798
|
+
* `configure()` is still in flight.
|
|
2799
|
+
*/
|
|
2800
|
+
let _pendingConfigDefaults = null;
|
|
2801
|
+
/** Validate `AmbaConfig.configDefaults`: absent, or a plain object. */
|
|
2802
|
+
function readConfigDefaults(value) {
|
|
2803
|
+
if (value === void 0 || value === null) return null;
|
|
2804
|
+
if (typeof value !== "object" || Array.isArray(value)) throw new AmbaApiError("INVALID_CONFIG", "Amba.configure: configDefaults must be an object of config key to value");
|
|
2805
|
+
return value;
|
|
2806
|
+
}
|
|
2807
|
+
/** The pending `configDefaults` entry for `key`, ignoring inherited properties. */
|
|
2808
|
+
function pendingDefault(key) {
|
|
2809
|
+
const defaults = _pendingConfigDefaults;
|
|
2810
|
+
if (!defaults || !Object.prototype.hasOwnProperty.call(defaults, key)) return;
|
|
2811
|
+
const value = defaults[key];
|
|
2812
|
+
return value === null ? void 0 : value;
|
|
2813
|
+
}
|
|
2814
|
+
/** `"array"` for arrays, else `typeof` — the JSON kind of a value. */
|
|
2815
|
+
function jsonKind(value) {
|
|
2816
|
+
return Array.isArray(value) ? "array" : typeof value;
|
|
2817
|
+
}
|
|
2818
|
+
function configGetCached(key, defaultValue) {
|
|
2819
|
+
if (state) refreshOnFirstRead(state, "config");
|
|
2820
|
+
const raw = state ? state.remoteValues.configValue(key) : pendingDefault(key);
|
|
2821
|
+
if (raw === void 0 || raw === null) return defaultValue;
|
|
2822
|
+
if (defaultValue !== void 0 && defaultValue !== null && jsonKind(raw) !== jsonKind(defaultValue)) return defaultValue;
|
|
2823
|
+
return raw;
|
|
2824
|
+
}
|
|
2825
|
+
/** `Amba.flags.isEnabled`. */
|
|
2826
|
+
function flagsIsEnabled(key, defaultValue = false) {
|
|
2827
|
+
if (state) refreshOnFirstRead(state, "flags");
|
|
2828
|
+
const held = state ? state.remoteValues.flagEnabled(key) : pendingDefault(key);
|
|
2829
|
+
return typeof held === "boolean" ? held : defaultValue;
|
|
2830
|
+
}
|
|
2831
|
+
/** `Amba.flags.getCached`. */
|
|
2832
|
+
function flagsGetCached(key) {
|
|
2833
|
+
if (!state) return null;
|
|
2834
|
+
refreshOnFirstRead(state, "flags");
|
|
2835
|
+
return state.remoteValues.flag(key);
|
|
2836
|
+
}
|
|
2837
|
+
/**
|
|
2838
|
+
* The first on-device read of config (or flags) for each identity after
|
|
2839
|
+
* `configure()` starts one background fetch that replaces the cached values:
|
|
2840
|
+
* once at launch, and once more after a sign-in or sign-out changes who is
|
|
2841
|
+
* reading. Apps that never read cached values make no extra request. A
|
|
2842
|
+
* failure (offline) leaves the cached values in place.
|
|
2843
|
+
*/
|
|
2844
|
+
function refreshOnFirstRead(s, kind) {
|
|
2845
|
+
const identity = s.identity.getAppUserId() ?? "";
|
|
2846
|
+
if (s.refreshedFor[kind] === identity) return;
|
|
2847
|
+
s.refreshedFor[kind] = identity;
|
|
2848
|
+
Promise.resolve().then(() => kind === "config" ? Amba.config.fetch() : Amba.flags.fetch()).catch(() => {});
|
|
2849
|
+
}
|
|
2247
2850
|
function snapshotSession(user) {
|
|
2248
2851
|
if (!user) return null;
|
|
2249
2852
|
return {
|
|
@@ -2755,9 +3358,28 @@ function offlineStore(s) {
|
|
|
2755
3358
|
};
|
|
2756
3359
|
}
|
|
2757
3360
|
/**
|
|
3361
|
+
* Whether a live `track` (outbox on) carries its idempotency key:
|
|
3362
|
+
* engagement events whose properties the keyed path takes. Telemetry stays
|
|
3363
|
+
* on the unkeyed hot path, which writes no idempotency record and answers a
|
|
3364
|
+
* failed telemetry insert with 200; its replays from the outbox are keyed.
|
|
3365
|
+
*/
|
|
3366
|
+
function keyedLiveTrack(properties, telemetry) {
|
|
3367
|
+
return telemetry !== true && keyableProperties(properties);
|
|
3368
|
+
}
|
|
3369
|
+
/**
|
|
3370
|
+
* A JSON copy of `properties`: exactly what a request body serializes, and
|
|
3371
|
+
* immune to the app changing the object after `track()` returns.
|
|
3372
|
+
*/
|
|
3373
|
+
function snapshotProperties(properties) {
|
|
3374
|
+
if (properties === void 0 || properties === null) return void 0;
|
|
3375
|
+
const text = JSON.stringify(properties);
|
|
3376
|
+
return text === void 0 ? void 0 : JSON.parse(text);
|
|
3377
|
+
}
|
|
3378
|
+
/**
|
|
2758
3379
|
* Replay one queued mutation against the live API, forwarding the op's
|
|
2759
|
-
* idempotency key on collection inserts so a write that
|
|
2760
|
-
* connection dropped is never double-applied (API
|
|
3380
|
+
* idempotency key on collection inserts and tracked events so a write that
|
|
3381
|
+
* landed before the connection dropped is never double-applied (API
|
|
3382
|
+
* migration 056 for collections, `client_event_idempotency` for events).
|
|
2761
3383
|
*/
|
|
2762
3384
|
async function replayOutboxOp(s, op) {
|
|
2763
3385
|
switch (op.kind) {
|
|
@@ -2779,8 +3401,9 @@ async function replayOutboxOp(s, op) {
|
|
|
2779
3401
|
event: op.event,
|
|
2780
3402
|
properties: op.properties ?? void 0,
|
|
2781
3403
|
anonymous_id: s.identity.getAnonymousId(),
|
|
2782
|
-
timestamp: op.occurredAt ??
|
|
2783
|
-
...op.telemetry ? { telemetry: true } : {}
|
|
3404
|
+
timestamp: op.occurredAt ?? op.enqueuedAt,
|
|
3405
|
+
...op.telemetry ? { telemetry: true } : {},
|
|
3406
|
+
...keyableProperties(op.properties) ? { idempotency_key: op.idempotencyKey } : {}
|
|
2784
3407
|
});
|
|
2785
3408
|
} catch (err) {
|
|
2786
3409
|
const code = err?.code;
|
|
@@ -2862,10 +3485,13 @@ const Amba = {
|
|
|
2862
3485
|
if (!Number.isInteger(flushIntervalMs) || flushIntervalMs < 0) throw new Error("Amba.configure: events.flushIntervalMs must be a non-negative integer");
|
|
2863
3486
|
if (!Number.isInteger(maxQueue) || maxQueue < 1) throw new Error("Amba.configure: events.maxQueue must be positive");
|
|
2864
3487
|
}
|
|
3488
|
+
const deadlines = resolveRequestDeadlines(config);
|
|
2865
3489
|
const tokenStorage = config.tokenStorage ?? "secure";
|
|
2866
3490
|
if (!TOKEN_STORAGE_MODES.includes(tokenStorage)) throw new Error("Amba.configure: tokenStorage must be \"secure\", \"memory\" or \"asyncStorage\"");
|
|
2867
3491
|
const secureStorage = config.secureStorage;
|
|
2868
3492
|
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");
|
|
3493
|
+
const configDefaults = readConfigDefaults(config.configDefaults);
|
|
3494
|
+
_pendingConfigDefaults = configDefaults;
|
|
2869
3495
|
const storagePrefix = config.storagePrefix ?? "amba";
|
|
2870
3496
|
const persistence = new AsyncStoragePersistence(storagePrefix);
|
|
2871
3497
|
if (config.events?.batching) await persistence.assertDurable();
|
|
@@ -2873,6 +3499,8 @@ const Amba = {
|
|
|
2873
3499
|
const vault = await resolveTokenVault(tokenStorage, storagePrefix, secureStorage);
|
|
2874
3500
|
const identity = await IdentityStore.create(persistence, vault);
|
|
2875
3501
|
const baseUrl = (config.apiUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
3502
|
+
const remoteValues = new RemoteValuesCache(persistence, recordKey(baseUrl, config.clientKey), configDefaults ?? {}, () => identity.getAppUserId());
|
|
3503
|
+
await remoteValues.load();
|
|
2876
3504
|
const sdkVersionHeader = `amba-sdk/${SDK_VERSION} ${`amba-react-native/${SDK_VERSION}`}`;
|
|
2877
3505
|
const http = new AmbaHttpClient({
|
|
2878
3506
|
apiKey: config.clientKey,
|
|
@@ -2883,6 +3511,8 @@ const Amba = {
|
|
|
2883
3511
|
deviceRegion: detectDeviceRegion(),
|
|
2884
3512
|
deviceOs: detectDeviceOs(),
|
|
2885
3513
|
identity,
|
|
3514
|
+
requestTimeoutMs: deadlines.requestMs,
|
|
3515
|
+
longRequestTimeoutMs: deadlines.longMs,
|
|
2886
3516
|
ensureSession: config.autoAnonymousSession === false ? void 0 : async (path) => {
|
|
2887
3517
|
if (pathSkipsLazySession(path)) return;
|
|
2888
3518
|
await ensureAnonymousSession(getState());
|
|
@@ -2906,6 +3536,11 @@ const Amba = {
|
|
|
2906
3536
|
resolvedFunctionsProjectId: null,
|
|
2907
3537
|
functionsProjectIdInflight: null,
|
|
2908
3538
|
persistence,
|
|
3539
|
+
remoteValues,
|
|
3540
|
+
refreshedFor: {
|
|
3541
|
+
config: null,
|
|
3542
|
+
flags: null
|
|
3543
|
+
},
|
|
2909
3544
|
offlineEnabled: false,
|
|
2910
3545
|
offlineQueue: null,
|
|
2911
3546
|
eventQueue: null
|
|
@@ -2958,12 +3593,14 @@ const Amba = {
|
|
|
2958
3593
|
},
|
|
2959
3594
|
collection: (name) => new CollectionSubscription({
|
|
2960
3595
|
baseUrl: getState().baseUrl,
|
|
2961
|
-
authHeaders: realtimeAuthHeaders
|
|
3596
|
+
authHeaders: realtimeAuthHeaders,
|
|
3597
|
+
connectTimeoutMs: getState().http.requestTimeoutMs
|
|
2962
3598
|
}, name),
|
|
2963
3599
|
get gamification() {
|
|
2964
3600
|
return new GamificationSubscription({
|
|
2965
3601
|
baseUrl: getState().baseUrl,
|
|
2966
|
-
authHeaders: realtimeAuthHeaders
|
|
3602
|
+
authHeaders: realtimeAuthHeaders,
|
|
3603
|
+
connectTimeoutMs: getState().http.requestTimeoutMs
|
|
2967
3604
|
});
|
|
2968
3605
|
},
|
|
2969
3606
|
events: {
|
|
@@ -2971,13 +3608,16 @@ const Amba = {
|
|
|
2971
3608
|
const s = getState();
|
|
2972
3609
|
if (s.eventQueue && !options?.immediate) return s.eventQueue.enqueue(event, properties, options?.telemetry === true);
|
|
2973
3610
|
const occurredAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3611
|
+
const props = s.offlineEnabled ? snapshotProperties(properties) : properties;
|
|
2974
3612
|
const body = {
|
|
2975
3613
|
event,
|
|
2976
|
-
properties:
|
|
3614
|
+
properties: props ?? void 0,
|
|
2977
3615
|
anonymous_id: s.identity.getAnonymousId(),
|
|
2978
3616
|
timestamp: occurredAt
|
|
2979
3617
|
};
|
|
2980
3618
|
if (options?.telemetry) body["telemetry"] = true;
|
|
3619
|
+
const idempotencyKey = s.offlineEnabled ? newIdempotencyKey() : void 0;
|
|
3620
|
+
if (idempotencyKey && keyedLiveTrack(props, options?.telemetry)) body["idempotency_key"] = idempotencyKey;
|
|
2981
3621
|
const outboxTicket = s.offlineEnabled ? getOfflineQueue(s).ticket() : void 0;
|
|
2982
3622
|
try {
|
|
2983
3623
|
await s.http.post(P.events, body);
|
|
@@ -2986,9 +3626,10 @@ const Amba = {
|
|
|
2986
3626
|
await getOfflineQueue(s).enqueue({
|
|
2987
3627
|
kind: "track",
|
|
2988
3628
|
event,
|
|
2989
|
-
properties,
|
|
3629
|
+
properties: props,
|
|
2990
3630
|
telemetry: options?.telemetry,
|
|
2991
|
-
occurredAt
|
|
3631
|
+
occurredAt,
|
|
3632
|
+
idempotencyKey
|
|
2992
3633
|
}, outboxTicket);
|
|
2993
3634
|
return;
|
|
2994
3635
|
}
|
|
@@ -3156,7 +3797,10 @@ const Amba = {
|
|
|
3156
3797
|
} catch (err) {
|
|
3157
3798
|
if (typeof console !== "undefined") console.warn("[amba] signOut server call failed", err);
|
|
3158
3799
|
}
|
|
3800
|
+
const signedOutUser = s.identity.getAppUserId();
|
|
3159
3801
|
await s.identity.clearSession(rotateAnonymousId);
|
|
3802
|
+
s.remoteValues.clearForSignOut(signedOutUser);
|
|
3803
|
+
await s.remoteValues.flushed();
|
|
3160
3804
|
try {
|
|
3161
3805
|
await getOfflineQueue(s).purge();
|
|
3162
3806
|
} catch (err) {
|
|
@@ -3447,21 +4091,25 @@ const Amba = {
|
|
|
3447
4091
|
await getState().http.delete(`${P.storage}/${encodePathSegment(assetId)}`);
|
|
3448
4092
|
},
|
|
3449
4093
|
download: async (assetId) => {
|
|
3450
|
-
const
|
|
4094
|
+
const s = getState();
|
|
4095
|
+
const assetEnv = await s.http.get(`${P.storage}/${encodePathSegment(assetId)}`);
|
|
3451
4096
|
const cdnUrl = assetEnv.data.url ?? assetEnv.data.storage_url ?? "";
|
|
3452
4097
|
if (!cdnUrl) throw new AmbaApiError("NOT_FOUND", "asset has no resolvable CDN url");
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
response
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
}
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
4098
|
+
const sizeBytes = assetEnv.data.size_bytes;
|
|
4099
|
+
const { response, blob, text } = await s.http.send(cdnUrl, { method: "GET" }, async (res) => res.ok ? {
|
|
4100
|
+
response: res,
|
|
4101
|
+
blob: await res.blob(),
|
|
4102
|
+
text: ""
|
|
4103
|
+
} : {
|
|
4104
|
+
response: res,
|
|
4105
|
+
blob: null,
|
|
4106
|
+
text: await res.text()
|
|
4107
|
+
}, {
|
|
4108
|
+
timeoutMs: s.http.longRequestTimeoutMs,
|
|
4109
|
+
responseBytes: typeof sizeBytes === "number" ? sizeBytes : void 0
|
|
4110
|
+
});
|
|
4111
|
+
if (!blob) throw mapHttpError(response, text, (response.headers.get("content-type") ?? "").includes("application/json"));
|
|
4112
|
+
return blob;
|
|
3465
4113
|
}
|
|
3466
4114
|
},
|
|
3467
4115
|
push: {
|
|
@@ -3509,27 +4157,30 @@ const Amba = {
|
|
|
3509
4157
|
functions: { invoke: async (name, body, options) => {
|
|
3510
4158
|
const s = getState();
|
|
3511
4159
|
if (!name || name.trim() === "") throw new AmbaApiError("INVALID_ARGUMENT", "Amba.functions.invoke: a function name is required.");
|
|
4160
|
+
let timeoutMs;
|
|
4161
|
+
try {
|
|
4162
|
+
timeoutMs = readTimeout("Amba.functions.invoke: timeoutMs", options?.timeoutMs, s.http.longRequestTimeoutMs);
|
|
4163
|
+
} catch (err) {
|
|
4164
|
+
throw new AmbaApiError("INVALID_ARGUMENT", err.message);
|
|
4165
|
+
}
|
|
3512
4166
|
const url = await resolveFunctionUrl(s, name);
|
|
3513
4167
|
const method = options?.method ?? "POST";
|
|
3514
4168
|
const headers = s.http.buildHeaders(options?.headers);
|
|
3515
4169
|
let requestBody;
|
|
3516
4170
|
if (body !== void 0 && method !== "GET") requestBody = JSON.stringify(body);
|
|
3517
4171
|
else delete headers["Content-Type"];
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
}
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
if (!response.ok)
|
|
3530
|
-
const ct = response.headers.get("content-type") ?? "";
|
|
3531
|
-
throw mapHttpError(response, text, ct.includes("application/json"));
|
|
3532
|
-
}
|
|
4172
|
+
const { response, text } = await s.http.send(url, {
|
|
4173
|
+
method,
|
|
4174
|
+
headers,
|
|
4175
|
+
body: requestBody
|
|
4176
|
+
}, async (res) => ({
|
|
4177
|
+
response: res,
|
|
4178
|
+
text: await res.text()
|
|
4179
|
+
}), {
|
|
4180
|
+
timeoutMs,
|
|
4181
|
+
label: `Amba.functions.invoke("${name}") network error`
|
|
4182
|
+
});
|
|
4183
|
+
if (!response.ok) throw mapHttpError(response, text, (response.headers.get("content-type") ?? "").includes("application/json"));
|
|
3533
4184
|
if (!text) return void 0;
|
|
3534
4185
|
try {
|
|
3535
4186
|
return JSON.parse(text);
|
|
@@ -3537,54 +4188,63 @@ const Amba = {
|
|
|
3537
4188
|
return text;
|
|
3538
4189
|
}
|
|
3539
4190
|
} },
|
|
3540
|
-
config: {
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
response = await
|
|
4191
|
+
config: {
|
|
4192
|
+
fetch: async () => {
|
|
4193
|
+
const s = getState();
|
|
4194
|
+
const ticket = s.remoteValues.ticket();
|
|
4195
|
+
const url = s.http.url(P.config);
|
|
4196
|
+
const headers = s.http.buildHeaders();
|
|
4197
|
+
const { response, text } = await s.http.send(url, {
|
|
3547
4198
|
method: "GET",
|
|
3548
4199
|
headers
|
|
3549
|
-
})
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
};
|
|
3575
|
-
} },
|
|
4200
|
+
}, async (res) => ({
|
|
4201
|
+
response: res,
|
|
4202
|
+
text: await res.text()
|
|
4203
|
+
}));
|
|
4204
|
+
if (!response.ok) throw mapHttpError(response, text, (response.headers.get("content-type") ?? "").includes("application/json"));
|
|
4205
|
+
let parsed = {};
|
|
4206
|
+
if (text) try {
|
|
4207
|
+
parsed = JSON.parse(text);
|
|
4208
|
+
} catch {
|
|
4209
|
+
parsed = {};
|
|
4210
|
+
}
|
|
4211
|
+
const etag = response.headers.get("etag");
|
|
4212
|
+
let version = null;
|
|
4213
|
+
if (etag) {
|
|
4214
|
+
const cleaned = etag.replace(/^"|"$/g, "");
|
|
4215
|
+
const m = cleaned.match(/^(v\d+)/);
|
|
4216
|
+
version = m ? m[1] ?? null : cleaned || null;
|
|
4217
|
+
}
|
|
4218
|
+
return s.remoteValues.storeConfig(ticket, {
|
|
4219
|
+
version,
|
|
4220
|
+
values: parsed.data ?? {}
|
|
4221
|
+
}, etag);
|
|
4222
|
+
},
|
|
4223
|
+
getCached: configGetCached
|
|
4224
|
+
},
|
|
3576
4225
|
flags: {
|
|
3577
4226
|
fetch: async () => {
|
|
3578
|
-
|
|
4227
|
+
const s = getState();
|
|
4228
|
+
const ticket = s.remoteValues.ticket();
|
|
4229
|
+
const env = await s.http.get(P.flags);
|
|
4230
|
+
return s.remoteValues.storeFlags(ticket, env.data);
|
|
3579
4231
|
},
|
|
3580
4232
|
get: async (key) => {
|
|
4233
|
+
const s = getState();
|
|
4234
|
+
const ticket = s.remoteValues.ticket();
|
|
3581
4235
|
try {
|
|
3582
|
-
|
|
4236
|
+
const env = await s.http.get(`${P.flags}/${encodePathSegment(key)}`);
|
|
4237
|
+
return s.remoteValues.upsertFlag(ticket, env.data);
|
|
3583
4238
|
} catch (err) {
|
|
3584
|
-
if (err instanceof AmbaApiError && err.code === "NOT_FOUND")
|
|
4239
|
+
if (err instanceof AmbaApiError && err.code === "NOT_FOUND") {
|
|
4240
|
+
s.remoteValues.removeFlag(ticket, key);
|
|
4241
|
+
return null;
|
|
4242
|
+
}
|
|
3585
4243
|
throw err;
|
|
3586
4244
|
}
|
|
3587
|
-
}
|
|
4245
|
+
},
|
|
4246
|
+
getCached: flagsGetCached,
|
|
4247
|
+
isEnabled: flagsIsEnabled
|
|
3588
4248
|
},
|
|
3589
4249
|
experiments: {
|
|
3590
4250
|
getVariant: async (key) => {
|
|
@@ -3764,7 +4424,8 @@ const Amba = {
|
|
|
3764
4424
|
messaging: {
|
|
3765
4425
|
conversation: (conversationId) => new ConversationSubscription({
|
|
3766
4426
|
baseUrl: getState().baseUrl,
|
|
3767
|
-
authHeaders: realtimeAuthHeaders
|
|
4427
|
+
authHeaders: realtimeAuthHeaders,
|
|
4428
|
+
connectTimeoutMs: getState().http.requestTimeoutMs
|
|
3768
4429
|
}, conversationId),
|
|
3769
4430
|
createConversation: async (request) => {
|
|
3770
4431
|
return (await getState().http.post(`${P.messaging}/conversations`, request)).data;
|