@reticlehq/browser 3.7.0 → 3.8.1
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/action-report.d.ts +10 -0
- package/dist/actions/action-report.js +59 -14
- package/dist/actions/actions-dom.d.ts +4 -0
- package/dist/actions/actions-dom.js +20 -4
- package/dist/actions/actions.d.ts +1 -0
- package/dist/actions/actions.js +94 -46
- package/dist/actions/appeared-text.js +2 -2
- package/dist/actions/click-target.d.ts +1 -0
- package/dist/actions/click-target.js +14 -0
- package/dist/actions/danger-context.d.ts +2 -1
- package/dist/actions/danger-context.js +43 -17
- package/dist/actions/scroll.d.ts +2 -0
- package/dist/actions/scroll.js +10 -2
- package/dist/activate.d.ts +14 -0
- package/dist/activate.js +43 -0
- package/dist/busy.d.ts +3 -0
- package/dist/busy.js +42 -0
- package/dist/commands/commands.js +45 -17
- package/dist/connect-options.d.ts +12 -1
- package/dist/connect-options.js +18 -1
- package/dist/dom/a11y.d.ts +0 -2
- package/dist/dom/a11y.js +103 -363
- package/dist/dom/chart.d.ts +3 -2
- package/dist/dom/chart.js +3 -2
- package/dist/dom/dom-ignore.d.ts +6 -0
- package/dist/dom/dom-ignore.js +60 -5
- package/dist/dom/layout-audit.d.ts +15 -0
- package/dist/dom/layout-audit.js +208 -0
- package/dist/dom/paint-context.d.ts +15 -0
- package/dist/dom/paint-context.js +78 -0
- package/dist/dom/query-finders.d.ts +6 -0
- package/dist/dom/query-finders.js +216 -0
- package/dist/dom/query.d.ts +1 -1
- package/dist/dom/query.js +154 -227
- package/dist/dom/shadow-registry.d.ts +1 -0
- package/dist/dom/shadow-registry.js +27 -0
- package/dist/dom/snapshot.d.ts +5 -0
- package/dist/dom/snapshot.js +87 -35
- package/dist/dom/stable-selector.d.ts +1 -0
- package/dist/dom/stable-selector.js +19 -0
- package/dist/dom/text-match.d.ts +12 -0
- package/dist/dom/text-match.js +115 -0
- package/dist/dom/theme.d.ts +1 -0
- package/dist/dom/theme.js +21 -9
- package/dist/dom/virtualized.js +5 -1
- package/dist/dom/visibility.d.ts +7 -0
- package/dist/dom/visibility.js +322 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +7 -1
- package/dist/observers/blind-spots.js +3 -2
- package/dist/observers/canvas.d.ts +18 -0
- package/dist/observers/canvas.js +76 -0
- package/dist/observers/dom.js +68 -19
- package/dist/observers/field.d.ts +11 -0
- package/dist/observers/field.js +117 -13
- package/dist/observers/install-all.js +4 -0
- package/dist/observers/media.d.ts +2 -0
- package/dist/observers/media.js +32 -0
- package/dist/observers/net-detail/network-body.d.ts +2 -0
- package/dist/observers/net-detail/network-body.js +36 -9
- package/dist/observers/net-detail/network-event-stream.d.ts +5 -0
- package/dist/observers/net-detail/network-event-stream.js +55 -0
- package/dist/observers/net-detail/network-frames.d.ts +2 -0
- package/dist/observers/net-detail/network-frames.js +39 -0
- package/dist/observers/net-detail/network-response-body.d.ts +3 -0
- package/dist/observers/net-detail/network-response-body.js +97 -0
- package/dist/observers/network.js +111 -101
- package/dist/observers/route.d.ts +2 -0
- package/dist/observers/route.js +33 -4
- package/dist/presenter/chrome/presenter-report-copy.d.ts +0 -5
- package/dist/presenter/chrome/presenter-report-copy.js +0 -14
- package/dist/presenter/hud-telemetry.js +1 -1
- package/dist/presenter/presenter-chat-views.d.ts +3 -4
- package/dist/presenter/presenter-chat-views.js +47 -20
- package/dist/presenter/presenter-config.d.ts +1 -0
- package/dist/presenter/presenter-config.js +1 -0
- package/dist/presenter/presenter-frame.d.ts +3 -2
- package/dist/presenter/presenter-frame.js +24 -15
- package/dist/presenter/presenter-harness-row.d.ts +4 -15
- package/dist/presenter/presenter-harness-row.js +11 -50
- package/dist/presenter/presenter-report-styles.js +0 -17
- package/dist/presenter/presenter-report.d.ts +1 -2
- package/dist/presenter/presenter-report.js +4 -54
- package/dist/presenter/presenter-run-state.js +2 -1
- package/dist/presenter/presenter-settings.d.ts +2 -3
- package/dist/presenter/presenter-settings.js +14 -17
- package/dist/presenter/presenter-shell.d.ts +6 -4
- package/dist/presenter/presenter-shell.js +37 -7
- package/dist/presenter/presenter.js +32 -11
- package/dist/registry/store-adapters.d.ts +5 -2
- package/dist/registry/store-adapters.js +40 -10
- package/dist/reticle-inject.js +700 -645
- package/dist/reticle.d.ts +4 -2
- package/dist/reticle.js +36 -6
- package/dist/security/page-location.d.ts +2 -0
- package/dist/security/page-location.js +7 -0
- package/dist/security/serialization.js +1 -1
- package/dist/storage-keys.d.ts +1 -0
- package/dist/storage-keys.js +1 -0
- package/dist/timers/native/native-timers.d.ts +1 -0
- package/dist/timers/native/native-timers.js +5 -0
- package/dist/transport/transport.d.ts +1 -0
- package/dist/transport/transport.js +18 -0
- package/package.json +6 -2
package/dist/observers/field.js
CHANGED
|
@@ -1,13 +1,34 @@
|
|
|
1
1
|
import { readTestId } from '../dom/addressing/testid-attr.js';
|
|
2
|
-
import { EventType } from '@reticlehq/core';
|
|
2
|
+
import { EventType, FieldChangeField, FieldChangeKind } from '@reticlehq/core';
|
|
3
3
|
import { getAccessibleName, isSensitiveField } from '../dom/a11y.js';
|
|
4
4
|
const MAX_VALUE_LEN = 160;
|
|
5
5
|
const BURST_MS = 150;
|
|
6
|
+
const CHECKBOX_TYPE = 'checkbox';
|
|
7
|
+
const RADIO_TYPE = 'radio';
|
|
8
|
+
let settledEmit;
|
|
6
9
|
function isField(target) {
|
|
7
10
|
return (target instanceof HTMLInputElement ||
|
|
8
11
|
target instanceof HTMLTextAreaElement ||
|
|
9
12
|
target instanceof HTMLSelectElement);
|
|
10
13
|
}
|
|
14
|
+
function isCheckable(element) {
|
|
15
|
+
return (element instanceof HTMLInputElement &&
|
|
16
|
+
(CHECKBOX_TYPE === element.type || RADIO_TYPE === element.type));
|
|
17
|
+
}
|
|
18
|
+
function readingOf(element) {
|
|
19
|
+
if (isCheckable(element))
|
|
20
|
+
return { value: element.value, checked: element.checked };
|
|
21
|
+
return { value: element.value };
|
|
22
|
+
}
|
|
23
|
+
function readingMoved(before, after) {
|
|
24
|
+
if (before.checked !== undefined || after.checked !== undefined) {
|
|
25
|
+
return before.checked !== after.checked;
|
|
26
|
+
}
|
|
27
|
+
return before.value !== after.value;
|
|
28
|
+
}
|
|
29
|
+
function clip(value) {
|
|
30
|
+
return value.length > MAX_VALUE_LEN ? `${value.slice(0, MAX_VALUE_LEN)}…` : value;
|
|
31
|
+
}
|
|
11
32
|
function fieldName(element) {
|
|
12
33
|
const testid = readTestId(element);
|
|
13
34
|
if (null !== testid && testid.length > 0)
|
|
@@ -20,21 +41,102 @@ function fieldName(element) {
|
|
|
20
41
|
const id = element.getAttribute('id');
|
|
21
42
|
return null !== id && id.length > 0 ? id : element.tagName.toLowerCase();
|
|
22
43
|
}
|
|
44
|
+
function fieldPayload(element, kind, after, before) {
|
|
45
|
+
const secret = isSensitiveField(element);
|
|
46
|
+
const payload = {
|
|
47
|
+
[FieldChangeField.FIELD]: fieldName(element),
|
|
48
|
+
[FieldChangeField.KIND]: kind,
|
|
49
|
+
[FieldChangeField.LENGTH]: after.value.length,
|
|
50
|
+
};
|
|
51
|
+
if (after.checked !== undefined)
|
|
52
|
+
payload[FieldChangeField.CHECKED] = after.checked;
|
|
53
|
+
if (before?.checked !== undefined)
|
|
54
|
+
payload[FieldChangeField.PREVIOUS_CHECKED] = before.checked;
|
|
55
|
+
if (secret) {
|
|
56
|
+
payload[FieldChangeField.REDACTED] = true;
|
|
57
|
+
return payload;
|
|
58
|
+
}
|
|
59
|
+
payload[FieldChangeField.VALUE] = clip(after.value);
|
|
60
|
+
if (before !== undefined && before.checked === undefined) {
|
|
61
|
+
payload[FieldChangeField.PREVIOUS] = clip(before.value);
|
|
62
|
+
}
|
|
63
|
+
return payload;
|
|
64
|
+
}
|
|
65
|
+
function settledProperty(el) {
|
|
66
|
+
return isCheckable(el) ? 'checked' : 'value';
|
|
67
|
+
}
|
|
68
|
+
function inheritedDescriptor(el, prop) {
|
|
69
|
+
for (let proto = Object.getPrototypeOf(el); proto !== null;) {
|
|
70
|
+
const found = Object.getOwnPropertyDescriptor(proto, prop);
|
|
71
|
+
if (found !== undefined)
|
|
72
|
+
return found;
|
|
73
|
+
proto = Object.getPrototypeOf(proto);
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
function watchWrites(el, prop) {
|
|
78
|
+
const own = Object.getOwnPropertyDescriptor(el, prop);
|
|
79
|
+
const target = own ?? inheritedDescriptor(el, prop);
|
|
80
|
+
const get = target === undefined ? undefined : Reflect.get(target, 'get');
|
|
81
|
+
const set = target === undefined ? undefined : Reflect.get(target, 'set');
|
|
82
|
+
let written = false;
|
|
83
|
+
const usable = 'function' === typeof get && 'function' === typeof set;
|
|
84
|
+
if (!usable || false === own?.configurable) {
|
|
85
|
+
return { stop: () => { }, written: () => written };
|
|
86
|
+
}
|
|
87
|
+
Object.defineProperty(el, prop, {
|
|
88
|
+
configurable: true,
|
|
89
|
+
enumerable: target?.enumerable ?? false,
|
|
90
|
+
get() {
|
|
91
|
+
return Reflect.apply(get, this, []);
|
|
92
|
+
},
|
|
93
|
+
set(next) {
|
|
94
|
+
written = true;
|
|
95
|
+
Reflect.apply(set, this, [next]);
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
let stopped = false;
|
|
99
|
+
return {
|
|
100
|
+
stop: () => {
|
|
101
|
+
if (stopped)
|
|
102
|
+
return;
|
|
103
|
+
stopped = true;
|
|
104
|
+
if (own === undefined)
|
|
105
|
+
Reflect.deleteProperty(el, prop);
|
|
106
|
+
else
|
|
107
|
+
Object.defineProperty(el, prop, own);
|
|
108
|
+
},
|
|
109
|
+
written: () => written,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
export function watchActedField(el) {
|
|
113
|
+
if (!isField(el) || el instanceof HTMLSelectElement)
|
|
114
|
+
return undefined;
|
|
115
|
+
return { before: readingOf(el), ...watchWrites(el, settledProperty(el)) };
|
|
116
|
+
}
|
|
117
|
+
export function publishSettledField(el, acted) {
|
|
118
|
+
const emit = settledEmit;
|
|
119
|
+
if (acted === undefined)
|
|
120
|
+
return;
|
|
121
|
+
acted.stop();
|
|
122
|
+
if (emit === undefined || !isField(el) || !el.isConnected || !acted.written())
|
|
123
|
+
return;
|
|
124
|
+
const before = acted.before;
|
|
125
|
+
if (isSensitiveField(el) && before.checked === undefined)
|
|
126
|
+
return;
|
|
127
|
+
const after = readingOf(el);
|
|
128
|
+
if (!readingMoved(before, after))
|
|
129
|
+
return;
|
|
130
|
+
emit(EventType.FIELD_CHANGE, fieldPayload(el, FieldChangeKind.SETTLED, after, before));
|
|
131
|
+
}
|
|
23
132
|
export function installField(emit) {
|
|
24
133
|
const ac = new AbortController();
|
|
25
134
|
const { signal } = ac;
|
|
135
|
+
const previousEmit = settledEmit;
|
|
136
|
+
settledEmit = emit;
|
|
26
137
|
const pending = new Map();
|
|
27
138
|
const report = (element, kind) => {
|
|
28
|
-
|
|
29
|
-
const secret = isSensitiveField(element);
|
|
30
|
-
emit(EventType.FIELD_CHANGE, {
|
|
31
|
-
field: fieldName(element),
|
|
32
|
-
kind,
|
|
33
|
-
length: value.length,
|
|
34
|
-
...(secret
|
|
35
|
-
? { redacted: true }
|
|
36
|
-
: { value: value.length > MAX_VALUE_LEN ? `${value.slice(0, MAX_VALUE_LEN)}…` : value }),
|
|
37
|
-
});
|
|
139
|
+
emit(EventType.FIELD_CHANGE, fieldPayload(element, kind, readingOf(element)));
|
|
38
140
|
};
|
|
39
141
|
const clear = (element) => {
|
|
40
142
|
const timer = pending.get(element);
|
|
@@ -49,7 +151,7 @@ export function installField(emit) {
|
|
|
49
151
|
clear(element);
|
|
50
152
|
pending.set(element, setTimeout(() => {
|
|
51
153
|
pending.delete(element);
|
|
52
|
-
report(element,
|
|
154
|
+
report(element, FieldChangeKind.INPUT);
|
|
53
155
|
}, BURST_MS));
|
|
54
156
|
};
|
|
55
157
|
const onChange = (event) => {
|
|
@@ -57,7 +159,7 @@ export function installField(emit) {
|
|
|
57
159
|
if (!isField(element))
|
|
58
160
|
return;
|
|
59
161
|
clear(element);
|
|
60
|
-
report(element,
|
|
162
|
+
report(element, FieldChangeKind.CHANGE);
|
|
61
163
|
};
|
|
62
164
|
document.addEventListener('input', onInput, { signal, capture: true });
|
|
63
165
|
document.addEventListener('change', onChange, { signal, capture: true });
|
|
@@ -65,6 +167,8 @@ export function installField(emit) {
|
|
|
65
167
|
for (const timer of pending.values())
|
|
66
168
|
clearTimeout(timer);
|
|
67
169
|
pending.clear();
|
|
170
|
+
if (settledEmit === emit)
|
|
171
|
+
settledEmit = previousEmit;
|
|
68
172
|
ac.abort();
|
|
69
173
|
};
|
|
70
174
|
}
|
|
@@ -17,6 +17,8 @@ import { installDialogs } from './dialogs.js';
|
|
|
17
17
|
import { installAnimation } from './animation.js';
|
|
18
18
|
import { installScroll } from './scroll.js';
|
|
19
19
|
import { installHealth } from './health.js';
|
|
20
|
+
import { installMedia } from './media.js';
|
|
21
|
+
import { installCanvas } from './canvas.js';
|
|
20
22
|
import { reportSdkFailure, SdkSite } from './sdk-failure.js';
|
|
21
23
|
function guard(emit, site, install) {
|
|
22
24
|
try {
|
|
@@ -65,5 +67,7 @@ export function installAllObservers(emit, options) {
|
|
|
65
67
|
guard(emit, SdkSite.DOM_OBSERVER, () => installBlindSpots(emit)),
|
|
66
68
|
guard(emit, SdkSite.HEALTH_OBSERVER, () => installHealth(emit)),
|
|
67
69
|
guard(emit, SdkSite.DOM_OBSERVER, () => installContextOpen(emit)),
|
|
70
|
+
guard(emit, SdkSite.DOM_OBSERVER, () => installMedia(emit)),
|
|
71
|
+
guard(emit, SdkSite.DOM_OBSERVER, () => installCanvas(emit)),
|
|
68
72
|
];
|
|
69
73
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { EventType, MediaEvent } from '@reticlehq/core';
|
|
2
|
+
import { refs } from '../dom/addressing/refs.js';
|
|
3
|
+
import { observeSafely } from './types.js';
|
|
4
|
+
const TAGS = { VIDEO: 'video', AUDIO: 'audio' };
|
|
5
|
+
export function installMedia(emit) {
|
|
6
|
+
const ac = new AbortController();
|
|
7
|
+
const onMedia = (event) => {
|
|
8
|
+
observeSafely(() => {
|
|
9
|
+
const el = event.target;
|
|
10
|
+
if (!(el instanceof HTMLMediaElement)) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const tag = TAGS[el.tagName];
|
|
14
|
+
if (tag === undefined) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const duration = Number.isFinite(el.duration) ? el.duration : undefined;
|
|
18
|
+
emit(EventType.MEDIA_STATE, {
|
|
19
|
+
event: event.type,
|
|
20
|
+
tag,
|
|
21
|
+
currentTime: el.currentTime,
|
|
22
|
+
...(duration === undefined ? {} : { duration }),
|
|
23
|
+
readyState: el.readyState,
|
|
24
|
+
paused: el.paused,
|
|
25
|
+
}, refs.refFor(el));
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
for (const type of Object.values(MediaEvent)) {
|
|
29
|
+
document.addEventListener(type, onMedia, { capture: true, signal: ac.signal });
|
|
30
|
+
}
|
|
31
|
+
return () => ac.abort();
|
|
32
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { ResponseBodyReason } from '@reticlehq/core';
|
|
1
2
|
export declare function isStreamingBody(contentType: string | null, contentLength: string | null): boolean;
|
|
2
3
|
export declare function withBodyDeadline(read: Promise<string>): Promise<string | undefined>;
|
|
4
|
+
export declare function skippedBodyReason(contentType: string | null): ResponseBodyReason | undefined;
|
|
3
5
|
export declare function isCapturableType(contentType: string | null): boolean;
|
|
4
6
|
export declare function setNetworkBodyMaxChars(chars: number): void;
|
|
5
7
|
export declare function networkBodyMaxChars(): number;
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import { REDACTED_VALUE } from '@reticlehq/core';
|
|
1
|
+
import { REDACTED_VALUE, ResponseBodyReason } from '@reticlehq/core';
|
|
2
2
|
import { isSensitiveKey, safeStringify, scrubKnownSecrets } from '../../security/serialization.js';
|
|
3
3
|
import { nativeClearTimeout, nativeSetTimeout } from '../../timers/native/native-timers.js';
|
|
4
|
+
import { isEventStream, redactEventStream } from './network-event-stream.js';
|
|
4
5
|
const CAPTURABLE_CONTENT = /application\/json|text\/|application\/xml|x-www-form-urlencoded|graphql/i;
|
|
5
6
|
const STREAMING_CONTENT = /event-stream|x-ndjson|application\/stream/i;
|
|
6
7
|
const ENDLESS_BY_DESIGN = /event-stream/i;
|
|
7
8
|
export function isStreamingBody(contentType, contentLength) {
|
|
8
|
-
if (contentType !== null && ENDLESS_BY_DESIGN.test(contentType))
|
|
9
|
+
if (contentType !== null && ENDLESS_BY_DESIGN.test(contentType)) {
|
|
9
10
|
return false;
|
|
11
|
+
}
|
|
10
12
|
return null === contentLength;
|
|
11
13
|
}
|
|
12
14
|
const BODY_READ_TIMEOUT_MS = 500;
|
|
@@ -21,15 +23,26 @@ export async function withBodyDeadline(read) {
|
|
|
21
23
|
]);
|
|
22
24
|
}
|
|
23
25
|
finally {
|
|
24
|
-
if (timer !== undefined)
|
|
26
|
+
if (timer !== undefined) {
|
|
25
27
|
nativeClearTimeout(timer);
|
|
28
|
+
}
|
|
26
29
|
}
|
|
27
30
|
}
|
|
31
|
+
export function skippedBodyReason(contentType) {
|
|
32
|
+
if (null === contentType) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
return STREAMING_CONTENT.test(contentType)
|
|
36
|
+
? ResponseBodyReason.STREAMING
|
|
37
|
+
: ResponseBodyReason.NOT_TEXT;
|
|
38
|
+
}
|
|
28
39
|
export function isCapturableType(contentType) {
|
|
29
|
-
if (null === contentType)
|
|
40
|
+
if (null === contentType) {
|
|
30
41
|
return false;
|
|
31
|
-
|
|
42
|
+
}
|
|
43
|
+
if (STREAMING_CONTENT.test(contentType)) {
|
|
32
44
|
return false;
|
|
45
|
+
}
|
|
33
46
|
return CAPTURABLE_CONTENT.test(contentType);
|
|
34
47
|
}
|
|
35
48
|
const DEFAULT_BODY_CHARS = 8192;
|
|
@@ -37,8 +50,9 @@ const MAX_CONFIGURABLE_BODY_CHARS = 262144;
|
|
|
37
50
|
const MIN_CONFIGURABLE_BODY_CHARS = 256;
|
|
38
51
|
let bodyMaxChars = DEFAULT_BODY_CHARS;
|
|
39
52
|
export function setNetworkBodyMaxChars(chars) {
|
|
40
|
-
if (!Number.isFinite(chars))
|
|
53
|
+
if (!Number.isFinite(chars)) {
|
|
41
54
|
return;
|
|
55
|
+
}
|
|
42
56
|
bodyMaxChars = Math.min(MAX_CONFIGURABLE_BODY_CHARS, Math.max(MIN_CONFIGURABLE_BODY_CHARS, Math.floor(chars)));
|
|
43
57
|
}
|
|
44
58
|
export function networkBodyMaxChars() {
|
|
@@ -53,7 +67,13 @@ const AUTH_SCHEME_TOKEN = /\b(Bearer|Basic)\s+(?=[A-Za-z0-9._~+/=-]{16,})(?=[A-Z
|
|
|
53
67
|
function redactText(text) {
|
|
54
68
|
return (text
|
|
55
69
|
.replace(AUTH_SCHEME_TOKEN, (_m, scheme) => `${scheme} ${REDACTED_VALUE}`)
|
|
56
|
-
.replace(/([A-Za-z0-9_.-]+)(
|
|
70
|
+
.replace(/([A-Za-z0-9_.-]+)("?\s*[=:]\s*)("(?:\\.|[^"\\])*"?|[^&\s,;"}]+)/g, (match, key, sep, value) => {
|
|
71
|
+
if (!isSensitiveKey(key)) {
|
|
72
|
+
return match;
|
|
73
|
+
}
|
|
74
|
+
const redacted = value.startsWith('"') ? `"${REDACTED_VALUE}"` : REDACTED_VALUE;
|
|
75
|
+
return `${key}${sep}${redacted}`;
|
|
76
|
+
}));
|
|
57
77
|
}
|
|
58
78
|
const ERROR_BODY_CHARS = 4096;
|
|
59
79
|
export function shouldCaptureErrorBody(status, enabled) {
|
|
@@ -61,8 +81,9 @@ export function shouldCaptureErrorBody(status, enabled) {
|
|
|
61
81
|
}
|
|
62
82
|
export function projectErrorBody(rawText, contentType) {
|
|
63
83
|
const projected = projectBody(rawText, contentType);
|
|
64
|
-
if (projected.body.length <= ERROR_BODY_CHARS)
|
|
84
|
+
if (projected.body.length <= ERROR_BODY_CHARS) {
|
|
65
85
|
return projected;
|
|
86
|
+
}
|
|
66
87
|
return { body: projected.body.slice(0, ERROR_BODY_CHARS), truncated: true };
|
|
67
88
|
}
|
|
68
89
|
export function projectBody(rawText, contentType) {
|
|
@@ -70,6 +91,7 @@ export function projectBody(rawText, contentType) {
|
|
|
70
91
|
const scanChars = scanWidth(!isJson);
|
|
71
92
|
const oversized = rawText.length > scanChars;
|
|
72
93
|
const text = oversized ? rawText.slice(0, scanChars) : rawText;
|
|
94
|
+
let incomplete = false;
|
|
73
95
|
let out;
|
|
74
96
|
if (isJson) {
|
|
75
97
|
try {
|
|
@@ -79,10 +101,15 @@ export function projectBody(rawText, contentType) {
|
|
|
79
101
|
out = redactText(text);
|
|
80
102
|
}
|
|
81
103
|
}
|
|
104
|
+
else if (isEventStream(contentType)) {
|
|
105
|
+
const projected = redactEventStream(text);
|
|
106
|
+
out = redactText(projected.body);
|
|
107
|
+
incomplete = projected.truncated;
|
|
108
|
+
}
|
|
82
109
|
else {
|
|
83
110
|
out = redactText(text);
|
|
84
111
|
}
|
|
85
112
|
out = scrubKnownSecrets(out);
|
|
86
|
-
const truncated = oversized || out.length > bodyMaxChars;
|
|
113
|
+
const truncated = incomplete || oversized || out.length > bodyMaxChars;
|
|
87
114
|
return { body: out.length > bodyMaxChars ? out.slice(0, bodyMaxChars) : out, truncated };
|
|
88
115
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { REDACTED_VALUE } from '@reticlehq/core';
|
|
2
|
+
import { sanitizeWithReport } from '../../security/serialization.js';
|
|
3
|
+
const EVENT_STREAM_CONTENT = /^text\/event-stream(?:\s*;|\s*$)/i;
|
|
4
|
+
const DATA_FIELD = 'data:';
|
|
5
|
+
const DATA_NAME = 'data';
|
|
6
|
+
const JSON_CONTAINER = /^[{[]/;
|
|
7
|
+
const TEXT_MARKER = /^\[[A-Z_]+\]$/;
|
|
8
|
+
export function isEventStream(contentType) {
|
|
9
|
+
return contentType !== null && EVENT_STREAM_CONTENT.test(contentType);
|
|
10
|
+
}
|
|
11
|
+
function isData(line) {
|
|
12
|
+
return DATA_NAME === line || line.startsWith(DATA_FIELD);
|
|
13
|
+
}
|
|
14
|
+
export function redactEventStream(text) {
|
|
15
|
+
let truncated = false;
|
|
16
|
+
const records = text.replace(/\r\n?/g, '\n').split('\n\n');
|
|
17
|
+
const body = records
|
|
18
|
+
.map((record) => {
|
|
19
|
+
const lines = record.split('\n');
|
|
20
|
+
const data = lines
|
|
21
|
+
.filter(isData)
|
|
22
|
+
.map((line) => line.slice(DATA_FIELD.length).replace(/^ /, ''))
|
|
23
|
+
.join('\n');
|
|
24
|
+
if (!JSON_CONTAINER.test(data.trimStart()) || TEXT_MARKER.test(data.trim())) {
|
|
25
|
+
return record;
|
|
26
|
+
}
|
|
27
|
+
let projected;
|
|
28
|
+
try {
|
|
29
|
+
const sanitized = sanitizeWithReport(JSON.parse(data));
|
|
30
|
+
projected = JSON.stringify(sanitized.value);
|
|
31
|
+
if (sanitized.truncation !== undefined) {
|
|
32
|
+
truncated = true;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
projected = REDACTED_VALUE;
|
|
37
|
+
truncated = true;
|
|
38
|
+
}
|
|
39
|
+
let first = true;
|
|
40
|
+
return lines
|
|
41
|
+
.flatMap((line) => {
|
|
42
|
+
if (!isData(line)) {
|
|
43
|
+
return [line];
|
|
44
|
+
}
|
|
45
|
+
if (!first) {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
first = false;
|
|
49
|
+
return [`${DATA_FIELD} ${projected}`];
|
|
50
|
+
})
|
|
51
|
+
.join('\n');
|
|
52
|
+
})
|
|
53
|
+
.join('\n\n');
|
|
54
|
+
return { body, truncated };
|
|
55
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { RETICLE_WS_PATH } from '@reticlehq/core';
|
|
2
|
+
import { projectBody } from './network-body.js';
|
|
3
|
+
function binaryFrameBytes(data) {
|
|
4
|
+
if (data instanceof ArrayBuffer) {
|
|
5
|
+
return data.byteLength;
|
|
6
|
+
}
|
|
7
|
+
if (typeof Blob !== 'undefined' && data instanceof Blob) {
|
|
8
|
+
return data.size;
|
|
9
|
+
}
|
|
10
|
+
if (ArrayBuffer.isView(data)) {
|
|
11
|
+
return data.byteLength;
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
export function frameFields(data, captureBodies) {
|
|
16
|
+
if (typeof data !== 'string') {
|
|
17
|
+
const bytes = binaryFrameBytes(data);
|
|
18
|
+
return bytes === undefined
|
|
19
|
+
? { frameType: typeof data }
|
|
20
|
+
: { frameType: 'binary', frameBytes: bytes };
|
|
21
|
+
}
|
|
22
|
+
const out = { frameBytes: data.length };
|
|
23
|
+
if (captureBodies) {
|
|
24
|
+
const { body, truncated } = projectBody(data, 'application/json');
|
|
25
|
+
out['frame'] = body;
|
|
26
|
+
if (truncated) {
|
|
27
|
+
out['frameTruncated'] = true;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
export function isBridgeSocket(url) {
|
|
33
|
+
try {
|
|
34
|
+
return new URL(url, location.href).pathname === RETICLE_WS_PATH;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return url.includes(RETICLE_WS_PATH);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { ResponseBodyReason } from '@reticlehq/core';
|
|
2
|
+
import { nativeClearTimeout, nativeSetTimeout } from '../../timers/native/native-timers.js';
|
|
3
|
+
import { networkBodyMaxChars, projectBody, projectErrorBody, withBodyDeadline, } from './network-body.js';
|
|
4
|
+
import { isEventStream } from './network-event-stream.js';
|
|
5
|
+
export { isEventStream };
|
|
6
|
+
const EVENT_STREAM_READ_MS = 10_000;
|
|
7
|
+
const UTF8_MAX_BYTES_PER_CHAR = 4;
|
|
8
|
+
const REDACTION_LOOKAHEAD = 2;
|
|
9
|
+
async function readEventStream(clone, signal) {
|
|
10
|
+
if (signal.aborted || typeof clone.body?.getReader !== 'function') {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
const reader = clone.body.getReader();
|
|
14
|
+
const decoder = new TextDecoder();
|
|
15
|
+
const limit = networkBodyMaxChars() * REDACTION_LOOKAHEAD;
|
|
16
|
+
let text = '';
|
|
17
|
+
let complete = false;
|
|
18
|
+
let stopped = false;
|
|
19
|
+
let stop = () => undefined;
|
|
20
|
+
const interrupted = new Promise((resolve) => {
|
|
21
|
+
stop = () => {
|
|
22
|
+
stopped = true;
|
|
23
|
+
resolve();
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
const timer = nativeSetTimeout(stop, EVENT_STREAM_READ_MS);
|
|
27
|
+
signal.addEventListener('abort', stop, { once: true });
|
|
28
|
+
try {
|
|
29
|
+
await Promise.race([
|
|
30
|
+
(async () => {
|
|
31
|
+
while (!stopped && !signal.aborted) {
|
|
32
|
+
const next = await reader.read();
|
|
33
|
+
if (stopped || signal.aborted) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (next.done) {
|
|
37
|
+
text += decoder.decode();
|
|
38
|
+
complete = true;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const remaining = limit - text.length;
|
|
42
|
+
const bytes = next.value.subarray(0, remaining * UTF8_MAX_BYTES_PER_CHAR);
|
|
43
|
+
const decoded = decoder.decode(bytes, { stream: true });
|
|
44
|
+
text += decoded.slice(0, remaining);
|
|
45
|
+
if (text.length >= limit || bytes.byteLength < next.value.byteLength) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
})(),
|
|
50
|
+
interrupted,
|
|
51
|
+
]);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
nativeClearTimeout(timer);
|
|
57
|
+
signal.removeEventListener('abort', stop);
|
|
58
|
+
if (!complete) {
|
|
59
|
+
void reader.cancel().catch(() => undefined);
|
|
60
|
+
}
|
|
61
|
+
reader.releaseLock();
|
|
62
|
+
}
|
|
63
|
+
return !complete && 0 === text.length ? undefined : { text, complete };
|
|
64
|
+
}
|
|
65
|
+
export async function captureResponseBody(response, contentType, captureBodies, signal) {
|
|
66
|
+
const unreadable = captureBodies ? { responseBodyReason: ResponseBodyReason.UNREADABLE } : {};
|
|
67
|
+
if (isEventStream(contentType) && (!captureBodies || signal.aborted)) {
|
|
68
|
+
return unreadable;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const clone = response.clone();
|
|
72
|
+
let text;
|
|
73
|
+
let incomplete = false;
|
|
74
|
+
if (isEventStream(contentType)) {
|
|
75
|
+
const stream = await readEventStream(clone, signal);
|
|
76
|
+
text = stream?.text;
|
|
77
|
+
incomplete = false === stream?.complete;
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
text = await withBodyDeadline(clone.text());
|
|
81
|
+
}
|
|
82
|
+
if (undefined === text) {
|
|
83
|
+
return unreadable;
|
|
84
|
+
}
|
|
85
|
+
const projected = captureBodies
|
|
86
|
+
? projectBody(text, contentType)
|
|
87
|
+
: projectErrorBody(text, contentType);
|
|
88
|
+
return {
|
|
89
|
+
responseBody: projected.body,
|
|
90
|
+
...(incomplete || projected.truncated ? { responseBodyTruncated: true } : {}),
|
|
91
|
+
...(captureBodies ? {} : { responseBodyReason: ResponseBodyReason.ERROR_STATUS }),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return unreadable;
|
|
96
|
+
}
|
|
97
|
+
}
|