@keepsnappy/react-native 0.0.0-stage → 0.1.0-alpha.11
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/README.md +260 -2
- package/android/build.gradle +35 -0
- package/android/src/main/java/expo/modules/perflab/PerfLabModule.kt +445 -0
- package/android/src/main/java/expo/modules/perflab/PerfLabSampler.kt +515 -0
- package/app.plugin.js +105 -0
- package/expo-module.config.json +9 -0
- package/ios/PerfLab.podspec +29 -0
- package/ios/PerfLabHermes.h +18 -0
- package/ios/PerfLabHermes.mm +64 -0
- package/ios/PerfLabModule.swift +371 -0
- package/ios/PerfLabSampler.swift +364 -0
- package/js/babel-transformer.js +71 -0
- package/js/hook.polyfill.js +333 -0
- package/js/renderer-switch.js +16 -0
- package/js/sdk/activation.ts +375 -0
- package/js/sdk/active.ts +62 -0
- package/js/sdk/artifacts.ts +632 -0
- package/js/sdk/boot.ts +21 -0
- package/js/sdk/causes.ts +932 -0
- package/js/sdk/core.ts +2403 -0
- package/js/sdk/cpuprof.ts +133 -0
- package/js/sdk/dormant.ts +77 -0
- package/js/sdk/env.d.ts +2 -0
- package/js/sdk/fiber.ts +591 -0
- package/js/sdk/interactions.ts +531 -0
- package/js/sdk/links.ts +201 -0
- package/js/sdk/nav.ts +383 -0
- package/js/sdk/net-install.ts +49 -0
- package/js/sdk/net.ts +684 -0
- package/js/sdk/profile-export.ts +257 -0
- package/js/sdk/profile.ts +539 -0
- package/js/sdk/push-install.ts +158 -0
- package/js/sdk/push.ts +819 -0
- package/js/sdk/retain-floor.ts +11 -0
- package/js/sdk/sampler.ts +236 -0
- package/js/sdk/screens.ts +586 -0
- package/js/sdk/stream.ts +125 -0
- package/js/zustand-vanilla.js +31 -0
- package/metro.js +238 -0
- package/package.json +26 -4
package/js/sdk/net.ts
ADDED
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
// Network request counter. Per request it does constant work: a few global
|
|
2
|
+
// counter increments and one Map lookup for duplicates. Windows never see
|
|
3
|
+
// individual requests: a window snapshots the counters when it opens and closes,
|
|
4
|
+
// and its numbers are the difference. Only the URL path is kept (no scheme, host,
|
|
5
|
+
// query, fragment, headers or bodies); the query enters the duplicate key only
|
|
6
|
+
// as a 32-bit hash, so different search terms are different requests. Path
|
|
7
|
+
// segments that look like identifiers (numbers, UUIDs, hex or token runs,
|
|
8
|
+
// anything with an `@`) are sent as `:id`.
|
|
9
|
+
//
|
|
10
|
+
// Pure logic with injected deps so it runs under bun; net-install.ts wires it to
|
|
11
|
+
// the app's fetch and XMLHttpRequest.
|
|
12
|
+
|
|
13
|
+
export type NetSource = 'fetch' | 'xhr';
|
|
14
|
+
|
|
15
|
+
export interface NetTotals {
|
|
16
|
+
requests: number;
|
|
17
|
+
duplicates: number;
|
|
18
|
+
errors: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface NetWindow {
|
|
22
|
+
requests: number;
|
|
23
|
+
byMethod: Record<string, number>;
|
|
24
|
+
bySource: Record<NetSource, number>;
|
|
25
|
+
duplicates: number;
|
|
26
|
+
duplicatesInFlight: number;
|
|
27
|
+
responses: number;
|
|
28
|
+
errors: number;
|
|
29
|
+
paths: [string, number][];
|
|
30
|
+
backfill: { requests: number; complete: boolean } | null;
|
|
31
|
+
proc: string;
|
|
32
|
+
cumStart: NetTotals;
|
|
33
|
+
cumEnd: NetTotals;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface KeyEntry {
|
|
37
|
+
inFlight: number;
|
|
38
|
+
lastStart: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface NetHandle {
|
|
42
|
+
readonly entry: KeyEntry;
|
|
43
|
+
done: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface Started {
|
|
47
|
+
t: number;
|
|
48
|
+
method: string;
|
|
49
|
+
label: string;
|
|
50
|
+
source: NetSource;
|
|
51
|
+
dup: boolean;
|
|
52
|
+
inFlightDup: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface Counters {
|
|
56
|
+
requests: number;
|
|
57
|
+
duplicates: number;
|
|
58
|
+
duplicatesInFlight: number;
|
|
59
|
+
responses: number;
|
|
60
|
+
errors: number;
|
|
61
|
+
fetch: number;
|
|
62
|
+
xhr: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface PathCount {
|
|
66
|
+
n: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface Snapshot {
|
|
70
|
+
c: Counters;
|
|
71
|
+
byMethod: Map<string, number>;
|
|
72
|
+
// The entry identity tells a label evicted and counted again apart from the one seen at open.
|
|
73
|
+
paths: Map<string, { entry: PathCount; n: number }>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface NetCounterOptions {
|
|
77
|
+
now: () => number;
|
|
78
|
+
// The same request started again this soon is a duplicate even if the first one finished.
|
|
79
|
+
dupHorizonMs?: number;
|
|
80
|
+
// A request with no end event after this long no longer counts as in flight.
|
|
81
|
+
maxInFlightMs?: number;
|
|
82
|
+
// Requests remembered for backdated windows (ring size).
|
|
83
|
+
recent?: number;
|
|
84
|
+
proc?: string;
|
|
85
|
+
// Called as each request starts (the cause tracker marks a timer tick that fetched).
|
|
86
|
+
onStart?: () => void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const MAX_PATH = 120;
|
|
90
|
+
// Path labels kept per process, least recently used evicted first.
|
|
91
|
+
const MAX_PATHS = 256;
|
|
92
|
+
const TOP_PATHS = 10;
|
|
93
|
+
const PRUNE_AT = 512;
|
|
94
|
+
// Key characters held before keys no request can match again are pruned, so long queries cannot grow the map.
|
|
95
|
+
const PRUNE_CHARS = 128 * 1024;
|
|
96
|
+
|
|
97
|
+
const SCHEME_ONLY = /^[a-z][a-z0-9+.-]*:/i;
|
|
98
|
+
|
|
99
|
+
const queryStart = (url: string) => {
|
|
100
|
+
const q = url.indexOf('?');
|
|
101
|
+
const h = url.indexOf('#');
|
|
102
|
+
if (q < 0) {
|
|
103
|
+
return h < 0 ? url.length : h;
|
|
104
|
+
}
|
|
105
|
+
return h >= 0 && h < q ? h : q;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
109
|
+
const NUMBER = /^\d+$/;
|
|
110
|
+
const HEX = /^[0-9a-f]{8,}$/i;
|
|
111
|
+
const TOKEN = /^[A-Za-z0-9_-]{16,}={0,2}$/;
|
|
112
|
+
const DIGIT = /\d/;
|
|
113
|
+
const LETTER = /[A-Za-z]/;
|
|
114
|
+
|
|
115
|
+
const looksLikeId = (segment: string) =>
|
|
116
|
+
segment.includes('@') ||
|
|
117
|
+
segment.toLowerCase().includes('%40') ||
|
|
118
|
+
NUMBER.test(segment) ||
|
|
119
|
+
UUID.test(segment) ||
|
|
120
|
+
(HEX.test(segment) && DIGIT.test(segment)) ||
|
|
121
|
+
(TOKEN.test(segment) && DIGIT.test(segment) && LETTER.test(segment));
|
|
122
|
+
|
|
123
|
+
const maskIds = (path: string) =>
|
|
124
|
+
path
|
|
125
|
+
.split('/')
|
|
126
|
+
.map(segment => (segment !== '' && looksLikeId(segment) ? ':id' : segment))
|
|
127
|
+
.join('/');
|
|
128
|
+
|
|
129
|
+
// `https://h/a/b?q#f` -> `/a/b`; `data:...` -> `data:`. Never returns host or
|
|
130
|
+
// query; identifier-like segments become `:id` (`/users/42` -> `/users/:id`).
|
|
131
|
+
export const pathOf = (url: string): string => {
|
|
132
|
+
let path = url.slice(0, queryStart(url));
|
|
133
|
+
const scheme = path.indexOf('://');
|
|
134
|
+
if (scheme >= 0 || path.startsWith('//')) {
|
|
135
|
+
const slash = path.indexOf('/', scheme >= 0 ? scheme + 3 : 2);
|
|
136
|
+
path = slash < 0 ? '/' : path.slice(slash);
|
|
137
|
+
} else if (!path.startsWith('/')) {
|
|
138
|
+
const match = SCHEME_ONLY.exec(path);
|
|
139
|
+
if (match) {
|
|
140
|
+
return match[0].toLowerCase();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (path === '') {
|
|
144
|
+
return '/';
|
|
145
|
+
}
|
|
146
|
+
path = maskIds(path);
|
|
147
|
+
return path.length > MAX_PATH ? `${path.slice(0, MAX_PATH)}…` : path;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// The query (fragment excluded) with its `?`; '' when there is none. Kept whole in the key: the Map compares strings
|
|
151
|
+
// natively, where hashing them here costs two engine calls per character (long queries, e.g. a test harness's
|
|
152
|
+
// readiness report, made this the SDK's second most expensive function).
|
|
153
|
+
const queryOf = (url: string) => {
|
|
154
|
+
const q = url.indexOf('?');
|
|
155
|
+
if (q < 0) {
|
|
156
|
+
return '';
|
|
157
|
+
}
|
|
158
|
+
const h = url.indexOf('#', q);
|
|
159
|
+
return url.slice(q, h < 0 ? url.length : h);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const zero = (): Counters => ({
|
|
163
|
+
requests: 0,
|
|
164
|
+
duplicates: 0,
|
|
165
|
+
duplicatesInFlight: 0,
|
|
166
|
+
responses: 0,
|
|
167
|
+
errors: 0,
|
|
168
|
+
fetch: 0,
|
|
169
|
+
xhr: 0,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const bump = (map: Map<string, number>, key: string, by = 1) => {
|
|
173
|
+
map.set(key, (map.get(key) ?? 0) + by);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
export const createNetCounter = (options: NetCounterOptions) => {
|
|
177
|
+
const now = options.now;
|
|
178
|
+
const dupHorizonMs = options.dupHorizonMs ?? 3000;
|
|
179
|
+
const maxInFlightMs = options.maxInFlightMs ?? 60_000;
|
|
180
|
+
const ringSize = options.recent ?? 256;
|
|
181
|
+
const proc = options.proc ?? Math.floor(now()).toString(36);
|
|
182
|
+
|
|
183
|
+
const keys = new Map<string, KeyEntry>();
|
|
184
|
+
const c = zero();
|
|
185
|
+
const byMethod = new Map<string, number>();
|
|
186
|
+
const paths = new Map<string, PathCount>();
|
|
187
|
+
let evicted = 0;
|
|
188
|
+
const open = new Map<number, Snapshot & { backfill: NetWindow['backfill'] }>();
|
|
189
|
+
const ring: (Started | undefined)[] = new Array(ringSize);
|
|
190
|
+
let ringNext = 0;
|
|
191
|
+
let pruned = 0;
|
|
192
|
+
let keyChars = 0;
|
|
193
|
+
|
|
194
|
+
// `settled`: only keys no later request can be a duplicate of (none in flight, past the horizon), so a prune for the
|
|
195
|
+
// keys' size changes no count; the prune for their number also forgets requests in flight longer than maxInFlightMs.
|
|
196
|
+
const prune = (t: number, settled: boolean) => {
|
|
197
|
+
for (const [key, entry] of keys) {
|
|
198
|
+
if (
|
|
199
|
+
t - entry.lastStart > dupHorizonMs &&
|
|
200
|
+
(entry.inFlight === 0 || (!settled && t - entry.lastStart > maxInFlightMs))
|
|
201
|
+
) {
|
|
202
|
+
keys.delete(key);
|
|
203
|
+
keyChars -= key.length;
|
|
204
|
+
pruned++;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// Map order is recency: a used label moves to the end, the first one is evicted.
|
|
210
|
+
const countPath = (label: string) => {
|
|
211
|
+
let entry = paths.get(label);
|
|
212
|
+
if (entry) {
|
|
213
|
+
paths.delete(label);
|
|
214
|
+
} else {
|
|
215
|
+
entry = { n: 0 };
|
|
216
|
+
if (paths.size >= MAX_PATHS) {
|
|
217
|
+
const oldest = paths.keys().next().value;
|
|
218
|
+
if (oldest !== undefined) {
|
|
219
|
+
paths.delete(oldest);
|
|
220
|
+
evicted++;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
entry.n++;
|
|
225
|
+
paths.set(label, entry);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const start = (method: string, url: string, source: NetSource): NetHandle => {
|
|
229
|
+
options.onStart?.();
|
|
230
|
+
const t = now();
|
|
231
|
+
const m = method ? method.toUpperCase() : 'GET';
|
|
232
|
+
const path = pathOf(url);
|
|
233
|
+
// In memory only: the unmasked path keeps /users/1 and /users/2 different requests.
|
|
234
|
+
const key = `${m} ${url.slice(0, queryStart(url))} ${queryOf(url)}`;
|
|
235
|
+
let entry = keys.get(key);
|
|
236
|
+
const inFlightDup =
|
|
237
|
+
entry !== undefined &&
|
|
238
|
+
entry.inFlight > 0 &&
|
|
239
|
+
t - entry.lastStart <= maxInFlightMs;
|
|
240
|
+
const dup =
|
|
241
|
+
inFlightDup ||
|
|
242
|
+
(entry !== undefined && t - entry.lastStart <= dupHorizonMs);
|
|
243
|
+
if (entry === undefined) {
|
|
244
|
+
if (keys.size >= PRUNE_AT) {
|
|
245
|
+
prune(t, false);
|
|
246
|
+
} else if (keyChars >= PRUNE_CHARS) {
|
|
247
|
+
prune(t, true);
|
|
248
|
+
}
|
|
249
|
+
entry = { inFlight: 0, lastStart: t };
|
|
250
|
+
keys.set(key, entry);
|
|
251
|
+
keyChars += key.length;
|
|
252
|
+
}
|
|
253
|
+
entry.inFlight++;
|
|
254
|
+
entry.lastStart = t;
|
|
255
|
+
c.requests++;
|
|
256
|
+
c[source]++;
|
|
257
|
+
if (dup) {
|
|
258
|
+
c.duplicates++;
|
|
259
|
+
}
|
|
260
|
+
if (inFlightDup) {
|
|
261
|
+
c.duplicatesInFlight++;
|
|
262
|
+
}
|
|
263
|
+
bump(byMethod, m);
|
|
264
|
+
const label = `${m} ${path}`;
|
|
265
|
+
countPath(label);
|
|
266
|
+
ring[ringNext] = { t, method: m, label, source, dup, inFlightDup };
|
|
267
|
+
ringNext = (ringNext + 1) % ringSize;
|
|
268
|
+
return { entry, done: false };
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// status 0 or `failed`: no response (network error, abort, timeout).
|
|
272
|
+
const end = (handle: NetHandle, status: number, failed = false) => {
|
|
273
|
+
if (handle.done) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
handle.done = true;
|
|
277
|
+
handle.entry.inFlight = Math.max(0, handle.entry.inFlight - 1);
|
|
278
|
+
c.responses++;
|
|
279
|
+
if (failed || !(status > 0) || status >= 400) {
|
|
280
|
+
c.errors++;
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const snapshot = (): Snapshot => ({
|
|
285
|
+
c: { ...c },
|
|
286
|
+
byMethod: new Map(byMethod),
|
|
287
|
+
paths: new Map([...paths].map(([label, entry]) => [label, { entry, n: entry.n }])),
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// A backdated window (t0 in the past) also takes the requests started since t0, from the ring.
|
|
291
|
+
const windowOpen = (id: number, t0: number, backdated: boolean) => {
|
|
292
|
+
const snap = snapshot();
|
|
293
|
+
let backfill: NetWindow['backfill'] = null;
|
|
294
|
+
if (backdated) {
|
|
295
|
+
let n = 0;
|
|
296
|
+
let complete = true;
|
|
297
|
+
for (let i = 1; i <= ringSize; i++) {
|
|
298
|
+
const req = ring[(ringNext - i + ringSize) % ringSize];
|
|
299
|
+
if (!req || req.t < t0) {
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
if (i === ringSize) {
|
|
303
|
+
complete = false;
|
|
304
|
+
}
|
|
305
|
+
n++;
|
|
306
|
+
snap.c.requests--;
|
|
307
|
+
snap.c[req.source]--;
|
|
308
|
+
if (req.dup) {
|
|
309
|
+
snap.c.duplicates--;
|
|
310
|
+
}
|
|
311
|
+
if (req.inFlightDup) {
|
|
312
|
+
snap.c.duplicatesInFlight--;
|
|
313
|
+
}
|
|
314
|
+
bump(snap.byMethod, req.method, -1);
|
|
315
|
+
const before = snap.paths.get(req.label);
|
|
316
|
+
if (before) {
|
|
317
|
+
before.n--;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
backfill = { requests: n, complete };
|
|
321
|
+
}
|
|
322
|
+
open.set(id, { ...snap, backfill });
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const diff = (now: Map<string, number>, before: Map<string, number>) => {
|
|
326
|
+
const out: [string, number][] = [];
|
|
327
|
+
for (const [key, value] of now) {
|
|
328
|
+
const d = value - (before.get(key) ?? 0);
|
|
329
|
+
if (d > 0) {
|
|
330
|
+
out.push([key, d]);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return out;
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const pathsSince = (before: Snapshot['paths']) => {
|
|
337
|
+
const out: [string, number][] = [];
|
|
338
|
+
for (const [label, entry] of paths) {
|
|
339
|
+
const b = before.get(label);
|
|
340
|
+
const d = b && b.entry === entry ? entry.n - b.n : entry.n;
|
|
341
|
+
if (d > 0) {
|
|
342
|
+
out.push([label, d]);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return out;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const windowClose = (id: number): NetWindow | null => {
|
|
349
|
+
const s = open.get(id);
|
|
350
|
+
if (!s) {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
open.delete(id);
|
|
354
|
+
return {
|
|
355
|
+
requests: c.requests - s.c.requests,
|
|
356
|
+
byMethod: Object.fromEntries(diff(byMethod, s.byMethod)),
|
|
357
|
+
bySource: { fetch: c.fetch - s.c.fetch, xhr: c.xhr - s.c.xhr },
|
|
358
|
+
duplicates: c.duplicates - s.c.duplicates,
|
|
359
|
+
duplicatesInFlight: c.duplicatesInFlight - s.c.duplicatesInFlight,
|
|
360
|
+
responses: c.responses - s.c.responses,
|
|
361
|
+
errors: c.errors - s.c.errors,
|
|
362
|
+
paths: pathsSince(s.paths)
|
|
363
|
+
.sort((a, b) => b[1] - a[1])
|
|
364
|
+
.slice(0, TOP_PATHS),
|
|
365
|
+
backfill: s.backfill,
|
|
366
|
+
proc,
|
|
367
|
+
cumStart: {
|
|
368
|
+
requests: s.c.requests,
|
|
369
|
+
duplicates: s.c.duplicates,
|
|
370
|
+
errors: s.c.errors,
|
|
371
|
+
},
|
|
372
|
+
cumEnd: { requests: c.requests, duplicates: c.duplicates, errors: c.errors },
|
|
373
|
+
};
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const stats = () => ({
|
|
377
|
+
proc,
|
|
378
|
+
dupHorizonMs,
|
|
379
|
+
totals: { ...c },
|
|
380
|
+
keys: keys.size,
|
|
381
|
+
pruned,
|
|
382
|
+
paths: paths.size,
|
|
383
|
+
pathsEvicted: evicted,
|
|
384
|
+
openWindows: open.size,
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
return { start, end, windowOpen, windowClose, stats };
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
export type NetCounter = ReturnType<typeof createNetCounter>;
|
|
391
|
+
|
|
392
|
+
// Interception of fetch and XMLHttpRequest.
|
|
393
|
+
//
|
|
394
|
+
// React Native's own fetch (whatwg-fetch) is built on XMLHttpRequest; Expo
|
|
395
|
+
// replaces the global fetch with a native one (expo/fetch) that is not. Both are
|
|
396
|
+
// wrapped. A request is counted once, at the outermost layer: the fetch wrapper
|
|
397
|
+
// raises `fetchDepth` while the original fetch runs synchronously, and
|
|
398
|
+
// whatwg-fetch opens its XMLHttpRequest inside that call, so the XHR layer sees
|
|
399
|
+
// the depth and does not count it again. `ownDepth` does the same for the SDK's
|
|
400
|
+
// own traffic (push client), which neither layer counts.
|
|
401
|
+
//
|
|
402
|
+
// Not seen: anything that does not go through JS fetch/XMLHttpRequest, e.g.
|
|
403
|
+
// images loaded natively (Image, expo-image), WebSockets, and native SDKs
|
|
404
|
+
// (Stream, LiveKit, Firebase).
|
|
405
|
+
|
|
406
|
+
const WRAPPED = '__perflabNet';
|
|
407
|
+
const XHR_STATE = '__perflabNetXhr';
|
|
408
|
+
const EXPO_BUILTIN = Symbol.for('expo.builtin');
|
|
409
|
+
|
|
410
|
+
type AnyFn = (...args: unknown[]) => unknown;
|
|
411
|
+
|
|
412
|
+
interface XhrState {
|
|
413
|
+
skip: boolean;
|
|
414
|
+
method: string;
|
|
415
|
+
url: string;
|
|
416
|
+
handle: NetHandle | null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
interface XhrLike {
|
|
420
|
+
status?: number;
|
|
421
|
+
addEventListener?: (type: string, listener: () => void) => void;
|
|
422
|
+
removeEventListener?: (type: string, listener: () => void) => void;
|
|
423
|
+
[XHR_STATE]?: XhrState;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export interface NetHooksDeps {
|
|
427
|
+
target: Record<string, unknown>;
|
|
428
|
+
counter: NetCounter;
|
|
429
|
+
enabled: () => boolean;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const fetchTarget = (input: unknown, init: unknown) => {
|
|
433
|
+
let url = '';
|
|
434
|
+
let method: unknown;
|
|
435
|
+
if (typeof input === 'string') {
|
|
436
|
+
url = input;
|
|
437
|
+
} else if (input && typeof input === 'object') {
|
|
438
|
+
const like = input as { url?: unknown; href?: unknown; method?: unknown };
|
|
439
|
+
url =
|
|
440
|
+
typeof like.url === 'string'
|
|
441
|
+
? like.url
|
|
442
|
+
: typeof like.href === 'string'
|
|
443
|
+
? like.href
|
|
444
|
+
: String(input);
|
|
445
|
+
method = like.method;
|
|
446
|
+
} else {
|
|
447
|
+
url = String(input);
|
|
448
|
+
}
|
|
449
|
+
const initMethod = (init as { method?: unknown } | null | undefined)?.method;
|
|
450
|
+
if (typeof initMethod === 'string') {
|
|
451
|
+
method = initMethod;
|
|
452
|
+
}
|
|
453
|
+
return { url, method: typeof method === 'string' ? method : 'GET' };
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
export const createNetHooks = (deps: NetHooksDeps) => {
|
|
457
|
+
const { target, counter } = deps;
|
|
458
|
+
let fetchDepth = 0;
|
|
459
|
+
let ownDepth = 0;
|
|
460
|
+
const stats = {
|
|
461
|
+
fetchWrapped: 0,
|
|
462
|
+
xhrPatched: 0,
|
|
463
|
+
fetchViaXhr: 0,
|
|
464
|
+
ownSkipped: 0,
|
|
465
|
+
lazyWraps: 0,
|
|
466
|
+
locked: [] as string[],
|
|
467
|
+
errors: 0,
|
|
468
|
+
lastError: null as string | null,
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const fail = (error: unknown) => {
|
|
472
|
+
stats.errors++;
|
|
473
|
+
stats.lastError = String(error);
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const wrapFetch = (original: unknown): unknown => {
|
|
477
|
+
if (typeof original !== 'function' || (original as unknown as Record<string, unknown>)[WRAPPED]) {
|
|
478
|
+
return original;
|
|
479
|
+
}
|
|
480
|
+
const orig = original as AnyFn;
|
|
481
|
+
const wrapped = function (this: unknown, ...args: unknown[]) {
|
|
482
|
+
if (ownDepth > 0 || !deps.enabled()) {
|
|
483
|
+
if (ownDepth > 0) {
|
|
484
|
+
stats.ownSkipped++;
|
|
485
|
+
}
|
|
486
|
+
return orig.apply(this, args);
|
|
487
|
+
}
|
|
488
|
+
let handle: NetHandle | null = null;
|
|
489
|
+
try {
|
|
490
|
+
const { method, url } = fetchTarget(args[0], args[1]);
|
|
491
|
+
handle = counter.start(method, url, 'fetch');
|
|
492
|
+
} catch (error) {
|
|
493
|
+
fail(error);
|
|
494
|
+
}
|
|
495
|
+
let result: unknown;
|
|
496
|
+
fetchDepth++;
|
|
497
|
+
try {
|
|
498
|
+
result = orig.apply(this, args);
|
|
499
|
+
} catch (error) {
|
|
500
|
+
if (handle) {
|
|
501
|
+
counter.end(handle, 0, true);
|
|
502
|
+
}
|
|
503
|
+
throw error;
|
|
504
|
+
} finally {
|
|
505
|
+
fetchDepth--;
|
|
506
|
+
}
|
|
507
|
+
const h = handle;
|
|
508
|
+
if (!h || !result || typeof (result as Promise<unknown>).then !== 'function') {
|
|
509
|
+
return result;
|
|
510
|
+
}
|
|
511
|
+
// The app gets the derived promise, so an unhandled rejection stays unhandled.
|
|
512
|
+
return (result as Promise<unknown>).then(
|
|
513
|
+
response => {
|
|
514
|
+
counter.end(h, (response as { status?: number } | null)?.status ?? 0);
|
|
515
|
+
return response;
|
|
516
|
+
},
|
|
517
|
+
error => {
|
|
518
|
+
counter.end(h, 0, true);
|
|
519
|
+
throw error;
|
|
520
|
+
},
|
|
521
|
+
);
|
|
522
|
+
};
|
|
523
|
+
for (const key of Reflect.ownKeys(orig)) {
|
|
524
|
+
if (key === 'length' || key === 'name' || key === 'prototype') {
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
const descriptor = Object.getOwnPropertyDescriptor(orig, key);
|
|
528
|
+
if (descriptor) {
|
|
529
|
+
try {
|
|
530
|
+
Object.defineProperty(wrapped, key, descriptor);
|
|
531
|
+
} catch {
|
|
532
|
+
// a static we cannot copy is not worth failing the wrap
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
if ((orig as unknown as Record<symbol, unknown>)[EXPO_BUILTIN] && !(EXPO_BUILTIN in wrapped)) {
|
|
537
|
+
Object.defineProperty(wrapped, EXPO_BUILTIN, { value: true });
|
|
538
|
+
}
|
|
539
|
+
Object.defineProperty(wrapped, WRAPPED, { value: orig });
|
|
540
|
+
stats.fetchWrapped++;
|
|
541
|
+
return wrapped;
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
const patchXhr = (cls: unknown): unknown => {
|
|
545
|
+
const proto = (cls as { prototype?: Record<string, unknown> } | null)?.prototype;
|
|
546
|
+
if (
|
|
547
|
+
!proto ||
|
|
548
|
+
typeof proto.open !== 'function' ||
|
|
549
|
+
typeof proto.send !== 'function' ||
|
|
550
|
+
Object.prototype.hasOwnProperty.call(proto, WRAPPED)
|
|
551
|
+
) {
|
|
552
|
+
return cls;
|
|
553
|
+
}
|
|
554
|
+
const open = proto.open as AnyFn;
|
|
555
|
+
const send = proto.send as AnyFn;
|
|
556
|
+
proto.open = function (this: XhrLike, ...args: unknown[]) {
|
|
557
|
+
const viaFetch = fetchDepth > 0;
|
|
558
|
+
if (viaFetch) {
|
|
559
|
+
stats.fetchViaXhr++;
|
|
560
|
+
}
|
|
561
|
+
this[XHR_STATE] = {
|
|
562
|
+
skip: viaFetch || ownDepth > 0,
|
|
563
|
+
method: typeof args[0] === 'string' ? args[0] : 'GET',
|
|
564
|
+
url: String(args[1]),
|
|
565
|
+
handle: null,
|
|
566
|
+
};
|
|
567
|
+
return open.apply(this, args);
|
|
568
|
+
};
|
|
569
|
+
proto.send = function (this: XhrLike, ...args: unknown[]) {
|
|
570
|
+
const state = this[XHR_STATE];
|
|
571
|
+
let handle: NetHandle | null = null;
|
|
572
|
+
if (state && !state.skip && !state.handle && deps.enabled()) {
|
|
573
|
+
try {
|
|
574
|
+
handle = counter.start(state.method, state.url, 'xhr');
|
|
575
|
+
state.handle = handle;
|
|
576
|
+
const h = handle;
|
|
577
|
+
// loadend follows load, error, abort and timeout.
|
|
578
|
+
const done = () => {
|
|
579
|
+
this.removeEventListener?.('loadend', done);
|
|
580
|
+
const status = typeof this.status === 'number' ? this.status : 0;
|
|
581
|
+
counter.end(h, status, status === 0);
|
|
582
|
+
};
|
|
583
|
+
this.addEventListener?.('loadend', done);
|
|
584
|
+
} catch (error) {
|
|
585
|
+
fail(error);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
try {
|
|
589
|
+
return send.apply(this, args);
|
|
590
|
+
} catch (error) {
|
|
591
|
+
if (handle) {
|
|
592
|
+
counter.end(handle, 0, true);
|
|
593
|
+
}
|
|
594
|
+
throw error;
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
Object.defineProperty(proto, WRAPPED, { value: true });
|
|
598
|
+
stats.xhrPatched++;
|
|
599
|
+
return cls;
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
// Wraps a global without evaluating it: React Native and Expo install fetch and
|
|
603
|
+
// XMLHttpRequest as lazy getters, so a getter is replaced by one that wraps the
|
|
604
|
+
// value on first read. Called again later because Expo redefines fetch after boot.
|
|
605
|
+
const ensureGlobal = (name: string, wrap: (value: unknown) => unknown) => {
|
|
606
|
+
const descriptor = Object.getOwnPropertyDescriptor(target, name);
|
|
607
|
+
if (!descriptor) {
|
|
608
|
+
return 'absent';
|
|
609
|
+
}
|
|
610
|
+
if (descriptor.get) {
|
|
611
|
+
if ((descriptor.get as unknown as Record<string, unknown>)[WRAPPED]) {
|
|
612
|
+
return 'lazy';
|
|
613
|
+
}
|
|
614
|
+
if (!descriptor.configurable) {
|
|
615
|
+
stats.locked.push(name);
|
|
616
|
+
return 'locked';
|
|
617
|
+
}
|
|
618
|
+
const originalGet = descriptor.get;
|
|
619
|
+
const enumerable = descriptor.enumerable;
|
|
620
|
+
const settle = (value: unknown) => {
|
|
621
|
+
const wrapped = value ? wrap(value) : value;
|
|
622
|
+
Object.defineProperty(target, name, {
|
|
623
|
+
value: wrapped,
|
|
624
|
+
writable: true,
|
|
625
|
+
enumerable,
|
|
626
|
+
configurable: true,
|
|
627
|
+
});
|
|
628
|
+
return wrapped;
|
|
629
|
+
};
|
|
630
|
+
const get = function () {
|
|
631
|
+
stats.lazyWraps++;
|
|
632
|
+
return settle(originalGet.call(target));
|
|
633
|
+
};
|
|
634
|
+
Object.defineProperty(get, WRAPPED, { value: true });
|
|
635
|
+
Object.defineProperty(target, name, {
|
|
636
|
+
get,
|
|
637
|
+
set: settle,
|
|
638
|
+
enumerable,
|
|
639
|
+
configurable: true,
|
|
640
|
+
});
|
|
641
|
+
return 'lazy';
|
|
642
|
+
}
|
|
643
|
+
const value = descriptor.value;
|
|
644
|
+
const wrapped = value ? wrap(value) : value;
|
|
645
|
+
if (wrapped === value) {
|
|
646
|
+
return 'value';
|
|
647
|
+
}
|
|
648
|
+
if (descriptor.writable) {
|
|
649
|
+
target[name] = wrapped;
|
|
650
|
+
} else if (descriptor.configurable) {
|
|
651
|
+
Object.defineProperty(target, name, { ...descriptor, value: wrapped });
|
|
652
|
+
} else {
|
|
653
|
+
stats.locked.push(name);
|
|
654
|
+
return 'locked';
|
|
655
|
+
}
|
|
656
|
+
return 'value';
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
const ensure = () => {
|
|
660
|
+
try {
|
|
661
|
+
return {
|
|
662
|
+
fetch: ensureGlobal('fetch', wrapFetch),
|
|
663
|
+
xhr: ensureGlobal('XMLHttpRequest', patchXhr),
|
|
664
|
+
};
|
|
665
|
+
} catch (error) {
|
|
666
|
+
fail(error);
|
|
667
|
+
return null;
|
|
668
|
+
}
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
// Runs `run` as SDK traffic: neither fetch nor XHR counts what it starts synchronously.
|
|
672
|
+
const own = <T>(run: () => T): T => {
|
|
673
|
+
ownDepth++;
|
|
674
|
+
try {
|
|
675
|
+
return run();
|
|
676
|
+
} finally {
|
|
677
|
+
ownDepth--;
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
return { ensure, own, stats: () => ({ ...stats, locked: [...stats.locked] }) };
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
export type NetHooks = ReturnType<typeof createNetHooks>;
|