@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/src/worker.ts ADDED
@@ -0,0 +1,302 @@
1
+ /**
2
+ * SessionSight Insights Web Worker
3
+ *
4
+ * Owns the event buffer, flush lifecycle, and WebSocket/HTTP transport.
5
+ * Receives pre-masked events from the main thread via postMessage.
6
+ * This file is bundled into a string and inlined as a blob URL at build time.
7
+ */
8
+
9
+ // ── Types (inlined to avoid cross-bundle imports) ────────────────
10
+
11
+ interface IngestPayload {
12
+ sessionId: string;
13
+ propertyId: string;
14
+ visitorId: string;
15
+ events: any[];
16
+ metadata?: SessionMetadata;
17
+ userId?: string | null;
18
+ userProperties?: Record<string, string | number | boolean>;
19
+ seqStart?: number;
20
+ seqEnd?: number;
21
+ final?: boolean;
22
+ }
23
+
24
+ interface SessionMetadata {
25
+ url: string;
26
+ referrer: string;
27
+ userAgent: string;
28
+ screenWidth: number;
29
+ screenHeight: number;
30
+ language: string;
31
+ }
32
+
33
+ interface PrivacyConfig {
34
+ privacyMode: 'default' | 'relaxed';
35
+ excludePages: string[];
36
+ }
37
+
38
+ // ── Configuration ────────────────────────────────────────────────
39
+
40
+ const FLUSH_INTERVAL_MS = 6_000;
41
+ const FLUSH_EVENT_THRESHOLD = 50;
42
+ const MAX_KEEPALIVE_BYTES = 60_000;
43
+
44
+ // ── Worker state ─────────────────────────────────────────────────
45
+
46
+ let apiUrl = '';
47
+ let publicApiKey = '';
48
+ let propertyId = '';
49
+ let sessionId = '';
50
+ let visitorId = '';
51
+ let userId: string | null = null;
52
+ let userProperties: Record<string, string | number | boolean> | null = null;
53
+ let userPropertiesDirty = false;
54
+ let metadata: SessionMetadata | null = null;
55
+ let metadataSent = false;
56
+ let killed = false;
57
+
58
+ // Buffer of { event, seq } entries
59
+ const buffer: Array<{ event: any; seq: number }> = [];
60
+ let flushTimer: ReturnType<typeof setInterval> | null = null;
61
+
62
+ // ── WebSocket transport ──────────────────────────────────────────
63
+
64
+ let ws: WebSocket | null = null;
65
+ let wsReady = false;
66
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
67
+ let reconnectDelay = 1_000;
68
+ const MAX_RECONNECT_DELAY = 30_000;
69
+ let closed = false;
70
+
71
+ function connectWs(): void {
72
+ if (killed || closed) return;
73
+
74
+ try {
75
+ const wsUrl = apiUrl.replace(/^http/, 'ws').replace(/\/$/, '');
76
+ ws = new WebSocket(`${wsUrl}/ws/ingest?key=${encodeURIComponent(publicApiKey)}&propertyId=${encodeURIComponent(propertyId)}`);
77
+
78
+ ws.onmessage = (event) => {
79
+ try {
80
+ const msg = JSON.parse(typeof event.data === 'string' ? event.data : '');
81
+ if (msg.type === 'ready') {
82
+ wsReady = true;
83
+ reconnectDelay = 1_000;
84
+ self.postMessage({ type: 'ready' });
85
+ if (msg.privacy) {
86
+ self.postMessage({ type: 'privacy', config: msg.privacy });
87
+ }
88
+ }
89
+ } catch {
90
+ // ignore non-JSON messages
91
+ }
92
+ };
93
+
94
+ ws.onclose = (event) => {
95
+ wsReady = false;
96
+ ws = null;
97
+
98
+ if (event.code === 4001) {
99
+ killed = true;
100
+ self.postMessage({ type: 'killed', reason: 'invalid_api_key' });
101
+ return;
102
+ }
103
+ if (event.code === 4002) {
104
+ killed = true;
105
+ self.postMessage({ type: 'killed', reason: 'subscription_required' });
106
+ return;
107
+ }
108
+
109
+ scheduleReconnect();
110
+ };
111
+
112
+ ws.onerror = () => {
113
+ // onclose fires after onerror, reconnect handled there
114
+ };
115
+ } catch {
116
+ scheduleReconnect();
117
+ }
118
+ }
119
+
120
+ function scheduleReconnect(): void {
121
+ if (killed || closed || reconnectTimer) return;
122
+ reconnectTimer = setTimeout(() => {
123
+ reconnectTimer = null;
124
+ connectWs();
125
+ }, reconnectDelay);
126
+ reconnectDelay = Math.min(reconnectDelay * 2 + Math.random() * 500, MAX_RECONNECT_DELAY);
127
+ }
128
+
129
+ // ── Flush logic ──────────────────────────────────────────────────
130
+
131
+ function flush(isFinal: boolean = false): void {
132
+ if (killed || buffer.length === 0) return;
133
+
134
+ const entries = buffer.splice(0);
135
+ const events = entries.map(e => e.event);
136
+ const seqStart = entries[0]!.seq;
137
+ const seqEnd = entries[entries.length - 1]!.seq;
138
+
139
+ const payload: IngestPayload = {
140
+ sessionId,
141
+ propertyId,
142
+ visitorId,
143
+ events,
144
+ seqStart,
145
+ seqEnd,
146
+ };
147
+
148
+ if (userId) payload.userId = userId;
149
+
150
+ if (userPropertiesDirty && userProperties) {
151
+ payload.userProperties = { ...userProperties };
152
+ userPropertiesDirty = false;
153
+ }
154
+
155
+ if (!metadataSent && metadata) {
156
+ payload.metadata = metadata;
157
+ metadataSent = true;
158
+ }
159
+
160
+ if (isFinal) {
161
+ payload.final = true;
162
+ }
163
+
164
+ sendPayload(payload, seqEnd);
165
+ }
166
+
167
+ function sendPayload(payload: IngestPayload, seqEnd: number): void {
168
+ // Try WebSocket first
169
+ if (wsReady && ws?.readyState === WebSocket.OPEN) {
170
+ try {
171
+ ws.send(JSON.stringify(payload));
172
+ self.postMessage({ type: 'ack', seq: seqEnd });
173
+ return;
174
+ } catch {
175
+ // WS send failed, fall through to HTTP
176
+ }
177
+ }
178
+
179
+ // HTTP fallback: ack only after successful send
180
+ sendHttpAsync(payload, seqEnd);
181
+ }
182
+
183
+ async function sendHttpAsync(payload: IngestPayload, seqEnd: number): Promise<void> {
184
+ const chunks = chunkEvents(payload);
185
+ let anyFailed = false;
186
+ for (const chunk of chunks) {
187
+ try {
188
+ const body = JSON.stringify(chunk);
189
+ const res = await fetch(`${apiUrl}/v1/ingest`, {
190
+ method: 'POST',
191
+ headers: {
192
+ 'Content-Type': 'application/json',
193
+ 'x-api-key': publicApiKey,
194
+ },
195
+ body,
196
+ keepalive: body.length < MAX_KEEPALIVE_BYTES,
197
+ });
198
+ if (res.status === 401 || res.status === 403) {
199
+ killed = true;
200
+ self.postMessage({ type: 'killed', reason: 'invalid_api_key' });
201
+ return;
202
+ }
203
+ } catch {
204
+ anyFailed = true;
205
+ }
206
+ }
207
+ // Only ack if all chunks sent successfully, so the mirror buffer retains
208
+ // unacked events for the sendBeacon fallback on page unload.
209
+ if (!anyFailed) {
210
+ self.postMessage({ type: 'ack', seq: seqEnd });
211
+ }
212
+ }
213
+
214
+ function chunkEvents(payload: IngestPayload): IngestPayload[] {
215
+ const { events, ...rest } = payload;
216
+ if (events.length === 0) return [payload];
217
+
218
+ const envelopeSize = JSON.stringify({ ...rest, events: [] }).length;
219
+ const chunks: IngestPayload[] = [];
220
+ let currentEvents: any[] = [];
221
+ let currentSize = envelopeSize;
222
+
223
+ for (const event of events) {
224
+ const eventSize = JSON.stringify(event).length + 1;
225
+ if (currentEvents.length > 0 && currentSize + eventSize > MAX_KEEPALIVE_BYTES) {
226
+ chunks.push({ ...rest, events: currentEvents });
227
+ currentEvents = [];
228
+ currentSize = envelopeSize;
229
+ }
230
+ currentEvents.push(event);
231
+ currentSize += eventSize;
232
+ }
233
+
234
+ if (currentEvents.length > 0) {
235
+ chunks.push({ ...rest, events: currentEvents });
236
+ }
237
+
238
+ return chunks;
239
+ }
240
+
241
+ // ── Message handler ──────────────────────────────────────────────
242
+
243
+ self.onmessage = (e: MessageEvent) => {
244
+ try {
245
+ const msg = e.data;
246
+ switch (msg.type) {
247
+ case 'init':
248
+ apiUrl = msg.apiUrl;
249
+ publicApiKey = msg.publicApiKey;
250
+ propertyId = msg.propertyId;
251
+ sessionId = msg.sessionId;
252
+ visitorId = msg.visitorId;
253
+ connectWs();
254
+ flushTimer = setInterval(() => flush(), FLUSH_INTERVAL_MS);
255
+ break;
256
+
257
+ case 'event':
258
+ if (killed) break;
259
+ buffer.push({ event: msg.event, seq: msg.seq });
260
+ if (buffer.length >= FLUSH_EVENT_THRESHOLD) {
261
+ flush();
262
+ }
263
+ break;
264
+
265
+ case 'metadata':
266
+ metadata = msg.metadata;
267
+ break;
268
+
269
+ case 'identify':
270
+ userId = msg.userId;
271
+ if (msg.userProperties) {
272
+ userProperties = { ...(userProperties || {}), ...msg.userProperties };
273
+ userPropertiesDirty = true;
274
+ }
275
+ break;
276
+
277
+ case 'flush':
278
+ flush();
279
+ break;
280
+
281
+ case 'flush-final':
282
+ if (flushTimer) {
283
+ clearInterval(flushTimer);
284
+ flushTimer = null;
285
+ }
286
+ flush(true);
287
+ closed = true;
288
+ if (ws) {
289
+ try { ws.close(1000); } catch {}
290
+ ws = null;
291
+ }
292
+ wsReady = false;
293
+ if (reconnectTimer) {
294
+ clearTimeout(reconnectTimer);
295
+ reconnectTimer = null;
296
+ }
297
+ break;
298
+ }
299
+ } catch (err) {
300
+ console.warn('SessionSight worker: error handling message', err);
301
+ }
302
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "declaration": true,
6
+ "noEmit": false,
7
+ "rootDir": "./src",
8
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
9
+ "module": "ESNext",
10
+ "moduleResolution": "bundler"
11
+ },
12
+ "include": ["src/**/*.ts"]
13
+ }