@loxel.dev/pharos-browser 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +360 -0
- package/TECHNICAL-DETAILS.md +480 -0
- package/dist/index-bc4bw3ba.js +844 -0
- package/dist/index-c3taa3cg.js +9 -0
- package/dist/index.d.ts +235 -0
- package/dist/index.js +504 -0
- package/dist/privacy/attributes.d.ts +12 -0
- package/dist/privacy/collect.d.ts +8 -0
- package/dist/privacy/markers.d.ts +7 -0
- package/dist/privacy/mask.d.ts +2 -0
- package/dist/privacy/policy.d.ts +27 -0
- package/dist/privacy/sensitivity.d.ts +1 -0
- package/dist/replay/budget.d.ts +49 -0
- package/dist/replay/buffer.d.ts +155 -0
- package/dist/replay/config.d.ts +190 -0
- package/dist/replay/drops.d.ts +46 -0
- package/dist/replay/index.d.ts +2 -0
- package/dist/replay/index.js +8 -0
- package/dist/replay/interactions.d.ts +48 -0
- package/dist/replay/persist.d.ts +75 -0
- package/dist/replay/privacy-hooks.d.ts +101 -0
- package/dist/replay/recorder.d.ts +20 -0
- package/dist/replay/triggers.d.ts +48 -0
- package/dist/replay/upload.d.ts +194 -0
- package/dist/replay/wire.d.ts +132 -0
- package/dist/stack.d.ts +8 -0
- package/dist/wire-992wvzs1.js +926 -0
- package/package.json +62 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ACTIVATION_EVENT_TAG,
|
|
3
|
+
EXCLUDE_CLASS,
|
|
4
|
+
LOCAL_TTL_MS,
|
|
5
|
+
MASK_CLASS,
|
|
6
|
+
MASK_TOKEN,
|
|
7
|
+
MAX_BUFFER_BYTES,
|
|
8
|
+
MAX_WINDOW_BYTES,
|
|
9
|
+
RECORDER_DEFAULTS,
|
|
10
|
+
beginsWithSnapshot,
|
|
11
|
+
clearPersisted,
|
|
12
|
+
createDropLedger,
|
|
13
|
+
createUploadSink,
|
|
14
|
+
decide,
|
|
15
|
+
drainPersisted,
|
|
16
|
+
encodeWindow,
|
|
17
|
+
envelopeMetaFor,
|
|
18
|
+
isSensitiveField,
|
|
19
|
+
maskText,
|
|
20
|
+
masksValue,
|
|
21
|
+
persistWindow,
|
|
22
|
+
postEnvelope,
|
|
23
|
+
quotaDroppedWindows,
|
|
24
|
+
resetQuotaDroppedWindows,
|
|
25
|
+
resolveConfig,
|
|
26
|
+
scrubAttribute,
|
|
27
|
+
scrubUrl,
|
|
28
|
+
shouldExclude,
|
|
29
|
+
shouldMaskByMarker
|
|
30
|
+
} from "./index-bc4bw3ba.js";
|
|
31
|
+
import {
|
|
32
|
+
__require
|
|
33
|
+
} from "./index-c3taa3cg.js";
|
|
34
|
+
|
|
35
|
+
// src/stack.ts
|
|
36
|
+
var MAX_FRAMES = 50;
|
|
37
|
+
var V8_WITH_FN = /^at\s+(.+?)\s+\((.+):(\d+):(\d+)\)$/;
|
|
38
|
+
var V8_NO_FN = /^at\s+(.+):(\d+):(\d+)$/;
|
|
39
|
+
var FIREFOX = /^([^@]*)@(.+):(\d+):(\d+)$/;
|
|
40
|
+
function parseLine(line) {
|
|
41
|
+
let m = V8_WITH_FN.exec(line);
|
|
42
|
+
if (m) {
|
|
43
|
+
return { fn: m[1], file: m[2], line: Number(m[3]), col: Number(m[4]) };
|
|
44
|
+
}
|
|
45
|
+
m = V8_NO_FN.exec(line);
|
|
46
|
+
if (m) {
|
|
47
|
+
return { file: m[1], line: Number(m[2]), col: Number(m[3]) };
|
|
48
|
+
}
|
|
49
|
+
m = FIREFOX.exec(line);
|
|
50
|
+
if (m) {
|
|
51
|
+
const frame = { file: m[2], line: Number(m[3]), col: Number(m[4]) };
|
|
52
|
+
if (m[1])
|
|
53
|
+
frame.fn = m[1];
|
|
54
|
+
return frame;
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
function parseStack(stack) {
|
|
59
|
+
const frames = [];
|
|
60
|
+
for (const rawLine of stack.split(`
|
|
61
|
+
`)) {
|
|
62
|
+
if (frames.length >= MAX_FRAMES)
|
|
63
|
+
break;
|
|
64
|
+
const line = rawLine.trim();
|
|
65
|
+
if (!line)
|
|
66
|
+
continue;
|
|
67
|
+
const frame = parseLine(line);
|
|
68
|
+
if (frame)
|
|
69
|
+
frames.push(frame);
|
|
70
|
+
}
|
|
71
|
+
return frames;
|
|
72
|
+
}
|
|
73
|
+
// src/privacy/collect.ts
|
|
74
|
+
var STRUCTURAL_ATTRS = new Set(["class", "type", "contenteditable"]);
|
|
75
|
+
function pathOf(el, root) {
|
|
76
|
+
const parts = [];
|
|
77
|
+
let node = el;
|
|
78
|
+
while (node && node !== root) {
|
|
79
|
+
const parent = node.parentElement;
|
|
80
|
+
if (!parent)
|
|
81
|
+
break;
|
|
82
|
+
const index = Array.prototype.indexOf.call(parent.children, node) + 1;
|
|
83
|
+
parts.unshift(`${node.tagName.toLowerCase()}:nth-child(${index})`);
|
|
84
|
+
node = parent;
|
|
85
|
+
}
|
|
86
|
+
return parts.join(" > ");
|
|
87
|
+
}
|
|
88
|
+
function collectRecordedStrings(root) {
|
|
89
|
+
const out = [];
|
|
90
|
+
const visit = (el) => {
|
|
91
|
+
const decision = decide(el);
|
|
92
|
+
if (decision === "exclude")
|
|
93
|
+
return;
|
|
94
|
+
const path = pathOf(el, root);
|
|
95
|
+
if (decision === "record" || decision === "mask") {
|
|
96
|
+
const valueIsMasked = masksValue(el);
|
|
97
|
+
for (const attr of Array.from(el.attributes)) {
|
|
98
|
+
const name = attr.name.toLowerCase();
|
|
99
|
+
if (STRUCTURAL_ATTRS.has(name))
|
|
100
|
+
continue;
|
|
101
|
+
if (name === "value" && valueIsMasked)
|
|
102
|
+
continue;
|
|
103
|
+
const scrubbed = scrubAttribute(el, attr.name, attr.value);
|
|
104
|
+
if (scrubbed === null || scrubbed === MASK_TOKEN)
|
|
105
|
+
continue;
|
|
106
|
+
if (scrubbed === "")
|
|
107
|
+
continue;
|
|
108
|
+
out.push({ value: scrubbed, path, origin: "attribute", attribute: attr.name });
|
|
109
|
+
}
|
|
110
|
+
if (decision === "record" && !valueIsMasked) {
|
|
111
|
+
for (const child of Array.from(el.childNodes)) {
|
|
112
|
+
if (child.nodeType !== 3)
|
|
113
|
+
continue;
|
|
114
|
+
const text = (child.textContent ?? "").trim();
|
|
115
|
+
if (text)
|
|
116
|
+
out.push({ value: text, path, origin: "text" });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
for (const child of Array.from(el.children))
|
|
121
|
+
visit(child);
|
|
122
|
+
};
|
|
123
|
+
visit(root);
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// src/index.ts
|
|
128
|
+
var MAX_MESSAGE_LEN = 8192;
|
|
129
|
+
var MAX_KIND_SITE_LEN = 256;
|
|
130
|
+
var MAX_FRAME_FIELD_LEN = 512;
|
|
131
|
+
var MAX_FLAG_KEYS = 64;
|
|
132
|
+
var MAX_ATTR_KEYS = 64;
|
|
133
|
+
var MAX_ATTR_KEY_LEN = 128;
|
|
134
|
+
function isScalar(v) {
|
|
135
|
+
return typeof v === "string" || typeof v === "number" || typeof v === "boolean";
|
|
136
|
+
}
|
|
137
|
+
function truncate(s, max) {
|
|
138
|
+
return s.length > max ? s.slice(0, max) : s;
|
|
139
|
+
}
|
|
140
|
+
function sanitizeMessage(err) {
|
|
141
|
+
let raw;
|
|
142
|
+
if (err && typeof err === "object" && typeof err.message === "string") {
|
|
143
|
+
raw = err.message;
|
|
144
|
+
} else {
|
|
145
|
+
raw = String(err);
|
|
146
|
+
}
|
|
147
|
+
return truncate(raw.length > 0 ? raw : "(no message)", MAX_MESSAGE_LEN);
|
|
148
|
+
}
|
|
149
|
+
function sanitizeKind(err) {
|
|
150
|
+
if (err && typeof err === "object") {
|
|
151
|
+
const name = err.name;
|
|
152
|
+
if (typeof name === "string" && name.length > 0)
|
|
153
|
+
return truncate(name, MAX_KIND_SITE_LEN);
|
|
154
|
+
}
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
function triggerSourceFor(kind, err) {
|
|
158
|
+
const name = sanitizeKind(err);
|
|
159
|
+
return name !== undefined ? { kind, name } : { kind };
|
|
160
|
+
}
|
|
161
|
+
function sanitizeStack(err) {
|
|
162
|
+
if (!err || typeof err !== "object")
|
|
163
|
+
return [];
|
|
164
|
+
const stack = err.stack;
|
|
165
|
+
if (typeof stack !== "string")
|
|
166
|
+
return [];
|
|
167
|
+
return parseStack(stack).map((f) => {
|
|
168
|
+
const out = {};
|
|
169
|
+
if (f.fn !== undefined)
|
|
170
|
+
out.fn = truncate(f.fn, MAX_FRAME_FIELD_LEN);
|
|
171
|
+
if (f.file !== undefined)
|
|
172
|
+
out.file = truncate(f.file, MAX_FRAME_FIELD_LEN);
|
|
173
|
+
if (typeof f.line === "number")
|
|
174
|
+
out.line = f.line;
|
|
175
|
+
if (typeof f.col === "number")
|
|
176
|
+
out.col = f.col;
|
|
177
|
+
return out;
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
function sanitizeFlags(flags) {
|
|
181
|
+
const out = {};
|
|
182
|
+
for (const k of Object.keys(flags).slice(0, MAX_FLAG_KEYS)) {
|
|
183
|
+
const v = flags[k];
|
|
184
|
+
if (isScalar(v))
|
|
185
|
+
out[k] = v;
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
function sanitizeAttrs(attrs) {
|
|
190
|
+
const out = {};
|
|
191
|
+
for (const k of Object.keys(attrs).slice(0, MAX_ATTR_KEYS)) {
|
|
192
|
+
if (k.length > MAX_ATTR_KEY_LEN)
|
|
193
|
+
continue;
|
|
194
|
+
const v = attrs[k];
|
|
195
|
+
if (isScalar(v)) {
|
|
196
|
+
out[k] = v;
|
|
197
|
+
} else if (Array.isArray(v)) {
|
|
198
|
+
const scalars = v.filter(isScalar);
|
|
199
|
+
if (scalars.length > 0)
|
|
200
|
+
out[k] = scalars;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
var RECONNECT_BACKOFF_MS = 1000;
|
|
206
|
+
function buildBootstrapBody(context) {
|
|
207
|
+
const { contextKey, application, release, sessionId, attributes } = context;
|
|
208
|
+
const pharos = { contextKey };
|
|
209
|
+
if (application !== undefined)
|
|
210
|
+
pharos.application = application;
|
|
211
|
+
if (release !== undefined)
|
|
212
|
+
pharos.release = release;
|
|
213
|
+
if (sessionId !== undefined)
|
|
214
|
+
pharos.sessionId = sessionId;
|
|
215
|
+
return { pharos, attributes: attributes ?? {} };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
class PharosBrowserClient {
|
|
219
|
+
baseUrl;
|
|
220
|
+
clientKey;
|
|
221
|
+
context;
|
|
222
|
+
eventSourceFactory;
|
|
223
|
+
flags = {};
|
|
224
|
+
version = -Infinity;
|
|
225
|
+
streamToken = null;
|
|
226
|
+
eventSource = null;
|
|
227
|
+
listeners = {
|
|
228
|
+
change: new Set,
|
|
229
|
+
error: new Set
|
|
230
|
+
};
|
|
231
|
+
closed = false;
|
|
232
|
+
reconnectTimer = null;
|
|
233
|
+
errorsEnabled;
|
|
234
|
+
errorTarget;
|
|
235
|
+
replayHandle = null;
|
|
236
|
+
replayTriggerKind = "exception";
|
|
237
|
+
replayErrorListeners = null;
|
|
238
|
+
onReplayError = (event) => {
|
|
239
|
+
const e = event;
|
|
240
|
+
this.replayHandle?.trigger(triggerSourceFor("exception", e?.error ?? e?.message));
|
|
241
|
+
};
|
|
242
|
+
onReplayRejection = (event) => {
|
|
243
|
+
const e = event;
|
|
244
|
+
this.replayHandle?.trigger(triggerSourceFor("unhandledrejection", e?.reason));
|
|
245
|
+
};
|
|
246
|
+
constructor(config) {
|
|
247
|
+
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
|
248
|
+
this.clientKey = config.clientKey;
|
|
249
|
+
this.context = config.context;
|
|
250
|
+
this.eventSourceFactory = config.eventSourceFactory ?? ((url) => new EventSource(url));
|
|
251
|
+
this.errorsEnabled = config.errors ?? true;
|
|
252
|
+
this.errorTarget = config.errorTarget ?? globalThis;
|
|
253
|
+
}
|
|
254
|
+
static async init(config) {
|
|
255
|
+
const client = new PharosBrowserClient(config);
|
|
256
|
+
await client.bootstrap();
|
|
257
|
+
client.connectStream();
|
|
258
|
+
if (client.errorsEnabled)
|
|
259
|
+
client.registerErrorListeners();
|
|
260
|
+
return client;
|
|
261
|
+
}
|
|
262
|
+
async bootstrap() {
|
|
263
|
+
const body = buildBootstrapBody(this.context);
|
|
264
|
+
const res = await fetch(`${this.baseUrl}/api/v1/client/bootstrap`, {
|
|
265
|
+
method: "POST",
|
|
266
|
+
headers: {
|
|
267
|
+
"Content-Type": "application/json",
|
|
268
|
+
Authorization: `Bearer ${this.clientKey}`
|
|
269
|
+
},
|
|
270
|
+
body: JSON.stringify(body)
|
|
271
|
+
});
|
|
272
|
+
if (!res.ok) {
|
|
273
|
+
throw new Error(`pharos bootstrap failed: HTTP ${res.status}`);
|
|
274
|
+
}
|
|
275
|
+
const data = await res.json();
|
|
276
|
+
this.flags = data.flags ?? {};
|
|
277
|
+
this.version = data.version ?? 0;
|
|
278
|
+
this.streamToken = data.streamToken;
|
|
279
|
+
}
|
|
280
|
+
connectStream() {
|
|
281
|
+
if (!this.streamToken)
|
|
282
|
+
return;
|
|
283
|
+
const url = `${this.baseUrl}/api/v1/client/stream?token=${encodeURIComponent(this.streamToken)}`;
|
|
284
|
+
const es = this.eventSourceFactory(url);
|
|
285
|
+
es.addEventListener("flags", (event) => {
|
|
286
|
+
try {
|
|
287
|
+
const payload = JSON.parse(event.data);
|
|
288
|
+
if (typeof payload.version === "number" && payload.version < this.version) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
this.flags = payload.flags ?? {};
|
|
292
|
+
this.version = payload.version;
|
|
293
|
+
this.emit("change", payload);
|
|
294
|
+
} catch (err) {
|
|
295
|
+
this.emit("error", err);
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
es.onerror = () => {
|
|
299
|
+
this.emit("error", { type: "stream-error" });
|
|
300
|
+
this.maybeReconnect(es);
|
|
301
|
+
};
|
|
302
|
+
this.eventSource = es;
|
|
303
|
+
}
|
|
304
|
+
maybeReconnect(es) {
|
|
305
|
+
if (this.closed || this.reconnectTimer)
|
|
306
|
+
return;
|
|
307
|
+
if (es.readyState !== 2)
|
|
308
|
+
return;
|
|
309
|
+
this.reconnectTimer = setTimeout(() => {
|
|
310
|
+
this.reconnectTimer = null;
|
|
311
|
+
if (this.closed)
|
|
312
|
+
return;
|
|
313
|
+
this.identify(this.context).catch((err) => this.emit("error", err));
|
|
314
|
+
}, RECONNECT_BACKOFF_MS);
|
|
315
|
+
}
|
|
316
|
+
onWindowError = (event) => {
|
|
317
|
+
const e = event;
|
|
318
|
+
this.captureException(e?.error ?? e?.message);
|
|
319
|
+
};
|
|
320
|
+
onUnhandledRejection = (event) => {
|
|
321
|
+
const e = event;
|
|
322
|
+
this.replayTriggerKind = "unhandledrejection";
|
|
323
|
+
try {
|
|
324
|
+
this.captureException(e?.reason);
|
|
325
|
+
} finally {
|
|
326
|
+
this.replayTriggerKind = "exception";
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
registerErrorListeners() {
|
|
330
|
+
this.errorTarget.addEventListener("error", this.onWindowError);
|
|
331
|
+
this.errorTarget.addEventListener("unhandledrejection", this.onUnhandledRejection);
|
|
332
|
+
}
|
|
333
|
+
unregisterErrorListeners() {
|
|
334
|
+
this.errorTarget.removeEventListener("error", this.onWindowError);
|
|
335
|
+
this.errorTarget.removeEventListener("unhandledrejection", this.onUnhandledRejection);
|
|
336
|
+
}
|
|
337
|
+
captureException(err, opts) {
|
|
338
|
+
if (this.closed)
|
|
339
|
+
return;
|
|
340
|
+
const entry = { message: sanitizeMessage(err) };
|
|
341
|
+
const kind = sanitizeKind(err);
|
|
342
|
+
if (kind !== undefined)
|
|
343
|
+
entry.kind = kind;
|
|
344
|
+
this.replayHandle?.trigger(triggerSourceFor(this.replayTriggerKind, err));
|
|
345
|
+
if (opts?.site)
|
|
346
|
+
entry.site = truncate(opts.site, MAX_KIND_SITE_LEN);
|
|
347
|
+
const stack = sanitizeStack(err);
|
|
348
|
+
if (stack.length > 0)
|
|
349
|
+
entry.stack = stack;
|
|
350
|
+
const flags = sanitizeFlags(this.flags);
|
|
351
|
+
if (Object.keys(flags).length > 0)
|
|
352
|
+
entry.flags = flags;
|
|
353
|
+
if (opts?.attributes) {
|
|
354
|
+
const attrs = sanitizeAttrs(opts.attributes);
|
|
355
|
+
if (Object.keys(attrs).length > 0)
|
|
356
|
+
entry.attrs = attrs;
|
|
357
|
+
}
|
|
358
|
+
const pharos = { contextKey: this.context.contextKey };
|
|
359
|
+
if (this.context.application !== undefined)
|
|
360
|
+
pharos.application = this.context.application;
|
|
361
|
+
if (this.context.release !== undefined)
|
|
362
|
+
pharos.release = this.context.release;
|
|
363
|
+
if (this.context.sessionId !== undefined)
|
|
364
|
+
pharos.sessionId = this.context.sessionId;
|
|
365
|
+
fetch(`${this.baseUrl}/api/v1/errors`, {
|
|
366
|
+
method: "POST",
|
|
367
|
+
headers: {
|
|
368
|
+
"Content-Type": "application/json",
|
|
369
|
+
Authorization: `Bearer ${this.clientKey}`
|
|
370
|
+
},
|
|
371
|
+
body: JSON.stringify({ pharos, errors: [entry] })
|
|
372
|
+
}).catch((err2) => this.emit("error", err2));
|
|
373
|
+
}
|
|
374
|
+
attachReplay(handle) {
|
|
375
|
+
if (this.closed)
|
|
376
|
+
return;
|
|
377
|
+
if (this.replayHandle !== null && this.replayHandle !== handle) {
|
|
378
|
+
throw new Error("pharos: a session recorder is already attached — call detachReplay() " + "before attaching another. rrweb records into one process-wide " + "session, so a second startRecording() silently takes over the first.");
|
|
379
|
+
}
|
|
380
|
+
this.warnIfSessionIdsDisagree(handle);
|
|
381
|
+
this.replayHandle = handle;
|
|
382
|
+
if (this.errorsEnabled || this.replayErrorListeners)
|
|
383
|
+
return;
|
|
384
|
+
this.errorTarget.addEventListener("error", this.onReplayError);
|
|
385
|
+
this.errorTarget.addEventListener("unhandledrejection", this.onReplayRejection);
|
|
386
|
+
this.replayErrorListeners = () => {
|
|
387
|
+
this.errorTarget.removeEventListener("error", this.onReplayError);
|
|
388
|
+
this.errorTarget.removeEventListener("unhandledrejection", this.onReplayRejection);
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
warnIfSessionIdsDisagree(handle) {
|
|
392
|
+
const ctxId = this.context.sessionId;
|
|
393
|
+
if (ctxId === undefined)
|
|
394
|
+
return;
|
|
395
|
+
const replayId = handle.sessionId;
|
|
396
|
+
if (typeof replayId !== "string" || replayId === ctxId)
|
|
397
|
+
return;
|
|
398
|
+
console.warn("pharos: this recorder uploads under sessionId " + `"${replayId}" while errors are reported under "${ctxId}". ` + "Replay windows cannot be joined to errors. Pass " + "`sessionId: <PharosContext.sessionId>` to startReplayUpload(), or " + "use client.startReplay() which does it for you.");
|
|
399
|
+
}
|
|
400
|
+
async startReplay(options = {}) {
|
|
401
|
+
if (this.closed)
|
|
402
|
+
return null;
|
|
403
|
+
const { startReplayUpload } = await import("./wire-992wvzs1.js");
|
|
404
|
+
if (this.closed)
|
|
405
|
+
return null;
|
|
406
|
+
const handle = startReplayUpload({
|
|
407
|
+
...options,
|
|
408
|
+
endpoint: `${this.baseUrl}/api/v1/client/replay`,
|
|
409
|
+
appKey: this.clientKey,
|
|
410
|
+
...this.context.sessionId !== undefined ? { sessionId: this.context.sessionId } : {}
|
|
411
|
+
});
|
|
412
|
+
try {
|
|
413
|
+
this.attachReplay(handle);
|
|
414
|
+
} catch (e) {
|
|
415
|
+
handle.stop();
|
|
416
|
+
throw e;
|
|
417
|
+
}
|
|
418
|
+
return handle;
|
|
419
|
+
}
|
|
420
|
+
detachReplay() {
|
|
421
|
+
this.replayHandle = null;
|
|
422
|
+
this.replayErrorListeners?.();
|
|
423
|
+
this.replayErrorListeners = null;
|
|
424
|
+
}
|
|
425
|
+
flag(key, defaultValue) {
|
|
426
|
+
if (Object.prototype.hasOwnProperty.call(this.flags, key)) {
|
|
427
|
+
return this.flags[key];
|
|
428
|
+
}
|
|
429
|
+
return defaultValue;
|
|
430
|
+
}
|
|
431
|
+
on(event, cb) {
|
|
432
|
+
this.listeners[event].add(cb);
|
|
433
|
+
return () => {
|
|
434
|
+
this.listeners[event].delete(cb);
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
emit(event, payload) {
|
|
438
|
+
for (const cb of this.listeners[event]) {
|
|
439
|
+
cb(payload);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
async identify(context) {
|
|
443
|
+
if (this.closed)
|
|
444
|
+
return;
|
|
445
|
+
this.context = context;
|
|
446
|
+
if (this.eventSource) {
|
|
447
|
+
this.eventSource.close();
|
|
448
|
+
this.eventSource = null;
|
|
449
|
+
}
|
|
450
|
+
await this.bootstrap();
|
|
451
|
+
if (!this.closed) {
|
|
452
|
+
this.connectStream();
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
close() {
|
|
456
|
+
this.closed = true;
|
|
457
|
+
if (this.reconnectTimer) {
|
|
458
|
+
clearTimeout(this.reconnectTimer);
|
|
459
|
+
this.reconnectTimer = null;
|
|
460
|
+
}
|
|
461
|
+
if (this.eventSource) {
|
|
462
|
+
this.eventSource.close();
|
|
463
|
+
this.eventSource = null;
|
|
464
|
+
}
|
|
465
|
+
if (this.errorsEnabled) {
|
|
466
|
+
this.unregisterErrorListeners();
|
|
467
|
+
}
|
|
468
|
+
this.replayHandle = null;
|
|
469
|
+
this.replayErrorListeners?.();
|
|
470
|
+
this.replayErrorListeners = null;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
export {
|
|
474
|
+
ACTIVATION_EVENT_TAG,
|
|
475
|
+
EXCLUDE_CLASS,
|
|
476
|
+
LOCAL_TTL_MS,
|
|
477
|
+
MASK_CLASS,
|
|
478
|
+
MASK_TOKEN,
|
|
479
|
+
MAX_BUFFER_BYTES,
|
|
480
|
+
MAX_WINDOW_BYTES,
|
|
481
|
+
PharosBrowserClient,
|
|
482
|
+
RECORDER_DEFAULTS,
|
|
483
|
+
beginsWithSnapshot,
|
|
484
|
+
clearPersisted,
|
|
485
|
+
collectRecordedStrings,
|
|
486
|
+
createDropLedger,
|
|
487
|
+
createUploadSink,
|
|
488
|
+
decide,
|
|
489
|
+
drainPersisted,
|
|
490
|
+
encodeWindow,
|
|
491
|
+
envelopeMetaFor,
|
|
492
|
+
isSensitiveField,
|
|
493
|
+
maskText,
|
|
494
|
+
masksValue,
|
|
495
|
+
persistWindow,
|
|
496
|
+
postEnvelope,
|
|
497
|
+
quotaDroppedWindows,
|
|
498
|
+
resetQuotaDroppedWindows,
|
|
499
|
+
resolveConfig,
|
|
500
|
+
scrubAttribute,
|
|
501
|
+
scrubUrl,
|
|
502
|
+
shouldExclude,
|
|
503
|
+
shouldMaskByMarker
|
|
504
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep scheme, host and path; drop `?query` and `#fragment`, which is where
|
|
3
|
+
* typed search text and record ids overwhelmingly live — the shape of the
|
|
4
|
+
* #792 leak. Non-http(s) schemes are masked entirely: a `mailto:` path IS an
|
|
5
|
+
* email address and a `data:` URL can embed the content itself. URL userinfo
|
|
6
|
+
* (`user:pw@host`) is dropped as a side effect of reconstructing the output
|
|
7
|
+
* from `protocol`/`host`/`pathname` rather than copying `url.href` verbatim —
|
|
8
|
+
* do not "simplify" this back to `url.href`, or credentials come back.
|
|
9
|
+
*/
|
|
10
|
+
export declare function scrubUrl(value: string): string;
|
|
11
|
+
/** Returns the value to record, or `null` to omit the attribute entirely. */
|
|
12
|
+
export declare function scrubAttribute(el: Element, name: string, value: string): string | null;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface RecordedString {
|
|
2
|
+
value: string;
|
|
3
|
+
path: string;
|
|
4
|
+
origin: 'text' | 'value' | 'attribute';
|
|
5
|
+
attribute?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare const STRUCTURAL_ATTRS: Set<string>;
|
|
8
|
+
export declare function collectRecordedStrings(root: Element): RecordedString[];
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const EXCLUDE_CLASS = "pharos-exclude";
|
|
2
|
+
export declare const MASK_CLASS = "pharos-mask";
|
|
3
|
+
export declare function hasMarkerAncestor(el: Element, cls: string): boolean;
|
|
4
|
+
/** The subtree is recorded as an empty same-tag element: contents never leave. */
|
|
5
|
+
export declare function shouldExclude(el: Element): boolean;
|
|
6
|
+
/** Structure and attributes are recorded; text and values are masked. */
|
|
7
|
+
export declare function shouldMaskByMarker(el: Element): boolean;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type Decision = 'exclude' | 'mask' | 'record';
|
|
2
|
+
export declare function decide(el: Element): Decision;
|
|
3
|
+
/**
|
|
4
|
+
* Whether this element's VALUE must be masked — always true for anything the
|
|
5
|
+
* user types into, regardless of markers or detection (spec §3.3).
|
|
6
|
+
*
|
|
7
|
+
* CONTENTEDITABLE IS INHERITED, AND THAT IS THE WHOLE POINT OF THE ANCESTOR
|
|
8
|
+
* CHECK. A browser stores multi-line contenteditable content in CHILD
|
|
9
|
+
* elements — a `<div>`/`<p>` per line on Enter, a `<span>` per styled run —
|
|
10
|
+
* and those children carry no `contenteditable` attribute of their own.
|
|
11
|
+
* Reading only `el.getAttribute('contenteditable')` (as this did until
|
|
12
|
+
* 2026-09-02) therefore answered `false` for essentially every real
|
|
13
|
+
* contenteditable region, and the typed text in those children was recorded
|
|
14
|
+
* verbatim: the #792 leak shape, through the one input surface that has
|
|
15
|
+
* element children. Do NOT "simplify" this back to a self-only attribute
|
|
16
|
+
* read.
|
|
17
|
+
*
|
|
18
|
+
* CALLING CONTRACT: this does NOT consult `pharos-exclude`/`pharos-mask` or
|
|
19
|
+
* run field-sensitivity detection, by design — that is `decide()`'s job.
|
|
20
|
+
* Call this only AFTER `decide(el)` has returned `'record'` or `'mask'`, never
|
|
21
|
+
* on an element `decide()` said to exclude: an excluded element's subtree is
|
|
22
|
+
* replaced wholesale (see `markers.ts`), so asking this function about it is
|
|
23
|
+
* a category error, not a stricter check. A recorder that calls this
|
|
24
|
+
* standalone, without routing the element through `decide()` first, will
|
|
25
|
+
* record values `decide()` would have excluded outright.
|
|
26
|
+
*/
|
|
27
|
+
export declare function masksValue(el: Element): boolean;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function isSensitiveField(el: Element): boolean;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { DegradationRecord, ResolvedRecorderConfig } from './config';
|
|
2
|
+
/** The browser's own long-task threshold. A `PerformanceObserver` longtask entry is ≥ this by definition. */
|
|
3
|
+
export declare const LONG_TASK_MS = 50;
|
|
4
|
+
/** Minimum gap between escalations, so one janky second does not collapse straight to the floor. */
|
|
5
|
+
export declare const ESCALATION_COOLDOWN_MS = 5000;
|
|
6
|
+
/** Quiet period before stepping back up one level. */
|
|
7
|
+
export declare const RECOVERY_AFTER_MS = 30000;
|
|
8
|
+
export declare const MAX_DEGRADATION_LEVEL = 3;
|
|
9
|
+
/** Rate limit on memory-ceiling records: a heavy page evicts constantly and must not emit thousands. */
|
|
10
|
+
export declare const CEILING_RECORD_COOLDOWN_MS = 10000;
|
|
11
|
+
export interface Budget {
|
|
12
|
+
level(): number;
|
|
13
|
+
checkpointIntervalMs(): number;
|
|
14
|
+
mousemoveWaitMs(): number;
|
|
15
|
+
/** Returns true when the level actually changed, so the caller can re-apply rates. */
|
|
16
|
+
noteLongTask(durationMs: number): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* One segment dropped for the byte ceiling. Call this once per eviction —
|
|
19
|
+
* i.e. wire it straight to `ReplayBuffer`'s `onCeilingEviction`, which fires
|
|
20
|
+
* once per dropped segment and in lockstep with `evictedForBytes`. Bursts
|
|
21
|
+
* are collapsed HERE, with the count preserved; see the emission comment
|
|
22
|
+
* below for why the caller must not pre-aggregate.
|
|
23
|
+
*/
|
|
24
|
+
noteCeilingEviction(): void;
|
|
25
|
+
/** Returns true when the level actually changed. */
|
|
26
|
+
maybeRecover(): boolean;
|
|
27
|
+
/** A snapshot. Callers may retain it; later coalescing never rewrites it. */
|
|
28
|
+
records(): DegradationRecord[];
|
|
29
|
+
}
|
|
30
|
+
export declare function createBudget(opts: {
|
|
31
|
+
config: ResolvedRecorderConfig;
|
|
32
|
+
now: () => number;
|
|
33
|
+
}): Budget;
|
|
34
|
+
/**
|
|
35
|
+
* Wires `PerformanceObserver` long-task entries into a budget. Returns a
|
|
36
|
+
* detach function.
|
|
37
|
+
*
|
|
38
|
+
* Long-task observation is optional: Safari ships the constructor but not the
|
|
39
|
+
* `longtask` entry type, and an engine asked for an entry type it does not
|
|
40
|
+
* support either throws or quietly observes nothing. BOTH ARE HANDLED — the
|
|
41
|
+
* throw by the `catch` below, the silence by simply never degrading — which is
|
|
42
|
+
* why this swallows the failure rather than reporting it.
|
|
43
|
+
*
|
|
44
|
+
* happy-dom DOES define `PerformanceObserver` and accepts the `observe` call,
|
|
45
|
+
* but never emits a `longtask` entry — so tests reach this function's success
|
|
46
|
+
* path and the escalation path below is unreachable without a stub. Do not
|
|
47
|
+
* assume the test environment lacks the constructor; it does not.
|
|
48
|
+
*/
|
|
49
|
+
export declare function observeLongTasks(budget: Budget, onEscalate: () => void): () => void;
|