react-native-tapline 1.3.1 → 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/actions.d.ts +12 -0
- package/dist/actions.js +340 -46
- package/dist/actions.js.map +1 -1
- package/dist/client.js +7 -0
- package/dist/client.js.map +1 -1
- package/dist/closest.d.ts +30 -0
- package/dist/closest.js +101 -0
- package/dist/closest.js.map +1 -0
- package/dist/commands.d.ts +9 -0
- package/dist/commands.js +135 -2
- package/dist/commands.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/inspect.js +4 -2
- package/dist/inspect.js.map +1 -1
- package/dist/locale.d.ts +67 -0
- package/dist/locale.js +224 -0
- package/dist/locale.js.map +1 -0
- package/dist/logs.d.ts +2 -0
- package/dist/logs.js +7 -0
- package/dist/logs.js.map +1 -1
- package/dist/network.d.ts +20 -2
- package/dist/network.js +731 -3
- package/dist/network.js.map +1 -1
- package/dist/types.d.ts +3 -2
- package/package.json +1 -1
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,
|
|
5
|
-
*
|
|
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
|
-
|
|
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. */
|