@sendoracloud/sdk-react-native 1.0.4 → 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 +163 -13
- package/dist/index.d.cts +91 -3
- package/dist/index.d.ts +91 -3
- package/dist/index.js +163 -13
- 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,21 @@ 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;
|
|
278
|
+
this.takeoverListeners = /* @__PURE__ */ new Set();
|
|
279
|
+
this.lastTakeover = null;
|
|
240
280
|
/**
|
|
241
281
|
* Proactive refresh (s58.72). Fires when access-token age crosses
|
|
242
282
|
* ~80% of TTL. Pairs with the AppState foreground listener so a
|
|
@@ -300,15 +340,79 @@ var Auth = class {
|
|
|
300
340
|
return this.user;
|
|
301
341
|
}
|
|
302
342
|
/**
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
343
|
+
* Subscribe to device-takeover events (s58.111+). Backend retires
|
|
344
|
+
* the anonymous user_id whenever a previously-anon device signs in
|
|
345
|
+
* to an identified account. Use this to clean up any local mirror
|
|
346
|
+
* keyed by the anonymous id — your own users table, an analytics
|
|
347
|
+
* cache, an audience filter — without needing a webhook receiver.
|
|
348
|
+
*
|
|
349
|
+
* Listener is invoked from `signIn`, `loginSocial`, `verifyMagicLink`,
|
|
350
|
+
* `verifyEmailOtp`, `passkey authentication`, and SSO completion
|
|
351
|
+
* (via `consumeSsoFragment`). Returns an unsubscribe function.
|
|
352
|
+
*
|
|
353
|
+
* Listeners are best-effort: errors thrown inside them are swallowed
|
|
354
|
+
* + logged in dev mode. Auth state never depends on listener success.
|
|
355
|
+
*/
|
|
356
|
+
onDeviceTakeover(listener) {
|
|
357
|
+
this.takeoverListeners.add(listener);
|
|
358
|
+
return () => {
|
|
359
|
+
this.takeoverListeners.delete(listener);
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Inspect the most recent device-takeover event observed by the
|
|
364
|
+
* SDK. Cleared on signOut. Returns null when no takeover has
|
|
365
|
+
* happened during this session — useful for late subscribers or
|
|
366
|
+
* post-sign-in side-effects (e.g. app-launch hydrate).
|
|
367
|
+
*/
|
|
368
|
+
getLastDeviceTakeover() {
|
|
369
|
+
return this.lastTakeover;
|
|
370
|
+
}
|
|
371
|
+
/** Internal — fan out to subscribers + cache the latest. */
|
|
372
|
+
fireDeviceTakeover(retiredAnonUserId, identifiedUserId) {
|
|
373
|
+
const evt = { retiredAnonUserId, identifiedUserId, at: /* @__PURE__ */ new Date() };
|
|
374
|
+
this.lastTakeover = evt;
|
|
375
|
+
for (const fn of this.takeoverListeners) {
|
|
376
|
+
try {
|
|
377
|
+
fn(evt);
|
|
378
|
+
} catch (err) {
|
|
379
|
+
if (this.hooks.debug) console.warn("[sendoracloud] onDeviceTakeover listener threw", err);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
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).
|
|
306
404
|
*/
|
|
307
405
|
async getAccessToken() {
|
|
308
406
|
if (!this.accessToken) return null;
|
|
309
407
|
if (this.accessExpiresAt && Date.now() < this.accessExpiresAt - REFRESH_SAFETY_MS) {
|
|
310
408
|
return this.accessToken;
|
|
311
409
|
}
|
|
410
|
+
if (this.refreshInflight) {
|
|
411
|
+
return this.accessToken;
|
|
412
|
+
}
|
|
413
|
+
if (Date.now() < this.refreshCooldownUntil) {
|
|
414
|
+
return this.accessToken;
|
|
415
|
+
}
|
|
312
416
|
return this.refreshAccessToken();
|
|
313
417
|
}
|
|
314
418
|
/** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
|
|
@@ -741,6 +845,7 @@ var Auth = class {
|
|
|
741
845
|
if (err) throw new AuthError("SSO_FAILED", decodeURIComponent(err));
|
|
742
846
|
const refresh = params.get("sendora_oidc_token") ?? params.get("sendora_saml_token");
|
|
743
847
|
if (!refresh) throw new AuthError("SSO_NO_TOKEN", "Callback URL has no SSO token");
|
|
848
|
+
const retiredAnonUserId = params.get("sendora_retired_anon");
|
|
744
849
|
const res = await post(
|
|
745
850
|
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
746
851
|
"/api/v1/auth-service/token/refresh",
|
|
@@ -769,7 +874,8 @@ var Auth = class {
|
|
|
769
874
|
refreshToken: newRefresh,
|
|
770
875
|
expiresIn,
|
|
771
876
|
tokenType: "Bearer"
|
|
772
|
-
}
|
|
877
|
+
},
|
|
878
|
+
...retiredAnonUserId ? { retiredAnonUserId } : {}
|
|
773
879
|
});
|
|
774
880
|
return user;
|
|
775
881
|
});
|
|
@@ -845,6 +951,8 @@ var Auth = class {
|
|
|
845
951
|
if (Number.isFinite(freshExpires) && freshExpires > Date.now()) {
|
|
846
952
|
this.accessToken = freshAccess;
|
|
847
953
|
this.accessExpiresAt = freshExpires;
|
|
954
|
+
this.refreshFailureCount = 0;
|
|
955
|
+
this.refreshCooldownUntil = 0;
|
|
848
956
|
return freshAccess;
|
|
849
957
|
}
|
|
850
958
|
}
|
|
@@ -857,11 +965,19 @@ var Auth = class {
|
|
|
857
965
|
this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
|
|
858
966
|
this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
|
|
859
967
|
this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
|
|
968
|
+
this.refreshFailureCount = 0;
|
|
969
|
+
this.refreshCooldownUntil = 0;
|
|
860
970
|
return data.tokens.accessToken;
|
|
861
971
|
} catch (err) {
|
|
862
972
|
if (err instanceof AuthError && isDeadRefreshError(err.code)) {
|
|
863
973
|
await this.wipeLocalIdentity().catch(() => void 0);
|
|
974
|
+
this.refreshFailureCount = 0;
|
|
975
|
+
this.refreshCooldownUntil = 0;
|
|
976
|
+
return null;
|
|
864
977
|
}
|
|
978
|
+
this.refreshFailureCount += 1;
|
|
979
|
+
const backoffMs = Math.min(3e4, 1e3 * Math.pow(2, this.refreshFailureCount - 1));
|
|
980
|
+
this.refreshCooldownUntil = Date.now() + backoffMs;
|
|
865
981
|
return null;
|
|
866
982
|
} finally {
|
|
867
983
|
this.refreshInflight = null;
|
|
@@ -869,6 +985,20 @@ var Auth = class {
|
|
|
869
985
|
})();
|
|
870
986
|
return this.refreshInflight;
|
|
871
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
|
+
}
|
|
872
1002
|
startProactiveRefreshCron() {
|
|
873
1003
|
if (this.proactiveRefreshTimer) return;
|
|
874
1004
|
const PROACTIVE_TARGET_PCT = 0.8;
|
|
@@ -890,7 +1020,10 @@ var Auth = class {
|
|
|
890
1020
|
}, TICK_MS);
|
|
891
1021
|
try {
|
|
892
1022
|
const subscription = import_react_native.AppState?.addEventListener?.("change", (state) => {
|
|
893
|
-
if (state === "active")
|
|
1023
|
+
if (state === "active") {
|
|
1024
|
+
this.resetRefreshState();
|
|
1025
|
+
void tick();
|
|
1026
|
+
}
|
|
894
1027
|
});
|
|
895
1028
|
if (subscription) {
|
|
896
1029
|
this.proactiveRefreshAppStateUnsub = () => subscription.remove();
|
|
@@ -1010,11 +1143,15 @@ var Auth = class {
|
|
|
1010
1143
|
this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
|
|
1011
1144
|
this.hooks.onIdentityChange(data.user.id);
|
|
1012
1145
|
this.startProactiveRefreshCron();
|
|
1146
|
+
if (data.retiredAnonUserId) {
|
|
1147
|
+
this.fireDeviceTakeover(data.retiredAnonUserId, data.user.id);
|
|
1148
|
+
}
|
|
1013
1149
|
}
|
|
1014
1150
|
async wipeLocalIdentity() {
|
|
1015
1151
|
this.user = null;
|
|
1016
1152
|
this.accessToken = null;
|
|
1017
1153
|
this.accessExpiresAt = 0;
|
|
1154
|
+
this.lastTakeover = null;
|
|
1018
1155
|
this.hooks.storage.remove(USER_KEY);
|
|
1019
1156
|
this.hooks.storage.remove(TOKEN_KEY);
|
|
1020
1157
|
this.hooks.storage.remove(TOKEN_EXPIRES_KEY);
|
|
@@ -1321,11 +1458,13 @@ async function getPlayInstallReferrer() {
|
|
|
1321
1458
|
}
|
|
1322
1459
|
var PREWARM_TTL_MS = 5 * 6e4;
|
|
1323
1460
|
var PREWARM_MAX = 50;
|
|
1461
|
+
var PREWARM_MAX_INFLIGHT = 5;
|
|
1324
1462
|
var Links = class {
|
|
1325
1463
|
constructor(deps) {
|
|
1326
1464
|
this.deps = deps;
|
|
1327
1465
|
this.listeners = [];
|
|
1328
1466
|
this.prewarmCache = /* @__PURE__ */ new Map();
|
|
1467
|
+
this.prewarmInflight = 0;
|
|
1329
1468
|
this.linkingSub = null;
|
|
1330
1469
|
}
|
|
1331
1470
|
/** Register a callback for warm + deferred deep-link opens. Returns
|
|
@@ -1373,14 +1512,25 @@ var Links = class {
|
|
|
1373
1512
|
*
|
|
1374
1513
|
* Fire-and-forget. Errors are swallowed; the matching `create()` call
|
|
1375
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).
|
|
1376
1522
|
*/
|
|
1377
1523
|
prewarm(input, opts) {
|
|
1378
1524
|
this.evictExpired();
|
|
1379
1525
|
const key = this.cacheKey(input, opts?.key);
|
|
1380
1526
|
if (this.prewarmCache.has(key)) return;
|
|
1527
|
+
if (this.prewarmInflight >= PREWARM_MAX_INFLIGHT) return;
|
|
1528
|
+
this.prewarmInflight++;
|
|
1381
1529
|
const promise = this.doCreate(input).catch((err) => {
|
|
1382
1530
|
this.prewarmCache.delete(key);
|
|
1383
1531
|
throw err;
|
|
1532
|
+
}).finally(() => {
|
|
1533
|
+
this.prewarmInflight--;
|
|
1384
1534
|
});
|
|
1385
1535
|
this.prewarmCache.set(key, { promise, ts: Date.now() });
|
|
1386
1536
|
}
|
package/dist/index.d.cts
CHANGED
|
@@ -99,6 +99,19 @@ interface AuthHooks {
|
|
|
99
99
|
publicKey: string;
|
|
100
100
|
debug: boolean;
|
|
101
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Detail handed to onDeviceTakeover subscribers. Fires once per
|
|
104
|
+
* signIn / loginSocial / verifyMagicLink / verifyEmailOtp /
|
|
105
|
+
* passkey-auth / SSO completion that retires an anonymous row.
|
|
106
|
+
*/
|
|
107
|
+
interface DeviceTakeoverEvent {
|
|
108
|
+
/** The anonymous user_id Sendora just hard-deleted. */
|
|
109
|
+
retiredAnonUserId: string;
|
|
110
|
+
/** The identified user_id that took the device over. */
|
|
111
|
+
identifiedUserId: string;
|
|
112
|
+
/** Local clock at the time the SDK observed the event. */
|
|
113
|
+
at: Date;
|
|
114
|
+
}
|
|
102
115
|
declare class Auth {
|
|
103
116
|
private hooks;
|
|
104
117
|
private user;
|
|
@@ -106,6 +119,21 @@ declare class Auth {
|
|
|
106
119
|
private accessExpiresAt;
|
|
107
120
|
private inflight;
|
|
108
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;
|
|
135
|
+
private takeoverListeners;
|
|
136
|
+
private lastTakeover;
|
|
109
137
|
/**
|
|
110
138
|
* Resolves when the parent SDK has finished its async init() —
|
|
111
139
|
* AsyncStorage hydrated + auth.hydrate() restored the cached
|
|
@@ -143,9 +171,50 @@ declare class Auth {
|
|
|
143
171
|
hydrate(): void;
|
|
144
172
|
getCurrentUser(): AuthUser | null;
|
|
145
173
|
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
174
|
+
* Subscribe to device-takeover events (s58.111+). Backend retires
|
|
175
|
+
* the anonymous user_id whenever a previously-anon device signs in
|
|
176
|
+
* to an identified account. Use this to clean up any local mirror
|
|
177
|
+
* keyed by the anonymous id — your own users table, an analytics
|
|
178
|
+
* cache, an audience filter — without needing a webhook receiver.
|
|
179
|
+
*
|
|
180
|
+
* Listener is invoked from `signIn`, `loginSocial`, `verifyMagicLink`,
|
|
181
|
+
* `verifyEmailOtp`, `passkey authentication`, and SSO completion
|
|
182
|
+
* (via `consumeSsoFragment`). Returns an unsubscribe function.
|
|
183
|
+
*
|
|
184
|
+
* Listeners are best-effort: errors thrown inside them are swallowed
|
|
185
|
+
* + logged in dev mode. Auth state never depends on listener success.
|
|
186
|
+
*/
|
|
187
|
+
onDeviceTakeover(listener: (evt: DeviceTakeoverEvent) => void): () => void;
|
|
188
|
+
/**
|
|
189
|
+
* Inspect the most recent device-takeover event observed by the
|
|
190
|
+
* SDK. Cleared on signOut. Returns null when no takeover has
|
|
191
|
+
* happened during this session — useful for late subscribers or
|
|
192
|
+
* post-sign-in side-effects (e.g. app-launch hydrate).
|
|
193
|
+
*/
|
|
194
|
+
getLastDeviceTakeover(): DeviceTakeoverEvent | null;
|
|
195
|
+
/** Internal — fan out to subscribers + cache the latest. */
|
|
196
|
+
private fireDeviceTakeover;
|
|
197
|
+
/**
|
|
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).
|
|
149
218
|
*/
|
|
150
219
|
getAccessToken(): Promise<string | null>;
|
|
151
220
|
/** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
|
|
@@ -320,6 +389,17 @@ declare class Auth {
|
|
|
320
389
|
* surface NOT_SIGNED_IN to the host app instead of looping.
|
|
321
390
|
*/
|
|
322
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;
|
|
323
403
|
/**
|
|
324
404
|
* Proactive refresh (s58.72). Fires when access-token age crosses
|
|
325
405
|
* ~80% of TTL. Pairs with the AppState foreground listener so a
|
|
@@ -535,6 +615,7 @@ declare class Links {
|
|
|
535
615
|
private deps;
|
|
536
616
|
private listeners;
|
|
537
617
|
private prewarmCache;
|
|
618
|
+
private prewarmInflight;
|
|
538
619
|
private linkingSub;
|
|
539
620
|
constructor(deps: LinksDeps);
|
|
540
621
|
/** Register a callback for warm + deferred deep-link opens. Returns
|
|
@@ -550,6 +631,13 @@ declare class Links {
|
|
|
550
631
|
*
|
|
551
632
|
* Fire-and-forget. Errors are swallowed; the matching `create()` call
|
|
552
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).
|
|
553
641
|
*/
|
|
554
642
|
prewarm<T extends LinkData = LinkData>(input: LinkCreateInput<T>, opts?: {
|
|
555
643
|
key?: string;
|
package/dist/index.d.ts
CHANGED
|
@@ -99,6 +99,19 @@ interface AuthHooks {
|
|
|
99
99
|
publicKey: string;
|
|
100
100
|
debug: boolean;
|
|
101
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Detail handed to onDeviceTakeover subscribers. Fires once per
|
|
104
|
+
* signIn / loginSocial / verifyMagicLink / verifyEmailOtp /
|
|
105
|
+
* passkey-auth / SSO completion that retires an anonymous row.
|
|
106
|
+
*/
|
|
107
|
+
interface DeviceTakeoverEvent {
|
|
108
|
+
/** The anonymous user_id Sendora just hard-deleted. */
|
|
109
|
+
retiredAnonUserId: string;
|
|
110
|
+
/** The identified user_id that took the device over. */
|
|
111
|
+
identifiedUserId: string;
|
|
112
|
+
/** Local clock at the time the SDK observed the event. */
|
|
113
|
+
at: Date;
|
|
114
|
+
}
|
|
102
115
|
declare class Auth {
|
|
103
116
|
private hooks;
|
|
104
117
|
private user;
|
|
@@ -106,6 +119,21 @@ declare class Auth {
|
|
|
106
119
|
private accessExpiresAt;
|
|
107
120
|
private inflight;
|
|
108
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;
|
|
135
|
+
private takeoverListeners;
|
|
136
|
+
private lastTakeover;
|
|
109
137
|
/**
|
|
110
138
|
* Resolves when the parent SDK has finished its async init() —
|
|
111
139
|
* AsyncStorage hydrated + auth.hydrate() restored the cached
|
|
@@ -143,9 +171,50 @@ declare class Auth {
|
|
|
143
171
|
hydrate(): void;
|
|
144
172
|
getCurrentUser(): AuthUser | null;
|
|
145
173
|
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
174
|
+
* Subscribe to device-takeover events (s58.111+). Backend retires
|
|
175
|
+
* the anonymous user_id whenever a previously-anon device signs in
|
|
176
|
+
* to an identified account. Use this to clean up any local mirror
|
|
177
|
+
* keyed by the anonymous id — your own users table, an analytics
|
|
178
|
+
* cache, an audience filter — without needing a webhook receiver.
|
|
179
|
+
*
|
|
180
|
+
* Listener is invoked from `signIn`, `loginSocial`, `verifyMagicLink`,
|
|
181
|
+
* `verifyEmailOtp`, `passkey authentication`, and SSO completion
|
|
182
|
+
* (via `consumeSsoFragment`). Returns an unsubscribe function.
|
|
183
|
+
*
|
|
184
|
+
* Listeners are best-effort: errors thrown inside them are swallowed
|
|
185
|
+
* + logged in dev mode. Auth state never depends on listener success.
|
|
186
|
+
*/
|
|
187
|
+
onDeviceTakeover(listener: (evt: DeviceTakeoverEvent) => void): () => void;
|
|
188
|
+
/**
|
|
189
|
+
* Inspect the most recent device-takeover event observed by the
|
|
190
|
+
* SDK. Cleared on signOut. Returns null when no takeover has
|
|
191
|
+
* happened during this session — useful for late subscribers or
|
|
192
|
+
* post-sign-in side-effects (e.g. app-launch hydrate).
|
|
193
|
+
*/
|
|
194
|
+
getLastDeviceTakeover(): DeviceTakeoverEvent | null;
|
|
195
|
+
/** Internal — fan out to subscribers + cache the latest. */
|
|
196
|
+
private fireDeviceTakeover;
|
|
197
|
+
/**
|
|
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).
|
|
149
218
|
*/
|
|
150
219
|
getAccessToken(): Promise<string | null>;
|
|
151
220
|
/** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
|
|
@@ -320,6 +389,17 @@ declare class Auth {
|
|
|
320
389
|
* surface NOT_SIGNED_IN to the host app instead of looping.
|
|
321
390
|
*/
|
|
322
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;
|
|
323
403
|
/**
|
|
324
404
|
* Proactive refresh (s58.72). Fires when access-token age crosses
|
|
325
405
|
* ~80% of TTL. Pairs with the AppState foreground listener so a
|
|
@@ -535,6 +615,7 @@ declare class Links {
|
|
|
535
615
|
private deps;
|
|
536
616
|
private listeners;
|
|
537
617
|
private prewarmCache;
|
|
618
|
+
private prewarmInflight;
|
|
538
619
|
private linkingSub;
|
|
539
620
|
constructor(deps: LinksDeps);
|
|
540
621
|
/** Register a callback for warm + deferred deep-link opens. Returns
|
|
@@ -550,6 +631,13 @@ declare class Links {
|
|
|
550
631
|
*
|
|
551
632
|
* Fire-and-forget. Errors are swallowed; the matching `create()` call
|
|
552
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).
|
|
553
641
|
*/
|
|
554
642
|
prewarm<T extends LinkData = LinkData>(input: LinkCreateInput<T>, opts?: {
|
|
555
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,21 @@ 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;
|
|
235
|
+
this.takeoverListeners = /* @__PURE__ */ new Set();
|
|
236
|
+
this.lastTakeover = null;
|
|
197
237
|
/**
|
|
198
238
|
* Proactive refresh (s58.72). Fires when access-token age crosses
|
|
199
239
|
* ~80% of TTL. Pairs with the AppState foreground listener so a
|
|
@@ -257,15 +297,79 @@ var Auth = class {
|
|
|
257
297
|
return this.user;
|
|
258
298
|
}
|
|
259
299
|
/**
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
300
|
+
* Subscribe to device-takeover events (s58.111+). Backend retires
|
|
301
|
+
* the anonymous user_id whenever a previously-anon device signs in
|
|
302
|
+
* to an identified account. Use this to clean up any local mirror
|
|
303
|
+
* keyed by the anonymous id — your own users table, an analytics
|
|
304
|
+
* cache, an audience filter — without needing a webhook receiver.
|
|
305
|
+
*
|
|
306
|
+
* Listener is invoked from `signIn`, `loginSocial`, `verifyMagicLink`,
|
|
307
|
+
* `verifyEmailOtp`, `passkey authentication`, and SSO completion
|
|
308
|
+
* (via `consumeSsoFragment`). Returns an unsubscribe function.
|
|
309
|
+
*
|
|
310
|
+
* Listeners are best-effort: errors thrown inside them are swallowed
|
|
311
|
+
* + logged in dev mode. Auth state never depends on listener success.
|
|
312
|
+
*/
|
|
313
|
+
onDeviceTakeover(listener) {
|
|
314
|
+
this.takeoverListeners.add(listener);
|
|
315
|
+
return () => {
|
|
316
|
+
this.takeoverListeners.delete(listener);
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Inspect the most recent device-takeover event observed by the
|
|
321
|
+
* SDK. Cleared on signOut. Returns null when no takeover has
|
|
322
|
+
* happened during this session — useful for late subscribers or
|
|
323
|
+
* post-sign-in side-effects (e.g. app-launch hydrate).
|
|
324
|
+
*/
|
|
325
|
+
getLastDeviceTakeover() {
|
|
326
|
+
return this.lastTakeover;
|
|
327
|
+
}
|
|
328
|
+
/** Internal — fan out to subscribers + cache the latest. */
|
|
329
|
+
fireDeviceTakeover(retiredAnonUserId, identifiedUserId) {
|
|
330
|
+
const evt = { retiredAnonUserId, identifiedUserId, at: /* @__PURE__ */ new Date() };
|
|
331
|
+
this.lastTakeover = evt;
|
|
332
|
+
for (const fn of this.takeoverListeners) {
|
|
333
|
+
try {
|
|
334
|
+
fn(evt);
|
|
335
|
+
} catch (err) {
|
|
336
|
+
if (this.hooks.debug) console.warn("[sendoracloud] onDeviceTakeover listener threw", err);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
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).
|
|
263
361
|
*/
|
|
264
362
|
async getAccessToken() {
|
|
265
363
|
if (!this.accessToken) return null;
|
|
266
364
|
if (this.accessExpiresAt && Date.now() < this.accessExpiresAt - REFRESH_SAFETY_MS) {
|
|
267
365
|
return this.accessToken;
|
|
268
366
|
}
|
|
367
|
+
if (this.refreshInflight) {
|
|
368
|
+
return this.accessToken;
|
|
369
|
+
}
|
|
370
|
+
if (Date.now() < this.refreshCooldownUntil) {
|
|
371
|
+
return this.accessToken;
|
|
372
|
+
}
|
|
269
373
|
return this.refreshAccessToken();
|
|
270
374
|
}
|
|
271
375
|
/** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
|
|
@@ -698,6 +802,7 @@ var Auth = class {
|
|
|
698
802
|
if (err) throw new AuthError("SSO_FAILED", decodeURIComponent(err));
|
|
699
803
|
const refresh = params.get("sendora_oidc_token") ?? params.get("sendora_saml_token");
|
|
700
804
|
if (!refresh) throw new AuthError("SSO_NO_TOKEN", "Callback URL has no SSO token");
|
|
805
|
+
const retiredAnonUserId = params.get("sendora_retired_anon");
|
|
701
806
|
const res = await post(
|
|
702
807
|
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
703
808
|
"/api/v1/auth-service/token/refresh",
|
|
@@ -726,7 +831,8 @@ var Auth = class {
|
|
|
726
831
|
refreshToken: newRefresh,
|
|
727
832
|
expiresIn,
|
|
728
833
|
tokenType: "Bearer"
|
|
729
|
-
}
|
|
834
|
+
},
|
|
835
|
+
...retiredAnonUserId ? { retiredAnonUserId } : {}
|
|
730
836
|
});
|
|
731
837
|
return user;
|
|
732
838
|
});
|
|
@@ -802,6 +908,8 @@ var Auth = class {
|
|
|
802
908
|
if (Number.isFinite(freshExpires) && freshExpires > Date.now()) {
|
|
803
909
|
this.accessToken = freshAccess;
|
|
804
910
|
this.accessExpiresAt = freshExpires;
|
|
911
|
+
this.refreshFailureCount = 0;
|
|
912
|
+
this.refreshCooldownUntil = 0;
|
|
805
913
|
return freshAccess;
|
|
806
914
|
}
|
|
807
915
|
}
|
|
@@ -814,11 +922,19 @@ var Auth = class {
|
|
|
814
922
|
this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
|
|
815
923
|
this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
|
|
816
924
|
this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
|
|
925
|
+
this.refreshFailureCount = 0;
|
|
926
|
+
this.refreshCooldownUntil = 0;
|
|
817
927
|
return data.tokens.accessToken;
|
|
818
928
|
} catch (err) {
|
|
819
929
|
if (err instanceof AuthError && isDeadRefreshError(err.code)) {
|
|
820
930
|
await this.wipeLocalIdentity().catch(() => void 0);
|
|
931
|
+
this.refreshFailureCount = 0;
|
|
932
|
+
this.refreshCooldownUntil = 0;
|
|
933
|
+
return null;
|
|
821
934
|
}
|
|
935
|
+
this.refreshFailureCount += 1;
|
|
936
|
+
const backoffMs = Math.min(3e4, 1e3 * Math.pow(2, this.refreshFailureCount - 1));
|
|
937
|
+
this.refreshCooldownUntil = Date.now() + backoffMs;
|
|
822
938
|
return null;
|
|
823
939
|
} finally {
|
|
824
940
|
this.refreshInflight = null;
|
|
@@ -826,6 +942,20 @@ var Auth = class {
|
|
|
826
942
|
})();
|
|
827
943
|
return this.refreshInflight;
|
|
828
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
|
+
}
|
|
829
959
|
startProactiveRefreshCron() {
|
|
830
960
|
if (this.proactiveRefreshTimer) return;
|
|
831
961
|
const PROACTIVE_TARGET_PCT = 0.8;
|
|
@@ -847,7 +977,10 @@ var Auth = class {
|
|
|
847
977
|
}, TICK_MS);
|
|
848
978
|
try {
|
|
849
979
|
const subscription = RnAppState?.addEventListener?.("change", (state) => {
|
|
850
|
-
if (state === "active")
|
|
980
|
+
if (state === "active") {
|
|
981
|
+
this.resetRefreshState();
|
|
982
|
+
void tick();
|
|
983
|
+
}
|
|
851
984
|
});
|
|
852
985
|
if (subscription) {
|
|
853
986
|
this.proactiveRefreshAppStateUnsub = () => subscription.remove();
|
|
@@ -967,11 +1100,15 @@ var Auth = class {
|
|
|
967
1100
|
this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
|
|
968
1101
|
this.hooks.onIdentityChange(data.user.id);
|
|
969
1102
|
this.startProactiveRefreshCron();
|
|
1103
|
+
if (data.retiredAnonUserId) {
|
|
1104
|
+
this.fireDeviceTakeover(data.retiredAnonUserId, data.user.id);
|
|
1105
|
+
}
|
|
970
1106
|
}
|
|
971
1107
|
async wipeLocalIdentity() {
|
|
972
1108
|
this.user = null;
|
|
973
1109
|
this.accessToken = null;
|
|
974
1110
|
this.accessExpiresAt = 0;
|
|
1111
|
+
this.lastTakeover = null;
|
|
975
1112
|
this.hooks.storage.remove(USER_KEY);
|
|
976
1113
|
this.hooks.storage.remove(TOKEN_KEY);
|
|
977
1114
|
this.hooks.storage.remove(TOKEN_EXPIRES_KEY);
|
|
@@ -1283,11 +1420,13 @@ async function getPlayInstallReferrer() {
|
|
|
1283
1420
|
}
|
|
1284
1421
|
var PREWARM_TTL_MS = 5 * 6e4;
|
|
1285
1422
|
var PREWARM_MAX = 50;
|
|
1423
|
+
var PREWARM_MAX_INFLIGHT = 5;
|
|
1286
1424
|
var Links = class {
|
|
1287
1425
|
constructor(deps) {
|
|
1288
1426
|
this.deps = deps;
|
|
1289
1427
|
this.listeners = [];
|
|
1290
1428
|
this.prewarmCache = /* @__PURE__ */ new Map();
|
|
1429
|
+
this.prewarmInflight = 0;
|
|
1291
1430
|
this.linkingSub = null;
|
|
1292
1431
|
}
|
|
1293
1432
|
/** Register a callback for warm + deferred deep-link opens. Returns
|
|
@@ -1335,14 +1474,25 @@ var Links = class {
|
|
|
1335
1474
|
*
|
|
1336
1475
|
* Fire-and-forget. Errors are swallowed; the matching `create()` call
|
|
1337
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).
|
|
1338
1484
|
*/
|
|
1339
1485
|
prewarm(input, opts) {
|
|
1340
1486
|
this.evictExpired();
|
|
1341
1487
|
const key = this.cacheKey(input, opts?.key);
|
|
1342
1488
|
if (this.prewarmCache.has(key)) return;
|
|
1489
|
+
if (this.prewarmInflight >= PREWARM_MAX_INFLIGHT) return;
|
|
1490
|
+
this.prewarmInflight++;
|
|
1343
1491
|
const promise = this.doCreate(input).catch((err) => {
|
|
1344
1492
|
this.prewarmCache.delete(key);
|
|
1345
1493
|
throw err;
|
|
1494
|
+
}).finally(() => {
|
|
1495
|
+
this.prewarmInflight--;
|
|
1346
1496
|
});
|
|
1347
1497
|
this.prewarmCache.set(key, { promise, ts: Date.now() });
|
|
1348
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",
|