@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +93 -0
  3. package/README.md +177 -0
  4. package/build/index.d.ts +11 -0
  5. package/build/index.js +1 -0
  6. package/build/next.d.ts +23 -0
  7. package/build/next.js +27 -0
  8. package/build/protocol/index.d.ts +74 -0
  9. package/build/protocol/index.js +22 -0
  10. package/build/recorder/RecorderProvider.d.ts +17 -0
  11. package/build/recorder/RecorderProvider.js +126 -0
  12. package/build/recorder/api-status.d.ts +6 -0
  13. package/build/recorder/api-status.js +6 -0
  14. package/build/recorder/api.d.ts +40 -0
  15. package/build/recorder/api.js +84 -0
  16. package/build/recorder/capture/click.d.ts +17 -0
  17. package/build/recorder/capture/click.js +77 -0
  18. package/build/recorder/capture/console.d.ts +3 -0
  19. package/build/recorder/capture/console.js +89 -0
  20. package/build/recorder/capture/nav.d.ts +8 -0
  21. package/build/recorder/capture/nav.js +54 -0
  22. package/build/recorder/capture/net.d.ts +22 -0
  23. package/build/recorder/capture/net.js +506 -0
  24. package/build/recorder/capture/redact.d.ts +38 -0
  25. package/build/recorder/capture/redact.js +54 -0
  26. package/build/recorder/capture/rrweb.d.ts +10 -0
  27. package/build/recorder/capture/rrweb.js +76 -0
  28. package/build/recorder/config.d.ts +50 -0
  29. package/build/recorder/config.js +100 -0
  30. package/build/recorder/control.d.ts +29 -0
  31. package/build/recorder/control.js +63 -0
  32. package/build/recorder/hud/AnnotateOverlay.d.ts +25 -0
  33. package/build/recorder/hud/AnnotateOverlay.js +122 -0
  34. package/build/recorder/hud/Hud.d.ts +12 -0
  35. package/build/recorder/hud/Hud.js +190 -0
  36. package/build/recorder/hud/LinkSheet.d.ts +26 -0
  37. package/build/recorder/hud/LinkSheet.js +15 -0
  38. package/build/recorder/hud/RecorderPill.d.ts +36 -0
  39. package/build/recorder/hud/RecorderPill.js +73 -0
  40. package/build/recorder/hud/Sheet.d.ts +20 -0
  41. package/build/recorder/hud/Sheet.js +36 -0
  42. package/build/recorder/hud/host.d.ts +27 -0
  43. package/build/recorder/hud/host.js +170 -0
  44. package/build/recorder/hud/icons.d.ts +15 -0
  45. package/build/recorder/hud/icons.js +40 -0
  46. package/build/recorder/hud/styles.d.ts +13 -0
  47. package/build/recorder/hud/styles.js +111 -0
  48. package/build/recorder/index.d.ts +46 -0
  49. package/build/recorder/index.js +61 -0
  50. package/build/recorder/link.d.ts +18 -0
  51. package/build/recorder/link.js +37 -0
  52. package/build/recorder/queue.d.ts +163 -0
  53. package/build/recorder/queue.js +642 -0
  54. package/build/recorder/session.d.ts +73 -0
  55. package/build/recorder/session.js +246 -0
  56. package/build/recorder/state.d.ts +26 -0
  57. package/build/recorder/state.js +42 -0
  58. package/build/recorder/storage/index.d.ts +35 -0
  59. package/build/recorder/storage/index.js +69 -0
  60. package/build/recorder/storage/memory.d.ts +2 -0
  61. package/build/recorder/storage/memory.js +2 -0
  62. package/package.json +77 -0
@@ -0,0 +1,246 @@
1
+ import { api, fetchPolicy, permanentStatus, VitrinkaApiError } from './api';
2
+ import { setRedactionPolicy } from './capture/redact';
3
+ import { recorderConfig } from './config';
4
+ import { armReconcile, capturesSettled, disarmReconcile, drainBuffer, getState, pushEvent, queuedCount, resetHealth, resetIdle, resetQueues, setState, } from './queue';
5
+ import { currentRoute, notify } from './state';
6
+ export { currentRoute, notify, subscribe } from './state';
7
+ /** Sent as `meta.recorder`; bumped with the package version. */
8
+ export const RECORDER_VERSION = '0.1.0';
9
+ export const RECORDER_ID = `web/${RECORDER_VERSION}`;
10
+ /**
11
+ * Re-apply a RECOVERED session's redaction policy after a reload — and
12
+ * re-FETCH it when the original fetch never settled (`policy === undefined`).
13
+ */
14
+ let policyRecoveryInFlight = false;
15
+ export function recoverRedactionPolicy() {
16
+ const rec = getState();
17
+ if (!rec)
18
+ return;
19
+ setRedactionPolicy(rec.policy ?? null);
20
+ if (rec.policy !== undefined)
21
+ return;
22
+ if (policyRecoveryInFlight)
23
+ return;
24
+ policyRecoveryInFlight = true;
25
+ void fetchPolicy()
26
+ .then((policy) => {
27
+ const live = getState();
28
+ if (live?.sessionId !== rec.sessionId)
29
+ return;
30
+ if (live.policy !== undefined)
31
+ return;
32
+ setRedactionPolicy(policy);
33
+ setState({ ...live, policy });
34
+ })
35
+ .finally(() => {
36
+ policyRecoveryInFlight = false;
37
+ });
38
+ }
39
+ export function elapsedOf(rec) {
40
+ if (!rec)
41
+ return 0;
42
+ let ms = rec.activeMs || 0;
43
+ if (!rec.paused && rec.resumeAt)
44
+ ms += Date.now() - Date.parse(rec.resumeAt);
45
+ return ms;
46
+ }
47
+ /** The document's host — the create's `host`, the same field the extension sends. */
48
+ function pageHost() {
49
+ try {
50
+ return globalThis.location?.host ?? '';
51
+ }
52
+ catch {
53
+ return '';
54
+ }
55
+ }
56
+ export async function startSession(opts = {}) {
57
+ const cfg = recorderConfig();
58
+ // The safe defaults apply from the first captured byte; the workspace
59
+ // policy (fetched in parallel — fetchPolicy never rejects) can only ADD
60
+ // rules or, self-host only, fullFidelity.
61
+ setRedactionPolicy(null);
62
+ const policyPromise = fetchPolicy();
63
+ const environment = opts.environment ?? cfg.environment;
64
+ const ses = await api('POST', '/api/v1/sessions', {
65
+ host: pageHost(),
66
+ title: opts.title || '',
67
+ ...(environment ? { environment } : {}),
68
+ meta: {
69
+ recorder: RECORDER_ID,
70
+ userAgent: globalThis.navigator?.userAgent ?? '',
71
+ platform: 'web',
72
+ ...(cfg.appVersion ? { appVersion: cfg.appVersion } : {}),
73
+ ...(opts.driver ? { driver: opts.driver } : {}),
74
+ },
75
+ });
76
+ void policyPromise.then((policy) => {
77
+ const rec = getState();
78
+ if (rec?.sessionId !== ses.id)
79
+ return;
80
+ setRedactionPolicy(policy);
81
+ setState({ ...rec, policy });
82
+ });
83
+ setState({
84
+ sessionId: ses.id,
85
+ project: ses.project,
86
+ environment: ses.environment,
87
+ title: ses.title,
88
+ ...(ses.boardUrl ? { boardUrl: ses.boardUrl } : {}),
89
+ seq: 0,
90
+ paused: false,
91
+ activeMs: 0,
92
+ resumeAt: new Date().toISOString(),
93
+ });
94
+ resetQueues();
95
+ resetHealth();
96
+ resetIdle();
97
+ await attachTags(ses.id, opts.tags);
98
+ armReconcile();
99
+ pushEvent('nav', { url: currentUrl(), route: currentRoute.pathname }, { tabId: currentRoute.tabId, tabHost: currentRoute.tabHost });
100
+ notify();
101
+ return getState();
102
+ }
103
+ function currentUrl() {
104
+ try {
105
+ return globalThis.location?.href ?? currentRoute.pathname;
106
+ }
107
+ catch {
108
+ return currentRoute.pathname;
109
+ }
110
+ }
111
+ async function attachTags(sessionId, tags) {
112
+ if (!tags?.length)
113
+ return;
114
+ try {
115
+ await api('POST', `/api/v1/sessions/${sessionId}/tags`, { tags });
116
+ }
117
+ catch (e) {
118
+ console.warn(`vitrinka: could not tag session ${sessionId} with ${tags.join(', ')}`, e);
119
+ }
120
+ }
121
+ export async function togglePause() {
122
+ const rec = getState();
123
+ if (!rec || rec.dead)
124
+ return false;
125
+ rec.paused = !rec.paused;
126
+ if (rec.paused) {
127
+ rec.activeMs = (rec.activeMs || 0) + (rec.resumeAt ? Date.now() - Date.parse(rec.resumeAt) : 0);
128
+ rec.resumeAt = null;
129
+ }
130
+ else {
131
+ rec.resumeAt = new Date().toISOString();
132
+ }
133
+ setState(rec);
134
+ notify();
135
+ await api('PATCH', `/api/v1/sessions/${rec.sessionId}`, {
136
+ status: rec.paused ? 'paused' : 'recording',
137
+ }).catch((e) => console.warn('vitrinka: pause PATCH failed', e));
138
+ return rec.paused;
139
+ }
140
+ const route = () => ({ tabId: currentRoute.tabId, tabHost: currentRoute.tabHost });
141
+ /** A plain note — `{text, route}`, the extension's shape. */
142
+ export function addNote(text) {
143
+ pushEvent('note', { text, route: currentRoute.pathname }, route());
144
+ }
145
+ /** Scale a viewport rect to device pixels — the extension's `imageRect` space. */
146
+ export function imagePixels(r) {
147
+ const s = globalThis.devicePixelRatio || 1;
148
+ return {
149
+ x: Math.round(r.x * s),
150
+ y: Math.round(r.y * s),
151
+ w: Math.round(r.w * s),
152
+ h: Math.round(r.h * s),
153
+ };
154
+ }
155
+ /**
156
+ * An annotation — the extension's annotate-note `{text, rect, selector,
157
+ * annotate: true}` (+ `task` when the tester chose the task destination);
158
+ * vitrinka projects it into a board annotation. `selector` is '' for a free
159
+ * region. The rect is in device pixels. An empty note is still a valid
160
+ * annotation, matching the extension.
161
+ */
162
+ export function addAnnotation(text, rect, selector, opts = {}) {
163
+ pushEvent('note', {
164
+ text,
165
+ rect: imagePixels(rect),
166
+ selector,
167
+ annotate: true,
168
+ route: currentRoute.pathname,
169
+ ...(opts.task ? { task: true } : {}),
170
+ }, route());
171
+ }
172
+ /**
173
+ * Hooks that run at the top of Stop, before the drain snapshot — the rrweb
174
+ * lane ships its sub-2s tail here so the last DOM events make the session.
175
+ */
176
+ const beforeStopHooks = new Set();
177
+ export function onBeforeStop(fn) {
178
+ beforeStopHooks.add(fn);
179
+ return () => beforeStopHooks.delete(fn);
180
+ }
181
+ /**
182
+ * Stop the session. Throws with the queued-item count when the server is
183
+ * unreachable — the durable tail is NEVER deleted; capture freezes paused and
184
+ * a later Stop finishes the job once online.
185
+ */
186
+ export async function stopSession() {
187
+ const rec = getState();
188
+ if (!rec)
189
+ return null;
190
+ disarmReconcile();
191
+ for (const fn of beforeStopHooks) {
192
+ try {
193
+ fn();
194
+ }
195
+ catch (e) {
196
+ console.warn('vitrinka: before-stop hook failed', e);
197
+ }
198
+ }
199
+ const completeDeadStop = (reason) => {
200
+ const kept = queuedCount();
201
+ setState(null);
202
+ setRedactionPolicy(null);
203
+ resetQueues();
204
+ notify();
205
+ throw new Error(`${reason || 'session rejected by the server'} — recording ended locally` +
206
+ (kept ? `; ${kept} undelivered item(s) discarded` : ''));
207
+ };
208
+ if (rec.dead)
209
+ completeDeadStop(rec.deadReason);
210
+ if (!(await capturesSettled())) {
211
+ console.warn('vitrinka: stopping with unsettled captures — a late event may not make this session');
212
+ }
213
+ if (!(await drainBuffer())) {
214
+ const held = getState();
215
+ if (held?.dead)
216
+ completeDeadStop(held.deadReason);
217
+ if (held && !held.paused) {
218
+ held.activeMs = elapsedOf(held);
219
+ held.paused = true;
220
+ held.resumeAt = null;
221
+ setState(held);
222
+ }
223
+ armReconcile();
224
+ notify();
225
+ throw new Error(`server unreachable — ${queuedCount()} item(s) kept; stop again once online`);
226
+ }
227
+ let done = null;
228
+ try {
229
+ done = await api('PATCH', `/api/v1/sessions/${rec.sessionId}`, { status: 'done' });
230
+ }
231
+ catch (e) {
232
+ if (e instanceof VitrinkaApiError && permanentStatus(e.status)) {
233
+ console.warn('vitrinka: stop rejected permanently — clearing local session', e);
234
+ }
235
+ else {
236
+ console.warn('vitrinka: stop PATCH failed — session kept', e);
237
+ armReconcile();
238
+ throw e;
239
+ }
240
+ }
241
+ setState(null);
242
+ setRedactionPolicy(null);
243
+ resetQueues();
244
+ notify();
245
+ return done;
246
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Shared recorder state with NO DOM or React imports, so the capture layers
3
+ * and the queue stay unit-testable under bun and `session.ts` can re-export
4
+ * it as the one import site for callers.
5
+ */
6
+ /**
7
+ * Where the recorder believes the user is. Mirrors the browser extension's
8
+ * lane vocabulary: `tabId` is a per-tab id (one timeline lane per browser
9
+ * tab), `tabHost` the page host; the route itself rides in event payloads.
10
+ */
11
+ export declare const currentRoute: {
12
+ tabId: string;
13
+ tabHost: string;
14
+ pathname: string;
15
+ };
16
+ /** Set the tab identity once per document (provider mount). */
17
+ export declare function setTabIdentity(tabId: string, tabHost: string): void;
18
+ /** Update the current pathname; returns true when it actually changed. */
19
+ export declare function setCurrentPath(pathname: string): boolean;
20
+ /** Annotate mode (element pick / region marquee); chip and overlay share it. */
21
+ export declare const annotateState: {
22
+ active: boolean;
23
+ };
24
+ export declare function setAnnotating(active: boolean): void;
25
+ export declare function subscribe(fn: () => void): () => void;
26
+ export declare function notify(): void;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Shared recorder state with NO DOM or React imports, so the capture layers
3
+ * and the queue stay unit-testable under bun and `session.ts` can re-export
4
+ * it as the one import site for callers.
5
+ */
6
+ /**
7
+ * Where the recorder believes the user is. Mirrors the browser extension's
8
+ * lane vocabulary: `tabId` is a per-tab id (one timeline lane per browser
9
+ * tab), `tabHost` the page host; the route itself rides in event payloads.
10
+ */
11
+ export const currentRoute = { tabId: 'root', tabHost: '/', pathname: '/' };
12
+ /** Set the tab identity once per document (provider mount). */
13
+ export function setTabIdentity(tabId, tabHost) {
14
+ currentRoute.tabId = tabId;
15
+ currentRoute.tabHost = tabHost;
16
+ }
17
+ /** Update the current pathname; returns true when it actually changed. */
18
+ export function setCurrentPath(pathname) {
19
+ if (currentRoute.pathname === pathname)
20
+ return false;
21
+ currentRoute.pathname = pathname;
22
+ notify();
23
+ return true;
24
+ }
25
+ /** Annotate mode (element pick / region marquee); chip and overlay share it. */
26
+ export const annotateState = { active: false };
27
+ export function setAnnotating(active) {
28
+ if (annotateState.active === active)
29
+ return;
30
+ annotateState.active = active;
31
+ notify();
32
+ }
33
+ // -- change subscription (the HUD re-renders off this) -----------------------
34
+ const listeners = new Set();
35
+ export function subscribe(fn) {
36
+ listeners.add(fn);
37
+ return () => listeners.delete(fn);
38
+ }
39
+ export function notify() {
40
+ for (const fn of listeners)
41
+ fn();
42
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Pluggable durable KV storage for the recorder's queue and session state.
3
+ *
4
+ * The queue's correctness leans on SYNCHRONOUS reads/writes: every
5
+ * read-modify-write completes in one JS tick, so no async mutex is needed and
6
+ * delivery-ack bookkeeping stays race-free. Any driver plugged in here MUST be
7
+ * synchronous — which is why the default is `localStorage` and not IndexedDB.
8
+ *
9
+ * When `localStorage` is unavailable or throws on first touch (Safari private
10
+ * mode, a sandboxed iframe, a storage-disabled profile) the recorder falls
11
+ * back to the in-memory driver: capture still works for the life of the
12
+ * document, only the reload-survival guarantee is lost — and it says so once.
13
+ */
14
+ export interface RecorderStorage {
15
+ /** Read a value; null/undefined when the key was never written. */
16
+ getString(key: string): string | null | undefined;
17
+ /** Write a value durably before returning. */
18
+ set(key: string, value: string): void;
19
+ /** Delete a key; a no-op when absent. */
20
+ remove(key: string): void;
21
+ }
22
+ /**
23
+ * Install a storage driver. Call before the recorder mounts; calling after
24
+ * first use throws — silently switching stores mid-session would strand the
25
+ * durable tail in the old one.
26
+ */
27
+ export declare function configureRecorderStorage(driver: RecorderStorage): void;
28
+ /** The active driver; lazily falls back to localStorage, then memory. */
29
+ export declare function getRecorderStorage(): RecorderStorage;
30
+ /** `localStorage` driver, or null when the API is absent or refuses a write. */
31
+ export declare function localRecorderStorage(): RecorderStorage | null;
32
+ /** In-memory driver — for tests and as the last-resort non-durable fallback. */
33
+ export declare function memoryRecorderStorage(): RecorderStorage;
34
+ /** Test-only: forget the configured driver and the first-use latch. */
35
+ export declare function __resetStorageForTests(): void;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Pluggable durable KV storage for the recorder's queue and session state.
3
+ *
4
+ * The queue's correctness leans on SYNCHRONOUS reads/writes: every
5
+ * read-modify-write completes in one JS tick, so no async mutex is needed and
6
+ * delivery-ack bookkeeping stays race-free. Any driver plugged in here MUST be
7
+ * synchronous — which is why the default is `localStorage` and not IndexedDB.
8
+ *
9
+ * When `localStorage` is unavailable or throws on first touch (Safari private
10
+ * mode, a sandboxed iframe, a storage-disabled profile) the recorder falls
11
+ * back to the in-memory driver: capture still works for the life of the
12
+ * document, only the reload-survival guarantee is lost — and it says so once.
13
+ */
14
+ // Keys land as `vitrinka.recorder.<key>`: rec · buffer · chunks · link.
15
+ const PREFIX = 'vitrinka.recorder.';
16
+ let current = null;
17
+ let used = false;
18
+ /**
19
+ * Install a storage driver. Call before the recorder mounts; calling after
20
+ * first use throws — silently switching stores mid-session would strand the
21
+ * durable tail in the old one.
22
+ */
23
+ export function configureRecorderStorage(driver) {
24
+ if (used && current !== driver) {
25
+ throw new Error('vitrinka: configureRecorderStorage() must run before the recorder first touches storage');
26
+ }
27
+ current = driver;
28
+ }
29
+ /** The active driver; lazily falls back to localStorage, then memory. */
30
+ export function getRecorderStorage() {
31
+ if (!current)
32
+ current = localRecorderStorage() ?? memoryRecorderStorage();
33
+ used = true;
34
+ return current;
35
+ }
36
+ /** `localStorage` driver, or null when the API is absent or refuses a write. */
37
+ export function localRecorderStorage() {
38
+ try {
39
+ const ls = globalThis.localStorage;
40
+ if (!ls)
41
+ return null;
42
+ const probe = `${PREFIX}probe`;
43
+ ls.setItem(probe, '1');
44
+ ls.removeItem(probe);
45
+ return {
46
+ getString: (k) => ls.getItem(PREFIX + k),
47
+ set: (k, v) => ls.setItem(PREFIX + k, v),
48
+ remove: (k) => ls.removeItem(PREFIX + k),
49
+ };
50
+ }
51
+ catch {
52
+ console.warn('vitrinka: localStorage unavailable — the recorder queue will not survive a reload');
53
+ return null;
54
+ }
55
+ }
56
+ /** In-memory driver — for tests and as the last-resort non-durable fallback. */
57
+ export function memoryRecorderStorage() {
58
+ const m = new Map();
59
+ return {
60
+ getString: (k) => m.get(k) ?? null,
61
+ set: (k, v) => void m.set(k, v),
62
+ remove: (k) => void m.delete(k),
63
+ };
64
+ }
65
+ /** Test-only: forget the configured driver and the first-use latch. */
66
+ export function __resetStorageForTests() {
67
+ current = null;
68
+ used = false;
69
+ }
@@ -0,0 +1,2 @@
1
+ /** The in-memory driver on its own import path (mirrors expo's layout). */
2
+ export { memoryRecorderStorage } from './index';
@@ -0,0 +1,2 @@
1
+ /** The in-memory driver on its own import path (mirrors expo's layout). */
2
+ export { memoryRecorderStorage } from './index';
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "@vitrinka/web",
3
+ "version": "0.1.0",
4
+ "description": "vitrinka toolkit for React DOM apps — the journey recorder: capture manual-testing sessions (rrweb DOM stream, clicks, navigation, network, console, notes) straight into vitrinka boards.",
5
+ "license": "Elastic-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/henderson-tech/vitrinka-kit.git",
9
+ "directory": "packages/web"
10
+ },
11
+ "type": "module",
12
+ "source": "src/index.ts",
13
+ "main": "build/index.js",
14
+ "types": "build/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./build/index.d.ts",
18
+ "default": "./build/index.js"
19
+ },
20
+ "./recorder": {
21
+ "types": "./build/recorder/index.d.ts",
22
+ "default": "./build/recorder/index.js"
23
+ },
24
+ "./protocol": {
25
+ "types": "./build/protocol/index.d.ts",
26
+ "default": "./build/protocol/index.js"
27
+ },
28
+ "./next": {
29
+ "types": "./build/next.d.ts",
30
+ "default": "./build/next.js"
31
+ },
32
+ "./package.json": "./package.json"
33
+ },
34
+ "files": [
35
+ "build",
36
+ "README.md",
37
+ "CHANGELOG.md"
38
+ ],
39
+ "sideEffects": false,
40
+ "scripts": {
41
+ "build": "tsc -p tsconfig.build.json",
42
+ "typecheck": "tsc --noEmit",
43
+ "test": "bun test src",
44
+ "test:e2e": "playwright test -c playwright.config.ts --workers 1",
45
+ "prepublishOnly": "tsc -p tsconfig.build.json"
46
+ },
47
+ "dependencies": {
48
+ "@vitrinka/link": "^0.1.0",
49
+ "@vitrinka/redact": "^0.1.0"
50
+ },
51
+ "peerDependencies": {
52
+ "react": ">=18",
53
+ "react-dom": ">=18",
54
+ "rrweb": "^2.0.0"
55
+ },
56
+ "devDependencies": {
57
+ "@rrweb/types": "^2.0.0",
58
+ "@types/bun": "^1.2.0",
59
+ "@types/node": "^22.0.0",
60
+ "@types/react": "19.2.14",
61
+ "@types/react-dom": "^19.2.0",
62
+ "react": "19.2.3",
63
+ "react-dom": "19.2.3",
64
+ "rrweb": "^2.0.0",
65
+ "typescript": "~6.0.3"
66
+ },
67
+ "keywords": [
68
+ "vitrinka",
69
+ "react",
70
+ "nextjs",
71
+ "recorder",
72
+ "rrweb",
73
+ "testing",
74
+ "devtools",
75
+ "session-recording"
76
+ ]
77
+ }