react-native-tapline 1.3.0 → 1.4.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/network.js CHANGED
@@ -1,15 +1,30 @@
1
1
  "use strict";
2
2
  /**
3
3
  * Network tracking: counts requests in flight (so an agent can wait for loading to finish)
4
- * and reports failed ones. React Native's fetch is built on XMLHttpRequest, so patching
5
- * XMLHttpRequest.prototype covers both. Nothing here may ever throw into the app.
4
+ * and reports failed ones. React Native's fetch is built on XMLHttpRequest, but Expo SDK 58's
5
+ * expo/fetch is native, so both XMLHttpRequest.prototype and the global fetch are wrapped (a
6
+ * request is counted once). Nothing here may ever throw into the app.
6
7
  */
7
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.NETWORK_MODES = void 0;
8
10
  exports.redactUrl = redactUrl;
11
+ exports.ensureFetchWrapped = ensureFetchWrapped;
12
+ exports.networkCondition = networkCondition;
13
+ exports.setNetworkCondition = setNetworkCondition;
14
+ exports.resetNetworkCondition = resetNetworkCondition;
15
+ exports.seedNetworkRandom = seedNetworkRandom;
9
16
  exports.installNetworkTracking = installNetworkTracking;
10
17
  exports.uninstallNetworkTracking = uninstallNetworkTracking;
11
18
  exports.inFlight = inFlight;
12
19
  exports.onNetworkFailure = onNetworkFailure;
20
+ exports.NETWORK_MODES = ['offline', '3g', 'lossy', 'normal'];
21
+ const THREE_G_LATENCY_MS = 400;
22
+ /** 750 kbit/s in bytes per millisecond. */
23
+ const THREE_G_BYTES_PER_MS = 750_000 / 8 / 1000;
24
+ const MAX_TRANSFER_MS = 30_000;
25
+ const LOSSY_FAIL_SHARE = 0.2;
26
+ const LOSSY_FAIL_DELAY_MS = [100, 400];
27
+ const LOSSY_SLOW_DELAY_MS = [50, 250];
13
28
  const MAX_URL_CHARS = 200;
14
29
  const ABSOLUTE_URL = /^([a-z][a-z0-9+.-]*:\/\/)([^/?#]*)([^?#]*)(\?[^#]*)?(#.*)?$/i;
15
30
  const DEV_SERVER_SEGMENT = /\/(?:symbolicate|logs|message|status|inspector|hot|open-stack-frame)(?:\/|$)/;
@@ -19,6 +34,16 @@ let installed = null;
19
34
  let customIgnore = null;
20
35
  let pending = 0;
21
36
  let generation = 0;
37
+ let condition = 'normal';
38
+ let random = Math.random;
39
+ /** Requests whose send is delayed (or doomed), so abort() can cancel them. */
40
+ const held = new WeakMap();
41
+ /** Finished 3g responses whose completion events are still held back; `cancel` drops them and says if any were held. */
42
+ const responseHolds = new WeakMap();
43
+ /** Each response hold still open, as the function that lets it finish now: uninstalling releases them all. */
44
+ const openHolds = new Set();
45
+ /** Each request a condition holds before its send, as the function that sends it now: uninstalling sends them all. */
46
+ const heldSends = new Set();
22
47
  /** scheme://host[:port]/path, query replaced by "?…", no fragment or credentials. */
23
48
  function redactUrl(raw) {
24
49
  const text = typeof raw === 'string' ? raw : String(raw);
@@ -138,7 +163,9 @@ function patchedOpen(original) {
138
163
  requests.set(this, {
139
164
  method: String(args[0]).toUpperCase(),
140
165
  url: redactUrl(rawUrl),
141
- ignored: isIgnored(rawUrl),
166
+ // A request that a wrapped fetch opens is counted and conditioned by the fetch wrapper, not twice.
167
+ ignored: insideFetch > 0 || isIgnored(rawUrl),
168
+ devServer: defaultIgnore(rawUrl),
142
169
  });
143
170
  }
144
171
  }
@@ -158,6 +185,17 @@ function patchedSend(original) {
158
185
  catch {
159
186
  settle = null;
160
187
  }
188
+ try {
189
+ if (condition !== 'normal' && typeof this === 'object' && this !== null) {
190
+ const info = requests.get(this);
191
+ if (info && !info.ignored && !info.devServer && applyCondition(this, condition, original, args, settle)) {
192
+ return undefined;
193
+ }
194
+ }
195
+ }
196
+ catch {
197
+ // conditioning must never break a request: fall through to a plain send
198
+ }
161
199
  try {
162
200
  return original.apply(this, args);
163
201
  }
@@ -173,6 +211,653 @@ function patchedSend(original) {
173
211
  }
174
212
  };
175
213
  }
214
+ function patchedAbort(original) {
215
+ return function abort(...args) {
216
+ try {
217
+ if (typeof this === 'object' && this !== null) {
218
+ const entry = held.get(this);
219
+ if (entry) {
220
+ // never really sent: cancel it here and end it the way React Native does on abort
221
+ clearTimeout(entry.timer);
222
+ held.delete(this);
223
+ if (entry.cancel)
224
+ heldSends.delete(entry.cancel);
225
+ entry.settle?.();
226
+ abortHeld(this, original, args);
227
+ return undefined;
228
+ }
229
+ const hold = responseHolds.get(this);
230
+ if (hold?.cancel()) {
231
+ // the answer was complete but held back: the app cancelled, so it gets abort and loadend now
232
+ try {
233
+ original.apply(this, args);
234
+ }
235
+ catch {
236
+ // the request is over either way
237
+ }
238
+ dispatchNow(this, ['abort', 'loadend']);
239
+ return undefined;
240
+ }
241
+ }
242
+ }
243
+ catch {
244
+ // never throw into the app
245
+ }
246
+ return original.apply(this, args);
247
+ };
248
+ }
249
+ /**
250
+ * Ends a request that was held (never really sent) as React Native ends an aborted one: readyState 4 and status 0,
251
+ * then readystatechange, abort and loadend, at once; then the XHR's own abort, which resets it to UNSENT so the app
252
+ * can open it again (Codex review of 1.4.0: before, a reused XHR threw "Cannot open, already sending").
253
+ */
254
+ function abortHeld(xhr, original, args) {
255
+ // React Native's XHR (it has `_sent`) ends an aborted request itself: marked as sent, its abort fires
256
+ // readystatechange, abort and loadend through its own event path and resets to UNSENT.
257
+ if ('_sent' in xhr) {
258
+ assign(xhr, '_sent', true);
259
+ try {
260
+ original.apply(xhr, args);
261
+ return;
262
+ }
263
+ catch {
264
+ // fall back to ending it here
265
+ }
266
+ }
267
+ assign(xhr, 'readyState', 4);
268
+ assign(xhr, 'status', 0);
269
+ dispatchNow(xhr, ['readystatechange', 'abort', 'loadend']);
270
+ try {
271
+ // readyState is DONE now, so React Native's abort sends no second set of events; it only resets.
272
+ original.apply(xhr, args);
273
+ }
274
+ catch {
275
+ // the request is over either way
276
+ }
277
+ }
278
+ function dispatchNow(xhr, types) {
279
+ try {
280
+ const target = xhr;
281
+ if (isFn(target.dispatchEvent)) {
282
+ const dispatch = target.dispatchEvent;
283
+ for (const type of types) {
284
+ try {
285
+ dispatch.call(xhr, makeEvent(type));
286
+ }
287
+ catch {
288
+ // a handler's error stays the app's
289
+ }
290
+ }
291
+ }
292
+ else if (isFn(target.onreadystatechange)) {
293
+ target.onreadystatechange.call(xhr);
294
+ }
295
+ }
296
+ catch {
297
+ // never throw into the app
298
+ }
299
+ }
300
+ function between(range) {
301
+ return range[0] + random() * (range[1] - range[0]);
302
+ }
303
+ function assign(target, key, value) {
304
+ try {
305
+ target[key] = value;
306
+ }
307
+ catch {
308
+ try {
309
+ Object.defineProperty(target, key, { value, configurable: true, writable: true });
310
+ }
311
+ catch {
312
+ // ignore
313
+ }
314
+ }
315
+ }
316
+ function makeEvent(type) {
317
+ const Ctor = globalThis.Event;
318
+ if (isFn(Ctor)) {
319
+ try {
320
+ return new Ctor(type);
321
+ }
322
+ catch {
323
+ // fall back to a plain object
324
+ }
325
+ }
326
+ return { type };
327
+ }
328
+ /** Fails a request the way React Native does with no connection: status 0, `error`, then `loadend`. */
329
+ function failRequest(xhr) {
330
+ try {
331
+ assign(xhr, 'readyState', 4);
332
+ assign(xhr, 'status', 0);
333
+ const target = xhr;
334
+ if (isFn(target.dispatchEvent)) {
335
+ const dispatch = target.dispatchEvent;
336
+ for (const type of ['readystatechange', 'error', 'loadend']) {
337
+ try {
338
+ dispatch.call(xhr, makeEvent(type));
339
+ }
340
+ catch {
341
+ // a handler's error stays the app's
342
+ }
343
+ }
344
+ }
345
+ else if (isFn(target.onreadystatechange)) {
346
+ target.onreadystatechange.call(xhr);
347
+ }
348
+ }
349
+ catch {
350
+ // never throw into the app
351
+ }
352
+ }
353
+ function sizeOf(xhr) {
354
+ const x = xhr;
355
+ try {
356
+ if (isFn(x.getResponseHeader)) {
357
+ const length = Number(x.getResponseHeader.call(xhr, 'content-length'));
358
+ if (Number.isFinite(length) && length > 0)
359
+ return length;
360
+ }
361
+ }
362
+ catch {
363
+ // fall through
364
+ }
365
+ try {
366
+ if (typeof x.responseText === 'string')
367
+ return x.responseText.length;
368
+ }
369
+ catch {
370
+ // responseText throws for non-text response types
371
+ }
372
+ const response = x.response;
373
+ if (typeof response === 'object' && response !== null) {
374
+ const { byteLength, size } = response;
375
+ if (typeof byteLength === 'number')
376
+ return byteLength;
377
+ if (typeof size === 'number')
378
+ return size;
379
+ }
380
+ return 0;
381
+ }
382
+ function bodyBytes(body) {
383
+ if (typeof body === 'string')
384
+ return body.length;
385
+ if (typeof body === 'object' && body !== null) {
386
+ const { byteLength, size } = body;
387
+ if (typeof byteLength === 'number')
388
+ return byteLength;
389
+ if (typeof size === 'number')
390
+ return size;
391
+ }
392
+ return 0;
393
+ }
394
+ function eventType(event) {
395
+ const type = typeof event === 'object' && event !== null ? event.type : undefined;
396
+ return typeof type === 'string' ? type : '';
397
+ }
398
+ /**
399
+ * Holds back the events that deliver the finished response (load, loadend, the final readystatechange)
400
+ * until the transfer would have taken. abort, error and timeout are never held: they drop the held
401
+ * completion at once.
402
+ */
403
+ function holdResponse(xhr) {
404
+ // React Native's own XMLHttpRequest finishes through __didCompleteResponse and fires its events through an inner
405
+ // path, not dispatchEvent (0.8x), so the transfer time is held there: the completion comes that much later. An
406
+ // abort meanwhile ends the request as usual, and the late completion finds no request to finish (Codex review).
407
+ // React Native sends every completion to every XHR that listens, so one for another request passes straight
408
+ // through and leaves this hold in place; an abort removes the hold and its timer, so a reused XHR isn't held.
409
+ const native = xhr;
410
+ if (isFn(native.__didCompleteResponse)) {
411
+ const complete = native.__didCompleteResponse;
412
+ let timer = null;
413
+ let waiting = null;
414
+ const restore = () => {
415
+ if (timer !== null)
416
+ clearTimeout(timer);
417
+ timer = null;
418
+ waiting = null;
419
+ assign(xhr, '__didCompleteResponse', complete);
420
+ responseHolds.delete(xhr);
421
+ openHolds.delete(release);
422
+ };
423
+ // Tapline stopping: the method goes back, and a completion it was holding is delivered now.
424
+ const release = () => {
425
+ const args = waiting;
426
+ restore();
427
+ if (!args)
428
+ return;
429
+ try {
430
+ complete.apply(xhr, args);
431
+ }
432
+ catch {
433
+ // a handler's error stays the app's
434
+ }
435
+ };
436
+ openHolds.add(release);
437
+ // false: nothing was dropped, so React Native's own abort ends the request and fires its events
438
+ responseHolds.set(xhr, {
439
+ cancel: () => {
440
+ restore();
441
+ return false;
442
+ },
443
+ });
444
+ assign(xhr, '__didCompleteResponse', function __didCompleteResponse(...args) {
445
+ if (args[0] !== native._requestId)
446
+ return complete.apply(xhr, args);
447
+ if (timer !== null)
448
+ return undefined; // already waiting out this request's transfer
449
+ const error = args[1];
450
+ const delay = typeof error === 'string' && error !== '' ? 0 : Math.min(MAX_TRANSFER_MS, Math.ceil(sizeOf(xhr) / THREE_G_BYTES_PER_MS));
451
+ if (delay <= 0) {
452
+ restore();
453
+ return complete.apply(xhr, args);
454
+ }
455
+ waiting = args;
456
+ timer = setTimeout(release, delay);
457
+ return undefined;
458
+ });
459
+ return;
460
+ }
461
+ const target = xhr;
462
+ if (!isFn(target.dispatchEvent))
463
+ return;
464
+ const dispatch = target.dispatchEvent;
465
+ let queue = null;
466
+ let timer = null;
467
+ let released = false;
468
+ const drop = () => {
469
+ const dropped = queue ?? [];
470
+ queue = null;
471
+ if (timer !== null)
472
+ clearTimeout(timer);
473
+ timer = null;
474
+ released = true;
475
+ responseHolds.delete(xhr);
476
+ openHolds.delete(release);
477
+ return dropped;
478
+ };
479
+ const release = () => {
480
+ for (const queued of drop()) {
481
+ try {
482
+ dispatch.call(xhr, queued);
483
+ }
484
+ catch {
485
+ // a handler's error stays the app's
486
+ }
487
+ }
488
+ };
489
+ openHolds.add(release);
490
+ responseHolds.set(xhr, { cancel: () => drop().length > 0 });
491
+ assign(xhr, 'dispatchEvent', function dispatchEvent(event) {
492
+ if (released)
493
+ return dispatch.call(xhr, event);
494
+ const type = eventType(event);
495
+ if (type === 'abort' || type === 'error' || type === 'timeout') {
496
+ // the app's cancel, or a failure: not part of the completion, so nothing waits. A held
497
+ // readystatechange still goes out first; the held load and loadend are dropped.
498
+ for (const queued of drop()) {
499
+ if (eventType(queued) !== 'readystatechange')
500
+ continue;
501
+ try {
502
+ dispatch.call(xhr, queued);
503
+ }
504
+ catch {
505
+ // a handler's error stays the app's
506
+ }
507
+ }
508
+ return dispatch.call(xhr, event);
509
+ }
510
+ const completion = type === 'load' ||
511
+ type === 'loadend' ||
512
+ (type === 'readystatechange' && xhr.readyState === 4);
513
+ if (!completion)
514
+ return dispatch.call(xhr, event);
515
+ if (queue) {
516
+ queue.push(event);
517
+ return true;
518
+ }
519
+ const delay = Math.min(MAX_TRANSFER_MS, Math.ceil(sizeOf(xhr) / THREE_G_BYTES_PER_MS));
520
+ if (delay > 0) {
521
+ const events = [event];
522
+ queue = events;
523
+ timer = setTimeout(release, delay);
524
+ return true;
525
+ }
526
+ released = true;
527
+ responseHolds.delete(xhr);
528
+ openHolds.delete(release);
529
+ return dispatch.call(xhr, event);
530
+ });
531
+ }
532
+ /** Returns true when the condition took over the request (the real send happens later, or never). */
533
+ function applyCondition(xhr, mode, original, args, settle) {
534
+ const forGeneration = generation;
535
+ const send = (hold) => {
536
+ held.delete(xhr);
537
+ heldSends.delete(sendNow);
538
+ // a timer from an earlier install never puts a hold on the request: nothing would take it off again
539
+ if (hold && mode === '3g' && installed && forGeneration === generation)
540
+ holdResponse(xhr);
541
+ try {
542
+ original.apply(xhr, args);
543
+ }
544
+ catch {
545
+ settle?.();
546
+ failRequest(xhr);
547
+ }
548
+ };
549
+ const realSend = () => send(true);
550
+ // Tapline stopping: the request goes out now, as it would with no condition
551
+ const sendNow = () => {
552
+ clearTimeout(timer);
553
+ send(false);
554
+ };
555
+ const fail = () => {
556
+ held.delete(xhr);
557
+ heldSends.delete(sendNow);
558
+ failRequest(xhr);
559
+ };
560
+ let timer;
561
+ if (mode === 'offline') {
562
+ timer = setTimeout(fail, 0);
563
+ }
564
+ else if (mode === '3g') {
565
+ const upload = bodyBytes(args[0]) / THREE_G_BYTES_PER_MS;
566
+ timer = setTimeout(realSend, Math.min(MAX_TRANSFER_MS, THREE_G_LATENCY_MS + upload));
567
+ }
568
+ else if (random() < LOSSY_FAIL_SHARE) {
569
+ timer = setTimeout(fail, between(LOSSY_FAIL_DELAY_MS));
570
+ }
571
+ else {
572
+ timer = setTimeout(realSend, between(LOSSY_SLOW_DELAY_MS));
573
+ }
574
+ held.set(xhr, { timer, settle, cancel: sendNow });
575
+ heldSends.add(sendNow);
576
+ return true;
577
+ }
578
+ // ---- fetch ----------------------------------------------------------------------
579
+ // Expo SDK 58 replaces the global fetch with expo/fetch, which doesn't go through XMLHttpRequest. So the global fetch
580
+ // is wrapped too, whatever it is, and wrapped again when the app replaces it after Tapline started.
581
+ const FETCH_MARK = Symbol.for('tapline.fetch');
582
+ /**
583
+ * On the init a counting wrapper passes on: the request is already counted and conditioned. Another Tapline wrapper
584
+ * further in (an app's interceptor in between, maybe after an await) passes it straight through. Spreads keep it.
585
+ */
586
+ const FETCH_OWNED = Symbol.for('tapline.fetch.owned');
587
+ function isOwned(init) {
588
+ return typeof init === 'object' && init !== null && init[FETCH_OWNED] === true;
589
+ }
590
+ function ownedInit(init) {
591
+ const copy = typeof init === 'object' && init !== null ? { ...init } : {};
592
+ copy[FETCH_OWNED] = true;
593
+ return copy;
594
+ }
595
+ /** While a wrapped fetch calls the app's own fetch: XHRs opened in that moment belong to that fetch. */
596
+ let insideFetch = 0;
597
+ let fetchInstalled = null;
598
+ /**
599
+ * The one fetch wrapper that counts and conditions requests: the newest. An app's wrapper around an older one (it
600
+ * may call it after an await, so `insideFetch` can't tell) and one left after uninstall pass straight through.
601
+ */
602
+ let activeFetchWrapper = null;
603
+ /** Each wrapper's own fetch, so the one that is active again can be undone at uninstall. */
604
+ const wrapperOriginals = new WeakMap();
605
+ /** Where the global fetch lives: the install target (globalThis in an app). */
606
+ let fetchTarget = null;
607
+ /** The request's abort signal: init.signal, else the Request's own. */
608
+ function signalOf(input, init) {
609
+ const fromInit = typeof init === 'object' && init !== null ? init.signal : undefined;
610
+ const fromInput = typeof input === 'object' && input !== null ? input.signal : undefined;
611
+ const signal = fromInit ?? fromInput;
612
+ return typeof signal === 'object' && signal !== null ? signal : null;
613
+ }
614
+ /** What an aborted fetch rejects with: the signal's reason, else an AbortError. */
615
+ function abortError(signal) {
616
+ if (signal.reason !== undefined)
617
+ return signal.reason;
618
+ const error = new Error('The operation was aborted.');
619
+ error.name = 'AbortError';
620
+ return error;
621
+ }
622
+ /**
623
+ * Waits `ms`, or rejects at once when the request is aborted (also when it already was): a held request never
624
+ * outlives its cancel, and a response held back is not delivered after it (Codex review of 1.4.0).
625
+ */
626
+ /** A fetch's condition waits still running, as the function that ends each now: uninstalling ends them all. */
627
+ const fetchWaits = new Set();
628
+ function wait(ms, signal = null) {
629
+ return new Promise((resolve, reject) => {
630
+ if (signal !== null && signal.aborted === true) {
631
+ reject(abortError(signal));
632
+ return;
633
+ }
634
+ let onAbort = null;
635
+ const finish = () => {
636
+ clearTimeout(timer);
637
+ fetchWaits.delete(finish);
638
+ if (onAbort !== null && signal !== null && isFn(signal.removeEventListener))
639
+ signal.removeEventListener.call(signal, 'abort', onAbort);
640
+ resolve();
641
+ };
642
+ const timer = setTimeout(finish, Math.max(0, ms));
643
+ fetchWaits.add(finish);
644
+ if (signal !== null && isFn(signal.addEventListener)) {
645
+ onAbort = () => {
646
+ clearTimeout(timer);
647
+ fetchWaits.delete(finish);
648
+ reject(abortError(signal));
649
+ };
650
+ signal.addEventListener.call(signal, 'abort', onAbort);
651
+ }
652
+ });
653
+ }
654
+ function fetchUrl(input) {
655
+ if (typeof input === 'string')
656
+ return input;
657
+ if (typeof input === 'object' && input !== null) {
658
+ const record = input;
659
+ if (typeof record.url === 'string')
660
+ return record.url;
661
+ if (typeof record.href === 'string')
662
+ return record.href;
663
+ }
664
+ return String(input);
665
+ }
666
+ function fetchMethod(input, init) {
667
+ const fromInit = typeof init === 'object' && init !== null ? init.method : undefined;
668
+ if (typeof fromInit === 'string')
669
+ return fromInit.toUpperCase();
670
+ const fromInput = typeof input === 'object' && input !== null ? input.method : undefined;
671
+ return typeof fromInput === 'string' ? fromInput.toUpperCase() : 'GET';
672
+ }
673
+ function responseStatus(response) {
674
+ const status = typeof response === 'object' && response !== null ? response.status : undefined;
675
+ return typeof status === 'number' ? status : null;
676
+ }
677
+ function responseLength(response) {
678
+ try {
679
+ const headers = typeof response === 'object' && response !== null ? response.headers : undefined;
680
+ const get = typeof headers === 'object' && headers !== null ? headers.get : undefined;
681
+ const raw = isFn(get) ? get.call(headers, 'content-length') : null;
682
+ const n = typeof raw === 'string' ? Number(raw) : NaN;
683
+ return Number.isFinite(n) && n > 0 ? n : 0;
684
+ }
685
+ catch {
686
+ return 0;
687
+ }
688
+ }
689
+ function isAbort(error) {
690
+ return typeof error === 'object' && error !== null && error.name === 'AbortError';
691
+ }
692
+ function wrapFetch(original) {
693
+ const wrapped = async function fetch(input, init) {
694
+ let rawUrl = '';
695
+ try {
696
+ rawUrl = fetchUrl(input);
697
+ }
698
+ catch {
699
+ rawUrl = '';
700
+ }
701
+ const call = (forward = init) => {
702
+ insideFetch += 1;
703
+ try {
704
+ return original.call(this, input, forward);
705
+ }
706
+ finally {
707
+ insideFetch -= 1;
708
+ }
709
+ };
710
+ // The global fetch may be an earlier wrapper again; the wrapper that is global then counts. Called from inside
711
+ // another Tapline-wrapped fetch (an app's wrapper in between), or not the active wrapper: it passes through.
712
+ followGlobalWrapper();
713
+ if (isOwned(init) || wrapped !== activeFetchWrapper || insideFetch > 0 || rawUrl === '' || isIgnored(rawUrl))
714
+ return call();
715
+ const devServer = defaultIgnore(rawUrl);
716
+ const signal = signalOf(input, init);
717
+ const method = fetchMethod(input, init);
718
+ const url = redactUrl(rawUrl);
719
+ const startedAt = Date.now();
720
+ const forGeneration = generation;
721
+ pending += 1;
722
+ let done = false;
723
+ const settle = () => {
724
+ if (done)
725
+ return;
726
+ done = true;
727
+ if (forGeneration === generation && pending > 0)
728
+ pending -= 1;
729
+ };
730
+ const failed = (status, error) => {
731
+ const at = Date.now();
732
+ notify({ method, url, status, error, ms: Math.max(0, at - startedAt), at });
733
+ };
734
+ // Tapline stopped since the request started: no more delays or made-up failures, it goes out as normal
735
+ const live = () => forGeneration === generation;
736
+ try {
737
+ const mode = devServer ? 'normal' : condition;
738
+ if (mode === 'offline' || (mode === 'lossy' && random() < LOSSY_FAIL_SHARE)) {
739
+ await wait(mode === 'offline' ? 0 : between(LOSSY_FAIL_DELAY_MS), signal);
740
+ if (live()) {
741
+ failed(null, 'network error');
742
+ throw new TypeError('Network request failed');
743
+ }
744
+ }
745
+ else if (mode === '3g') {
746
+ const body = typeof init === 'object' && init !== null ? init.body : undefined;
747
+ await wait(Math.min(MAX_TRANSFER_MS, THREE_G_LATENCY_MS + bodyBytes(body) / THREE_G_BYTES_PER_MS), signal);
748
+ }
749
+ else if (mode === 'lossy') {
750
+ await wait(between(LOSSY_SLOW_DELAY_MS), signal);
751
+ }
752
+ let response;
753
+ try {
754
+ response = await call(ownedInit(init));
755
+ }
756
+ catch (error) {
757
+ if (!isAbort(error))
758
+ failed(null, 'network error');
759
+ throw error;
760
+ }
761
+ if (mode === '3g' && live())
762
+ await wait(Math.min(MAX_TRANSFER_MS, responseLength(response) / THREE_G_BYTES_PER_MS), signal);
763
+ const status = responseStatus(response);
764
+ if (status !== null && status >= 400)
765
+ failed(status, null);
766
+ return response;
767
+ }
768
+ finally {
769
+ settle();
770
+ }
771
+ };
772
+ wrapped[FETCH_MARK] = true;
773
+ return wrapped;
774
+ }
775
+ function isFetchWrapper(fn) {
776
+ return isFn(fn) && fn[FETCH_MARK] === true;
777
+ }
778
+ /** The app put an earlier wrapper back as the global fetch (an interceptor cleaning up): that one counts again. */
779
+ function reactivate(target, wrapper) {
780
+ const original = wrapperOriginals.get(wrapper);
781
+ if (wrapper === activeFetchWrapper || original === undefined)
782
+ return;
783
+ activeFetchWrapper = wrapper;
784
+ fetchInstalled = { target, original };
785
+ }
786
+ /** At each fetch: when the global fetch is a Tapline wrapper again, it is the active one. Wraps nothing new. */
787
+ function followGlobalWrapper() {
788
+ if (!installed || fetchTarget === null)
789
+ return;
790
+ try {
791
+ const current = fetchTarget.fetch;
792
+ if (isFetchWrapper(current))
793
+ reactivate(fetchTarget, current);
794
+ }
795
+ catch {
796
+ // never throw into the app
797
+ }
798
+ }
799
+ /**
800
+ * Wraps the app's global fetch, once per function: a fetch replaced after Tapline started (Expo installs its own
801
+ * lazily) is wrapped when this runs next. Called on install, on each condition change and whenever requests are
802
+ * counted. Never throws into the app.
803
+ */
804
+ function ensureFetchWrapped(given) {
805
+ if (!installed)
806
+ return;
807
+ const target = given ?? fetchTarget;
808
+ if (target === null)
809
+ return;
810
+ try {
811
+ const current = target.fetch;
812
+ if (!isFn(current))
813
+ return;
814
+ if (isFetchWrapper(current)) {
815
+ reactivate(target, current);
816
+ return;
817
+ }
818
+ const wrapper = wrapFetch(current);
819
+ wrapperOriginals.set(wrapper, current);
820
+ target.fetch = wrapper;
821
+ activeFetchWrapper = wrapper;
822
+ fetchInstalled = { target, original: current };
823
+ }
824
+ catch {
825
+ // never throw into the app
826
+ }
827
+ }
828
+ /** The mode the app's requests are in now. */
829
+ function networkCondition() {
830
+ return condition;
831
+ }
832
+ /** Puts the app's requests into a mode; `normal` restores them. Requests already sent are not touched. */
833
+ function setNetworkCondition(mode) {
834
+ if (!exports.NETWORK_MODES.includes(mode))
835
+ throw new Error(`mode must be one of ${exports.NETWORK_MODES.join(', ')}`);
836
+ if (mode !== 'normal' && !installed) {
837
+ throw new Error('network conditions need Tapline running in the app (the request patch is not installed)');
838
+ }
839
+ ensureFetchWrapped();
840
+ condition = mode;
841
+ }
842
+ /** Back to normal; safe to call any time (host gone, Tapline stopped). */
843
+ function resetNetworkCondition() {
844
+ condition = 'normal';
845
+ }
846
+ /** A seeded random source for `lossy` (tests); `null` goes back to Math.random. */
847
+ function seedNetworkRandom(seed) {
848
+ if (seed === null) {
849
+ random = Math.random;
850
+ return;
851
+ }
852
+ let state = seed >>> 0;
853
+ random = () => {
854
+ state = (state + 0x6d2b79f5) >>> 0;
855
+ let t = state;
856
+ t = Math.imul(t ^ (t >>> 15), t | 1);
857
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
858
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
859
+ };
860
+ }
176
861
  function installNetworkTracking(options = {}) {
177
862
  if (installed)
178
863
  return;
@@ -187,20 +872,28 @@ function installNetworkTracking(options = {}) {
187
872
  const record = proto;
188
873
  const open = record.open;
189
874
  const send = record.send;
875
+ const abort = record.abort;
190
876
  if (!isFn(open) || !isFn(send))
191
877
  return;
192
878
  installed = {
193
879
  proto: record,
194
880
  open: { own: Object.prototype.hasOwnProperty.call(record, 'open'), fn: open },
195
881
  send: { own: Object.prototype.hasOwnProperty.call(record, 'send'), fn: send },
882
+ abort: { own: Object.prototype.hasOwnProperty.call(record, 'abort'), fn: isFn(abort) ? abort : null },
196
883
  };
197
884
  customIgnore = options.ignore ?? null;
198
885
  record.open = patchedOpen(open);
199
886
  record.send = patchedSend(send);
887
+ if (isFn(abort))
888
+ record.abort = patchedAbort(abort);
200
889
  }
201
890
  catch {
202
891
  // never throw into the app
203
892
  }
893
+ if (!installed)
894
+ return;
895
+ fetchTarget = (options.target ?? globalThis);
896
+ ensureFetchWrapped();
204
897
  }
205
898
  function uninstallNetworkTracking() {
206
899
  const current = installed;
@@ -208,6 +901,34 @@ function uninstallNetworkTracking() {
208
901
  customIgnore = null;
209
902
  pending = 0;
210
903
  generation += 1;
904
+ condition = 'normal';
905
+ // Held responses finish now and their wrappers come off: once the patched abort is gone, nothing else would clean
906
+ // them up, and a reused XHR would stay held (Codex review of 1.4.0).
907
+ for (const release of [...heldSends, ...openHolds, ...fetchWaits]) {
908
+ try {
909
+ release();
910
+ }
911
+ catch {
912
+ // ignore
913
+ }
914
+ }
915
+ heldSends.clear();
916
+ openHolds.clear();
917
+ fetchWaits.clear();
918
+ activeFetchWrapper = null;
919
+ const wrappedFetch = fetchInstalled;
920
+ fetchInstalled = null;
921
+ fetchTarget = null;
922
+ try {
923
+ // Put the app's fetch back, unless the app replaced it again since: the own fetch of whichever wrapper is global
924
+ // now (an interceptor may have put an earlier one back since the last fetch).
925
+ const now = wrappedFetch?.target.fetch;
926
+ if (wrappedFetch && isFetchWrapper(now))
927
+ wrappedFetch.target.fetch = wrapperOriginals.get(now) ?? wrappedFetch.original;
928
+ }
929
+ catch {
930
+ // ignore
931
+ }
211
932
  if (!current)
212
933
  return;
213
934
  try {
@@ -219,6 +940,12 @@ function uninstallNetworkTracking() {
219
940
  current.proto.send = current.send.fn;
220
941
  else
221
942
  delete current.proto.send;
943
+ if (current.abort.fn) {
944
+ if (current.abort.own)
945
+ current.proto.abort = current.abort.fn;
946
+ else
947
+ delete current.proto.abort;
948
+ }
222
949
  }
223
950
  catch {
224
951
  // ignore
@@ -226,6 +953,7 @@ function uninstallNetworkTracking() {
226
953
  }
227
954
  /** Requests that have been sent and not yet finished (dev-server traffic excluded). */
228
955
  function inFlight() {
956
+ ensureFetchWrapped();
229
957
  return pending;
230
958
  }
231
959
  /** Be told about every failed request. Returns the unsubscribe function. */