@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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# @vitrinka/web
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
- Initial release: the React DOM journey recorder (`@vitrinka/web/recorder`)
|
|
6
|
+
— rrweb DOM stream (chunked like the browser extension), DOM clicks,
|
|
7
|
+
navigation, `console.error` / uncaught errors, fetch/XHR with
|
|
8
|
+
`@vitrinka/redact`; the pill HUD in a top-layer shadow host with notes,
|
|
9
|
+
element/region annotations and keyboard shortcuts; `window.__vitrinkaRecorder`
|
|
10
|
+
control handle; `withVitrinkaRecorder` (`@vitrinka/web/next`) build guard.
|
|
11
|
+
- Device link: no key needed — the pill links the device (`@vitrinka/link`),
|
|
12
|
+
stores the minted token under `vitrinka.recorder.link`; `recorderKey` is
|
|
13
|
+
for CI. The runtime strip is the URL alone.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Elastic License 2.0
|
|
2
|
+
|
|
3
|
+
URL: https://www.elastic.co/licensing/elastic-license
|
|
4
|
+
|
|
5
|
+
## Acceptance
|
|
6
|
+
|
|
7
|
+
By using the software, you agree to all of the terms and conditions below.
|
|
8
|
+
|
|
9
|
+
## Copyright License
|
|
10
|
+
|
|
11
|
+
The licensor grants you a non-exclusive, royalty-free, worldwide,
|
|
12
|
+
non-sublicensable, non-transferable license to use, copy, distribute, make
|
|
13
|
+
available, and prepare derivative works of the software, in each case subject to
|
|
14
|
+
the limitations and conditions below.
|
|
15
|
+
|
|
16
|
+
## Limitations
|
|
17
|
+
|
|
18
|
+
You may not provide the software to third parties as a hosted or managed
|
|
19
|
+
service, where the service provides users with access to any substantial set of
|
|
20
|
+
the features or functionality of the software.
|
|
21
|
+
|
|
22
|
+
You may not move, change, disable, or circumvent the license key functionality
|
|
23
|
+
in the software, and you may not remove or obscure any functionality in the
|
|
24
|
+
software that is protected by the license key.
|
|
25
|
+
|
|
26
|
+
You may not alter, remove, or obscure any licensing, copyright, or other notices
|
|
27
|
+
of the licensor in the software. Any use of the licensor’s trademarks is subject
|
|
28
|
+
to applicable law.
|
|
29
|
+
|
|
30
|
+
## Patents
|
|
31
|
+
|
|
32
|
+
The licensor grants you a license, under any patent claims the licensor can
|
|
33
|
+
license, or becomes able to license, to make, have made, use, sell, offer for
|
|
34
|
+
sale, import and have imported the software, in each case subject to the
|
|
35
|
+
limitations and conditions in this license. This license does not cover any
|
|
36
|
+
patent claims that you cause to be infringed by modifications or additions to
|
|
37
|
+
the software. If you or your company make any written claim that the software
|
|
38
|
+
infringes or contributes to infringement of any patent, your patent license for
|
|
39
|
+
the software granted under these terms ends immediately. If your company makes
|
|
40
|
+
such a claim, your patent license ends immediately for work on behalf of your
|
|
41
|
+
company.
|
|
42
|
+
|
|
43
|
+
## Notices
|
|
44
|
+
|
|
45
|
+
You must ensure that anyone who gets a copy of any part of the software from you
|
|
46
|
+
also gets a copy of these terms.
|
|
47
|
+
|
|
48
|
+
If you modify the software, you must include in any modified copies of the
|
|
49
|
+
software prominent notices stating that you have modified the software.
|
|
50
|
+
|
|
51
|
+
## No Other Rights
|
|
52
|
+
|
|
53
|
+
These terms do not imply any licenses other than those expressly granted in
|
|
54
|
+
these terms.
|
|
55
|
+
|
|
56
|
+
## Termination
|
|
57
|
+
|
|
58
|
+
If you use the software in violation of these terms, such use is not licensed,
|
|
59
|
+
and your licenses will automatically terminate. If the licensor provides you
|
|
60
|
+
with a notice of your violation, and you cease all violation of this license no
|
|
61
|
+
later than 30 days after you receive that notice, your licenses will be
|
|
62
|
+
reinstated retroactively. However, if you violate these terms after such
|
|
63
|
+
reinstatement, any additional violation of these terms will cause your licenses
|
|
64
|
+
to terminate automatically and permanently.
|
|
65
|
+
|
|
66
|
+
## No Liability
|
|
67
|
+
|
|
68
|
+
*As far as the law allows, the software comes as is, without any warranty or
|
|
69
|
+
condition, and the licensor will not be liable to you for any damages arising
|
|
70
|
+
out of these terms or the use or nature of the software, under any kind of
|
|
71
|
+
legal claim.*
|
|
72
|
+
|
|
73
|
+
## Definitions
|
|
74
|
+
|
|
75
|
+
The **licensor** is the entity offering these terms, and the **software** is the
|
|
76
|
+
software the licensor makes available under these terms, including any portion
|
|
77
|
+
of it.
|
|
78
|
+
|
|
79
|
+
**you** refers to the individual or entity agreeing to these terms.
|
|
80
|
+
|
|
81
|
+
**your company** is any legal entity, sole proprietorship, or other kind of
|
|
82
|
+
organization that you work for, plus all organizations that have control over,
|
|
83
|
+
are under the control of, or are under common control with that organization.
|
|
84
|
+
**control** means ownership of substantially all the assets of an entity, or the
|
|
85
|
+
power to direct its management and policies by vote, contract, or otherwise.
|
|
86
|
+
Control can be direct or indirect.
|
|
87
|
+
|
|
88
|
+
**your licenses** are all the licenses granted to you for the software under
|
|
89
|
+
these terms.
|
|
90
|
+
|
|
91
|
+
**use** means anything you do with the software requiring one of your licenses.
|
|
92
|
+
|
|
93
|
+
**trademark** means trademarks, service marks, and similar rights.
|
package/README.md
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# @vitrinka/web
|
|
2
|
+
|
|
3
|
+
The vitrinka toolkit for React DOM apps — today, the **journey recorder**:
|
|
4
|
+
record a manual-testing session straight from the app under test into a
|
|
5
|
+
vitrinka board. The web sibling of [`@vitrinka/expo`](../expo) and of the
|
|
6
|
+
browser extension: same wire protocol, same redaction engine, no extension
|
|
7
|
+
to install.
|
|
8
|
+
|
|
9
|
+
What a session carries: the **rrweb DOM stream** (the keyframe — no
|
|
10
|
+
screenshots), **clicks** (selector, text, rect), **navigation**, **network**
|
|
11
|
+
(fetch + XHR, headers + bodies redacted), **console errors**, and the
|
|
12
|
+
**notes and annotations** you type in the pill. Everything is redacted by
|
|
13
|
+
[`@vitrinka/redact`](../redact) before it is buffered; the full contract is
|
|
14
|
+
[`docs/PROTOCOL.md`](../../docs/PROTOCOL.md).
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
bun add @vitrinka/web rrweb
|
|
20
|
+
# or: npm i @vitrinka/web rrweb
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Peer deps: `react` ≥ 18, `react-dom` ≥ 18, `rrweb` ^2.
|
|
24
|
+
|
|
25
|
+
## Mount (Next.js app router)
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
// app/layout.tsx
|
|
29
|
+
import { VitrinkaRecorderRoot, VitrinkaRecorderPill } from '@vitrinka/web/recorder';
|
|
30
|
+
|
|
31
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
32
|
+
return (
|
|
33
|
+
<html lang="en">
|
|
34
|
+
<body>
|
|
35
|
+
<VitrinkaRecorderRoot
|
|
36
|
+
url={process.env.NEXT_PUBLIC_VITRINKA_URL}
|
|
37
|
+
recorderKey={process.env.NEXT_PUBLIC_VITRINKA_KEY}
|
|
38
|
+
appVersion={process.env.NEXT_PUBLIC_APP_VERSION}
|
|
39
|
+
>
|
|
40
|
+
{children}
|
|
41
|
+
<VitrinkaRecorderPill />
|
|
42
|
+
</VitrinkaRecorderRoot>
|
|
43
|
+
</body>
|
|
44
|
+
</html>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`VitrinkaRecorderRoot` installs the capture lanes; `VitrinkaRecorderPill`
|
|
50
|
+
renders the HUD into its own shadow host on `<html>` (top layer, never inside
|
|
51
|
+
your DOM). **When `url` is empty the root renders its children and starts
|
|
52
|
+
nothing** — a build without the URL carries an inert recorder.
|
|
53
|
+
|
|
54
|
+
**No key is needed.** Dev/preview builds set only `NEXT_PUBLIC_VITRINKA_URL`;
|
|
55
|
+
testers **link their device from the pill**: the pill shows **Link
|
|
56
|
+
recorder**, the sheet shows a short code, **Open vitrinka** (approve on this
|
|
57
|
+
device) and a QR (scan from your phone); once approved the server mints an
|
|
58
|
+
ingest-only `vkr_` token, stored in `localStorage` under
|
|
59
|
+
`vitrinka.recorder.link`, and recording starts. **Unlink** in the ⋯ menu
|
|
60
|
+
forgets it — so does a 401 from the server. `recorderKey` (an admin-minted
|
|
61
|
+
`vkr_` key) is for CI, e2e and unattended builds only; when passed it wins
|
|
62
|
+
over the link. The prop is `recorderKey`, not `key`: React reserves `key`
|
|
63
|
+
and never delivers it to a component.
|
|
64
|
+
|
|
65
|
+
Navigation is observed through `history.pushState` / `replaceState` /
|
|
66
|
+
`popstate`, which covers Next, React Router and friends. A router that
|
|
67
|
+
navigates without History can feed its pathname instead:
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
import { usePathname } from 'next/navigation';
|
|
71
|
+
import { useRecorderRoute } from '@vitrinka/web/recorder';
|
|
72
|
+
|
|
73
|
+
function RouteFeed() {
|
|
74
|
+
useRecorderRoute(usePathname());
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Vite and plain React work the same way — pass `url` / `recorderKey` from
|
|
80
|
+
`import.meta.env` (the `NEXT_PUBLIC_*` fallback only applies where a
|
|
81
|
+
bundler inlines `process.env`).
|
|
82
|
+
|
|
83
|
+
### Env vars
|
|
84
|
+
|
|
85
|
+
| Var | What |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `NEXT_PUBLIC_VITRINKA_URL` | Your vitrinka server, e.g. `https://app.vitrinka.ai` |
|
|
88
|
+
| `NEXT_PUBLIC_VITRINKA_KEY` | Optional — CI / unattended only. A **`vkr_` recorder key** minted in vitrinka under Settings → project → Recorder keys: project-pinned, origin-allowlisted, valid only on the session routes — never a `vkp_`/`vks_` API key. Testers link instead. |
|
|
89
|
+
| `VITRINKA_RECORDER_LANE` | The build's lane for the guard below (`development`, `preview`, …) |
|
|
90
|
+
|
|
91
|
+
### The build guard
|
|
92
|
+
|
|
93
|
+
A production build must never ship the recorder by accident. Wrap your Next
|
|
94
|
+
config:
|
|
95
|
+
|
|
96
|
+
```js
|
|
97
|
+
// next.config.js
|
|
98
|
+
const { withVitrinkaRecorder } = require('@vitrinka/web/next');
|
|
99
|
+
module.exports = withVitrinkaRecorder({ /* your config */ });
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`next build` (`NODE_ENV=production`) then **refuses** when a baked
|
|
103
|
+
`NEXT_PUBLIC_VITRINKA_KEY` is set and `VITRINKA_RECORDER_LANE` is not one of
|
|
104
|
+
the allowed lanes — the URL alone is allowed everywhere (dev/preview builds
|
|
105
|
+
set only the URL; testers link from the pill; a key is for CI) (default `development`, `preview`; override with
|
|
106
|
+
`allowedLanes`, and the var names with `laneVar` / `keyVar`). Unset the key
|
|
107
|
+
for the production lane, or set the lane on the preview one. The config is
|
|
108
|
+
returned unchanged otherwise.
|
|
109
|
+
|
|
110
|
+
## Using the pill
|
|
111
|
+
|
|
112
|
+
Unlinked: **Link recorder** bottom-right. Linked and idle: a quiet dot — click to **start** (the session title is
|
|
113
|
+
`document.title`, or the `title` prop). Recording: rec dot · timer · name ·
|
|
114
|
+
sync glyph · controls (keycaps show on hover):
|
|
115
|
+
|
|
116
|
+
| Control | Shortcut | What |
|
|
117
|
+
|---|---|---|
|
|
118
|
+
| ⏸ Pause / ▶ Resume | ⌥⇧P (Alt⇧P) | freezes the clock and capture |
|
|
119
|
+
| ✎ Note | ⌥⇧N | the 360px sheet — Enter sends, ⇧Enter newline, Esc / ✕ / click-outside cancel (the draft survives a cancel) |
|
|
120
|
+
| ⌖ Annotate | ⌥⇧A | click an element or drag a region, then describe it; `board` (an annotation on the board) or `task` (also filed as an intake draft) |
|
|
121
|
+
| ⋯ | | **Open board** (the server-minted link) · **Stop recording** · **Unlink** |
|
|
122
|
+
|
|
123
|
+
The sync glyph is honest: ✓ means the server confirmed it holds everything
|
|
124
|
+
captured; a second line unfolds only for a backlog, an outage (`offline ·
|
|
125
|
+
N held · retrying` — nothing is dropped, the tail is kept in `localStorage`)
|
|
126
|
+
or a session the server closed. Stop drains first and refuses while the
|
|
127
|
+
server is unreachable — stop again once online.
|
|
128
|
+
|
|
129
|
+
The sheet renders inside the topmost open dialog when one exists, so a
|
|
130
|
+
Radix focus trap or a `<dialog>.showModal()` never fights it, and nothing
|
|
131
|
+
you do on the pill reaches the page (a "close on outside click" never fires
|
|
132
|
+
because of the recorder).
|
|
133
|
+
|
|
134
|
+
## Driving it from code
|
|
135
|
+
|
|
136
|
+
`window.__vitrinkaRecorder` — for agents and tests:
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
await __vitrinkaRecorder.start({ title: 'checkout', tags: ['ai'] });
|
|
140
|
+
__vitrinkaRecorder.note('price flashes on hover');
|
|
141
|
+
const { boardUrl } = await __vitrinkaRecorder.stop();
|
|
142
|
+
__vitrinkaRecorder.status(); // { recording, sessionId, elapsedMs, queued, synced, … }
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## What is captured, exactly
|
|
146
|
+
|
|
147
|
+
| Lane | Event `kind` | Payload |
|
|
148
|
+
|---|---|---|
|
|
149
|
+
| rrweb | `rrweb` | `{count}` + `blobKey` — the batch itself is uploaded as a chunk; inputs always masked, all text under a `maskAllText` policy |
|
|
150
|
+
| clicks | `click` | `{selector, text, rect, route}` |
|
|
151
|
+
| navigation | `nav` | `{url, route, spa}` |
|
|
152
|
+
| network | `net` | `{method, url, status, ms, reqHeaders, resHeaders, reqBody, resBody, via}` — redacted, capped at 64 KiB per body; the recorder's own uploads are never recorded |
|
|
153
|
+
| console | `console` | `{level: 'error', text}` |
|
|
154
|
+
| notes | `note` | `{text, route}`; annotations add `{rect, selector, annotate: true, task?}` |
|
|
155
|
+
|
|
156
|
+
Data goes to your vitrinka server only. Details, the redaction rules and the
|
|
157
|
+
policy fetch: [`docs/PROTOCOL.md`](../../docs/PROTOCOL.md).
|
|
158
|
+
|
|
159
|
+
## Storage
|
|
160
|
+
|
|
161
|
+
The undelivered tail lives in `localStorage` (a recording survives a reload
|
|
162
|
+
of its tab). Where that is unavailable (private mode, a storage-disabled
|
|
163
|
+
profile) the recorder falls back to memory and says so once; plug your own
|
|
164
|
+
synchronous driver with `configureRecorderStorage()` before mounting.
|
|
165
|
+
|
|
166
|
+
## Development
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
bun run --filter '@vitrinka/redact' build # the workspace dep resolves through build/
|
|
170
|
+
bun run --filter '@vitrinka/web' typecheck
|
|
171
|
+
bun run --filter '@vitrinka/web' test # bun test src
|
|
172
|
+
bun run test:e2e:web # one headless Chromium spec (packages/web/e2e)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
A release is a tag: bump `version` here (and `RECORDER_VERSION` in
|
|
176
|
+
`src/recorder/session.ts`), merge, then `git tag web-vX.Y.Z && git push
|
|
177
|
+
origin web-vX.Y.Z`.
|
package/build/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitrinka/web — the vitrinka toolkit for React DOM apps.
|
|
3
|
+
*
|
|
4
|
+
* The root export carries only the wire protocol types; the tools live on
|
|
5
|
+
* subpaths so an app bundles exactly what it imports:
|
|
6
|
+
*
|
|
7
|
+
* @vitrinka/web/recorder journey recorder (VitrinkaRecorderRoot, VitrinkaRecorderPill)
|
|
8
|
+
* @vitrinka/web/next withVitrinkaRecorder (next.config.js build guard, Node-only)
|
|
9
|
+
* @vitrinka/web/protocol recorder↔server wire types
|
|
10
|
+
*/
|
|
11
|
+
export type * from './protocol';
|
package/build/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/build/next.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `withVitrinkaRecorder` — the build guard for Next apps (`next.config.js`).
|
|
3
|
+
* Node-only, no React import.
|
|
4
|
+
*
|
|
5
|
+
* The recorder ships in any build whose env carries the key; this guard is
|
|
6
|
+
* the safety boundary: a PRODUCTION build (`NODE_ENV=production`) that has
|
|
7
|
+
* the key var set must also declare an allowed lane in `VITRINKA_RECORDER_LANE`
|
|
8
|
+
* (default: `development` or `preview`), or the build refuses with a clear
|
|
9
|
+
* error. The URL alone is allowed everywhere (testers link from the pill); the
|
|
10
|
+
* config is returned unchanged otherwise.
|
|
11
|
+
*/
|
|
12
|
+
export interface WithVitrinkaRecorderOptions {
|
|
13
|
+
/** Lanes a production build may carry the recorder key in. */
|
|
14
|
+
allowedLanes?: readonly string[];
|
|
15
|
+
/** Env var naming the build's lane. */
|
|
16
|
+
laneVar?: string;
|
|
17
|
+
/** Env var carrying the recorder key. */
|
|
18
|
+
keyVar?: string;
|
|
19
|
+
/** Env var carrying the vitrinka URL (informational — only the key gates). */
|
|
20
|
+
urlVar?: string;
|
|
21
|
+
}
|
|
22
|
+
export declare const DEFAULT_ALLOWED_LANES: readonly string[];
|
|
23
|
+
export declare function withVitrinkaRecorder<T>(nextConfig: T, opts?: WithVitrinkaRecorderOptions): T;
|
package/build/next.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `withVitrinkaRecorder` — the build guard for Next apps (`next.config.js`).
|
|
3
|
+
* Node-only, no React import.
|
|
4
|
+
*
|
|
5
|
+
* The recorder ships in any build whose env carries the key; this guard is
|
|
6
|
+
* the safety boundary: a PRODUCTION build (`NODE_ENV=production`) that has
|
|
7
|
+
* the key var set must also declare an allowed lane in `VITRINKA_RECORDER_LANE`
|
|
8
|
+
* (default: `development` or `preview`), or the build refuses with a clear
|
|
9
|
+
* error. The URL alone is allowed everywhere (testers link from the pill); the
|
|
10
|
+
* config is returned unchanged otherwise.
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_ALLOWED_LANES = ['development', 'preview'];
|
|
13
|
+
export function withVitrinkaRecorder(nextConfig, opts = {}) {
|
|
14
|
+
const allowed = opts.allowedLanes ?? DEFAULT_ALLOWED_LANES;
|
|
15
|
+
const laneVar = opts.laneVar ?? 'VITRINKA_RECORDER_LANE';
|
|
16
|
+
const keyVar = opts.keyVar ?? 'NEXT_PUBLIC_VITRINKA_KEY';
|
|
17
|
+
const env = process.env;
|
|
18
|
+
if (env.NODE_ENV === 'production' && env[keyVar]) {
|
|
19
|
+
const lane = env[laneVar] ?? '';
|
|
20
|
+
if (!allowed.includes(lane)) {
|
|
21
|
+
throw new Error(`vitrinka: ${keyVar} is set on a production build but ${laneVar}=${JSON.stringify(lane)} ` +
|
|
22
|
+
`is not an allowed recorder lane (${allowed.join(', ')}). ` +
|
|
23
|
+
`Unset ${keyVar} for this build, or set ${laneVar} to one of the allowed lanes.`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return nextConfig;
|
|
27
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MIRROR of `packages/expo/src/protocol/index.ts` — platform-first packages
|
|
3
|
+
* each own their copy, and this one MUST stay byte-compatible with expo's
|
|
4
|
+
* (same shapes, same field names): the vitrinka server pins ONE ingest
|
|
5
|
+
* contract and both recorders ride it. Edit expo's first, then re-mirror.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* The recorder↔server wire contract — `@vitrinka/web/protocol` (mirrors `@vitrinka/expo/protocol`).
|
|
9
|
+
*
|
|
10
|
+
* Everything the recorders send rides these shapes over four routes:
|
|
11
|
+
*
|
|
12
|
+
* POST /api/v1/sessions SessionCreateRequest → SessionCreateResponse
|
|
13
|
+
* POST /api/v1/sessions/:id/events { events: RecorderEvent[] }
|
|
14
|
+
* POST /api/v1/sessions/:id/shot?seq=N (image body; the event stream
|
|
15
|
+
* carries a matching 'shot' event)
|
|
16
|
+
* PATCH /api/v1/sessions/:id { status: SessionStatus }
|
|
17
|
+
*
|
|
18
|
+
* This module is types-only and dependency-free on purpose: the vitrinka
|
|
19
|
+
* server pins its ingest contract against it, so a change here is a change to
|
|
20
|
+
* the wire — version it deliberately.
|
|
21
|
+
*/
|
|
22
|
+
/** One captured event in a session's ordered stream. */
|
|
23
|
+
export interface RecorderEvent {
|
|
24
|
+
/** Recorder-allocated, strictly increasing; the delivery-ack unit. */
|
|
25
|
+
seq: number;
|
|
26
|
+
/** ISO timestamp at capture. */
|
|
27
|
+
ts: string;
|
|
28
|
+
/** Timeline lane (tab/section grouping). */
|
|
29
|
+
tabId: string;
|
|
30
|
+
/** Full pathname/host context the event happened on. */
|
|
31
|
+
tabHost: string;
|
|
32
|
+
/** Event kind: 'nav' | 'click' | 'shot' | 'note' | 'net' | 'console' | … */
|
|
33
|
+
kind: string;
|
|
34
|
+
payload?: Record<string, unknown>;
|
|
35
|
+
/** Server blob reference for 'shot' events (set after upload). */
|
|
36
|
+
blobKey?: string;
|
|
37
|
+
}
|
|
38
|
+
export interface SessionCreateRequest {
|
|
39
|
+
/** App id the server resolves project+environment from. */
|
|
40
|
+
app: string;
|
|
41
|
+
title: string;
|
|
42
|
+
/** Explicit server lane; omitted = the server's app-id rule decides. */
|
|
43
|
+
environment?: string;
|
|
44
|
+
meta?: {
|
|
45
|
+
/** Recorder implementation + protocol revision, e.g. 'vitrinka-expo/1'. */
|
|
46
|
+
recorder?: string;
|
|
47
|
+
platform?: string;
|
|
48
|
+
appVersion?: string;
|
|
49
|
+
/** 'ai' marks a machine-driven run. */
|
|
50
|
+
driver?: string;
|
|
51
|
+
[key: string]: unknown;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export interface SessionCreateResponse {
|
|
55
|
+
id: string;
|
|
56
|
+
project: string;
|
|
57
|
+
environment: string;
|
|
58
|
+
title: string;
|
|
59
|
+
}
|
|
60
|
+
export type SessionStatus = 'recording' | 'paused' | 'done';
|
|
61
|
+
/** GET /api/v1/sessions/:id — the reconcile poll's answer. */
|
|
62
|
+
export interface SessionReconcileResponse {
|
|
63
|
+
/** Highest seq the server actually holds. */
|
|
64
|
+
maxSeq?: number;
|
|
65
|
+
status?: string;
|
|
66
|
+
deletedAt?: string | null;
|
|
67
|
+
}
|
|
68
|
+
/** PATCH response when a session completes (board projection). */
|
|
69
|
+
export interface SessionDone {
|
|
70
|
+
boardSlug?: string;
|
|
71
|
+
board?: {
|
|
72
|
+
url?: string;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MIRROR of `packages/expo/src/protocol/index.ts` — platform-first packages
|
|
3
|
+
* each own their copy, and this one MUST stay byte-compatible with expo's
|
|
4
|
+
* (same shapes, same field names): the vitrinka server pins ONE ingest
|
|
5
|
+
* contract and both recorders ride it. Edit expo's first, then re-mirror.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* The recorder↔server wire contract — `@vitrinka/web/protocol` (mirrors `@vitrinka/expo/protocol`).
|
|
9
|
+
*
|
|
10
|
+
* Everything the recorders send rides these shapes over four routes:
|
|
11
|
+
*
|
|
12
|
+
* POST /api/v1/sessions SessionCreateRequest → SessionCreateResponse
|
|
13
|
+
* POST /api/v1/sessions/:id/events { events: RecorderEvent[] }
|
|
14
|
+
* POST /api/v1/sessions/:id/shot?seq=N (image body; the event stream
|
|
15
|
+
* carries a matching 'shot' event)
|
|
16
|
+
* PATCH /api/v1/sessions/:id { status: SessionStatus }
|
|
17
|
+
*
|
|
18
|
+
* This module is types-only and dependency-free on purpose: the vitrinka
|
|
19
|
+
* server pins its ingest contract against it, so a change here is a change to
|
|
20
|
+
* the wire — version it deliberately.
|
|
21
|
+
*/
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recorder root — mounted ONLY when `url` + `key` are present (see index.ts).
|
|
3
|
+
* Installs the capture lanes once per document, recovers a session that
|
|
4
|
+
* survived a reload, and keeps the rrweb lane in step with the session.
|
|
5
|
+
*/
|
|
6
|
+
import { type ReactElement, type ReactNode } from 'react';
|
|
7
|
+
import { type RecorderConfig } from './config';
|
|
8
|
+
export declare function RecorderProvider({ config, children, }: {
|
|
9
|
+
config: RecorderConfig;
|
|
10
|
+
children?: ReactNode;
|
|
11
|
+
}): ReactElement;
|
|
12
|
+
/**
|
|
13
|
+
* Feed the recorder a router's pathname (Next: `usePathname()`). The default
|
|
14
|
+
* History wrap already sees `pushState`; this hook exists for routers that
|
|
15
|
+
* navigate without it and is idempotent alongside it.
|
|
16
|
+
*/
|
|
17
|
+
export declare function useRecorderRoute(pathname: string | null | undefined): void;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Recorder root — mounted ONLY when `url` + `key` are present (see index.ts).
|
|
4
|
+
* Installs the capture lanes once per document, recovers a session that
|
|
5
|
+
* survived a reload, and keeps the rrweb lane in step with the session.
|
|
6
|
+
*/
|
|
7
|
+
import { useEffect } from 'react';
|
|
8
|
+
import { installClickLane } from './capture/click';
|
|
9
|
+
import { patchConsole, unpatchConsole } from './capture/console';
|
|
10
|
+
import { installNavLane, noteNavigation, primeNavigation } from './capture/nav';
|
|
11
|
+
import { patchNetwork, unpatchNetwork } from './capture/net';
|
|
12
|
+
import { checkoutRRWeb, flushRRWeb, startRRWeb, stopRRWeb } from './capture/rrweb';
|
|
13
|
+
import { configureRecorder } from './config';
|
|
14
|
+
import { installControl } from './control';
|
|
15
|
+
import { installUnauthorizedHandler } from './link';
|
|
16
|
+
import { insideHud } from './hud/host';
|
|
17
|
+
import { armReconcile, flush, getState, persistNow, reconcile, scheduleFlush } from './queue';
|
|
18
|
+
import { onBeforeStop, recoverRedactionPolicy } from './session';
|
|
19
|
+
import { annotateState, setTabIdentity, subscribe } from './state';
|
|
20
|
+
const TAB_KEY = 'vitrinka.tab';
|
|
21
|
+
/** One lane per browser tab: a per-tab id kept in sessionStorage (survives reloads). */
|
|
22
|
+
function tabId() {
|
|
23
|
+
try {
|
|
24
|
+
const ss = globalThis.sessionStorage;
|
|
25
|
+
const have = ss.getItem(TAB_KEY);
|
|
26
|
+
if (have)
|
|
27
|
+
return have;
|
|
28
|
+
const id = Math.random().toString(36).slice(2, 10);
|
|
29
|
+
ss.setItem(TAB_KEY, id);
|
|
30
|
+
return id;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return 'root';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function RecorderProvider({ config, children, }) {
|
|
37
|
+
configureRecorder(config);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
configureRecorder(config);
|
|
40
|
+
}, [config]);
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
setTabIdentity(tabId(), location.host);
|
|
43
|
+
primeNavigation();
|
|
44
|
+
// The fetch/XHR and console patches are idempotent (a globalThis mark)
|
|
45
|
+
// and UNINSTALLED on unmount below, so the recorder never outlives its
|
|
46
|
+
// tree; the History wrap is idempotent too and stays (a nav event with no
|
|
47
|
+
// session is dropped at the queue, so it costs nothing).
|
|
48
|
+
patchNetwork();
|
|
49
|
+
patchConsole();
|
|
50
|
+
installNavLane();
|
|
51
|
+
const uninstallClicks = installClickLane({
|
|
52
|
+
ignore: (t) => annotateState.active || insideHud(t),
|
|
53
|
+
});
|
|
54
|
+
const uninstallControl = installControl();
|
|
55
|
+
const uninstall401 = installUnauthorizedHandler();
|
|
56
|
+
// Keep the rrweb lane in step with the session: start on record, a fresh
|
|
57
|
+
// checkout on resume, ship-and-stop on stop.
|
|
58
|
+
let wasRecording = false;
|
|
59
|
+
let wasPaused = false;
|
|
60
|
+
const syncLanes = () => {
|
|
61
|
+
const rec = getState();
|
|
62
|
+
const recording = rec !== null && !rec.dead;
|
|
63
|
+
if (recording && !wasRecording)
|
|
64
|
+
startRRWeb();
|
|
65
|
+
else if (!recording && wasRecording)
|
|
66
|
+
stopRRWeb();
|
|
67
|
+
else if (recording && wasPaused && !rec.paused)
|
|
68
|
+
checkoutRRWeb();
|
|
69
|
+
wasRecording = recording;
|
|
70
|
+
wasPaused = rec?.paused ?? false;
|
|
71
|
+
};
|
|
72
|
+
const unsubscribe = subscribe(syncLanes);
|
|
73
|
+
const offBeforeStop = onBeforeStop(flushRRWeb);
|
|
74
|
+
// A reload mid-session: the durable tail needs a drain, the reconcile
|
|
75
|
+
// poll re-arming, the policy re-applying, and the new document is a nav.
|
|
76
|
+
scheduleFlush();
|
|
77
|
+
if (getState()) {
|
|
78
|
+
recoverRedactionPolicy();
|
|
79
|
+
armReconcile();
|
|
80
|
+
noteNavigation();
|
|
81
|
+
}
|
|
82
|
+
syncLanes();
|
|
83
|
+
// Leaving the document: ship the rrweb tail, persist, and try a keepalive
|
|
84
|
+
// flush of a small events batch so the last steps ride out.
|
|
85
|
+
const onPageHide = () => {
|
|
86
|
+
flushRRWeb();
|
|
87
|
+
persistNow();
|
|
88
|
+
void flush({ keepalive: true });
|
|
89
|
+
};
|
|
90
|
+
const onVisible = () => {
|
|
91
|
+
if (document.visibilityState === 'visible' && getState()) {
|
|
92
|
+
void flush();
|
|
93
|
+
void reconcile();
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
addEventListener('pagehide', onPageHide);
|
|
97
|
+
document.addEventListener('visibilitychange', onVisible);
|
|
98
|
+
return () => {
|
|
99
|
+
removeEventListener('pagehide', onPageHide);
|
|
100
|
+
document.removeEventListener('visibilitychange', onVisible);
|
|
101
|
+
unsubscribe();
|
|
102
|
+
offBeforeStop();
|
|
103
|
+
uninstallClicks();
|
|
104
|
+
uninstallControl();
|
|
105
|
+
uninstall401();
|
|
106
|
+
stopRRWeb();
|
|
107
|
+
unpatchNetwork();
|
|
108
|
+
unpatchConsole();
|
|
109
|
+
};
|
|
110
|
+
// The lanes install once per mount; config changes are handled above.
|
|
111
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
112
|
+
}, []);
|
|
113
|
+
return _jsx(_Fragment, { children: children });
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Feed the recorder a router's pathname (Next: `usePathname()`). The default
|
|
117
|
+
* History wrap already sees `pushState`; this hook exists for routers that
|
|
118
|
+
* navigate without it and is idempotent alongside it.
|
|
119
|
+
*/
|
|
120
|
+
export function useRecorderRoute(pathname) {
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (pathname == null)
|
|
123
|
+
return;
|
|
124
|
+
noteNavigation();
|
|
125
|
+
}, [pathname]);
|
|
126
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport-status vocabulary — ONE copy lives in `@vitrinka/link` (the
|
|
3
|
+
* zero-dependency package every recorder already depends on); this module
|
|
4
|
+
* keeps the import path stable for `api.ts` and the tests.
|
|
5
|
+
*/
|
|
6
|
+
export { permanentStatus, VitrinkaApiError } from '@vitrinka/link';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport-status vocabulary — ONE copy lives in `@vitrinka/link` (the
|
|
3
|
+
* zero-dependency package every recorder already depends on); this module
|
|
4
|
+
* keeps the import path stable for `api.ts` and the tests.
|
|
5
|
+
*/
|
|
6
|
+
export { permanentStatus, VitrinkaApiError } from '@vitrinka/link';
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vitrinka API client for the web journey recorder.
|
|
3
|
+
*
|
|
4
|
+
* Ingest contract, shared with the Expo recorder and the browser extension:
|
|
5
|
+
* GET /api/v1/recorder/policy workspace redaction policy
|
|
6
|
+
* POST /api/v1/sessions {host, title, environment?, meta} → session
|
|
7
|
+
* POST /api/v1/sessions/:id/events {events: [...]}
|
|
8
|
+
* POST /api/v1/sessions/:id/chunk?seq=N (rrweb batch body, application/json)
|
|
9
|
+
* POST /api/v1/sessions/:id/tags {tags} (non-fatal)
|
|
10
|
+
* GET /api/v1/sessions/:id reconcile
|
|
11
|
+
* PATCH /api/v1/sessions/:id {status: recording|paused|done}
|
|
12
|
+
*
|
|
13
|
+
* Every call: `authorization: Bearer <key>`, `credentials: "omit"` (the key
|
|
14
|
+
* IS the credential; cookies never ride), `mode: "cors"`.
|
|
15
|
+
*/
|
|
16
|
+
import type { RedactionPolicy } from '@vitrinka/redact';
|
|
17
|
+
export { permanentStatus, VitrinkaApiError } from './api-status';
|
|
18
|
+
export declare function onUnauthorized(fn: () => void): () => void;
|
|
19
|
+
/**
|
|
20
|
+
* Fetch the workspace redaction policy at session start. NEVER rejects: null
|
|
21
|
+
* (server too old, network down, 4xx) means the engine's safe defaults — fail
|
|
22
|
+
* closed, never capture-everything.
|
|
23
|
+
*/
|
|
24
|
+
export declare function fetchPolicy(): Promise<RedactionPolicy | null>;
|
|
25
|
+
export interface ApiOptions {
|
|
26
|
+
/**
|
|
27
|
+
* Survive page unload (the pagehide flush). Browsers cap keepalive bodies at
|
|
28
|
+
* 64 KiB — callers pass it only for a small tail batch.
|
|
29
|
+
*/
|
|
30
|
+
keepalive?: boolean;
|
|
31
|
+
}
|
|
32
|
+
export declare function api<T = Record<string, unknown>>(method: string, path: string, body?: unknown, opts?: ApiOptions): Promise<T>;
|
|
33
|
+
/**
|
|
34
|
+
* Upload one rrweb chunk (an already-serialized JSON array of rrweb events)
|
|
35
|
+
* under a pre-allocated seq. Returns the server's blobKey — the matching
|
|
36
|
+
* `rrweb` event row carries it.
|
|
37
|
+
*/
|
|
38
|
+
export declare function uploadChunk(sessionId: string, seq: number, body: string): Promise<{
|
|
39
|
+
blobKey?: string;
|
|
40
|
+
}>;
|