@obsrviq/tracker 0.4.2 → 0.6.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/transport.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { gzipSync } from 'fflate';
2
2
  import type { IngestBatch, IngestSessionMeta, ObsrviqEvent } from '@obsrviq/types';
3
+ import { redactBatchSensitiveKeys, isMaskConfigPending } from './mask.js';
3
4
 
4
5
  export interface TransportOptions {
5
6
  endpoint: string;
@@ -15,15 +16,50 @@ export interface TransportOptions {
15
16
  * the host persist progress synchronously so a later page load resumes without a
16
17
  * seq collision even if an unload interrupts the in-flight request. */
17
18
  onSeqDispatch?: (nextSeq: number) => void;
19
+ /** Called after backpressure had to drop buffered DOM deltas — the host should
20
+ * force a fresh rrweb FullSnapshot so the replay stream re-anchors instead of
21
+ * playing corrupt deltas until the next scheduled checkpoint. */
22
+ onDomDrop?: () => void;
18
23
  /** test/observability hook */
19
24
  onFlush?: (info: { bytes: number; events: number; ok: boolean }) => void;
20
25
  }
21
26
 
22
27
  const MAX_BUFFER_EVENTS = 5000; // backpressure cap (R-5)
28
+ /** Split batches whose JSON exceeds this before sending — well under the ingest
29
+ * body cap (16MB gzip) even at gzip-hostile content, and keeps main-thread
30
+ * stringify/compress work per flush bounded. */
31
+ const MAX_BATCH_JSON_BYTES = 4 * 1024 * 1024;
32
+ /** Target gzip size per unload piece — sendBeacon/keepalive cap in-flight bodies
33
+ * at ~64KB, so pieces are kept comfortably under it. */
34
+ const BEACON_PIECE_BYTES = 56 * 1024;
35
+ /** localStorage spill of batches an unload couldn't beacon out (queue full /
36
+ * oversized). Resent by the next page load. Bounded so we respect the ~5MB
37
+ * origin quota shared with the app. */
38
+ const SPILL_KEY = 'obsrviq.spill';
39
+ const SPILL_MAX_BYTES = 1_500_000;
40
+ const SPILL_MAX_AGE_MS = 24 * 60 * 60 * 1000;
41
+
42
+ // rrweb event types we need to recognise for snapshot-aware dropping.
43
+ const RR_FULL_SNAPSHOT = 2;
44
+ const RR_META = 4;
45
+
46
+ /** Outcome of one network attempt — drives retry/split/drop decisions. */
47
+ type SendResult = 'ok' | 'too_large' | 'fatal' | 'retry';
48
+
49
+ interface SpillEntry {
50
+ /** siteKey — so multi-site pages only resend their own batches. */
51
+ k: string;
52
+ /** base64 of the gzipped, fully-stamped IngestBatch (self-contained). */
53
+ b: string;
54
+ ts: number;
55
+ }
23
56
 
24
57
  export class Transport {
25
58
  private buffer: ObsrviqEvent[] = [];
26
59
  private seq: number;
60
+ /** Floor set by an unload flush that consumed seqs out-of-band (beacon pieces),
61
+ * so the normal path can never re-use one of them. */
62
+ private seqFloor = 0;
27
63
  private timer: ReturnType<typeof setInterval> | undefined;
28
64
  private sending = false;
29
65
  private retryDelay = 1000;
@@ -37,28 +73,147 @@ export class Transport {
37
73
  this.timer = setInterval(() => void this.flush('timer'), this.opts.flushIntervalMs);
38
74
  document.addEventListener('visibilitychange', this.onVisibility, { capture: true });
39
75
  window.addEventListener('pagehide', this.onPageHide, { capture: true });
76
+ // Recover anything a previous page's unload had to spill to localStorage.
77
+ this.resendSpill();
40
78
  }
41
79
 
42
80
  enqueue(event: ObsrviqEvent) {
43
81
  if (this.stopped) return;
44
82
  this.buffer.push(event);
45
83
  // Backpressure: if we've buffered a lot, flush early; if still over after a
46
- // failed flush, drop the oldest DOM events (keep structured signal).
84
+ // failed flush, shed DOM backlog without corrupting the delta stream.
47
85
  if (this.buffer.length >= MAX_BUFFER_EVENTS) {
48
86
  void this.flush('backpressure');
49
- if (this.buffer.length >= MAX_BUFFER_EVENTS) {
50
- this.buffer = this.buffer.filter((e, i) => e.type !== 'dom' || i % 2 === 0);
87
+ if (this.buffer.length >= MAX_BUFFER_EVENTS) this.shedDomBacklog();
88
+ }
89
+ }
90
+
91
+ /** Drop buffered DOM events WITHOUT breaking rrweb's delta chain. Deltas only
92
+ * apply on top of everything before them, so arbitrary dropping corrupts the
93
+ * replay until the next 30s checkpoint. Instead: if the buffer holds a
94
+ * FullSnapshot, drop only the DOM events BEFORE it (the snapshot re-anchors
95
+ * everything after). Otherwise drop ALL buffered DOM and ask the recorder for
96
+ * a fresh FullSnapshot so the stream re-anchors immediately. Non-DOM events
97
+ * (console/network/errors/…) are independent and kept. */
98
+ private shedDomBacklog() {
99
+ let snapIdx = -1;
100
+ for (let i = this.buffer.length - 1; i >= 0; i--) {
101
+ const e = this.buffer[i];
102
+ if (e?.type === 'dom' && (e.data as { type?: number } | undefined)?.type === RR_FULL_SNAPSHOT) {
103
+ snapIdx = i;
104
+ break;
51
105
  }
52
106
  }
107
+ if (snapIdx > 0) {
108
+ // Keep from the snapshot's preceding Meta (rrweb emits Meta → FullSnapshot).
109
+ let keepFrom = snapIdx;
110
+ const prev = this.buffer[snapIdx - 1];
111
+ if (prev?.type === 'dom' && (prev.data as { type?: number } | undefined)?.type === RR_META) {
112
+ keepFrom = snapIdx - 1;
113
+ }
114
+ const kept = this.buffer.filter((e, i) => e.type !== 'dom' || i >= keepFrom);
115
+ if (kept.length < this.buffer.length) {
116
+ this.buffer = kept;
117
+ if (this.buffer.length < MAX_BUFFER_EVENTS) return;
118
+ }
119
+ }
120
+ // No snapshot to anchor on (or one window alone overflows): drop all DOM and
121
+ // re-anchor with a fresh snapshot.
122
+ this.buffer = this.buffer.filter((e) => e.type !== 'dom');
123
+ // Pathological non-DOM flood: shed oldest half so the buffer stays bounded.
124
+ if (this.buffer.length >= MAX_BUFFER_EVENTS) {
125
+ this.buffer = this.buffer.slice(-Math.floor(MAX_BUFFER_EVENTS / 2));
126
+ }
127
+ this.opts.onDomDrop?.();
53
128
  }
54
129
 
55
130
  /** Build, compress, and send the current buffer. Returns true on success. */
56
131
  async flush(reason: 'timer' | 'hidden' | 'pagehide' | 'manual' | 'backpressure'): Promise<boolean> {
132
+ // Unload path is separate: it must run even mid-send (the in-flight fetch is
133
+ // about to be killed by navigation) and can't await anything.
134
+ if (reason === 'pagehide' || reason === 'hidden') return this.flushUnload();
135
+
57
136
  if (this.sending || this.buffer.length === 0) return true;
137
+
138
+ // Hold non-unload sends until the per-site mask config has loaded, so a
139
+ // remote-only custom key can't leak in the brief init fetch window. The fetch
140
+ // is bounded (index.ts marks config ready within ~2s even on failure/timeout),
141
+ // so this delays at most the first batch — it never blocks data indefinitely.
142
+ // A stopped transport can't wait (there'll be no retry) — its final flush
143
+ // proceeds best-effort with whatever keys have loaded, like the unload path.
144
+ if (isMaskConfigPending() && !this.stopped) {
145
+ setTimeout(() => void this.flush('timer'), 200);
146
+ return true;
147
+ }
148
+
58
149
  this.sending = true;
59
150
  const events = this.buffer;
60
151
  this.buffer = [];
61
152
 
153
+ // Send-time safety net: mask sensitive keys across the batch — the built-in
154
+ // list (password/token/secret/…) PLUS any customer-defined keys — so a
155
+ // `{"password":"…"}` style body can't leave the page unmasked. Network/WS
156
+ // bodies get PII-pattern masking at capture but not key-based masking, so this
157
+ // is where key redaction lands for them; it also covers custom keys captured
158
+ // before the per-site config arrived.
159
+ redactBatchSensitiveKeys(events);
160
+
161
+ let ok: boolean;
162
+ try {
163
+ ok = await this.sendEvents(events);
164
+ } finally {
165
+ this.sending = false;
166
+ }
167
+ // stop() during this send no-oped (sending guard) — drain what it left
168
+ // behind, since no timer remains to pick it up. Only on success: a failure
169
+ // just re-buffered, and retrying here would spin against a dead network.
170
+ if (ok && this.stopped && this.buffer.length > 0) void this.flush('manual');
171
+ return ok;
172
+ }
173
+
174
+ /** Send events as one or more seq-stamped batches: oversized batches split in
175
+ * half (byte cap here, or a server 413), poison batches (other 4xx) are dropped
176
+ * instead of retried forever, and transient failures re-buffer + back off. */
177
+ private async sendEvents(events: ObsrviqEvent[]): Promise<boolean> {
178
+ const queue: ObsrviqEvent[][] = [events];
179
+ while (queue.length > 0) {
180
+ const slice = queue[0]!;
181
+ const result = await this.dispatch(slice);
182
+ if (result === 'ok') {
183
+ queue.shift();
184
+ continue;
185
+ }
186
+ if (result === 'too_large' && slice.length > 1) {
187
+ const mid = Math.ceil(slice.length / 2);
188
+ queue.splice(0, 1, slice.slice(0, mid), slice.slice(mid));
189
+ continue;
190
+ }
191
+ if (result === 'fatal' || result === 'too_large') {
192
+ // Rejected outright (bad key / revoked site / single event over the cap):
193
+ // retrying can never succeed — drop this slice, keep sending the rest.
194
+ queue.shift();
195
+ continue;
196
+ }
197
+ // Transient (network / 5xx / 429): re-buffer everything left, in original
198
+ // order ahead of anything captured meanwhile, and retry with jitter.
199
+ this.buffer = ([] as ObsrviqEvent[]).concat(...queue, this.buffer);
200
+ if (this.buffer.length >= MAX_BUFFER_EVENTS) this.shedDomBacklog();
201
+ if (!this.stopped) {
202
+ const jitter = this.retryDelay * (0.5 + Math.random());
203
+ this.retryDelay = Math.min(this.retryDelay * 2, 30_000);
204
+ setTimeout(() => void this.flush('timer'), jitter);
205
+ }
206
+ return false;
207
+ }
208
+ this.retryDelay = 1000;
209
+ return true;
210
+ }
211
+
212
+ /** One batch → one network attempt. Owns seq assignment/advance. */
213
+ private async dispatch(events: ObsrviqEvent[]): Promise<SendResult> {
214
+ // Never re-use a seq an unload flush consumed out-of-band.
215
+ if (this.seqFloor > this.seq) this.seq = this.seqFloor;
216
+
62
217
  let batch: IngestBatch | null = {
63
218
  siteKey: this.opts.siteKey,
64
219
  sessionId: this.opts.sessionId,
@@ -73,10 +228,12 @@ export class Transport {
73
228
  batch = null;
74
229
  }
75
230
  }
76
- if (!batch) {
77
- this.sending = false;
78
- return true; // dropped intentionally
79
- }
231
+ if (!batch) return 'ok'; // dropped intentionally
232
+
233
+ const json = JSON.stringify(batch);
234
+ // Byte cap: split BEFORE compressing/sending rather than bounce off the
235
+ // server's body limit (a lost batch) — sendEvents halves it and retries.
236
+ if (json.length > MAX_BATCH_JSON_BYTES && events.length > 1) return 'too_large';
80
237
 
81
238
  // Reserve the next seq + stamp activity SYNCHRONOUSLY at dispatch — before any
82
239
  // network await. This is what session continuation persists, so an unload that
@@ -86,84 +243,188 @@ export class Transport {
86
243
  // active-but-offline session isn't wrongly treated as idle and split.
87
244
  this.opts.onSeqDispatch?.(this.seq + 1);
88
245
 
89
- const json = JSON.stringify(batch);
90
- const gz = gzipSync(new TextEncoder().encode(json));
91
- const ok = await this.send(gz, reason === 'pagehide' || reason === 'hidden');
92
- this.opts.onFlush?.({ bytes: gz.byteLength, events: events.length, ok });
93
-
94
- if (ok) {
95
- this.seq++;
96
- this.retryDelay = 1000;
97
- this.sending = false;
98
- return true;
99
- }
246
+ const gz = await gzipBytes(new TextEncoder().encode(json));
247
+ const result = await this.send(gz);
248
+ this.opts.onFlush?.({ bytes: gz.byteLength, events: events.length, ok: result === 'ok' });
249
+ if (result === 'ok') this.seq = Math.max(this.seq + 1, this.seqFloor);
250
+ return result;
251
+ }
100
252
 
101
- // Failed: re-buffer (capped) and retry with jitter.
102
- this.buffer = events.slice(-MAX_BUFFER_EVENTS).concat(this.buffer);
103
- this.sending = false;
104
- if (!this.stopped) {
105
- const jitter = this.retryDelay * (0.5 + Math.random());
106
- this.retryDelay = Math.min(this.retryDelay * 2, 30_000);
107
- setTimeout(() => void this.flush('timer'), jitter);
253
+ private async send(bytes: Uint8Array): Promise<SendResult> {
254
+ const url = `${this.opts.endpoint.replace(/\/$/, '')}/v1/batch`;
255
+ // Normal flush: a plain fetch — NO `keepalive`, so there's no 64KB body cap
256
+ // and large FullSnapshot batches actually send. Await the real result so a
257
+ // failure re-buffers and retries instead of being silently dropped. A timeout
258
+ // aborts a hung request so it can't stall all future flushes (`sending` stays
259
+ // true until this resolves).
260
+ const ctrl = new AbortController();
261
+ const timeout = setTimeout(() => ctrl.abort(), 15_000);
262
+ try {
263
+ const res = await fetch(url, {
264
+ method: 'POST',
265
+ body: bytes as unknown as BodyInit,
266
+ headers: {
267
+ 'content-type': 'application/octet-stream',
268
+ 'x-obsrviq-key': this.opts.siteKey,
269
+ },
270
+ credentials: 'omit',
271
+ mode: 'cors',
272
+ signal: ctrl.signal,
273
+ });
274
+ if (res.ok) return 'ok';
275
+ if (res.status === 413) return 'too_large';
276
+ // 408 (timeout) and 429 (rate limit) are transient; other 4xx means the
277
+ // server will never accept this batch — retrying just re-loses it later.
278
+ if (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429) return 'fatal';
279
+ return 'retry';
280
+ } catch {
281
+ return 'retry';
282
+ } finally {
283
+ clearTimeout(timeout);
108
284
  }
109
- return false;
110
285
  }
111
286
 
112
- private async send(bytes: Uint8Array, unloading: boolean): Promise<boolean> {
113
- const url = `${this.opts.endpoint.replace(/\/$/, '')}/v1/batch`;
114
- const headers = {
115
- 'content-type': 'application/octet-stream',
116
- 'x-obsrviq-key': this.opts.siteKey,
117
- };
118
- const body = bytes as unknown as BodyInit;
119
-
120
- // Unload path: a normal fetch would be cancelled as the page goes away, so
121
- // use sendBeacon (survives navigation) or a keepalive fetch. BOTH cap the
122
- // body at ~64KB, so large (snapshot) batches are best-effort here — but the
123
- // periodic flushes below send them reliably during normal operation.
124
- if (unloading) {
125
- const blob = new Blob([bytes as unknown as BlobPart], { type: 'application/octet-stream' });
126
- if (typeof navigator.sendBeacon === 'function') {
287
+ /** Unload flush (pagehide / tab hidden). Fully synchronous — the page may be
288
+ * gone before any await resolves. Ignores the `sending` guard (that in-flight
289
+ * fetch is about to be cancelled; its seq is skipped via seqFloor), chunks the
290
+ * tail into beacon-sized pieces (sendBeacon caps bodies at ~64KB — one big
291
+ * batch would be rejected whole), and spills anything the beacon queue refuses
292
+ * to localStorage for the next page load to resend. */
293
+ private flushUnload(): boolean {
294
+ if (this.buffer.length === 0) return true;
295
+ const events = this.buffer;
296
+ this.buffer = [];
297
+ // Best-effort masking: built-in + whatever custom keys have loaded by now
298
+ // (an unload can't wait for the config fetch).
299
+ redactBatchSensitiveKeys(events);
300
+
301
+ // The in-flight batch (if any) holds this.seq start past it; a prior
302
+ // unload flush (hidden → pagehide) may have raised the floor further.
303
+ let seq = Math.max(this.sending ? this.seq + 1 : this.seq, this.seqFloor);
304
+
305
+ const pieces: Array<{ gz: Uint8Array; count: number }> = [];
306
+ const stack: ObsrviqEvent[][] = [events];
307
+ while (stack.length > 0) {
308
+ const slice = stack.shift()!;
309
+ let batch: IngestBatch | null = {
310
+ siteKey: this.opts.siteKey,
311
+ sessionId: this.opts.sessionId,
312
+ seq,
313
+ meta: this.opts.getMeta(),
314
+ events: slice,
315
+ };
316
+ if (this.opts.beforeSend) {
127
317
  try {
128
- if (navigator.sendBeacon(url, blob)) return true;
318
+ batch = this.opts.beforeSend(batch);
129
319
  } catch {
130
- /* fall through */
320
+ batch = null;
131
321
  }
132
322
  }
323
+ if (!batch) continue; // dropped intentionally
324
+ const gz = gzipSync(new TextEncoder().encode(JSON.stringify(batch)));
325
+ if (gz.byteLength > BEACON_PIECE_BYTES && slice.length > 1) {
326
+ // Too big for a beacon — halve and re-stamp (the seq wasn't consumed).
327
+ const mid = Math.ceil(slice.length / 2);
328
+ stack.unshift(slice.slice(0, mid), slice.slice(mid));
329
+ continue;
330
+ }
331
+ pieces.push({ gz, count: slice.length });
332
+ seq++;
333
+ }
334
+
335
+ // Persist seq progress BEFORE handing bytes to the beacon queue, so a resumed
336
+ // session can't collide with a piece that's still in flight after unload.
337
+ this.seqFloor = seq;
338
+ this.opts.onSeqDispatch?.(seq);
339
+
340
+ for (const { gz, count } of pieces) {
341
+ const sent = gz.byteLength <= BEACON_PIECE_BYTES && this.sendUnload(gz);
342
+ if (!sent) this.spill(gz); // beacon queue full / single oversized event
343
+ this.opts.onFlush?.({ bytes: gz.byteLength, events: count, ok: sent });
344
+ }
345
+ return true;
346
+ }
347
+
348
+ /** Fire-and-forget delivery that survives navigation. True = queued. */
349
+ private sendUnload(bytes: Uint8Array): boolean {
350
+ const url = `${this.opts.endpoint.replace(/\/$/, '')}/v1/batch`;
351
+ const blob = new Blob([bytes as unknown as BlobPart], { type: 'application/octet-stream' });
352
+ if (typeof navigator.sendBeacon === 'function') {
133
353
  try {
134
- void fetch(url, { method: 'POST', body, headers, keepalive: true, credentials: 'omit', mode: 'cors' }).catch(
135
- () => {},
136
- );
137
- return true;
354
+ if (navigator.sendBeacon(url, blob)) return true;
138
355
  } catch {
139
- return false;
356
+ /* fall through */
140
357
  }
141
358
  }
142
-
143
- // Normal flush: a plain fetch — NO `keepalive`, so there's no 64KB body cap
144
- // and large FullSnapshot batches actually send. Await the real result so a
145
- // failure (rejected / 4xx / 5xx / network) re-buffers and retries instead of
146
- // being silently dropped. A timeout aborts a hung request so it can't stall
147
- // all future flushes (`sending` stays true until this resolves).
148
- const ctrl = new AbortController();
149
- const timeout = setTimeout(() => ctrl.abort(), 15_000);
150
359
  try {
151
- const res = await fetch(url, {
360
+ void fetch(url, {
152
361
  method: 'POST',
153
- body,
154
- headers,
362
+ body: bytes as unknown as BodyInit,
363
+ headers: {
364
+ 'content-type': 'application/octet-stream',
365
+ 'x-obsrviq-key': this.opts.siteKey,
366
+ },
367
+ keepalive: true,
155
368
  credentials: 'omit',
156
369
  mode: 'cors',
157
- signal: ctrl.signal,
158
- });
159
- return res.ok;
370
+ }).catch(() => {});
371
+ return true;
160
372
  } catch {
161
373
  return false;
162
- } finally {
163
- clearTimeout(timeout);
164
374
  }
165
375
  }
166
376
 
377
+ /** Append one gzipped batch to the localStorage spill (bounded, oldest-first
378
+ * eviction). Quota/private-mode failures mean the tail is lost — same as
379
+ * before spilling existed. */
380
+ private spill(gz: Uint8Array) {
381
+ try {
382
+ const raw = window.localStorage.getItem(SPILL_KEY);
383
+ const entries: SpillEntry[] = raw ? (JSON.parse(raw) as SpillEntry[]) : [];
384
+ entries.push({ k: this.opts.siteKey, b: b64encode(gz), ts: Date.now() });
385
+ let total = entries.reduce((n, e) => n + e.b.length, 0);
386
+ while (entries.length > 0 && total > SPILL_MAX_BYTES) {
387
+ total -= entries[0]!.b.length;
388
+ entries.shift();
389
+ }
390
+ window.localStorage.setItem(SPILL_KEY, JSON.stringify(entries));
391
+ } catch {
392
+ /* unavailable / quota — drop */
393
+ }
394
+ }
395
+
396
+ /** Resend batches a previous page's unload spilled. Entries are claimed
397
+ * (removed) up front so two tabs can't double-send; still-transient failures
398
+ * are re-spilled for the next load. Fire-and-forget. */
399
+ private resendSpill() {
400
+ let mine: SpillEntry[] = [];
401
+ try {
402
+ const raw = window.localStorage.getItem(SPILL_KEY);
403
+ if (!raw) return;
404
+ const entries = JSON.parse(raw) as SpillEntry[];
405
+ const now = Date.now();
406
+ const fresh = entries.filter((e) => e && typeof e.b === 'string' && now - e.ts < SPILL_MAX_AGE_MS);
407
+ mine = fresh.filter((e) => e.k === this.opts.siteKey);
408
+ const others = fresh.filter((e) => e.k !== this.opts.siteKey);
409
+ if (others.length > 0) window.localStorage.setItem(SPILL_KEY, JSON.stringify(others));
410
+ else window.localStorage.removeItem(SPILL_KEY);
411
+ } catch {
412
+ return;
413
+ }
414
+ if (mine.length === 0) return;
415
+ void (async () => {
416
+ for (const entry of mine) {
417
+ try {
418
+ const result = await this.send(b64decode(entry.b));
419
+ if (result === 'retry') this.spill(b64decode(entry.b)); // network still down
420
+ // ok / fatal / too_large → done with it either way
421
+ } catch {
422
+ /* drop */
423
+ }
424
+ }
425
+ })();
426
+ }
427
+
167
428
  private onVisibility = () => {
168
429
  if (document.visibilityState === 'hidden') void this.flush('hidden');
169
430
  };
@@ -179,3 +440,35 @@ export class Transport {
179
440
  void this.flush('manual');
180
441
  }
181
442
  }
443
+
444
+ /** gzip — browser-native async CompressionStream when available (keeps multi-MB
445
+ * snapshot compression off the main thread), fflate's gzipSync otherwise. The
446
+ * unload path always uses gzipSync directly (it can't await). */
447
+ async function gzipBytes(bytes: Uint8Array): Promise<Uint8Array> {
448
+ if (typeof CompressionStream !== 'undefined') {
449
+ try {
450
+ const stream = new Blob([bytes as unknown as BlobPart])
451
+ .stream()
452
+ .pipeThrough(new CompressionStream('gzip'));
453
+ return new Uint8Array(await new Response(stream).arrayBuffer());
454
+ } catch {
455
+ /* fall back to sync */
456
+ }
457
+ }
458
+ return gzipSync(bytes);
459
+ }
460
+
461
+ function b64encode(bytes: Uint8Array): string {
462
+ let bin = '';
463
+ for (let i = 0; i < bytes.length; i += 0x8000) {
464
+ bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
465
+ }
466
+ return btoa(bin);
467
+ }
468
+
469
+ function b64decode(s: string): Uint8Array {
470
+ const bin = atob(s);
471
+ const out = new Uint8Array(bin.length);
472
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
473
+ return out;
474
+ }
package/src/util.ts CHANGED
@@ -64,9 +64,23 @@ export function detectDevice(): {
64
64
  os?: string;
65
65
  browser?: string;
66
66
  viewport: { w: number; h: number };
67
+ tz?: string;
68
+ tzOffset?: number;
67
69
  } {
68
70
  const ua = navigator.userAgent;
69
71
  const viewport = { w: window.innerWidth, h: window.innerHeight };
72
+
73
+ // The visitor's own timezone — so the player can show wall-clock times in their
74
+ // local time, not just the viewer's. IANA name (DST-correct) plus a numeric
75
+ // offset EAST of UTC as a fallback label. Both best-effort.
76
+ let tz: string | undefined;
77
+ let tzOffset: number | undefined;
78
+ try {
79
+ tz = Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
80
+ tzOffset = -new Date().getTimezoneOffset();
81
+ } catch {
82
+ /* Intl unavailable — omit timezone */
83
+ }
70
84
  let type: 'desktop' | 'mobile' | 'tablet' = 'desktop';
71
85
  if (/iPad|Tablet/i.test(ua)) type = 'tablet';
72
86
  else if (/Mobi|Android|iPhone/i.test(ua)) type = 'mobile';
@@ -84,7 +98,7 @@ export function detectDevice(): {
84
98
  else if (/Firefox\//.test(ua)) browser = 'Firefox';
85
99
  else if (/Safari\//.test(ua)) browser = 'Safari';
86
100
 
87
- return { type, os, browser, viewport };
101
+ return { type, os, browser, viewport, tz, tzOffset };
88
102
  }
89
103
 
90
104
  /** Global Privacy Control / Do Not Track. */