@copilotkit/learning 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +76 -0
- package/dist/index.cjs +1134 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +173 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +173 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1126 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +45 -0
- package/scripts/check-size.mjs +33 -0
- package/src/__tests__/clicks.test.ts +202 -0
- package/src/__tests__/collector.test.ts +307 -0
- package/src/__tests__/http-sink.test.ts +72 -0
- package/src/__tests__/inputs.test.ts +77 -0
- package/src/__tests__/navigation.test.ts +117 -0
- package/src/__tests__/network.test.ts +609 -0
- package/src/__tests__/redact.test.ts +732 -0
- package/src/__tests__/routes.test.ts +26 -0
- package/src/__tests__/trajectory-collector.test.ts +367 -0
- package/src/__tests__/vitest-environment.mjs +23 -0
- package/src/clicks.ts +193 -0
- package/src/collector.ts +199 -0
- package/src/http-sink.ts +38 -0
- package/src/index.ts +30 -0
- package/src/inputs.ts +42 -0
- package/src/navigation.ts +123 -0
- package/src/network-body.ts +237 -0
- package/src/network.ts +394 -0
- package/src/redact.ts +257 -0
- package/src/routes.ts +14 -0
- package/src/trajectory-collector.ts +193 -0
- package/src/trajectory-types.ts +50 -0
- package/src/types.ts +98 -0
- package/tsconfig.json +16 -0
- package/tsdown.config.ts +11 -0
- package/vitest.config.mjs +9 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1126 @@
|
|
|
1
|
+
//#region src/types.ts
|
|
2
|
+
/** Event names the collector and CopilotKit Core emit. `emit()` rejects them. */
|
|
3
|
+
const BUILT_IN_EVENT_NAMES = [
|
|
4
|
+
"page",
|
|
5
|
+
"navigation",
|
|
6
|
+
"click",
|
|
7
|
+
"input",
|
|
8
|
+
"network",
|
|
9
|
+
"thread.linked",
|
|
10
|
+
"agent.run",
|
|
11
|
+
"agent.message",
|
|
12
|
+
"tool.call"
|
|
13
|
+
];
|
|
14
|
+
/** Replaces values that never leave the browser, even with full capture: passwords and credentials. */
|
|
15
|
+
const REDACTED = "[redacted]";
|
|
16
|
+
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region src/redact.ts
|
|
19
|
+
const CREDENTIAL = /password|passwd|passphrase|passcode|credential|api(key|token)|privatekey|subscriptionkey|(access|refresh|auth|session)token|clientsecret/;
|
|
20
|
+
const CREDENTIAL_WORD = / (secret|id ?token)s? | (authorization|cookies?|jwt|bearer ?token|[cx]srf ?token) $|^ (pwd|pass|token|otp) $/;
|
|
21
|
+
const METADATA = / (ids?|at|in|expires?|expiry|length|count|enabled|required|policy|hint|type) $/;
|
|
22
|
+
const PASSWORD_AUTOCOMPLETE = /(current|new)-password|one-time-code/;
|
|
23
|
+
const TOKEN = /"((?:\\[\s\S]|[^"\\])*)("?)(\s*:\s*)?/g;
|
|
24
|
+
const STRING = /"(?:\\[\s\S]?|[^"\\])*"?/y;
|
|
25
|
+
const PAIR = /([\w$.%+[\]-]{1,64})(\s*(?:=|:(?=\s|$))\s*)((?:(?:basic|bearer|digest|token) +)?[^\s&,;)}\]]*)/gi;
|
|
26
|
+
const USERINFO = /([a-z][\w+.-]{0,31}:\/\/)[^\s/?#"]*@/gi;
|
|
27
|
+
/** Text beyond this is neither redacted nor kept; snapshots keep 4 KiB of the result. */
|
|
28
|
+
const TEXT_LIMIT = 16 * 1024;
|
|
29
|
+
const REMEMBERED_VALUES = 20;
|
|
30
|
+
const isCredentialKey = (key) => {
|
|
31
|
+
const words = ` ${key.replace(/%([\da-f]{2})/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16))).replace(/([a-z\d])(?=[A-Z])/g, "$1 ").toLowerCase().split(/[^a-z\d]+/).filter(Boolean).join(" ")} `;
|
|
32
|
+
return !METADATA.test(words) && (CREDENTIAL.test(words.replace(/ /g, "")) || CREDENTIAL_WORD.test(words));
|
|
33
|
+
};
|
|
34
|
+
const decode = (body) => {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(`"${body}"`);
|
|
37
|
+
} catch {
|
|
38
|
+
return body;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
/** The end of the JSON value at `start`: a string, a balanced object or array, or a bare literal. */
|
|
42
|
+
const endOfValue = (text, start) => {
|
|
43
|
+
let depth = 0;
|
|
44
|
+
for (let i = start; i < text.length; i++) {
|
|
45
|
+
const char = text[i];
|
|
46
|
+
if (char === "\"") {
|
|
47
|
+
STRING.lastIndex = i;
|
|
48
|
+
STRING.test(text);
|
|
49
|
+
i = STRING.lastIndex - 1;
|
|
50
|
+
} else if (char === "{" || char === "[") {
|
|
51
|
+
depth++;
|
|
52
|
+
continue;
|
|
53
|
+
} else if (char === "}" || char === "]") {
|
|
54
|
+
if (--depth < 0) return i;
|
|
55
|
+
} else {
|
|
56
|
+
if (!depth && (char === "," || /\s/.test(char))) return i;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (!depth) return i + 1;
|
|
60
|
+
}
|
|
61
|
+
return text.length;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Redacts credential values in JSON (valid or cut off) and in key/value text,
|
|
65
|
+
* and recurses into string values that hold JSON, forms, or URLs. Unchanged
|
|
66
|
+
* parts keep their exact original text.
|
|
67
|
+
*/
|
|
68
|
+
const redactText = (text, depth = 0) => {
|
|
69
|
+
const token = new RegExp(TOKEN);
|
|
70
|
+
let pending = false;
|
|
71
|
+
const pairs = (part) => {
|
|
72
|
+
const segment = part.replace(USERINFO, "$1");
|
|
73
|
+
return segment.replace(PAIR, (pair, key, separator, value, offset) => {
|
|
74
|
+
if (!isCredentialKey(key)) return pair;
|
|
75
|
+
if (offset + pair.length === segment.length) {
|
|
76
|
+
pending = true;
|
|
77
|
+
if (!value) return pair;
|
|
78
|
+
}
|
|
79
|
+
return key + separator + REDACTED;
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
let out = "";
|
|
83
|
+
let last = 0;
|
|
84
|
+
let match;
|
|
85
|
+
while (match = token.exec(text)) {
|
|
86
|
+
const [raw, body, closed, colon] = match;
|
|
87
|
+
pending = false;
|
|
88
|
+
out += pairs(text.slice(last, match.index));
|
|
89
|
+
let end = token.lastIndex;
|
|
90
|
+
let replacement = raw;
|
|
91
|
+
const value = body.includes("\\") ? decode(body) : body;
|
|
92
|
+
if (colon) {
|
|
93
|
+
if (isCredentialKey(value)) {
|
|
94
|
+
const valueEnd = endOfValue(text, end);
|
|
95
|
+
if (!/^(true|false|null|)$/.test(text.slice(end, valueEnd))) {
|
|
96
|
+
replacement += `"${REDACTED}"`;
|
|
97
|
+
end = valueEnd;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} else if (pending) replacement = `"${REDACTED}"`;
|
|
101
|
+
else if (depth < 4 && /[=:"]/.test(value)) {
|
|
102
|
+
const redacted = redactText(value, depth + 1);
|
|
103
|
+
if (redacted !== value) replacement = JSON.stringify(redacted).slice(0, closed ? void 0 : -1);
|
|
104
|
+
}
|
|
105
|
+
out += replacement;
|
|
106
|
+
last = token.lastIndex = end;
|
|
107
|
+
}
|
|
108
|
+
return out + pairs(text.slice(last));
|
|
109
|
+
};
|
|
110
|
+
/** Per-session redaction state: password-like fields and the values typed into them. */
|
|
111
|
+
function createRedactor() {
|
|
112
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
113
|
+
const remembered = /* @__PURE__ */ new Map();
|
|
114
|
+
let observer;
|
|
115
|
+
const toggled = (records) => {
|
|
116
|
+
for (const record of records) if (record.oldValue?.toLowerCase() === "password") seen.add(record.target);
|
|
117
|
+
};
|
|
118
|
+
/** Replaces remembered values, raw and in their JSON and URL encodings. */
|
|
119
|
+
const scrub = (text) => {
|
|
120
|
+
for (const value of [...remembered.values()].sort((a, b) => b.length - a.length)) for (const form of [
|
|
121
|
+
value,
|
|
122
|
+
JSON.stringify(value).slice(1, -1),
|
|
123
|
+
encodeURIComponent(value),
|
|
124
|
+
new URLSearchParams({ v: value }).toString().slice(2)
|
|
125
|
+
]) text = /^\d+$/.test(form) ? text.replace(RegExp(`(?<!\\d)${form}(?!\\d)`, "g"), REDACTED) : text.split(form).join(REDACTED);
|
|
126
|
+
return text;
|
|
127
|
+
};
|
|
128
|
+
/** True for current, former (show-password toggles), and autocomplete password fields. */
|
|
129
|
+
const isPassword = (element) => {
|
|
130
|
+
if (observer) toggled(observer.takeRecords());
|
|
131
|
+
if (!(element instanceof HTMLInputElement) || !(element.type === "password" || seen.has(element) || isCredentialKey(element.name) || PASSWORD_AUTOCOMPLETE.test(element.getAttribute("autocomplete") ?? ""))) return false;
|
|
132
|
+
seen.add(element);
|
|
133
|
+
if (element.value.length >= 4) {
|
|
134
|
+
remembered.delete(element);
|
|
135
|
+
remembered.set(element, element.value);
|
|
136
|
+
if (remembered.size > REMEMBERED_VALUES) remembered.delete(remembered.keys().next().value);
|
|
137
|
+
}
|
|
138
|
+
return true;
|
|
139
|
+
};
|
|
140
|
+
return {
|
|
141
|
+
isPassword,
|
|
142
|
+
watch() {
|
|
143
|
+
try {
|
|
144
|
+
for (const input of document.querySelectorAll("input[type=password]")) seen.add(input);
|
|
145
|
+
} catch {}
|
|
146
|
+
let current;
|
|
147
|
+
try {
|
|
148
|
+
current = new MutationObserver(toggled);
|
|
149
|
+
current.observe(document, {
|
|
150
|
+
subtree: true,
|
|
151
|
+
attributeFilter: ["type"],
|
|
152
|
+
attributeOldValue: true
|
|
153
|
+
});
|
|
154
|
+
observer = current;
|
|
155
|
+
} catch {
|
|
156
|
+
current = void 0;
|
|
157
|
+
}
|
|
158
|
+
const remember = (event) => {
|
|
159
|
+
try {
|
|
160
|
+
const [target] = event.composedPath();
|
|
161
|
+
if (target instanceof Element) isPassword(target);
|
|
162
|
+
} catch {}
|
|
163
|
+
};
|
|
164
|
+
for (const type of ["input", "change"]) window.addEventListener(type, remember, true);
|
|
165
|
+
return () => {
|
|
166
|
+
current?.disconnect();
|
|
167
|
+
if (current !== void 0 && observer === current) observer = void 0;
|
|
168
|
+
for (const type of ["input", "change"]) window.removeEventListener(type, remember, true);
|
|
169
|
+
};
|
|
170
|
+
},
|
|
171
|
+
field: (key, value) => isCredentialKey(key) ? REDACTED : scrub(redactText(value)),
|
|
172
|
+
body(text) {
|
|
173
|
+
try {
|
|
174
|
+
return scrub(redactText(text.slice(0, TEXT_LIMIT)));
|
|
175
|
+
} catch {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
url(value) {
|
|
180
|
+
try {
|
|
181
|
+
return scrub(value.replace(/[^#]+/g, (part) => redactText(part)));
|
|
182
|
+
} catch {
|
|
183
|
+
return REDACTED;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
//#endregion
|
|
190
|
+
//#region src/clicks.ts
|
|
191
|
+
const INTERACTIVE = "button, a[href], input, select, textarea, label, summary, [role], [data-copilotkit-action], [contenteditable]:not([contenteditable=\"false\"])";
|
|
192
|
+
const RECOVERY_DISTANCE_PX = 5;
|
|
193
|
+
const MAX_TEXT_LENGTH = 1e3;
|
|
194
|
+
/** `closest()` that continues through shadow roots to their hosts. */
|
|
195
|
+
function closestAcrossShadow(el, selector) {
|
|
196
|
+
let current = el;
|
|
197
|
+
while (current !== null) {
|
|
198
|
+
const match = current.closest(selector);
|
|
199
|
+
if (match !== null) return match;
|
|
200
|
+
const root = current.getRootNode();
|
|
201
|
+
current = root instanceof ShadowRoot ? root.host : null;
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Finds the nearest interactive element and retains its text, attributes, and value.
|
|
207
|
+
* The explicit `data-copilotkit-ignore` opt-out still applies.
|
|
208
|
+
*/
|
|
209
|
+
function describeTarget(el, redact) {
|
|
210
|
+
if (closestAcrossShadow(el, "[data-copilotkit-ignore]") !== null) return null;
|
|
211
|
+
const element = el.closest(INTERACTIVE) ?? el;
|
|
212
|
+
const password = redact.isPassword(element);
|
|
213
|
+
return {
|
|
214
|
+
element,
|
|
215
|
+
target: {
|
|
216
|
+
tag: element.localName,
|
|
217
|
+
role: element.getAttribute("role"),
|
|
218
|
+
action: element.getAttribute("data-copilotkit-action"),
|
|
219
|
+
text: (element.textContent ?? "").slice(0, MAX_TEXT_LENGTH),
|
|
220
|
+
attributes: Object.fromEntries(Array.from(element.attributes, ({ name, value }) => [name, password && name === "value" || isCredentialKey(name.replace(/^data-/, "")) ? REDACTED : redact.url(value)])),
|
|
221
|
+
...readControlValue(element, redact)
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
/** Snapshot live control properties; HTML attributes do not reflect edited values. */
|
|
226
|
+
function readControlValue(element, redact) {
|
|
227
|
+
if (element instanceof HTMLInputElement) return {
|
|
228
|
+
value: redact.isPassword(element) ? REDACTED : element.value,
|
|
229
|
+
...["checkbox", "radio"].includes(element.type) ? { checked: element.checked } : {},
|
|
230
|
+
...element.type === "file" ? { files: Array.from(element.files ?? [], (file) => ({
|
|
231
|
+
name: file.name,
|
|
232
|
+
type: file.type,
|
|
233
|
+
size: file.size,
|
|
234
|
+
lastModified: file.lastModified
|
|
235
|
+
})) } : {}
|
|
236
|
+
};
|
|
237
|
+
if (element instanceof HTMLSelectElement) return {
|
|
238
|
+
value: element.value,
|
|
239
|
+
selectedValues: Array.from(element.selectedOptions, (option) => option.value)
|
|
240
|
+
};
|
|
241
|
+
if (element instanceof HTMLTextAreaElement) return { value: element.value };
|
|
242
|
+
if (element.matches("[contenteditable]:not([contenteditable=\"false\"])")) return { value: element.textContent ?? "" };
|
|
243
|
+
return {};
|
|
244
|
+
}
|
|
245
|
+
function eventElement(event) {
|
|
246
|
+
const [first] = event.composedPath();
|
|
247
|
+
return first instanceof Element ? first : null;
|
|
248
|
+
}
|
|
249
|
+
function isRootOrDetached(el) {
|
|
250
|
+
return el.localName === "body" || el.localName === "html" || !el.isConnected;
|
|
251
|
+
}
|
|
252
|
+
function isNear(origin, event) {
|
|
253
|
+
return Math.abs(origin.x - event.clientX) <= RECOVERY_DISTANCE_PX && Math.abs(origin.y - event.clientY) <= RECOVERY_DISTANCE_PX;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Listens for clicks in the capture phase on `window`, so handlers that stop
|
|
257
|
+
* propagation do not hide them. The target is described at `pointerdown`,
|
|
258
|
+
* because a re-render before `click` can move the click to `<body>`.
|
|
259
|
+
*/
|
|
260
|
+
function installClickCapture(params) {
|
|
261
|
+
const { emit, getRoute, enrich, isTrusted = (event) => event.isTrusted, redact = createRedactor() } = params;
|
|
262
|
+
let origin = null;
|
|
263
|
+
const onPointerDown = (event) => {
|
|
264
|
+
try {
|
|
265
|
+
if (event.isPrimary === false) return;
|
|
266
|
+
const el = eventElement(event);
|
|
267
|
+
origin = {
|
|
268
|
+
described: el === null ? null : describeTarget(el, redact),
|
|
269
|
+
x: event.clientX,
|
|
270
|
+
y: event.clientY
|
|
271
|
+
};
|
|
272
|
+
} catch {
|
|
273
|
+
origin = null;
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
const onClick = (event) => {
|
|
277
|
+
const pointerOrigin = origin;
|
|
278
|
+
origin = null;
|
|
279
|
+
try {
|
|
280
|
+
if (!isTrusted(event)) return;
|
|
281
|
+
const el = eventElement(event);
|
|
282
|
+
if (el === null) return;
|
|
283
|
+
const described = pointerOrigin !== null && isRootOrDetached(el) && isNear(pointerOrigin, event) ? pointerOrigin.described : describeTarget(el, redact);
|
|
284
|
+
if (described === null) return;
|
|
285
|
+
const input = event.detail === 0 ? "keyboard" : "pointer";
|
|
286
|
+
emit("click", {
|
|
287
|
+
target: {
|
|
288
|
+
...described.target,
|
|
289
|
+
input
|
|
290
|
+
},
|
|
291
|
+
route: getRoute(),
|
|
292
|
+
url: redact.url(location.href),
|
|
293
|
+
...enrich?.(described.element)
|
|
294
|
+
});
|
|
295
|
+
} catch {}
|
|
296
|
+
};
|
|
297
|
+
window.addEventListener("pointerdown", onPointerDown, {
|
|
298
|
+
capture: true,
|
|
299
|
+
passive: true
|
|
300
|
+
});
|
|
301
|
+
window.addEventListener("click", onClick, {
|
|
302
|
+
capture: true,
|
|
303
|
+
passive: true
|
|
304
|
+
});
|
|
305
|
+
return () => {
|
|
306
|
+
window.removeEventListener("pointerdown", onPointerDown, { capture: true });
|
|
307
|
+
window.removeEventListener("click", onClick, { capture: true });
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
//#endregion
|
|
312
|
+
//#region src/inputs.ts
|
|
313
|
+
/** Records user edits without changing the control or interfering with its listeners. */
|
|
314
|
+
function installInputCapture(params) {
|
|
315
|
+
const { emit, isTrusted = (event) => event.isTrusted, redact = createRedactor() } = params;
|
|
316
|
+
const onInput = (event) => {
|
|
317
|
+
try {
|
|
318
|
+
if (!isTrusted(event)) return;
|
|
319
|
+
const [element] = event.composedPath();
|
|
320
|
+
if (!(element instanceof Element)) return;
|
|
321
|
+
const described = describeTarget(element, redact);
|
|
322
|
+
if (described === null) return;
|
|
323
|
+
const value = readControlValue(element, redact);
|
|
324
|
+
if (!("value" in value)) return;
|
|
325
|
+
emit("input", {
|
|
326
|
+
eventType: event.type,
|
|
327
|
+
target: {
|
|
328
|
+
...described.target,
|
|
329
|
+
...value
|
|
330
|
+
},
|
|
331
|
+
url: redact.url(location.href),
|
|
332
|
+
route: location.pathname
|
|
333
|
+
});
|
|
334
|
+
} catch {}
|
|
335
|
+
};
|
|
336
|
+
window.addEventListener("input", onInput, {
|
|
337
|
+
capture: true,
|
|
338
|
+
passive: true
|
|
339
|
+
});
|
|
340
|
+
window.addEventListener("change", onInput, {
|
|
341
|
+
capture: true,
|
|
342
|
+
passive: true
|
|
343
|
+
});
|
|
344
|
+
return () => {
|
|
345
|
+
window.removeEventListener("input", onInput, { capture: true });
|
|
346
|
+
window.removeEventListener("change", onInput, { capture: true });
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
//#endregion
|
|
351
|
+
//#region src/navigation.ts
|
|
352
|
+
function isNavigationTarget(value) {
|
|
353
|
+
return typeof value === "object" && value !== null && "addEventListener" in value && "removeEventListener" in value;
|
|
354
|
+
}
|
|
355
|
+
function getNavigation() {
|
|
356
|
+
const navigation = Reflect.get(window, "navigation");
|
|
357
|
+
return isNavigationTarget(navigation) ? navigation : null;
|
|
358
|
+
}
|
|
359
|
+
function toNavigationType(value) {
|
|
360
|
+
switch (value) {
|
|
361
|
+
case "replace":
|
|
362
|
+
case "traverse":
|
|
363
|
+
case "reload": return value;
|
|
364
|
+
default: return "push";
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Emits `navigation` when the full URL changes in a single-page app.
|
|
369
|
+
* Uses the Navigation API when it exists. Otherwise patches `pushState` and
|
|
370
|
+
* `replaceState` on the prototype and on the instance, because some routers
|
|
371
|
+
* (Next.js) wrap the instance method.
|
|
372
|
+
*/
|
|
373
|
+
function installNavigationCapture(params) {
|
|
374
|
+
const { emit, redact = createRedactor() } = params;
|
|
375
|
+
let current = location.href;
|
|
376
|
+
const check = (navigationType) => {
|
|
377
|
+
try {
|
|
378
|
+
const next = location.href;
|
|
379
|
+
if (next === current) return;
|
|
380
|
+
emit("navigation", {
|
|
381
|
+
from: redact.url(current),
|
|
382
|
+
to: redact.url(next),
|
|
383
|
+
navigationType
|
|
384
|
+
});
|
|
385
|
+
current = next;
|
|
386
|
+
} catch {}
|
|
387
|
+
};
|
|
388
|
+
const navigation = getNavigation();
|
|
389
|
+
if (navigation !== null) {
|
|
390
|
+
const onEntryChange = (event) => check(toNavigationType(Reflect.get(event, "navigationType")));
|
|
391
|
+
navigation.addEventListener("currententrychange", onEntryChange);
|
|
392
|
+
return () => navigation.removeEventListener("currententrychange", onEntryChange);
|
|
393
|
+
}
|
|
394
|
+
return installHistoryFallback(check);
|
|
395
|
+
}
|
|
396
|
+
function installHistoryFallback(check) {
|
|
397
|
+
let active = true;
|
|
398
|
+
const restores = [];
|
|
399
|
+
const patch = (target, method) => {
|
|
400
|
+
const original = target[method];
|
|
401
|
+
const wrapper = function(...args) {
|
|
402
|
+
const result = original.apply(this, args);
|
|
403
|
+
if (active) check(method === "pushState" ? "push" : "replace");
|
|
404
|
+
return result;
|
|
405
|
+
};
|
|
406
|
+
target[method] = wrapper;
|
|
407
|
+
restores.push(() => {
|
|
408
|
+
if (target[method] === wrapper) target[method] = original;
|
|
409
|
+
});
|
|
410
|
+
};
|
|
411
|
+
for (const method of ["pushState", "replaceState"]) {
|
|
412
|
+
patch(History.prototype, method);
|
|
413
|
+
if (Object.prototype.hasOwnProperty.call(history, method)) patch(history, method);
|
|
414
|
+
}
|
|
415
|
+
const onPopState = () => check("traverse");
|
|
416
|
+
window.addEventListener("popstate", onPopState);
|
|
417
|
+
window.addEventListener("hashchange", onPopState);
|
|
418
|
+
return () => {
|
|
419
|
+
active = false;
|
|
420
|
+
window.removeEventListener("popstate", onPopState);
|
|
421
|
+
window.removeEventListener("hashchange", onPopState);
|
|
422
|
+
for (const restore of restores) restore();
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
//#endregion
|
|
427
|
+
//#region src/network-body.ts
|
|
428
|
+
const BODY_BYTES = 4096;
|
|
429
|
+
const READ_TIMEOUT_MS = 1e3;
|
|
430
|
+
const encoder = new TextEncoder();
|
|
431
|
+
const redactionFailed = () => ({
|
|
432
|
+
status: "unavailable",
|
|
433
|
+
reason: "redaction-failed"
|
|
434
|
+
});
|
|
435
|
+
/** Redacts up to TEXT_LIMIT bytes of text first, then cuts the result to BODY_BYTES. */
|
|
436
|
+
function snapshot(bytes, status, contentType, redact, reason) {
|
|
437
|
+
let text;
|
|
438
|
+
if (!isBinary(contentType)) try {
|
|
439
|
+
text = new TextDecoder("utf-8", { fatal: !contentType }).decode(bytes, { stream: status !== "complete" });
|
|
440
|
+
} catch {}
|
|
441
|
+
if (text !== void 0) {
|
|
442
|
+
text = redact(text);
|
|
443
|
+
if (text === void 0) return redactionFailed();
|
|
444
|
+
if (text.includes("\0")) {
|
|
445
|
+
bytes = encoder.encode(text);
|
|
446
|
+
text = void 0;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const binary = text === void 0;
|
|
450
|
+
text ??= btoa(String.fromCharCode(...bytes.subarray(0, BODY_BYTES)));
|
|
451
|
+
const result = {
|
|
452
|
+
status: status === "complete" && bytes.length === 0 ? "empty" : status,
|
|
453
|
+
text,
|
|
454
|
+
encoding: binary ? "base64" : "utf-8",
|
|
455
|
+
...reason ? { reason } : {}
|
|
456
|
+
};
|
|
457
|
+
if (encoder.encode(JSON.stringify(result)).length <= BODY_BYTES) return result;
|
|
458
|
+
result.status = status === "complete" ? "truncated" : status;
|
|
459
|
+
result.truncated = true;
|
|
460
|
+
let low = 0;
|
|
461
|
+
let high = text.length;
|
|
462
|
+
while (low < high) {
|
|
463
|
+
const middle = Math.ceil((low + high) / 2);
|
|
464
|
+
result.text = text.slice(0, middle);
|
|
465
|
+
if (encoder.encode(JSON.stringify(result)).length <= BODY_BYTES) low = middle;
|
|
466
|
+
else high = middle - 1;
|
|
467
|
+
}
|
|
468
|
+
if (binary) low -= low % 4;
|
|
469
|
+
else if (low > 0 && /[\uD800-\uDBFF]/.test(text[low - 1])) low--;
|
|
470
|
+
result.text = text.slice(0, low);
|
|
471
|
+
return result;
|
|
472
|
+
}
|
|
473
|
+
function snapshotText(value, redact, status = "complete", reason) {
|
|
474
|
+
return snapshot(encoder.encode(value.slice(0, TEXT_LIMIT)), value.length > TEXT_LIMIT && status === "complete" ? "truncated" : status, "text/plain", redact, reason);
|
|
475
|
+
}
|
|
476
|
+
function isBinary(contentType) {
|
|
477
|
+
return contentType !== "" && !/(^text\/|json|xml|javascript|graphql|x-www-form-urlencoded|multipart\/form-data)/i.test(contentType);
|
|
478
|
+
}
|
|
479
|
+
/** Cancel only cloned/synthetic bodies. Cancelling a tee may wait for the app's branch. */
|
|
480
|
+
function createBodyCapture(redact) {
|
|
481
|
+
const cancellations = /* @__PURE__ */ new Set();
|
|
482
|
+
let active = true;
|
|
483
|
+
const read = (stream, contentType = "") => {
|
|
484
|
+
if (!stream) return Promise.resolve({ status: "empty" });
|
|
485
|
+
if (!active) return Promise.resolve({
|
|
486
|
+
status: "unavailable",
|
|
487
|
+
reason: "capture-stopped"
|
|
488
|
+
});
|
|
489
|
+
let reader;
|
|
490
|
+
try {
|
|
491
|
+
reader = stream.getReader();
|
|
492
|
+
} catch {
|
|
493
|
+
return Promise.resolve({
|
|
494
|
+
status: "unavailable",
|
|
495
|
+
reason: "body-locked"
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
return new Promise((resolve) => {
|
|
499
|
+
const bytes = new Uint8Array(TEXT_LIMIT);
|
|
500
|
+
let length = 0;
|
|
501
|
+
let finished = false;
|
|
502
|
+
const finish = (status, reason) => {
|
|
503
|
+
if (finished) return;
|
|
504
|
+
finished = true;
|
|
505
|
+
clearTimeout(timer);
|
|
506
|
+
cancellations.delete(cancel);
|
|
507
|
+
resolve(snapshot(bytes.subarray(0, length), status, contentType, redact.body, reason));
|
|
508
|
+
reader.cancel().catch(() => {});
|
|
509
|
+
};
|
|
510
|
+
const cancel = () => finish("interrupted", "capture-stopped");
|
|
511
|
+
const timer = setTimeout(() => finish("timeout", "body-read-timeout"), READ_TIMEOUT_MS);
|
|
512
|
+
cancellations.add(cancel);
|
|
513
|
+
(async () => {
|
|
514
|
+
try {
|
|
515
|
+
for (;;) {
|
|
516
|
+
const chunk = await reader.read();
|
|
517
|
+
if (finished) break;
|
|
518
|
+
if (chunk.done) {
|
|
519
|
+
finish("complete");
|
|
520
|
+
break;
|
|
521
|
+
}
|
|
522
|
+
const kept = chunk.value.subarray(0, TEXT_LIMIT - length);
|
|
523
|
+
bytes.set(kept, length);
|
|
524
|
+
length += kept.length;
|
|
525
|
+
if (length === TEXT_LIMIT) {
|
|
526
|
+
finish("truncated");
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
} catch {
|
|
531
|
+
finish("interrupted", "body-read-failed");
|
|
532
|
+
} finally {
|
|
533
|
+
try {
|
|
534
|
+
reader.releaseLock();
|
|
535
|
+
} catch {}
|
|
536
|
+
}
|
|
537
|
+
})();
|
|
538
|
+
});
|
|
539
|
+
};
|
|
540
|
+
const body = (value, contentType = "") => {
|
|
541
|
+
try {
|
|
542
|
+
if (value == null) return Promise.resolve({ status: "empty" });
|
|
543
|
+
if (value instanceof URLSearchParams) value = value.toString();
|
|
544
|
+
if (typeof value === "string") return Promise.resolve(snapshotText(value, redact.body));
|
|
545
|
+
if (typeof ReadableStream !== "undefined" && value instanceof ReadableStream) return Promise.resolve({
|
|
546
|
+
status: "unavailable",
|
|
547
|
+
reason: "caller-owned-stream"
|
|
548
|
+
});
|
|
549
|
+
if (value instanceof FormData) {
|
|
550
|
+
const form = new FormData();
|
|
551
|
+
value.forEach((field, key) => form.append(key, typeof field === "string" ? redact.field(key, field) : field));
|
|
552
|
+
value = form;
|
|
553
|
+
}
|
|
554
|
+
const response = new Response(value);
|
|
555
|
+
return read(response.body, contentType || response.headers.get("content-type") || "");
|
|
556
|
+
} catch {
|
|
557
|
+
return Promise.resolve({
|
|
558
|
+
status: "unavailable",
|
|
559
|
+
reason: "body-not-readable"
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
return {
|
|
564
|
+
read,
|
|
565
|
+
body,
|
|
566
|
+
text: (value, status, reason) => snapshotText(value, redact.body, status, reason),
|
|
567
|
+
stop() {
|
|
568
|
+
active = false;
|
|
569
|
+
for (const cancel of cancellations) cancel();
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
//#endregion
|
|
575
|
+
//#region src/network.ts
|
|
576
|
+
const emptyBody = () => Promise.resolve({ status: "empty" });
|
|
577
|
+
const unavailableBody = (reason) => Promise.resolve({
|
|
578
|
+
status: "unavailable",
|
|
579
|
+
reason
|
|
580
|
+
});
|
|
581
|
+
const toAbsolute = (url) => new URL(url, location.href).href;
|
|
582
|
+
const CREDENTIAL_HEADERS = new Set([
|
|
583
|
+
"x-amz-security-token",
|
|
584
|
+
"private-token",
|
|
585
|
+
"x-token"
|
|
586
|
+
]);
|
|
587
|
+
const headerValue = (name, value) => CREDENTIAL_HEADERS.has(name.toLowerCase()) || isCredentialKey(name) ? REDACTED : value;
|
|
588
|
+
const headerValues = (headers) => Object.fromEntries(Array.from(headers.entries(), ([name, value]) => [name, headerValue(name, value)]));
|
|
589
|
+
function isAbortError(error) {
|
|
590
|
+
return typeof error === "object" && error !== null && "name" in error && error.name === "AbortError";
|
|
591
|
+
}
|
|
592
|
+
/** Records browser-visible request/response data without consuming the app's bodies. */
|
|
593
|
+
function installNetworkCapture(params) {
|
|
594
|
+
let active = true;
|
|
595
|
+
const { redact = createRedactor() } = params;
|
|
596
|
+
const bodies = createBodyCapture(redact);
|
|
597
|
+
const ignoreUrls = params.ignoreUrls.map((pattern) => typeof pattern === "string" ? toAbsolute(pattern) : new RegExp(pattern.source, pattern.flags));
|
|
598
|
+
const isIgnored = (url) => ignoreUrls.some((pattern) => {
|
|
599
|
+
if (typeof pattern === "string") return url.startsWith(pattern);
|
|
600
|
+
pattern.lastIndex = 0;
|
|
601
|
+
return pattern.test(url);
|
|
602
|
+
});
|
|
603
|
+
const record = (transport, request, requestBody, startedAt, status, outcome, response) => {
|
|
604
|
+
const durationMs = Math.round(performance.now() - startedAt);
|
|
605
|
+
const completedAt = Date.now();
|
|
606
|
+
Promise.all([requestBody, response?.body ?? unavailableBody("no-response")]).then(([sent, received]) => {
|
|
607
|
+
if (!active) return;
|
|
608
|
+
const href = redact.url(request.url);
|
|
609
|
+
const url = new URL(href);
|
|
610
|
+
params.emit("network", {
|
|
611
|
+
transport,
|
|
612
|
+
method: request.method,
|
|
613
|
+
url: href,
|
|
614
|
+
origin: url.origin,
|
|
615
|
+
route: url.pathname,
|
|
616
|
+
status,
|
|
617
|
+
durationMs,
|
|
618
|
+
completedAt,
|
|
619
|
+
outcome,
|
|
620
|
+
request: {
|
|
621
|
+
headers: request.headers,
|
|
622
|
+
body: sent
|
|
623
|
+
},
|
|
624
|
+
response: {
|
|
625
|
+
...response?.url ? { url: redact.url(response.url) } : {},
|
|
626
|
+
headers: response?.headers ?? {},
|
|
627
|
+
body: received
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
}).catch(() => {});
|
|
631
|
+
};
|
|
632
|
+
const originalFetch = globalThis.fetch;
|
|
633
|
+
const wrappedFetch = (input, init) => {
|
|
634
|
+
if (!active) return originalFetch.call(globalThis, input, init);
|
|
635
|
+
let request;
|
|
636
|
+
let ignored;
|
|
637
|
+
try {
|
|
638
|
+
const source = typeof Request !== "undefined" && input instanceof Request ? input : void 0;
|
|
639
|
+
request = {
|
|
640
|
+
url: toAbsolute(source ? source.url : String(input)),
|
|
641
|
+
method: (init?.method ?? source?.method ?? "GET").toUpperCase(),
|
|
642
|
+
headers: headerValues(new Headers(init?.headers ?? source?.headers))
|
|
643
|
+
};
|
|
644
|
+
ignored = isIgnored(request.url);
|
|
645
|
+
} catch {
|
|
646
|
+
return originalFetch.call(globalThis, input, init);
|
|
647
|
+
}
|
|
648
|
+
if (ignored) return originalFetch.call(globalThis, input, init);
|
|
649
|
+
const startedAt = performance.now();
|
|
650
|
+
let requestBody;
|
|
651
|
+
try {
|
|
652
|
+
requestBody = init?.body != null ? bodies.body(init.body, request.headers["content-type"]) : typeof Request !== "undefined" && input instanceof Request ? bodies.read(input.clone().body, request.headers["content-type"]) : emptyBody();
|
|
653
|
+
} catch {
|
|
654
|
+
requestBody = unavailableBody("request-clone-failed");
|
|
655
|
+
}
|
|
656
|
+
const failed = (error) => record("fetch", request, requestBody, startedAt, null, isAbortError(error) ? "aborted" : "error");
|
|
657
|
+
let promise;
|
|
658
|
+
try {
|
|
659
|
+
promise = originalFetch.call(globalThis, input, init);
|
|
660
|
+
} catch (error) {
|
|
661
|
+
failed(error);
|
|
662
|
+
throw error;
|
|
663
|
+
}
|
|
664
|
+
promise.then((response) => {
|
|
665
|
+
if (!active) return;
|
|
666
|
+
let responseBody;
|
|
667
|
+
try {
|
|
668
|
+
responseBody = response.type === "opaque" || response.type === "opaqueredirect" ? unavailableBody("opaque-response") : bodies.read(response.clone().body, response.headers.get("content-type") ?? "");
|
|
669
|
+
} catch {
|
|
670
|
+
responseBody = unavailableBody("response-clone-failed");
|
|
671
|
+
}
|
|
672
|
+
record("fetch", request, requestBody, startedAt, response.status, "ok", {
|
|
673
|
+
url: response.url,
|
|
674
|
+
headers: headerValues(response.headers),
|
|
675
|
+
body: responseBody
|
|
676
|
+
});
|
|
677
|
+
}, failed).catch(() => {});
|
|
678
|
+
return promise;
|
|
679
|
+
};
|
|
680
|
+
if (typeof originalFetch === "function") globalThis.fetch = wrappedFetch;
|
|
681
|
+
const uninstallXhr = patchXhr(isIgnored, record, bodies);
|
|
682
|
+
return () => {
|
|
683
|
+
active = false;
|
|
684
|
+
bodies.stop();
|
|
685
|
+
if (globalThis.fetch === wrappedFetch) globalThis.fetch = originalFetch;
|
|
686
|
+
uninstallXhr();
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
function patchXhr(isIgnored, record, bodies) {
|
|
690
|
+
if (typeof XMLHttpRequest !== "function") return () => {};
|
|
691
|
+
const proto = XMLHttpRequest.prototype;
|
|
692
|
+
const originalOpen = proto.open;
|
|
693
|
+
const originalSend = proto.send;
|
|
694
|
+
const originalSetHeader = proto.setRequestHeader;
|
|
695
|
+
const requests = /* @__PURE__ */ new WeakMap();
|
|
696
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
697
|
+
const inFlight = /* @__PURE__ */ new WeakMap();
|
|
698
|
+
let active = true;
|
|
699
|
+
const wrappedOpen = function(method, url, ...rest) {
|
|
700
|
+
inFlight.get(this)?.();
|
|
701
|
+
const result = Reflect.apply(originalOpen, this, [
|
|
702
|
+
method,
|
|
703
|
+
url,
|
|
704
|
+
...rest
|
|
705
|
+
]);
|
|
706
|
+
try {
|
|
707
|
+
requests.set(this, {
|
|
708
|
+
url: toAbsolute(String(url)),
|
|
709
|
+
method: method.toUpperCase(),
|
|
710
|
+
headers: {}
|
|
711
|
+
});
|
|
712
|
+
} catch {
|
|
713
|
+
requests.delete(this);
|
|
714
|
+
}
|
|
715
|
+
return result;
|
|
716
|
+
};
|
|
717
|
+
const wrappedSetHeader = function(name, value) {
|
|
718
|
+
const result = originalSetHeader.call(this, name, value);
|
|
719
|
+
const request = requests.get(this);
|
|
720
|
+
if (active && request) try {
|
|
721
|
+
const headers = new Headers(request.headers);
|
|
722
|
+
headers.append(name, value);
|
|
723
|
+
request.headers = headerValues(headers);
|
|
724
|
+
} catch {}
|
|
725
|
+
return result;
|
|
726
|
+
};
|
|
727
|
+
const wrappedSend = function(body) {
|
|
728
|
+
const request = requests.get(this);
|
|
729
|
+
if (!active || !request || isIgnored(request.url)) return originalSend.call(this, body);
|
|
730
|
+
const startedAt = performance.now();
|
|
731
|
+
let requestBody;
|
|
732
|
+
try {
|
|
733
|
+
requestBody = typeof Document !== "undefined" && body instanceof Document ? Promise.resolve(bodies.text(new XMLSerializer().serializeToString(body))) : bodies.body(body, request.headers["content-type"]);
|
|
734
|
+
} catch {
|
|
735
|
+
requestBody = unavailableBody("request-body-unavailable");
|
|
736
|
+
}
|
|
737
|
+
let aborted = false;
|
|
738
|
+
const onAbort = () => {
|
|
739
|
+
aborted = true;
|
|
740
|
+
};
|
|
741
|
+
const cleanup = () => {
|
|
742
|
+
this.removeEventListener("abort", onAbort);
|
|
743
|
+
this.removeEventListener("loadend", onEnd);
|
|
744
|
+
listeners.delete(cleanup);
|
|
745
|
+
if (inFlight.get(this) === cleanup) inFlight.delete(this);
|
|
746
|
+
};
|
|
747
|
+
const onEnd = () => {
|
|
748
|
+
cleanup();
|
|
749
|
+
if (!active) return;
|
|
750
|
+
const outcome = aborted ? "aborted" : this.status === 0 ? "error" : "ok";
|
|
751
|
+
const headers = {};
|
|
752
|
+
try {
|
|
753
|
+
for (const line of this.getAllResponseHeaders().split(/\r?\n/)) {
|
|
754
|
+
const split = line.indexOf(":");
|
|
755
|
+
if (split > 0) {
|
|
756
|
+
const name = line.slice(0, split).toLowerCase();
|
|
757
|
+
headers[name] = headerValue(name, line.slice(split + 1).trim());
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
} catch {}
|
|
761
|
+
let responseBody;
|
|
762
|
+
try {
|
|
763
|
+
if (this.responseType === "" || this.responseType === "text") responseBody = Promise.resolve(bodies.text(this.responseText, outcome === "ok" ? "complete" : "interrupted", outcome === "ok" ? void 0 : `request-${outcome}`));
|
|
764
|
+
else if (this.responseType === "json") responseBody = Promise.resolve(bodies.text(JSON.stringify(this.response)));
|
|
765
|
+
else if (this.responseType === "document") responseBody = this.responseXML ? Promise.resolve(bodies.text(new XMLSerializer().serializeToString(this.responseXML))) : unavailableBody("empty-document");
|
|
766
|
+
else responseBody = bodies.body(this.response, headers["content-type"] ?? "application/octet-stream");
|
|
767
|
+
} catch {
|
|
768
|
+
responseBody = unavailableBody("response-body-unavailable");
|
|
769
|
+
}
|
|
770
|
+
record("xhr", request, requestBody, startedAt, outcome === "ok" ? this.status : null, outcome, {
|
|
771
|
+
url: this.responseURL,
|
|
772
|
+
headers,
|
|
773
|
+
body: responseBody
|
|
774
|
+
});
|
|
775
|
+
};
|
|
776
|
+
this.addEventListener("abort", onAbort);
|
|
777
|
+
this.addEventListener("loadend", onEnd);
|
|
778
|
+
listeners.add(cleanup);
|
|
779
|
+
inFlight.set(this, cleanup);
|
|
780
|
+
try {
|
|
781
|
+
return originalSend.call(this, body);
|
|
782
|
+
} catch (error) {
|
|
783
|
+
cleanup();
|
|
784
|
+
record("xhr", request, requestBody, startedAt, null, isAbortError(error) ? "aborted" : "error");
|
|
785
|
+
throw error;
|
|
786
|
+
}
|
|
787
|
+
};
|
|
788
|
+
proto.open = wrappedOpen;
|
|
789
|
+
proto.send = wrappedSend;
|
|
790
|
+
proto.setRequestHeader = wrappedSetHeader;
|
|
791
|
+
return () => {
|
|
792
|
+
active = false;
|
|
793
|
+
for (const cleanup of listeners) cleanup();
|
|
794
|
+
if (proto.open === wrappedOpen) proto.open = originalOpen;
|
|
795
|
+
if (proto.send === wrappedSend) proto.send = originalSend;
|
|
796
|
+
if (proto.setRequestHeader === wrappedSetHeader) proto.setRequestHeader = originalSetHeader;
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
//#endregion
|
|
801
|
+
//#region src/routes.ts
|
|
802
|
+
/** Returns the original pathname. Capture no longer masks or categorizes routes. */
|
|
803
|
+
function toRoute(pathname, _templates = []) {
|
|
804
|
+
return pathname;
|
|
805
|
+
}
|
|
806
|
+
/** Resolves a URL without removing path segments, query parameters, or fragments. */
|
|
807
|
+
function toOriginAndRoute(url, _templates = [], base = globalThis.location?.href ?? "http://localhost/") {
|
|
808
|
+
const parsed = new URL(url, base);
|
|
809
|
+
return {
|
|
810
|
+
url: parsed.href,
|
|
811
|
+
origin: parsed.origin,
|
|
812
|
+
route: parsed.pathname
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
//#endregion
|
|
817
|
+
//#region src/collector.ts
|
|
818
|
+
const FLUSH_SIZE = 50;
|
|
819
|
+
const FLUSH_INTERVAL_MS = 2e3;
|
|
820
|
+
const BUILT_IN = new Set(BUILT_IN_EVENT_NAMES);
|
|
821
|
+
/**
|
|
822
|
+
* Creates a collector. Nothing is installed until `start()`: no listeners,
|
|
823
|
+
* no patches, no timers, no buffer. `stop()` removes all of them and sends
|
|
824
|
+
* queued events once, without retries.
|
|
825
|
+
*
|
|
826
|
+
* @example
|
|
827
|
+
* const collector = createCollector({ sink: httpSink("/api/learning-events") });
|
|
828
|
+
* collector.start({ trajectoryId: crypto.randomUUID() });
|
|
829
|
+
*/
|
|
830
|
+
function createCollector(options) {
|
|
831
|
+
const { sink, routes, beforeSend, enrich } = options;
|
|
832
|
+
const capture = {
|
|
833
|
+
clicks: true,
|
|
834
|
+
navigation: true,
|
|
835
|
+
network: true,
|
|
836
|
+
inputs: true,
|
|
837
|
+
...options.capture
|
|
838
|
+
};
|
|
839
|
+
let session = null;
|
|
840
|
+
let seq = 0;
|
|
841
|
+
const getRoute = () => toRoute(location.pathname, routes);
|
|
842
|
+
const flush = (sendOptions) => {
|
|
843
|
+
const current = session;
|
|
844
|
+
if (current === null) return;
|
|
845
|
+
if (current.queue.length === 0 && current.dropped === 0) return;
|
|
846
|
+
const events = current.queue.splice(0);
|
|
847
|
+
const dropped = current.dropped;
|
|
848
|
+
current.dropped = 0;
|
|
849
|
+
const batch = {
|
|
850
|
+
trajectoryId: current.trajectoryId,
|
|
851
|
+
learningContainerIds: current.learningContainerIds,
|
|
852
|
+
events,
|
|
853
|
+
dropped
|
|
854
|
+
};
|
|
855
|
+
const onFailure = () => {
|
|
856
|
+
if (session === current) current.dropped += events.length + dropped;
|
|
857
|
+
};
|
|
858
|
+
try {
|
|
859
|
+
Promise.resolve(sink(batch, sendOptions)).catch(onFailure);
|
|
860
|
+
} catch {
|
|
861
|
+
onFailure();
|
|
862
|
+
}
|
|
863
|
+
};
|
|
864
|
+
const record = (name, value) => {
|
|
865
|
+
const current = session;
|
|
866
|
+
if (current === null) return;
|
|
867
|
+
try {
|
|
868
|
+
seq += 1;
|
|
869
|
+
const event = {
|
|
870
|
+
type: "CUSTOM",
|
|
871
|
+
name,
|
|
872
|
+
value: {
|
|
873
|
+
...value,
|
|
874
|
+
seq
|
|
875
|
+
},
|
|
876
|
+
timestamp: Date.now()
|
|
877
|
+
};
|
|
878
|
+
const kept = beforeSend === void 0 ? event : beforeSend(event);
|
|
879
|
+
if (kept === null) return;
|
|
880
|
+
current.queue.push(kept);
|
|
881
|
+
if (current.queue.length >= FLUSH_SIZE) flush();
|
|
882
|
+
} catch {}
|
|
883
|
+
};
|
|
884
|
+
const start = ({ trajectoryId, learningContainerIds }) => {
|
|
885
|
+
if (session !== null) {
|
|
886
|
+
if (session.trajectoryId !== trajectoryId) console.warn(`[@copilotkit/learning] Trajectory "${session.trajectoryId}" is active. Stop it before you start "${trajectoryId}".`);
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
if (typeof window === "undefined" || typeof window.addEventListener !== "function") return;
|
|
890
|
+
const current = {
|
|
891
|
+
trajectoryId,
|
|
892
|
+
learningContainerIds,
|
|
893
|
+
queue: [],
|
|
894
|
+
dropped: 0,
|
|
895
|
+
uninstalls: [],
|
|
896
|
+
timer: void 0
|
|
897
|
+
};
|
|
898
|
+
session = current;
|
|
899
|
+
const recordCurrent = (name, value) => {
|
|
900
|
+
if (session === current) record(name, value);
|
|
901
|
+
};
|
|
902
|
+
const redact = createRedactor();
|
|
903
|
+
current.uninstalls.push(redact.watch());
|
|
904
|
+
const ignoreUrls = [...options.ignoreUrls ?? [], ...sink.url === void 0 ? [] : [sink.url]];
|
|
905
|
+
if (capture.clicks) current.uninstalls.push(installClickCapture({
|
|
906
|
+
emit: recordCurrent,
|
|
907
|
+
getRoute,
|
|
908
|
+
enrich,
|
|
909
|
+
redact
|
|
910
|
+
}));
|
|
911
|
+
if (capture.navigation) current.uninstalls.push(installNavigationCapture({
|
|
912
|
+
emit: recordCurrent,
|
|
913
|
+
routes,
|
|
914
|
+
redact
|
|
915
|
+
}));
|
|
916
|
+
if (capture.network) current.uninstalls.push(installNetworkCapture({
|
|
917
|
+
emit: recordCurrent,
|
|
918
|
+
routes,
|
|
919
|
+
ignoreUrls,
|
|
920
|
+
redact
|
|
921
|
+
}));
|
|
922
|
+
if (capture.inputs) current.uninstalls.push(installInputCapture({
|
|
923
|
+
emit: recordCurrent,
|
|
924
|
+
redact
|
|
925
|
+
}));
|
|
926
|
+
const onPageHide = () => flush({ beacon: true });
|
|
927
|
+
window.addEventListener("pagehide", onPageHide);
|
|
928
|
+
current.uninstalls.push(() => window.removeEventListener("pagehide", onPageHide));
|
|
929
|
+
current.timer = setInterval(() => flush(), FLUSH_INTERVAL_MS);
|
|
930
|
+
record("page", {
|
|
931
|
+
route: getRoute(),
|
|
932
|
+
url: redact.url(location.href),
|
|
933
|
+
title: document.title,
|
|
934
|
+
referrer: redact.url(document.referrer)
|
|
935
|
+
});
|
|
936
|
+
};
|
|
937
|
+
const stop = () => {
|
|
938
|
+
const current = session;
|
|
939
|
+
if (current === null) return;
|
|
940
|
+
clearInterval(current.timer);
|
|
941
|
+
for (const uninstall of current.uninstalls) uninstall();
|
|
942
|
+
flush();
|
|
943
|
+
session = null;
|
|
944
|
+
};
|
|
945
|
+
const emit = (name, value) => {
|
|
946
|
+
if (BUILT_IN.has(name)) {
|
|
947
|
+
console.warn(`[@copilotkit/learning] "${name}" is a built-in event name. Use your own name, such as "deal.approved".`);
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
record(name, value);
|
|
951
|
+
};
|
|
952
|
+
const ɵemit = (name, value) => record(name, value);
|
|
953
|
+
return {
|
|
954
|
+
start,
|
|
955
|
+
stop,
|
|
956
|
+
emit,
|
|
957
|
+
ɵemit,
|
|
958
|
+
get trajectoryId() {
|
|
959
|
+
return session?.trajectoryId ?? null;
|
|
960
|
+
}
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
//#endregion
|
|
965
|
+
//#region src/http-sink.ts
|
|
966
|
+
/**
|
|
967
|
+
* A sink that POSTs each batch as JSON to `url`.
|
|
968
|
+
*
|
|
969
|
+
* Uses `fetch` with `keepalive`. When the page is going away it uses
|
|
970
|
+
* `navigator.sendBeacon`, which cannot send custom headers. There is no retry:
|
|
971
|
+
* a failed send rejects, and the collector counts those events as dropped.
|
|
972
|
+
*
|
|
973
|
+
* @example createCollector({ sink: httpSink("/api/learning-events") })
|
|
974
|
+
*/
|
|
975
|
+
function httpSink(url, init = {}) {
|
|
976
|
+
const send = async (batch, options = {}) => {
|
|
977
|
+
const body = JSON.stringify(batch);
|
|
978
|
+
if (options.beacon === true && typeof navigator.sendBeacon === "function") {
|
|
979
|
+
if (!navigator.sendBeacon(url, new Blob([body], { type: "application/json" }))) throw new Error("sendBeacon refused the batch");
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
const response = await fetch(url, {
|
|
983
|
+
method: "POST",
|
|
984
|
+
keepalive: true,
|
|
985
|
+
headers: {
|
|
986
|
+
"content-type": "application/json",
|
|
987
|
+
...init.headers
|
|
988
|
+
},
|
|
989
|
+
body
|
|
990
|
+
});
|
|
991
|
+
if (!response.ok) throw new Error(`Sink responded with ${response.status}`);
|
|
992
|
+
};
|
|
993
|
+
return Object.assign(send, { url });
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
//#endregion
|
|
997
|
+
//#region src/trajectory-collector.ts
|
|
998
|
+
const MAX_TRAJECTORY_EVENT_BYTES = 16 * 1024;
|
|
999
|
+
const RESERVED_NAMES = new Set(BUILT_IN_EVENT_NAMES);
|
|
1000
|
+
function isJson(value, ancestors = /* @__PURE__ */ new Set()) {
|
|
1001
|
+
if (typeof value === "string") return !value.includes("\0");
|
|
1002
|
+
if (value === null || typeof value === "boolean") return true;
|
|
1003
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
1004
|
+
if (typeof value !== "object" || ancestors.has(value)) return false;
|
|
1005
|
+
if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return false;
|
|
1006
|
+
ancestors.add(value);
|
|
1007
|
+
if (!Array.isArray(value) && Object.keys(value).some((key) => key.includes("\0"))) return false;
|
|
1008
|
+
const values = Array.isArray(value) ? value : Object.values(value);
|
|
1009
|
+
for (const item of values) if (!isJson(item, ancestors)) return false;
|
|
1010
|
+
ancestors.delete(value);
|
|
1011
|
+
return true;
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* Captures outside-chat activity as plain AG-UI events, without a send queue.
|
|
1015
|
+
* Core starts this collector only after authentication and channel join.
|
|
1016
|
+
*/
|
|
1017
|
+
function createTrajectoryCollector(options) {
|
|
1018
|
+
let active = false;
|
|
1019
|
+
let uninstalls = [];
|
|
1020
|
+
const report = (code, message) => {
|
|
1021
|
+
try {
|
|
1022
|
+
options.onError?.({
|
|
1023
|
+
code,
|
|
1024
|
+
message
|
|
1025
|
+
});
|
|
1026
|
+
} catch {}
|
|
1027
|
+
};
|
|
1028
|
+
const getRoute = () => toRoute(location.pathname);
|
|
1029
|
+
const record = (name, value) => {
|
|
1030
|
+
if (!active) return;
|
|
1031
|
+
let payload;
|
|
1032
|
+
try {
|
|
1033
|
+
const event = {
|
|
1034
|
+
type: "CUSTOM",
|
|
1035
|
+
name,
|
|
1036
|
+
timestamp: Date.now(),
|
|
1037
|
+
value
|
|
1038
|
+
};
|
|
1039
|
+
const kept = options.beforeSend === void 0 ? event : options.beforeSend(event);
|
|
1040
|
+
if (kept === null || !active) return;
|
|
1041
|
+
if (kept.type !== "CUSTOM" || typeof kept.name !== "string" || !kept.name.trim() || kept.name.length > 200 || kept.name.includes("\0") || !Number.isFinite(kept.timestamp) || kept.timestamp <= 0 || kept.timestamp >= 0xe677d21fdc00 || !isJson(kept.value)) {
|
|
1042
|
+
report("INVALID_EVENT", "The event must contain a name, timestamp, and JSON value.");
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
const serialized = JSON.stringify({
|
|
1046
|
+
type: kept.type,
|
|
1047
|
+
name: kept.name,
|
|
1048
|
+
timestamp: kept.timestamp,
|
|
1049
|
+
value: kept.value
|
|
1050
|
+
});
|
|
1051
|
+
if (new TextEncoder().encode(serialized).byteLength > MAX_TRAJECTORY_EVENT_BYTES) {
|
|
1052
|
+
report("EVENT_TOO_LARGE", "The serialized event exceeds 16 KiB.");
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
payload = JSON.parse(serialized);
|
|
1056
|
+
} catch {
|
|
1057
|
+
report("INVALID_EVENT", "The event could not be serialized as JSON.");
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
try {
|
|
1061
|
+
options.send(payload);
|
|
1062
|
+
} catch {
|
|
1063
|
+
report("PERSISTENCE_UNKNOWN", "Event delivery failed. The event will not be retried.");
|
|
1064
|
+
}
|
|
1065
|
+
};
|
|
1066
|
+
const capture = (name, value) => {
|
|
1067
|
+
record(name, value);
|
|
1068
|
+
};
|
|
1069
|
+
const stop = () => {
|
|
1070
|
+
active = false;
|
|
1071
|
+
const installed = uninstalls;
|
|
1072
|
+
uninstalls = [];
|
|
1073
|
+
for (const uninstall of installed) uninstall();
|
|
1074
|
+
};
|
|
1075
|
+
return {
|
|
1076
|
+
start() {
|
|
1077
|
+
if (active || typeof window === "undefined") return;
|
|
1078
|
+
active = true;
|
|
1079
|
+
if (typeof window.addEventListener !== "function") return;
|
|
1080
|
+
const redact = createRedactor();
|
|
1081
|
+
try {
|
|
1082
|
+
uninstalls.push(redact.watch());
|
|
1083
|
+
if (options.capture?.clicks !== false) uninstalls.push(installClickCapture({
|
|
1084
|
+
emit: capture,
|
|
1085
|
+
getRoute,
|
|
1086
|
+
redact
|
|
1087
|
+
}));
|
|
1088
|
+
if (options.capture?.navigation !== false) uninstalls.push(installNavigationCapture({
|
|
1089
|
+
emit: capture,
|
|
1090
|
+
redact
|
|
1091
|
+
}));
|
|
1092
|
+
if (options.capture?.inputs !== false) uninstalls.push(installInputCapture({
|
|
1093
|
+
emit: capture,
|
|
1094
|
+
redact
|
|
1095
|
+
}));
|
|
1096
|
+
if (options.capture?.network !== false) uninstalls.push(installNetworkCapture({
|
|
1097
|
+
emit: capture,
|
|
1098
|
+
ignoreUrls: options.ignoreUrls ?? [],
|
|
1099
|
+
redact
|
|
1100
|
+
}));
|
|
1101
|
+
record("page", {
|
|
1102
|
+
route: getRoute(),
|
|
1103
|
+
url: redact.url(location.href),
|
|
1104
|
+
title: document.title,
|
|
1105
|
+
referrer: redact.url(document.referrer)
|
|
1106
|
+
});
|
|
1107
|
+
} catch {
|
|
1108
|
+
stop();
|
|
1109
|
+
report("CAPTURE_FAILED", "Browser capture could not start.");
|
|
1110
|
+
}
|
|
1111
|
+
},
|
|
1112
|
+
stop,
|
|
1113
|
+
emit(name, value) {
|
|
1114
|
+
if (!active) return;
|
|
1115
|
+
if (RESERVED_NAMES.has(name)) {
|
|
1116
|
+
report("INVALID_EVENT", "Use a developer event name, not a reserved capture name.");
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
record(name, value);
|
|
1120
|
+
}
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
//#endregion
|
|
1125
|
+
export { BUILT_IN_EVENT_NAMES, MAX_TRAJECTORY_EVENT_BYTES, createCollector, createTrajectoryCollector, httpSink, toOriginAndRoute, toRoute };
|
|
1126
|
+
//# sourceMappingURL=index.mjs.map
|