@parallax-rn/sdk 0.0.0-stage → 0.1.0-beta.2
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/LICENSE +72 -0
- package/README.md +31 -2
- package/dist/commands.d.ts +15 -0
- package/dist/commands.js +50 -0
- package/dist/console-capture.d.ts +7 -0
- package/dist/console-capture.js +56 -0
- package/dist/error-capture.d.ts +4 -0
- package/dist/error-capture.js +31 -0
- package/dist/format.d.ts +10 -0
- package/dist/format.js +92 -0
- package/dist/host.d.ts +19 -0
- package/dist/host.js +8 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +32 -0
- package/dist/limits.d.ts +31 -0
- package/dist/limits.js +24 -0
- package/dist/navigation.d.ts +26 -0
- package/dist/navigation.js +153 -0
- package/dist/network-capture.d.ts +13 -0
- package/dist/network-capture.js +375 -0
- package/dist/network-redact.d.ts +13 -0
- package/dist/network-redact.js +108 -0
- package/dist/push.d.ts +36 -0
- package/dist/push.js +130 -0
- package/dist/ring-buffer.d.ts +26 -0
- package/dist/ring-buffer.js +46 -0
- package/dist/sdk.d.ts +35 -0
- package/dist/sdk.js +223 -0
- package/dist/snapshot.d.ts +9 -0
- package/dist/snapshot.js +151 -0
- package/dist/stores.d.ts +25 -0
- package/dist/stores.js +220 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.js +5 -0
- package/package.json +19 -4
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NAV_LIMITS = void 0;
|
|
4
|
+
exports.scrubParams = scrubParams;
|
|
5
|
+
exports.scrubPath = scrubPath;
|
|
6
|
+
exports.entryOf = entryOf;
|
|
7
|
+
exports.followNavigation = followNavigation;
|
|
8
|
+
// The `navigation` source (plan 0013, 13.6): the state of a React Navigation container, read through its ref (duck-typed: `getRootState`, `addListener('state', fn)`; the SDK
|
|
9
|
+
// never imports the library). `entryOf` is the SDK copy of the reader the server injects without the SDK (`core/src/app/nav/nav-script.ts`); a test runs both on the same states.
|
|
10
|
+
const protocol_1 = require("@parallax-rn/protocol");
|
|
11
|
+
exports.NAV_LIMITS = { depth: 8, routes: 50, paramDepth: 3, keys: 30, chars: 200, rootStateChars: 16000 };
|
|
12
|
+
/** Params without secrets: keys by the picker's word rule, values shaped like a credential, long strings cut, depth and width capped, getters and functions never run. */
|
|
13
|
+
function scrubParams(value, depth = 0, seen = []) {
|
|
14
|
+
if (typeof value === 'string')
|
|
15
|
+
return (0, protocol_1.looksRandom)(value) ? protocol_1.REDACTED_TEXT : value.length > exports.NAV_LIMITS.chars ? `${value.slice(0, exports.NAV_LIMITS.chars)}…` : value;
|
|
16
|
+
if (typeof value === 'number')
|
|
17
|
+
return Number.isFinite(value) ? value : String(value);
|
|
18
|
+
if (typeof value === 'boolean' || value === null || value === undefined)
|
|
19
|
+
return value;
|
|
20
|
+
if (typeof value === 'function')
|
|
21
|
+
return '[function]';
|
|
22
|
+
if (typeof value !== 'object')
|
|
23
|
+
return String(value);
|
|
24
|
+
if (seen.indexOf(value) >= 0)
|
|
25
|
+
return '[circular]';
|
|
26
|
+
const isArray = Array.isArray(value);
|
|
27
|
+
if (depth >= exports.NAV_LIMITS.paramDepth)
|
|
28
|
+
return isArray ? `[array ${value.length}]` : '[object]';
|
|
29
|
+
seen.push(value);
|
|
30
|
+
const keys = Object.keys(value);
|
|
31
|
+
const out = {};
|
|
32
|
+
const list = [];
|
|
33
|
+
for (let i = 0; i < keys.length && i < exports.NAV_LIMITS.keys; i++) {
|
|
34
|
+
const key = keys[i];
|
|
35
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
36
|
+
const item = !descriptor || !('value' in descriptor) ? '[getter]' : !isArray && (0, protocol_1.isSecretKey)(key) ? protocol_1.REDACTED_TEXT : scrubParams(descriptor.value, depth + 1, seen);
|
|
37
|
+
if (isArray)
|
|
38
|
+
list.push(item);
|
|
39
|
+
else
|
|
40
|
+
out[key] = item;
|
|
41
|
+
}
|
|
42
|
+
seen.pop();
|
|
43
|
+
if (keys.length > exports.NAV_LIMITS.keys) {
|
|
44
|
+
const more = `… ${keys.length - exports.NAV_LIMITS.keys} more`;
|
|
45
|
+
if (isArray)
|
|
46
|
+
list.push(more);
|
|
47
|
+
else
|
|
48
|
+
out['…'] = more;
|
|
49
|
+
}
|
|
50
|
+
return isArray ? list : out;
|
|
51
|
+
}
|
|
52
|
+
/** A route's `path` (a deep link) with the value of every secret-looking query parameter masked. */
|
|
53
|
+
function scrubPath(path) {
|
|
54
|
+
const at = path.indexOf('?');
|
|
55
|
+
if (at < 0)
|
|
56
|
+
return path;
|
|
57
|
+
const pairs = path.slice(at + 1).split('&');
|
|
58
|
+
const masked = pairs.map(pair => {
|
|
59
|
+
const eq = pair.indexOf('=');
|
|
60
|
+
return eq < 0 || !((0, protocol_1.isSecretKey)(pair.slice(0, eq)) || (0, protocol_1.looksRandom)(pair.slice(eq + 1))) ? pair : `${pair.slice(0, eq)}=${protocol_1.REDACTED_TEXT}`;
|
|
61
|
+
});
|
|
62
|
+
return `${path.slice(0, at)}?${masked.join('&')}`;
|
|
63
|
+
}
|
|
64
|
+
const isObject = (value) => typeof value === 'object' && value !== null;
|
|
65
|
+
function reduceState(state, depth) {
|
|
66
|
+
const routes = state.routes;
|
|
67
|
+
if (!Array.isArray(routes) || depth >= exports.NAV_LIMITS.depth)
|
|
68
|
+
return null;
|
|
69
|
+
const out = { index: typeof state.index === 'number' ? state.index : routes.length - 1, routes: [] };
|
|
70
|
+
if (typeof state.type === 'string')
|
|
71
|
+
out.type = state.type;
|
|
72
|
+
for (let i = 0; i < routes.length && i < exports.NAV_LIMITS.routes; i++) {
|
|
73
|
+
const route = routes[i];
|
|
74
|
+
if (!isObject(route))
|
|
75
|
+
continue;
|
|
76
|
+
const reduced = { name: typeof route.name === 'string' ? route.name : '', key: typeof route.key === 'string' ? route.key : '' };
|
|
77
|
+
if (route.params !== undefined)
|
|
78
|
+
reduced.params = scrubParams(route.params);
|
|
79
|
+
if (typeof route.path === 'string')
|
|
80
|
+
reduced.path = scrubPath(route.path);
|
|
81
|
+
const nested = isObject(route.state) ? reduceState(route.state, depth + 1) : null;
|
|
82
|
+
if (nested)
|
|
83
|
+
reduced.state = nested;
|
|
84
|
+
out.routes.push(reduced);
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
/** The entry for one root state: the focused route (the deepest one following each navigator's `index`), its params and path, the route names from the root, the reduced state (dropped past 16,000 characters). Null: no routes. */
|
|
89
|
+
function entryOf(rootState) {
|
|
90
|
+
var _a;
|
|
91
|
+
if (!isObject(rootState))
|
|
92
|
+
return null;
|
|
93
|
+
const root = reduceState(rootState, 0);
|
|
94
|
+
if (!root || root.routes.length === 0)
|
|
95
|
+
return null;
|
|
96
|
+
const stack = [];
|
|
97
|
+
let focused = null;
|
|
98
|
+
for (let node = root; node;) {
|
|
99
|
+
focused = (_a = node.routes[node.index]) !== null && _a !== void 0 ? _a : null;
|
|
100
|
+
if (!focused)
|
|
101
|
+
break;
|
|
102
|
+
stack.push(focused.name);
|
|
103
|
+
node = focused.state;
|
|
104
|
+
}
|
|
105
|
+
if (!focused)
|
|
106
|
+
return null;
|
|
107
|
+
const entry = { kind: 'state', route: focused.name, stack };
|
|
108
|
+
if (focused.params !== undefined)
|
|
109
|
+
entry.params = focused.params;
|
|
110
|
+
if (focused.path !== undefined)
|
|
111
|
+
entry.path = focused.path;
|
|
112
|
+
if (JSON.stringify(root).length <= exports.NAV_LIMITS.rootStateChars)
|
|
113
|
+
entry.rootState = root;
|
|
114
|
+
return entry;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Follows a container ref: `emit` gets an entry now and after every state change that differs from the last one. Returns the stop function. A ref that throws is ignored (the
|
|
118
|
+
* app must never see the SDK fail).
|
|
119
|
+
*/
|
|
120
|
+
function followNavigation(ref, emit) {
|
|
121
|
+
let last = '';
|
|
122
|
+
const read = () => {
|
|
123
|
+
try {
|
|
124
|
+
const entry = entryOf(ref.getRootState());
|
|
125
|
+
const text = entry ? JSON.stringify(entry) : '';
|
|
126
|
+
if (entry && text !== last) {
|
|
127
|
+
last = text;
|
|
128
|
+
emit({ ...entry, producer: 'adapter' });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
/* the app's navigation must never break because of us */
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
let off;
|
|
136
|
+
try {
|
|
137
|
+
off = ref.addListener('state', read);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
off = undefined;
|
|
141
|
+
}
|
|
142
|
+
read();
|
|
143
|
+
return () => {
|
|
144
|
+
if (typeof off === 'function') {
|
|
145
|
+
try {
|
|
146
|
+
off();
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
/* already gone */
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { NetworkEntry } from '@parallax-rn/protocol';
|
|
2
|
+
import type { HostGlobal } from './host.js';
|
|
3
|
+
import type { Installed } from './console-capture.js';
|
|
4
|
+
export interface NetworkCaptureOptions {
|
|
5
|
+
readonly now: () => number;
|
|
6
|
+
/** The most characters of a request or response body kept; a longer one is cut (`responseBodyState: 'cut'`) and its size stays in `*BodyBytes`. */
|
|
7
|
+
readonly bodyChars: number;
|
|
8
|
+
readonly headerChars: number;
|
|
9
|
+
readonly stackChars: number;
|
|
10
|
+
onEntry(entry: NetworkEntry, time: number): void;
|
|
11
|
+
}
|
|
12
|
+
export declare function installNetworkCapture(host: HostGlobal, options: NetworkCaptureOptions): Installed | null;
|
|
13
|
+
//# sourceMappingURL=network-capture.d.ts.map
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.installNetworkCapture = installNetworkCapture;
|
|
4
|
+
const format_js_1 = require("./format.js");
|
|
5
|
+
const network_redact_js_1 = require("./network-redact.js");
|
|
6
|
+
/** The SDK's own wrappers (named so, apart from the app's `open`), and the engine's frames, come first in a stack taken inside them; the app's call is the first frame after. */
|
|
7
|
+
const OWN_FRAME = /^\s*at (\S+\.)?prn(Begin|Open|Fetch)\b|InternalBytecode|\(native\)/;
|
|
8
|
+
function callSite(stack, max) {
|
|
9
|
+
if (!stack)
|
|
10
|
+
return undefined;
|
|
11
|
+
const lines = stack.split('\n').slice(1);
|
|
12
|
+
let first = 0;
|
|
13
|
+
while (first < lines.length && OWN_FRAME.test(lines[first]))
|
|
14
|
+
first++;
|
|
15
|
+
return (0, format_js_1.cap)(lines.slice(first).join('\n'), max).text;
|
|
16
|
+
}
|
|
17
|
+
const BINARY = /^(image|audio|video|font)\/|\/(octet-stream|pdf|zip|gzip|x-protobuf|wasm)$/i;
|
|
18
|
+
const STREAM = /event-stream/i;
|
|
19
|
+
function parseHeaders(raw) {
|
|
20
|
+
const out = {};
|
|
21
|
+
if (!raw)
|
|
22
|
+
return out;
|
|
23
|
+
const lines = raw.split(/\r?\n/);
|
|
24
|
+
for (let i = 0; i < lines.length; i++) {
|
|
25
|
+
const colon = lines[i].indexOf(':');
|
|
26
|
+
if (colon <= 0)
|
|
27
|
+
continue;
|
|
28
|
+
const name = lines[i].slice(0, colon).trim();
|
|
29
|
+
const value = lines[i].slice(colon + 1).trim();
|
|
30
|
+
out[name] = name in out ? `${out[name]}, ${value}` : value;
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
const headerOf = (headers, wanted) => {
|
|
35
|
+
const names = Object.keys(headers);
|
|
36
|
+
for (let i = 0; i < names.length; i++)
|
|
37
|
+
if (names[i].toLowerCase() === wanted)
|
|
38
|
+
return headers[names[i]];
|
|
39
|
+
return undefined;
|
|
40
|
+
};
|
|
41
|
+
/** The size and text of a request body as the app handed it to `send` / `fetch`: a string is the text; FormData, Blob and ArrayBuffer are described, never read. */
|
|
42
|
+
function describeBody(body) {
|
|
43
|
+
if (body === undefined || body === null)
|
|
44
|
+
return {};
|
|
45
|
+
if (typeof body === 'string')
|
|
46
|
+
return { text: body, bytes: body.length };
|
|
47
|
+
const b = body;
|
|
48
|
+
if (Array.isArray(b._parts))
|
|
49
|
+
return { text: `[FormData ${b._parts.length} parts]` };
|
|
50
|
+
if (typeof b.size === 'number')
|
|
51
|
+
return { text: `[Blob ${b.size} bytes]`, bytes: b.size };
|
|
52
|
+
if (typeof b.byteLength === 'number')
|
|
53
|
+
return { text: `[binary ${b.byteLength} bytes]`, bytes: b.byteLength };
|
|
54
|
+
if (typeof b.append === 'function' && typeof b.toString === 'function')
|
|
55
|
+
return { text: b.toString() }; // URLSearchParams
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
function installNetworkCapture(host, options) {
|
|
59
|
+
const proto = typeof host.XMLHttpRequest === 'function' ? host.XMLHttpRequest.prototype : undefined;
|
|
60
|
+
const hasXhr = !!proto && typeof proto.open === 'function' && typeof proto.setRequestHeader === 'function' && typeof proto.send === 'function';
|
|
61
|
+
const originalFetch = typeof host.fetch === 'function' ? host.fetch : undefined;
|
|
62
|
+
if (!hasXhr && !originalFetch)
|
|
63
|
+
return null;
|
|
64
|
+
const pending = new WeakMap();
|
|
65
|
+
const restores = [];
|
|
66
|
+
let active = true;
|
|
67
|
+
let counter = 0;
|
|
68
|
+
let xhrSends = 0;
|
|
69
|
+
let fetchSite; // the caller of a `fetch` while it runs: React Native's fetch opens its XHR from inside its own frames
|
|
70
|
+
function emit(p, fields) {
|
|
71
|
+
var _a;
|
|
72
|
+
if (!active)
|
|
73
|
+
return;
|
|
74
|
+
try {
|
|
75
|
+
const entry = { id: p.id, method: p.method, url: (0, network_redact_js_1.redactUrl)(p.url), startedAt: p.startedAt, via: 'sdk', ...fields };
|
|
76
|
+
if (p.initiator !== undefined)
|
|
77
|
+
entry.initiator = p.initiator;
|
|
78
|
+
if (Object.keys(p.headers).length > 0)
|
|
79
|
+
entry.requestHeaders = p.headers;
|
|
80
|
+
if (p.requestBody !== undefined) {
|
|
81
|
+
const body = (0, format_js_1.cap)(p.requestBody, options.bodyChars);
|
|
82
|
+
entry.requestBody = body.text;
|
|
83
|
+
entry.requestBodyBytes = (_a = p.requestBodyBytes) !== null && _a !== void 0 ? _a : p.requestBody.length;
|
|
84
|
+
}
|
|
85
|
+
options.onEntry(entry, options.now());
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
/* capture must never break the app */
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function prnBegin(method, url) {
|
|
92
|
+
return {
|
|
93
|
+
id: String(++counter),
|
|
94
|
+
method: typeof method === 'string' ? method.toUpperCase() : 'GET',
|
|
95
|
+
url: typeof url === 'string' ? url : String(url),
|
|
96
|
+
startedAt: options.now(),
|
|
97
|
+
initiator: fetchSite !== null && fetchSite !== void 0 ? fetchSite : callSite(new Error().stack, options.stackChars),
|
|
98
|
+
headers: {},
|
|
99
|
+
requestBody: undefined,
|
|
100
|
+
requestBodyBytes: undefined,
|
|
101
|
+
contentType: '',
|
|
102
|
+
ended: false,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function setBody(p, body) {
|
|
106
|
+
const described = describeBody(body);
|
|
107
|
+
if (described.text === undefined)
|
|
108
|
+
return;
|
|
109
|
+
p.requestBody = (0, network_redact_js_1.redactBody)(described.text.length > 256 * 1024 ? described.text.slice(0, 256 * 1024) : described.text, p.contentType);
|
|
110
|
+
if (described.bytes !== undefined)
|
|
111
|
+
p.requestBodyBytes = described.bytes;
|
|
112
|
+
}
|
|
113
|
+
function setResponse(fields, headers, text, bytes) {
|
|
114
|
+
var _a;
|
|
115
|
+
fields.responseHeaders = (0, network_redact_js_1.redactHeaders)(headers, options.headerChars);
|
|
116
|
+
const type = ((_a = headerOf(headers, 'content-type')) !== null && _a !== void 0 ? _a : '').split(';')[0].trim().toLowerCase();
|
|
117
|
+
if (type)
|
|
118
|
+
fields.mimeType = type;
|
|
119
|
+
const length = Number(headerOf(headers, 'content-length'));
|
|
120
|
+
if (Number.isFinite(length) && length >= 0)
|
|
121
|
+
fields.encodedBytes = length;
|
|
122
|
+
if (text === undefined) {
|
|
123
|
+
fields.responseBodyState = BINARY.test(type) ? 'binary' : 'unavailable';
|
|
124
|
+
if (bytes !== undefined)
|
|
125
|
+
fields.responseBodyBytes = bytes;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
fields.responseBodyBytes = bytes !== null && bytes !== void 0 ? bytes : text.length;
|
|
129
|
+
if (BINARY.test(type)) {
|
|
130
|
+
fields.responseBodyState = 'binary';
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const body = (0, format_js_1.cap)((0, network_redact_js_1.redactBody)(text.length > 256 * 1024 ? text.slice(0, 256 * 1024) : text, type), options.bodyChars);
|
|
134
|
+
fields.responseBody = body.text;
|
|
135
|
+
fields.responseBodyState = body.truncated || text.length > 256 * 1024 ? 'cut' : 'kept';
|
|
136
|
+
}
|
|
137
|
+
function finish(self, p, kind) {
|
|
138
|
+
if (p.ended)
|
|
139
|
+
return;
|
|
140
|
+
p.ended = true;
|
|
141
|
+
try {
|
|
142
|
+
describe(self, p, kind);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
/* capture must never break the app */
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function describe(self, p, kind) {
|
|
149
|
+
var _a, _b, _c;
|
|
150
|
+
var _d;
|
|
151
|
+
const fields = { durationMs: Math.max(0, options.now() - p.startedAt) };
|
|
152
|
+
if (kind === 'abort')
|
|
153
|
+
fields.canceled = true;
|
|
154
|
+
if (kind === 'error' || kind === 'timeout') {
|
|
155
|
+
fields.failed = true;
|
|
156
|
+
fields.errorText = kind === 'timeout' ? 'timeout' : 'network error';
|
|
157
|
+
}
|
|
158
|
+
if (self.status > 0) {
|
|
159
|
+
fields.status = self.status;
|
|
160
|
+
if (self.statusText)
|
|
161
|
+
fields.statusText = self.statusText;
|
|
162
|
+
let text;
|
|
163
|
+
let bytes;
|
|
164
|
+
const type = self.responseType;
|
|
165
|
+
if (type === '' || type === 'text')
|
|
166
|
+
text = typeof self.responseText === 'string' ? self.responseText : undefined;
|
|
167
|
+
else if (type === 'json')
|
|
168
|
+
text = self.response === undefined || self.response === null ? undefined : JSON.stringify(self.response);
|
|
169
|
+
else
|
|
170
|
+
bytes = typeof ((_a = self.response) === null || _a === void 0 ? void 0 : _a.size) === 'number' ? self.response.size : (_b = self.response) === null || _b === void 0 ? void 0 : _b.byteLength;
|
|
171
|
+
const headers = parseHeaders((_c = self.getAllResponseHeaders) === null || _c === void 0 ? void 0 : _c.call(self));
|
|
172
|
+
// React Native's fetch is an XHR with responseType 'blob' (L27: every fetch body was empty): a text body is read back from the Blob before the entry is emitted
|
|
173
|
+
const Reader = host.FileReader;
|
|
174
|
+
const mime = ((_d = headerOf(headers, 'content-type')) !== null && _d !== void 0 ? _d : '').split(';')[0].trim().toLowerCase();
|
|
175
|
+
if (type === 'blob' && typeof Reader === 'function' && self.response && !BINARY.test(mime) && (bytes !== null && bytes !== void 0 ? bytes : 0) <= 1024 * 1024) {
|
|
176
|
+
try {
|
|
177
|
+
const reader = new Reader();
|
|
178
|
+
const done = (body) => {
|
|
179
|
+
setResponse(fields, headers, body, bytes);
|
|
180
|
+
emit(p, fields);
|
|
181
|
+
};
|
|
182
|
+
reader.onload = () => done(typeof reader.result === 'string' ? reader.result : undefined);
|
|
183
|
+
reader.onerror = () => done(undefined);
|
|
184
|
+
reader.readAsText(self.response);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
/* fall through: the entry without its body */
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
setResponse(fields, headers, text, bytes);
|
|
192
|
+
}
|
|
193
|
+
emit(p, fields);
|
|
194
|
+
}
|
|
195
|
+
if (hasXhr) {
|
|
196
|
+
const original = {
|
|
197
|
+
open: proto.open,
|
|
198
|
+
setRequestHeader: proto.setRequestHeader,
|
|
199
|
+
send: proto.send,
|
|
200
|
+
};
|
|
201
|
+
const prnOpen = function (...args) {
|
|
202
|
+
const result = original.open.apply(this, args);
|
|
203
|
+
if (active) {
|
|
204
|
+
try {
|
|
205
|
+
pending.set(this, prnBegin(args[0], args[1]));
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
/* capture must never break the app */
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return result;
|
|
212
|
+
};
|
|
213
|
+
const setRequestHeader = function (...args) {
|
|
214
|
+
const result = original.setRequestHeader.apply(this, args);
|
|
215
|
+
if (active) {
|
|
216
|
+
try {
|
|
217
|
+
const p = pending.get(this);
|
|
218
|
+
if (p && typeof args[0] === 'string') {
|
|
219
|
+
const value = String(args[1]);
|
|
220
|
+
if (args[0].toLowerCase() === 'content-type')
|
|
221
|
+
p.contentType = value;
|
|
222
|
+
p.headers[args[0]] = args[0] in p.headers ? `${p.headers[args[0]]}, ${value}` : value;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
/* capture must never break the app */
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return result;
|
|
230
|
+
};
|
|
231
|
+
const send = function (...args) {
|
|
232
|
+
xhrSends++;
|
|
233
|
+
if (active) {
|
|
234
|
+
try {
|
|
235
|
+
const self = this;
|
|
236
|
+
const p = pending.get(self);
|
|
237
|
+
if (p && !p.ended && typeof self.addEventListener === 'function') {
|
|
238
|
+
p.startedAt = options.now();
|
|
239
|
+
p.headers = (0, network_redact_js_1.redactHeaders)(p.headers, options.headerChars);
|
|
240
|
+
setBody(p, args[0]);
|
|
241
|
+
self.addEventListener('load', () => finish(self, p, 'load'));
|
|
242
|
+
self.addEventListener('error', () => finish(self, p, 'error'));
|
|
243
|
+
self.addEventListener('abort', () => finish(self, p, 'abort'));
|
|
244
|
+
self.addEventListener('timeout', () => finish(self, p, 'timeout'));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
/* capture must never break the app */
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return original.send.apply(this, args);
|
|
252
|
+
};
|
|
253
|
+
proto.open = prnOpen;
|
|
254
|
+
proto.setRequestHeader = setRequestHeader;
|
|
255
|
+
proto.send = send;
|
|
256
|
+
// someone may have wrapped the prototype after us: then leave their wrapper in place and just stop capturing
|
|
257
|
+
restores.push(() => {
|
|
258
|
+
if (proto.open === prnOpen)
|
|
259
|
+
proto.open = original.open;
|
|
260
|
+
if (proto.setRequestHeader === setRequestHeader)
|
|
261
|
+
proto.setRequestHeader = original.setRequestHeader;
|
|
262
|
+
if (proto.send === send)
|
|
263
|
+
proto.send = original.send;
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
if (originalFetch) {
|
|
267
|
+
const prnFetch = function (...args) {
|
|
268
|
+
const before = xhrSends;
|
|
269
|
+
const startedAt = options.now();
|
|
270
|
+
let result;
|
|
271
|
+
const site = active ? callSite(new Error().stack, options.stackChars) : undefined;
|
|
272
|
+
fetchSite = site;
|
|
273
|
+
try {
|
|
274
|
+
result = originalFetch.apply(this, args);
|
|
275
|
+
}
|
|
276
|
+
finally {
|
|
277
|
+
fetchSite = undefined;
|
|
278
|
+
}
|
|
279
|
+
if (active && xhrSends === before) {
|
|
280
|
+
try {
|
|
281
|
+
trackFetch(result, args, startedAt, site);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
/* capture must never break the app */
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return result;
|
|
288
|
+
};
|
|
289
|
+
host.fetch = prnFetch;
|
|
290
|
+
restores.push(() => {
|
|
291
|
+
if (host.fetch === prnFetch)
|
|
292
|
+
host.fetch = originalFetch;
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
function trackFetch(result, args, startedAt, site) {
|
|
296
|
+
var _a, _b, _c, _d;
|
|
297
|
+
const input = args[0];
|
|
298
|
+
const init = ((_a = args[1]) !== null && _a !== void 0 ? _a : {});
|
|
299
|
+
const p = prnBegin((_b = init.method) !== null && _b !== void 0 ? _b : (typeof input === 'object' ? input === null || input === void 0 ? void 0 : input.method : undefined), typeof input === 'string' ? input : input === null || input === void 0 ? void 0 : input.url);
|
|
300
|
+
p.startedAt = startedAt;
|
|
301
|
+
p.initiator = site; // a fetch that is not an XHR (Expo's native fetch): the call site taken in the wrapper (L27 on the SDK 57 fixture had none)
|
|
302
|
+
const raw = {};
|
|
303
|
+
collectHeaders((_c = init.headers) !== null && _c !== void 0 ? _c : (typeof input === 'object' ? input === null || input === void 0 ? void 0 : input.headers : undefined), raw);
|
|
304
|
+
p.contentType = (_d = headerOf(raw, 'content-type')) !== null && _d !== void 0 ? _d : '';
|
|
305
|
+
p.headers = (0, network_redact_js_1.redactHeaders)(raw, options.headerChars);
|
|
306
|
+
setBody(p, init.body);
|
|
307
|
+
const done = result;
|
|
308
|
+
if (!done || typeof done.then !== 'function')
|
|
309
|
+
return;
|
|
310
|
+
done.then(response => fetched(p, response), error => emit(p, { durationMs: Math.max(0, options.now() - startedAt), failed: true, errorText: error instanceof Error ? error.message : 'network error' }));
|
|
311
|
+
}
|
|
312
|
+
function fetched(p, response) {
|
|
313
|
+
var _a;
|
|
314
|
+
const res = response;
|
|
315
|
+
const headers = {};
|
|
316
|
+
collectHeaders(res.headers, headers);
|
|
317
|
+
const fields = { durationMs: Math.max(0, options.now() - p.startedAt) };
|
|
318
|
+
if (typeof res.status === 'number' && res.status > 0)
|
|
319
|
+
fields.status = res.status;
|
|
320
|
+
if (res.statusText)
|
|
321
|
+
fields.statusText = res.statusText;
|
|
322
|
+
const type = (_a = headerOf(headers, 'content-type')) !== null && _a !== void 0 ? _a : '';
|
|
323
|
+
if (typeof res.clone !== 'function' || STREAM.test(type) || BINARY.test(type)) {
|
|
324
|
+
setResponse(fields, headers, undefined, undefined);
|
|
325
|
+
emit(p, fields);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
// the body is read from a clone, so the app's own response is untouched; the entry waits for it
|
|
329
|
+
let text;
|
|
330
|
+
try {
|
|
331
|
+
text = res.clone().text();
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
setResponse(fields, headers, undefined, undefined);
|
|
335
|
+
emit(p, fields);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
text.then(body => {
|
|
339
|
+
setResponse(fields, headers, body, undefined);
|
|
340
|
+
emit(p, fields);
|
|
341
|
+
}, () => {
|
|
342
|
+
setResponse(fields, headers, undefined, undefined);
|
|
343
|
+
emit(p, fields);
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
return {
|
|
347
|
+
uninstall() {
|
|
348
|
+
active = false;
|
|
349
|
+
for (const restore of restores)
|
|
350
|
+
restore();
|
|
351
|
+
restores.length = 0;
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
/** Headers as a Headers object (`forEach`), an array of pairs or a plain object. */
|
|
356
|
+
function collectHeaders(source, into) {
|
|
357
|
+
if (!source || typeof source !== 'object')
|
|
358
|
+
return;
|
|
359
|
+
const s = source;
|
|
360
|
+
if (typeof s.forEach === 'function' && !Array.isArray(source)) {
|
|
361
|
+
s.forEach((value, name) => {
|
|
362
|
+
into[name] = value;
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
else if (Array.isArray(source)) {
|
|
366
|
+
for (let i = 0; i < source.length; i++)
|
|
367
|
+
if (Array.isArray(source[i]))
|
|
368
|
+
into[String(source[i][0])] = String(source[i][1]);
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
const names = Object.keys(source);
|
|
372
|
+
for (let i = 0; i < names.length; i++)
|
|
373
|
+
into[names[i]] = String(source[names[i]]);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare function redactHeaders(headers: {
|
|
2
|
+
readonly [name: string]: string;
|
|
3
|
+
}, maxChars: number): {
|
|
4
|
+
[name: string]: string;
|
|
5
|
+
};
|
|
6
|
+
/** Userinfo, secret query parameters (by key, or a credential-shaped value) and credential-shaped path segments. Plain string work: `URL` is incomplete in React Native. */
|
|
7
|
+
export declare function redactUrl(url: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* A text body, redacted by what it is: JSON by key (and credential-shaped values), a form by key, anything else by the console-line scrub. `contentType` may be empty; a
|
|
10
|
+
* body that looks like JSON is treated as JSON. A body too big to parse is scrubbed as text. The caller cuts the result to its cap afterwards.
|
|
11
|
+
*/
|
|
12
|
+
export declare function redactBody(text: string, contentType: string): string;
|
|
13
|
+
//# sourceMappingURL=network-redact.d.ts.map
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.redactHeaders = redactHeaders;
|
|
4
|
+
exports.redactUrl = redactUrl;
|
|
5
|
+
exports.redactBody = redactBody;
|
|
6
|
+
// Redaction for the network capture, applied in the app before an entry reaches the ring: nothing secret leaves the app. Everything else stays as captured (the
|
|
7
|
+
// panel shows the detail; the core scrubs again on every path to an agent). The rules are the protocol's (`SECRET_KEY`, `isSecretKey`, `looksRandom`, `scrubLine`) plus the
|
|
8
|
+
// header and URL rules of plan 0013 (Part B, "Scrub"). A cheap guard, not a promise.
|
|
9
|
+
const protocol_1 = require("@parallax-rn/protocol");
|
|
10
|
+
const SECRET_HEADER = /^(authorization|proxy-authorization|cookie|set-cookie|x-api-key|x-auth-.*)$/i;
|
|
11
|
+
const SECRET_PARAM = /^(access_token|sig|signature|code|jwt|key)$/i;
|
|
12
|
+
const JWT = /^eyJ[\w-]+\.[\w-]+\.[\w-]*$/;
|
|
13
|
+
const BEARER = /^(bearer|basic)\s/i;
|
|
14
|
+
/** The most JSON text that is parsed to be redacted by key; a bigger body is cut first and scrubbed as text. */
|
|
15
|
+
const LONG_RUN = /[A-Za-z0-9+/_=-]{200,}/g;
|
|
16
|
+
const MAX_PARSE_CHARS = 256 * 1024;
|
|
17
|
+
const secretName = (name) => protocol_1.SECRET_KEY.test(name) || (0, protocol_1.isSecretKey)(name);
|
|
18
|
+
/** A path segment or value shaped like a credential: a JWT, 32 or more hex characters, or 32 or more mixed-case letters and digits. Wider than `looksRandom` would be for ids. */
|
|
19
|
+
function tokenShaped(value) {
|
|
20
|
+
if (JWT.test(value) || /^[0-9a-f]{32,}$/i.test(value))
|
|
21
|
+
return true;
|
|
22
|
+
return value.length >= 32 && /^[A-Za-z0-9+/_=-]+$/.test(value) && /\d/.test(value) && /[A-Z]/.test(value) && /[a-z]/.test(value);
|
|
23
|
+
}
|
|
24
|
+
function redactHeaders(headers, maxChars) {
|
|
25
|
+
const out = {};
|
|
26
|
+
const names = Object.keys(headers);
|
|
27
|
+
for (let i = 0; i < names.length; i++) {
|
|
28
|
+
const name = names[i];
|
|
29
|
+
const value = headers[name];
|
|
30
|
+
out[name] = SECRET_HEADER.test(name) || secretName(name) || BEARER.test(value) || (0, protocol_1.looksRandom)(value) ? protocol_1.REDACTED_TEXT : value.length > maxChars ? value.slice(0, maxChars) : value;
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
/** Userinfo, secret query parameters (by key, or a credential-shaped value) and credential-shaped path segments. Plain string work: `URL` is incomplete in React Native. */
|
|
35
|
+
function redactUrl(url) {
|
|
36
|
+
const hash = url.indexOf('#');
|
|
37
|
+
const fragment = hash === -1 ? '' : url.slice(hash);
|
|
38
|
+
const rest = hash === -1 ? url : url.slice(0, hash);
|
|
39
|
+
const q = rest.indexOf('?');
|
|
40
|
+
let base = q === -1 ? rest : rest.slice(0, q);
|
|
41
|
+
const query = q === -1 ? '' : rest.slice(q + 1);
|
|
42
|
+
base = base.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@]*@/i, `$1${protocol_1.REDACTED_TEXT}@`);
|
|
43
|
+
const schemeEnd = base.indexOf('://');
|
|
44
|
+
const pathStart = schemeEnd === -1 ? 0 : base.indexOf('/', schemeEnd + 3);
|
|
45
|
+
if (pathStart !== -1) {
|
|
46
|
+
const segments = base.slice(pathStart).split('/');
|
|
47
|
+
for (let i = 0; i < segments.length; i++)
|
|
48
|
+
if (tokenShaped(segments[i]))
|
|
49
|
+
segments[i] = protocol_1.REDACTED_TEXT;
|
|
50
|
+
base = base.slice(0, pathStart) + segments.join('/');
|
|
51
|
+
}
|
|
52
|
+
return base + (q === -1 ? '' : `?${redactPairs(query, 'param')}`) + fragment;
|
|
53
|
+
}
|
|
54
|
+
function decode(text) {
|
|
55
|
+
try {
|
|
56
|
+
return decodeURIComponent(text.replace(/\+/g, ' '));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return text;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** `a=1&b=2`: a pair goes by its key (parameters also by the extra names of a URL) or by a credential-shaped value. */
|
|
63
|
+
function redactPairs(text, kind) {
|
|
64
|
+
const pairs = text.split('&');
|
|
65
|
+
for (let i = 0; i < pairs.length; i++) {
|
|
66
|
+
const eq = pairs[i].indexOf('=');
|
|
67
|
+
if (eq === -1)
|
|
68
|
+
continue;
|
|
69
|
+
const key = pairs[i].slice(0, eq);
|
|
70
|
+
const value = pairs[i].slice(eq + 1);
|
|
71
|
+
const name = decode(key);
|
|
72
|
+
if (secretName(name) || (kind === 'param' && SECRET_PARAM.test(name)) || (0, protocol_1.looksRandom)(decode(value)))
|
|
73
|
+
pairs[i] = `${key}=${protocol_1.REDACTED_TEXT}`;
|
|
74
|
+
}
|
|
75
|
+
return pairs.join('&');
|
|
76
|
+
}
|
|
77
|
+
function redactValue(value, depth) {
|
|
78
|
+
if (typeof value === 'string')
|
|
79
|
+
return (0, protocol_1.looksRandom)(value) ? protocol_1.REDACTED_TEXT : value;
|
|
80
|
+
if (depth > 20 || value === null || typeof value !== 'object')
|
|
81
|
+
return value;
|
|
82
|
+
if (Array.isArray(value))
|
|
83
|
+
return value.map(item => redactValue(item, depth + 1));
|
|
84
|
+
const out = {};
|
|
85
|
+
const keys = Object.keys(value);
|
|
86
|
+
for (let i = 0; i < keys.length; i++)
|
|
87
|
+
out[keys[i]] = secretName(keys[i]) ? protocol_1.REDACTED_TEXT : redactValue(value[keys[i]], depth + 1);
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* A text body, redacted by what it is: JSON by key (and credential-shaped values), a form by key, anything else by the console-line scrub. `contentType` may be empty; a
|
|
92
|
+
* body that looks like JSON is treated as JSON. A body too big to parse is scrubbed as text. The caller cuts the result to its cap afterwards.
|
|
93
|
+
*/
|
|
94
|
+
function redactBody(text, contentType) {
|
|
95
|
+
const trimmed = text.trimStart();
|
|
96
|
+
if (text.length <= MAX_PARSE_CHARS && (/json/i.test(contentType) || trimmed.charAt(0) === '{' || trimmed.charAt(0) === '[')) {
|
|
97
|
+
try {
|
|
98
|
+
return JSON.stringify(redactValue(JSON.parse(text), 0));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
/* not JSON after all: scrubbed as text below */
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (/x-www-form-urlencoded/i.test(contentType))
|
|
105
|
+
return redactPairs(text, 'form');
|
|
106
|
+
// the console-line scrub backtracks on a very long run of one word (quadratic): such a run is no readable text, it goes first
|
|
107
|
+
return (0, protocol_1.scrubLine)(text.replace(LONG_RUN, protocol_1.REDACTED_TEXT));
|
|
108
|
+
}
|