@vitrinka/web 0.1.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/CHANGELOG.md +13 -0
- package/LICENSE +93 -0
- package/README.md +177 -0
- package/build/index.d.ts +11 -0
- package/build/index.js +1 -0
- package/build/next.d.ts +23 -0
- package/build/next.js +27 -0
- package/build/protocol/index.d.ts +74 -0
- package/build/protocol/index.js +22 -0
- package/build/recorder/RecorderProvider.d.ts +17 -0
- package/build/recorder/RecorderProvider.js +126 -0
- package/build/recorder/api-status.d.ts +6 -0
- package/build/recorder/api-status.js +6 -0
- package/build/recorder/api.d.ts +40 -0
- package/build/recorder/api.js +84 -0
- package/build/recorder/capture/click.d.ts +17 -0
- package/build/recorder/capture/click.js +77 -0
- package/build/recorder/capture/console.d.ts +3 -0
- package/build/recorder/capture/console.js +89 -0
- package/build/recorder/capture/nav.d.ts +8 -0
- package/build/recorder/capture/nav.js +54 -0
- package/build/recorder/capture/net.d.ts +22 -0
- package/build/recorder/capture/net.js +506 -0
- package/build/recorder/capture/redact.d.ts +38 -0
- package/build/recorder/capture/redact.js +54 -0
- package/build/recorder/capture/rrweb.d.ts +10 -0
- package/build/recorder/capture/rrweb.js +76 -0
- package/build/recorder/config.d.ts +50 -0
- package/build/recorder/config.js +100 -0
- package/build/recorder/control.d.ts +29 -0
- package/build/recorder/control.js +63 -0
- package/build/recorder/hud/AnnotateOverlay.d.ts +25 -0
- package/build/recorder/hud/AnnotateOverlay.js +122 -0
- package/build/recorder/hud/Hud.d.ts +12 -0
- package/build/recorder/hud/Hud.js +190 -0
- package/build/recorder/hud/LinkSheet.d.ts +26 -0
- package/build/recorder/hud/LinkSheet.js +15 -0
- package/build/recorder/hud/RecorderPill.d.ts +36 -0
- package/build/recorder/hud/RecorderPill.js +73 -0
- package/build/recorder/hud/Sheet.d.ts +20 -0
- package/build/recorder/hud/Sheet.js +36 -0
- package/build/recorder/hud/host.d.ts +27 -0
- package/build/recorder/hud/host.js +170 -0
- package/build/recorder/hud/icons.d.ts +15 -0
- package/build/recorder/hud/icons.js +40 -0
- package/build/recorder/hud/styles.d.ts +13 -0
- package/build/recorder/hud/styles.js +111 -0
- package/build/recorder/index.d.ts +46 -0
- package/build/recorder/index.js +61 -0
- package/build/recorder/link.d.ts +18 -0
- package/build/recorder/link.js +37 -0
- package/build/recorder/queue.d.ts +163 -0
- package/build/recorder/queue.js +642 -0
- package/build/recorder/session.d.ts +73 -0
- package/build/recorder/session.js +246 -0
- package/build/recorder/state.d.ts +26 -0
- package/build/recorder/state.js +42 -0
- package/build/recorder/storage/index.d.ts +35 -0
- package/build/recorder/storage/index.js +69 -0
- package/build/recorder/storage/memory.d.ts +2 -0
- package/build/recorder/storage/memory.js +2 -0
- package/package.json +77 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { VitrinkaApiError } from './api-status';
|
|
2
|
+
import { isUnauthorized } from '@vitrinka/link';
|
|
3
|
+
import { bearerToken, recorderConfig } from './config';
|
|
4
|
+
export { permanentStatus, VitrinkaApiError } from './api-status';
|
|
5
|
+
/**
|
|
6
|
+
* A 401 from any session door means the token is dead (link revoked, key
|
|
7
|
+
* rotated). The session module registers the handler that forgets the link
|
|
8
|
+
* and ends the recording locally; api.ts only reports.
|
|
9
|
+
*/
|
|
10
|
+
let unauthorizedHandler = null;
|
|
11
|
+
export function onUnauthorized(fn) {
|
|
12
|
+
unauthorizedHandler = fn;
|
|
13
|
+
return () => {
|
|
14
|
+
if (unauthorizedHandler === fn)
|
|
15
|
+
unauthorizedHandler = null;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function reportStatus(status) {
|
|
19
|
+
if (isUnauthorized(status))
|
|
20
|
+
unauthorizedHandler?.();
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Fetch the workspace redaction policy at session start. NEVER rejects: null
|
|
24
|
+
* (server too old, network down, 4xx) means the engine's safe defaults — fail
|
|
25
|
+
* closed, never capture-everything.
|
|
26
|
+
*/
|
|
27
|
+
export async function fetchPolicy() {
|
|
28
|
+
try {
|
|
29
|
+
const res = await api('GET', '/api/v1/recorder/policy');
|
|
30
|
+
return res.policy ?? null;
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
console.warn('vitrinka: redaction policy fetch failed — using safe defaults', e);
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export async function api(method, path, body, opts = {}) {
|
|
38
|
+
const { url } = recorderConfig();
|
|
39
|
+
const key = bearerToken();
|
|
40
|
+
const res = await fetch(url + path, {
|
|
41
|
+
method,
|
|
42
|
+
mode: 'cors',
|
|
43
|
+
credentials: 'omit',
|
|
44
|
+
keepalive: opts.keepalive ?? false,
|
|
45
|
+
headers: {
|
|
46
|
+
authorization: `Bearer ${key}`,
|
|
47
|
+
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
48
|
+
},
|
|
49
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
50
|
+
});
|
|
51
|
+
const text = await res.text();
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
reportStatus(res.status);
|
|
54
|
+
throw new VitrinkaApiError(`${method} ${path} → ${res.status}: ${text}`, res.status);
|
|
55
|
+
}
|
|
56
|
+
return (text ? JSON.parse(text) : {});
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Upload one rrweb chunk (an already-serialized JSON array of rrweb events)
|
|
60
|
+
* under a pre-allocated seq. Returns the server's blobKey — the matching
|
|
61
|
+
* `rrweb` event row carries it.
|
|
62
|
+
*/
|
|
63
|
+
export async function uploadChunk(sessionId, seq, body) {
|
|
64
|
+
const { url } = recorderConfig();
|
|
65
|
+
const key = bearerToken();
|
|
66
|
+
const res = await fetch(`${url}/api/v1/sessions/${sessionId}/chunk?seq=${seq}`, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
mode: 'cors',
|
|
69
|
+
credentials: 'omit',
|
|
70
|
+
headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
|
71
|
+
body,
|
|
72
|
+
});
|
|
73
|
+
const text = await res.text();
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
reportStatus(res.status);
|
|
76
|
+
throw new VitrinkaApiError(`POST chunk seq ${seq} → ${res.status}: ${text}`, res.status);
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(text);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** The extension's selector heuristic — id, then testid, then a short path. */
|
|
2
|
+
export declare function shortSelector(el: Element | null): string;
|
|
3
|
+
/** Bounding rect in device pixels (the extension's `imageRect`). */
|
|
4
|
+
export declare function imageRect(el: Element): {
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
w: number;
|
|
8
|
+
h: number;
|
|
9
|
+
};
|
|
10
|
+
/** The element's own visible text or value, trimmed and capped. */
|
|
11
|
+
export declare function elementText(el: Element): string;
|
|
12
|
+
export interface ClickLaneOptions {
|
|
13
|
+
/** True while the HUD owns the pointer (annotate mode) or the target is HUD chrome. */
|
|
14
|
+
ignore: (target: EventTarget | null) => boolean;
|
|
15
|
+
}
|
|
16
|
+
/** Install the click lane; returns the uninstaller. */
|
|
17
|
+
export declare function installClickLane(opts: ClickLaneOptions): () => void;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Click lane — a capture-phase document listener (sees clicks the app
|
|
3
|
+
* swallows). Shape matches the extension's content script exactly:
|
|
4
|
+
* `{selector, text, rect}` — `shortSelector` (id > data-testid > a ≤4-hop
|
|
5
|
+
* tag.class path), the element's text (redacted, 80 chars) and its bounding
|
|
6
|
+
* rect in device pixels — plus `route`.
|
|
7
|
+
*/
|
|
8
|
+
import { pushEvent } from '../queue';
|
|
9
|
+
import { currentRoute } from '../state';
|
|
10
|
+
import { redactText } from './redact';
|
|
11
|
+
const TEXT_CAP = 80;
|
|
12
|
+
const CLICKABLE = 'a,button,[role=button],input,select,textarea,label';
|
|
13
|
+
/** The extension's selector heuristic — id, then testid, then a short path. */
|
|
14
|
+
export function shortSelector(el) {
|
|
15
|
+
if (!el)
|
|
16
|
+
return '';
|
|
17
|
+
if (el.id)
|
|
18
|
+
return `#${el.id}`;
|
|
19
|
+
const t = el.getAttribute('data-testid') || el.getAttribute('data-test');
|
|
20
|
+
if (t)
|
|
21
|
+
return `[data-testid="${t}"]`;
|
|
22
|
+
const parts = [];
|
|
23
|
+
let n = el;
|
|
24
|
+
while (n && parts.length < 4) {
|
|
25
|
+
let p = n.tagName.toLowerCase();
|
|
26
|
+
if (n.classList.length)
|
|
27
|
+
p += '.' + [...n.classList].slice(0, 2).join('.');
|
|
28
|
+
parts.unshift(p);
|
|
29
|
+
if (n.id) {
|
|
30
|
+
parts[0] = `#${n.id}`;
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
n = n.parentElement;
|
|
34
|
+
}
|
|
35
|
+
return parts.join(' > ');
|
|
36
|
+
}
|
|
37
|
+
/** Bounding rect in device pixels (the extension's `imageRect`). */
|
|
38
|
+
export function imageRect(el) {
|
|
39
|
+
const r = el.getBoundingClientRect();
|
|
40
|
+
const s = globalThis.devicePixelRatio || 1;
|
|
41
|
+
return {
|
|
42
|
+
x: Math.round(r.x * s),
|
|
43
|
+
y: Math.round(r.y * s),
|
|
44
|
+
w: Math.round(r.width * s),
|
|
45
|
+
h: Math.round(r.height * s),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** The element's own visible text or value, trimmed and capped. */
|
|
49
|
+
export function elementText(el) {
|
|
50
|
+
const html = el;
|
|
51
|
+
const raw = html.innerText || (typeof html.value === 'string' ? html.value : '') || '';
|
|
52
|
+
return raw.trim().slice(0, TEXT_CAP);
|
|
53
|
+
}
|
|
54
|
+
/** Install the click lane; returns the uninstaller. */
|
|
55
|
+
export function installClickLane(opts) {
|
|
56
|
+
const onClick = (e) => {
|
|
57
|
+
try {
|
|
58
|
+
if (opts.ignore(e.target))
|
|
59
|
+
return;
|
|
60
|
+
const target = e.target instanceof Element ? e.target : null;
|
|
61
|
+
const el = target ? target.closest(CLICKABLE) || target : null;
|
|
62
|
+
if (!el)
|
|
63
|
+
return;
|
|
64
|
+
pushEvent('click', {
|
|
65
|
+
selector: shortSelector(el),
|
|
66
|
+
text: redactText(elementText(el)) ?? '',
|
|
67
|
+
rect: imageRect(el),
|
|
68
|
+
route: currentRoute.pathname,
|
|
69
|
+
}, { tabId: currentRoute.tabId, tabHost: currentRoute.tabHost });
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// capture must never break the click
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
document.addEventListener('click', onClick, true);
|
|
76
|
+
return () => document.removeEventListener('click', onClick, true);
|
|
77
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Console lane — `console.error` + `window.onerror` + `unhandledrejection`.
|
|
3
|
+
* The recorder's own logs are prefixed "vitrinka:" and skipped, or a failed
|
|
4
|
+
* flush would feed itself forever. Shape matches the extension's CDP tap:
|
|
5
|
+
* `{level: 'error', text}`.
|
|
6
|
+
*/
|
|
7
|
+
import { pushEvent } from '../queue';
|
|
8
|
+
import { currentRoute } from '../state';
|
|
9
|
+
import { redactText } from './redact';
|
|
10
|
+
const TEXT_CAP = 8 * 1024;
|
|
11
|
+
const PATCH_MARK = '__vitrinkaRecorderConsolePatched';
|
|
12
|
+
function describe(args) {
|
|
13
|
+
return args
|
|
14
|
+
.map((a) => {
|
|
15
|
+
if (typeof a === 'string')
|
|
16
|
+
return a;
|
|
17
|
+
if (a instanceof Error)
|
|
18
|
+
return a.stack ?? a.message;
|
|
19
|
+
try {
|
|
20
|
+
return JSON.stringify(a);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return String(a);
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
.join(' ')
|
|
27
|
+
.slice(0, TEXT_CAP);
|
|
28
|
+
}
|
|
29
|
+
function record(text) {
|
|
30
|
+
if (!text || text.startsWith('vitrinka:'))
|
|
31
|
+
return;
|
|
32
|
+
// Logged objects routinely carry tokens/headers — same redaction pass as
|
|
33
|
+
// network bodies.
|
|
34
|
+
pushEvent('console', { level: 'error', text: redactText(text) }, { tabId: currentRoute.tabId, tabHost: currentRoute.tabHost });
|
|
35
|
+
}
|
|
36
|
+
const UNPATCH_MARK = '__vitrinkaRecorderConsoleUnpatch';
|
|
37
|
+
/** Restore console.error (while it is still ours) and drop the window listeners. */
|
|
38
|
+
export function unpatchConsole() {
|
|
39
|
+
globalThis[UNPATCH_MARK]?.();
|
|
40
|
+
}
|
|
41
|
+
export function patchConsole() {
|
|
42
|
+
const g = globalThis;
|
|
43
|
+
if (g[PATCH_MARK])
|
|
44
|
+
return;
|
|
45
|
+
g[PATCH_MARK] = true;
|
|
46
|
+
const origError = console.error;
|
|
47
|
+
const orig = origError.bind(console);
|
|
48
|
+
const patched = (...args) => {
|
|
49
|
+
orig(...args);
|
|
50
|
+
try {
|
|
51
|
+
record(describe(args));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// capture must never break logging
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
console.error = patched;
|
|
58
|
+
const onError = (e) => {
|
|
59
|
+
try {
|
|
60
|
+
record(describe([e.error ?? e.message]));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// never break the page's own error handling
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
const onRejection = (e) => {
|
|
67
|
+
try {
|
|
68
|
+
record(`unhandled rejection: ${describe([e.reason])}`);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// see above
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const hasWindow = typeof addEventListener === 'function';
|
|
75
|
+
if (hasWindow) {
|
|
76
|
+
addEventListener('error', onError);
|
|
77
|
+
addEventListener('unhandledrejection', onRejection);
|
|
78
|
+
}
|
|
79
|
+
g[UNPATCH_MARK] = () => {
|
|
80
|
+
if (console.error === patched)
|
|
81
|
+
console.error = origError;
|
|
82
|
+
if (hasWindow) {
|
|
83
|
+
removeEventListener('error', onError);
|
|
84
|
+
removeEventListener('unhandledrejection', onRejection);
|
|
85
|
+
}
|
|
86
|
+
delete g[PATCH_MARK];
|
|
87
|
+
delete g[UNPATCH_MARK];
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Record a navigation to the document's current URL (idempotent per URL). */
|
|
2
|
+
export declare function noteNavigation(): void;
|
|
3
|
+
/** Seed the dedupe with the URL the session started on (its nav is pushed by startSession). */
|
|
4
|
+
export declare function primeNavigation(): void;
|
|
5
|
+
/** Wrap pushState/replaceState + popstate/hashchange. Idempotent across HMR. */
|
|
6
|
+
export declare function installNavLane(): void;
|
|
7
|
+
/** Test-only. */
|
|
8
|
+
export declare function __resetNavForTests(): void;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigation lane. Every route change lands as ONE `nav` event
|
|
3
|
+
* (`{url, route, spa: true}` for in-document navigations, the extension's
|
|
4
|
+
* shape) whether it was observed by the default History wrap or fed by
|
|
5
|
+
* `useRecorderRoute(pathname)` — both funnel through `noteNavigation`, which
|
|
6
|
+
* dedupes on the URL so a router that fires both produces one event.
|
|
7
|
+
*/
|
|
8
|
+
import { pushEvent } from '../queue';
|
|
9
|
+
import { currentRoute, setCurrentPath } from '../state';
|
|
10
|
+
import { redactUrl } from './redact';
|
|
11
|
+
let lastUrl = '';
|
|
12
|
+
/** Record a navigation to the document's current URL (idempotent per URL). */
|
|
13
|
+
export function noteNavigation() {
|
|
14
|
+
const loc = globalThis.location;
|
|
15
|
+
if (!loc)
|
|
16
|
+
return;
|
|
17
|
+
const url = loc.href;
|
|
18
|
+
setCurrentPath(loc.pathname);
|
|
19
|
+
if (url === lastUrl)
|
|
20
|
+
return;
|
|
21
|
+
lastUrl = url;
|
|
22
|
+
pushEvent('nav', { url: redactUrl(url), route: loc.pathname, spa: true }, { tabId: currentRoute.tabId, tabHost: currentRoute.tabHost });
|
|
23
|
+
}
|
|
24
|
+
/** Seed the dedupe with the URL the session started on (its nav is pushed by startSession). */
|
|
25
|
+
export function primeNavigation() {
|
|
26
|
+
lastUrl = globalThis.location?.href ?? '';
|
|
27
|
+
setCurrentPath(globalThis.location?.pathname ?? '/');
|
|
28
|
+
}
|
|
29
|
+
const PATCH_MARK = '__vitrinkaRecorderNavPatched';
|
|
30
|
+
/** Wrap pushState/replaceState + popstate/hashchange. Idempotent across HMR. */
|
|
31
|
+
export function installNavLane() {
|
|
32
|
+
const g = globalThis;
|
|
33
|
+
if (g[PATCH_MARK] || typeof history === 'undefined')
|
|
34
|
+
return;
|
|
35
|
+
g[PATCH_MARK] = true;
|
|
36
|
+
const wrap = (name) => {
|
|
37
|
+
const orig = history[name];
|
|
38
|
+
history[name] = function (...args) {
|
|
39
|
+
const r = orig.apply(this, args);
|
|
40
|
+
// After the URL changed, off the caller's stack so a router's own
|
|
41
|
+
// synchronous state update never sees the recorder in its way.
|
|
42
|
+
queueMicrotask(noteNavigation);
|
|
43
|
+
return r;
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
wrap('pushState');
|
|
47
|
+
wrap('replaceState');
|
|
48
|
+
addEventListener('popstate', () => queueMicrotask(noteNavigation));
|
|
49
|
+
addEventListener('hashchange', () => queueMicrotask(noteNavigation));
|
|
50
|
+
}
|
|
51
|
+
/** Test-only. */
|
|
52
|
+
export function __resetNavForTests() {
|
|
53
|
+
lastUrl = '';
|
|
54
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Test-only: shorten the read deadline so suites don't burn real seconds. */
|
|
2
|
+
export declare function __setBodyReadDeadlineForTests(ms: number): () => void;
|
|
3
|
+
/**
|
|
4
|
+
* Non-string bodies are recorded as a TYPED PLACEHOLDER, never their bytes:
|
|
5
|
+
* a FormData routinely carries files and credentials and cannot be scrubbed
|
|
6
|
+
* without parsing it; a Blob/ArrayBuffer is binary. The placeholder keeps
|
|
7
|
+
* the timeline honest (a body WAS sent, this shape, this size) without the
|
|
8
|
+
* content.
|
|
9
|
+
*/
|
|
10
|
+
export declare function describeOpaqueBody(body: unknown): string | undefined;
|
|
11
|
+
/** Test-only: the RAW bounded read, before capping — see `readBoundedText`. */
|
|
12
|
+
export declare function __readBoundedTextForTests(clone: Response, headers: Headers): Promise<string | undefined>;
|
|
13
|
+
/**
|
|
14
|
+
* Remove the fetch/XHR wrappers installed by `patchNetwork` — the provider's
|
|
15
|
+
* unmount calls it so the recorder never outlives its tree. Each global is
|
|
16
|
+
* restored only while it is STILL our wrapper: a later patch by someone else
|
|
17
|
+
* (a devtools, an APM agent) stacked on top must not be torn out from under
|
|
18
|
+
* them, so such a global is left in place and only our capture goes quiet
|
|
19
|
+
* (no session ⇒ the wrapper passes straight through).
|
|
20
|
+
*/
|
|
21
|
+
export declare function unpatchNetwork(): void;
|
|
22
|
+
export declare function patchNetwork(): void;
|