@sessionsight/insights 1.0.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 +21 -0
- package/README.md +287 -0
- package/build.ts +47 -0
- package/package.json +54 -0
- package/src/_worker-bundle.d.ts +3 -0
- package/src/iife.ts +3 -0
- package/src/index.ts +240 -0
- package/src/recorder.ts +1396 -0
- package/src/transport.ts +241 -0
- package/src/types.ts +56 -0
- package/src/worker-bridge.ts +315 -0
- package/src/worker-inline.ts +23 -0
- package/src/worker.ts +302 -0
- package/tsconfig.json +13 -0
package/src/transport.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import type { IngestPayload, PrivacyConfig } from './types.js';
|
|
2
|
+
|
|
3
|
+
const MAX_KEEPALIVE_BYTES = 60_000;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* WebSocket-first transport with HTTP fallback.
|
|
7
|
+
*
|
|
8
|
+
* On init, opens a persistent WebSocket to /ws/ingest. While the socket is
|
|
9
|
+
* open, payloads are sent as WS messages (zero network tab noise). If the
|
|
10
|
+
* socket isn't ready yet, drops, or fails to connect, falls back to HTTP
|
|
11
|
+
* fetch. sendBeacon (page unload) always uses HTTP since the WS may be
|
|
12
|
+
* closing.
|
|
13
|
+
*/
|
|
14
|
+
export class Transport {
|
|
15
|
+
private apiUrl: string;
|
|
16
|
+
private publicApiKey: string;
|
|
17
|
+
private propertyId: string;
|
|
18
|
+
private killed = false;
|
|
19
|
+
|
|
20
|
+
// WebSocket state
|
|
21
|
+
private ws: WebSocket | null = null;
|
|
22
|
+
private wsReady = false;
|
|
23
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
24
|
+
private reconnectDelay = 1_000;
|
|
25
|
+
private static readonly MAX_RECONNECT_DELAY = 30_000;
|
|
26
|
+
private closed = false;
|
|
27
|
+
|
|
28
|
+
// Privacy config callback
|
|
29
|
+
private onPrivacyConfig: ((config: PrivacyConfig) => void) | null = null;
|
|
30
|
+
|
|
31
|
+
constructor(apiUrl: string, publicApiKey: string, propertyId: string) {
|
|
32
|
+
this.apiUrl = apiUrl;
|
|
33
|
+
this.publicApiKey = publicApiKey;
|
|
34
|
+
this.propertyId = propertyId;
|
|
35
|
+
this.connectWs();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Register a callback for when the server sends privacy configuration. */
|
|
39
|
+
onPrivacy(callback: (config: PrivacyConfig) => void): void {
|
|
40
|
+
this.onPrivacyConfig = callback;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
isKilled(): boolean {
|
|
44
|
+
return this.killed;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── WebSocket lifecycle ────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
private connectWs(): void {
|
|
50
|
+
if (this.killed || this.closed) return;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const wsUrl = this.apiUrl
|
|
54
|
+
.replace(/^http/, 'ws')
|
|
55
|
+
.replace(/\/$/, '');
|
|
56
|
+
this.ws = new WebSocket(`${wsUrl}/ws/ingest?key=${encodeURIComponent(this.publicApiKey)}&propertyId=${encodeURIComponent(this.propertyId)}`);
|
|
57
|
+
|
|
58
|
+
this.ws.onopen = () => {
|
|
59
|
+
// Wait for the 'ready' message before marking as ready
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
this.ws.onmessage = (event) => {
|
|
63
|
+
try {
|
|
64
|
+
const msg = JSON.parse(typeof event.data === 'string' ? event.data : '');
|
|
65
|
+
if (msg.type === 'ready') {
|
|
66
|
+
this.wsReady = true;
|
|
67
|
+
this.reconnectDelay = 1_000; // reset backoff on successful connection
|
|
68
|
+
if (msg.privacy && this.onPrivacyConfig) {
|
|
69
|
+
this.onPrivacyConfig(msg.privacy);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (msg.type === 'error' && msg.code === 'QUOTA_EXCEEDED') {
|
|
73
|
+
// Server says quota exceeded; stop sending but don't kill (quota resets monthly)
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// ignore non-JSON messages
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
this.ws.onclose = (event) => {
|
|
81
|
+
this.wsReady = false;
|
|
82
|
+
this.ws = null;
|
|
83
|
+
|
|
84
|
+
// 4001 = invalid API key, same as HTTP 401/403
|
|
85
|
+
if (event.code === 4001) {
|
|
86
|
+
this.killed = true;
|
|
87
|
+
console.warn('SessionSight: invalid API key. Ingestion disabled.');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 4002 = subscription required
|
|
92
|
+
if (event.code === 4002) {
|
|
93
|
+
this.killed = true;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
this.scheduleReconnect();
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
this.ws.onerror = () => {
|
|
101
|
+
// onclose fires after onerror, so reconnect is handled there
|
|
102
|
+
};
|
|
103
|
+
} catch {
|
|
104
|
+
this.scheduleReconnect();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private scheduleReconnect(): void {
|
|
109
|
+
if (this.killed || this.closed || this.reconnectTimer) return;
|
|
110
|
+
|
|
111
|
+
this.reconnectTimer = setTimeout(() => {
|
|
112
|
+
this.reconnectTimer = null;
|
|
113
|
+
this.connectWs();
|
|
114
|
+
}, this.reconnectDelay);
|
|
115
|
+
|
|
116
|
+
// Exponential backoff with jitter
|
|
117
|
+
this.reconnectDelay = Math.min(
|
|
118
|
+
this.reconnectDelay * 2 + Math.random() * 500,
|
|
119
|
+
Transport.MAX_RECONNECT_DELAY,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Public API ─────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
async send(payload: IngestPayload): Promise<void> {
|
|
126
|
+
if (this.killed) return;
|
|
127
|
+
|
|
128
|
+
// Try WebSocket first
|
|
129
|
+
if (this.wsReady && this.ws?.readyState === WebSocket.OPEN) {
|
|
130
|
+
try {
|
|
131
|
+
this.ws.send(JSON.stringify(payload));
|
|
132
|
+
return;
|
|
133
|
+
} catch {
|
|
134
|
+
// WS send failed, fall through to HTTP
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// HTTP fallback
|
|
139
|
+
const chunks = this.chunkEvents(payload);
|
|
140
|
+
for (const chunk of chunks) {
|
|
141
|
+
await this.sendHttpChunk(chunk);
|
|
142
|
+
if (this.killed) return;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Best-effort send for page unload. Always uses HTTP (sendBeacon or fetch)
|
|
148
|
+
* since the WebSocket may be in the process of closing.
|
|
149
|
+
*/
|
|
150
|
+
sendBeacon(payload: IngestPayload): void {
|
|
151
|
+
if (this.killed) return;
|
|
152
|
+
|
|
153
|
+
const chunks = this.chunkEvents(payload);
|
|
154
|
+
for (const chunk of chunks) {
|
|
155
|
+
try {
|
|
156
|
+
const body = JSON.stringify({ ...chunk, apiKey: this.publicApiKey });
|
|
157
|
+
if (body.length > MAX_KEEPALIVE_BYTES) {
|
|
158
|
+
// sendBeacon also has size limits; fall back to fetch for oversized chunks
|
|
159
|
+
this.sendHttpChunk(chunk);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const blob = new Blob([body], { type: 'application/json' });
|
|
163
|
+
navigator.sendBeacon(`${this.apiUrl}/v1/ingest`, blob);
|
|
164
|
+
} catch {
|
|
165
|
+
// Silently fail
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Tear down the WebSocket (called when the recorder stops). */
|
|
171
|
+
destroy(): void {
|
|
172
|
+
this.closed = true;
|
|
173
|
+
if (this.reconnectTimer) {
|
|
174
|
+
clearTimeout(this.reconnectTimer);
|
|
175
|
+
this.reconnectTimer = null;
|
|
176
|
+
}
|
|
177
|
+
if (this.ws) {
|
|
178
|
+
try { this.ws.close(1000); } catch {}
|
|
179
|
+
this.ws = null;
|
|
180
|
+
}
|
|
181
|
+
this.wsReady = false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Internal helpers ───────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Split a payload into chunks that each fit under the keepalive size limit.
|
|
188
|
+
* Events that are individually larger than the limit get their own chunk.
|
|
189
|
+
*/
|
|
190
|
+
private chunkEvents(payload: IngestPayload): IngestPayload[] {
|
|
191
|
+
const { events, ...rest } = payload;
|
|
192
|
+
if (events.length === 0) return [payload];
|
|
193
|
+
|
|
194
|
+
// Build the envelope once to know its overhead
|
|
195
|
+
const envelopeSize = JSON.stringify({ ...rest, events: [] }).length;
|
|
196
|
+
|
|
197
|
+
const chunks: IngestPayload[] = [];
|
|
198
|
+
let currentEvents: any[] = [];
|
|
199
|
+
let currentSize = envelopeSize;
|
|
200
|
+
|
|
201
|
+
for (const event of events) {
|
|
202
|
+
const eventSize = JSON.stringify(event).length + 1; // +1 for comma separator
|
|
203
|
+
|
|
204
|
+
if (currentEvents.length > 0 && currentSize + eventSize > MAX_KEEPALIVE_BYTES) {
|
|
205
|
+
chunks.push({ ...rest, events: currentEvents });
|
|
206
|
+
currentEvents = [];
|
|
207
|
+
currentSize = envelopeSize;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
currentEvents.push(event);
|
|
211
|
+
currentSize += eventSize;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (currentEvents.length > 0) {
|
|
215
|
+
chunks.push({ ...rest, events: currentEvents });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return chunks;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private async sendHttpChunk(payload: IngestPayload): Promise<void> {
|
|
222
|
+
try {
|
|
223
|
+
const body = JSON.stringify(payload);
|
|
224
|
+
const res = await fetch(`${this.apiUrl}/v1/ingest`, {
|
|
225
|
+
method: 'POST',
|
|
226
|
+
headers: {
|
|
227
|
+
'Content-Type': 'application/json',
|
|
228
|
+
'x-api-key': this.publicApiKey,
|
|
229
|
+
},
|
|
230
|
+
body,
|
|
231
|
+
keepalive: body.length < MAX_KEEPALIVE_BYTES,
|
|
232
|
+
});
|
|
233
|
+
if (res.status === 401 || res.status === 403) {
|
|
234
|
+
this.killed = true;
|
|
235
|
+
console.warn('SessionSight: invalid API key. Ingestion disabled.');
|
|
236
|
+
}
|
|
237
|
+
} catch {
|
|
238
|
+
// Silently fail — don't break the host page
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export interface SessionSightConfig {
|
|
2
|
+
publicApiKey: string;
|
|
3
|
+
propertyId?: string; // defaults to 'dev' (localhost)
|
|
4
|
+
apiUrl?: string; // defaults to https://api.sessionsight.com
|
|
5
|
+
autoRecord?: boolean; // default true — set to false for manual recording control
|
|
6
|
+
enabled?: boolean | (() => boolean); // default true — pass false to defer, or a getter function for reactive consent
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface PrivacyConfig {
|
|
10
|
+
privacyMode: 'default' | 'relaxed';
|
|
11
|
+
excludePages: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RecordOptions {
|
|
15
|
+
preRecordSecs?: number; // 0-5, default 0 — include N seconds of pre-buffer
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SessionMetadata {
|
|
19
|
+
url: string;
|
|
20
|
+
referrer: string;
|
|
21
|
+
userAgent: string;
|
|
22
|
+
screenWidth: number;
|
|
23
|
+
screenHeight: number;
|
|
24
|
+
language: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface IngestPayload {
|
|
28
|
+
sessionId: string;
|
|
29
|
+
propertyId: string;
|
|
30
|
+
visitorId: string;
|
|
31
|
+
events: any[];
|
|
32
|
+
metadata?: SessionMetadata;
|
|
33
|
+
userId?: string | null;
|
|
34
|
+
userProperties?: Record<string, string | number | boolean>;
|
|
35
|
+
seqStart?: number;
|
|
36
|
+
seqEnd?: number;
|
|
37
|
+
final?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── Worker message protocol ──────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/** Main thread → Worker */
|
|
43
|
+
export type WorkerInMessage =
|
|
44
|
+
| { type: 'init'; apiUrl: string; publicApiKey: string; propertyId: string; sessionId: string; visitorId: string }
|
|
45
|
+
| { type: 'event'; event: any; seq: number }
|
|
46
|
+
| { type: 'metadata'; metadata: SessionMetadata }
|
|
47
|
+
| { type: 'identify'; userId: string; userProperties?: Record<string, string | number | boolean> }
|
|
48
|
+
| { type: 'flush' }
|
|
49
|
+
| { type: 'flush-final' };
|
|
50
|
+
|
|
51
|
+
/** Worker → Main thread */
|
|
52
|
+
export type WorkerOutMessage =
|
|
53
|
+
| { type: 'ack'; seq: number }
|
|
54
|
+
| { type: 'privacy'; config: PrivacyConfig }
|
|
55
|
+
| { type: 'killed'; reason: string }
|
|
56
|
+
| { type: 'ready' };
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkerBridge: main-thread coordinator for the Web Worker transport.
|
|
3
|
+
*
|
|
4
|
+
* Assigns sequence numbers to events, maintains a mirror buffer of unacked
|
|
5
|
+
* events for sendBeacon fallback, and relays worker messages (privacy config,
|
|
6
|
+
* kill signals) back to the recorder.
|
|
7
|
+
*
|
|
8
|
+
* Falls back to inline Transport when Workers are unavailable.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Transport } from './transport.js';
|
|
12
|
+
import { createInlineWorker } from './worker-inline.js';
|
|
13
|
+
import type { PrivacyConfig, SessionMetadata, WorkerOutMessage, IngestPayload } from './types.js';
|
|
14
|
+
|
|
15
|
+
const FLUSH_INTERVAL_MS = 6_000;
|
|
16
|
+
const FLUSH_EVENT_THRESHOLD = 50;
|
|
17
|
+
const MAX_KEEPALIVE_BYTES = 60_000;
|
|
18
|
+
|
|
19
|
+
export class WorkerBridge {
|
|
20
|
+
private worker: Worker | null = null;
|
|
21
|
+
private seq = 0;
|
|
22
|
+
private lastAckedSeq = 0;
|
|
23
|
+
private mirrorBuffer: Array<{ event: any; seq: number }> = [];
|
|
24
|
+
private _killed = false;
|
|
25
|
+
|
|
26
|
+
// Callbacks
|
|
27
|
+
private onPrivacyCallback: ((config: PrivacyConfig) => void) | null = null;
|
|
28
|
+
private onKilledCallback: (() => void) | null = null;
|
|
29
|
+
|
|
30
|
+
// Session context (needed for sendBeacon payloads)
|
|
31
|
+
private sessionId: string;
|
|
32
|
+
private propertyId: string;
|
|
33
|
+
private visitorId: string;
|
|
34
|
+
private apiUrl: string;
|
|
35
|
+
private publicApiKey: string;
|
|
36
|
+
|
|
37
|
+
// Metadata and identify state (for sendBeacon payloads)
|
|
38
|
+
private metadata: SessionMetadata | null = null;
|
|
39
|
+
private metadataSent = false;
|
|
40
|
+
private userId: string | null = null;
|
|
41
|
+
private userProperties: Record<string, string | number | boolean> | null = null;
|
|
42
|
+
private userPropertiesDirty = false;
|
|
43
|
+
|
|
44
|
+
// Fallback: inline transport + buffer when Workers unavailable
|
|
45
|
+
private fallbackTransport: Transport | null = null;
|
|
46
|
+
private fallbackBuffer: any[] = [];
|
|
47
|
+
private fallbackFlushTimer: ReturnType<typeof setInterval> | null = null;
|
|
48
|
+
|
|
49
|
+
constructor(
|
|
50
|
+
apiUrl: string,
|
|
51
|
+
publicApiKey: string,
|
|
52
|
+
propertyId: string,
|
|
53
|
+
sessionId: string,
|
|
54
|
+
visitorId: string,
|
|
55
|
+
) {
|
|
56
|
+
this.apiUrl = apiUrl;
|
|
57
|
+
this.publicApiKey = publicApiKey;
|
|
58
|
+
this.propertyId = propertyId;
|
|
59
|
+
this.sessionId = sessionId;
|
|
60
|
+
this.visitorId = visitorId;
|
|
61
|
+
|
|
62
|
+
const worker = createInlineWorker();
|
|
63
|
+
if (worker) {
|
|
64
|
+
this.worker = worker;
|
|
65
|
+
this.worker.onmessage = this.handleWorkerMessage;
|
|
66
|
+
this.worker.onerror = (e) => {
|
|
67
|
+
console.warn('SessionSight: worker error, falling back to main thread', e);
|
|
68
|
+
this.switchToFallback();
|
|
69
|
+
};
|
|
70
|
+
this.worker.postMessage({
|
|
71
|
+
type: 'init',
|
|
72
|
+
apiUrl,
|
|
73
|
+
publicApiKey,
|
|
74
|
+
propertyId,
|
|
75
|
+
sessionId,
|
|
76
|
+
visitorId,
|
|
77
|
+
});
|
|
78
|
+
} else {
|
|
79
|
+
this.initFallback();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Public API ─────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
postEvent(event: any): void {
|
|
86
|
+
if (this._killed) return;
|
|
87
|
+
|
|
88
|
+
const seq = ++this.seq;
|
|
89
|
+
|
|
90
|
+
if (this.worker) {
|
|
91
|
+
this.mirrorBuffer.push({ event, seq });
|
|
92
|
+
try {
|
|
93
|
+
this.worker.postMessage({ type: 'event', event, seq });
|
|
94
|
+
} catch (e) {
|
|
95
|
+
console.warn('SessionSight: postMessage failed, falling back', e);
|
|
96
|
+
this.switchToFallback();
|
|
97
|
+
this.fallbackBuffer.push(event);
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
this.fallbackBuffer.push(event);
|
|
101
|
+
if (this.fallbackBuffer.length >= FLUSH_EVENT_THRESHOLD) {
|
|
102
|
+
this.fallbackFlush();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
postMetadata(metadata: SessionMetadata): void {
|
|
108
|
+
this.metadata = metadata;
|
|
109
|
+
if (this.worker) {
|
|
110
|
+
try { this.worker.postMessage({ type: 'metadata', metadata }); } catch {}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
postIdentify(userId: string, userProperties?: Record<string, string | number | boolean>): void {
|
|
115
|
+
this.userId = userId;
|
|
116
|
+
if (userProperties) {
|
|
117
|
+
this.userProperties = { ...(this.userProperties || {}), ...userProperties };
|
|
118
|
+
this.userPropertiesDirty = true;
|
|
119
|
+
}
|
|
120
|
+
if (this.worker) {
|
|
121
|
+
try { this.worker.postMessage({ type: 'identify', userId, userProperties }); } catch {}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Trigger an immediate flush (e.g. before page exclusion pause). */
|
|
126
|
+
flush(): void {
|
|
127
|
+
if (this.worker) {
|
|
128
|
+
try { this.worker.postMessage({ type: 'flush' }); } catch {}
|
|
129
|
+
} else {
|
|
130
|
+
this.fallbackFlush();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Page unload handler. Sends flush-final to worker AND fires sendBeacon
|
|
136
|
+
* with unacked events from the mirror buffer as a safety net.
|
|
137
|
+
*/
|
|
138
|
+
sendBeacon(): void {
|
|
139
|
+
// Tell the worker to flush and close
|
|
140
|
+
if (this.worker) {
|
|
141
|
+
try { this.worker.postMessage({ type: 'flush-final' }); } catch {}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Main-thread sendBeacon with unacked events
|
|
145
|
+
const unacked = this.worker
|
|
146
|
+
? this.mirrorBuffer.filter(e => e.seq > this.lastAckedSeq)
|
|
147
|
+
: this.fallbackBuffer.splice(0).map((event, i) => ({ event, seq: this.lastAckedSeq + i + 1 }));
|
|
148
|
+
|
|
149
|
+
if (unacked.length === 0) return;
|
|
150
|
+
|
|
151
|
+
const events = unacked.map(e => e.event);
|
|
152
|
+
const payload: IngestPayload = {
|
|
153
|
+
sessionId: this.sessionId,
|
|
154
|
+
propertyId: this.propertyId,
|
|
155
|
+
visitorId: this.visitorId,
|
|
156
|
+
events,
|
|
157
|
+
seqStart: unacked[0]!.seq,
|
|
158
|
+
seqEnd: unacked[unacked.length - 1]!.seq,
|
|
159
|
+
final: true,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
if (this.userId) payload.userId = this.userId;
|
|
163
|
+
if (this.userPropertiesDirty && this.userProperties) {
|
|
164
|
+
payload.userProperties = { ...this.userProperties };
|
|
165
|
+
}
|
|
166
|
+
if (!this.metadataSent && this.metadata) {
|
|
167
|
+
payload.metadata = this.metadata;
|
|
168
|
+
this.metadataSent = true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const body = JSON.stringify({ ...payload, apiKey: this.publicApiKey });
|
|
173
|
+
if (body.length <= MAX_KEEPALIVE_BYTES) {
|
|
174
|
+
const blob = new Blob([body], { type: 'application/json' });
|
|
175
|
+
navigator.sendBeacon(`${this.apiUrl}/v1/ingest`, blob);
|
|
176
|
+
} else {
|
|
177
|
+
// Oversized: fire fetch with keepalive as best-effort
|
|
178
|
+
fetch(`${this.apiUrl}/v1/ingest`, {
|
|
179
|
+
method: 'POST',
|
|
180
|
+
headers: { 'Content-Type': 'application/json', 'x-api-key': this.publicApiKey },
|
|
181
|
+
body,
|
|
182
|
+
keepalive: true,
|
|
183
|
+
}).catch(() => {});
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
// Silently fail
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
onPrivacy(callback: (config: PrivacyConfig) => void): void {
|
|
191
|
+
this.onPrivacyCallback = callback;
|
|
192
|
+
// Also register on fallback transport if active
|
|
193
|
+
if (this.fallbackTransport) {
|
|
194
|
+
this.fallbackTransport.onPrivacy(callback);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
onKilled(callback: () => void): void {
|
|
199
|
+
this.onKilledCallback = callback;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
isKilled(): boolean {
|
|
203
|
+
return this._killed;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
destroy(): void {
|
|
207
|
+
if (this.worker) {
|
|
208
|
+
try { this.worker.terminate(); } catch {}
|
|
209
|
+
this.worker = null;
|
|
210
|
+
}
|
|
211
|
+
if (this.fallbackTransport) {
|
|
212
|
+
this.fallbackTransport.destroy();
|
|
213
|
+
this.fallbackTransport = null;
|
|
214
|
+
}
|
|
215
|
+
if (this.fallbackFlushTimer) {
|
|
216
|
+
clearInterval(this.fallbackFlushTimer);
|
|
217
|
+
this.fallbackFlushTimer = null;
|
|
218
|
+
}
|
|
219
|
+
this.mirrorBuffer = [];
|
|
220
|
+
this.fallbackBuffer = [];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── Worker message handling ────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
private handleWorkerMessage = (e: MessageEvent<WorkerOutMessage>): void => {
|
|
226
|
+
try {
|
|
227
|
+
const msg = e.data;
|
|
228
|
+
switch (msg.type) {
|
|
229
|
+
case 'ack':
|
|
230
|
+
this.lastAckedSeq = msg.seq;
|
|
231
|
+
// Prune mirror buffer: remove all entries up to acked seq
|
|
232
|
+
while (this.mirrorBuffer.length > 0 && this.mirrorBuffer[0]!.seq <= msg.seq) {
|
|
233
|
+
this.mirrorBuffer.shift();
|
|
234
|
+
}
|
|
235
|
+
break;
|
|
236
|
+
|
|
237
|
+
case 'privacy':
|
|
238
|
+
if (this.onPrivacyCallback) this.onPrivacyCallback(msg.config);
|
|
239
|
+
break;
|
|
240
|
+
|
|
241
|
+
case 'killed':
|
|
242
|
+
this._killed = true;
|
|
243
|
+
if (this.onKilledCallback) this.onKilledCallback();
|
|
244
|
+
break;
|
|
245
|
+
|
|
246
|
+
case 'ready':
|
|
247
|
+
// WebSocket connected in worker
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
} catch (err) {
|
|
251
|
+
console.warn('SessionSight: error handling worker message', err);
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// ── Fallback (inline transport) ────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
private initFallback(): void {
|
|
258
|
+
this.fallbackTransport = new Transport(this.apiUrl, this.publicApiKey, this.propertyId);
|
|
259
|
+
if (this.onPrivacyCallback) {
|
|
260
|
+
this.fallbackTransport.onPrivacy(this.onPrivacyCallback);
|
|
261
|
+
}
|
|
262
|
+
this.fallbackFlushTimer = setInterval(() => this.fallbackFlush(), FLUSH_INTERVAL_MS);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Switch from worker mode to fallback after a worker error. */
|
|
266
|
+
private switchToFallback(): void {
|
|
267
|
+
if (this.fallbackTransport) return; // already switched
|
|
268
|
+
|
|
269
|
+
// Terminate broken worker
|
|
270
|
+
if (this.worker) {
|
|
271
|
+
try { this.worker.terminate(); } catch {}
|
|
272
|
+
this.worker = null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
this.initFallback();
|
|
276
|
+
|
|
277
|
+
// Re-send unacked events from mirror
|
|
278
|
+
for (const entry of this.mirrorBuffer) {
|
|
279
|
+
this.fallbackBuffer.push(entry.event);
|
|
280
|
+
}
|
|
281
|
+
this.mirrorBuffer = [];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private fallbackFlush(): void {
|
|
285
|
+
if (!this.fallbackTransport || this.fallbackBuffer.length === 0) return;
|
|
286
|
+
|
|
287
|
+
if (this.fallbackTransport.isKilled()) {
|
|
288
|
+
this._killed = true;
|
|
289
|
+
this.fallbackBuffer = [];
|
|
290
|
+
if (this.fallbackFlushTimer) { clearInterval(this.fallbackFlushTimer); this.fallbackFlushTimer = null; }
|
|
291
|
+
if (this.onKilledCallback) this.onKilledCallback();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const events = this.fallbackBuffer.splice(0);
|
|
296
|
+
const payload: IngestPayload = {
|
|
297
|
+
sessionId: this.sessionId,
|
|
298
|
+
propertyId: this.propertyId,
|
|
299
|
+
visitorId: this.visitorId,
|
|
300
|
+
events,
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
if (this.userId) payload.userId = this.userId;
|
|
304
|
+
if (this.userPropertiesDirty && this.userProperties) {
|
|
305
|
+
payload.userProperties = { ...this.userProperties };
|
|
306
|
+
this.userPropertiesDirty = false;
|
|
307
|
+
}
|
|
308
|
+
if (!this.metadataSent && this.metadata) {
|
|
309
|
+
payload.metadata = this.metadata;
|
|
310
|
+
this.metadataSent = true;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
this.fallbackTransport.send(payload);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates a Web Worker from an inlined blob URL.
|
|
3
|
+
*
|
|
4
|
+
* The WORKER_SOURCE string is imported from _worker-bundle.ts, which is generated
|
|
5
|
+
* by running `bun run build:worker` (or the full `bun run build`).
|
|
6
|
+
* Run `bun run build:worker` once before starting the dev server.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { WORKER_SOURCE } from './_worker-bundle.js';
|
|
10
|
+
|
|
11
|
+
export function createInlineWorker(): Worker | null {
|
|
12
|
+
try {
|
|
13
|
+
if (typeof Worker === 'undefined' || !WORKER_SOURCE) return null;
|
|
14
|
+
const blob = new Blob([WORKER_SOURCE], { type: 'application/javascript' });
|
|
15
|
+
const url = URL.createObjectURL(blob);
|
|
16
|
+
const worker = new Worker(url);
|
|
17
|
+
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
18
|
+
return worker;
|
|
19
|
+
} catch (e) {
|
|
20
|
+
console.warn('SessionSight: failed to create worker, using main-thread fallback', e);
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|