@harness-fe/runtime 3.2.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.d.ts +7 -0
- package/dist/client.js +5 -1
- package/dist/recording.d.ts +16 -1
- package/dist/recording.js +15 -1
- package/package.json +1 -1
- package/src/client.ts +15 -1
- package/src/recording.test.ts +57 -0
- package/src/recording.ts +31 -1
package/dist/client.d.ts
CHANGED
|
@@ -28,6 +28,13 @@ export interface ClientOptions {
|
|
|
28
28
|
* by visitorId). Propagated by HarnessScript via window.__HARNESS_FE__.userId.
|
|
29
29
|
*/
|
|
30
30
|
userId?: string;
|
|
31
|
+
/**
|
|
32
|
+
* How often (in ms) rrweb should emit a fresh FullSnapshot baseline.
|
|
33
|
+
* Defaults to 30 minutes. Set to 0 to disable periodic baselines (the
|
|
34
|
+
* recorder still emits one at start() and one per ws reconnect).
|
|
35
|
+
* See {@link RrwebRecorderOptions.checkoutEveryNms} for the trade-off.
|
|
36
|
+
*/
|
|
37
|
+
rrwebCheckoutEveryNms?: number;
|
|
31
38
|
}
|
|
32
39
|
export { tryInheritFromParent } from './parent-inherit.js';
|
|
33
40
|
export type { ParentInheritance } from './parent-inherit.js';
|
package/dist/client.js
CHANGED
|
@@ -90,7 +90,10 @@ export class RuntimeClient {
|
|
|
90
90
|
}
|
|
91
91
|
pageLoadSent = false;
|
|
92
92
|
ctx = { capture: getCaptureStore() };
|
|
93
|
-
|
|
93
|
+
// Initialized in constructor (parameter property `opts` isn't readable at
|
|
94
|
+
// class-field-initializer time — field initializers run before parameter
|
|
95
|
+
// property assignment).
|
|
96
|
+
recorder;
|
|
94
97
|
reconnectAttempts = 0;
|
|
95
98
|
closed = false;
|
|
96
99
|
static MAX_OUTBOX_FRAMES = 500;
|
|
@@ -109,6 +112,7 @@ export class RuntimeClient {
|
|
|
109
112
|
const inheritedVisitor = tryInheritVisitorFromParent();
|
|
110
113
|
this.visitorId = inheritedVisitor ?? getOrCreateVisitorId();
|
|
111
114
|
publishVisitorIdToWindow(this.visitorId);
|
|
115
|
+
this.recorder = new RrwebRecorder((chunk) => this.sendEvent(EVENT_NAME.RRWEB, chunk), { checkoutEveryNms: opts.rrwebCheckoutEveryNms });
|
|
112
116
|
}
|
|
113
117
|
start() {
|
|
114
118
|
const daemonUrl = this.opts.mcpUrl ?? `ws://127.0.0.1:${DEFAULT_WS_PORT}`;
|
package/dist/recording.d.ts
CHANGED
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
import type { RrwebChunkPayload } from '@harness-fe/protocol';
|
|
2
2
|
export { RRWEB_FULL_SNAPSHOT_TYPE, chunkHasFullSnapshot } from './rrweb-types.js';
|
|
3
|
+
export interface RrwebRecorderOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Force rrweb to emit a fresh FullSnapshot every N milliseconds. Caps how
|
|
6
|
+
* stale the most recent baseline can be, so window replays mid-session
|
|
7
|
+
* don't have to roll forward from a baseline that's potentially hours old.
|
|
8
|
+
*
|
|
9
|
+
* Set to `0` (or a negative number) to disable periodic baselines and
|
|
10
|
+
* rely solely on the start() baseline + reconnect baselines. Useful for
|
|
11
|
+
* extremely bandwidth-constrained deployments.
|
|
12
|
+
*
|
|
13
|
+
* @default 30 * 60 * 1000 (30 minutes)
|
|
14
|
+
*/
|
|
15
|
+
checkoutEveryNms?: number;
|
|
16
|
+
}
|
|
3
17
|
export declare class RrwebRecorder {
|
|
4
18
|
private readonly onChunk;
|
|
19
|
+
private readonly opts;
|
|
5
20
|
private stopRecording?;
|
|
6
21
|
private flushTimer?;
|
|
7
22
|
private chunkSeq;
|
|
8
23
|
private buffer;
|
|
9
|
-
constructor(onChunk: (chunk: RrwebChunkPayload) => void);
|
|
24
|
+
constructor(onChunk: (chunk: RrwebChunkPayload) => void, opts?: RrwebRecorderOptions);
|
|
10
25
|
start(): void;
|
|
11
26
|
stop(): void;
|
|
12
27
|
/**
|
package/dist/recording.js
CHANGED
|
@@ -2,24 +2,38 @@ import { record } from 'rrweb';
|
|
|
2
2
|
export { RRWEB_FULL_SNAPSHOT_TYPE, chunkHasFullSnapshot } from './rrweb-types.js';
|
|
3
3
|
const FLUSH_MS = 5_000;
|
|
4
4
|
const MAX_EVENTS = 200;
|
|
5
|
+
// Default periodic-baseline cadence. Long-running sessions otherwise rely on
|
|
6
|
+
// a single FullSnapshot at start() + one per ws reconnect, which makes
|
|
7
|
+
// mid-session window replays expensive (rrweb has to roll forward all
|
|
8
|
+
// incremental events back to the original baseline) and leaves a window of
|
|
9
|
+
// vulnerability if the original baseline is ever evicted from the outbox.
|
|
10
|
+
// 30 min is a deliberate middle ground: ~16 baselines per 8h session at
|
|
11
|
+
// ~500KB each ≈ 8MB extra storage, which is acceptable for a dev tool.
|
|
12
|
+
const DEFAULT_CHECKOUT_EVERY_MS = 30 * 60 * 1000;
|
|
5
13
|
export class RrwebRecorder {
|
|
6
14
|
onChunk;
|
|
15
|
+
opts;
|
|
7
16
|
stopRecording;
|
|
8
17
|
flushTimer;
|
|
9
18
|
chunkSeq = 0;
|
|
10
19
|
buffer = [];
|
|
11
|
-
constructor(onChunk) {
|
|
20
|
+
constructor(onChunk, opts = {}) {
|
|
12
21
|
this.onChunk = onChunk;
|
|
22
|
+
this.opts = opts;
|
|
13
23
|
}
|
|
14
24
|
start() {
|
|
15
25
|
if (this.stopRecording)
|
|
16
26
|
return;
|
|
27
|
+
const checkoutEveryNms = this.opts.checkoutEveryNms ?? DEFAULT_CHECKOUT_EVERY_MS;
|
|
28
|
+
// rrweb interprets `checkoutEveryNms` falsy / undefined as "off".
|
|
29
|
+
// Pass undefined when disabled so we get the native off-path.
|
|
17
30
|
this.stopRecording = record({
|
|
18
31
|
emit: (event) => this.push(event),
|
|
19
32
|
inlineImages: false,
|
|
20
33
|
recordCanvas: false,
|
|
21
34
|
collectFonts: false,
|
|
22
35
|
maskAllInputs: false,
|
|
36
|
+
checkoutEveryNms: checkoutEveryNms > 0 ? checkoutEveryNms : undefined,
|
|
23
37
|
});
|
|
24
38
|
this.flushTimer = window.setInterval(() => this.flush(), FLUSH_MS);
|
|
25
39
|
}
|
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -53,6 +53,13 @@ export interface ClientOptions {
|
|
|
53
53
|
* by visitorId). Propagated by HarnessScript via window.__HARNESS_FE__.userId.
|
|
54
54
|
*/
|
|
55
55
|
userId?: string;
|
|
56
|
+
/**
|
|
57
|
+
* How often (in ms) rrweb should emit a fresh FullSnapshot baseline.
|
|
58
|
+
* Defaults to 30 minutes. Set to 0 to disable periodic baselines (the
|
|
59
|
+
* recorder still emits one at start() and one per ws reconnect).
|
|
60
|
+
* See {@link RrwebRecorderOptions.checkoutEveryNms} for the trade-off.
|
|
61
|
+
*/
|
|
62
|
+
rrwebCheckoutEveryNms?: number;
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
const TAB_ID_KEY = '__hfe_tab_id__';
|
|
@@ -137,7 +144,10 @@ export class RuntimeClient {
|
|
|
137
144
|
}
|
|
138
145
|
private pageLoadSent = false;
|
|
139
146
|
private readonly ctx: CommandContext = { capture: getCaptureStore() };
|
|
140
|
-
|
|
147
|
+
// Initialized in constructor (parameter property `opts` isn't readable at
|
|
148
|
+
// class-field-initializer time — field initializers run before parameter
|
|
149
|
+
// property assignment).
|
|
150
|
+
private readonly recorder: RrwebRecorder;
|
|
141
151
|
private reconnectAttempts = 0;
|
|
142
152
|
private closed = false;
|
|
143
153
|
private static readonly MAX_OUTBOX_FRAMES = 500;
|
|
@@ -159,6 +169,10 @@ export class RuntimeClient {
|
|
|
159
169
|
const inheritedVisitor = tryInheritVisitorFromParent();
|
|
160
170
|
this.visitorId = inheritedVisitor ?? getOrCreateVisitorId();
|
|
161
171
|
publishVisitorIdToWindow(this.visitorId);
|
|
172
|
+
this.recorder = new RrwebRecorder(
|
|
173
|
+
(chunk) => this.sendEvent(EVENT_NAME.RRWEB, chunk),
|
|
174
|
+
{ checkoutEveryNms: opts.rrwebCheckoutEveryNms },
|
|
175
|
+
);
|
|
162
176
|
}
|
|
163
177
|
|
|
164
178
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
// vi.mock is hoisted, so the spy refs need to be hoisted alongside it.
|
|
5
|
+
const { recordSpy, takeFullSnapshotSpy } = vi.hoisted(() => {
|
|
6
|
+
const takeFullSnapshotSpy = vi.fn();
|
|
7
|
+
const recordSpy = vi.fn(() => () => { /* stop noop */ });
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
9
|
+
(recordSpy as any).takeFullSnapshot = takeFullSnapshotSpy;
|
|
10
|
+
return { recordSpy, takeFullSnapshotSpy };
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
vi.mock('rrweb', () => ({
|
|
14
|
+
record: recordSpy,
|
|
15
|
+
EventType: { Custom: 5 },
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
import { RrwebRecorder } from './recording.js';
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
recordSpy.mockClear();
|
|
22
|
+
takeFullSnapshotSpy.mockClear();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('RrwebRecorder periodic baseline (checkoutEveryNms)', () => {
|
|
26
|
+
it('passes the default 30-minute interval to rrweb when no option supplied', () => {
|
|
27
|
+
const r = new RrwebRecorder(() => { /* noop */ });
|
|
28
|
+
r.start();
|
|
29
|
+
const call = recordSpy.mock.calls[0]?.[0] as { checkoutEveryNms?: number };
|
|
30
|
+
expect(call.checkoutEveryNms).toBe(30 * 60 * 1000);
|
|
31
|
+
r.stop();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('honors an explicit interval', () => {
|
|
35
|
+
const r = new RrwebRecorder(() => { /* noop */ }, { checkoutEveryNms: 60_000 });
|
|
36
|
+
r.start();
|
|
37
|
+
const call = recordSpy.mock.calls[0]?.[0] as { checkoutEveryNms?: number };
|
|
38
|
+
expect(call.checkoutEveryNms).toBe(60_000);
|
|
39
|
+
r.stop();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('disables periodic baselines when interval is 0', () => {
|
|
43
|
+
const r = new RrwebRecorder(() => { /* noop */ }, { checkoutEveryNms: 0 });
|
|
44
|
+
r.start();
|
|
45
|
+
const call = recordSpy.mock.calls[0]?.[0] as { checkoutEveryNms?: number };
|
|
46
|
+
expect(call.checkoutEveryNms).toBeUndefined();
|
|
47
|
+
r.stop();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('disables periodic baselines for negative intervals', () => {
|
|
51
|
+
const r = new RrwebRecorder(() => { /* noop */ }, { checkoutEveryNms: -1 });
|
|
52
|
+
r.start();
|
|
53
|
+
const call = recordSpy.mock.calls[0]?.[0] as { checkoutEveryNms?: number };
|
|
54
|
+
expect(call.checkoutEveryNms).toBeUndefined();
|
|
55
|
+
r.stop();
|
|
56
|
+
});
|
|
57
|
+
});
|
package/src/recording.ts
CHANGED
|
@@ -5,6 +5,29 @@ export { RRWEB_FULL_SNAPSHOT_TYPE, chunkHasFullSnapshot } from './rrweb-types.js
|
|
|
5
5
|
|
|
6
6
|
const FLUSH_MS = 5_000;
|
|
7
7
|
const MAX_EVENTS = 200;
|
|
8
|
+
// Default periodic-baseline cadence. Long-running sessions otherwise rely on
|
|
9
|
+
// a single FullSnapshot at start() + one per ws reconnect, which makes
|
|
10
|
+
// mid-session window replays expensive (rrweb has to roll forward all
|
|
11
|
+
// incremental events back to the original baseline) and leaves a window of
|
|
12
|
+
// vulnerability if the original baseline is ever evicted from the outbox.
|
|
13
|
+
// 30 min is a deliberate middle ground: ~16 baselines per 8h session at
|
|
14
|
+
// ~500KB each ≈ 8MB extra storage, which is acceptable for a dev tool.
|
|
15
|
+
const DEFAULT_CHECKOUT_EVERY_MS = 30 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
export interface RrwebRecorderOptions {
|
|
18
|
+
/**
|
|
19
|
+
* Force rrweb to emit a fresh FullSnapshot every N milliseconds. Caps how
|
|
20
|
+
* stale the most recent baseline can be, so window replays mid-session
|
|
21
|
+
* don't have to roll forward from a baseline that's potentially hours old.
|
|
22
|
+
*
|
|
23
|
+
* Set to `0` (or a negative number) to disable periodic baselines and
|
|
24
|
+
* rely solely on the start() baseline + reconnect baselines. Useful for
|
|
25
|
+
* extremely bandwidth-constrained deployments.
|
|
26
|
+
*
|
|
27
|
+
* @default 30 * 60 * 1000 (30 minutes)
|
|
28
|
+
*/
|
|
29
|
+
checkoutEveryNms?: number;
|
|
30
|
+
}
|
|
8
31
|
|
|
9
32
|
export class RrwebRecorder {
|
|
10
33
|
private stopRecording?: () => void;
|
|
@@ -12,16 +35,23 @@ export class RrwebRecorder {
|
|
|
12
35
|
private chunkSeq = 0;
|
|
13
36
|
private buffer: unknown[] = [];
|
|
14
37
|
|
|
15
|
-
constructor(
|
|
38
|
+
constructor(
|
|
39
|
+
private readonly onChunk: (chunk: RrwebChunkPayload) => void,
|
|
40
|
+
private readonly opts: RrwebRecorderOptions = {},
|
|
41
|
+
) {}
|
|
16
42
|
|
|
17
43
|
start(): void {
|
|
18
44
|
if (this.stopRecording) return;
|
|
45
|
+
const checkoutEveryNms = this.opts.checkoutEveryNms ?? DEFAULT_CHECKOUT_EVERY_MS;
|
|
46
|
+
// rrweb interprets `checkoutEveryNms` falsy / undefined as "off".
|
|
47
|
+
// Pass undefined when disabled so we get the native off-path.
|
|
19
48
|
this.stopRecording = record({
|
|
20
49
|
emit: (event: unknown) => this.push(event),
|
|
21
50
|
inlineImages: false,
|
|
22
51
|
recordCanvas: false,
|
|
23
52
|
collectFonts: false,
|
|
24
53
|
maskAllInputs: false,
|
|
54
|
+
checkoutEveryNms: checkoutEveryNms > 0 ? checkoutEveryNms : undefined,
|
|
25
55
|
});
|
|
26
56
|
this.flushTimer = window.setInterval(() => this.flush(), FLUSH_MS);
|
|
27
57
|
}
|