@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,61 @@
1
+ /**
2
+ * Vitrinka journey recorder for React DOM apps — `@vitrinka/web/recorder`.
3
+ *
4
+ * The runtime strip: when `url` is empty, `VitrinkaRecorderRoot` renders its
5
+ * children and starts NOTHING, and `VitrinkaRecorderPill` renders null. Auth
6
+ * is a device link minted from the pill (`@vitrinka/link`) or, for CI and
7
+ * unattended builds, an explicit `recorderKey`; `withVitrinkaRecorder`
8
+ * (`@vitrinka/web/next`) refuses a production build that carries a baked key
9
+ * outside an allowed lane.
10
+ */
11
+ import { createElement, Fragment, useEffect, useRef } from 'react';
12
+ import { createRoot } from 'react-dom/client';
13
+ import { envConfig, vitrinkaConfigured } from './config';
14
+ import { createHudHost } from './hud/host';
15
+ import { Hud } from './hud/Hud';
16
+ import { RecorderProvider, useRecorderRoute } from './RecorderProvider';
17
+ export { useRecorderRoute };
18
+ export { configureRecorderStorage } from './storage';
19
+ /** Wrap the app root. Starts the capture lanes only when `url` + `key` are present. */
20
+ export function VitrinkaRecorderRoot(props) {
21
+ const env = envConfig();
22
+ const url = props.url ?? env.url;
23
+ const key = props.recorderKey ?? env.key;
24
+ // The strip: the URL alone enables the recorder; auth is a key or the device link.
25
+ if (!url)
26
+ return createElement(Fragment, null, props.children);
27
+ return createElement(RecorderProvider, {
28
+ config: { url, key, appVersion: props.appVersion, environment: props.environment, label: props.label },
29
+ }, createElement(RouteFeed, { route: props.route }), props.children);
30
+ }
31
+ function RouteFeed({ route }) {
32
+ useRecorderRoute(route);
33
+ return null;
34
+ }
35
+ /**
36
+ * The HUD — mount it anywhere under the root (it renders into its own shadow
37
+ * host on `<html>`, never into the app's DOM). Renders null when the root is
38
+ * inert.
39
+ */
40
+ export function VitrinkaRecorderPill(props) {
41
+ const rootRef = useRef(null);
42
+ const titleRef = useRef(props.title);
43
+ titleRef.current = props.title;
44
+ useEffect(() => {
45
+ if (!vitrinkaConfigured())
46
+ return;
47
+ const host = createHudHost();
48
+ const root = createRoot(host.mount);
49
+ rootRef.current = root;
50
+ root.render(createElement(Hud, {
51
+ hostMount: host.mount,
52
+ defaultTitle: () => titleRef.current ?? document.title,
53
+ }));
54
+ return () => {
55
+ root.unmount();
56
+ host.destroy();
57
+ rootRef.current = null;
58
+ };
59
+ }, []);
60
+ return null;
61
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The device link, wired to the recorder: start a code, poll for approval,
3
+ * store the token; forget it on Unlink or when a session door answers 401.
4
+ */
5
+ import { type Linked, type LinkStart } from '@vitrinka/link';
6
+ export { LinkExpired } from '@vitrinka/link';
7
+ export interface DeviceLink {
8
+ start: LinkStart;
9
+ /** Resolves once the tester approved; rejects with LinkExpired / AbortError. */
10
+ linked: Promise<Linked>;
11
+ cancel: () => void;
12
+ }
13
+ /** Ask for a code and poll until approved. The token is stored on success. */
14
+ export declare function linkDevice(): Promise<DeviceLink>;
15
+ /** Forget the stored link; a live session ends locally (its tail is dropped). */
16
+ export declare function forgetLink(): void;
17
+ /** Register the 401 → unlinked transition. Returns the uninstaller. */
18
+ export declare function installUnauthorizedHandler(): () => void;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The device link, wired to the recorder: start a code, poll for approval,
3
+ * store the token; forget it on Unlink or when a session door answers 401.
4
+ */
5
+ import { pollLink, startLink } from '@vitrinka/link';
6
+ import { onUnauthorized } from './api';
7
+ import { clearLink, defaultLinkLabel, recorderConfig, storeLink } from './config';
8
+ import { getState, resetQueues, setState } from './queue';
9
+ import { notify } from './state';
10
+ export { LinkExpired } from '@vitrinka/link';
11
+ /** Ask for a code and poll until approved. The token is stored on success. */
12
+ export async function linkDevice() {
13
+ const { url } = recorderConfig();
14
+ const start = await startLink(url, { label: defaultLinkLabel() });
15
+ const ac = new AbortController();
16
+ const linked = pollLink(url, start.device_code, { interval: start.interval, signal: ac.signal }).then((l) => {
17
+ storeLink(l);
18
+ return l;
19
+ });
20
+ return { start, linked, cancel: () => ac.abort() };
21
+ }
22
+ /** Forget the stored link; a live session ends locally (its tail is dropped). */
23
+ export function forgetLink() {
24
+ clearLink();
25
+ if (getState()) {
26
+ setState(null);
27
+ resetQueues();
28
+ }
29
+ notify();
30
+ }
31
+ /** Register the 401 → unlinked transition. Returns the uninstaller. */
32
+ export function installUnauthorizedHandler() {
33
+ return onUnauthorized(() => {
34
+ console.warn('vitrinka: recorder token rejected (401) — link the device again');
35
+ forgetLink();
36
+ });
37
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Durable event queue — a port of the Expo recorder's queue (itself a port of
3
+ * the browser extension's design), adapted to the browser:
4
+ *
5
+ * - A synchronous KV store (./storage — localStorage by default, memory when
6
+ * that is unavailable) holds the session record and the event buffer:
7
+ * reads/writes complete in one JS tick, so no async mutex is needed. Only
8
+ * flush() needs single-flight guarding (its POST is async) and it removes
9
+ * exactly the sent seqs on return.
10
+ * - rrweb batches ride as CHUNKS the way the extension sends them: each batch
11
+ * is serialized once, split under the server's chunk cap, uploaded to
12
+ * `/chunk?seq=N` under a pre-allocated seq, and only then does its `rrweb`
13
+ * event row (payload {count}, blobKey) join the event stream — keeping its
14
+ * originally allocated seq (gap-fill), retried oldest-first AHEAD of the
15
+ * event flush. Chunks are kept in memory (they can be megabytes; the KV
16
+ * store is small) and persisted best-effort under a byte budget.
17
+ * - A permanent server verdict (4xx minus 408/429) drops the item loudly;
18
+ * transient failures stop the pass and the next flush retries.
19
+ * - A reload keeps the undelivered event tail (KV store is durable).
20
+ * - Reconciliation (extension D5/D9): a 10s poll asks the server what it
21
+ * actually holds (`serverMaxSeq`) and whether the session still exists.
22
+ * Only the SERVER's verdict (404 / done / deleted / permanent events
23
+ * rejection) marks a session dead — flushing then stops instead of
24
+ * retrying into a session that can never accept another event.
25
+ * - health() (extension D4): honest by construction — "synced" means the
26
+ * server confirmed it holds everything this recorder allocated, not
27
+ * merely "my last POST returned 200".
28
+ */
29
+ import type { RedactionPolicy } from '@vitrinka/redact';
30
+ import type { RecorderEvent } from '../protocol';
31
+ /** How often a live session reconciles against the server (extension D5). */
32
+ export declare const RECONCILE_MS = 10000;
33
+ export interface SessionState {
34
+ sessionId: string;
35
+ project: string;
36
+ environment: string;
37
+ title: string;
38
+ /** Server-minted board link; "Open board" uses it verbatim. */
39
+ boardUrl?: string;
40
+ seq: number;
41
+ paused: boolean;
42
+ /** Active-time bookkeeping: elapsed = activeMs + (now - resumeAt while running). */
43
+ activeMs: number;
44
+ resumeAt: string | null;
45
+ /**
46
+ * The SERVER will not accept this session's events any more (extension D9):
47
+ * 404 / done / deleted from the reconcile poll, or a permanent verdict on
48
+ * the events POST. Capture and flushing stop; Stop completes locally.
49
+ * Durable so a reload cannot resurrect the pointless retry loop.
50
+ */
51
+ dead?: boolean;
52
+ deadReason?: string;
53
+ /**
54
+ * The workspace redaction policy fetched at session start (null = fetch
55
+ * failed ⇒ the engine's safe defaults). Durable WITH the session so a
56
+ * reload re-applies the same rules instead of silently reverting.
57
+ */
58
+ policy?: RedactionPolicy | null;
59
+ }
60
+ export type { RecorderEvent };
61
+ /**
62
+ * The LIVE session record — a mutable alias of the cache, not a copy. Treat it
63
+ * as READ-ONLY unless you pass the object you mutated straight to `setState()`
64
+ * in the same tick.
65
+ */
66
+ export declare function getState(): SessionState | null;
67
+ export declare function setState(rec: SessionState | null): void;
68
+ /** Write the in-memory buffer (and the pending chunks) to storage NOW. */
69
+ export declare function persistNow(): void;
70
+ export declare function queuedCount(): number;
71
+ /** Fresh-session baseline; called by startSession before capture begins. */
72
+ export declare function resetHealth(baseSeq?: number): void;
73
+ export type RecorderHealthState = 'idle' | 'ok' | 'backlog' | 'offline' | 'dead';
74
+ export interface RecorderHealth {
75
+ state: RecorderHealthState;
76
+ queued: number;
77
+ failures: number;
78
+ error: string;
79
+ sinceSyncMs: number | null;
80
+ localSeq: number;
81
+ serverMaxSeq: number;
82
+ /** The reconciliation itself: the server accounts for every allocated seq. */
83
+ synced: boolean;
84
+ deadReason: string;
85
+ }
86
+ export declare function health(): RecorderHealth;
87
+ /**
88
+ * Record that the SERVER will not accept this session's events any more
89
+ * (extension D9). Freezes the HUD clock and stops the retry loop; the durable
90
+ * tail stays until Stop.
91
+ */
92
+ export declare function markSessionDead(reason: string): void;
93
+ /** Ask the server what it actually holds (extension D5/D9). */
94
+ export declare function reconcile(): Promise<void>;
95
+ export declare function armReconcile(): void;
96
+ export declare function disarmReconcile(): void;
97
+ /** Allocate `count` consecutive seqs without emitting events; null when not capturing. */
98
+ export declare function allocSeq(count?: number): {
99
+ seq: number;
100
+ sessionId: string;
101
+ } | null;
102
+ export declare function trackCapture(p: Promise<void>): Promise<void>;
103
+ export declare const CAPTURES_SETTLE_MS = 5000;
104
+ /** Wait for in-flight captures, bounded; false when stragglers were abandoned. */
105
+ export declare function capturesSettled(deadlineMs?: number): Promise<boolean>;
106
+ /** Is `id` the session currently in state AND still accepting capture? */
107
+ export declare function isSessionLive(id: string): boolean;
108
+ /**
109
+ * Append an event to the durable buffer (drops when no live session or
110
+ * paused). `tabId`/`tabHost`/`ts`/`seq` are filled here; capture layers pass
111
+ * kind+payload plus the route they observed. Returns the stamped ts (null
112
+ * when dropped).
113
+ */
114
+ export declare function pushEvent(kind: string, payload: Record<string, unknown> | undefined, route: {
115
+ tabId: string;
116
+ tabHost: string;
117
+ }): string | null;
118
+ /** Push a fully-formed event (chunk rows carry a pre-allocated seq). */
119
+ export declare function pushRawEvent(ev: RecorderEvent): void;
120
+ export declare function idleMs(): number;
121
+ export declare function resetIdle(): void;
122
+ /**
123
+ * Split a batch of rrweb events into size-bounded, in-order parts (extension
124
+ * `splitRRWebEvents`). Returns the serialized bodies and the byte sizes of
125
+ * events that are alone beyond the wire cap (undeliverable).
126
+ */
127
+ export declare function splitRRWebEvents(events: readonly unknown[], packBytes?: number, hardBytes?: number): {
128
+ parts: {
129
+ count: number;
130
+ body: string;
131
+ }[];
132
+ dropped: number[];
133
+ };
134
+ /**
135
+ * Queue a batch of rrweb events as chunks under pre-allocated seqs. Dropped
136
+ * (undeliverable) events surface as a ⚠ note on the timeline, as the
137
+ * extension does. Returns the number of chunks queued (0 when not capturing).
138
+ */
139
+ export declare function pushRRWebBatch(events: readonly unknown[], route: {
140
+ tabId: string;
141
+ tabHost: string;
142
+ }): number;
143
+ export declare function scheduleFlush(): void;
144
+ /** Single-flight flush; true when the events POST succeeded (or nothing to send). */
145
+ export declare function flush(opts?: {
146
+ keepalive?: boolean;
147
+ }): Promise<boolean>;
148
+ /** Drain until buffer + chunks are empty or the deadline passes. */
149
+ export declare function drainBuffer(deadlineMs?: number): Promise<boolean>;
150
+ /** Reset buffers for a fresh session. */
151
+ export declare function resetQueues(): void;
152
+ /** Test-only: drop all module state so suites cannot leak into each other. */
153
+ export declare function __resetForTests(): void;
154
+ /** Test-only: the recorded events currently buffered (in order). */
155
+ export declare function __bufferForTests(): RecorderEvent[];
156
+ /** Test-only: the pending chunks (in order). */
157
+ export declare function __chunksForTests(): {
158
+ seq: number;
159
+ count: number;
160
+ sessionId: string;
161
+ }[];
162
+ /** Test-only: drop the in-memory caches while LEAVING storage intact (a reload). */
163
+ export declare function __dropCachesForTests(): void;