@sendoracloud/sdk-react-native 1.0.5 → 1.1.0
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 +113 -12
- package/dist/index.d.cts +52 -3
- package/dist/index.d.ts +52 -3
- package/dist/index.js +113 -12
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -136,16 +136,36 @@ var Storage = class {
|
|
|
136
136
|
};
|
|
137
137
|
|
|
138
138
|
// src/http.ts
|
|
139
|
-
|
|
140
|
-
|
|
139
|
+
var NetworkTimeoutError = class extends Error {
|
|
140
|
+
constructor(method, path, timeoutMs) {
|
|
141
|
+
super(`Request timed out: ${method} ${path} (${timeoutMs}ms)`);
|
|
142
|
+
this.name = "NetworkTimeoutError";
|
|
143
|
+
this.method = method;
|
|
144
|
+
this.path = path;
|
|
145
|
+
this.timeoutMs = timeoutMs;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
var DEFAULT_AUTH_TIMEOUT_MS = 1e4;
|
|
149
|
+
var DEFAULT_INGEST_TIMEOUT_MS = 3e4;
|
|
150
|
+
function defaultTimeoutFor(path) {
|
|
151
|
+
if (path.startsWith("/api/v1/auth-service/")) return DEFAULT_AUTH_TIMEOUT_MS;
|
|
152
|
+
return DEFAULT_INGEST_TIMEOUT_MS;
|
|
153
|
+
}
|
|
154
|
+
async function post(opts, path, body, extraHeaders, reqOpts) {
|
|
155
|
+
return request(opts, "POST", path, body, extraHeaders, reqOpts);
|
|
141
156
|
}
|
|
142
|
-
async function getJson(opts, path, extraHeaders) {
|
|
143
|
-
return request(opts, "GET", path, void 0, extraHeaders);
|
|
157
|
+
async function getJson(opts, path, extraHeaders, reqOpts) {
|
|
158
|
+
return request(opts, "GET", path, void 0, extraHeaders, reqOpts);
|
|
144
159
|
}
|
|
145
|
-
async function del(opts, path, extraHeaders) {
|
|
146
|
-
return request(opts, "DELETE", path, void 0, extraHeaders);
|
|
160
|
+
async function del(opts, path, extraHeaders, reqOpts) {
|
|
161
|
+
return request(opts, "DELETE", path, void 0, extraHeaders, reqOpts);
|
|
147
162
|
}
|
|
148
|
-
async function request(opts, method, path, body, extraHeaders) {
|
|
163
|
+
async function request(opts, method, path, body, extraHeaders, reqOpts) {
|
|
164
|
+
const timeoutMs = reqOpts?.timeoutMs ?? defaultTimeoutFor(path);
|
|
165
|
+
const controller = new AbortController();
|
|
166
|
+
const timer = setTimeout(() => {
|
|
167
|
+
controller.abort();
|
|
168
|
+
}, timeoutMs);
|
|
149
169
|
let res;
|
|
150
170
|
try {
|
|
151
171
|
res = await fetch(`${opts.apiUrl}${path}`, {
|
|
@@ -160,15 +180,20 @@ async function request(opts, method, path, body, extraHeaders) {
|
|
|
160
180
|
"x-api-key": opts.publicKey,
|
|
161
181
|
...extraHeaders ?? {}
|
|
162
182
|
},
|
|
163
|
-
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
183
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0,
|
|
184
|
+
signal: controller.signal
|
|
164
185
|
});
|
|
165
186
|
} catch (err) {
|
|
187
|
+
clearTimeout(timer);
|
|
188
|
+
const aborted = err?.name === "AbortError";
|
|
189
|
+
if (aborted) throw new NetworkTimeoutError(method, path, timeoutMs);
|
|
166
190
|
console.warn(
|
|
167
191
|
`[sendoracloud] ${method} ${path} failed at the network layer:`,
|
|
168
192
|
err instanceof Error ? err.message : String(err)
|
|
169
193
|
);
|
|
170
194
|
return null;
|
|
171
195
|
}
|
|
196
|
+
clearTimeout(timer);
|
|
172
197
|
if (!res.ok) {
|
|
173
198
|
const isAuthPath = path.startsWith("/api/v1/auth-service/");
|
|
174
199
|
if (isAuthPath && !opts.debug) {
|
|
@@ -237,6 +262,19 @@ var Auth = class {
|
|
|
237
262
|
this.accessExpiresAt = 0;
|
|
238
263
|
this.inflight = Promise.resolve();
|
|
239
264
|
this.refreshInflight = null;
|
|
265
|
+
/**
|
|
266
|
+
* Wave 62 — refresh failure back-pressure.
|
|
267
|
+
*
|
|
268
|
+
* Consecutive network-timeout failures don't wipe identity (refresh
|
|
269
|
+
* may still be valid; iOS NSURLSession was just stalled) but do
|
|
270
|
+
* back off so the next caller doesn't immediately fire another
|
|
271
|
+
* fetch that will also time out. After `REFRESH_FAILURE_COOLDOWN_MS`
|
|
272
|
+
* the counter resets + retries are allowed. AppState foreground
|
|
273
|
+
* also clears it so a user returning to the app gets an immediate
|
|
274
|
+
* fresh attempt.
|
|
275
|
+
*/
|
|
276
|
+
this.refreshFailureCount = 0;
|
|
277
|
+
this.refreshCooldownUntil = 0;
|
|
240
278
|
this.takeoverListeners = /* @__PURE__ */ new Set();
|
|
241
279
|
this.lastTakeover = null;
|
|
242
280
|
/**
|
|
@@ -343,15 +381,38 @@ var Auth = class {
|
|
|
343
381
|
}
|
|
344
382
|
}
|
|
345
383
|
/**
|
|
346
|
-
* Returns
|
|
347
|
-
*
|
|
348
|
-
*
|
|
384
|
+
* Returns an access token or null. Cold-launch hardening (Wave 62):
|
|
385
|
+
*
|
|
386
|
+
* 1. Non-expired cached → return it. (Fast path.)
|
|
387
|
+
* 2. Expired AND refresh inflight → return stale cached anyway.
|
|
388
|
+
* Backend grace window (60s — see auth-service refresh-rotation
|
|
389
|
+
* middleware) accepts slightly-stale access tokens, so the
|
|
390
|
+
* caller's downstream RPC will succeed against grace while
|
|
391
|
+
* the in-flight refresh resolves out of band. Distinct from
|
|
392
|
+
* pre-Wave-62 behaviour where 5 concurrent callers all shared
|
|
393
|
+
* the same in-flight promise — when that promise hung (iOS
|
|
394
|
+
* NSURLSession cold-launch contention) every caller hung too.
|
|
395
|
+
* 3. Expired + no inflight + refresh available + not in cooldown →
|
|
396
|
+
* fire refresh, await it.
|
|
397
|
+
* 4. Expired + in cooldown → return stale cached. Caller's RPC
|
|
398
|
+
* either succeeds against grace or fails fast on 401, which is
|
|
399
|
+
* a better UX than blocking the whole bridge waiting on a
|
|
400
|
+
* network that we already know is stalled.
|
|
401
|
+
*
|
|
402
|
+
* Returns null only when there's NO usable token at all (no cached
|
|
403
|
+
* + no refresh available).
|
|
349
404
|
*/
|
|
350
405
|
async getAccessToken() {
|
|
351
406
|
if (!this.accessToken) return null;
|
|
352
407
|
if (this.accessExpiresAt && Date.now() < this.accessExpiresAt - REFRESH_SAFETY_MS) {
|
|
353
408
|
return this.accessToken;
|
|
354
409
|
}
|
|
410
|
+
if (this.refreshInflight) {
|
|
411
|
+
return this.accessToken;
|
|
412
|
+
}
|
|
413
|
+
if (Date.now() < this.refreshCooldownUntil) {
|
|
414
|
+
return this.accessToken;
|
|
415
|
+
}
|
|
355
416
|
return this.refreshAccessToken();
|
|
356
417
|
}
|
|
357
418
|
/** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
|
|
@@ -890,6 +951,8 @@ var Auth = class {
|
|
|
890
951
|
if (Number.isFinite(freshExpires) && freshExpires > Date.now()) {
|
|
891
952
|
this.accessToken = freshAccess;
|
|
892
953
|
this.accessExpiresAt = freshExpires;
|
|
954
|
+
this.refreshFailureCount = 0;
|
|
955
|
+
this.refreshCooldownUntil = 0;
|
|
893
956
|
return freshAccess;
|
|
894
957
|
}
|
|
895
958
|
}
|
|
@@ -902,11 +965,19 @@ var Auth = class {
|
|
|
902
965
|
this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
|
|
903
966
|
this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
|
|
904
967
|
this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
|
|
968
|
+
this.refreshFailureCount = 0;
|
|
969
|
+
this.refreshCooldownUntil = 0;
|
|
905
970
|
return data.tokens.accessToken;
|
|
906
971
|
} catch (err) {
|
|
907
972
|
if (err instanceof AuthError && isDeadRefreshError(err.code)) {
|
|
908
973
|
await this.wipeLocalIdentity().catch(() => void 0);
|
|
974
|
+
this.refreshFailureCount = 0;
|
|
975
|
+
this.refreshCooldownUntil = 0;
|
|
976
|
+
return null;
|
|
909
977
|
}
|
|
978
|
+
this.refreshFailureCount += 1;
|
|
979
|
+
const backoffMs = Math.min(3e4, 1e3 * Math.pow(2, this.refreshFailureCount - 1));
|
|
980
|
+
this.refreshCooldownUntil = Date.now() + backoffMs;
|
|
910
981
|
return null;
|
|
911
982
|
} finally {
|
|
912
983
|
this.refreshInflight = null;
|
|
@@ -914,6 +985,20 @@ var Auth = class {
|
|
|
914
985
|
})();
|
|
915
986
|
return this.refreshInflight;
|
|
916
987
|
}
|
|
988
|
+
/**
|
|
989
|
+
* Wave 62 — AppState resume hook. Clears stuck backoff so a user
|
|
990
|
+
* who backgrounded the app during a network blip + returns now
|
|
991
|
+
* gets an immediate fresh refresh attempt instead of waiting on
|
|
992
|
+
* the cooldown timer.
|
|
993
|
+
*
|
|
994
|
+
* Called from the AppState 'active' listener registered in
|
|
995
|
+
* startProactiveRefreshCron. Idempotent + cheap — three field
|
|
996
|
+
* writes; no I/O.
|
|
997
|
+
*/
|
|
998
|
+
resetRefreshState() {
|
|
999
|
+
this.refreshFailureCount = 0;
|
|
1000
|
+
this.refreshCooldownUntil = 0;
|
|
1001
|
+
}
|
|
917
1002
|
startProactiveRefreshCron() {
|
|
918
1003
|
if (this.proactiveRefreshTimer) return;
|
|
919
1004
|
const PROACTIVE_TARGET_PCT = 0.8;
|
|
@@ -935,7 +1020,10 @@ var Auth = class {
|
|
|
935
1020
|
}, TICK_MS);
|
|
936
1021
|
try {
|
|
937
1022
|
const subscription = import_react_native.AppState?.addEventListener?.("change", (state) => {
|
|
938
|
-
if (state === "active")
|
|
1023
|
+
if (state === "active") {
|
|
1024
|
+
this.resetRefreshState();
|
|
1025
|
+
void tick();
|
|
1026
|
+
}
|
|
939
1027
|
});
|
|
940
1028
|
if (subscription) {
|
|
941
1029
|
this.proactiveRefreshAppStateUnsub = () => subscription.remove();
|
|
@@ -1370,11 +1458,13 @@ async function getPlayInstallReferrer() {
|
|
|
1370
1458
|
}
|
|
1371
1459
|
var PREWARM_TTL_MS = 5 * 6e4;
|
|
1372
1460
|
var PREWARM_MAX = 50;
|
|
1461
|
+
var PREWARM_MAX_INFLIGHT = 5;
|
|
1373
1462
|
var Links = class {
|
|
1374
1463
|
constructor(deps) {
|
|
1375
1464
|
this.deps = deps;
|
|
1376
1465
|
this.listeners = [];
|
|
1377
1466
|
this.prewarmCache = /* @__PURE__ */ new Map();
|
|
1467
|
+
this.prewarmInflight = 0;
|
|
1378
1468
|
this.linkingSub = null;
|
|
1379
1469
|
}
|
|
1380
1470
|
/** Register a callback for warm + deferred deep-link opens. Returns
|
|
@@ -1422,14 +1512,25 @@ var Links = class {
|
|
|
1422
1512
|
*
|
|
1423
1513
|
* Fire-and-forget. Errors are swallowed; the matching `create()` call
|
|
1424
1514
|
* will surface them on demand instead.
|
|
1515
|
+
*
|
|
1516
|
+
* Wave 28 — silently drops the call when more than
|
|
1517
|
+
* `PREWARM_MAX_INFLIGHT` mints are already in flight. Prewarm is
|
|
1518
|
+
* fire-and-forget by contract; an overflow `prewarm()` is fine to
|
|
1519
|
+
* skip because the next matching `create()` will simply do the mint
|
|
1520
|
+
* inline. Caps unbounded fan-out from runaway loops (eg `prewarm()`
|
|
1521
|
+
* in a FlatList renderItem).
|
|
1425
1522
|
*/
|
|
1426
1523
|
prewarm(input, opts) {
|
|
1427
1524
|
this.evictExpired();
|
|
1428
1525
|
const key = this.cacheKey(input, opts?.key);
|
|
1429
1526
|
if (this.prewarmCache.has(key)) return;
|
|
1527
|
+
if (this.prewarmInflight >= PREWARM_MAX_INFLIGHT) return;
|
|
1528
|
+
this.prewarmInflight++;
|
|
1430
1529
|
const promise = this.doCreate(input).catch((err) => {
|
|
1431
1530
|
this.prewarmCache.delete(key);
|
|
1432
1531
|
throw err;
|
|
1532
|
+
}).finally(() => {
|
|
1533
|
+
this.prewarmInflight--;
|
|
1433
1534
|
});
|
|
1434
1535
|
this.prewarmCache.set(key, { promise, ts: Date.now() });
|
|
1435
1536
|
}
|
package/dist/index.d.cts
CHANGED
|
@@ -119,6 +119,19 @@ declare class Auth {
|
|
|
119
119
|
private accessExpiresAt;
|
|
120
120
|
private inflight;
|
|
121
121
|
private refreshInflight;
|
|
122
|
+
/**
|
|
123
|
+
* Wave 62 — refresh failure back-pressure.
|
|
124
|
+
*
|
|
125
|
+
* Consecutive network-timeout failures don't wipe identity (refresh
|
|
126
|
+
* may still be valid; iOS NSURLSession was just stalled) but do
|
|
127
|
+
* back off so the next caller doesn't immediately fire another
|
|
128
|
+
* fetch that will also time out. After `REFRESH_FAILURE_COOLDOWN_MS`
|
|
129
|
+
* the counter resets + retries are allowed. AppState foreground
|
|
130
|
+
* also clears it so a user returning to the app gets an immediate
|
|
131
|
+
* fresh attempt.
|
|
132
|
+
*/
|
|
133
|
+
private refreshFailureCount;
|
|
134
|
+
private refreshCooldownUntil;
|
|
122
135
|
private takeoverListeners;
|
|
123
136
|
private lastTakeover;
|
|
124
137
|
/**
|
|
@@ -182,9 +195,26 @@ declare class Auth {
|
|
|
182
195
|
/** Internal — fan out to subscribers + cache the latest. */
|
|
183
196
|
private fireDeviceTakeover;
|
|
184
197
|
/**
|
|
185
|
-
* Returns
|
|
186
|
-
*
|
|
187
|
-
*
|
|
198
|
+
* Returns an access token or null. Cold-launch hardening (Wave 62):
|
|
199
|
+
*
|
|
200
|
+
* 1. Non-expired cached → return it. (Fast path.)
|
|
201
|
+
* 2. Expired AND refresh inflight → return stale cached anyway.
|
|
202
|
+
* Backend grace window (60s — see auth-service refresh-rotation
|
|
203
|
+
* middleware) accepts slightly-stale access tokens, so the
|
|
204
|
+
* caller's downstream RPC will succeed against grace while
|
|
205
|
+
* the in-flight refresh resolves out of band. Distinct from
|
|
206
|
+
* pre-Wave-62 behaviour where 5 concurrent callers all shared
|
|
207
|
+
* the same in-flight promise — when that promise hung (iOS
|
|
208
|
+
* NSURLSession cold-launch contention) every caller hung too.
|
|
209
|
+
* 3. Expired + no inflight + refresh available + not in cooldown →
|
|
210
|
+
* fire refresh, await it.
|
|
211
|
+
* 4. Expired + in cooldown → return stale cached. Caller's RPC
|
|
212
|
+
* either succeeds against grace or fails fast on 401, which is
|
|
213
|
+
* a better UX than blocking the whole bridge waiting on a
|
|
214
|
+
* network that we already know is stalled.
|
|
215
|
+
*
|
|
216
|
+
* Returns null only when there's NO usable token at all (no cached
|
|
217
|
+
* + no refresh available).
|
|
188
218
|
*/
|
|
189
219
|
getAccessToken(): Promise<string | null>;
|
|
190
220
|
/** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
|
|
@@ -359,6 +389,17 @@ declare class Auth {
|
|
|
359
389
|
* surface NOT_SIGNED_IN to the host app instead of looping.
|
|
360
390
|
*/
|
|
361
391
|
private refreshAccessToken;
|
|
392
|
+
/**
|
|
393
|
+
* Wave 62 — AppState resume hook. Clears stuck backoff so a user
|
|
394
|
+
* who backgrounded the app during a network blip + returns now
|
|
395
|
+
* gets an immediate fresh refresh attempt instead of waiting on
|
|
396
|
+
* the cooldown timer.
|
|
397
|
+
*
|
|
398
|
+
* Called from the AppState 'active' listener registered in
|
|
399
|
+
* startProactiveRefreshCron. Idempotent + cheap — three field
|
|
400
|
+
* writes; no I/O.
|
|
401
|
+
*/
|
|
402
|
+
private resetRefreshState;
|
|
362
403
|
/**
|
|
363
404
|
* Proactive refresh (s58.72). Fires when access-token age crosses
|
|
364
405
|
* ~80% of TTL. Pairs with the AppState foreground listener so a
|
|
@@ -574,6 +615,7 @@ declare class Links {
|
|
|
574
615
|
private deps;
|
|
575
616
|
private listeners;
|
|
576
617
|
private prewarmCache;
|
|
618
|
+
private prewarmInflight;
|
|
577
619
|
private linkingSub;
|
|
578
620
|
constructor(deps: LinksDeps);
|
|
579
621
|
/** Register a callback for warm + deferred deep-link opens. Returns
|
|
@@ -589,6 +631,13 @@ declare class Links {
|
|
|
589
631
|
*
|
|
590
632
|
* Fire-and-forget. Errors are swallowed; the matching `create()` call
|
|
591
633
|
* will surface them on demand instead.
|
|
634
|
+
*
|
|
635
|
+
* Wave 28 — silently drops the call when more than
|
|
636
|
+
* `PREWARM_MAX_INFLIGHT` mints are already in flight. Prewarm is
|
|
637
|
+
* fire-and-forget by contract; an overflow `prewarm()` is fine to
|
|
638
|
+
* skip because the next matching `create()` will simply do the mint
|
|
639
|
+
* inline. Caps unbounded fan-out from runaway loops (eg `prewarm()`
|
|
640
|
+
* in a FlatList renderItem).
|
|
592
641
|
*/
|
|
593
642
|
prewarm<T extends LinkData = LinkData>(input: LinkCreateInput<T>, opts?: {
|
|
594
643
|
key?: string;
|
package/dist/index.d.ts
CHANGED
|
@@ -119,6 +119,19 @@ declare class Auth {
|
|
|
119
119
|
private accessExpiresAt;
|
|
120
120
|
private inflight;
|
|
121
121
|
private refreshInflight;
|
|
122
|
+
/**
|
|
123
|
+
* Wave 62 — refresh failure back-pressure.
|
|
124
|
+
*
|
|
125
|
+
* Consecutive network-timeout failures don't wipe identity (refresh
|
|
126
|
+
* may still be valid; iOS NSURLSession was just stalled) but do
|
|
127
|
+
* back off so the next caller doesn't immediately fire another
|
|
128
|
+
* fetch that will also time out. After `REFRESH_FAILURE_COOLDOWN_MS`
|
|
129
|
+
* the counter resets + retries are allowed. AppState foreground
|
|
130
|
+
* also clears it so a user returning to the app gets an immediate
|
|
131
|
+
* fresh attempt.
|
|
132
|
+
*/
|
|
133
|
+
private refreshFailureCount;
|
|
134
|
+
private refreshCooldownUntil;
|
|
122
135
|
private takeoverListeners;
|
|
123
136
|
private lastTakeover;
|
|
124
137
|
/**
|
|
@@ -182,9 +195,26 @@ declare class Auth {
|
|
|
182
195
|
/** Internal — fan out to subscribers + cache the latest. */
|
|
183
196
|
private fireDeviceTakeover;
|
|
184
197
|
/**
|
|
185
|
-
* Returns
|
|
186
|
-
*
|
|
187
|
-
*
|
|
198
|
+
* Returns an access token or null. Cold-launch hardening (Wave 62):
|
|
199
|
+
*
|
|
200
|
+
* 1. Non-expired cached → return it. (Fast path.)
|
|
201
|
+
* 2. Expired AND refresh inflight → return stale cached anyway.
|
|
202
|
+
* Backend grace window (60s — see auth-service refresh-rotation
|
|
203
|
+
* middleware) accepts slightly-stale access tokens, so the
|
|
204
|
+
* caller's downstream RPC will succeed against grace while
|
|
205
|
+
* the in-flight refresh resolves out of band. Distinct from
|
|
206
|
+
* pre-Wave-62 behaviour where 5 concurrent callers all shared
|
|
207
|
+
* the same in-flight promise — when that promise hung (iOS
|
|
208
|
+
* NSURLSession cold-launch contention) every caller hung too.
|
|
209
|
+
* 3. Expired + no inflight + refresh available + not in cooldown →
|
|
210
|
+
* fire refresh, await it.
|
|
211
|
+
* 4. Expired + in cooldown → return stale cached. Caller's RPC
|
|
212
|
+
* either succeeds against grace or fails fast on 401, which is
|
|
213
|
+
* a better UX than blocking the whole bridge waiting on a
|
|
214
|
+
* network that we already know is stalled.
|
|
215
|
+
*
|
|
216
|
+
* Returns null only when there's NO usable token at all (no cached
|
|
217
|
+
* + no refresh available).
|
|
188
218
|
*/
|
|
189
219
|
getAccessToken(): Promise<string | null>;
|
|
190
220
|
/** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
|
|
@@ -359,6 +389,17 @@ declare class Auth {
|
|
|
359
389
|
* surface NOT_SIGNED_IN to the host app instead of looping.
|
|
360
390
|
*/
|
|
361
391
|
private refreshAccessToken;
|
|
392
|
+
/**
|
|
393
|
+
* Wave 62 — AppState resume hook. Clears stuck backoff so a user
|
|
394
|
+
* who backgrounded the app during a network blip + returns now
|
|
395
|
+
* gets an immediate fresh refresh attempt instead of waiting on
|
|
396
|
+
* the cooldown timer.
|
|
397
|
+
*
|
|
398
|
+
* Called from the AppState 'active' listener registered in
|
|
399
|
+
* startProactiveRefreshCron. Idempotent + cheap — three field
|
|
400
|
+
* writes; no I/O.
|
|
401
|
+
*/
|
|
402
|
+
private resetRefreshState;
|
|
362
403
|
/**
|
|
363
404
|
* Proactive refresh (s58.72). Fires when access-token age crosses
|
|
364
405
|
* ~80% of TTL. Pairs with the AppState foreground listener so a
|
|
@@ -574,6 +615,7 @@ declare class Links {
|
|
|
574
615
|
private deps;
|
|
575
616
|
private listeners;
|
|
576
617
|
private prewarmCache;
|
|
618
|
+
private prewarmInflight;
|
|
577
619
|
private linkingSub;
|
|
578
620
|
constructor(deps: LinksDeps);
|
|
579
621
|
/** Register a callback for warm + deferred deep-link opens. Returns
|
|
@@ -589,6 +631,13 @@ declare class Links {
|
|
|
589
631
|
*
|
|
590
632
|
* Fire-and-forget. Errors are swallowed; the matching `create()` call
|
|
591
633
|
* will surface them on demand instead.
|
|
634
|
+
*
|
|
635
|
+
* Wave 28 — silently drops the call when more than
|
|
636
|
+
* `PREWARM_MAX_INFLIGHT` mints are already in flight. Prewarm is
|
|
637
|
+
* fire-and-forget by contract; an overflow `prewarm()` is fine to
|
|
638
|
+
* skip because the next matching `create()` will simply do the mint
|
|
639
|
+
* inline. Caps unbounded fan-out from runaway loops (eg `prewarm()`
|
|
640
|
+
* in a FlatList renderItem).
|
|
592
641
|
*/
|
|
593
642
|
prewarm<T extends LinkData = LinkData>(input: LinkCreateInput<T>, opts?: {
|
|
594
643
|
key?: string;
|
package/dist/index.js
CHANGED
|
@@ -93,16 +93,36 @@ var Storage = class {
|
|
|
93
93
|
};
|
|
94
94
|
|
|
95
95
|
// src/http.ts
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
var NetworkTimeoutError = class extends Error {
|
|
97
|
+
constructor(method, path, timeoutMs) {
|
|
98
|
+
super(`Request timed out: ${method} ${path} (${timeoutMs}ms)`);
|
|
99
|
+
this.name = "NetworkTimeoutError";
|
|
100
|
+
this.method = method;
|
|
101
|
+
this.path = path;
|
|
102
|
+
this.timeoutMs = timeoutMs;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
var DEFAULT_AUTH_TIMEOUT_MS = 1e4;
|
|
106
|
+
var DEFAULT_INGEST_TIMEOUT_MS = 3e4;
|
|
107
|
+
function defaultTimeoutFor(path) {
|
|
108
|
+
if (path.startsWith("/api/v1/auth-service/")) return DEFAULT_AUTH_TIMEOUT_MS;
|
|
109
|
+
return DEFAULT_INGEST_TIMEOUT_MS;
|
|
110
|
+
}
|
|
111
|
+
async function post(opts, path, body, extraHeaders, reqOpts) {
|
|
112
|
+
return request(opts, "POST", path, body, extraHeaders, reqOpts);
|
|
98
113
|
}
|
|
99
|
-
async function getJson(opts, path, extraHeaders) {
|
|
100
|
-
return request(opts, "GET", path, void 0, extraHeaders);
|
|
114
|
+
async function getJson(opts, path, extraHeaders, reqOpts) {
|
|
115
|
+
return request(opts, "GET", path, void 0, extraHeaders, reqOpts);
|
|
101
116
|
}
|
|
102
|
-
async function del(opts, path, extraHeaders) {
|
|
103
|
-
return request(opts, "DELETE", path, void 0, extraHeaders);
|
|
117
|
+
async function del(opts, path, extraHeaders, reqOpts) {
|
|
118
|
+
return request(opts, "DELETE", path, void 0, extraHeaders, reqOpts);
|
|
104
119
|
}
|
|
105
|
-
async function request(opts, method, path, body, extraHeaders) {
|
|
120
|
+
async function request(opts, method, path, body, extraHeaders, reqOpts) {
|
|
121
|
+
const timeoutMs = reqOpts?.timeoutMs ?? defaultTimeoutFor(path);
|
|
122
|
+
const controller = new AbortController();
|
|
123
|
+
const timer = setTimeout(() => {
|
|
124
|
+
controller.abort();
|
|
125
|
+
}, timeoutMs);
|
|
106
126
|
let res;
|
|
107
127
|
try {
|
|
108
128
|
res = await fetch(`${opts.apiUrl}${path}`, {
|
|
@@ -117,15 +137,20 @@ async function request(opts, method, path, body, extraHeaders) {
|
|
|
117
137
|
"x-api-key": opts.publicKey,
|
|
118
138
|
...extraHeaders ?? {}
|
|
119
139
|
},
|
|
120
|
-
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
140
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0,
|
|
141
|
+
signal: controller.signal
|
|
121
142
|
});
|
|
122
143
|
} catch (err) {
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
const aborted = err?.name === "AbortError";
|
|
146
|
+
if (aborted) throw new NetworkTimeoutError(method, path, timeoutMs);
|
|
123
147
|
console.warn(
|
|
124
148
|
`[sendoracloud] ${method} ${path} failed at the network layer:`,
|
|
125
149
|
err instanceof Error ? err.message : String(err)
|
|
126
150
|
);
|
|
127
151
|
return null;
|
|
128
152
|
}
|
|
153
|
+
clearTimeout(timer);
|
|
129
154
|
if (!res.ok) {
|
|
130
155
|
const isAuthPath = path.startsWith("/api/v1/auth-service/");
|
|
131
156
|
if (isAuthPath && !opts.debug) {
|
|
@@ -194,6 +219,19 @@ var Auth = class {
|
|
|
194
219
|
this.accessExpiresAt = 0;
|
|
195
220
|
this.inflight = Promise.resolve();
|
|
196
221
|
this.refreshInflight = null;
|
|
222
|
+
/**
|
|
223
|
+
* Wave 62 — refresh failure back-pressure.
|
|
224
|
+
*
|
|
225
|
+
* Consecutive network-timeout failures don't wipe identity (refresh
|
|
226
|
+
* may still be valid; iOS NSURLSession was just stalled) but do
|
|
227
|
+
* back off so the next caller doesn't immediately fire another
|
|
228
|
+
* fetch that will also time out. After `REFRESH_FAILURE_COOLDOWN_MS`
|
|
229
|
+
* the counter resets + retries are allowed. AppState foreground
|
|
230
|
+
* also clears it so a user returning to the app gets an immediate
|
|
231
|
+
* fresh attempt.
|
|
232
|
+
*/
|
|
233
|
+
this.refreshFailureCount = 0;
|
|
234
|
+
this.refreshCooldownUntil = 0;
|
|
197
235
|
this.takeoverListeners = /* @__PURE__ */ new Set();
|
|
198
236
|
this.lastTakeover = null;
|
|
199
237
|
/**
|
|
@@ -300,15 +338,38 @@ var Auth = class {
|
|
|
300
338
|
}
|
|
301
339
|
}
|
|
302
340
|
/**
|
|
303
|
-
* Returns
|
|
304
|
-
*
|
|
305
|
-
*
|
|
341
|
+
* Returns an access token or null. Cold-launch hardening (Wave 62):
|
|
342
|
+
*
|
|
343
|
+
* 1. Non-expired cached → return it. (Fast path.)
|
|
344
|
+
* 2. Expired AND refresh inflight → return stale cached anyway.
|
|
345
|
+
* Backend grace window (60s — see auth-service refresh-rotation
|
|
346
|
+
* middleware) accepts slightly-stale access tokens, so the
|
|
347
|
+
* caller's downstream RPC will succeed against grace while
|
|
348
|
+
* the in-flight refresh resolves out of band. Distinct from
|
|
349
|
+
* pre-Wave-62 behaviour where 5 concurrent callers all shared
|
|
350
|
+
* the same in-flight promise — when that promise hung (iOS
|
|
351
|
+
* NSURLSession cold-launch contention) every caller hung too.
|
|
352
|
+
* 3. Expired + no inflight + refresh available + not in cooldown →
|
|
353
|
+
* fire refresh, await it.
|
|
354
|
+
* 4. Expired + in cooldown → return stale cached. Caller's RPC
|
|
355
|
+
* either succeeds against grace or fails fast on 401, which is
|
|
356
|
+
* a better UX than blocking the whole bridge waiting on a
|
|
357
|
+
* network that we already know is stalled.
|
|
358
|
+
*
|
|
359
|
+
* Returns null only when there's NO usable token at all (no cached
|
|
360
|
+
* + no refresh available).
|
|
306
361
|
*/
|
|
307
362
|
async getAccessToken() {
|
|
308
363
|
if (!this.accessToken) return null;
|
|
309
364
|
if (this.accessExpiresAt && Date.now() < this.accessExpiresAt - REFRESH_SAFETY_MS) {
|
|
310
365
|
return this.accessToken;
|
|
311
366
|
}
|
|
367
|
+
if (this.refreshInflight) {
|
|
368
|
+
return this.accessToken;
|
|
369
|
+
}
|
|
370
|
+
if (Date.now() < this.refreshCooldownUntil) {
|
|
371
|
+
return this.accessToken;
|
|
372
|
+
}
|
|
312
373
|
return this.refreshAccessToken();
|
|
313
374
|
}
|
|
314
375
|
/** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
|
|
@@ -847,6 +908,8 @@ var Auth = class {
|
|
|
847
908
|
if (Number.isFinite(freshExpires) && freshExpires > Date.now()) {
|
|
848
909
|
this.accessToken = freshAccess;
|
|
849
910
|
this.accessExpiresAt = freshExpires;
|
|
911
|
+
this.refreshFailureCount = 0;
|
|
912
|
+
this.refreshCooldownUntil = 0;
|
|
850
913
|
return freshAccess;
|
|
851
914
|
}
|
|
852
915
|
}
|
|
@@ -859,11 +922,19 @@ var Auth = class {
|
|
|
859
922
|
this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
|
|
860
923
|
this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
|
|
861
924
|
this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
|
|
925
|
+
this.refreshFailureCount = 0;
|
|
926
|
+
this.refreshCooldownUntil = 0;
|
|
862
927
|
return data.tokens.accessToken;
|
|
863
928
|
} catch (err) {
|
|
864
929
|
if (err instanceof AuthError && isDeadRefreshError(err.code)) {
|
|
865
930
|
await this.wipeLocalIdentity().catch(() => void 0);
|
|
931
|
+
this.refreshFailureCount = 0;
|
|
932
|
+
this.refreshCooldownUntil = 0;
|
|
933
|
+
return null;
|
|
866
934
|
}
|
|
935
|
+
this.refreshFailureCount += 1;
|
|
936
|
+
const backoffMs = Math.min(3e4, 1e3 * Math.pow(2, this.refreshFailureCount - 1));
|
|
937
|
+
this.refreshCooldownUntil = Date.now() + backoffMs;
|
|
867
938
|
return null;
|
|
868
939
|
} finally {
|
|
869
940
|
this.refreshInflight = null;
|
|
@@ -871,6 +942,20 @@ var Auth = class {
|
|
|
871
942
|
})();
|
|
872
943
|
return this.refreshInflight;
|
|
873
944
|
}
|
|
945
|
+
/**
|
|
946
|
+
* Wave 62 — AppState resume hook. Clears stuck backoff so a user
|
|
947
|
+
* who backgrounded the app during a network blip + returns now
|
|
948
|
+
* gets an immediate fresh refresh attempt instead of waiting on
|
|
949
|
+
* the cooldown timer.
|
|
950
|
+
*
|
|
951
|
+
* Called from the AppState 'active' listener registered in
|
|
952
|
+
* startProactiveRefreshCron. Idempotent + cheap — three field
|
|
953
|
+
* writes; no I/O.
|
|
954
|
+
*/
|
|
955
|
+
resetRefreshState() {
|
|
956
|
+
this.refreshFailureCount = 0;
|
|
957
|
+
this.refreshCooldownUntil = 0;
|
|
958
|
+
}
|
|
874
959
|
startProactiveRefreshCron() {
|
|
875
960
|
if (this.proactiveRefreshTimer) return;
|
|
876
961
|
const PROACTIVE_TARGET_PCT = 0.8;
|
|
@@ -892,7 +977,10 @@ var Auth = class {
|
|
|
892
977
|
}, TICK_MS);
|
|
893
978
|
try {
|
|
894
979
|
const subscription = RnAppState?.addEventListener?.("change", (state) => {
|
|
895
|
-
if (state === "active")
|
|
980
|
+
if (state === "active") {
|
|
981
|
+
this.resetRefreshState();
|
|
982
|
+
void tick();
|
|
983
|
+
}
|
|
896
984
|
});
|
|
897
985
|
if (subscription) {
|
|
898
986
|
this.proactiveRefreshAppStateUnsub = () => subscription.remove();
|
|
@@ -1332,11 +1420,13 @@ async function getPlayInstallReferrer() {
|
|
|
1332
1420
|
}
|
|
1333
1421
|
var PREWARM_TTL_MS = 5 * 6e4;
|
|
1334
1422
|
var PREWARM_MAX = 50;
|
|
1423
|
+
var PREWARM_MAX_INFLIGHT = 5;
|
|
1335
1424
|
var Links = class {
|
|
1336
1425
|
constructor(deps) {
|
|
1337
1426
|
this.deps = deps;
|
|
1338
1427
|
this.listeners = [];
|
|
1339
1428
|
this.prewarmCache = /* @__PURE__ */ new Map();
|
|
1429
|
+
this.prewarmInflight = 0;
|
|
1340
1430
|
this.linkingSub = null;
|
|
1341
1431
|
}
|
|
1342
1432
|
/** Register a callback for warm + deferred deep-link opens. Returns
|
|
@@ -1384,14 +1474,25 @@ var Links = class {
|
|
|
1384
1474
|
*
|
|
1385
1475
|
* Fire-and-forget. Errors are swallowed; the matching `create()` call
|
|
1386
1476
|
* will surface them on demand instead.
|
|
1477
|
+
*
|
|
1478
|
+
* Wave 28 — silently drops the call when more than
|
|
1479
|
+
* `PREWARM_MAX_INFLIGHT` mints are already in flight. Prewarm is
|
|
1480
|
+
* fire-and-forget by contract; an overflow `prewarm()` is fine to
|
|
1481
|
+
* skip because the next matching `create()` will simply do the mint
|
|
1482
|
+
* inline. Caps unbounded fan-out from runaway loops (eg `prewarm()`
|
|
1483
|
+
* in a FlatList renderItem).
|
|
1387
1484
|
*/
|
|
1388
1485
|
prewarm(input, opts) {
|
|
1389
1486
|
this.evictExpired();
|
|
1390
1487
|
const key = this.cacheKey(input, opts?.key);
|
|
1391
1488
|
if (this.prewarmCache.has(key)) return;
|
|
1489
|
+
if (this.prewarmInflight >= PREWARM_MAX_INFLIGHT) return;
|
|
1490
|
+
this.prewarmInflight++;
|
|
1392
1491
|
const promise = this.doCreate(input).catch((err) => {
|
|
1393
1492
|
this.prewarmCache.delete(key);
|
|
1394
1493
|
throw err;
|
|
1494
|
+
}).finally(() => {
|
|
1495
|
+
this.prewarmInflight--;
|
|
1395
1496
|
});
|
|
1396
1497
|
this.prewarmCache.set(key, { promise, ts: Date.now() });
|
|
1397
1498
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendoracloud/sdk-react-native",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Sendora Cloud React Native + Expo SDK — analytics, identity, push-token registration, auth, deep links (Branch / Firebase Dynamic Links parity). Auth + analytics + deep links work in Expo Go; push token registration requires a Dev Client (EAS Build or `npx expo prebuild`).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|