@nagoorkani/session-capture-vue-replay 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/LICENSE ADDED
@@ -0,0 +1,25 @@
1
+ PROPRIETARY SOFTWARE LICENSE
2
+
3
+ Copyright (c) 2026 Nagoorkani2393. All rights reserved.
4
+
5
+ This software, its source code, and its accompanying documentation (the
6
+ "Software") are the proprietary and confidential property of the copyright
7
+ holder.
8
+
9
+ NO LICENSE IS GRANTED. No person or entity is permitted to use, copy, modify,
10
+ merge, publish, distribute, sublicense, reverse engineer, create derivative
11
+ works from, or sell the Software, in whole or in part, by any means, except
12
+ under a separate written agreement signed by the copyright holder.
13
+
14
+ Availability of the Software through a public package registry, source
15
+ repository, or any other distribution channel is for the convenience of
16
+ authorized licensees only. It does not grant any license, right, or permission
17
+ to the general public, and no such license may be implied from that
18
+ availability. Unauthorized use, reproduction, or distribution is prohibited.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
24
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
25
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # @nagoorkani/session-capture-vue-replay
2
+
3
+ Optional DOM session replay for [`@nagoorkani/session-capture-vue`](../vue/README.md).
4
+
5
+ Separate from the SDK on purpose. rrweb is several times the size of the SDK
6
+ it attaches to, and section 23 says a recorder that most projects will not turn
7
+ on does not belong in everybody's bundle. The glue here is under 5 KB; rrweb is
8
+ what you are actually installing.
9
+
10
+ ## Setup
11
+
12
+ ```bash
13
+ npm install @nagoorkani/session-capture-vue-replay rrweb
14
+ ```
15
+
16
+ ```ts
17
+ import { installReplay } from '@nagoorkani/session-capture-vue-replay'
18
+
19
+ // After sessionCapture is installed, so the session is already open.
20
+ installReplay({ endpoint: 'https://your-ingestion-host', apiKey: 'pk_live_…' })
21
+ ```
22
+
23
+ Nothing records until remote config says so. `replayEnabled` and
24
+ `replaySampleRate` are both off by default and live on the project's Settings
25
+ page, because continuous recording is the most expensive thing this SDK does
26
+ and a shipped page can only be stopped from the server. An unsampled session
27
+ costs nothing — the recorder never starts.
28
+
29
+ A session that hits an error is recorded whatever the sampling says, the same
30
+ exemption errors get everywhere else here.
31
+
32
+ ## What is recorded
33
+
34
+ rrweb's stream: one full snapshot of the DOM, then the mutations after it. The
35
+ dashboard replays them to rebuild the page. Not screenshots — a browser cannot
36
+ photograph itself, which is why this and the mobile package produce different
37
+ data behind the same player.
38
+
39
+ **Every input is masked before it is buffered.** Not a setting: § 32 says
40
+ redaction happens in the SDK before anything is stored, and a DOM recording is
41
+ the one capture path that would otherwise carry a password in plain text.
42
+
43
+ Static text is *not* masked. Mark what should be:
44
+
45
+ ```ts
46
+ installReplay({
47
+ endpoint, apiKey,
48
+ maskTextClass: 'sc-mask', // text replaced with asterisks
49
+ blockClass: 'sc-block', // element kept as a box, contents dropped
50
+ })
51
+ ```
52
+
53
+ Those are the defaults, so `<div class="sc-block">` works without configuring
54
+ anything.
55
+
56
+ ## Delivery
57
+
58
+ A segment every thirty seconds, or sooner if the buffer reaches 2 MB. The last
59
+ one is sent with `keepalive` as the page goes away.
60
+
61
+ **Segments are not persisted.** The mobile package keeps failed ones on disk
62
+ because a phone loses connectivity for minutes at a time; a browser tab that
63
+ has gone is gone, and its ~5 MB of `localStorage` is better spent on events —
64
+ those are the bytes that reconstruct a timeline, and a lost segment costs
65
+ pictures rather than facts. A failed send is retried once on the next tick and
66
+ then dropped.
67
+
68
+ rrweb's stream is only meaningful in order, so a lost segment breaks the replay
69
+ from that point. A full snapshot is taken every two minutes to bound the
70
+ damage: the recording resumes at the next one.
71
+
72
+ ## Size
73
+
74
+ ```bash
75
+ pnpm --filter @nagoorkani/session-capture-vue-replay size
76
+ ```
77
+
78
+ Measures this package only. rrweb and the SDK are external — including them
79
+ would report the size of the recorder rather than of the glue.
80
+
81
+ ## License
82
+
83
+ Proprietary — all rights reserved. See [LICENSE](./LICENSE). No use, copying,
84
+ modification or distribution is permitted without a written agreement with the
85
+ copyright holder.
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @nagoorkani/session-capture-vue-replay
3
+ *
4
+ * Optional DOM session replay, kept out of `@nagoorkani/session-capture-vue` so it costs
5
+ * nothing in bundle size for applications that do not want it (section 23).
6
+ * rrweb is several times the size of the SDK it attaches to, which is the
7
+ * whole reason this is a package of its own.
8
+ *
9
+ * Records a sampled session from start to finish, cutting a segment every
10
+ * thirty seconds. Which sessions those are is a remote-config decision —
11
+ * `replayEnabled` and `replaySampleRate`, both off by default — because
12
+ * continuous recording is the most expensive thing this SDK can do and a
13
+ * shipped page can only be stopped from the server.
14
+ *
15
+ * An unsampled session costs nothing: the recorder never starts.
16
+ */
17
+ import { type RecorderOptions } from './recorder.ts';
18
+ import { type UploadResult } from './upload.ts';
19
+ export type { RecorderOptions, Segment } from './recorder.ts';
20
+ export type { UploadResult } from './upload.ts';
21
+ export interface ReplayOptions extends RecorderOptions {
22
+ /** Where to send segments. The same host as the SDK's `endpoint`. */
23
+ endpoint: string;
24
+ apiKey: string;
25
+ /**
26
+ * CSS class on elements to leave out of the recording entirely. Their shape
27
+ * is kept and their content is not.
28
+ */
29
+ blockClass?: string;
30
+ /** CSS class on elements whose text is replaced with asterisks. */
31
+ maskTextClass?: string;
32
+ /** Observes each upload. For diagnostics and the example app. */
33
+ onUpload?: (result: UploadResult, events: number) => void;
34
+ }
35
+ /**
36
+ * Start recording.
37
+ *
38
+ * Call after `sessionCapture` has been installed, and only in the browser —
39
+ * during server rendering there is no DOM to record and this does nothing.
40
+ */
41
+ export declare const installReplay: (options: ReplayOptions) => void | undefined;
42
+ /** Stop recording and unwind everything installed on the page. */
43
+ export declare const stopReplay: () => void | undefined;
44
+ /** Whether the open session is being recorded. */
45
+ export declare const isRecording: () => boolean;
package/dist/index.js ADDED
@@ -0,0 +1,200 @@
1
+ /**
2
+ * @nagoorkani/session-capture-vue-replay
3
+ *
4
+ * Optional DOM session replay, kept out of `@nagoorkani/session-capture-vue` so it costs
5
+ * nothing in bundle size for applications that do not want it (section 23).
6
+ * rrweb is several times the size of the SDK it attaches to, which is the
7
+ * whole reason this is a package of its own.
8
+ *
9
+ * Records a sampled session from start to finish, cutting a segment every
10
+ * thirty seconds. Which sessions those are is a remote-config decision —
11
+ * `replayEnabled` and `replaySampleRate`, both off by default — because
12
+ * continuous recording is the most expensive thing this SDK can do and a
13
+ * shipped page can only be stopped from the server.
14
+ *
15
+ * An unsampled session costs nothing: the recorder never starts.
16
+ */
17
+ import { isSampled, ReplayGate, safe } from '@nagoorkani/session-capture-core';
18
+ import { currentConfig, keepSessionAlive, onError, onSession, } from '@nagoorkani/session-capture-vue';
19
+ import { record } from 'rrweb';
20
+ import { Recorder } from "./recorder.js";
21
+ import { uploadSegment } from "./upload.js";
22
+ /** Set by `installReplay`; read by the flusher, which outlives one call. */
23
+ const uploadOptions = {
24
+ endpoint: '',
25
+ apiKey: '',
26
+ };
27
+ let recorder = null;
28
+ let flushTimer = null;
29
+ let gateTimer = null;
30
+ let pageHideListener = null;
31
+ const unsubscribes = [];
32
+ let gate = null;
33
+ /** The session being recorded, or null when this one is not. */
34
+ let recordingSession = null;
35
+ /** Held for one more attempt when a send fails while the tab is still alive. */
36
+ let retrying = [];
37
+ let sending = false;
38
+ /**
39
+ * How often the gate is asked, as opposed to how often a segment is cut.
40
+ *
41
+ * Two timers, and the difference matters. Remote config has not arrived when
42
+ * `installReplay` runs, so the first answer is always "wait" — and on the web
43
+ * a reload resumes the session rather than starting one, so the session hook
44
+ * that would have asked again never fires. Asking only on the flush tick would
45
+ * lose the first thirty seconds of every visit, which is the part that
46
+ * explains what the user was doing.
47
+ */
48
+ const GATE_INTERVAL_MS = 2000;
49
+ /**
50
+ * Start recording.
51
+ *
52
+ * Call after `sessionCapture` has been installed, and only in the browser —
53
+ * during server rendering there is no DOM to record and this does nothing.
54
+ */
55
+ export const installReplay = safe((options) => {
56
+ if (recorder || typeof globalThis.document === 'undefined')
57
+ return;
58
+ uploadOptions.endpoint = options.endpoint;
59
+ uploadOptions.apiKey = options.apiKey;
60
+ uploadOptions.onUpload = options.onUpload;
61
+ recorder = new Recorder((emit) => record({
62
+ emit: (event) => emit(event),
63
+ // Every field a user types is masked before it is buffered. This is
64
+ // not a preference: § 32 says redaction happens in the SDK before
65
+ // anything is stored, and a DOM recording is the one capture path
66
+ // that would otherwise carry a password in plain text.
67
+ maskAllInputs: true,
68
+ blockClass: options.blockClass ?? 'sc-block',
69
+ maskTextClass: options.maskTextClass ?? 'sc-mask',
70
+ // A fresh full snapshot every two minutes. rrweb's stream is only
71
+ // meaningful in order, so without one a single lost or oversized
72
+ // segment would break every segment after it for the rest of the
73
+ // session; with one, the damage ends at the next checkout.
74
+ checkoutEveryNms: 2 * 60 * 1000,
75
+ }), options,
76
+ // The buffer reached its ceiling before the timer did.
77
+ () => void flushNow());
78
+ gate = new ReplayGate(isSampled);
79
+ unsubscribes.push(onSession((sessionId) => {
80
+ // Whatever the previous session had buffered belongs to the previous
81
+ // session, so cut it before the id changes under it.
82
+ void flushNow();
83
+ gate?.open(sessionId);
84
+ recordingSession = null;
85
+ evaluate();
86
+ }));
87
+ // An error records regardless of sampling — the same exemption errors get
88
+ // everywhere else here — and flushes immediately, because an error often
89
+ // precedes the page being abandoned.
90
+ unsubscribes.push(onError((sessionId) => {
91
+ gate?.force(sessionId);
92
+ evaluate();
93
+ void flushNow();
94
+ }));
95
+ gateTimer = setInterval(evaluate, GATE_INTERVAL_MS);
96
+ gateTimer?.unref?.();
97
+ flushTimer = setInterval(() => {
98
+ evaluate();
99
+ void flushNow();
100
+ }, recorder.segmentMs);
101
+ flushTimer?.unref?.();
102
+ // The last cut. A hidden tab may never run again, and `keepalive` is the
103
+ // only request a browser promises to finish after the page is gone.
104
+ pageHideListener = () => void flushNow(true);
105
+ globalThis.addEventListener?.('pagehide', pageHideListener);
106
+ globalThis.addEventListener?.('visibilitychange', pageHideListener);
107
+ evaluate();
108
+ }, undefined, 'installReplay');
109
+ /** Stop recording and unwind everything installed on the page. */
110
+ export const stopReplay = safe(() => {
111
+ for (const undo of unsubscribes.splice(0)) {
112
+ try {
113
+ undo();
114
+ }
115
+ catch {
116
+ // Teardown must never throw into the host page.
117
+ }
118
+ }
119
+ if (flushTimer)
120
+ clearInterval(flushTimer);
121
+ if (gateTimer)
122
+ clearInterval(gateTimer);
123
+ flushTimer = null;
124
+ gateTimer = null;
125
+ if (pageHideListener) {
126
+ globalThis.removeEventListener?.('pagehide', pageHideListener);
127
+ globalThis.removeEventListener?.('visibilitychange', pageHideListener);
128
+ pageHideListener = null;
129
+ }
130
+ recorder?.pause();
131
+ // Not merely stopped: § 36 — a kill switch that leaves screenshots of
132
+ // somebody's screen sitting in memory has not done its job.
133
+ recorder?.discard();
134
+ recorder = null;
135
+ gate?.reset();
136
+ gate = null;
137
+ recordingSession = null;
138
+ retrying = [];
139
+ }, undefined, 'stopReplay');
140
+ /** Whether the open session is being recorded. */
141
+ export const isRecording = safe(() => recordingSession !== null && (recorder?.recording ?? false), false, 'isRecording');
142
+ /** Ask the gate what this session should do, and act on the answer. */
143
+ function evaluate() {
144
+ if (!gate || !recorder)
145
+ return;
146
+ const action = gate.evaluate(currentConfig());
147
+ switch (action.kind) {
148
+ case 'wait':
149
+ return;
150
+ case 'off':
151
+ recorder.pause();
152
+ recorder.discard();
153
+ retrying = [];
154
+ recordingSession = null;
155
+ return;
156
+ case 'skip':
157
+ recorder.pause();
158
+ recorder.discard();
159
+ recordingSession = null;
160
+ return;
161
+ case 'record':
162
+ recordingSession = action.sessionId;
163
+ recorder.start();
164
+ }
165
+ }
166
+ /**
167
+ * Cut a segment and send it.
168
+ *
169
+ * Recording is activity: a page nobody is clicking emits no events, so without
170
+ * this the session would idle out underneath the recorder and segments would
171
+ * arrive for a session the backend had already closed.
172
+ */
173
+ async function flushNow(unloading = false) {
174
+ const active = recorder;
175
+ const sessionId = recordingSession;
176
+ if (!active || !sessionId || sending)
177
+ return;
178
+ const segment = [...retrying, ...active.take()];
179
+ retrying = [];
180
+ if (segment.length === 0)
181
+ return;
182
+ keepSessionAlive();
183
+ sending = true;
184
+ try {
185
+ const result = await uploadSegment({
186
+ endpoint: uploadOptions.endpoint,
187
+ apiKey: uploadOptions.apiKey,
188
+ sessionId,
189
+ keepalive: unloading,
190
+ }, segment);
191
+ // One more attempt on the next tick, then it is gone. Holding a failing
192
+ // segment forever is how an offline tab runs out of memory.
193
+ if (result.kind === 'failed' && !unloading)
194
+ retrying = segment;
195
+ uploadOptions.onUpload?.(result, segment.length);
196
+ }
197
+ finally {
198
+ sending = false;
199
+ }
200
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Recording the DOM, and cutting it into segments.
3
+ *
4
+ * A browser cannot photograph itself, so a web recording is not pictures: it
5
+ * is rrweb's stream of one full snapshot followed by mutations. The player
6
+ * replays them to rebuild what the user saw.
7
+ *
8
+ * `record` is injected rather than imported, exactly as the mobile recorder
9
+ * takes its screenshot function: it keeps this module testable in Node, where
10
+ * there is no DOM and no rrweb, and it is the seam that decides masking.
11
+ */
12
+ export interface RecordedEvent {
13
+ /** rrweb stamps each event with the device clock. Display only, as ever. */
14
+ timestamp?: number;
15
+ [key: string]: unknown;
16
+ }
17
+ /** What rrweb's `record` is, reduced to what this uses. */
18
+ export type RecordFn = (emit: (event: RecordedEvent) => void) => (() => void) | undefined;
19
+ export interface Segment {
20
+ timestamp: number;
21
+ payload: Uint8Array;
22
+ }
23
+ export interface RecorderOptions {
24
+ /**
25
+ * How much of the stream one upload carries. Thirty seconds, matching the
26
+ * mobile package: long enough that a page's chatter is not one request per
27
+ * mutation, short enough that closing the tab loses little.
28
+ */
29
+ seconds?: number;
30
+ /**
31
+ * Bytes buffered before a segment is cut early.
32
+ *
33
+ * The ceiling exists twice over: ingestion refuses an upload above 8 MB, and
34
+ * a page that mutates heavily would otherwise hold megabytes of DOM in
35
+ * memory between ticks.
36
+ */
37
+ maxSegmentBytes?: number;
38
+ }
39
+ export declare class Recorder {
40
+ private readonly record;
41
+ private readonly maxSegmentBytes;
42
+ private readonly onSegmentFull;
43
+ private readonly encoder;
44
+ private stop;
45
+ private buffered;
46
+ private bufferedBytes;
47
+ private droppedCount;
48
+ readonly segmentMs: number;
49
+ constructor(record: RecordFn, options: RecorderOptions, onSegmentFull: () => void);
50
+ get recording(): boolean;
51
+ /** Events discarded for being individually too large to send. */
52
+ get dropped(): number;
53
+ start(): void;
54
+ /** Stop recording. Anything already buffered stays until it is taken. */
55
+ pause(): void;
56
+ /** Take everything buffered, leaving the recorder empty and still running. */
57
+ take(): Segment[];
58
+ /** Throw away what is buffered — a kill switch, or consent withdrawn. */
59
+ discard(): void;
60
+ private push;
61
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Recording the DOM, and cutting it into segments.
3
+ *
4
+ * A browser cannot photograph itself, so a web recording is not pictures: it
5
+ * is rrweb's stream of one full snapshot followed by mutations. The player
6
+ * replays them to rebuild what the user saw.
7
+ *
8
+ * `record` is injected rather than imported, exactly as the mobile recorder
9
+ * takes its screenshot function: it keeps this module testable in Node, where
10
+ * there is no DOM and no rrweb, and it is the seam that decides masking.
11
+ */
12
+ const DEFAULTS = {
13
+ seconds: 30,
14
+ maxSegmentBytes: 2 * 1024 * 1024,
15
+ };
16
+ /**
17
+ * One event larger than this cannot be sent — ingestion's per-frame limit is
18
+ * 4 MB — so it is dropped rather than poisoning the segment it is in.
19
+ *
20
+ * ponytail: dropping one event breaks the replay from that point, because
21
+ * rrweb's stream is only meaningful in order. The alternative is an upload
22
+ * rejected whole, which breaks it just as badly and wastes the request. A
23
+ * periodic checkout is what recovers: the next full snapshot starts again.
24
+ */
25
+ const MAX_EVENT_BYTES = 4 * 1024 * 1024;
26
+ export class Recorder {
27
+ constructor(record, options, onSegmentFull) {
28
+ this.encoder = new TextEncoder();
29
+ this.buffered = [];
30
+ this.bufferedBytes = 0;
31
+ this.droppedCount = 0;
32
+ this.record = record;
33
+ this.segmentMs = Math.max(1000, (options.seconds ?? DEFAULTS.seconds) * 1000);
34
+ this.maxSegmentBytes = options.maxSegmentBytes ?? DEFAULTS.maxSegmentBytes;
35
+ this.onSegmentFull = onSegmentFull;
36
+ }
37
+ get recording() {
38
+ return this.stop !== undefined;
39
+ }
40
+ /** Events discarded for being individually too large to send. */
41
+ get dropped() {
42
+ return this.droppedCount;
43
+ }
44
+ start() {
45
+ if (this.stop)
46
+ return;
47
+ this.stop = this.record((event) => this.push(event)) ?? (() => { });
48
+ }
49
+ /** Stop recording. Anything already buffered stays until it is taken. */
50
+ pause() {
51
+ try {
52
+ this.stop?.();
53
+ }
54
+ catch {
55
+ // rrweb's own teardown must not throw into the host page.
56
+ }
57
+ this.stop = undefined;
58
+ }
59
+ /** Take everything buffered, leaving the recorder empty and still running. */
60
+ take() {
61
+ const segment = this.buffered;
62
+ this.buffered = [];
63
+ this.bufferedBytes = 0;
64
+ return segment;
65
+ }
66
+ /** Throw away what is buffered — a kill switch, or consent withdrawn. */
67
+ discard() {
68
+ this.buffered = [];
69
+ this.bufferedBytes = 0;
70
+ }
71
+ push(event) {
72
+ let payload;
73
+ try {
74
+ payload = this.encoder.encode(JSON.stringify(event));
75
+ }
76
+ catch {
77
+ // A circular or unserializable event. rrweb does not produce one, but a
78
+ // recorder must not throw into the page it is watching.
79
+ return;
80
+ }
81
+ if (payload.byteLength > MAX_EVENT_BYTES) {
82
+ this.droppedCount += 1;
83
+ return;
84
+ }
85
+ this.buffered.push({
86
+ timestamp: typeof event.timestamp === 'number' ? event.timestamp : Date.now(),
87
+ payload,
88
+ });
89
+ this.bufferedBytes += payload.byteLength;
90
+ if (this.bufferedBytes >= this.maxSegmentBytes) {
91
+ // Cut now rather than at the next tick: the ceiling is about memory and
92
+ // the upload limit, and both are reached before the timer is.
93
+ this.onSegmentFull();
94
+ }
95
+ }
96
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Sending a segment.
3
+ *
4
+ * One attempt, no retry and no queue. The mobile package keeps failed segments
5
+ * on disk because a phone loses connectivity for minutes at a time; a browser
6
+ * tab that has gone is gone, and its ~5 MB of `localStorage` is better spent
7
+ * on events than on DOM snapshots — those are the bytes that reconstruct a
8
+ * timeline, and a lost segment costs pictures rather than facts.
9
+ *
10
+ * ponytail: in-memory only, so a segment that fails while the tab lives is
11
+ * retried once on the next tick and then dropped. Persist it when someone
12
+ * shows a recording worth the quota.
13
+ */
14
+ import type { Segment } from './recorder.ts';
15
+ export interface UploadOptions {
16
+ endpoint: string;
17
+ apiKey: string;
18
+ sessionId: string;
19
+ fetchImpl?: typeof fetch;
20
+ /** Ask the browser to finish this one after the page is gone. */
21
+ keepalive?: boolean;
22
+ }
23
+ export type UploadResult = {
24
+ kind: 'sent';
25
+ events: number;
26
+ } | {
27
+ kind: 'skipped';
28
+ reason: string;
29
+ } | {
30
+ kind: 'failed';
31
+ reason: string;
32
+ };
33
+ export declare function uploadSegment(options: UploadOptions, segment: readonly Segment[]): Promise<UploadResult>;
package/dist/upload.js ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Sending a segment.
3
+ *
4
+ * One attempt, no retry and no queue. The mobile package keeps failed segments
5
+ * on disk because a phone loses connectivity for minutes at a time; a browser
6
+ * tab that has gone is gone, and its ~5 MB of `localStorage` is better spent
7
+ * on events than on DOM snapshots — those are the bytes that reconstruct a
8
+ * timeline, and a lost segment costs pictures rather than facts.
9
+ *
10
+ * ponytail: in-memory only, so a segment that fails while the tab lives is
11
+ * retried once on the next tick and then dropped. Persist it when someone
12
+ * shows a recording worth the quota.
13
+ */
14
+ import { encodeContainer, MAGIC_DOM } from '@nagoorkani/session-capture-core';
15
+ export async function uploadSegment(options, segment) {
16
+ if (segment.length === 0)
17
+ return { kind: 'skipped', reason: 'no events' };
18
+ if (!options.sessionId) {
19
+ // Without a session there is nothing to attach the recording to, and the
20
+ // server would reject it anyway.
21
+ return { kind: 'skipped', reason: 'no session' };
22
+ }
23
+ const base = options.endpoint.replace(/\/$/, '');
24
+ // The same endpoint the mobile package uses. What differs is the container's
25
+ // magic — everything between here and the player treats a frame as opaque
26
+ // bytes with a timestamp, so one intake serves both.
27
+ const url = `${base}/v1/mobile/replay?sessionId=${encodeURIComponent(options.sessionId)}`;
28
+ const doFetch = options.fetchImpl ?? fetch;
29
+ try {
30
+ const body = encodeContainer(MAGIC_DOM, segment);
31
+ const response = await doFetch(url, {
32
+ method: 'POST',
33
+ headers: {
34
+ 'content-type': 'application/octet-stream',
35
+ authorization: `Bearer ${options.apiKey}`,
36
+ },
37
+ body: body,
38
+ keepalive: options.keepalive === true,
39
+ });
40
+ return response.ok
41
+ ? { kind: 'sent', events: segment.length }
42
+ : { kind: 'failed', reason: `http ${response.status}` };
43
+ }
44
+ catch (err) {
45
+ return { kind: 'failed', reason: err instanceof Error ? err.message : 'network error' };
46
+ }
47
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@nagoorkani/session-capture-vue-replay",
3
+ "version": "0.1.0",
4
+ "description": "Optional DOM session replay for @nagoorkani/session-capture-vue",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ }
14
+ },
15
+ "sideEffects": false,
16
+ "files": [
17
+ "dist",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "dependencies": {
22
+ "@nagoorkani/session-capture-core": "0.1.0"
23
+ },
24
+ "peerDependencies": {
25
+ "rrweb": "^2.0.0",
26
+ "@nagoorkani/session-capture-vue": "0.1.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^22.0.0",
30
+ "esbuild": "^0.24.2",
31
+ "rrweb": "^2.0.0",
32
+ "typescript": "^5.6.0"
33
+ },
34
+ "engines": {
35
+ "node": ">=22.6"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/Nagoorkani2393/session-capture.git",
40
+ "directory": "sdk/vue-replay"
41
+ },
42
+ "homepage": "https://github.com/Nagoorkani2393/session-capture/tree/main/sdk/vue-replay#readme",
43
+ "bugs": {
44
+ "url": "https://github.com/Nagoorkani2393/session-capture/issues"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "scripts": {
50
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
51
+ "typecheck": "tsc --noEmit",
52
+ "test": "node --test --experimental-strip-types \"src/**/*.test.ts\"",
53
+ "size": "node scripts/size.mjs"
54
+ }
55
+ }