@aeres/farol-web 0.0.0-stage → 0.2.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 +167 -2
- package/dist/index.cjs +1273 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +1273 -0
- package/dist/index.js.map +1 -0
- package/dist/network.d.ts +18 -0
- package/dist/react.cjs +56 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.ts +26 -0
- package/dist/react.js +56 -0
- package/dist/react.js.map +1 -0
- package/dist/serialize.d.ts +13 -0
- package/dist/types.d.ts +77 -0
- package/package.json +54 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,1273 @@
|
|
|
1
|
+
const REDACTED = "[REDACTED]";
|
|
2
|
+
const sensitiveKey = /(?:authorization|cookie|password|passwd|secret|token|api[-_]?key|credential|session[-_]?id|private[-_]?key)/i;
|
|
3
|
+
const isSensitiveKey = (key) => sensitiveKey.test(key);
|
|
4
|
+
function redactUrl(value) {
|
|
5
|
+
try {
|
|
6
|
+
const url = new URL(value, "http://farol.invalid");
|
|
7
|
+
if (url.username) url.username = REDACTED;
|
|
8
|
+
if (url.password) url.password = REDACTED;
|
|
9
|
+
for (const key of [...url.searchParams.keys()])
|
|
10
|
+
if (isSensitiveKey(key)) url.searchParams.set(key, REDACTED);
|
|
11
|
+
if (url.hash && /(?:token|password|secret|api[-_]?key|code)=/i.test(url.hash))
|
|
12
|
+
url.hash = REDACTED;
|
|
13
|
+
const result = url.toString();
|
|
14
|
+
return result.startsWith("http://farol.invalid") ? `${url.pathname}${url.search}${url.hash}` : result;
|
|
15
|
+
} catch {
|
|
16
|
+
return value.replace(
|
|
17
|
+
/([?&](?:[^=&]*(?:token|password|secret|api[-_]?key)[^=&]*)=)[^&#\s]*/gi,
|
|
18
|
+
`$1${REDACTED}`
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function redactText(value, max) {
|
|
23
|
+
let text = value;
|
|
24
|
+
if (text.length <= 65536 && /^[\s]*[\[{]/.test(text)) {
|
|
25
|
+
try {
|
|
26
|
+
const encoded = JSON.stringify(
|
|
27
|
+
safeSerialize(JSON.parse(text), { maxString: max })
|
|
28
|
+
);
|
|
29
|
+
return encoded.length > max ? `${encoded.slice(0, max)}…[truncated]` : encoded;
|
|
30
|
+
} catch {
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
text = text.replace(
|
|
34
|
+
/\b(?:https?|wss?):\/\/[^\s"'<>]+/gi,
|
|
35
|
+
(url) => redactUrl(url)
|
|
36
|
+
);
|
|
37
|
+
if (/^(?:\/|[a-z][a-z\d+.-]*:\/\/)/i.test(text)) text = redactUrl(text);
|
|
38
|
+
text = text.replace(/\b(Bearer|Basic)\s+[A-Za-z\d+/_=.-]+/gi, `$1 ${REDACTED}`).replace(
|
|
39
|
+
/((?:(?:password|passwd|secret|token|api[-_]?key|authorization|cookie)[\w-]{0,40})["']?\s*[:=]\s*["']?)[^\s,"'&;}]+/gi,
|
|
40
|
+
`$1${REDACTED}`
|
|
41
|
+
);
|
|
42
|
+
return text.length > max ? `${text.slice(0, max)}…[truncated]` : text;
|
|
43
|
+
}
|
|
44
|
+
function safeSerialize(value, limits = {}) {
|
|
45
|
+
const depthLimit = limits.maxDepth ?? 6;
|
|
46
|
+
const entriesLimit = limits.maxEntries ?? 80;
|
|
47
|
+
const stringLimit = limits.maxString ?? 4096;
|
|
48
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
49
|
+
let budget = 600;
|
|
50
|
+
function visit(item, depth, key = "") {
|
|
51
|
+
if (isSensitiveKey(key)) return REDACTED;
|
|
52
|
+
if (budget-- <= 0) return "[Truncated]";
|
|
53
|
+
if (item === null || typeof item === "boolean" || typeof item === "number")
|
|
54
|
+
return typeof item === "number" && !Number.isFinite(item) ? String(item) : item;
|
|
55
|
+
if (typeof item === "string") return redactText(item, stringLimit);
|
|
56
|
+
if (typeof item === "bigint") return `${item}n`;
|
|
57
|
+
if (typeof item === "undefined") return "[undefined]";
|
|
58
|
+
if (typeof item === "function")
|
|
59
|
+
return `[Function ${item.name || "anonymous"}]`;
|
|
60
|
+
if (typeof item === "symbol") return String(item);
|
|
61
|
+
if (depth >= depthLimit) return "[Max depth]";
|
|
62
|
+
const obj = item;
|
|
63
|
+
if (seen.has(obj)) return "[Circular]";
|
|
64
|
+
seen.add(obj);
|
|
65
|
+
if (obj instanceof Error)
|
|
66
|
+
return {
|
|
67
|
+
name: obj.name,
|
|
68
|
+
message: redactText(obj.message, stringLimit),
|
|
69
|
+
stack: obj.stack ? redactText(obj.stack, stringLimit) : void 0,
|
|
70
|
+
..."cause" in obj ? { cause: visit(obj.cause, depth + 1) } : {}
|
|
71
|
+
};
|
|
72
|
+
if (obj instanceof Date)
|
|
73
|
+
return Number.isFinite(obj.getTime()) ? obj.toISOString() : "Invalid Date";
|
|
74
|
+
if (typeof URL !== "undefined" && obj instanceof URL)
|
|
75
|
+
return redactUrl(obj.href);
|
|
76
|
+
if (typeof Headers !== "undefined" && obj instanceof Headers)
|
|
77
|
+
return visit(Object.fromEntries(obj.entries()), depth + 1);
|
|
78
|
+
if (typeof Element !== "undefined" && obj instanceof Element)
|
|
79
|
+
return `<${obj.tagName.toLowerCase()}${obj.id ? `#${obj.id}` : ""}>`;
|
|
80
|
+
if (typeof Blob !== "undefined" && obj instanceof Blob)
|
|
81
|
+
return `[Blob ${obj.type || "binary"}; ${obj.size} bytes]`;
|
|
82
|
+
if (obj instanceof Map) {
|
|
83
|
+
const entries = [];
|
|
84
|
+
for (const [mapKey, mapValue] of obj) {
|
|
85
|
+
if (entries.length >= entriesLimit) break;
|
|
86
|
+
entries.push([
|
|
87
|
+
visit(mapKey, depth + 1),
|
|
88
|
+
visit(mapValue, depth + 1, typeof mapKey === "string" ? mapKey : "")
|
|
89
|
+
]);
|
|
90
|
+
}
|
|
91
|
+
if (obj.size > entriesLimit)
|
|
92
|
+
entries.push(`[${obj.size - entriesLimit} more items]`);
|
|
93
|
+
return entries;
|
|
94
|
+
}
|
|
95
|
+
if (obj instanceof Set) {
|
|
96
|
+
const items = [];
|
|
97
|
+
for (const value2 of obj) {
|
|
98
|
+
if (items.length >= entriesLimit) break;
|
|
99
|
+
items.push(visit(value2, depth + 1));
|
|
100
|
+
}
|
|
101
|
+
if (obj.size > entriesLimit)
|
|
102
|
+
items.push(`[${obj.size - entriesLimit} more items]`);
|
|
103
|
+
return items;
|
|
104
|
+
}
|
|
105
|
+
if (ArrayBuffer.isView(obj))
|
|
106
|
+
return `[${obj.constructor.name}; ${obj.byteLength} bytes]`;
|
|
107
|
+
if (Array.isArray(obj))
|
|
108
|
+
return obj.slice(0, entriesLimit).map((child) => visit(child, depth + 1)).concat(
|
|
109
|
+
obj.length > entriesLimit ? [`[${obj.length - entriesLimit} more items]`] : []
|
|
110
|
+
);
|
|
111
|
+
const result = {};
|
|
112
|
+
let descriptors;
|
|
113
|
+
try {
|
|
114
|
+
descriptors = Object.getOwnPropertyDescriptors(obj);
|
|
115
|
+
} catch {
|
|
116
|
+
return "[Uninspectable]";
|
|
117
|
+
}
|
|
118
|
+
const keys = Object.keys(descriptors).filter(
|
|
119
|
+
(k) => descriptors[k].enumerable
|
|
120
|
+
);
|
|
121
|
+
for (const property of keys.slice(0, entriesLimit)) {
|
|
122
|
+
const desc = descriptors[property];
|
|
123
|
+
Object.defineProperty(result, property, {
|
|
124
|
+
value: isSensitiveKey(property) ? REDACTED : "value" in desc ? visit(desc.value, depth + 1, property) : "[Getter]",
|
|
125
|
+
enumerable: true,
|
|
126
|
+
configurable: true,
|
|
127
|
+
writable: true
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
if (keys.length > entriesLimit)
|
|
131
|
+
result["…"] = `${keys.length - entriesLimit} more keys`;
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
return visit(value, 0);
|
|
136
|
+
} catch {
|
|
137
|
+
return "[Unserializable]";
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const serializationMarker = /\[(?:REDACTED|Max depth|Circular|Truncated(?:[^\]]*)?|undefined|Uninspectable|Unserializable|Getter|Function(?:[^\]]*)|Blob(?:[^\]]*)|(?:[A-Za-z]\w*Array|DataView); \d+ bytes|\d+ more items|Body (?:omitted|unavailable)(?:[^\]]*))\]/i;
|
|
141
|
+
function containsSerializationMarkers(value) {
|
|
142
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
143
|
+
let budget = 1e4;
|
|
144
|
+
function check(item, depth) {
|
|
145
|
+
if (budget-- <= 0 || depth > 40) return true;
|
|
146
|
+
if (typeof item === "string") {
|
|
147
|
+
if (serializationMarker.test(item) || /%5b(?:REDACTED|Max%20depth|Circular|Truncated)%5d/i.test(item))
|
|
148
|
+
return true;
|
|
149
|
+
if (/^(?:NaN|[+-]?Infinity|-?\d+n|Symbol\([^)]*\)|<[^<> ]+>)$/.test(item))
|
|
150
|
+
return true;
|
|
151
|
+
if (/^\s*[\[{]/.test(item) && item.length <= 65536) {
|
|
152
|
+
try {
|
|
153
|
+
return check(JSON.parse(item), depth + 1);
|
|
154
|
+
} catch {
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
if (item === null || typeof item === "boolean") return false;
|
|
160
|
+
if (typeof item === "number") return !Number.isFinite(item);
|
|
161
|
+
if (typeof item !== "object") return true;
|
|
162
|
+
if (seen.has(item)) return true;
|
|
163
|
+
seen.add(item);
|
|
164
|
+
if (Array.isArray(item))
|
|
165
|
+
return item.some((entry) => check(entry, depth + 1));
|
|
166
|
+
let descriptors;
|
|
167
|
+
try {
|
|
168
|
+
descriptors = Object.getOwnPropertyDescriptors(item);
|
|
169
|
+
} catch {
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
for (const [key, descriptor] of Object.entries(descriptors)) {
|
|
173
|
+
if (!descriptor.enumerable) continue;
|
|
174
|
+
if (key === "…" || key === "truncated" && descriptor.value === true)
|
|
175
|
+
return true;
|
|
176
|
+
if (!("value" in descriptor) || check(descriptor.value, depth + 1))
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
return check(value, 0);
|
|
183
|
+
} catch {
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const textContent = /(?:json|text|xml|javascript|x-www-form-urlencoded)/i;
|
|
188
|
+
const now = () => typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
189
|
+
function matchMock(rules, url, method) {
|
|
190
|
+
for (const rule of rules.values()) {
|
|
191
|
+
if (rule.method !== "*" && rule.method.toUpperCase() !== method.toUpperCase())
|
|
192
|
+
continue;
|
|
193
|
+
const expression = `^${rule.url.split("*").map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`;
|
|
194
|
+
try {
|
|
195
|
+
if (new RegExp(expression).test(url) || new RegExp(expression).test(new URL(url).pathname))
|
|
196
|
+
return rule;
|
|
197
|
+
} catch {
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return void 0;
|
|
201
|
+
}
|
|
202
|
+
async function readBody(stream, headers, max) {
|
|
203
|
+
if (!stream) return void 0;
|
|
204
|
+
const contentType = headers.get("content-type") || "";
|
|
205
|
+
if (!textContent.test(contentType) || /event-stream/i.test(contentType)) {
|
|
206
|
+
void stream.cancel().catch(() => {
|
|
207
|
+
});
|
|
208
|
+
return "[Body omitted: non-text or streaming content]";
|
|
209
|
+
}
|
|
210
|
+
const length = Number(headers.get("content-length"));
|
|
211
|
+
if (length > max) {
|
|
212
|
+
void stream.cancel().catch(() => {
|
|
213
|
+
});
|
|
214
|
+
return `[Body omitted: ${length} bytes]`;
|
|
215
|
+
}
|
|
216
|
+
const reader = stream.getReader();
|
|
217
|
+
const decoder = new TextDecoder();
|
|
218
|
+
let size = 0;
|
|
219
|
+
let text = "";
|
|
220
|
+
try {
|
|
221
|
+
while (true) {
|
|
222
|
+
const chunk = await reader.read();
|
|
223
|
+
if (chunk.done) break;
|
|
224
|
+
size += chunk.value.byteLength;
|
|
225
|
+
if (size > max) {
|
|
226
|
+
void reader.cancel().catch(() => {
|
|
227
|
+
});
|
|
228
|
+
return "[Body omitted: size limit]";
|
|
229
|
+
}
|
|
230
|
+
text += decoder.decode(chunk.value, { stream: true });
|
|
231
|
+
}
|
|
232
|
+
text += decoder.decode();
|
|
233
|
+
if (/json/i.test(contentType)) {
|
|
234
|
+
try {
|
|
235
|
+
return JSON.parse(text);
|
|
236
|
+
} catch {
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return text;
|
|
240
|
+
} catch {
|
|
241
|
+
return "[Body unavailable]";
|
|
242
|
+
} finally {
|
|
243
|
+
try {
|
|
244
|
+
reader.releaseLock();
|
|
245
|
+
} catch {
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
function requestBody(input, init, max) {
|
|
250
|
+
if (init?.body != null) {
|
|
251
|
+
if (typeof init.body === "string")
|
|
252
|
+
return Promise.resolve(
|
|
253
|
+
new TextEncoder().encode(init.body).length <= max ? init.body : "[Body omitted: size limit]"
|
|
254
|
+
);
|
|
255
|
+
if (init.body instanceof URLSearchParams)
|
|
256
|
+
return Promise.resolve(Object.fromEntries(init.body.entries()));
|
|
257
|
+
if (typeof FormData !== "undefined" && init.body instanceof FormData)
|
|
258
|
+
return Promise.resolve("[Body omitted: multipart data]");
|
|
259
|
+
return Promise.resolve("[Body omitted: binary/stream content]");
|
|
260
|
+
}
|
|
261
|
+
if (typeof Request !== "undefined" && input instanceof Request) {
|
|
262
|
+
try {
|
|
263
|
+
const clone = input.clone();
|
|
264
|
+
return readBody(clone.body, clone.headers, max);
|
|
265
|
+
} catch {
|
|
266
|
+
return Promise.resolve("[Body unavailable]");
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return Promise.resolve(void 0);
|
|
270
|
+
}
|
|
271
|
+
function mockBody(rule) {
|
|
272
|
+
if (rule.status === 204 || rule.status === 205 || rule.status === 304)
|
|
273
|
+
return null;
|
|
274
|
+
return typeof rule.body === "string" ? rule.body : JSON.stringify(rule.body ?? null);
|
|
275
|
+
}
|
|
276
|
+
function instrumentNetwork(ctx) {
|
|
277
|
+
const { win, controls } = ctx;
|
|
278
|
+
let stopped = false;
|
|
279
|
+
const cleanups = /* @__PURE__ */ new Set();
|
|
280
|
+
const collector = new URL(ctx.endpoint);
|
|
281
|
+
collector.protocol = collector.protocol === "wss:" ? "https:" : "http:";
|
|
282
|
+
const internalPaths = /* @__PURE__ */ new Set([
|
|
283
|
+
collector.pathname,
|
|
284
|
+
"/ws",
|
|
285
|
+
"/rn",
|
|
286
|
+
"/api/config",
|
|
287
|
+
"/api/demo/config",
|
|
288
|
+
"/api/health"
|
|
289
|
+
]);
|
|
290
|
+
const excluded = (url) => {
|
|
291
|
+
try {
|
|
292
|
+
const u = new URL(url, win.location.href);
|
|
293
|
+
return u.origin === collector.origin && internalPaths.has(u.pathname);
|
|
294
|
+
} catch {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
const emit = (data) => {
|
|
299
|
+
if (!stopped) ctx.emit(data);
|
|
300
|
+
};
|
|
301
|
+
const wait = (ms, signal) => new Promise((resolve, reject) => {
|
|
302
|
+
if (signal?.aborted) {
|
|
303
|
+
reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const done = () => {
|
|
307
|
+
clearTimeout(timer);
|
|
308
|
+
signal?.removeEventListener("abort", abort);
|
|
309
|
+
cleanups.delete(cancel);
|
|
310
|
+
resolve();
|
|
311
|
+
};
|
|
312
|
+
const abort = () => {
|
|
313
|
+
clearTimeout(timer);
|
|
314
|
+
signal?.removeEventListener("abort", abort);
|
|
315
|
+
cleanups.delete(cancel);
|
|
316
|
+
reject(signal?.reason ?? new DOMException("Aborted", "AbortError"));
|
|
317
|
+
};
|
|
318
|
+
const timer = setTimeout(done, ms);
|
|
319
|
+
const cancel = () => done();
|
|
320
|
+
cleanups.add(cancel);
|
|
321
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
322
|
+
});
|
|
323
|
+
const originalFetch = win.fetch;
|
|
324
|
+
if (typeof originalFetch === "function") {
|
|
325
|
+
const wrapped = async function(input, init) {
|
|
326
|
+
const rawUrl = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
327
|
+
let url;
|
|
328
|
+
try {
|
|
329
|
+
url = new URL(rawUrl, win.location.href).href;
|
|
330
|
+
} catch {
|
|
331
|
+
return originalFetch.call(win, input, init);
|
|
332
|
+
}
|
|
333
|
+
if (excluded(url) || stopped) return originalFetch.call(win, input, init);
|
|
334
|
+
const req = typeof Request !== "undefined" && input instanceof Request ? input : void 0;
|
|
335
|
+
const method = (init?.method || req?.method || "GET").toUpperCase();
|
|
336
|
+
const headers = new Headers(init?.headers ?? req?.headers);
|
|
337
|
+
const start = now();
|
|
338
|
+
const rule = matchMock(controls.mocks, url, method);
|
|
339
|
+
const signal = init?.signal ?? req?.signal;
|
|
340
|
+
const body = ctx.captureBodies ? requestBody(input, init, ctx.maxBodyBytes) : Promise.resolve(void 0);
|
|
341
|
+
const base = {
|
|
342
|
+
method,
|
|
343
|
+
url,
|
|
344
|
+
requestHeaders: Object.fromEntries(headers.entries()),
|
|
345
|
+
mocked: Boolean(rule)
|
|
346
|
+
};
|
|
347
|
+
try {
|
|
348
|
+
if (controls.latency > 0 || (rule?.delay ?? 0) > 0)
|
|
349
|
+
await wait(controls.latency + (rule?.delay ?? 0), signal);
|
|
350
|
+
if (!stopped && controls.offline)
|
|
351
|
+
throw new TypeError("Farol: simulated offline network");
|
|
352
|
+
if (signal?.aborted)
|
|
353
|
+
throw signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
354
|
+
const response = !stopped && rule ? new Response(mockBody(rule), {
|
|
355
|
+
status: rule.status,
|
|
356
|
+
headers: {
|
|
357
|
+
"content-type": typeof rule.body === "string" ? "text/plain" : "application/json",
|
|
358
|
+
"x-farol-mock": rule.id
|
|
359
|
+
}
|
|
360
|
+
}) : await originalFetch.call(win, input, init);
|
|
361
|
+
const duration = now() - start;
|
|
362
|
+
const responseHeaders = Object.fromEntries(response.headers.entries());
|
|
363
|
+
let responseBody = Promise.resolve(void 0);
|
|
364
|
+
if (ctx.captureBodies) {
|
|
365
|
+
try {
|
|
366
|
+
const clone = response.clone();
|
|
367
|
+
responseBody = readBody(
|
|
368
|
+
clone.body,
|
|
369
|
+
clone.headers,
|
|
370
|
+
ctx.maxBodyBytes
|
|
371
|
+
);
|
|
372
|
+
} catch {
|
|
373
|
+
responseBody = Promise.resolve("[Body unavailable]");
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
void Promise.all([body, responseBody]).then(
|
|
377
|
+
([requestBody2, responseBody2]) => emit({
|
|
378
|
+
...base,
|
|
379
|
+
status: response.status,
|
|
380
|
+
duration,
|
|
381
|
+
responseHeaders,
|
|
382
|
+
...ctx.captureBodies ? { requestBody: requestBody2, responseBody: responseBody2 } : {}
|
|
383
|
+
})
|
|
384
|
+
).catch(() => {
|
|
385
|
+
});
|
|
386
|
+
return response;
|
|
387
|
+
} catch (error) {
|
|
388
|
+
void body.then(
|
|
389
|
+
(requestBody2) => emit({
|
|
390
|
+
...base,
|
|
391
|
+
status: 0,
|
|
392
|
+
duration: now() - start,
|
|
393
|
+
error: error instanceof Error ? error.message : String(error),
|
|
394
|
+
...ctx.captureBodies ? { requestBody: requestBody2 } : {}
|
|
395
|
+
})
|
|
396
|
+
).catch(() => {
|
|
397
|
+
});
|
|
398
|
+
throw error;
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
win.fetch = wrapped;
|
|
402
|
+
cleanups.add(() => {
|
|
403
|
+
if (win.fetch === wrapped) win.fetch = originalFetch;
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
if (typeof win.XMLHttpRequest === "function") {
|
|
407
|
+
let restoreOverrides = function(xhr) {
|
|
408
|
+
const request = requests.get(xhr);
|
|
409
|
+
for (const key of request?.overrides ?? []) {
|
|
410
|
+
try {
|
|
411
|
+
delete xhr[key];
|
|
412
|
+
} catch {
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (request) request.overrides = [];
|
|
416
|
+
const ref = refsByRequest.get(xhr);
|
|
417
|
+
if (ref) {
|
|
418
|
+
mockRefs.delete(ref);
|
|
419
|
+
finalizer.unregister(ref);
|
|
420
|
+
refsByRequest.delete(xhr);
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
const proto = win.XMLHttpRequest.prototype;
|
|
424
|
+
const originalOpen = proto.open;
|
|
425
|
+
const originalSend = proto.send;
|
|
426
|
+
const originalSetHeader = proto.setRequestHeader;
|
|
427
|
+
const originalAbort = proto.abort;
|
|
428
|
+
const requests = /* @__PURE__ */ new WeakMap();
|
|
429
|
+
const mockRefs = /* @__PURE__ */ new Set();
|
|
430
|
+
const refsByRequest = /* @__PURE__ */ new WeakMap();
|
|
431
|
+
const finalizer = new FinalizationRegistry(
|
|
432
|
+
(ref) => mockRefs.delete(ref)
|
|
433
|
+
);
|
|
434
|
+
cleanups.add(() => {
|
|
435
|
+
for (const ref of mockRefs) {
|
|
436
|
+
const xhr = ref.deref();
|
|
437
|
+
if (xhr) restoreOverrides(xhr);
|
|
438
|
+
else finalizer.unregister(ref);
|
|
439
|
+
}
|
|
440
|
+
mockRefs.clear();
|
|
441
|
+
});
|
|
442
|
+
const open = function(method, url, ...rest) {
|
|
443
|
+
const previous = requests.get(this);
|
|
444
|
+
previous?.cancel?.();
|
|
445
|
+
restoreOverrides(this);
|
|
446
|
+
requests.set(this, {
|
|
447
|
+
method: method.toUpperCase(),
|
|
448
|
+
url: new URL(String(url), win.location.href).href,
|
|
449
|
+
headers: {}
|
|
450
|
+
});
|
|
451
|
+
return originalOpen.call(this, method, url, ...rest);
|
|
452
|
+
};
|
|
453
|
+
const setHeader = function(key, value) {
|
|
454
|
+
const request = requests.get(this);
|
|
455
|
+
const result = originalSetHeader.call(this, key, value);
|
|
456
|
+
if (request) request.headers[key] = value;
|
|
457
|
+
return result;
|
|
458
|
+
};
|
|
459
|
+
const send = function(body) {
|
|
460
|
+
const request = requests.get(this);
|
|
461
|
+
if (!request || excluded(request.url) || stopped)
|
|
462
|
+
return originalSend.call(this, body);
|
|
463
|
+
const start = now();
|
|
464
|
+
const xhr = this;
|
|
465
|
+
const rule = matchMock(controls.mocks, request.url, request.method);
|
|
466
|
+
let completed = false;
|
|
467
|
+
const finish = () => {
|
|
468
|
+
if (completed) return;
|
|
469
|
+
completed = true;
|
|
470
|
+
xhr.removeEventListener("loadend", finish);
|
|
471
|
+
cleanups.delete(removeListener);
|
|
472
|
+
let responseHeaders = {};
|
|
473
|
+
let responseBody;
|
|
474
|
+
try {
|
|
475
|
+
responseHeaders = Object.fromEntries(
|
|
476
|
+
xhr.getAllResponseHeaders().trim().split(/[\r\n]+/).filter(Boolean).map((h) => {
|
|
477
|
+
const i = h.indexOf(":");
|
|
478
|
+
return [h.slice(0, i).trim(), h.slice(i + 1).trim()];
|
|
479
|
+
})
|
|
480
|
+
);
|
|
481
|
+
} catch {
|
|
482
|
+
}
|
|
483
|
+
if (ctx.captureBodies) {
|
|
484
|
+
try {
|
|
485
|
+
responseBody = xhr.responseType === "json" ? xhr.response : !xhr.responseType || xhr.responseType === "text" ? xhr.responseText.length <= ctx.maxBodyBytes ? xhr.responseText : "[Body omitted: size limit]" : "[Body omitted: binary content]";
|
|
486
|
+
} catch {
|
|
487
|
+
responseBody = "[Body unavailable]";
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
const requestBody2 = typeof body === "string" ? body.length <= ctx.maxBodyBytes ? body : "[Body omitted: size limit]" : body == null ? void 0 : "[Body omitted: non-text content]";
|
|
491
|
+
emit({
|
|
492
|
+
method: request.method,
|
|
493
|
+
url: request.url,
|
|
494
|
+
status: xhr.status,
|
|
495
|
+
duration: now() - start,
|
|
496
|
+
requestHeaders: request.headers,
|
|
497
|
+
responseHeaders,
|
|
498
|
+
mocked: Boolean(rule),
|
|
499
|
+
...xhr.status === 0 ? { error: "XHR failed, aborted, or simulated offline" } : {},
|
|
500
|
+
...ctx.captureBodies ? { requestBody: requestBody2, responseBody } : {}
|
|
501
|
+
});
|
|
502
|
+
};
|
|
503
|
+
const removeListener = () => xhr.removeEventListener("loadend", finish);
|
|
504
|
+
xhr.addEventListener("loadend", finish, { once: true });
|
|
505
|
+
cleanups.add(removeListener);
|
|
506
|
+
request.finish = finish;
|
|
507
|
+
const dispatch = (name) => xhr.dispatchEvent(new Event(name));
|
|
508
|
+
const simulate = () => {
|
|
509
|
+
const offline = controls.offline;
|
|
510
|
+
if (!offline && !rule) {
|
|
511
|
+
originalSend.call(xhr, body);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const content = rule ? mockBody(rule) ?? "" : "";
|
|
515
|
+
const values = {
|
|
516
|
+
readyState: 4,
|
|
517
|
+
status: offline ? 0 : rule.status,
|
|
518
|
+
statusText: offline ? "" : "Farol Mock",
|
|
519
|
+
responseURL: request.url,
|
|
520
|
+
responseText: content,
|
|
521
|
+
response: xhr.responseType === "json" ? rule?.body : content,
|
|
522
|
+
getAllResponseHeaders: () => "content-type: application/json\r\n",
|
|
523
|
+
getResponseHeader: (name) => name.toLowerCase() === "content-type" ? "application/json" : null
|
|
524
|
+
};
|
|
525
|
+
request.overrides = Object.keys(values);
|
|
526
|
+
const ref = new WeakRef(xhr);
|
|
527
|
+
mockRefs.add(ref);
|
|
528
|
+
refsByRequest.set(xhr, ref);
|
|
529
|
+
finalizer.register(xhr, ref, ref);
|
|
530
|
+
for (const [key, value] of Object.entries(values))
|
|
531
|
+
Object.defineProperty(xhr, key, { configurable: true, value });
|
|
532
|
+
dispatch("readystatechange");
|
|
533
|
+
dispatch(offline ? "error" : "load");
|
|
534
|
+
dispatch("loadend");
|
|
535
|
+
};
|
|
536
|
+
const simulated = controls.offline || Boolean(rule);
|
|
537
|
+
const latency = controls.latency + (rule?.delay ?? 0);
|
|
538
|
+
if (!simulated && latency === 0) {
|
|
539
|
+
try {
|
|
540
|
+
return originalSend.call(xhr, body);
|
|
541
|
+
} catch (error) {
|
|
542
|
+
finish();
|
|
543
|
+
throw error;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
const timer = setTimeout(() => {
|
|
547
|
+
cleanups.delete(cancel);
|
|
548
|
+
request.cancel = void 0;
|
|
549
|
+
if (stopped) originalSend.call(xhr, body);
|
|
550
|
+
else simulate();
|
|
551
|
+
}, latency);
|
|
552
|
+
const cancel = () => {
|
|
553
|
+
clearTimeout(timer);
|
|
554
|
+
cleanups.delete(cancel);
|
|
555
|
+
request.cancel = void 0;
|
|
556
|
+
if (stopped && !completed) {
|
|
557
|
+
try {
|
|
558
|
+
originalSend.call(xhr, body);
|
|
559
|
+
} catch {
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
request.cancel = cancel;
|
|
564
|
+
cleanups.add(cancel);
|
|
565
|
+
dispatch("loadstart");
|
|
566
|
+
};
|
|
567
|
+
const abort = function() {
|
|
568
|
+
const request = requests.get(this);
|
|
569
|
+
if (request?.cancel) {
|
|
570
|
+
request.cancel();
|
|
571
|
+
this.dispatchEvent(new Event("abort"));
|
|
572
|
+
this.dispatchEvent(new Event("loadend"));
|
|
573
|
+
}
|
|
574
|
+
return originalAbort.call(this);
|
|
575
|
+
};
|
|
576
|
+
proto.open = open;
|
|
577
|
+
proto.send = send;
|
|
578
|
+
proto.setRequestHeader = setHeader;
|
|
579
|
+
proto.abort = abort;
|
|
580
|
+
cleanups.add(() => {
|
|
581
|
+
if (proto.open === open) proto.open = originalOpen;
|
|
582
|
+
if (proto.send === send) proto.send = originalSend;
|
|
583
|
+
if (proto.setRequestHeader === setHeader)
|
|
584
|
+
proto.setRequestHeader = originalSetHeader;
|
|
585
|
+
if (proto.abort === abort) proto.abort = originalAbort;
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
return () => {
|
|
589
|
+
stopped = true;
|
|
590
|
+
for (const cleanup of [...cleanups]) {
|
|
591
|
+
try {
|
|
592
|
+
cleanup();
|
|
593
|
+
} catch {
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
cleanups.clear();
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
const SDK_VERSION = "0.2.0";
|
|
600
|
+
const SINGLETON = /* @__PURE__ */ Symbol.for("aeres.farol.web.client");
|
|
601
|
+
const NOOP = () => {
|
|
602
|
+
};
|
|
603
|
+
function inactive() {
|
|
604
|
+
return {
|
|
605
|
+
enabled: false,
|
|
606
|
+
connected: false,
|
|
607
|
+
session: null,
|
|
608
|
+
getSession: () => null,
|
|
609
|
+
track: NOOP,
|
|
610
|
+
record: NOOP,
|
|
611
|
+
watchStore: () => NOOP,
|
|
612
|
+
observeQueryClient: () => NOOP,
|
|
613
|
+
destroy: NOOP
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
const uid = () => globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
617
|
+
const clamp = (value, fallback, max) => typeof value === "number" && Number.isFinite(value) ? Math.min(Math.max(Math.floor(value), 0), max) : fallback;
|
|
618
|
+
function initFarol(options) {
|
|
619
|
+
if (typeof window === "undefined" || typeof document === "undefined")
|
|
620
|
+
return inactive();
|
|
621
|
+
const win = window;
|
|
622
|
+
const global = win;
|
|
623
|
+
global[SINGLETON]?.destroy();
|
|
624
|
+
if (options?.enabled !== true || !options.token?.trim() || typeof win.WebSocket !== "function")
|
|
625
|
+
return inactive();
|
|
626
|
+
let endpoint;
|
|
627
|
+
try {
|
|
628
|
+
endpoint = new URL(options.endpoint ?? "ws://127.0.0.1:4318/ws");
|
|
629
|
+
} catch {
|
|
630
|
+
return inactive();
|
|
631
|
+
}
|
|
632
|
+
if (!["ws:", "wss:"].includes(endpoint.protocol) || !["localhost", "127.0.0.1", "[::1]"].includes(endpoint.hostname) || endpoint.username || endpoint.password)
|
|
633
|
+
return inactive();
|
|
634
|
+
const session = {
|
|
635
|
+
platform: "web",
|
|
636
|
+
runtime: "browser",
|
|
637
|
+
id: uid(),
|
|
638
|
+
appName: String(options.appName || "React app").slice(0, 120),
|
|
639
|
+
url: redactUrl(win.location.href),
|
|
640
|
+
userAgent: win.navigator.userAgent,
|
|
641
|
+
startedAt: Date.now(),
|
|
642
|
+
sdkVersion: SDK_VERSION
|
|
643
|
+
};
|
|
644
|
+
const cleanups = /* @__PURE__ */ new Set();
|
|
645
|
+
const stores = /* @__PURE__ */ new Map();
|
|
646
|
+
const queryClients = /* @__PURE__ */ new Set();
|
|
647
|
+
const queue = [];
|
|
648
|
+
const maxQueue = clamp(options.maxQueueSize, 400, 2e3);
|
|
649
|
+
const controls = {
|
|
650
|
+
offline: false,
|
|
651
|
+
latency: 0,
|
|
652
|
+
mocks: /* @__PURE__ */ new Map()
|
|
653
|
+
};
|
|
654
|
+
let stopped = false;
|
|
655
|
+
let ready = false;
|
|
656
|
+
let socket = null;
|
|
657
|
+
let reconnectTimer;
|
|
658
|
+
let flushTimer;
|
|
659
|
+
let attempt = 0;
|
|
660
|
+
let highlighted = null;
|
|
661
|
+
let highlightTimer;
|
|
662
|
+
let executingCommand = false;
|
|
663
|
+
function flushQueue() {
|
|
664
|
+
if (flushTimer) {
|
|
665
|
+
clearTimeout(flushTimer);
|
|
666
|
+
flushTimer = void 0;
|
|
667
|
+
}
|
|
668
|
+
if (stopped || !ready || socket?.readyState !== 1) return;
|
|
669
|
+
while (queue.length && socket.bufferedAmount < 1048576) {
|
|
670
|
+
try {
|
|
671
|
+
socket.send(queue.shift());
|
|
672
|
+
} catch {
|
|
673
|
+
break;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
if (queue.length) flushTimer = setTimeout(flushQueue, 100);
|
|
677
|
+
}
|
|
678
|
+
function send(message, buffer = true) {
|
|
679
|
+
if (stopped) return;
|
|
680
|
+
let serialized = JSON.stringify(message);
|
|
681
|
+
if (new TextEncoder().encode(serialized).byteLength > 65536) {
|
|
682
|
+
const result = message;
|
|
683
|
+
if (result.type !== "command.result") return;
|
|
684
|
+
serialized = JSON.stringify({
|
|
685
|
+
type: "command.result",
|
|
686
|
+
id: result.id,
|
|
687
|
+
sessionId: result.sessionId,
|
|
688
|
+
ok: false,
|
|
689
|
+
error: "Resultado excede o limite de 64 KiB após redação. Reduza o volume de dados consultados."
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
if (ready && socket?.readyState === 1 && socket.bufferedAmount < 1048576) {
|
|
693
|
+
try {
|
|
694
|
+
socket.send(serialized);
|
|
695
|
+
return;
|
|
696
|
+
} catch {
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
if (buffer && maxQueue > 0) {
|
|
700
|
+
queue.push(serialized);
|
|
701
|
+
if (queue.length > maxQueue) queue.splice(0, queue.length - maxQueue);
|
|
702
|
+
if (ready && !flushTimer) flushTimer = setTimeout(flushQueue, 100);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
function record(kind, name, data, level) {
|
|
706
|
+
if (stopped) return;
|
|
707
|
+
try {
|
|
708
|
+
const safe = safeSerialize(data);
|
|
709
|
+
const event = {
|
|
710
|
+
id: uid(),
|
|
711
|
+
sessionId: session.id,
|
|
712
|
+
timestamp: Date.now(),
|
|
713
|
+
kind,
|
|
714
|
+
name: String(safeSerialize(name, { maxString: 200 })),
|
|
715
|
+
data: safe,
|
|
716
|
+
...level ? { level } : {},
|
|
717
|
+
...typeof data.duration === "number" ? { duration: data.duration } : {}
|
|
718
|
+
};
|
|
719
|
+
if (new TextEncoder().encode(JSON.stringify(event)).byteLength > 6e4)
|
|
720
|
+
event.data = {
|
|
721
|
+
truncated: true,
|
|
722
|
+
message: "Event exceeded 60 KB after redaction"
|
|
723
|
+
};
|
|
724
|
+
send({ type: "event", event });
|
|
725
|
+
} catch {
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
const listen = (target, type, handler) => {
|
|
729
|
+
target.addEventListener(type, handler);
|
|
730
|
+
cleanups.add(() => target.removeEventListener(type, handler));
|
|
731
|
+
};
|
|
732
|
+
function getStorage(area) {
|
|
733
|
+
if (area !== "local" && area !== "session")
|
|
734
|
+
throw new Error("area must be local or session");
|
|
735
|
+
return area === "local" ? win.localStorage : win.sessionStorage;
|
|
736
|
+
}
|
|
737
|
+
function storageSnapshot() {
|
|
738
|
+
const result = {
|
|
739
|
+
local: {},
|
|
740
|
+
session: {}
|
|
741
|
+
};
|
|
742
|
+
for (const area of ["local", "session"]) {
|
|
743
|
+
try {
|
|
744
|
+
const storage = getStorage(area);
|
|
745
|
+
for (let i = 0; i < Math.min(storage.length, 100); i++) {
|
|
746
|
+
const key = storage.key(i);
|
|
747
|
+
if (key != null)
|
|
748
|
+
Object.defineProperty(result[area], key, {
|
|
749
|
+
value: isSensitiveKey(key) ? REDACTED : safeSerialize(storage.getItem(key)),
|
|
750
|
+
enumerable: true
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
} catch {
|
|
754
|
+
result[area] = { unavailable: "Storage is blocked by browser policy" };
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return result;
|
|
758
|
+
}
|
|
759
|
+
function clearHighlight() {
|
|
760
|
+
if (highlightTimer) clearTimeout(highlightTimer);
|
|
761
|
+
highlighted?.remove();
|
|
762
|
+
highlighted = null;
|
|
763
|
+
}
|
|
764
|
+
function domSnapshot() {
|
|
765
|
+
return Array.from(
|
|
766
|
+
document.body?.querySelectorAll(
|
|
767
|
+
"main,section,article,header,nav,h1,h2,h3,p,button,a,input,select,textarea,[role]"
|
|
768
|
+
) ?? []
|
|
769
|
+
).filter((element) => element !== highlighted).slice(0, 150).map((element) => ({
|
|
770
|
+
tag: element.tagName.toLowerCase(),
|
|
771
|
+
id: element.id,
|
|
772
|
+
role: element.getAttribute("role"),
|
|
773
|
+
// Text excludes controls and editable/private content; no input values or data-* attributes.
|
|
774
|
+
text: element.matches(
|
|
775
|
+
"input,select,textarea,[contenteditable], [data-farol-private], [data-private]"
|
|
776
|
+
) || element.closest(
|
|
777
|
+
"[data-farol-private],[data-private],[contenteditable]"
|
|
778
|
+
) ? "[private]" : Array.from(element.childNodes).filter((node) => node.nodeType === 3).map((node) => node.textContent).join(" ").trim().slice(0, 160),
|
|
779
|
+
rect: (() => {
|
|
780
|
+
const rect = element.getBoundingClientRect();
|
|
781
|
+
return {
|
|
782
|
+
x: Math.round(rect.x),
|
|
783
|
+
y: Math.round(rect.y),
|
|
784
|
+
width: Math.round(rect.width),
|
|
785
|
+
height: Math.round(rect.height)
|
|
786
|
+
};
|
|
787
|
+
})()
|
|
788
|
+
}));
|
|
789
|
+
}
|
|
790
|
+
async function execute(command) {
|
|
791
|
+
const args = command.args ?? {};
|
|
792
|
+
switch (command.name) {
|
|
793
|
+
case "storage.list":
|
|
794
|
+
return storageSnapshot();
|
|
795
|
+
case "storage.set": {
|
|
796
|
+
if (typeof args.key !== "string" || typeof args.value !== "string" || args.key.length > 512 || args.value.length > 16384)
|
|
797
|
+
throw new Error("Invalid storage key/value");
|
|
798
|
+
if (containsSerializationMarkers(args.value))
|
|
799
|
+
throw new Error(
|
|
800
|
+
"Valor contém marcadores de redação ou truncamento. Informe um valor completo para evitar sobrescrever dados reais."
|
|
801
|
+
);
|
|
802
|
+
getStorage(args.area).setItem(args.key, args.value);
|
|
803
|
+
return { area: args.area, key: args.key, updated: true };
|
|
804
|
+
}
|
|
805
|
+
case "storage.remove": {
|
|
806
|
+
if (typeof args.key !== "string")
|
|
807
|
+
throw new Error("Invalid storage key");
|
|
808
|
+
getStorage(args.area).removeItem(args.key);
|
|
809
|
+
return { area: args.area, key: args.key, removed: true };
|
|
810
|
+
}
|
|
811
|
+
case "state.set": {
|
|
812
|
+
const store = stores.get(String(args.store));
|
|
813
|
+
if (!store?.setState) throw new Error("Store does not expose setState");
|
|
814
|
+
if (containsSerializationMarkers(args.value))
|
|
815
|
+
throw new Error(
|
|
816
|
+
"Estado contém marcadores de redação ou truncamento. Snapshots incompletos não podem substituir dados reais."
|
|
817
|
+
);
|
|
818
|
+
store.setState(args.value);
|
|
819
|
+
return { store: args.store, state: store.getState() };
|
|
820
|
+
}
|
|
821
|
+
case "query.invalidate": {
|
|
822
|
+
if (args.queryKey !== void 0 && !Array.isArray(args.queryKey))
|
|
823
|
+
throw new Error("queryKey must be an array");
|
|
824
|
+
if (!queryClients.size) throw new Error("No QueryClient registered");
|
|
825
|
+
await Promise.all(
|
|
826
|
+
[...queryClients].map(
|
|
827
|
+
(client2) => Promise.resolve(
|
|
828
|
+
client2.invalidateQueries(
|
|
829
|
+
args.queryKey ? { queryKey: args.queryKey } : void 0
|
|
830
|
+
)
|
|
831
|
+
)
|
|
832
|
+
)
|
|
833
|
+
);
|
|
834
|
+
return { invalidated: true };
|
|
835
|
+
}
|
|
836
|
+
case "network.get":
|
|
837
|
+
return {
|
|
838
|
+
offline: controls.offline,
|
|
839
|
+
latency: controls.latency,
|
|
840
|
+
mocks: [...controls.mocks.values()]
|
|
841
|
+
};
|
|
842
|
+
case "network.configure": {
|
|
843
|
+
if (typeof args.offline === "boolean") controls.offline = args.offline;
|
|
844
|
+
if (args.latency !== void 0)
|
|
845
|
+
controls.latency = clamp(args.latency, 0, 3e4);
|
|
846
|
+
record("custom", "Network simulation configured", {
|
|
847
|
+
...controls,
|
|
848
|
+
mocks: controls.mocks.size
|
|
849
|
+
});
|
|
850
|
+
return { offline: controls.offline, latency: controls.latency };
|
|
851
|
+
}
|
|
852
|
+
case "mock.set": {
|
|
853
|
+
if (typeof args.id !== "string" || !args.id || typeof args.url !== "string" || !args.url || args.url.length > 512 || typeof args.method !== "string")
|
|
854
|
+
throw new Error("Mock requires id, url and method");
|
|
855
|
+
const status = Number(args.status ?? 200);
|
|
856
|
+
if (!Number.isInteger(status) || status < 200 || status > 599)
|
|
857
|
+
throw new Error("Mock status must be between 200 and 599");
|
|
858
|
+
if (controls.mocks.size >= 50 && !controls.mocks.has(args.id))
|
|
859
|
+
throw new Error("Maximum 50 mocks");
|
|
860
|
+
const rule = {
|
|
861
|
+
id: args.id.slice(0, 100),
|
|
862
|
+
url: args.url,
|
|
863
|
+
method: args.method.toUpperCase(),
|
|
864
|
+
status,
|
|
865
|
+
body: args.body,
|
|
866
|
+
delay: clamp(args.delay, 0, 3e4)
|
|
867
|
+
};
|
|
868
|
+
controls.mocks.set(rule.id, rule);
|
|
869
|
+
return rule;
|
|
870
|
+
}
|
|
871
|
+
case "mock.remove":
|
|
872
|
+
return { removed: controls.mocks.delete(String(args.id)) };
|
|
873
|
+
case "dom.snapshot":
|
|
874
|
+
return { elements: domSnapshot() };
|
|
875
|
+
case "dom.highlight": {
|
|
876
|
+
if (typeof args.selector !== "string" || args.selector.length > 512)
|
|
877
|
+
throw new Error("Invalid selector");
|
|
878
|
+
const target = document.querySelector(args.selector);
|
|
879
|
+
if (!target) throw new Error("Element not found");
|
|
880
|
+
clearHighlight();
|
|
881
|
+
const rect = target.getBoundingClientRect();
|
|
882
|
+
highlighted = document.createElement("div");
|
|
883
|
+
highlighted.setAttribute("data-farol-highlight", "");
|
|
884
|
+
highlighted.style.cssText = `position:fixed;pointer-events:none;z-index:2147483647;border:2px solid #27c9a8;background:rgba(39,201,168,.12);left:${rect.left}px;top:${rect.top}px;width:${rect.width}px;height:${rect.height}px;box-sizing:border-box;`;
|
|
885
|
+
document.body.appendChild(highlighted);
|
|
886
|
+
highlightTimer = setTimeout(clearHighlight, 3e3);
|
|
887
|
+
return { highlighted: args.selector };
|
|
888
|
+
}
|
|
889
|
+
case "page.reload":
|
|
890
|
+
setTimeout(() => {
|
|
891
|
+
if (!stopped) win.location.reload();
|
|
892
|
+
}, 80);
|
|
893
|
+
return { reloading: true };
|
|
894
|
+
case "nav.go": {
|
|
895
|
+
if (typeof args.url !== "string") throw new Error("Invalid URL");
|
|
896
|
+
const url = new URL(args.url, win.location.href);
|
|
897
|
+
if (url.origin !== win.location.origin || !["http:", "https:"].includes(url.protocol) || url.username || url.password)
|
|
898
|
+
throw new Error("Navigation must stay on the same origin");
|
|
899
|
+
win.history.pushState(null, "", url.href);
|
|
900
|
+
win.dispatchEvent(new PopStateEvent("popstate"));
|
|
901
|
+
return { url: redactUrl(url.href) };
|
|
902
|
+
}
|
|
903
|
+
default:
|
|
904
|
+
throw new Error(`Unsupported command: ${command.name}`);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
function connect() {
|
|
908
|
+
if (stopped) return;
|
|
909
|
+
try {
|
|
910
|
+
socket = new win.WebSocket(endpoint.href);
|
|
911
|
+
} catch {
|
|
912
|
+
scheduleReconnect();
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
const current = socket;
|
|
916
|
+
current.onopen = () => {
|
|
917
|
+
if (stopped) return;
|
|
918
|
+
current.send(
|
|
919
|
+
JSON.stringify({ type: "auth", role: "sdk", token: options.token })
|
|
920
|
+
);
|
|
921
|
+
};
|
|
922
|
+
current.onmessage = (message) => {
|
|
923
|
+
if (stopped || current !== socket || typeof message.data !== "string" || message.data.length > 65536)
|
|
924
|
+
return;
|
|
925
|
+
try {
|
|
926
|
+
const data = JSON.parse(message.data);
|
|
927
|
+
if (data.type === "ready") {
|
|
928
|
+
ready = true;
|
|
929
|
+
attempt = 0;
|
|
930
|
+
send({ type: "hello", session });
|
|
931
|
+
flushQueue();
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
if (data.type !== "command" || !ready || data.sessionId !== session.id || typeof data.id !== "string" || data.id.length > 128 || typeof data.name !== "string" || data.name.length > 100)
|
|
935
|
+
return;
|
|
936
|
+
if (executingCommand) {
|
|
937
|
+
send(
|
|
938
|
+
{
|
|
939
|
+
type: "command.result",
|
|
940
|
+
id: data.id,
|
|
941
|
+
sessionId: session.id,
|
|
942
|
+
ok: false,
|
|
943
|
+
error: "Another command is running"
|
|
944
|
+
},
|
|
945
|
+
false
|
|
946
|
+
);
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
executingCommand = true;
|
|
950
|
+
void execute(data).then(
|
|
951
|
+
(result) => {
|
|
952
|
+
send(
|
|
953
|
+
{
|
|
954
|
+
type: "command.result",
|
|
955
|
+
id: data.id,
|
|
956
|
+
sessionId: session.id,
|
|
957
|
+
ok: true,
|
|
958
|
+
data: safeSerialize(result)
|
|
959
|
+
},
|
|
960
|
+
false
|
|
961
|
+
);
|
|
962
|
+
},
|
|
963
|
+
(error) => {
|
|
964
|
+
send(
|
|
965
|
+
{
|
|
966
|
+
type: "command.result",
|
|
967
|
+
id: data.id,
|
|
968
|
+
sessionId: session.id,
|
|
969
|
+
ok: false,
|
|
970
|
+
error: String(
|
|
971
|
+
safeSerialize(
|
|
972
|
+
error instanceof Error ? error.message : String(error)
|
|
973
|
+
)
|
|
974
|
+
)
|
|
975
|
+
},
|
|
976
|
+
false
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
).finally(() => {
|
|
980
|
+
executingCommand = false;
|
|
981
|
+
});
|
|
982
|
+
} catch {
|
|
983
|
+
}
|
|
984
|
+
};
|
|
985
|
+
current.onclose = (event) => {
|
|
986
|
+
if (current !== socket) return;
|
|
987
|
+
ready = false;
|
|
988
|
+
if (!stopped && event.code !== 1008) scheduleReconnect();
|
|
989
|
+
};
|
|
990
|
+
current.onerror = NOOP;
|
|
991
|
+
}
|
|
992
|
+
function scheduleReconnect() {
|
|
993
|
+
if (stopped || reconnectTimer) return;
|
|
994
|
+
reconnectTimer = setTimeout(
|
|
995
|
+
() => {
|
|
996
|
+
reconnectTimer = void 0;
|
|
997
|
+
connect();
|
|
998
|
+
},
|
|
999
|
+
Math.min(500 * 2 ** attempt++, 15e3) + Math.random() * 250
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
const client = {
|
|
1003
|
+
enabled: true,
|
|
1004
|
+
get connected() {
|
|
1005
|
+
return ready && !stopped;
|
|
1006
|
+
},
|
|
1007
|
+
session,
|
|
1008
|
+
getSession: () => session,
|
|
1009
|
+
record,
|
|
1010
|
+
track: (name, data = {}) => record("custom", name, data),
|
|
1011
|
+
watchStore(name, adapter) {
|
|
1012
|
+
if (stopped) return NOOP;
|
|
1013
|
+
const key = String(name).slice(0, 120);
|
|
1014
|
+
stores.set(key, adapter);
|
|
1015
|
+
const emit = () => {
|
|
1016
|
+
try {
|
|
1017
|
+
record("state", key, { store: key, state: adapter.getState() });
|
|
1018
|
+
} catch (error) {
|
|
1019
|
+
record("error", "Store inspection failed", { store: key, error });
|
|
1020
|
+
}
|
|
1021
|
+
};
|
|
1022
|
+
emit();
|
|
1023
|
+
let unsubscribe;
|
|
1024
|
+
try {
|
|
1025
|
+
unsubscribe = adapter.subscribe(emit);
|
|
1026
|
+
} catch (error) {
|
|
1027
|
+
record("error", "Store subscription failed", { store: key, error });
|
|
1028
|
+
}
|
|
1029
|
+
const cleanup = () => {
|
|
1030
|
+
try {
|
|
1031
|
+
unsubscribe?.();
|
|
1032
|
+
} catch {
|
|
1033
|
+
}
|
|
1034
|
+
if (stores.get(key) === adapter) stores.delete(key);
|
|
1035
|
+
cleanups.delete(cleanup);
|
|
1036
|
+
};
|
|
1037
|
+
cleanups.add(cleanup);
|
|
1038
|
+
return cleanup;
|
|
1039
|
+
},
|
|
1040
|
+
observeQueryClient(queryClient) {
|
|
1041
|
+
if (stopped) return NOOP;
|
|
1042
|
+
queryClients.add(queryClient);
|
|
1043
|
+
const emit = (query) => record("query", "Query cache", {
|
|
1044
|
+
queryKey: query.queryKey,
|
|
1045
|
+
state: query.state
|
|
1046
|
+
});
|
|
1047
|
+
let unsubscribe;
|
|
1048
|
+
try {
|
|
1049
|
+
const cache = queryClient.getQueryCache();
|
|
1050
|
+
cache.getAll().slice(0, 100).forEach(emit);
|
|
1051
|
+
unsubscribe = cache.subscribe((event) => {
|
|
1052
|
+
if (event?.query) emit(event.query);
|
|
1053
|
+
});
|
|
1054
|
+
} catch (error) {
|
|
1055
|
+
record("error", "Query inspection failed", { error });
|
|
1056
|
+
}
|
|
1057
|
+
const cleanup = () => {
|
|
1058
|
+
try {
|
|
1059
|
+
unsubscribe?.();
|
|
1060
|
+
} catch {
|
|
1061
|
+
}
|
|
1062
|
+
queryClients.delete(queryClient);
|
|
1063
|
+
cleanups.delete(cleanup);
|
|
1064
|
+
};
|
|
1065
|
+
cleanups.add(cleanup);
|
|
1066
|
+
return cleanup;
|
|
1067
|
+
},
|
|
1068
|
+
destroy() {
|
|
1069
|
+
if (stopped) return;
|
|
1070
|
+
stopped = true;
|
|
1071
|
+
ready = false;
|
|
1072
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
1073
|
+
if (flushTimer) clearTimeout(flushTimer);
|
|
1074
|
+
clearHighlight();
|
|
1075
|
+
for (const cleanup of [...cleanups]) {
|
|
1076
|
+
try {
|
|
1077
|
+
cleanup();
|
|
1078
|
+
} catch {
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
cleanups.clear();
|
|
1082
|
+
stores.clear();
|
|
1083
|
+
queryClients.clear();
|
|
1084
|
+
controls.mocks.clear();
|
|
1085
|
+
queue.length = 0;
|
|
1086
|
+
if (socket) {
|
|
1087
|
+
socket.onopen = socket.onmessage = socket.onclose = socket.onerror = null;
|
|
1088
|
+
socket.close(1e3, "Farol stopped");
|
|
1089
|
+
socket = null;
|
|
1090
|
+
}
|
|
1091
|
+
if (global[SINGLETON] === client) delete global[SINGLETON];
|
|
1092
|
+
}
|
|
1093
|
+
};
|
|
1094
|
+
global[SINGLETON] = client;
|
|
1095
|
+
for (const level of ["debug", "log", "info", "warn", "error"]) {
|
|
1096
|
+
const original = win.console[level];
|
|
1097
|
+
const wrapped = (...args) => {
|
|
1098
|
+
original.apply(win.console, args);
|
|
1099
|
+
record("console", level, { args }, level === "log" ? "info" : level);
|
|
1100
|
+
};
|
|
1101
|
+
win.console[level] = wrapped;
|
|
1102
|
+
cleanups.add(() => {
|
|
1103
|
+
if (win.console[level] === wrapped) win.console[level] = original;
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
listen(win, "error", ((event) => record(
|
|
1107
|
+
"error",
|
|
1108
|
+
event.message || "Resource error",
|
|
1109
|
+
{
|
|
1110
|
+
error: event.error,
|
|
1111
|
+
message: event.message,
|
|
1112
|
+
filename: event.filename,
|
|
1113
|
+
line: event.lineno,
|
|
1114
|
+
column: event.colno
|
|
1115
|
+
},
|
|
1116
|
+
"error"
|
|
1117
|
+
)));
|
|
1118
|
+
listen(win, "unhandledrejection", ((event) => record(
|
|
1119
|
+
"error",
|
|
1120
|
+
"Unhandled promise rejection",
|
|
1121
|
+
{ reason: event.reason },
|
|
1122
|
+
"error"
|
|
1123
|
+
)));
|
|
1124
|
+
try {
|
|
1125
|
+
cleanups.add(
|
|
1126
|
+
instrumentNetwork({
|
|
1127
|
+
win,
|
|
1128
|
+
endpoint: endpoint.href,
|
|
1129
|
+
controls,
|
|
1130
|
+
captureBodies: options.captureBodies === true,
|
|
1131
|
+
maxBodyBytes: clamp(options.maxBodyBytes, 8192, 32768),
|
|
1132
|
+
emit: (data) => record(
|
|
1133
|
+
"network",
|
|
1134
|
+
`${data.method} ${redactUrl(String(data.url))}`,
|
|
1135
|
+
data,
|
|
1136
|
+
data.error || Number(data.status) >= 400 ? "error" : "info"
|
|
1137
|
+
)
|
|
1138
|
+
})
|
|
1139
|
+
);
|
|
1140
|
+
} catch (error) {
|
|
1141
|
+
record("error", "Network instrumentation unavailable", { error });
|
|
1142
|
+
}
|
|
1143
|
+
try {
|
|
1144
|
+
const prototype = win.Storage.prototype;
|
|
1145
|
+
for (const operation of ["setItem", "removeItem", "clear"]) {
|
|
1146
|
+
const original = prototype[operation];
|
|
1147
|
+
const wrapped = function(...args) {
|
|
1148
|
+
const result = original.apply(this, args);
|
|
1149
|
+
let area;
|
|
1150
|
+
try {
|
|
1151
|
+
area = this === win.localStorage ? "local" : this === win.sessionStorage ? "session" : void 0;
|
|
1152
|
+
} catch {
|
|
1153
|
+
}
|
|
1154
|
+
if (area)
|
|
1155
|
+
record("storage", `${area}.${operation}`, {
|
|
1156
|
+
area,
|
|
1157
|
+
key: args[0] ?? null,
|
|
1158
|
+
value: args[0] && isSensitiveKey(args[0]) ? REDACTED : args[1],
|
|
1159
|
+
action: operation
|
|
1160
|
+
});
|
|
1161
|
+
return result;
|
|
1162
|
+
};
|
|
1163
|
+
prototype[operation] = wrapped;
|
|
1164
|
+
cleanups.add(() => {
|
|
1165
|
+
if (prototype[operation] === wrapped)
|
|
1166
|
+
prototype[operation] = original;
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
listen(win, "storage", ((event) => {
|
|
1170
|
+
let area = "local";
|
|
1171
|
+
try {
|
|
1172
|
+
if (event.storageArea === win.sessionStorage) area = "session";
|
|
1173
|
+
} catch {
|
|
1174
|
+
}
|
|
1175
|
+
record("storage", "Cross-tab storage change", {
|
|
1176
|
+
area,
|
|
1177
|
+
key: event.key,
|
|
1178
|
+
value: event.key && isSensitiveKey(event.key) ? REDACTED : event.newValue,
|
|
1179
|
+
action: event.key == null ? "clear" : event.newValue == null ? "removeItem" : "setItem"
|
|
1180
|
+
});
|
|
1181
|
+
}));
|
|
1182
|
+
record("storage", "Storage snapshot", storageSnapshot());
|
|
1183
|
+
} catch (error) {
|
|
1184
|
+
record("storage", "Storage unavailable", { error });
|
|
1185
|
+
}
|
|
1186
|
+
const navigation = (action) => record("navigation", action, {
|
|
1187
|
+
url: redactUrl(win.location.href),
|
|
1188
|
+
title: document.title
|
|
1189
|
+
});
|
|
1190
|
+
for (const method of ["pushState", "replaceState"]) {
|
|
1191
|
+
const original = win.history[method];
|
|
1192
|
+
const wrapped = function(...args) {
|
|
1193
|
+
const result = original.apply(win.history, args);
|
|
1194
|
+
navigation(method);
|
|
1195
|
+
return result;
|
|
1196
|
+
};
|
|
1197
|
+
win.history[method] = wrapped;
|
|
1198
|
+
cleanups.add(() => {
|
|
1199
|
+
if (win.history[method] === wrapped) win.history[method] = original;
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
listen(win, "popstate", () => navigation("popstate"));
|
|
1203
|
+
listen(win, "hashchange", () => navigation("hashchange"));
|
|
1204
|
+
navigation("initial");
|
|
1205
|
+
if (typeof win.PerformanceObserver === "function") {
|
|
1206
|
+
let cls = 0;
|
|
1207
|
+
let windowStart = 0;
|
|
1208
|
+
let lastShift = 0;
|
|
1209
|
+
let windowValue = 0;
|
|
1210
|
+
for (const type of [
|
|
1211
|
+
"navigation",
|
|
1212
|
+
"largest-contentful-paint",
|
|
1213
|
+
"layout-shift",
|
|
1214
|
+
"longtask",
|
|
1215
|
+
"paint"
|
|
1216
|
+
]) {
|
|
1217
|
+
if (!win.PerformanceObserver.supportedEntryTypes?.includes(type))
|
|
1218
|
+
continue;
|
|
1219
|
+
try {
|
|
1220
|
+
const observer = new win.PerformanceObserver((list) => {
|
|
1221
|
+
for (const entry of list.getEntries().slice(0, 50)) {
|
|
1222
|
+
const item = entry;
|
|
1223
|
+
if (type === "layout-shift") {
|
|
1224
|
+
if (item.hadRecentInput) continue;
|
|
1225
|
+
if (!lastShift || item.startTime - lastShift > 1e3 || item.startTime - windowStart > 5e3) {
|
|
1226
|
+
windowStart = item.startTime;
|
|
1227
|
+
windowValue = 0;
|
|
1228
|
+
}
|
|
1229
|
+
windowValue += item.value ?? 0;
|
|
1230
|
+
lastShift = item.startTime;
|
|
1231
|
+
cls = Math.max(cls, windowValue);
|
|
1232
|
+
record("performance", "CLS", {
|
|
1233
|
+
value: cls,
|
|
1234
|
+
delta: item.value,
|
|
1235
|
+
startTime: item.startTime
|
|
1236
|
+
});
|
|
1237
|
+
} else
|
|
1238
|
+
record(
|
|
1239
|
+
"performance",
|
|
1240
|
+
type === "largest-contentful-paint" ? "LCP" : type,
|
|
1241
|
+
{
|
|
1242
|
+
value: type === "largest-contentful-paint" ? item.renderTime || item.loadTime || item.startTime : item.duration || item.startTime,
|
|
1243
|
+
name: item.name,
|
|
1244
|
+
duration: item.duration,
|
|
1245
|
+
startTime: item.startTime,
|
|
1246
|
+
...type === "navigation" ? {
|
|
1247
|
+
domContentLoaded: item.domContentLoadedEventEnd,
|
|
1248
|
+
load: item.loadEventEnd,
|
|
1249
|
+
ttfb: item.responseStart,
|
|
1250
|
+
transferSize: item.transferSize
|
|
1251
|
+
} : {}
|
|
1252
|
+
}
|
|
1253
|
+
);
|
|
1254
|
+
}
|
|
1255
|
+
});
|
|
1256
|
+
observer.observe({ type, buffered: true });
|
|
1257
|
+
cleanups.add(() => observer.disconnect());
|
|
1258
|
+
} catch {
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
connect();
|
|
1263
|
+
return client;
|
|
1264
|
+
}
|
|
1265
|
+
export {
|
|
1266
|
+
REDACTED,
|
|
1267
|
+
SDK_VERSION,
|
|
1268
|
+
containsSerializationMarkers,
|
|
1269
|
+
initFarol,
|
|
1270
|
+
redactUrl,
|
|
1271
|
+
safeSerialize
|
|
1272
|
+
};
|
|
1273
|
+
//# sourceMappingURL=index.js.map
|