@harness-fe/runtime 3.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/LICENSE +21 -0
- package/README.md +48 -0
- package/dist/buffer.d.ts +13 -0
- package/dist/buffer.js +26 -0
- package/dist/capture.d.ts +47 -0
- package/dist/capture.js +112 -0
- package/dist/client.d.ts +82 -0
- package/dist/client.js +364 -0
- package/dist/commands.d.ts +10 -0
- package/dist/commands.js +304 -0
- package/dist/dashboardUrl.d.ts +18 -0
- package/dist/dashboardUrl.js +20 -0
- package/dist/fetchPatch.d.ts +39 -0
- package/dist/fetchPatch.js +311 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +23 -0
- package/dist/outbox.d.ts +37 -0
- package/dist/outbox.js +80 -0
- package/dist/overlay.d.ts +68 -0
- package/dist/overlay.js +1946 -0
- package/dist/parent-inherit.d.ts +25 -0
- package/dist/parent-inherit.js +43 -0
- package/dist/recording.d.ts +27 -0
- package/dist/recording.js +86 -0
- package/dist/rrweb-types.d.ts +13 -0
- package/dist/rrweb-types.js +20 -0
- package/dist/selectors.d.ts +14 -0
- package/dist/selectors.js +91 -0
- package/dist/snapshot.d.ts +12 -0
- package/dist/snapshot.js +111 -0
- package/dist/visitor.d.ts +28 -0
- package/dist/visitor.js +107 -0
- package/dist/xhrPatch.d.ts +26 -0
- package/dist/xhrPatch.js +269 -0
- package/package.json +50 -0
- package/src/buffer.test.ts +26 -0
- package/src/buffer.ts +29 -0
- package/src/capture.ts +126 -0
- package/src/client.test.ts +89 -0
- package/src/client.ts +423 -0
- package/src/commands.test.ts +128 -0
- package/src/commands.ts +335 -0
- package/src/dashboardUrl.test.ts +59 -0
- package/src/dashboardUrl.ts +36 -0
- package/src/fetchPatch.test.ts +203 -0
- package/src/fetchPatch.ts +371 -0
- package/src/index.ts +32 -0
- package/src/outbox.test.ts +115 -0
- package/src/outbox.ts +84 -0
- package/src/overlay.test.ts +319 -0
- package/src/overlay.ts +2070 -0
- package/src/parent-inherit.ts +54 -0
- package/src/recording.ts +88 -0
- package/src/rrweb-types.test.ts +40 -0
- package/src/rrweb-types.ts +24 -0
- package/src/selectors.test.ts +50 -0
- package/src/selectors.ts +103 -0
- package/src/snapshot.ts +112 -0
- package/src/visitor.ts +116 -0
- package/src/xhrPatch.test.ts +191 -0
- package/src/xhrPatch.ts +314 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function deriveDashboardUrl(input) {
|
|
2
|
+
if (!input.mcpUrl)
|
|
3
|
+
return undefined;
|
|
4
|
+
let url;
|
|
5
|
+
try {
|
|
6
|
+
url = new URL(input.mcpUrl);
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
const httpScheme = url.protocol === 'wss:' ? 'https:' : 'http:';
|
|
12
|
+
const path = input.sessionId
|
|
13
|
+
? `/dashboard/sessions/${encodeURIComponent(input.sessionId)}`
|
|
14
|
+
: '/dashboard/';
|
|
15
|
+
const token = url.searchParams.get('token');
|
|
16
|
+
const search = token ? `?token=${encodeURIComponent(token)}` : '';
|
|
17
|
+
// Build manually so we don't leak any extra query/hash from the WS URL
|
|
18
|
+
// (rare, but be defensive — the agent only ever sees what we hand it).
|
|
19
|
+
return `${httpScheme}//${url.host}${path}${search}`;
|
|
20
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fetch monkey-patch — captures URL/method/headers/body for request and
|
|
3
|
+
* response, including streaming SSE responses, without changing
|
|
4
|
+
* business-observable fetch behavior.
|
|
5
|
+
*
|
|
6
|
+
* Safety contract (do not weaken without updating the spec):
|
|
7
|
+
* 1. Identity-preserving: replacement is a named `fetch`, with
|
|
8
|
+
* defineProperty'd name/length/toString so library fingerprint checks
|
|
9
|
+
* still pass. Response / Request instances are NOT wrapped.
|
|
10
|
+
* 2. Error-isolated: capture failures are swallowed via `safeEmit`; they
|
|
11
|
+
* NEVER propagate to business code.
|
|
12
|
+
* 3. No timing or value change: the original Promise is returned to the
|
|
13
|
+
* caller unchanged. body capture reads `response.clone()` on a side
|
|
14
|
+
* branch — the business path retains an untouched stream.
|
|
15
|
+
* 4. Self-traffic guard: requests carrying `init.__hfeInternal === true`
|
|
16
|
+
* short-circuit to the original fetch. A URL denylist also skips HMR /
|
|
17
|
+
* dev-server traffic to prevent capture feedback loops.
|
|
18
|
+
* 5. Bounded memory: bodies are capped at BODY_CAP per request. SSE
|
|
19
|
+
* streams stop accumulating and `cancel()` the cloned reader once
|
|
20
|
+
* the cap is hit.
|
|
21
|
+
*
|
|
22
|
+
* The patch is idempotent (re-install is a no-op) and returns a dispose
|
|
23
|
+
* function that restores the original `window.fetch`.
|
|
24
|
+
*/
|
|
25
|
+
import type { NetworkEntry } from '@harness-fe/protocol';
|
|
26
|
+
export interface FetchPatchOptions {
|
|
27
|
+
/** Called once for each emitted record (request and response are separate calls). */
|
|
28
|
+
onEntry: (entry: NetworkEntry) => void;
|
|
29
|
+
/** Per-body byte cap. Default 256 KB. */
|
|
30
|
+
bodyCap?: number;
|
|
31
|
+
/** URL patterns to skip capture entirely. */
|
|
32
|
+
denylist?: RegExp[];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Install the fetch patch. Returns a dispose function that restores the
|
|
36
|
+
* original window.fetch. Safe to call multiple times (subsequent calls
|
|
37
|
+
* are no-ops while a patch is active).
|
|
38
|
+
*/
|
|
39
|
+
export declare function installFetchPatch(opts: FetchPatchOptions): () => void;
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fetch monkey-patch — captures URL/method/headers/body for request and
|
|
3
|
+
* response, including streaming SSE responses, without changing
|
|
4
|
+
* business-observable fetch behavior.
|
|
5
|
+
*
|
|
6
|
+
* Safety contract (do not weaken without updating the spec):
|
|
7
|
+
* 1. Identity-preserving: replacement is a named `fetch`, with
|
|
8
|
+
* defineProperty'd name/length/toString so library fingerprint checks
|
|
9
|
+
* still pass. Response / Request instances are NOT wrapped.
|
|
10
|
+
* 2. Error-isolated: capture failures are swallowed via `safeEmit`; they
|
|
11
|
+
* NEVER propagate to business code.
|
|
12
|
+
* 3. No timing or value change: the original Promise is returned to the
|
|
13
|
+
* caller unchanged. body capture reads `response.clone()` on a side
|
|
14
|
+
* branch — the business path retains an untouched stream.
|
|
15
|
+
* 4. Self-traffic guard: requests carrying `init.__hfeInternal === true`
|
|
16
|
+
* short-circuit to the original fetch. A URL denylist also skips HMR /
|
|
17
|
+
* dev-server traffic to prevent capture feedback loops.
|
|
18
|
+
* 5. Bounded memory: bodies are capped at BODY_CAP per request. SSE
|
|
19
|
+
* streams stop accumulating and `cancel()` the cloned reader once
|
|
20
|
+
* the cap is hit.
|
|
21
|
+
*
|
|
22
|
+
* The patch is idempotent (re-install is a no-op) and returns a dispose
|
|
23
|
+
* function that restores the original `window.fetch`.
|
|
24
|
+
*/
|
|
25
|
+
const DEFAULT_BODY_CAP = 256 * 1024;
|
|
26
|
+
const INTERNAL_FLAG = '__hfeInternal';
|
|
27
|
+
const PATCHED_FLAG = '__hfePatched';
|
|
28
|
+
const DEFAULT_DENYLIST = [/\/__hfe\//, /sockjs-node/, /\.hot-update\./];
|
|
29
|
+
const SENSITIVE_HEADER = /^(authorization|cookie|x-api-key|x-auth-.+)$/i;
|
|
30
|
+
/**
|
|
31
|
+
* Install the fetch patch. Returns a dispose function that restores the
|
|
32
|
+
* original window.fetch. Safe to call multiple times (subsequent calls
|
|
33
|
+
* are no-ops while a patch is active).
|
|
34
|
+
*/
|
|
35
|
+
export function installFetchPatch(opts) {
|
|
36
|
+
if (typeof window === 'undefined' || typeof window.fetch !== 'function') {
|
|
37
|
+
return () => { };
|
|
38
|
+
}
|
|
39
|
+
const original = window.fetch;
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
41
|
+
if (original[PATCHED_FLAG])
|
|
42
|
+
return () => { };
|
|
43
|
+
const bodyCap = opts.bodyCap ?? DEFAULT_BODY_CAP;
|
|
44
|
+
const denylist = opts.denylist ?? DEFAULT_DENYLIST;
|
|
45
|
+
const emit = (entry) => safeEmit(opts.onEntry, entry);
|
|
46
|
+
// Named function so .name === 'fetch'.
|
|
47
|
+
const patched = function fetch(input, init) {
|
|
48
|
+
// Self-traffic short-circuit — internal requests bypass capture.
|
|
49
|
+
if (init && init[INTERNAL_FLAG]) {
|
|
50
|
+
return original.call(this, input, init);
|
|
51
|
+
}
|
|
52
|
+
const meta = extractRequestMeta(input, init);
|
|
53
|
+
if (denylist.some((re) => re.test(meta.url))) {
|
|
54
|
+
return original.call(this, input, init);
|
|
55
|
+
}
|
|
56
|
+
const id = generateId();
|
|
57
|
+
const startedAt = performance.now();
|
|
58
|
+
const startedTs = Date.now();
|
|
59
|
+
// Emit request record eagerly (req body is read async — second emit
|
|
60
|
+
// updates the record once body is serialized; consumers join by id).
|
|
61
|
+
const reqRecord = {
|
|
62
|
+
ts: startedTs,
|
|
63
|
+
id,
|
|
64
|
+
phase: 'req',
|
|
65
|
+
method: meta.method,
|
|
66
|
+
url: meta.url,
|
|
67
|
+
requestHeaders: meta.headers,
|
|
68
|
+
};
|
|
69
|
+
emit(reqRecord);
|
|
70
|
+
cloneRequestBody(input, init, bodyCap).then(({ body, truncated }) => {
|
|
71
|
+
if (body === undefined && !truncated)
|
|
72
|
+
return;
|
|
73
|
+
emit({
|
|
74
|
+
...reqRecord,
|
|
75
|
+
requestBody: body,
|
|
76
|
+
requestBodyTruncated: truncated || undefined,
|
|
77
|
+
});
|
|
78
|
+
}, () => {
|
|
79
|
+
/* serialization error — ignore, req already emitted */
|
|
80
|
+
});
|
|
81
|
+
const promise = original.call(this, input, init);
|
|
82
|
+
promise.then((response) => {
|
|
83
|
+
let cloned;
|
|
84
|
+
try {
|
|
85
|
+
cloned = response.clone();
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
emit({
|
|
89
|
+
ts: Date.now(),
|
|
90
|
+
id,
|
|
91
|
+
phase: 'res',
|
|
92
|
+
method: meta.method,
|
|
93
|
+
url: meta.url,
|
|
94
|
+
status: response.status,
|
|
95
|
+
responseHeaders: headersToObject(response.headers),
|
|
96
|
+
durationMs: performance.now() - startedAt,
|
|
97
|
+
});
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const finalize = (body, truncated) => {
|
|
101
|
+
emit({
|
|
102
|
+
ts: Date.now(),
|
|
103
|
+
id,
|
|
104
|
+
phase: 'res',
|
|
105
|
+
method: meta.method,
|
|
106
|
+
url: meta.url,
|
|
107
|
+
status: response.status,
|
|
108
|
+
responseHeaders: headersToObject(response.headers),
|
|
109
|
+
responseBody: body,
|
|
110
|
+
responseBodyTruncated: truncated || undefined,
|
|
111
|
+
durationMs: performance.now() - startedAt,
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
const ct = response.headers.get('content-type') ?? '';
|
|
115
|
+
if (isSSE(ct)) {
|
|
116
|
+
pumpSSE(cloned, bodyCap, finalize);
|
|
117
|
+
}
|
|
118
|
+
else if (isTextLike(ct)) {
|
|
119
|
+
readTextWithCap(cloned, bodyCap).then(({ body, truncated }) => finalize(maybeParseJson(body, ct), truncated), () => finalize(undefined, false));
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
// Binary or unknown: only record size, do not pull bytes.
|
|
123
|
+
cloned.arrayBuffer().then((buf) => finalize(`[binary ${buf.byteLength}B]`, false), () => finalize(undefined, false));
|
|
124
|
+
}
|
|
125
|
+
}, (err) => {
|
|
126
|
+
emit({
|
|
127
|
+
ts: Date.now(),
|
|
128
|
+
id,
|
|
129
|
+
phase: 'res',
|
|
130
|
+
method: meta.method,
|
|
131
|
+
url: meta.url,
|
|
132
|
+
durationMs: performance.now() - startedAt,
|
|
133
|
+
error: err instanceof Error ? err.message : String(err),
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
return promise;
|
|
137
|
+
};
|
|
138
|
+
// Preserve fingerprint — library detection commonly inspects these.
|
|
139
|
+
try {
|
|
140
|
+
Object.defineProperty(patched, 'name', { value: 'fetch' });
|
|
141
|
+
Object.defineProperty(patched, 'length', { value: original.length });
|
|
142
|
+
Object.defineProperty(patched, 'toString', {
|
|
143
|
+
value: () => original.toString(),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
/* sealed property — safe to skip */
|
|
148
|
+
}
|
|
149
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
150
|
+
patched[PATCHED_FLAG] = true;
|
|
151
|
+
window.fetch = patched;
|
|
152
|
+
return () => {
|
|
153
|
+
if (window.fetch === patched) {
|
|
154
|
+
window.fetch = original;
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
// ─── helpers ────────────────────────────────────────────────────────────────
|
|
159
|
+
function generateId() {
|
|
160
|
+
try {
|
|
161
|
+
return crypto.randomUUID();
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function extractRequestMeta(input, init) {
|
|
168
|
+
let url;
|
|
169
|
+
let method = 'GET';
|
|
170
|
+
let headers;
|
|
171
|
+
if (typeof input === 'string') {
|
|
172
|
+
url = input;
|
|
173
|
+
}
|
|
174
|
+
else if (input instanceof URL) {
|
|
175
|
+
url = input.toString();
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
url = input.url;
|
|
179
|
+
method = input.method;
|
|
180
|
+
headers = headersToObject(input.headers);
|
|
181
|
+
}
|
|
182
|
+
if (init?.method)
|
|
183
|
+
method = init.method;
|
|
184
|
+
if (init?.headers) {
|
|
185
|
+
headers = { ...(headers ?? {}), ...(headersToObject(init.headers) ?? {}) };
|
|
186
|
+
}
|
|
187
|
+
return { url, method, headers };
|
|
188
|
+
}
|
|
189
|
+
function headersToObject(h) {
|
|
190
|
+
if (!h)
|
|
191
|
+
return undefined;
|
|
192
|
+
const out = {};
|
|
193
|
+
if (h instanceof Headers) {
|
|
194
|
+
h.forEach((v, k) => {
|
|
195
|
+
out[k] = v;
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
else if (Array.isArray(h)) {
|
|
199
|
+
for (const [k, v] of h)
|
|
200
|
+
out[k] = v;
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
Object.assign(out, h);
|
|
204
|
+
}
|
|
205
|
+
return redactHeaders(out);
|
|
206
|
+
}
|
|
207
|
+
function redactHeaders(h) {
|
|
208
|
+
const out = {};
|
|
209
|
+
for (const [k, v] of Object.entries(h)) {
|
|
210
|
+
out[k] = SENSITIVE_HEADER.test(k) ? `[redacted ${String(v).length}]` : v;
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
async function cloneRequestBody(input, init, cap) {
|
|
215
|
+
if (init?.body !== undefined && init.body !== null) {
|
|
216
|
+
return serializeBodyInit(init.body, cap);
|
|
217
|
+
}
|
|
218
|
+
if (input instanceof Request && input.body) {
|
|
219
|
+
try {
|
|
220
|
+
const text = await input.clone().text();
|
|
221
|
+
return capText(text, cap);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return { truncated: false };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return { truncated: false };
|
|
228
|
+
}
|
|
229
|
+
function serializeBodyInit(body, cap) {
|
|
230
|
+
if (typeof body === 'string')
|
|
231
|
+
return capText(body, cap);
|
|
232
|
+
if (typeof FormData !== 'undefined' && body instanceof FormData) {
|
|
233
|
+
const obj = {};
|
|
234
|
+
body.forEach((v, k) => {
|
|
235
|
+
obj[k] = typeof v === 'string'
|
|
236
|
+
? v
|
|
237
|
+
: `[File ${v.name} ${v.size}B]`;
|
|
238
|
+
});
|
|
239
|
+
return { body: obj, truncated: false };
|
|
240
|
+
}
|
|
241
|
+
if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) {
|
|
242
|
+
return { body: body.toString(), truncated: false };
|
|
243
|
+
}
|
|
244
|
+
if (typeof Blob !== 'undefined' && body instanceof Blob) {
|
|
245
|
+
return { body: `[Blob ${body.size}B]`, truncated: false };
|
|
246
|
+
}
|
|
247
|
+
if (body instanceof ArrayBuffer) {
|
|
248
|
+
return { body: `[ArrayBuffer ${body.byteLength}B]`, truncated: false };
|
|
249
|
+
}
|
|
250
|
+
if (ArrayBuffer.isView(body)) {
|
|
251
|
+
return { body: `[${body.constructor.name} ${body.byteLength}B]`, truncated: false };
|
|
252
|
+
}
|
|
253
|
+
return { body: '[unknown body]', truncated: false };
|
|
254
|
+
}
|
|
255
|
+
function capText(text, cap) {
|
|
256
|
+
if (text.length > cap)
|
|
257
|
+
return { body: text.slice(0, cap), truncated: true };
|
|
258
|
+
return { body: text, truncated: false };
|
|
259
|
+
}
|
|
260
|
+
async function readTextWithCap(res, cap) {
|
|
261
|
+
const text = await res.text();
|
|
262
|
+
return capText(text, cap);
|
|
263
|
+
}
|
|
264
|
+
function maybeParseJson(text, contentType) {
|
|
265
|
+
if (!/json/i.test(contentType))
|
|
266
|
+
return text;
|
|
267
|
+
try {
|
|
268
|
+
return JSON.parse(text);
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return text;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
function isSSE(ct) {
|
|
275
|
+
return /text\/event-stream/i.test(ct);
|
|
276
|
+
}
|
|
277
|
+
function isTextLike(ct) {
|
|
278
|
+
return /json|text|xml|javascript|x-www-form-urlencoded/i.test(ct);
|
|
279
|
+
}
|
|
280
|
+
function pumpSSE(res, cap, done) {
|
|
281
|
+
if (!res.body || typeof res.body.getReader !== 'function') {
|
|
282
|
+
return done('', false);
|
|
283
|
+
}
|
|
284
|
+
const reader = res.body.getReader();
|
|
285
|
+
const dec = new TextDecoder();
|
|
286
|
+
let total = '';
|
|
287
|
+
let truncated = false;
|
|
288
|
+
const step = () => {
|
|
289
|
+
reader.read().then(({ done: end, value }) => {
|
|
290
|
+
if (end)
|
|
291
|
+
return done(total, truncated);
|
|
292
|
+
total += dec.decode(value, { stream: true });
|
|
293
|
+
if (total.length > cap) {
|
|
294
|
+
total = total.slice(0, cap);
|
|
295
|
+
truncated = true;
|
|
296
|
+
reader.cancel().catch(() => { });
|
|
297
|
+
return done(total, truncated);
|
|
298
|
+
}
|
|
299
|
+
step();
|
|
300
|
+
}, () => done(total, truncated));
|
|
301
|
+
};
|
|
302
|
+
step();
|
|
303
|
+
}
|
|
304
|
+
function safeEmit(fn, entry) {
|
|
305
|
+
try {
|
|
306
|
+
fn(entry);
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
/* swallow — capture must not break business */
|
|
310
|
+
}
|
|
311
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-start entry. Importing this module (as the Vite plugin does)
|
|
3
|
+
* boots a RuntimeClient using the config planted on `window.__HARNESS_FE__`.
|
|
4
|
+
*
|
|
5
|
+
* Idempotent: importing twice is a no-op.
|
|
6
|
+
*/
|
|
7
|
+
export { RuntimeClient, tryInheritFromParent } from './client.js';
|
|
8
|
+
export type { ClientOptions, ParentInheritance } from './client.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-start entry. Importing this module (as the Vite plugin does)
|
|
3
|
+
* boots a RuntimeClient using the config planted on `window.__HARNESS_FE__`.
|
|
4
|
+
*
|
|
5
|
+
* Idempotent: importing twice is a no-op.
|
|
6
|
+
*/
|
|
7
|
+
import { installOverlay } from './overlay.js';
|
|
8
|
+
import { RuntimeClient, readInjectedConfig } from './client.js';
|
|
9
|
+
const w = window;
|
|
10
|
+
if (typeof window !== 'undefined' && !w.__harness_fe_started__) {
|
|
11
|
+
w.__harness_fe_started__ = true;
|
|
12
|
+
const cfg = readInjectedConfig();
|
|
13
|
+
const client = new RuntimeClient(cfg);
|
|
14
|
+
client.start();
|
|
15
|
+
installOverlay(client);
|
|
16
|
+
// Expose for debugging + same-origin iframe inheritance.
|
|
17
|
+
// Same-origin children read `window.parent.__hfe_session_id__` and
|
|
18
|
+
// `window.parent.__harness_fe_client__.tabId` in tryInheritFromParent()
|
|
19
|
+
// so all iframes within one pageload share identity.
|
|
20
|
+
w.__harness_fe_client__ = client;
|
|
21
|
+
w.__hfe_session_id__ = client.sessionId;
|
|
22
|
+
}
|
|
23
|
+
export { RuntimeClient, tryInheritFromParent } from './client.js';
|
package/dist/outbox.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-handshake / reconnect outbox.
|
|
3
|
+
*
|
|
4
|
+
* Buffers frames produced while the WebSocket isn't OPEN and replays them
|
|
5
|
+
* in FIFO order on `flush(send)`. Capped to defend against OOM if the
|
|
6
|
+
* bridge is unreachable for an extended period.
|
|
7
|
+
*
|
|
8
|
+
* `sticky` marks frames that must survive eviction even when the outbox
|
|
9
|
+
* is full — currently used for rrweb chunks containing a FullSnapshot
|
|
10
|
+
* (type:2) baseline. Without this protection, the FullSnapshot — being
|
|
11
|
+
* the oldest frame in the outbox — was always the first to be FIFO-
|
|
12
|
+
* evicted under load, leaving the session permanently unreplayable.
|
|
13
|
+
* Eviction only touches sticky frames as a last resort (all remaining
|
|
14
|
+
* frames are sticky and we still exceed cap).
|
|
15
|
+
*/
|
|
16
|
+
export declare class Outbox {
|
|
17
|
+
private readonly maxFrames;
|
|
18
|
+
private readonly maxBytes;
|
|
19
|
+
private entries;
|
|
20
|
+
private bytes;
|
|
21
|
+
constructor(maxFrames: number, maxBytes: number);
|
|
22
|
+
/** Current buffered frame count. */
|
|
23
|
+
get size(): number;
|
|
24
|
+
/** Current buffered byte size. */
|
|
25
|
+
get byteSize(): number;
|
|
26
|
+
/** Inspect entries without exposing the underlying mutable array. */
|
|
27
|
+
snapshot(): ReadonlyArray<{
|
|
28
|
+
payload: string;
|
|
29
|
+
sticky: boolean;
|
|
30
|
+
}>;
|
|
31
|
+
enqueue(payload: string, sticky: boolean): void;
|
|
32
|
+
/**
|
|
33
|
+
* Drain FIFO into `send`. If `send` throws (or returns false), keep the
|
|
34
|
+
* frame in the queue and bail — caller will retry on next flush.
|
|
35
|
+
*/
|
|
36
|
+
flush(send: (payload: string) => boolean | void): void;
|
|
37
|
+
}
|
package/dist/outbox.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-handshake / reconnect outbox.
|
|
3
|
+
*
|
|
4
|
+
* Buffers frames produced while the WebSocket isn't OPEN and replays them
|
|
5
|
+
* in FIFO order on `flush(send)`. Capped to defend against OOM if the
|
|
6
|
+
* bridge is unreachable for an extended period.
|
|
7
|
+
*
|
|
8
|
+
* `sticky` marks frames that must survive eviction even when the outbox
|
|
9
|
+
* is full — currently used for rrweb chunks containing a FullSnapshot
|
|
10
|
+
* (type:2) baseline. Without this protection, the FullSnapshot — being
|
|
11
|
+
* the oldest frame in the outbox — was always the first to be FIFO-
|
|
12
|
+
* evicted under load, leaving the session permanently unreplayable.
|
|
13
|
+
* Eviction only touches sticky frames as a last resort (all remaining
|
|
14
|
+
* frames are sticky and we still exceed cap).
|
|
15
|
+
*/
|
|
16
|
+
export class Outbox {
|
|
17
|
+
maxFrames;
|
|
18
|
+
maxBytes;
|
|
19
|
+
entries = [];
|
|
20
|
+
bytes = 0;
|
|
21
|
+
constructor(maxFrames, maxBytes) {
|
|
22
|
+
this.maxFrames = maxFrames;
|
|
23
|
+
this.maxBytes = maxBytes;
|
|
24
|
+
}
|
|
25
|
+
/** Current buffered frame count. */
|
|
26
|
+
get size() {
|
|
27
|
+
return this.entries.length;
|
|
28
|
+
}
|
|
29
|
+
/** Current buffered byte size. */
|
|
30
|
+
get byteSize() {
|
|
31
|
+
return this.bytes;
|
|
32
|
+
}
|
|
33
|
+
/** Inspect entries without exposing the underlying mutable array. */
|
|
34
|
+
snapshot() {
|
|
35
|
+
return this.entries.slice();
|
|
36
|
+
}
|
|
37
|
+
enqueue(payload, sticky) {
|
|
38
|
+
this.entries.push({ payload, sticky });
|
|
39
|
+
this.bytes += payload.length;
|
|
40
|
+
// Pass 1: evict only non-sticky frames, oldest first.
|
|
41
|
+
let i = 0;
|
|
42
|
+
while ((this.entries.length > this.maxFrames || this.bytes > this.maxBytes) && i < this.entries.length) {
|
|
43
|
+
const entry = this.entries[i];
|
|
44
|
+
if (entry.sticky) {
|
|
45
|
+
i++;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
this.entries.splice(i, 1);
|
|
49
|
+
this.bytes -= entry.payload.length;
|
|
50
|
+
}
|
|
51
|
+
// Pass 2 (last resort): drop oldest sticky frames if we still bust
|
|
52
|
+
// the cap. Replay only needs the *most recent* baseline, so dropping
|
|
53
|
+
// older sticky frames is acceptable.
|
|
54
|
+
while ((this.entries.length > this.maxFrames || this.bytes > this.maxBytes) && this.entries.length > 0) {
|
|
55
|
+
const dropped = this.entries.shift();
|
|
56
|
+
this.bytes -= dropped.payload.length;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Drain FIFO into `send`. If `send` throws (or returns false), keep the
|
|
61
|
+
* frame in the queue and bail — caller will retry on next flush.
|
|
62
|
+
*/
|
|
63
|
+
flush(send) {
|
|
64
|
+
while (this.entries.length > 0) {
|
|
65
|
+
const entry = this.entries[0];
|
|
66
|
+
let ok = false;
|
|
67
|
+
try {
|
|
68
|
+
const result = send(entry.payload);
|
|
69
|
+
ok = result !== false;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
ok = false;
|
|
73
|
+
}
|
|
74
|
+
if (!ok)
|
|
75
|
+
return;
|
|
76
|
+
this.entries.shift();
|
|
77
|
+
this.bytes -= entry.payload.length;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-page overlay — single floating "H" mark in the bottom-right corner.
|
|
3
|
+
*
|
|
4
|
+
* Click → expands into an info card that surfaces:
|
|
5
|
+
* - project / buildId / connection status
|
|
6
|
+
* - sessionId / tabId (click-to-copy)
|
|
7
|
+
* - current URL
|
|
8
|
+
* - "Copy snapshot" — key fields as markdown for sharing with a teammate
|
|
9
|
+
* or pasting into an agent prompt
|
|
10
|
+
* - "Report a problem" — enters element-picker mode (the legacy annotation
|
|
11
|
+
* flow, now reachable from inside the card so users don't see two FABs)
|
|
12
|
+
*
|
|
13
|
+
* Single Shadow DOM root attached to <body>; host page styles never leak in
|
|
14
|
+
* or out. State machine: idle → info → (picker → question) → flash → idle.
|
|
15
|
+
*/
|
|
16
|
+
import { type TaskAttachment } from '@harness-fe/protocol';
|
|
17
|
+
export interface OverlayClient {
|
|
18
|
+
readonly projectId: string;
|
|
19
|
+
readonly buildId?: string;
|
|
20
|
+
readonly displayName?: string;
|
|
21
|
+
readonly tabId: string;
|
|
22
|
+
readonly sessionId: string;
|
|
23
|
+
readonly visitorId?: string;
|
|
24
|
+
readonly userId?: string;
|
|
25
|
+
readonly parentProjectId?: string;
|
|
26
|
+
/**
|
|
27
|
+
* WebSocket URL the runtime connects to. Used by the overlay to derive
|
|
28
|
+
* the daemon's dashboard URL ("Open dashboard" button). Optional — if
|
|
29
|
+
* absent, the button hides instead of pointing at the wrong host.
|
|
30
|
+
*/
|
|
31
|
+
readonly mcpUrl?: string;
|
|
32
|
+
getConnectionState(): 'connecting' | 'open' | 'closed';
|
|
33
|
+
sendEvent(name: string, payload: unknown): void;
|
|
34
|
+
/**
|
|
35
|
+
* RPC channel to the daemon. Used to fetch and mutate the visitor's own
|
|
36
|
+
* tasks. Resolves with `result`, rejects with the remote error message.
|
|
37
|
+
*/
|
|
38
|
+
query?<TResult = unknown>(method: string, args?: unknown): Promise<TResult>;
|
|
39
|
+
}
|
|
40
|
+
export declare function installOverlay(client: OverlayClient): void;
|
|
41
|
+
interface ArrowStroke {
|
|
42
|
+
kind: 'arrow';
|
|
43
|
+
color: string;
|
|
44
|
+
x1: number;
|
|
45
|
+
y1: number;
|
|
46
|
+
x2: number;
|
|
47
|
+
y2: number;
|
|
48
|
+
}
|
|
49
|
+
interface TextStroke {
|
|
50
|
+
kind: 'text';
|
|
51
|
+
color: string;
|
|
52
|
+
x: number;
|
|
53
|
+
y: number;
|
|
54
|
+
text: string;
|
|
55
|
+
}
|
|
56
|
+
type Stroke = ArrowStroke | TextStroke;
|
|
57
|
+
export declare function replayStrokes(ctx: CanvasRenderingContext2D, bgCanvas: HTMLCanvasElement, strokes: Stroke[]): void;
|
|
58
|
+
/**
|
|
59
|
+
* Flatten strokes onto the background canvas and return as a TaskAttachment.
|
|
60
|
+
* Exported for testing.
|
|
61
|
+
*/
|
|
62
|
+
export declare function finalizeAnnotation(): Promise<TaskAttachment | null>;
|
|
63
|
+
/**
|
|
64
|
+
* Best-effort CSS path. Depth cap 12, id anchor short-circuits, ` >>> `
|
|
65
|
+
* separates shadow-DOM boundaries.
|
|
66
|
+
*/
|
|
67
|
+
export declare function buildCssPath(el: Element): string;
|
|
68
|
+
export {};
|