@layers/amba-react-native 4.2.0 → 4.2.2

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 CHANGED
@@ -84,12 +84,200 @@ 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. */
90
251
  const DEFAULT_BASE_URL = "https://api.amba.dev";
91
- /** Server codes for a session token the server no longer accepts. */
92
- const SESSION_REFUSED_CODES = new Set(["SESSION_REVOKED", "INVALID_SESSION_TOKEN"]);
252
+ /**
253
+ * Server codes for a session token the server no longer accepts.
254
+ * `INVALID_SESSION` is the code the auth routes that verify the session
255
+ * themselves (`/identifiers`, `/link`, `/unlink`) answer with.
256
+ */
257
+ const SESSION_REFUSED_CODES = new Set([
258
+ "SESSION_REVOKED",
259
+ "INVALID_SESSION_TOKEN",
260
+ "INVALID_SESSION"
261
+ ]);
262
+ /** True for a server error that refused the session token a call sent. */
263
+ function isSessionRefusedError(err) {
264
+ return err instanceof AmbaApiError && SESSION_REFUSED_CODES.has(err.code);
265
+ }
266
+ const AUTH_PATH_PREFIX = "/v1/client/auth/";
267
+ /**
268
+ * The auth routes that read the session from the `Authorization` header and
269
+ * mint nothing: a refused session on them recovers like it does on any
270
+ * other authenticated call. The other auth routes stay out of the
271
+ * recovery: `/refresh` is the recovery itself, `/logout` ends the session,
272
+ * the sign-in, sign-up, OTP and magic-link routes take credentials (a 401
273
+ * there is a credential error), and `/link` and `/unlink` carry the session
274
+ * in their body, so their callers recover and rebuild the body themselves.
275
+ */
276
+ const SESSION_HEADER_AUTH_PATHS = new Set([`${AUTH_PATH_PREFIX}me`, `${AUTH_PATH_PREFIX}identifiers`]);
277
+ /** Whether a refused session on `path` goes through `onSessionRefused`. */
278
+ function recoversRefusedSession(path) {
279
+ return !path.startsWith(AUTH_PATH_PREFIX) || SESSION_HEADER_AUTH_PATHS.has(path);
280
+ }
93
281
  var AmbaHttpClient = class {
94
282
  apiKey;
95
283
  baseUrl;
@@ -100,6 +288,12 @@ var AmbaHttpClient = class {
100
288
  deviceOs;
101
289
  identity;
102
290
  fetchImpl;
291
+ /** The `fetchImpl` option, when one was passed (`send` resolves fetch per call otherwise). */
292
+ injectedFetch;
293
+ /** Deadline for an API request, in milliseconds. */
294
+ requestTimeoutMs;
295
+ /** Deadline for AI generation, function invocations and media transfers. */
296
+ longRequestTimeoutMs;
103
297
  ensureSession;
104
298
  onSessionRefused;
105
299
  constructor(opts) {
@@ -113,7 +307,39 @@ var AmbaHttpClient = class {
113
307
  this.identity = opts.identity;
114
308
  this.ensureSession = opts.ensureSession;
115
309
  this.onSessionRefused = opts.onSessionRefused;
310
+ this.injectedFetch = opts.fetchImpl;
116
311
  this.fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
312
+ this.requestTimeoutMs = opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
313
+ this.longRequestTimeoutMs = opts.longRequestTimeoutMs ?? DEFAULT_LONG_REQUEST_TIMEOUT_MS;
314
+ }
315
+ /**
316
+ * Send a request to an absolute URL outside the API's JSON envelope (a
317
+ * function invocation, a CDN download, the config route that reads the
318
+ * `ETag`) under a deadline. `read` takes what the caller needs from the
319
+ * response (the body as text, a `Blob`) and runs inside the same
320
+ * deadline, so a body that stops arriving fails too.
321
+ *
322
+ * A transport failure rejects with `NETWORK_ERROR` and the message
323
+ * `<label>: <cause>`; the deadline rejects with `NETWORK_ERROR` and
324
+ * `<label>: request timed out after <ms> ms`. `label` defaults to
325
+ * `Network error` and `timeoutMs` to the request deadline; the deadline
326
+ * widens with the request body and with `responseBytes`, the size the
327
+ * caller expects to download (see `exchange`). An `AmbaApiError` that
328
+ * `read` throws passes through unchanged.
329
+ */
330
+ async send(url, init, read, opts = {}) {
331
+ const timeoutMs = opts.timeoutMs ?? this.requestTimeoutMs;
332
+ const label = opts.label ?? "Network error";
333
+ const fetchFn = this.injectedFetch ?? globalThis.fetch;
334
+ try {
335
+ return await exchange(timeoutMs, (signal) => fetchFn(url, withSignal(init, signal)), read, (ms) => timeoutError(label, ms), {
336
+ requestBytes: bodyByteLength(init.body),
337
+ responseBytes: opts.responseBytes,
338
+ fetchBuffersBody: true
339
+ });
340
+ } catch (err) {
341
+ throw asNetworkError(err, label);
342
+ }
117
343
  }
118
344
  /** Build the absolute URL for an API path, optionally with a query string. */
119
345
  url(path, query) {
@@ -173,16 +399,21 @@ var AmbaHttpClient = class {
173
399
  headers,
174
400
  body: body === void 0 ? void 0 : JSON.stringify(body ?? {})
175
401
  };
402
+ const timeoutMs = isLongApiPath(path) ? this.longRequestTimeoutMs : this.requestTimeoutMs;
176
403
  let response;
404
+ let text;
177
405
  try {
178
- response = await this.fetchImpl(url, init);
406
+ ({response, text} = await exchange(timeoutMs, (signal) => this.fetchImpl(url, withSignal(init, signal)), async (res) => ({
407
+ response: res,
408
+ text: res.status === 204 ? "" : await res.text()
409
+ }), (ms) => timeoutError("Network error", ms), { requestBytes: bodyByteLength(init.body) }));
179
410
  } catch (err) {
180
- throw new AmbaApiError("NETWORK_ERROR", `Network error: ${err instanceof Error ? err.message : typeof err === "string" ? err : "fetch failed"}`, err);
411
+ throw asNetworkError(err, "Network error");
181
412
  }
182
413
  try {
183
- return await parseResponse(response);
414
+ return parseResponse(response, text);
184
415
  } catch (err) {
185
- 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);
416
+ if (!resent && storedToken && this.onSessionRefused && recoversRefusedSession(path) && isSessionRefusedError(err) && await this.recoverSession(storedToken, storedUserId)) return this.request(method, path, body, query, void 0, true);
186
417
  throw err;
187
418
  }
188
419
  }
@@ -195,9 +426,21 @@ var AmbaHttpClient = class {
195
426
  }
196
427
  }
197
428
  };
198
- async function parseResponse(response) {
429
+ /** The `NETWORK_ERROR` a passed deadline raises. */
430
+ function timeoutError(label, timeoutMs) {
431
+ return new AmbaApiError("NETWORK_ERROR", `${label}: ${timeoutMessage(timeoutMs)}`, { timeoutMs });
432
+ }
433
+ /**
434
+ * Wrap a transport failure as `NETWORK_ERROR` (`<label>: <cause>`). An
435
+ * `AmbaApiError` (the deadline's own, or one a `send` reader throws) passes
436
+ * through unchanged.
437
+ */
438
+ function asNetworkError(err, label) {
439
+ if (err instanceof AmbaApiError) return err;
440
+ return new AmbaApiError("NETWORK_ERROR", `${label}: ${err instanceof Error ? err.message : typeof err === "string" ? err : "fetch failed"}`, err);
441
+ }
442
+ function parseResponse(response, text) {
199
443
  if (response.status === 204) return null;
200
- const text = await response.text();
201
444
  const isJson = (response.headers.get("content-type") ?? "").includes("application/json");
202
445
  if (!response.ok) throw mapHttpError(response, text, isJson);
203
446
  if (text === "") return null;
@@ -893,7 +1136,16 @@ const DETERMINISTIC_ERROR_CODES = new Set([
893
1136
  "NOT_FOUND",
894
1137
  "CONFLICT",
895
1138
  "CONSENT_NOT_GRANTED",
896
- "NOT_INITIALIZED"
1139
+ "NOT_INITIALIZED",
1140
+ "INVALID_JSON",
1141
+ "INVALID_BODY",
1142
+ "INVALID_EVENT",
1143
+ "RESERVED_EVENT_NAME",
1144
+ "INVALID_IDEMPOTENCY_KEY",
1145
+ "INVALID_TIMESTAMP",
1146
+ "INVALID_PROPERTIES",
1147
+ "INVALID_TELEMETRY",
1148
+ "IDEMPOTENCY_KEY_CONFLICT"
897
1149
  ]);
898
1150
  /**
899
1151
  * Is this a deterministic server rejection — a coded error the server would
@@ -931,13 +1183,34 @@ function isOfflineError(err) {
931
1183
  if (typeof msg === "string" && /network (error|request failed)|failed to fetch|fetch failed|load failed/i.test(msg)) return true;
932
1184
  return !isOnline();
933
1185
  }
934
- /** Generate a stable idempotency key for a queued op. */
1186
+ /**
1187
+ * Generate a stable idempotency key for a queued op: `amba_obx_` and a random
1188
+ * UUID. The server records event keys project-wide, so the UUID comes from
1189
+ * the runtime's secure random source: `crypto.randomUUID`, else
1190
+ * `crypto.getRandomValues` (Hermes has only the latter).
1191
+ */
935
1192
  function newIdempotencyKey() {
936
1193
  const c = typeof globalThis !== "undefined" ? globalThis.crypto : void 0;
937
1194
  if (c && typeof c.randomUUID === "function") return `amba_obx_${c.randomUUID()}`;
1195
+ if (c && typeof c.getRandomValues === "function") {
1196
+ const b = c.getRandomValues(new Uint8Array(16));
1197
+ b[6] = b[6] & 15 | 64;
1198
+ b[8] = b[8] & 63 | 128;
1199
+ const h = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
1200
+ return `amba_obx_${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
1201
+ }
938
1202
  return `amba_obx_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
939
1203
  }
940
1204
  /**
1205
+ * Whether a `track` with these properties can take the events route's keyed
1206
+ * path, which accepts a plain object or no properties. A JS caller can pass
1207
+ * something else (an array); such an event stays on the unkeyed path, which
1208
+ * stores it as it always has.
1209
+ */
1210
+ function keyableProperties(properties) {
1211
+ return properties === void 0 || properties === null || typeof properties === "object" && !Array.isArray(properties);
1212
+ }
1213
+ /**
941
1214
  * Durable, ordered, idempotent offline outbox. Opt-in via `Amba.offline`.
942
1215
  * Single in-flight flush is enforced so concurrent reconnect signals don't
943
1216
  * double-replay.
@@ -2208,6 +2481,7 @@ var CollectionSubscription = class {
2208
2481
  return openStream({
2209
2482
  url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/collections/${pathSegment(this.name)}${qs}`,
2210
2483
  authHeaders: this.ctx.authHeaders,
2484
+ connectTimeoutMs: this.ctx.connectTimeoutMs,
2211
2485
  onChange: cb,
2212
2486
  options,
2213
2487
  parse: parseRowChangeFrame
@@ -2232,6 +2506,7 @@ var ConversationSubscription = class {
2232
2506
  return openStream({
2233
2507
  url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/conversations/${pathSegment(this.conversationId)}`,
2234
2508
  authHeaders: this.ctx.authHeaders,
2509
+ connectTimeoutMs: this.ctx.connectTimeoutMs,
2235
2510
  onChange: cb,
2236
2511
  options,
2237
2512
  parse: parseRowChangeFrame
@@ -2256,6 +2531,7 @@ var GamificationSubscription = class {
2256
2531
  return openStream({
2257
2532
  url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/gamification`,
2258
2533
  authHeaders: this.ctx.authHeaders,
2534
+ connectTimeoutMs: this.ctx.connectTimeoutMs,
2259
2535
  onChange: cb,
2260
2536
  options,
2261
2537
  parse: parseGamificationFrame
@@ -2290,6 +2566,7 @@ function openStream(args) {
2290
2566
  closeActive = connectOnce({
2291
2567
  url: args.url,
2292
2568
  headers,
2569
+ connectTimeoutMs: args.connectTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
2293
2570
  onFrame: (frame) => {
2294
2571
  const ev = args.parse(frame);
2295
2572
  if (ev !== null) args.onChange(ev);
@@ -2360,8 +2637,13 @@ function connectXhr(args, XHR) {
2360
2637
  xhr.open("GET", args.url, true);
2361
2638
  for (const [k, v] of Object.entries(args.headers)) xhr.setRequestHeader(k, v);
2362
2639
  xhr.setRequestHeader("Accept", "text/event-stream");
2640
+ const connectTimer = setTimeout(() => {
2641
+ if (aborted || xhr.readyState >= 2) return;
2642
+ args.onError(/* @__PURE__ */ new Error(`realtime connection timed out after ${args.connectTimeoutMs} ms`));
2643
+ }, args.connectTimeoutMs);
2363
2644
  xhr.onreadystatechange = () => {
2364
2645
  if (aborted) return;
2646
+ if (xhr.readyState >= 2) clearTimeout(connectTimer);
2365
2647
  if (xhr.readyState === 3) {
2366
2648
  if (xhr.status >= 400) return;
2367
2649
  drain();
@@ -2385,10 +2667,12 @@ function connectXhr(args, XHR) {
2385
2667
  try {
2386
2668
  xhr.send();
2387
2669
  } catch (err) {
2670
+ clearTimeout(connectTimer);
2388
2671
  args.onError(err);
2389
2672
  }
2390
2673
  return () => {
2391
2674
  aborted = true;
2675
+ clearTimeout(connectTimer);
2392
2676
  try {
2393
2677
  xhr.abort();
2394
2678
  } catch {}
@@ -2403,14 +2687,14 @@ function connectFetchStream(args) {
2403
2687
  let aborted = false;
2404
2688
  (async () => {
2405
2689
  try {
2406
- const resp = await fetch(args.url, {
2690
+ const resp = await raceDeadline(fetch(args.url, {
2407
2691
  method: "GET",
2408
2692
  headers: {
2409
2693
  ...args.headers,
2410
2694
  Accept: "text/event-stream"
2411
2695
  },
2412
2696
  signal: controller.signal
2413
- });
2697
+ }), args.connectTimeoutMs, controller, (ms) => /* @__PURE__ */ new Error(`realtime connection timed out after ${ms} ms`));
2414
2698
  if (!resp.ok || !resp.body) throw new Error(`realtime connect failed: HTTP ${resp.status}`);
2415
2699
  const reader = resp.body.getReader();
2416
2700
  const decoder = new TextDecoder();
@@ -2490,7 +2774,7 @@ function delay(ms) {
2490
2774
  //#endregion
2491
2775
  //#region src/index.ts
2492
2776
  /** SDK semver — burned in at build time. Bump alongside package.json. */
2493
- const SDK_VERSION = "4.2.0";
2777
+ const SDK_VERSION = "4.2.2";
2494
2778
  /**
2495
2779
  * Default host that serves deployed functions. `Amba.functions.invoke(name)`
2496
2780
  * targets `https://<projectId>.<DEFAULT_FUNCTIONS_HOST>/<name>` unless a
@@ -2776,7 +3060,11 @@ function trackLink(state_, link) {
2776
3060
  }
2777
3061
  /**
2778
3062
  * After the server refused the stored session token on an authenticated
2779
- * call (`SESSION_REVOKED`, `INVALID_SESSION_TOKEN`). True when a usable
3063
+ * call (`SESSION_REVOKED`, `INVALID_SESSION_TOKEN`, `INVALID_SESSION`). The
3064
+ * HTTP client runs it for calls that send the session in the
3065
+ * `Authorization` header, `/v1/client/auth/me` and `/identifiers`
3066
+ * included; `withBodySession` runs it for the link and unlink calls, which
3067
+ * send the session in their body. True when a usable
2780
3068
  * session for the user the call was sent for is stored afterwards, so the
2781
3069
  * call is sent once more as that user:
2782
3070
  * - a link in flight on this device stores the pair it returns, which is
@@ -2810,6 +3098,40 @@ async function recoverRefusedSession(state_, usedToken, usedUserId) {
2810
3098
  return usableForCaller(state_.identity.getSessionToken());
2811
3099
  }
2812
3100
  /**
3101
+ * Send an auth call that carries the stored session token in its body
3102
+ * (`/link`, `/unlink`). `send` builds the request from the token it is
3103
+ * given. When the server refuses that token, the session recovers through
3104
+ * `recoverRefusedSession` (one refresh shared with every other refused
3105
+ * call) and the call is sent once more with the recovered token; a second
3106
+ * refusal reaches the app. A link waits here outside `trackLink`: its own
3107
+ * failed attempt has already left `linksInflight` (the removal is the first
3108
+ * reaction on that promise), so recovery never waits on itself.
3109
+ */
3110
+ async function withBodySession(state_, send) {
3111
+ const usedToken = state_.identity.getSessionToken();
3112
+ if (!usedToken) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
3113
+ const usedUserId = state_.identity.getAppUserId();
3114
+ try {
3115
+ return await send(usedToken);
3116
+ } catch (err) {
3117
+ if (!isSessionRefusedError(err)) throw err;
3118
+ if (!await recoverRefusedSession(state_, usedToken, usedUserId)) throw err;
3119
+ const recovered = state_.identity.getSessionToken();
3120
+ if (!recovered) throw err;
3121
+ return send(recovered);
3122
+ }
3123
+ }
3124
+ /**
3125
+ * Before a read of the current session (`me`, `getSession`,
3126
+ * `getCurrentIdentifiers`) while no session token is stored: wait for the
3127
+ * launch restore or sign-in in flight, so a check made at launch sees the
3128
+ * session it restores.
3129
+ */
3130
+ async function settleSessionInFlight(state_) {
3131
+ if (state_.identity.getSessionToken() || !state_.anonSignInInflight) return;
3132
+ await state_.anonSignInInflight.catch(() => {});
3133
+ }
3134
+ /**
2813
3135
  * Continue the stored session with its refresh token. True when it did.
2814
3136
  * False when there is no refresh token, or the server rejected it: the
2815
3137
  * session is then cleared (the anonymous id rotates unless the session was
@@ -2920,7 +3242,10 @@ async function refreshSessionOnce(state_) {
2920
3242
  * account's rotated anonymous id is adopted.
2921
3243
  */
2922
3244
  function postLink(state_, body) {
2923
- return trackLink(state_, postLinkOnce(state_, body));
3245
+ return withBodySession(state_, (sessionToken) => trackLink(state_, postLinkOnce(state_, {
3246
+ ...body,
3247
+ session_token: sessionToken
3248
+ })));
2924
3249
  }
2925
3250
  async function postLinkOnce(state_, body) {
2926
3251
  const env = await state_.http.post(P.authLink, {
@@ -2956,12 +3281,10 @@ async function postUserNoRotate(state_, path, body) {
2956
3281
  }
2957
3282
  async function unlinkProvider(provider) {
2958
3283
  const s = getState();
2959
- const sessionToken = s.identity.getSessionToken();
2960
- if (!sessionToken) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
2961
- return postUserNoRotate(s, P.authUnlink, {
3284
+ return withBodySession(s, (sessionToken) => postUserNoRotate(s, P.authUnlink, {
2962
3285
  provider,
2963
3286
  session_token: sessionToken
2964
- });
3287
+ }));
2965
3288
  }
2966
3289
  function requireEmail(email) {
2967
3290
  const trimmed = email.trim();
@@ -3101,9 +3424,28 @@ function offlineStore(s) {
3101
3424
  };
3102
3425
  }
3103
3426
  /**
3427
+ * Whether a live `track` (outbox on) carries its idempotency key:
3428
+ * engagement events whose properties the keyed path takes. Telemetry stays
3429
+ * on the unkeyed hot path, which writes no idempotency record and answers a
3430
+ * failed telemetry insert with 200; its replays from the outbox are keyed.
3431
+ */
3432
+ function keyedLiveTrack(properties, telemetry) {
3433
+ return telemetry !== true && keyableProperties(properties);
3434
+ }
3435
+ /**
3436
+ * A JSON copy of `properties`: exactly what a request body serializes, and
3437
+ * immune to the app changing the object after `track()` returns.
3438
+ */
3439
+ function snapshotProperties(properties) {
3440
+ if (properties === void 0 || properties === null) return void 0;
3441
+ const text = JSON.stringify(properties);
3442
+ return text === void 0 ? void 0 : JSON.parse(text);
3443
+ }
3444
+ /**
3104
3445
  * Replay one queued mutation against the live API, forwarding the op's
3105
- * idempotency key on collection inserts so a write that landed before the
3106
- * connection dropped is never double-applied (API migration 056).
3446
+ * idempotency key on collection inserts and tracked events so a write that
3447
+ * landed before the connection dropped is never double-applied (API
3448
+ * migration 056 for collections, `client_event_idempotency` for events).
3107
3449
  */
3108
3450
  async function replayOutboxOp(s, op) {
3109
3451
  switch (op.kind) {
@@ -3125,8 +3467,9 @@ async function replayOutboxOp(s, op) {
3125
3467
  event: op.event,
3126
3468
  properties: op.properties ?? void 0,
3127
3469
  anonymous_id: s.identity.getAnonymousId(),
3128
- timestamp: op.occurredAt ?? (/* @__PURE__ */ new Date()).toISOString(),
3129
- ...op.telemetry ? { telemetry: true } : {}
3470
+ timestamp: op.occurredAt ?? op.enqueuedAt,
3471
+ ...op.telemetry ? { telemetry: true } : {},
3472
+ ...keyableProperties(op.properties) ? { idempotency_key: op.idempotencyKey } : {}
3130
3473
  });
3131
3474
  } catch (err) {
3132
3475
  const code = err?.code;
@@ -3208,6 +3551,7 @@ const Amba = {
3208
3551
  if (!Number.isInteger(flushIntervalMs) || flushIntervalMs < 0) throw new Error("Amba.configure: events.flushIntervalMs must be a non-negative integer");
3209
3552
  if (!Number.isInteger(maxQueue) || maxQueue < 1) throw new Error("Amba.configure: events.maxQueue must be positive");
3210
3553
  }
3554
+ const deadlines = resolveRequestDeadlines(config);
3211
3555
  const tokenStorage = config.tokenStorage ?? "secure";
3212
3556
  if (!TOKEN_STORAGE_MODES.includes(tokenStorage)) throw new Error("Amba.configure: tokenStorage must be \"secure\", \"memory\" or \"asyncStorage\"");
3213
3557
  const secureStorage = config.secureStorage;
@@ -3233,6 +3577,8 @@ const Amba = {
3233
3577
  deviceRegion: detectDeviceRegion(),
3234
3578
  deviceOs: detectDeviceOs(),
3235
3579
  identity,
3580
+ requestTimeoutMs: deadlines.requestMs,
3581
+ longRequestTimeoutMs: deadlines.longMs,
3236
3582
  ensureSession: config.autoAnonymousSession === false ? void 0 : async (path) => {
3237
3583
  if (pathSkipsLazySession(path)) return;
3238
3584
  await ensureAnonymousSession(getState());
@@ -3313,12 +3659,14 @@ const Amba = {
3313
3659
  },
3314
3660
  collection: (name) => new CollectionSubscription({
3315
3661
  baseUrl: getState().baseUrl,
3316
- authHeaders: realtimeAuthHeaders
3662
+ authHeaders: realtimeAuthHeaders,
3663
+ connectTimeoutMs: getState().http.requestTimeoutMs
3317
3664
  }, name),
3318
3665
  get gamification() {
3319
3666
  return new GamificationSubscription({
3320
3667
  baseUrl: getState().baseUrl,
3321
- authHeaders: realtimeAuthHeaders
3668
+ authHeaders: realtimeAuthHeaders,
3669
+ connectTimeoutMs: getState().http.requestTimeoutMs
3322
3670
  });
3323
3671
  },
3324
3672
  events: {
@@ -3326,13 +3674,16 @@ const Amba = {
3326
3674
  const s = getState();
3327
3675
  if (s.eventQueue && !options?.immediate) return s.eventQueue.enqueue(event, properties, options?.telemetry === true);
3328
3676
  const occurredAt = (/* @__PURE__ */ new Date()).toISOString();
3677
+ const props = s.offlineEnabled ? snapshotProperties(properties) : properties;
3329
3678
  const body = {
3330
3679
  event,
3331
- properties: properties ?? void 0,
3680
+ properties: props ?? void 0,
3332
3681
  anonymous_id: s.identity.getAnonymousId(),
3333
3682
  timestamp: occurredAt
3334
3683
  };
3335
3684
  if (options?.telemetry) body["telemetry"] = true;
3685
+ const idempotencyKey = s.offlineEnabled ? newIdempotencyKey() : void 0;
3686
+ if (idempotencyKey && keyedLiveTrack(props, options?.telemetry)) body["idempotency_key"] = idempotencyKey;
3336
3687
  const outboxTicket = s.offlineEnabled ? getOfflineQueue(s).ticket() : void 0;
3337
3688
  try {
3338
3689
  await s.http.post(P.events, body);
@@ -3341,9 +3692,10 @@ const Amba = {
3341
3692
  await getOfflineQueue(s).enqueue({
3342
3693
  kind: "track",
3343
3694
  event,
3344
- properties,
3695
+ properties: props,
3345
3696
  telemetry: options?.telemetry,
3346
- occurredAt
3697
+ occurredAt,
3698
+ idempotencyKey
3347
3699
  }, outboxTicket);
3348
3700
  return;
3349
3701
  }
@@ -3446,52 +3798,38 @@ const Amba = {
3446
3798
  if (providerTrimmed === "") throw new AmbaApiError("VALIDATION_ERROR", "provider must not be empty");
3447
3799
  if (typeof credential !== "string" || credential.trim() === "") throw new AmbaApiError("VALIDATION_ERROR", "credential must not be empty");
3448
3800
  const s = getState();
3449
- const sessionToken = s.identity.getSessionToken();
3450
- if (!sessionToken) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
3451
- return trackLink(s, postAuth(s, P.authLink, {
3801
+ return withBodySession(s, (sessionToken) => trackLink(s, postAuth(s, P.authLink, {
3452
3802
  provider: providerTrimmed,
3453
3803
  token: credential,
3454
3804
  session_token: sessionToken,
3455
3805
  ...nonceField(options)
3456
- }));
3806
+ })));
3457
3807
  },
3458
3808
  linkSmsOtp: async (phone, code) => {
3459
3809
  requireE164Phone(phone);
3460
3810
  requireOtpCode(code);
3461
- const s = getState();
3462
- const sessionToken = s.identity.getSessionToken();
3463
- if (!sessionToken) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
3464
- return postLink(s, {
3811
+ return postLink(getState(), {
3465
3812
  provider: "sms_otp",
3466
3813
  phone,
3467
- code,
3468
- session_token: sessionToken
3814
+ code
3469
3815
  });
3470
3816
  },
3471
3817
  linkEmailOtp: async (email, code) => {
3472
3818
  requireEmail(email);
3473
3819
  requireOtpCode(code);
3474
- const s = getState();
3475
- const sessionToken = s.identity.getSessionToken();
3476
- if (!sessionToken) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
3477
- return postLink(s, {
3820
+ return postLink(getState(), {
3478
3821
  provider: "email_otp",
3479
3822
  email,
3480
- code,
3481
- session_token: sessionToken
3823
+ code
3482
3824
  });
3483
3825
  },
3484
3826
  linkEmailPassword: async (email, password) => {
3485
3827
  requireEmail(email);
3486
3828
  requirePassword(password);
3487
- const s = getState();
3488
- const sessionToken = s.identity.getSessionToken();
3489
- if (!sessionToken) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
3490
- return postLink(s, {
3829
+ return postLink(getState(), {
3491
3830
  provider: "email_password",
3492
3831
  email,
3493
- password,
3494
- session_token: sessionToken
3832
+ password
3495
3833
  });
3496
3834
  },
3497
3835
  unlinkSmsOtp: async () => unlinkProvider("sms_otp"),
@@ -3500,6 +3838,7 @@ const Amba = {
3500
3838
  unlinkGoogle: async () => unlinkProvider("google"),
3501
3839
  getCurrentIdentifiers: async () => {
3502
3840
  const s = getState();
3841
+ await settleSessionInFlight(s);
3503
3842
  if (!s.identity.getSessionToken()) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
3504
3843
  return (await s.http.get(P.authIdentifiers)).data.identifiers;
3505
3844
  },
@@ -3526,13 +3865,20 @@ const Amba = {
3526
3865
  refresh: async () => refreshSession(getState()),
3527
3866
  me: async () => {
3528
3867
  const s = getState();
3868
+ await settleSessionInFlight(s);
3529
3869
  if (!s.identity.getSessionToken()) throw new AmbaApiError("UNAUTHORIZED", "no active session — call signIn* first");
3530
3870
  return normalizeUserAuthProviders((await s.http.get(P.authMe)).data);
3531
3871
  },
3532
3872
  getSession: async () => {
3533
3873
  const s = getState();
3874
+ await settleSessionInFlight(s);
3534
3875
  if (!s.identity.isAuthenticated()) return null;
3535
- return snapshotSession(normalizeUserAuthProviders((await s.http.get(P.authMe)).data));
3876
+ try {
3877
+ return snapshotSession(normalizeUserAuthProviders((await s.http.get(P.authMe)).data));
3878
+ } catch (err) {
3879
+ if (isSessionRefusedError(err) && !s.identity.isAuthenticated()) return null;
3880
+ throw err;
3881
+ }
3536
3882
  },
3537
3883
  getAnonymousId: async () => {
3538
3884
  return getState().identity.getAnonymousId();
@@ -3805,21 +4151,25 @@ const Amba = {
3805
4151
  await getState().http.delete(`${P.storage}/${encodePathSegment(assetId)}`);
3806
4152
  },
3807
4153
  download: async (assetId) => {
3808
- const assetEnv = await getState().http.get(`${P.storage}/${encodePathSegment(assetId)}`);
4154
+ const s = getState();
4155
+ const assetEnv = await s.http.get(`${P.storage}/${encodePathSegment(assetId)}`);
3809
4156
  const cdnUrl = assetEnv.data.url ?? assetEnv.data.storage_url ?? "";
3810
4157
  if (!cdnUrl) throw new AmbaApiError("NOT_FOUND", "asset has no resolvable CDN url");
3811
- let response;
3812
- try {
3813
- response = await (globalThis.fetch ?? fetch)(cdnUrl, { method: "GET" });
3814
- } catch (err) {
3815
- throw new AmbaApiError("NETWORK_ERROR", `Network error: ${err instanceof Error ? err.message : String(err)}`, err);
3816
- }
3817
- if (!response.ok) {
3818
- const text = await response.text();
3819
- const ct = response.headers.get("content-type") ?? "";
3820
- throw mapHttpError(response, text, ct.includes("application/json"));
3821
- }
3822
- return response.blob();
4158
+ const sizeBytes = assetEnv.data.size_bytes;
4159
+ const { response, blob, text } = await s.http.send(cdnUrl, { method: "GET" }, async (res) => res.ok ? {
4160
+ response: res,
4161
+ blob: await res.blob(),
4162
+ text: ""
4163
+ } : {
4164
+ response: res,
4165
+ blob: null,
4166
+ text: await res.text()
4167
+ }, {
4168
+ timeoutMs: s.http.longRequestTimeoutMs,
4169
+ responseBytes: typeof sizeBytes === "number" ? sizeBytes : void 0
4170
+ });
4171
+ if (!blob) throw mapHttpError(response, text, (response.headers.get("content-type") ?? "").includes("application/json"));
4172
+ return blob;
3823
4173
  }
3824
4174
  },
3825
4175
  push: {
@@ -3867,27 +4217,30 @@ const Amba = {
3867
4217
  functions: { invoke: async (name, body, options) => {
3868
4218
  const s = getState();
3869
4219
  if (!name || name.trim() === "") throw new AmbaApiError("INVALID_ARGUMENT", "Amba.functions.invoke: a function name is required.");
4220
+ let timeoutMs;
4221
+ try {
4222
+ timeoutMs = readTimeout("Amba.functions.invoke: timeoutMs", options?.timeoutMs, s.http.longRequestTimeoutMs);
4223
+ } catch (err) {
4224
+ throw new AmbaApiError("INVALID_ARGUMENT", err.message);
4225
+ }
3870
4226
  const url = await resolveFunctionUrl(s, name);
3871
4227
  const method = options?.method ?? "POST";
3872
4228
  const headers = s.http.buildHeaders(options?.headers);
3873
4229
  let requestBody;
3874
4230
  if (body !== void 0 && method !== "GET") requestBody = JSON.stringify(body);
3875
4231
  else delete headers["Content-Type"];
3876
- let response;
3877
- try {
3878
- response = await (globalThis.fetch ?? fetch)(url, {
3879
- method,
3880
- headers,
3881
- body: requestBody
3882
- });
3883
- } catch (err) {
3884
- throw new AmbaApiError("NETWORK_ERROR", `Amba.functions.invoke("${name}") network error: ${err instanceof Error ? err.message : String(err)}`, err);
3885
- }
3886
- const text = await response.text();
3887
- if (!response.ok) {
3888
- const ct = response.headers.get("content-type") ?? "";
3889
- throw mapHttpError(response, text, ct.includes("application/json"));
3890
- }
4232
+ const { response, text } = await s.http.send(url, {
4233
+ method,
4234
+ headers,
4235
+ body: requestBody
4236
+ }, async (res) => ({
4237
+ response: res,
4238
+ text: await res.text()
4239
+ }), {
4240
+ timeoutMs,
4241
+ label: `Amba.functions.invoke("${name}") network error`
4242
+ });
4243
+ if (!response.ok) throw mapHttpError(response, text, (response.headers.get("content-type") ?? "").includes("application/json"));
3891
4244
  if (!text) return void 0;
3892
4245
  try {
3893
4246
  return JSON.parse(text);
@@ -3901,20 +4254,14 @@ const Amba = {
3901
4254
  const ticket = s.remoteValues.ticket();
3902
4255
  const url = s.http.url(P.config);
3903
4256
  const headers = s.http.buildHeaders();
3904
- let response;
3905
- try {
3906
- response = await (globalThis.fetch ?? fetch)(url, {
3907
- method: "GET",
3908
- headers
3909
- });
3910
- } catch (err) {
3911
- throw new AmbaApiError("NETWORK_ERROR", `Network error: ${err instanceof Error ? err.message : String(err)}`, err);
3912
- }
3913
- const text = await response.text();
3914
- if (!response.ok) {
3915
- const ct = response.headers.get("content-type") ?? "";
3916
- throw mapHttpError(response, text, ct.includes("application/json"));
3917
- }
4257
+ const { response, text } = await s.http.send(url, {
4258
+ method: "GET",
4259
+ headers
4260
+ }, async (res) => ({
4261
+ response: res,
4262
+ text: await res.text()
4263
+ }));
4264
+ if (!response.ok) throw mapHttpError(response, text, (response.headers.get("content-type") ?? "").includes("application/json"));
3918
4265
  let parsed = {};
3919
4266
  if (text) try {
3920
4267
  parsed = JSON.parse(text);
@@ -4137,7 +4484,8 @@ const Amba = {
4137
4484
  messaging: {
4138
4485
  conversation: (conversationId) => new ConversationSubscription({
4139
4486
  baseUrl: getState().baseUrl,
4140
- authHeaders: realtimeAuthHeaders
4487
+ authHeaders: realtimeAuthHeaders,
4488
+ connectTimeoutMs: getState().http.requestTimeoutMs
4141
4489
  }, conversationId),
4142
4490
  createConversation: async (request) => {
4143
4491
  return (await getState().http.post(`${P.messaging}/conversations`, request)).data;