@reticlehq/browser 3.8.0 → 3.8.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/dist/actions/action-report.js +36 -18
- package/dist/actions/actions-dom.d.ts +9 -1
- package/dist/actions/actions-dom.js +61 -21
- package/dist/actions/actions.js +98 -43
- package/dist/actions/danger-context.js +27 -16
- package/dist/actions/scroll.d.ts +2 -0
- package/dist/actions/scroll.js +10 -2
- package/dist/actions/select-combobox.d.ts +6 -0
- package/dist/actions/select-combobox.js +69 -0
- package/dist/dom/a11y.d.ts +0 -4
- package/dist/dom/a11y.js +2 -294
- package/dist/dom/dom-ignore.js +23 -10
- package/dist/dom/query-finders.d.ts +6 -0
- package/dist/dom/query-finders.js +216 -0
- package/dist/dom/query.d.ts +0 -1
- package/dist/dom/query.js +108 -244
- package/dist/dom/snapshot.js +2 -1
- package/dist/dom/visibility.d.ts +7 -0
- package/dist/dom/visibility.js +322 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/observers/dom.js +2 -1
- package/dist/observers/net-detail/network-body.js +29 -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 +95 -105
- package/dist/reticle-inject.js +695 -631
- package/package.json +2 -2
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { isHtmlElement } from './realm.js';
|
|
2
|
+
import { parentAcrossShadowBoundary } from './shadow-registry.js';
|
|
3
|
+
import { clippingBox, overflowAxes, scrolledInto, } from './paint-context.js';
|
|
4
|
+
export const HTML_DETAILS_TAG = 'details';
|
|
5
|
+
export const HTML_DETAILS_OPEN_ATTRIBUTE = 'open';
|
|
6
|
+
export const HTML_SUMMARY_TAG = 'summary';
|
|
7
|
+
function hiddenInsideClosedDetails(el) {
|
|
8
|
+
const parent = el.parentElement;
|
|
9
|
+
if (null === parent) {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
const details = parent.closest(HTML_DETAILS_TAG);
|
|
13
|
+
if (null === details || details.hasAttribute(HTML_DETAILS_OPEN_ATTRIBUTE)) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
const summary = Array.from(details.children).find((child) => HTML_SUMMARY_TAG === child.localName);
|
|
17
|
+
return summary === undefined || !summary.contains(el);
|
|
18
|
+
}
|
|
19
|
+
function stalledFade(el) {
|
|
20
|
+
if (!el.ownerDocument.hidden || !('getAnimations' in el)) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
return el.getAnimations().some((a) => 'running' === a.playState || a.pending);
|
|
24
|
+
}
|
|
25
|
+
const OVERLAY_ARIA_HIDDEN = 'data-aria-hidden';
|
|
26
|
+
function hiddenByAnOverlay(el) {
|
|
27
|
+
return el.hasAttribute(OVERLAY_ARIA_HIDDEN);
|
|
28
|
+
}
|
|
29
|
+
function selfCssHidden(el, style) {
|
|
30
|
+
if (isHtmlElement(el) && el.hidden) {
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
if (hiddenInsideClosedDetails(el)) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
if (style !== null) {
|
|
37
|
+
if ('none' === style.display ||
|
|
38
|
+
'hidden' === style.visibility ||
|
|
39
|
+
'collapse' === style.visibility) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
if (0 === Number.parseFloat(style.opacity || '1') && !stalledFade(el)) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
if (clippedAway(style)) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
function clippedAway(style) {
|
|
52
|
+
const rect = /^rect\((.*)\)$/.exec(style.getPropertyValue('clip').trim());
|
|
53
|
+
if (null !== rect && ('absolute' === style.position || 'fixed' === style.position)) {
|
|
54
|
+
const [top, right, bottom, left] = (rect[1] ?? '').split(/[\s,]+/).map(Number.parseFloat);
|
|
55
|
+
if (right !== undefined && left !== undefined && right <= left) {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
if (bottom !== undefined && top !== undefined && bottom <= top) {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const inset = /^inset\(([^)]*)\)$/.exec(style.getPropertyValue('clip-path').trim());
|
|
63
|
+
const edges = inset?.[1]
|
|
64
|
+
?.split(/\s+round\s/)[0]
|
|
65
|
+
?.trim()
|
|
66
|
+
.split(/\s+/) ?? [];
|
|
67
|
+
const known = (edge) => edge.endsWith('%') || 0 === Number.parseFloat(edge);
|
|
68
|
+
if (0 === edges.length || !edges.every(known)) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
const [top = 0, right = top, bottom = top, left = right] = edges.map(Number.parseFloat);
|
|
72
|
+
return top + bottom >= 100 || left + right >= 100;
|
|
73
|
+
}
|
|
74
|
+
function selfHidden(el, style) {
|
|
75
|
+
if ('true' === el.getAttribute('aria-hidden') && !hiddenByAnOverlay(el)) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
return selfCssHidden(el, style);
|
|
79
|
+
}
|
|
80
|
+
export function isHiddenByAriaOnly(el) {
|
|
81
|
+
let foundAriaHidden = false;
|
|
82
|
+
let current = el;
|
|
83
|
+
while (null !== current) {
|
|
84
|
+
if ('true' === current.getAttribute('aria-hidden')) {
|
|
85
|
+
foundAriaHidden = true;
|
|
86
|
+
}
|
|
87
|
+
const style = current.ownerDocument.defaultView?.getComputedStyle(current) ?? null;
|
|
88
|
+
if (selfCssHidden(current, style)) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
current = parentAcrossShadowBoundary(current);
|
|
92
|
+
}
|
|
93
|
+
return foundAriaHidden;
|
|
94
|
+
}
|
|
95
|
+
export function isRenderedOutsideAriaHidden(el) {
|
|
96
|
+
let current = el;
|
|
97
|
+
while (null !== current) {
|
|
98
|
+
if ('true' === current.getAttribute('aria-hidden')) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
const style = current.ownerDocument.defaultView?.getComputedStyle(current) ?? null;
|
|
102
|
+
if (selfCssHidden(current, style)) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
current = parentAcrossShadowBoundary(current);
|
|
106
|
+
}
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
const CLIPPING_OVERFLOW = new Set(['hidden', 'clip']);
|
|
110
|
+
const SCROLLING_OVERFLOW = new Set(['auto', 'scroll']);
|
|
111
|
+
const NON_CLIPPING_DISPLAY = new Set([
|
|
112
|
+
'contents',
|
|
113
|
+
'table-row',
|
|
114
|
+
'table-row-group',
|
|
115
|
+
'table-header-group',
|
|
116
|
+
'table-footer-group',
|
|
117
|
+
'table-column',
|
|
118
|
+
'table-column-group',
|
|
119
|
+
]);
|
|
120
|
+
const TRANSFORM_CONTAINING_BLOCK_PROPERTIES = new Set([
|
|
121
|
+
'transform',
|
|
122
|
+
'translate',
|
|
123
|
+
'rotate',
|
|
124
|
+
'scale',
|
|
125
|
+
'perspective',
|
|
126
|
+
]);
|
|
127
|
+
const CONTAINING_BLOCK_PROPERTIES = new Set([
|
|
128
|
+
...TRANSFORM_CONTAINING_BLOCK_PROPERTIES,
|
|
129
|
+
'filter',
|
|
130
|
+
'backdrop-filter',
|
|
131
|
+
]);
|
|
132
|
+
const CONTAINING_BLOCK_CONTAIN = new Set(['layout', 'paint', 'strict', 'content']);
|
|
133
|
+
const REPLACED_INLINE_TAGS = new Set([
|
|
134
|
+
'img',
|
|
135
|
+
'video',
|
|
136
|
+
'audio',
|
|
137
|
+
'canvas',
|
|
138
|
+
'iframe',
|
|
139
|
+
'embed',
|
|
140
|
+
'object',
|
|
141
|
+
'input',
|
|
142
|
+
'textarea',
|
|
143
|
+
'select',
|
|
144
|
+
]);
|
|
145
|
+
const visibilityInfoMemos = new WeakMap();
|
|
146
|
+
function visibilityInfoMemo(memo) {
|
|
147
|
+
if (memo === undefined) {
|
|
148
|
+
return new Map();
|
|
149
|
+
}
|
|
150
|
+
let info = visibilityInfoMemos.get(memo);
|
|
151
|
+
if (info === undefined) {
|
|
152
|
+
info = new Map();
|
|
153
|
+
visibilityInfoMemos.set(memo, info);
|
|
154
|
+
}
|
|
155
|
+
return info;
|
|
156
|
+
}
|
|
157
|
+
function ownVisibilityInfo(el, memo) {
|
|
158
|
+
let info = memo.get(el);
|
|
159
|
+
if (info === undefined) {
|
|
160
|
+
const style = el.ownerDocument.defaultView?.getComputedStyle(el) ?? null;
|
|
161
|
+
if (selfHidden(el, style)) {
|
|
162
|
+
info = {
|
|
163
|
+
hidden: true,
|
|
164
|
+
style,
|
|
165
|
+
parent: null,
|
|
166
|
+
clipX: false,
|
|
167
|
+
clipY: false,
|
|
168
|
+
scrollX: false,
|
|
169
|
+
scrollY: false,
|
|
170
|
+
hasClipping: false,
|
|
171
|
+
};
|
|
172
|
+
memo.set(el, info);
|
|
173
|
+
return info;
|
|
174
|
+
}
|
|
175
|
+
const parent = parentAcrossShadowBoundary(el);
|
|
176
|
+
const inherited = null === parent ? undefined : ownVisibilityInfo(parent, memo);
|
|
177
|
+
const [overflowX, overflowY] = overflowAxes(style);
|
|
178
|
+
const root = el.ownerDocument.documentElement;
|
|
179
|
+
const viewportOverflow = el === root ||
|
|
180
|
+
(el === el.ownerDocument.body &&
|
|
181
|
+
overflowAxes(ownVisibilityInfo(root, memo).style).every((axis) => 'visible' === axis));
|
|
182
|
+
const hasClipBox = !viewportOverflow &&
|
|
183
|
+
!NON_CLIPPING_DISPLAY.has(style?.display ?? '') &&
|
|
184
|
+
!(isHtmlElement(el) && 'inline' === style?.display);
|
|
185
|
+
const clipX = hasClipBox && CLIPPING_OVERFLOW.has(overflowX);
|
|
186
|
+
const clipY = hasClipBox && CLIPPING_OVERFLOW.has(overflowY);
|
|
187
|
+
info = {
|
|
188
|
+
hidden: true === inherited?.hidden,
|
|
189
|
+
style,
|
|
190
|
+
parent,
|
|
191
|
+
clipX,
|
|
192
|
+
clipY,
|
|
193
|
+
scrollX: hasClipBox && SCROLLING_OVERFLOW.has(overflowX),
|
|
194
|
+
scrollY: hasClipBox && SCROLLING_OVERFLOW.has(overflowY),
|
|
195
|
+
hasClipping: clipX || clipY || true === inherited?.hasClipping,
|
|
196
|
+
};
|
|
197
|
+
memo.set(el, info);
|
|
198
|
+
}
|
|
199
|
+
return info;
|
|
200
|
+
}
|
|
201
|
+
function positionedContainingBlock(el, fixed, memo) {
|
|
202
|
+
for (let parent = ownVisibilityInfo(el, memo).parent; parent !== null; parent = ownVisibilityInfo(parent, memo).parent) {
|
|
203
|
+
const style = ownVisibilityInfo(parent, memo).style;
|
|
204
|
+
if (null === style || 'contents' === style.display) {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (!fixed && '' !== style.position && 'static' !== style.position) {
|
|
208
|
+
return parent;
|
|
209
|
+
}
|
|
210
|
+
const transformable = !('inline' === style.display &&
|
|
211
|
+
isHtmlElement(parent) &&
|
|
212
|
+
!REPLACED_INLINE_TAGS.has(parent.localName)) &&
|
|
213
|
+
'table-column' !== style.display &&
|
|
214
|
+
'table-column-group' !== style.display;
|
|
215
|
+
const applies = (property) => transformable || !TRANSFORM_CONTAINING_BLOCK_PROPERTIES.has(property);
|
|
216
|
+
if ([...CONTAINING_BLOCK_PROPERTIES].some((property) => {
|
|
217
|
+
const value = style.getPropertyValue(property);
|
|
218
|
+
return applies(property) && '' !== value && 'none' !== value;
|
|
219
|
+
}) ||
|
|
220
|
+
style.willChange
|
|
221
|
+
.split(',')
|
|
222
|
+
.some((property) => CONTAINING_BLOCK_PROPERTIES.has(property.trim()) && applies(property.trim())) ||
|
|
223
|
+
(transformable &&
|
|
224
|
+
!NON_CLIPPING_DISPLAY.has(style.display) &&
|
|
225
|
+
(style.contain.split(/\s+/).some((value) => CONTAINING_BLOCK_CONTAIN.has(value)) ||
|
|
226
|
+
'auto' === style.contentVisibility))) {
|
|
227
|
+
return parent;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
function visibleBounds(el, memo, initial, throughScrollers = false) {
|
|
233
|
+
let bounds = initial;
|
|
234
|
+
const target = ownVisibilityInfo(el, memo);
|
|
235
|
+
if (target.hidden) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
if (null === target.parent || !ownVisibilityInfo(target.parent, memo).hasClipping) {
|
|
239
|
+
return bounds;
|
|
240
|
+
}
|
|
241
|
+
let clipFrom;
|
|
242
|
+
for (let current = el; current !== null; current = ownVisibilityInfo(current, memo).parent) {
|
|
243
|
+
const info = ownVisibilityInfo(current, memo);
|
|
244
|
+
if (info.hidden) {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
if (current === clipFrom) {
|
|
248
|
+
clipFrom = undefined;
|
|
249
|
+
}
|
|
250
|
+
const style = info.style;
|
|
251
|
+
if (throughScrollers &&
|
|
252
|
+
current !== el &&
|
|
253
|
+
undefined === clipFrom &&
|
|
254
|
+
(info.scrollX || info.scrollY)) {
|
|
255
|
+
bounds ??= el.getBoundingClientRect();
|
|
256
|
+
if (bounds.width > 0 && bounds.height > 0) {
|
|
257
|
+
bounds = scrolledInto(bounds, clippingBox(current, info), info.scrollX, info.scrollY);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (current !== el && undefined === clipFrom && (info.clipX || info.clipY)) {
|
|
261
|
+
bounds ??= el.getBoundingClientRect();
|
|
262
|
+
if (bounds.width > 0 && bounds.height > 0) {
|
|
263
|
+
const clip = clippingBox(current, info);
|
|
264
|
+
const left = info.clipX ? Math.max(bounds.left, clip.left) : bounds.left;
|
|
265
|
+
const right = info.clipX ? Math.min(bounds.right, clip.right) : bounds.right;
|
|
266
|
+
const top = info.clipY ? Math.max(bounds.top, clip.top) : bounds.top;
|
|
267
|
+
const bottom = info.clipY ? Math.min(bounds.bottom, clip.bottom) : bounds.bottom;
|
|
268
|
+
if (right <= left || bottom <= top) {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
bounds = { left, right, top, bottom, width: right - left, height: bottom - top };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (clipFrom === undefined && ('absolute' === style?.position || 'fixed' === style?.position)) {
|
|
275
|
+
clipFrom = positionedContainingBlock(current, 'fixed' === style?.position, memo);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return bounds;
|
|
279
|
+
}
|
|
280
|
+
export function isInViewport(el, memo) {
|
|
281
|
+
if (!isVisible(el, memo)) {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
const view = el.ownerDocument.defaultView;
|
|
285
|
+
if (null === view) {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
const r = visibleBounds(el, visibilityInfoMemo(memo), el.getBoundingClientRect());
|
|
289
|
+
if (null === r || undefined === r) {
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
if (r.width <= 0 || r.height <= 0) {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
return r.bottom > 0 && r.right > 0 && r.top < view.innerHeight && r.left < view.innerWidth;
|
|
296
|
+
}
|
|
297
|
+
function collapsed(el, memo) {
|
|
298
|
+
const own = ownVisibilityInfo(el, memo);
|
|
299
|
+
if (!own.clipX && !own.clipY) {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
const rects = Array.from(el.getClientRects());
|
|
303
|
+
return (rects.length > 0 &&
|
|
304
|
+
rects.every((r) => (own.clipX && 0 === r.width) ||
|
|
305
|
+
(own.clipY && 0 === r.height) ||
|
|
306
|
+
(own.clipX && own.clipY && r.width <= 1 && r.height <= 1)));
|
|
307
|
+
}
|
|
308
|
+
export function isVisible(el, memo) {
|
|
309
|
+
if (!el.isConnected) {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
const cached = memo?.get(el);
|
|
313
|
+
if (cached !== undefined) {
|
|
314
|
+
return cached;
|
|
315
|
+
}
|
|
316
|
+
const info = visibilityInfoMemo(memo);
|
|
317
|
+
const result = visibleBounds(el, info, undefined, true) !== null && !collapsed(el, info);
|
|
318
|
+
if (memo !== undefined) {
|
|
319
|
+
memo.set(el, result);
|
|
320
|
+
}
|
|
321
|
+
return result;
|
|
322
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -21,7 +21,8 @@ export { inspectChart, canvasChartData } from './dom/chart.js';
|
|
|
21
21
|
export type { ChartReport, ChartFinding, CanvasChartData, ChartGlobals } from './dom/chart.js';
|
|
22
22
|
export { matchQuery, runQuery } from './dom/query.js';
|
|
23
23
|
export { executeAction, executeSequence } from './actions/actions.js';
|
|
24
|
-
export { describe, getRole, getAccessibleName, getStates
|
|
24
|
+
export { describe, getRole, getAccessibleName, getStates } from './dom/a11y.js';
|
|
25
|
+
export { isVisible } from './dom/visibility.js';
|
|
25
26
|
export { refs, RefRegistry } from './dom/addressing/refs.js';
|
|
26
27
|
export { Annotator, installAnnotator, type AnnotatorDeps } from './review/annotator.js';
|
|
27
28
|
export { resolveMarkAnchor, type MarkAnchor } from './review/mark-anchor.js';
|
package/dist/index.js
CHANGED
|
@@ -15,7 +15,8 @@ export { buildSnapshot } from './dom/snapshot.js';
|
|
|
15
15
|
export { inspectChart, canvasChartData } from './dom/chart.js';
|
|
16
16
|
export { matchQuery, runQuery } from './dom/query.js';
|
|
17
17
|
export { executeAction, executeSequence } from './actions/actions.js';
|
|
18
|
-
export { describe, getRole, getAccessibleName, getStates
|
|
18
|
+
export { describe, getRole, getAccessibleName, getStates } from './dom/a11y.js';
|
|
19
|
+
export { isVisible } from './dom/visibility.js';
|
|
19
20
|
export { refs, RefRegistry } from './dom/addressing/refs.js';
|
|
20
21
|
export { Annotator, installAnnotator } from './review/annotator.js';
|
|
21
22
|
export { resolveMarkAnchor } from './review/mark-anchor.js';
|
package/dist/observers/dom.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readTestId, testIdSelector } from '../dom/addressing/testid-attr.js';
|
|
2
2
|
import { EventType, REDACTED_VALUE, TruncationChannel } from '@reticlehq/core';
|
|
3
|
-
import { getAccessibleName, getRole, isSensitiveField
|
|
3
|
+
import { getAccessibleName, getRole, isSensitiveField } from '../dom/a11y.js';
|
|
4
|
+
import { isVisible } from '../dom/visibility.js';
|
|
4
5
|
import { refs } from '../dom/addressing/refs.js';
|
|
5
6
|
function regionKeyOf(target) {
|
|
6
7
|
const el = isElement(target) ? target : null;
|
|
@@ -1,12 +1,14 @@
|
|
|
1
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,22 +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
|
}
|
|
28
31
|
export function skippedBodyReason(contentType) {
|
|
29
|
-
if (null === contentType)
|
|
32
|
+
if (null === contentType) {
|
|
30
33
|
return undefined;
|
|
34
|
+
}
|
|
31
35
|
return STREAMING_CONTENT.test(contentType)
|
|
32
36
|
? ResponseBodyReason.STREAMING
|
|
33
37
|
: ResponseBodyReason.NOT_TEXT;
|
|
34
38
|
}
|
|
35
39
|
export function isCapturableType(contentType) {
|
|
36
|
-
if (null === contentType)
|
|
40
|
+
if (null === contentType) {
|
|
37
41
|
return false;
|
|
38
|
-
|
|
42
|
+
}
|
|
43
|
+
if (STREAMING_CONTENT.test(contentType)) {
|
|
39
44
|
return false;
|
|
45
|
+
}
|
|
40
46
|
return CAPTURABLE_CONTENT.test(contentType);
|
|
41
47
|
}
|
|
42
48
|
const DEFAULT_BODY_CHARS = 8192;
|
|
@@ -44,8 +50,9 @@ const MAX_CONFIGURABLE_BODY_CHARS = 262144;
|
|
|
44
50
|
const MIN_CONFIGURABLE_BODY_CHARS = 256;
|
|
45
51
|
let bodyMaxChars = DEFAULT_BODY_CHARS;
|
|
46
52
|
export function setNetworkBodyMaxChars(chars) {
|
|
47
|
-
if (!Number.isFinite(chars))
|
|
53
|
+
if (!Number.isFinite(chars)) {
|
|
48
54
|
return;
|
|
55
|
+
}
|
|
49
56
|
bodyMaxChars = Math.min(MAX_CONFIGURABLE_BODY_CHARS, Math.max(MIN_CONFIGURABLE_BODY_CHARS, Math.floor(chars)));
|
|
50
57
|
}
|
|
51
58
|
export function networkBodyMaxChars() {
|
|
@@ -60,7 +67,13 @@ const AUTH_SCHEME_TOKEN = /\b(Bearer|Basic)\s+(?=[A-Za-z0-9._~+/=-]{16,})(?=[A-Z
|
|
|
60
67
|
function redactText(text) {
|
|
61
68
|
return (text
|
|
62
69
|
.replace(AUTH_SCHEME_TOKEN, (_m, scheme) => `${scheme} ${REDACTED_VALUE}`)
|
|
63
|
-
.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
|
+
}));
|
|
64
77
|
}
|
|
65
78
|
const ERROR_BODY_CHARS = 4096;
|
|
66
79
|
export function shouldCaptureErrorBody(status, enabled) {
|
|
@@ -68,8 +81,9 @@ export function shouldCaptureErrorBody(status, enabled) {
|
|
|
68
81
|
}
|
|
69
82
|
export function projectErrorBody(rawText, contentType) {
|
|
70
83
|
const projected = projectBody(rawText, contentType);
|
|
71
|
-
if (projected.body.length <= ERROR_BODY_CHARS)
|
|
84
|
+
if (projected.body.length <= ERROR_BODY_CHARS) {
|
|
72
85
|
return projected;
|
|
86
|
+
}
|
|
73
87
|
return { body: projected.body.slice(0, ERROR_BODY_CHARS), truncated: true };
|
|
74
88
|
}
|
|
75
89
|
export function projectBody(rawText, contentType) {
|
|
@@ -77,6 +91,7 @@ export function projectBody(rawText, contentType) {
|
|
|
77
91
|
const scanChars = scanWidth(!isJson);
|
|
78
92
|
const oversized = rawText.length > scanChars;
|
|
79
93
|
const text = oversized ? rawText.slice(0, scanChars) : rawText;
|
|
94
|
+
let incomplete = false;
|
|
80
95
|
let out;
|
|
81
96
|
if (isJson) {
|
|
82
97
|
try {
|
|
@@ -86,10 +101,15 @@ export function projectBody(rawText, contentType) {
|
|
|
86
101
|
out = redactText(text);
|
|
87
102
|
}
|
|
88
103
|
}
|
|
104
|
+
else if (isEventStream(contentType)) {
|
|
105
|
+
const projected = redactEventStream(text);
|
|
106
|
+
out = redactText(projected.body);
|
|
107
|
+
incomplete = projected.truncated;
|
|
108
|
+
}
|
|
89
109
|
else {
|
|
90
110
|
out = redactText(text);
|
|
91
111
|
}
|
|
92
112
|
out = scrubKnownSecrets(out);
|
|
93
|
-
const truncated = oversized || out.length > bodyMaxChars;
|
|
113
|
+
const truncated = incomplete || oversized || out.length > bodyMaxChars;
|
|
94
114
|
return { body: out.length > bodyMaxChars ? out.slice(0, bodyMaxChars) : out, truncated };
|
|
95
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
|
+
}
|