@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/src/collector.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { installClickCapture } from "./clicks";
|
|
2
|
+
import { installInputCapture } from "./inputs";
|
|
3
|
+
import { installNavigationCapture } from "./navigation";
|
|
4
|
+
import { installNetworkCapture } from "./network";
|
|
5
|
+
import { createRedactor } from "./redact";
|
|
6
|
+
import { toRoute } from "./routes";
|
|
7
|
+
import { BUILT_IN_EVENT_NAMES } from "./types";
|
|
8
|
+
import type {
|
|
9
|
+
BuiltInEventName,
|
|
10
|
+
Collector,
|
|
11
|
+
CollectorOptions,
|
|
12
|
+
LearningBatch,
|
|
13
|
+
LearningEvent,
|
|
14
|
+
SinkSendOptions,
|
|
15
|
+
StartOptions,
|
|
16
|
+
} from "./types";
|
|
17
|
+
|
|
18
|
+
// ponytail: flushing at 50 also bounds the queue at 50; add a separate cap only if sends become async-buffered.
|
|
19
|
+
const FLUSH_SIZE = 50;
|
|
20
|
+
const FLUSH_INTERVAL_MS = 2000;
|
|
21
|
+
const BUILT_IN = new Set<string>(BUILT_IN_EVENT_NAMES);
|
|
22
|
+
|
|
23
|
+
interface Session {
|
|
24
|
+
trajectoryId: string;
|
|
25
|
+
learningContainerIds: string[] | undefined;
|
|
26
|
+
queue: LearningEvent[];
|
|
27
|
+
dropped: number;
|
|
28
|
+
uninstalls: (() => void)[];
|
|
29
|
+
timer: ReturnType<typeof setInterval> | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Creates a collector. Nothing is installed until `start()`: no listeners,
|
|
34
|
+
* no patches, no timers, no buffer. `stop()` removes all of them and sends
|
|
35
|
+
* queued events once, without retries.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* const collector = createCollector({ sink: httpSink("/api/learning-events") });
|
|
39
|
+
* collector.start({ trajectoryId: crypto.randomUUID() });
|
|
40
|
+
*/
|
|
41
|
+
export function createCollector(options: CollectorOptions) {
|
|
42
|
+
const { sink, routes, beforeSend, enrich } = options;
|
|
43
|
+
const capture = {
|
|
44
|
+
clicks: true,
|
|
45
|
+
navigation: true,
|
|
46
|
+
network: true,
|
|
47
|
+
inputs: true,
|
|
48
|
+
...options.capture,
|
|
49
|
+
};
|
|
50
|
+
let session: Session | null = null;
|
|
51
|
+
let seq = 0;
|
|
52
|
+
|
|
53
|
+
const getRoute = () => toRoute(location.pathname, routes);
|
|
54
|
+
|
|
55
|
+
const flush = (sendOptions?: SinkSendOptions) => {
|
|
56
|
+
const current = session;
|
|
57
|
+
if (current === null) return;
|
|
58
|
+
if (current.queue.length === 0 && current.dropped === 0) return;
|
|
59
|
+
const events = current.queue.splice(0);
|
|
60
|
+
const dropped = current.dropped;
|
|
61
|
+
current.dropped = 0;
|
|
62
|
+
const batch: LearningBatch = {
|
|
63
|
+
trajectoryId: current.trajectoryId,
|
|
64
|
+
learningContainerIds: current.learningContainerIds,
|
|
65
|
+
events,
|
|
66
|
+
dropped,
|
|
67
|
+
};
|
|
68
|
+
const onFailure = () => {
|
|
69
|
+
// No retry: report the loss in the next batch instead.
|
|
70
|
+
if (session === current) current.dropped += events.length + dropped;
|
|
71
|
+
};
|
|
72
|
+
try {
|
|
73
|
+
Promise.resolve(sink(batch, sendOptions)).catch(onFailure);
|
|
74
|
+
} catch {
|
|
75
|
+
onFailure();
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const record = (name: string, value: Record<string, unknown>) => {
|
|
80
|
+
const current = session;
|
|
81
|
+
if (current === null) return;
|
|
82
|
+
try {
|
|
83
|
+
seq += 1;
|
|
84
|
+
const event: LearningEvent = {
|
|
85
|
+
type: "CUSTOM",
|
|
86
|
+
name,
|
|
87
|
+
value: { ...value, seq },
|
|
88
|
+
timestamp: Date.now(),
|
|
89
|
+
};
|
|
90
|
+
const kept = beforeSend === undefined ? event : beforeSend(event);
|
|
91
|
+
if (kept === null) return;
|
|
92
|
+
current.queue.push(kept);
|
|
93
|
+
if (current.queue.length >= FLUSH_SIZE) flush();
|
|
94
|
+
} catch {
|
|
95
|
+
// Capture must never break the host app.
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const start = ({ trajectoryId, learningContainerIds }: StartOptions) => {
|
|
100
|
+
if (session !== null) {
|
|
101
|
+
if (session.trajectoryId !== trajectoryId) {
|
|
102
|
+
console.warn(
|
|
103
|
+
`[@copilotkit/learning] Trajectory "${session.trajectoryId}" is active. Stop it before you start "${trajectoryId}".`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (
|
|
109
|
+
typeof window === "undefined" ||
|
|
110
|
+
typeof window.addEventListener !== "function"
|
|
111
|
+
)
|
|
112
|
+
return;
|
|
113
|
+
const current: Session = {
|
|
114
|
+
trajectoryId,
|
|
115
|
+
learningContainerIds,
|
|
116
|
+
queue: [],
|
|
117
|
+
dropped: 0,
|
|
118
|
+
uninstalls: [],
|
|
119
|
+
timer: undefined,
|
|
120
|
+
};
|
|
121
|
+
session = current;
|
|
122
|
+
const recordCurrent: typeof record = (name, value) => {
|
|
123
|
+
if (session === current) record(name, value);
|
|
124
|
+
};
|
|
125
|
+
// One per session: fields seen and values typed are forgotten on stop().
|
|
126
|
+
const redact = createRedactor();
|
|
127
|
+
current.uninstalls.push(redact.watch());
|
|
128
|
+
const ignoreUrls = [
|
|
129
|
+
...(options.ignoreUrls ?? []),
|
|
130
|
+
...(sink.url === undefined ? [] : [sink.url]),
|
|
131
|
+
];
|
|
132
|
+
if (capture.clicks)
|
|
133
|
+
current.uninstalls.push(
|
|
134
|
+
installClickCapture({ emit: recordCurrent, getRoute, enrich, redact }),
|
|
135
|
+
);
|
|
136
|
+
if (capture.navigation)
|
|
137
|
+
current.uninstalls.push(
|
|
138
|
+
installNavigationCapture({ emit: recordCurrent, routes, redact }),
|
|
139
|
+
);
|
|
140
|
+
if (capture.network)
|
|
141
|
+
current.uninstalls.push(
|
|
142
|
+
installNetworkCapture({
|
|
143
|
+
emit: recordCurrent,
|
|
144
|
+
routes,
|
|
145
|
+
ignoreUrls,
|
|
146
|
+
redact,
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
if (capture.inputs)
|
|
150
|
+
current.uninstalls.push(
|
|
151
|
+
installInputCapture({ emit: recordCurrent, redact }),
|
|
152
|
+
);
|
|
153
|
+
const onPageHide = () => flush({ beacon: true });
|
|
154
|
+
window.addEventListener("pagehide", onPageHide);
|
|
155
|
+
current.uninstalls.push(() =>
|
|
156
|
+
window.removeEventListener("pagehide", onPageHide),
|
|
157
|
+
);
|
|
158
|
+
current.timer = setInterval(() => flush(), FLUSH_INTERVAL_MS);
|
|
159
|
+
record("page", {
|
|
160
|
+
route: getRoute(),
|
|
161
|
+
url: redact.url(location.href),
|
|
162
|
+
title: document.title,
|
|
163
|
+
referrer: redact.url(document.referrer),
|
|
164
|
+
});
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const stop = () => {
|
|
168
|
+
const current = session;
|
|
169
|
+
if (current === null) return;
|
|
170
|
+
clearInterval(current.timer);
|
|
171
|
+
for (const uninstall of current.uninstalls) uninstall();
|
|
172
|
+
flush();
|
|
173
|
+
session = null;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const emit = (name: string, value: Record<string, unknown>) => {
|
|
177
|
+
if (BUILT_IN.has(name)) {
|
|
178
|
+
console.warn(
|
|
179
|
+
`[@copilotkit/learning] "${name}" is a built-in event name. Use your own name, such as "deal.approved".`,
|
|
180
|
+
);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
record(name, value);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const ɵemit = (name: BuiltInEventName, value: Record<string, unknown>) =>
|
|
187
|
+
record(name, value);
|
|
188
|
+
|
|
189
|
+
const collector: Collector = {
|
|
190
|
+
start,
|
|
191
|
+
stop,
|
|
192
|
+
emit,
|
|
193
|
+
ɵemit,
|
|
194
|
+
get trajectoryId() {
|
|
195
|
+
return session?.trajectoryId ?? null;
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
return collector;
|
|
199
|
+
}
|
package/src/http-sink.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { LearningBatch, LearningSink, SinkSendOptions } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A sink that POSTs each batch as JSON to `url`.
|
|
5
|
+
*
|
|
6
|
+
* Uses `fetch` with `keepalive`. When the page is going away it uses
|
|
7
|
+
* `navigator.sendBeacon`, which cannot send custom headers. There is no retry:
|
|
8
|
+
* a failed send rejects, and the collector counts those events as dropped.
|
|
9
|
+
*
|
|
10
|
+
* @example createCollector({ sink: httpSink("/api/learning-events") })
|
|
11
|
+
*/
|
|
12
|
+
export function httpSink(
|
|
13
|
+
url: string,
|
|
14
|
+
init: { headers?: Record<string, string> } = {},
|
|
15
|
+
) {
|
|
16
|
+
const send = async (batch: LearningBatch, options: SinkSendOptions = {}) => {
|
|
17
|
+
const body = JSON.stringify(batch);
|
|
18
|
+
const canBeacon =
|
|
19
|
+
options.beacon === true && typeof navigator.sendBeacon === "function";
|
|
20
|
+
if (canBeacon) {
|
|
21
|
+
const queued = navigator.sendBeacon(
|
|
22
|
+
url,
|
|
23
|
+
new Blob([body], { type: "application/json" }),
|
|
24
|
+
);
|
|
25
|
+
if (!queued) throw new Error("sendBeacon refused the batch");
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const response = await fetch(url, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
keepalive: true,
|
|
31
|
+
headers: { "content-type": "application/json", ...init.headers },
|
|
32
|
+
body,
|
|
33
|
+
});
|
|
34
|
+
if (!response.ok) throw new Error(`Sink responded with ${response.status}`);
|
|
35
|
+
};
|
|
36
|
+
const sink: LearningSink = Object.assign(send, { url });
|
|
37
|
+
return sink;
|
|
38
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export { createCollector } from "./collector";
|
|
2
|
+
export { httpSink } from "./http-sink";
|
|
3
|
+
export { toOriginAndRoute, toRoute } from "./routes";
|
|
4
|
+
export { BUILT_IN_EVENT_NAMES } from "./types";
|
|
5
|
+
export {
|
|
6
|
+
createTrajectoryCollector,
|
|
7
|
+
MAX_TRAJECTORY_EVENT_BYTES,
|
|
8
|
+
} from "./trajectory-collector";
|
|
9
|
+
export type {
|
|
10
|
+
JsonValue,
|
|
11
|
+
TrajectoryEvent,
|
|
12
|
+
StartResult,
|
|
13
|
+
ConnectionGrant,
|
|
14
|
+
TrajectoryError,
|
|
15
|
+
TrajectoryCaptureOptions,
|
|
16
|
+
TrajectoryCollector,
|
|
17
|
+
} from "./trajectory-types";
|
|
18
|
+
export type {
|
|
19
|
+
BuiltInEventName,
|
|
20
|
+
CaptureOptions,
|
|
21
|
+
ClickTarget,
|
|
22
|
+
Collector,
|
|
23
|
+
CollectorOptions,
|
|
24
|
+
EnrichFn,
|
|
25
|
+
LearningBatch,
|
|
26
|
+
LearningEvent,
|
|
27
|
+
LearningSink,
|
|
28
|
+
SinkSendOptions,
|
|
29
|
+
StartOptions,
|
|
30
|
+
} from "./types";
|
package/src/inputs.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describeTarget, readControlValue } from "./clicks";
|
|
2
|
+
import { createRedactor } from "./redact";
|
|
3
|
+
import type { Redactor } from "./redact";
|
|
4
|
+
import type { Emit } from "./types";
|
|
5
|
+
|
|
6
|
+
/** Records user edits without changing the control or interfering with its listeners. */
|
|
7
|
+
export function installInputCapture(params: {
|
|
8
|
+
emit: Emit;
|
|
9
|
+
isTrusted?: (event: Event) => boolean;
|
|
10
|
+
redact?: Redactor;
|
|
11
|
+
}) {
|
|
12
|
+
const {
|
|
13
|
+
emit,
|
|
14
|
+
isTrusted = (event: Event) => event.isTrusted,
|
|
15
|
+
redact = createRedactor(),
|
|
16
|
+
} = params;
|
|
17
|
+
const onInput = (event: Event) => {
|
|
18
|
+
try {
|
|
19
|
+
if (!isTrusted(event)) return;
|
|
20
|
+
const [element] = event.composedPath();
|
|
21
|
+
if (!(element instanceof Element)) return;
|
|
22
|
+
const described = describeTarget(element, redact);
|
|
23
|
+
if (described === null) return;
|
|
24
|
+
const value = readControlValue(element, redact);
|
|
25
|
+
if (!("value" in value)) return;
|
|
26
|
+
emit("input", {
|
|
27
|
+
eventType: event.type,
|
|
28
|
+
target: { ...described.target, ...value },
|
|
29
|
+
url: redact.url(location.href),
|
|
30
|
+
route: location.pathname,
|
|
31
|
+
});
|
|
32
|
+
} catch {
|
|
33
|
+
// Capture must never break an edit in the host app.
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
window.addEventListener("input", onInput, { capture: true, passive: true });
|
|
37
|
+
window.addEventListener("change", onInput, { capture: true, passive: true });
|
|
38
|
+
return () => {
|
|
39
|
+
window.removeEventListener("input", onInput, { capture: true });
|
|
40
|
+
window.removeEventListener("change", onInput, { capture: true });
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createRedactor } from "./redact";
|
|
2
|
+
import type { Redactor } from "./redact";
|
|
3
|
+
import type { Emit } from "./types";
|
|
4
|
+
|
|
5
|
+
type NavigationType = "push" | "replace" | "traverse" | "reload";
|
|
6
|
+
type HistoryMethod = "pushState" | "replaceState";
|
|
7
|
+
|
|
8
|
+
/** The part of the Navigation API this module uses (not in TypeScript's DOM lib yet). */
|
|
9
|
+
interface NavigationTarget {
|
|
10
|
+
addEventListener(
|
|
11
|
+
type: "currententrychange",
|
|
12
|
+
listener: (event: Event) => void,
|
|
13
|
+
): void;
|
|
14
|
+
removeEventListener(
|
|
15
|
+
type: "currententrychange",
|
|
16
|
+
listener: (event: Event) => void,
|
|
17
|
+
): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isNavigationTarget(value: unknown): value is NavigationTarget {
|
|
21
|
+
return (
|
|
22
|
+
typeof value === "object" &&
|
|
23
|
+
value !== null &&
|
|
24
|
+
"addEventListener" in value &&
|
|
25
|
+
"removeEventListener" in value
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getNavigation() {
|
|
30
|
+
const navigation: unknown = Reflect.get(window, "navigation");
|
|
31
|
+
return isNavigationTarget(navigation) ? navigation : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function toNavigationType(value: unknown): NavigationType {
|
|
35
|
+
switch (value) {
|
|
36
|
+
case "replace":
|
|
37
|
+
case "traverse":
|
|
38
|
+
case "reload":
|
|
39
|
+
return value;
|
|
40
|
+
default:
|
|
41
|
+
return "push";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Emits `navigation` when the full URL changes in a single-page app.
|
|
47
|
+
* Uses the Navigation API when it exists. Otherwise patches `pushState` and
|
|
48
|
+
* `replaceState` on the prototype and on the instance, because some routers
|
|
49
|
+
* (Next.js) wrap the instance method.
|
|
50
|
+
*/
|
|
51
|
+
export function installNavigationCapture(params: {
|
|
52
|
+
emit: Emit;
|
|
53
|
+
routes?: string[];
|
|
54
|
+
redact?: Redactor;
|
|
55
|
+
}) {
|
|
56
|
+
const { emit, redact = createRedactor() } = params;
|
|
57
|
+
let current = location.href;
|
|
58
|
+
|
|
59
|
+
const check = (navigationType: NavigationType) => {
|
|
60
|
+
try {
|
|
61
|
+
const next = location.href;
|
|
62
|
+
if (next === current) return;
|
|
63
|
+
emit("navigation", {
|
|
64
|
+
from: redact.url(current),
|
|
65
|
+
to: redact.url(next),
|
|
66
|
+
navigationType,
|
|
67
|
+
});
|
|
68
|
+
current = next;
|
|
69
|
+
} catch {
|
|
70
|
+
// Capture must never break navigation.
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const navigation = getNavigation();
|
|
75
|
+
if (navigation !== null) {
|
|
76
|
+
const onEntryChange = (event: Event) =>
|
|
77
|
+
check(toNavigationType(Reflect.get(event, "navigationType")));
|
|
78
|
+
navigation.addEventListener("currententrychange", onEntryChange);
|
|
79
|
+
return () =>
|
|
80
|
+
navigation.removeEventListener("currententrychange", onEntryChange);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return installHistoryFallback(check);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function installHistoryFallback(
|
|
87
|
+
check: (navigationType: NavigationType) => void,
|
|
88
|
+
) {
|
|
89
|
+
let active = true;
|
|
90
|
+
const restores: (() => void)[] = [];
|
|
91
|
+
|
|
92
|
+
const patch = (target: History, method: HistoryMethod) => {
|
|
93
|
+
const original = target[method];
|
|
94
|
+
const wrapper: History[HistoryMethod] = function (this: History, ...args) {
|
|
95
|
+
const result = original.apply(this, args);
|
|
96
|
+
if (active) check(method === "pushState" ? "push" : "replace");
|
|
97
|
+
return result;
|
|
98
|
+
};
|
|
99
|
+
target[method] = wrapper;
|
|
100
|
+
restores.push(() => {
|
|
101
|
+
// Restore only if no one wrapped it after us; otherwise our wrapper stays as a pass-through.
|
|
102
|
+
if (target[method] === wrapper) target[method] = original;
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const methods: HistoryMethod[] = ["pushState", "replaceState"];
|
|
107
|
+
for (const method of methods) {
|
|
108
|
+
patch(History.prototype, method);
|
|
109
|
+
if (Object.prototype.hasOwnProperty.call(history, method))
|
|
110
|
+
patch(history, method);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const onPopState = () => check("traverse");
|
|
114
|
+
window.addEventListener("popstate", onPopState);
|
|
115
|
+
window.addEventListener("hashchange", onPopState);
|
|
116
|
+
|
|
117
|
+
return () => {
|
|
118
|
+
active = false;
|
|
119
|
+
window.removeEventListener("popstate", onPopState);
|
|
120
|
+
window.removeEventListener("hashchange", onPopState);
|
|
121
|
+
for (const restore of restores) restore();
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { TEXT_LIMIT } from "./redact";
|
|
2
|
+
import type { Redactor } from "./redact";
|
|
3
|
+
|
|
4
|
+
/** A bounded observation of a body; never the stream returned to the app. */
|
|
5
|
+
export interface BodySnapshot {
|
|
6
|
+
status:
|
|
7
|
+
| "complete"
|
|
8
|
+
| "empty"
|
|
9
|
+
| "truncated"
|
|
10
|
+
| "unavailable"
|
|
11
|
+
| "interrupted"
|
|
12
|
+
| "timeout";
|
|
13
|
+
text?: string;
|
|
14
|
+
encoding?: "utf-8" | "base64";
|
|
15
|
+
truncated?: true;
|
|
16
|
+
reason?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const BODY_BYTES = 4096;
|
|
20
|
+
const READ_TIMEOUT_MS = 1000;
|
|
21
|
+
const encoder = new TextEncoder();
|
|
22
|
+
const redactionFailed = (): BodySnapshot => ({
|
|
23
|
+
status: "unavailable",
|
|
24
|
+
reason: "redaction-failed",
|
|
25
|
+
});
|
|
26
|
+
type RedactText = (text: string) => string | undefined;
|
|
27
|
+
|
|
28
|
+
/** Redacts up to TEXT_LIMIT bytes of text first, then cuts the result to BODY_BYTES. */
|
|
29
|
+
function snapshot(
|
|
30
|
+
bytes: Uint8Array,
|
|
31
|
+
status: BodySnapshot["status"],
|
|
32
|
+
contentType: string,
|
|
33
|
+
redact: RedactText,
|
|
34
|
+
reason?: string,
|
|
35
|
+
): BodySnapshot {
|
|
36
|
+
let text: string | undefined;
|
|
37
|
+
if (!isBinary(contentType))
|
|
38
|
+
try {
|
|
39
|
+
// Declared text decodes lossily so it is always redacted; untyped bytes
|
|
40
|
+
// that are not UTF-8 are treated as binary.
|
|
41
|
+
text = new TextDecoder("utf-8", { fatal: !contentType }).decode(bytes, {
|
|
42
|
+
stream: status !== "complete",
|
|
43
|
+
});
|
|
44
|
+
} catch {
|
|
45
|
+
/* Not UTF-8: keep the bytes as base64. */
|
|
46
|
+
}
|
|
47
|
+
if (text !== undefined) {
|
|
48
|
+
text = redact(text);
|
|
49
|
+
if (text === undefined) return redactionFailed();
|
|
50
|
+
// PostgreSQL jsonb rejects NUL in strings; base64 retains the redacted bytes.
|
|
51
|
+
if (text.includes("\0")) {
|
|
52
|
+
bytes = encoder.encode(text);
|
|
53
|
+
text = undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const binary = text === undefined;
|
|
57
|
+
// Binary bodies are not inspected; only their first bytes are kept.
|
|
58
|
+
text ??= btoa(String.fromCharCode(...bytes.subarray(0, BODY_BYTES)));
|
|
59
|
+
const result: BodySnapshot = {
|
|
60
|
+
status: status === "complete" && bytes.length === 0 ? "empty" : status,
|
|
61
|
+
text,
|
|
62
|
+
encoding: binary ? "base64" : "utf-8",
|
|
63
|
+
...(reason ? { reason } : {}),
|
|
64
|
+
};
|
|
65
|
+
if (encoder.encode(JSON.stringify(result)).length <= BODY_BYTES)
|
|
66
|
+
return result;
|
|
67
|
+
result.status = status === "complete" ? "truncated" : status;
|
|
68
|
+
result.truncated = true;
|
|
69
|
+
// Bound the serialized field too: quotes/control characters expand in JSON.
|
|
70
|
+
let low = 0;
|
|
71
|
+
let high = text.length;
|
|
72
|
+
while (low < high) {
|
|
73
|
+
const middle = Math.ceil((low + high) / 2);
|
|
74
|
+
result.text = text.slice(0, middle);
|
|
75
|
+
if (encoder.encode(JSON.stringify(result)).length <= BODY_BYTES)
|
|
76
|
+
low = middle;
|
|
77
|
+
else high = middle - 1;
|
|
78
|
+
}
|
|
79
|
+
if (binary)
|
|
80
|
+
low -= low % 4; // Keep complete base64 groups.
|
|
81
|
+
else if (low > 0 && /[\uD800-\uDBFF]/.test(text[low - 1]!)) low--;
|
|
82
|
+
result.text = text.slice(0, low);
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function snapshotText(
|
|
87
|
+
value: string,
|
|
88
|
+
redact: RedactText,
|
|
89
|
+
status: BodySnapshot["status"] = "complete",
|
|
90
|
+
reason?: string,
|
|
91
|
+
): BodySnapshot {
|
|
92
|
+
return snapshot(
|
|
93
|
+
encoder.encode(value.slice(0, TEXT_LIMIT)),
|
|
94
|
+
value.length > TEXT_LIMIT && status === "complete" ? "truncated" : status,
|
|
95
|
+
"text/plain",
|
|
96
|
+
redact,
|
|
97
|
+
reason,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isBinary(contentType: string) {
|
|
102
|
+
return (
|
|
103
|
+
contentType !== "" &&
|
|
104
|
+
!/(^text\/|json|xml|javascript|graphql|x-www-form-urlencoded|multipart\/form-data)/i.test(
|
|
105
|
+
contentType,
|
|
106
|
+
)
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Cancel only cloned/synthetic bodies. Cancelling a tee may wait for the app's branch. */
|
|
111
|
+
export function createBodyCapture(redact: Redactor) {
|
|
112
|
+
const cancellations = new Set<() => void>();
|
|
113
|
+
let active = true;
|
|
114
|
+
const read = (
|
|
115
|
+
stream: ReadableStream<Uint8Array> | null,
|
|
116
|
+
contentType = "",
|
|
117
|
+
): Promise<BodySnapshot> => {
|
|
118
|
+
if (!stream) return Promise.resolve({ status: "empty" });
|
|
119
|
+
if (!active)
|
|
120
|
+
return Promise.resolve({
|
|
121
|
+
status: "unavailable",
|
|
122
|
+
reason: "capture-stopped",
|
|
123
|
+
});
|
|
124
|
+
let reader: ReadableStreamDefaultReader<Uint8Array>;
|
|
125
|
+
try {
|
|
126
|
+
reader = stream.getReader();
|
|
127
|
+
} catch {
|
|
128
|
+
return Promise.resolve({ status: "unavailable", reason: "body-locked" });
|
|
129
|
+
}
|
|
130
|
+
return new Promise((resolve) => {
|
|
131
|
+
const bytes = new Uint8Array(TEXT_LIMIT);
|
|
132
|
+
let length = 0;
|
|
133
|
+
let finished = false;
|
|
134
|
+
const finish = (status: BodySnapshot["status"], reason?: string) => {
|
|
135
|
+
if (finished) return;
|
|
136
|
+
finished = true;
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
cancellations.delete(cancel);
|
|
139
|
+
resolve(
|
|
140
|
+
snapshot(
|
|
141
|
+
bytes.subarray(0, length),
|
|
142
|
+
status,
|
|
143
|
+
contentType,
|
|
144
|
+
redact.body,
|
|
145
|
+
reason,
|
|
146
|
+
),
|
|
147
|
+
);
|
|
148
|
+
// Do not await cancellation: a cloned fetch stream can share a live SSE source.
|
|
149
|
+
void reader.cancel().catch(() => {});
|
|
150
|
+
};
|
|
151
|
+
const cancel = () => finish("interrupted", "capture-stopped");
|
|
152
|
+
const timer = setTimeout(
|
|
153
|
+
() => finish("timeout", "body-read-timeout"),
|
|
154
|
+
READ_TIMEOUT_MS,
|
|
155
|
+
);
|
|
156
|
+
cancellations.add(cancel);
|
|
157
|
+
void (async () => {
|
|
158
|
+
try {
|
|
159
|
+
for (;;) {
|
|
160
|
+
const chunk = await reader.read();
|
|
161
|
+
if (finished) break;
|
|
162
|
+
if (chunk.done) {
|
|
163
|
+
finish("complete");
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
const kept = chunk.value.subarray(0, TEXT_LIMIT - length);
|
|
167
|
+
bytes.set(kept, length);
|
|
168
|
+
length += kept.length;
|
|
169
|
+
if (length === TEXT_LIMIT) {
|
|
170
|
+
finish("truncated");
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} catch {
|
|
175
|
+
finish("interrupted", "body-read-failed");
|
|
176
|
+
} finally {
|
|
177
|
+
try {
|
|
178
|
+
reader.releaseLock();
|
|
179
|
+
} catch {
|
|
180
|
+
/* A cancelled read may still be settling. */
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
})();
|
|
184
|
+
});
|
|
185
|
+
};
|
|
186
|
+
const body = (
|
|
187
|
+
value: BodyInit | null | undefined,
|
|
188
|
+
contentType = "",
|
|
189
|
+
): Promise<BodySnapshot> => {
|
|
190
|
+
try {
|
|
191
|
+
if (value == null) return Promise.resolve({ status: "empty" });
|
|
192
|
+
if (value instanceof URLSearchParams) value = value.toString();
|
|
193
|
+
if (typeof value === "string")
|
|
194
|
+
return Promise.resolve(snapshotText(value, redact.body));
|
|
195
|
+
if (
|
|
196
|
+
typeof ReadableStream !== "undefined" &&
|
|
197
|
+
value instanceof ReadableStream
|
|
198
|
+
) {
|
|
199
|
+
// A caller-owned init.body cannot be teed without locking/replacing it.
|
|
200
|
+
return Promise.resolve({
|
|
201
|
+
status: "unavailable",
|
|
202
|
+
reason: "caller-owned-stream",
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
if (value instanceof FormData) {
|
|
206
|
+
const form = new FormData();
|
|
207
|
+
value.forEach((field, key) =>
|
|
208
|
+
form.append(
|
|
209
|
+
key,
|
|
210
|
+
typeof field === "string" ? redact.field(key, field) : field,
|
|
211
|
+
),
|
|
212
|
+
);
|
|
213
|
+
value = form;
|
|
214
|
+
}
|
|
215
|
+
const response = new Response(value);
|
|
216
|
+
return read(
|
|
217
|
+
response.body,
|
|
218
|
+
contentType || response.headers.get("content-type") || "",
|
|
219
|
+
);
|
|
220
|
+
} catch {
|
|
221
|
+
return Promise.resolve({
|
|
222
|
+
status: "unavailable",
|
|
223
|
+
reason: "body-not-readable",
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
return {
|
|
228
|
+
read,
|
|
229
|
+
body,
|
|
230
|
+
text: (value: string, status?: BodySnapshot["status"], reason?: string) =>
|
|
231
|
+
snapshotText(value, redact.body, status, reason),
|
|
232
|
+
stop() {
|
|
233
|
+
active = false;
|
|
234
|
+
for (const cancel of cancellations) cancel();
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|