@layers/amba-react-native 4.2.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 +350 -62
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +43 -9
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +43 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +350 -62
- 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.
|
|
@@ -2208,6 +2454,7 @@ var CollectionSubscription = class {
|
|
|
2208
2454
|
return openStream({
|
|
2209
2455
|
url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/collections/${pathSegment(this.name)}${qs}`,
|
|
2210
2456
|
authHeaders: this.ctx.authHeaders,
|
|
2457
|
+
connectTimeoutMs: this.ctx.connectTimeoutMs,
|
|
2211
2458
|
onChange: cb,
|
|
2212
2459
|
options,
|
|
2213
2460
|
parse: parseRowChangeFrame
|
|
@@ -2232,6 +2479,7 @@ var ConversationSubscription = class {
|
|
|
2232
2479
|
return openStream({
|
|
2233
2480
|
url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/conversations/${pathSegment(this.conversationId)}`,
|
|
2234
2481
|
authHeaders: this.ctx.authHeaders,
|
|
2482
|
+
connectTimeoutMs: this.ctx.connectTimeoutMs,
|
|
2235
2483
|
onChange: cb,
|
|
2236
2484
|
options,
|
|
2237
2485
|
parse: parseRowChangeFrame
|
|
@@ -2256,6 +2504,7 @@ var GamificationSubscription = class {
|
|
|
2256
2504
|
return openStream({
|
|
2257
2505
|
url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/gamification`,
|
|
2258
2506
|
authHeaders: this.ctx.authHeaders,
|
|
2507
|
+
connectTimeoutMs: this.ctx.connectTimeoutMs,
|
|
2259
2508
|
onChange: cb,
|
|
2260
2509
|
options,
|
|
2261
2510
|
parse: parseGamificationFrame
|
|
@@ -2290,6 +2539,7 @@ function openStream(args) {
|
|
|
2290
2539
|
closeActive = connectOnce({
|
|
2291
2540
|
url: args.url,
|
|
2292
2541
|
headers,
|
|
2542
|
+
connectTimeoutMs: args.connectTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
|
|
2293
2543
|
onFrame: (frame) => {
|
|
2294
2544
|
const ev = args.parse(frame);
|
|
2295
2545
|
if (ev !== null) args.onChange(ev);
|
|
@@ -2360,8 +2610,13 @@ function connectXhr(args, XHR) {
|
|
|
2360
2610
|
xhr.open("GET", args.url, true);
|
|
2361
2611
|
for (const [k, v] of Object.entries(args.headers)) xhr.setRequestHeader(k, v);
|
|
2362
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);
|
|
2363
2617
|
xhr.onreadystatechange = () => {
|
|
2364
2618
|
if (aborted) return;
|
|
2619
|
+
if (xhr.readyState >= 2) clearTimeout(connectTimer);
|
|
2365
2620
|
if (xhr.readyState === 3) {
|
|
2366
2621
|
if (xhr.status >= 400) return;
|
|
2367
2622
|
drain();
|
|
@@ -2385,10 +2640,12 @@ function connectXhr(args, XHR) {
|
|
|
2385
2640
|
try {
|
|
2386
2641
|
xhr.send();
|
|
2387
2642
|
} catch (err) {
|
|
2643
|
+
clearTimeout(connectTimer);
|
|
2388
2644
|
args.onError(err);
|
|
2389
2645
|
}
|
|
2390
2646
|
return () => {
|
|
2391
2647
|
aborted = true;
|
|
2648
|
+
clearTimeout(connectTimer);
|
|
2392
2649
|
try {
|
|
2393
2650
|
xhr.abort();
|
|
2394
2651
|
} catch {}
|
|
@@ -2403,14 +2660,14 @@ function connectFetchStream(args) {
|
|
|
2403
2660
|
let aborted = false;
|
|
2404
2661
|
(async () => {
|
|
2405
2662
|
try {
|
|
2406
|
-
const resp = await fetch(args.url, {
|
|
2663
|
+
const resp = await raceDeadline(fetch(args.url, {
|
|
2407
2664
|
method: "GET",
|
|
2408
2665
|
headers: {
|
|
2409
2666
|
...args.headers,
|
|
2410
2667
|
Accept: "text/event-stream"
|
|
2411
2668
|
},
|
|
2412
2669
|
signal: controller.signal
|
|
2413
|
-
});
|
|
2670
|
+
}), args.connectTimeoutMs, controller, (ms) => /* @__PURE__ */ new Error(`realtime connection timed out after ${ms} ms`));
|
|
2414
2671
|
if (!resp.ok || !resp.body) throw new Error(`realtime connect failed: HTTP ${resp.status}`);
|
|
2415
2672
|
const reader = resp.body.getReader();
|
|
2416
2673
|
const decoder = new TextDecoder();
|
|
@@ -2490,7 +2747,7 @@ function delay(ms) {
|
|
|
2490
2747
|
//#endregion
|
|
2491
2748
|
//#region src/index.ts
|
|
2492
2749
|
/** SDK semver — burned in at build time. Bump alongside package.json. */
|
|
2493
|
-
const SDK_VERSION = "4.2.
|
|
2750
|
+
const SDK_VERSION = "4.2.1";
|
|
2494
2751
|
/**
|
|
2495
2752
|
* Default host that serves deployed functions. `Amba.functions.invoke(name)`
|
|
2496
2753
|
* targets `https://<projectId>.<DEFAULT_FUNCTIONS_HOST>/<name>` unless a
|
|
@@ -3101,9 +3358,28 @@ function offlineStore(s) {
|
|
|
3101
3358
|
};
|
|
3102
3359
|
}
|
|
3103
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
|
+
/**
|
|
3104
3379
|
* Replay one queued mutation against the live API, forwarding the op's
|
|
3105
|
-
* idempotency key on collection inserts so a write that
|
|
3106
|
-
* 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).
|
|
3107
3383
|
*/
|
|
3108
3384
|
async function replayOutboxOp(s, op) {
|
|
3109
3385
|
switch (op.kind) {
|
|
@@ -3125,8 +3401,9 @@ async function replayOutboxOp(s, op) {
|
|
|
3125
3401
|
event: op.event,
|
|
3126
3402
|
properties: op.properties ?? void 0,
|
|
3127
3403
|
anonymous_id: s.identity.getAnonymousId(),
|
|
3128
|
-
timestamp: op.occurredAt ??
|
|
3129
|
-
...op.telemetry ? { telemetry: true } : {}
|
|
3404
|
+
timestamp: op.occurredAt ?? op.enqueuedAt,
|
|
3405
|
+
...op.telemetry ? { telemetry: true } : {},
|
|
3406
|
+
...keyableProperties(op.properties) ? { idempotency_key: op.idempotencyKey } : {}
|
|
3130
3407
|
});
|
|
3131
3408
|
} catch (err) {
|
|
3132
3409
|
const code = err?.code;
|
|
@@ -3208,6 +3485,7 @@ const Amba = {
|
|
|
3208
3485
|
if (!Number.isInteger(flushIntervalMs) || flushIntervalMs < 0) throw new Error("Amba.configure: events.flushIntervalMs must be a non-negative integer");
|
|
3209
3486
|
if (!Number.isInteger(maxQueue) || maxQueue < 1) throw new Error("Amba.configure: events.maxQueue must be positive");
|
|
3210
3487
|
}
|
|
3488
|
+
const deadlines = resolveRequestDeadlines(config);
|
|
3211
3489
|
const tokenStorage = config.tokenStorage ?? "secure";
|
|
3212
3490
|
if (!TOKEN_STORAGE_MODES.includes(tokenStorage)) throw new Error("Amba.configure: tokenStorage must be \"secure\", \"memory\" or \"asyncStorage\"");
|
|
3213
3491
|
const secureStorage = config.secureStorage;
|
|
@@ -3233,6 +3511,8 @@ const Amba = {
|
|
|
3233
3511
|
deviceRegion: detectDeviceRegion(),
|
|
3234
3512
|
deviceOs: detectDeviceOs(),
|
|
3235
3513
|
identity,
|
|
3514
|
+
requestTimeoutMs: deadlines.requestMs,
|
|
3515
|
+
longRequestTimeoutMs: deadlines.longMs,
|
|
3236
3516
|
ensureSession: config.autoAnonymousSession === false ? void 0 : async (path) => {
|
|
3237
3517
|
if (pathSkipsLazySession(path)) return;
|
|
3238
3518
|
await ensureAnonymousSession(getState());
|
|
@@ -3313,12 +3593,14 @@ const Amba = {
|
|
|
3313
3593
|
},
|
|
3314
3594
|
collection: (name) => new CollectionSubscription({
|
|
3315
3595
|
baseUrl: getState().baseUrl,
|
|
3316
|
-
authHeaders: realtimeAuthHeaders
|
|
3596
|
+
authHeaders: realtimeAuthHeaders,
|
|
3597
|
+
connectTimeoutMs: getState().http.requestTimeoutMs
|
|
3317
3598
|
}, name),
|
|
3318
3599
|
get gamification() {
|
|
3319
3600
|
return new GamificationSubscription({
|
|
3320
3601
|
baseUrl: getState().baseUrl,
|
|
3321
|
-
authHeaders: realtimeAuthHeaders
|
|
3602
|
+
authHeaders: realtimeAuthHeaders,
|
|
3603
|
+
connectTimeoutMs: getState().http.requestTimeoutMs
|
|
3322
3604
|
});
|
|
3323
3605
|
},
|
|
3324
3606
|
events: {
|
|
@@ -3326,13 +3608,16 @@ const Amba = {
|
|
|
3326
3608
|
const s = getState();
|
|
3327
3609
|
if (s.eventQueue && !options?.immediate) return s.eventQueue.enqueue(event, properties, options?.telemetry === true);
|
|
3328
3610
|
const occurredAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3611
|
+
const props = s.offlineEnabled ? snapshotProperties(properties) : properties;
|
|
3329
3612
|
const body = {
|
|
3330
3613
|
event,
|
|
3331
|
-
properties:
|
|
3614
|
+
properties: props ?? void 0,
|
|
3332
3615
|
anonymous_id: s.identity.getAnonymousId(),
|
|
3333
3616
|
timestamp: occurredAt
|
|
3334
3617
|
};
|
|
3335
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;
|
|
3336
3621
|
const outboxTicket = s.offlineEnabled ? getOfflineQueue(s).ticket() : void 0;
|
|
3337
3622
|
try {
|
|
3338
3623
|
await s.http.post(P.events, body);
|
|
@@ -3341,9 +3626,10 @@ const Amba = {
|
|
|
3341
3626
|
await getOfflineQueue(s).enqueue({
|
|
3342
3627
|
kind: "track",
|
|
3343
3628
|
event,
|
|
3344
|
-
properties,
|
|
3629
|
+
properties: props,
|
|
3345
3630
|
telemetry: options?.telemetry,
|
|
3346
|
-
occurredAt
|
|
3631
|
+
occurredAt,
|
|
3632
|
+
idempotencyKey
|
|
3347
3633
|
}, outboxTicket);
|
|
3348
3634
|
return;
|
|
3349
3635
|
}
|
|
@@ -3805,21 +4091,25 @@ const Amba = {
|
|
|
3805
4091
|
await getState().http.delete(`${P.storage}/${encodePathSegment(assetId)}`);
|
|
3806
4092
|
},
|
|
3807
4093
|
download: async (assetId) => {
|
|
3808
|
-
const
|
|
4094
|
+
const s = getState();
|
|
4095
|
+
const assetEnv = await s.http.get(`${P.storage}/${encodePathSegment(assetId)}`);
|
|
3809
4096
|
const cdnUrl = assetEnv.data.url ?? assetEnv.data.storage_url ?? "";
|
|
3810
4097
|
if (!cdnUrl) throw new AmbaApiError("NOT_FOUND", "asset has no resolvable CDN url");
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
response
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
}
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
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;
|
|
3823
4113
|
}
|
|
3824
4114
|
},
|
|
3825
4115
|
push: {
|
|
@@ -3867,27 +4157,30 @@ const Amba = {
|
|
|
3867
4157
|
functions: { invoke: async (name, body, options) => {
|
|
3868
4158
|
const s = getState();
|
|
3869
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
|
+
}
|
|
3870
4166
|
const url = await resolveFunctionUrl(s, name);
|
|
3871
4167
|
const method = options?.method ?? "POST";
|
|
3872
4168
|
const headers = s.http.buildHeaders(options?.headers);
|
|
3873
4169
|
let requestBody;
|
|
3874
4170
|
if (body !== void 0 && method !== "GET") requestBody = JSON.stringify(body);
|
|
3875
4171
|
else delete headers["Content-Type"];
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
}
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
if (!response.ok)
|
|
3888
|
-
const ct = response.headers.get("content-type") ?? "";
|
|
3889
|
-
throw mapHttpError(response, text, ct.includes("application/json"));
|
|
3890
|
-
}
|
|
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"));
|
|
3891
4184
|
if (!text) return void 0;
|
|
3892
4185
|
try {
|
|
3893
4186
|
return JSON.parse(text);
|
|
@@ -3901,20 +4194,14 @@ const Amba = {
|
|
|
3901
4194
|
const ticket = s.remoteValues.ticket();
|
|
3902
4195
|
const url = s.http.url(P.config);
|
|
3903
4196
|
const headers = s.http.buildHeaders();
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
}
|
|
3911
|
-
|
|
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
|
-
}
|
|
4197
|
+
const { response, text } = await s.http.send(url, {
|
|
4198
|
+
method: "GET",
|
|
4199
|
+
headers
|
|
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"));
|
|
3918
4205
|
let parsed = {};
|
|
3919
4206
|
if (text) try {
|
|
3920
4207
|
parsed = JSON.parse(text);
|
|
@@ -4137,7 +4424,8 @@ const Amba = {
|
|
|
4137
4424
|
messaging: {
|
|
4138
4425
|
conversation: (conversationId) => new ConversationSubscription({
|
|
4139
4426
|
baseUrl: getState().baseUrl,
|
|
4140
|
-
authHeaders: realtimeAuthHeaders
|
|
4427
|
+
authHeaders: realtimeAuthHeaders,
|
|
4428
|
+
connectTimeoutMs: getState().http.requestTimeoutMs
|
|
4141
4429
|
}, conversationId),
|
|
4142
4430
|
createConversation: async (request) => {
|
|
4143
4431
|
return (await getState().http.post(`${P.messaging}/conversations`, request)).data;
|