@offscript/sdk 0.1.2 → 0.1.5

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.
Files changed (56) hide show
  1. package/README.md +32 -119
  2. package/dist/adapters/nuxt.cjs +3 -3
  3. package/dist/adapters/nuxt.js +2 -2
  4. package/dist/adapters/vanilla.cjs +5 -5
  5. package/dist/adapters/vanilla.d.ts +2 -3
  6. package/dist/adapters/vanilla.js +3 -3
  7. package/dist/adapters/vue.cjs +3 -3
  8. package/dist/adapters/vue.js +2 -2
  9. package/dist/chunk-2MY2FWIA.cjs +20 -0
  10. package/dist/chunk-2MY2FWIA.cjs.map +1 -0
  11. package/dist/{chunk-WUFW7TEL.js → chunk-6ROP37WI.js} +3 -5
  12. package/dist/chunk-6ROP37WI.js.map +1 -0
  13. package/dist/{chunk-KQVK5EJR.cjs → chunk-HPDLEAAL.cjs} +3 -5
  14. package/dist/chunk-HPDLEAAL.cjs.map +1 -0
  15. package/dist/{chunk-HECPMGDC.js → chunk-V2YYWGIA.js} +5 -5
  16. package/dist/chunk-V2YYWGIA.js.map +1 -0
  17. package/dist/{chunk-I6JPZAQA.cjs → chunk-VYCDQLP7.cjs} +449 -1242
  18. package/dist/chunk-VYCDQLP7.cjs.map +1 -0
  19. package/dist/{chunk-IBEPWI7S.js → chunk-XBXFGCUT.js} +446 -1234
  20. package/dist/chunk-XBXFGCUT.js.map +1 -0
  21. package/dist/core/LifecycleManager.d.ts +1 -0
  22. package/dist/core/OffscriptSDK.d.ts +33 -70
  23. package/dist/core/RecordingStore.d.ts +26 -0
  24. package/dist/index.cjs +20 -40
  25. package/dist/index.d.ts +2 -2
  26. package/dist/index.js +3 -3
  27. package/dist/playwright/PlaywrightDataContract.d.ts +0 -7
  28. package/dist/playwright.cjs +13 -13
  29. package/dist/playwright.js +1 -1
  30. package/dist/recorders/ActionRecorder.d.ts +1 -1
  31. package/dist/recorders/ContextRecorder.d.ts +2 -8
  32. package/dist/recorders/HTTPRecorder.d.ts +7 -2
  33. package/dist/types/config.d.ts +6 -45
  34. package/dist/types/events.d.ts +2 -23
  35. package/dist/types/recording.d.ts +20 -0
  36. package/dist/ui/RecordingControls.d.ts +6 -4
  37. package/dist/utils/errors.d.ts +1 -18
  38. package/docs/api-contract.md +4 -53
  39. package/docs/architecture.md +5 -34
  40. package/docs/event-contract.md +3 -24
  41. package/docs/playwright-contract.md +3 -37
  42. package/package.json +2 -2
  43. package/dist/chunk-HECPMGDC.js.map +0 -1
  44. package/dist/chunk-I6JPZAQA.cjs.map +0 -1
  45. package/dist/chunk-IBEPWI7S.js.map +0 -1
  46. package/dist/chunk-KQVK5EJR.cjs.map +0 -1
  47. package/dist/chunk-TBPD2LMI.cjs +0 -20
  48. package/dist/chunk-TBPD2LMI.cjs.map +0 -1
  49. package/dist/chunk-WUFW7TEL.js.map +0 -1
  50. package/dist/core/ApiClient.d.ts +0 -24
  51. package/dist/core/SessionManager.d.ts +0 -45
  52. package/dist/transport/EventQueue.d.ts +0 -28
  53. package/dist/transport/EventTransport.d.ts +0 -84
  54. package/dist/transport/PersistentBuffer.d.ts +0 -60
  55. package/dist/transport/RetryManager.d.ts +0 -9
  56. package/dist/types/session.d.ts +0 -77
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- var chunkKQVK5EJR_cjs = require('./chunk-KQVK5EJR.cjs');
3
+ var chunkHPDLEAAL_cjs = require('./chunk-HPDLEAAL.cjs');
4
4
 
5
5
  // src/types/config.ts
6
6
  var DEFAULT_RECORDING = {
@@ -34,22 +34,7 @@ var DEFAULT_PRIVACY = {
34
34
  redactKeys: [],
35
35
  captureSensitiveValues: false,
36
36
  maxBodyBytes: 16 * 1024,
37
- textContentTypes: [/^text\//i, /json/i, /xml/i, /x-www-form-urlencoded/i, /javascript/i],
38
- localRetentionMs: 24 * 36e5
39
- };
40
- var DEFAULT_TRANSPORT = {
41
- batchMaxEvents: 200,
42
- batchMaxBytes: 512 * 1024,
43
- flushIntervalMs: 3e3,
44
- maxMemoryEvents: 5e3,
45
- maxBufferBytes: 64 * 1024 * 1024,
46
- maxRetries: 6,
47
- retryBaseMs: 500,
48
- retryMaxMs: 3e4,
49
- compress: false,
50
- keepaliveMaxBytes: 60 * 1024,
51
- persist: true,
52
- requestTimeoutMs: 15e3
37
+ textContentTypes: [/^text\//i, /json/i, /xml/i, /x-www-form-urlencoded/i, /javascript/i]
53
38
  };
54
39
 
55
40
  // src/utils/misc.ts
@@ -88,13 +73,6 @@ function debounce(fn, ms) {
88
73
  };
89
74
  return d;
90
75
  }
91
- function approxSize(value) {
92
- try {
93
- return JSON.stringify(value)?.length ?? 0;
94
- } catch {
95
- return 0;
96
- }
97
- }
98
76
  function deepMerge(base, patch) {
99
77
  const out = { ...base };
100
78
  if (!patch) return out;
@@ -309,37 +287,12 @@ var ConfigError = class extends OffscriptError {
309
287
  this.name = "ConfigError";
310
288
  }
311
289
  };
312
- var AuthError = class extends OffscriptError {
313
- constructor(message, status) {
314
- super("AUTH_FAILED", message, { status });
315
- this.name = "AuthError";
316
- }
317
- };
318
- var SessionExpiredError = class extends OffscriptError {
319
- constructor(message = "Sess\xE3o expirada") {
320
- super("SESSION_EXPIRED", message, { status: 410 });
321
- this.name = "SessionExpiredError";
322
- }
323
- };
324
- var SessionDisabledError = class extends OffscriptError {
325
- constructor(message = "Grava\xE7\xE3o desativada pelo backend") {
326
- super("SESSION_DISABLED", message);
327
- this.name = "SessionDisabledError";
328
- }
329
- };
330
290
  var InvalidStateError = class extends OffscriptError {
331
291
  constructor(message) {
332
292
  super("INVALID_STATE", message);
333
293
  this.name = "InvalidStateError";
334
294
  }
335
295
  };
336
- var TransportError = class extends OffscriptError {
337
- constructor(message, opts) {
338
- super("TRANSPORT_FAILED", message, opts);
339
- this.name = "TransportError";
340
- this.retryable = opts.retryable;
341
- }
342
- };
343
296
  var Emitter = class {
344
297
  constructor() {
345
298
  this.handlers = /* @__PURE__ */ new Map();
@@ -366,591 +319,6 @@ var Emitter = class {
366
319
  }
367
320
  };
368
321
 
369
- // src/core/ApiClient.ts
370
- var ORIGINAL_FETCH = "__offscriptOriginal";
371
- function nativeFetch() {
372
- const f = globalThis.fetch;
373
- if (!f) throw new TransportError("fetch indispon\xEDvel", { retryable: false });
374
- return (f[ORIGINAL_FETCH] ?? f).bind(globalThis);
375
- }
376
- var ApiClient = class {
377
- constructor(apiUrl, getToken, timeoutMs) {
378
- this.apiUrl = apiUrl;
379
- this.getToken = getToken;
380
- this.timeoutMs = timeoutMs;
381
- }
382
- get baseUrl() {
383
- return this.apiUrl;
384
- }
385
- createSession(body) {
386
- return this.json("POST", "/sdk/sessions", body);
387
- }
388
- heartbeat(sessionId, body) {
389
- return this.json("POST", `/sdk/sessions/${encodeURIComponent(sessionId)}/heartbeat`, body);
390
- }
391
- complete(sessionId, body) {
392
- return this.json("POST", `/sdk/sessions/${encodeURIComponent(sessionId)}/complete`, body);
393
- }
394
- status(sessionId) {
395
- return this.json("GET", `/sdk/sessions/${encodeURIComponent(sessionId)}/status`);
396
- }
397
- async sendEvents(batch, opts = {}) {
398
- const path = `/sdk/sessions/${encodeURIComponent(batch.sessionId)}/events`;
399
- return this.json("POST", path, batch, { "Idempotency-Key": batch.batchId }, opts);
400
- }
401
- async json(method, path, body, extraHeaders = {}, opts = {}) {
402
- const headers = {
403
- Accept: "application/json",
404
- Authorization: `Bearer ${this.getToken()}`,
405
- "X-Offscript-Sdk": chunkKQVK5EJR_cjs.SDK_VERSION,
406
- ...extraHeaders
407
- };
408
- let payload;
409
- if (body !== void 0) {
410
- headers["Content-Type"] = "application/json";
411
- const text = JSON.stringify(body);
412
- payload = text;
413
- if (opts.compress && !opts.keepalive && text.length > 1024) {
414
- const gz = await gzip(text);
415
- if (gz) {
416
- payload = gz;
417
- headers["Content-Encoding"] = "gzip";
418
- }
419
- }
420
- }
421
- const ctrl = typeof AbortController !== "undefined" ? new AbortController() : void 0;
422
- const timer = ctrl && !opts.keepalive ? setTimeout(() => ctrl.abort(), this.timeoutMs) : void 0;
423
- let res;
424
- try {
425
- res = await nativeFetch()(this.apiUrl + path, {
426
- method,
427
- headers,
428
- body: payload,
429
- keepalive: opts.keepalive,
430
- credentials: "omit",
431
- mode: "cors",
432
- signal: ctrl?.signal
433
- });
434
- } catch (e) {
435
- throw new TransportError(`Falha de rede: ${e?.message ?? e}`, { retryable: true, cause: e });
436
- } finally {
437
- if (timer) clearTimeout(timer);
438
- }
439
- if (res.ok) {
440
- const text = await res.text();
441
- return text ? JSON.parse(text) : {};
442
- }
443
- const detail = await res.text().catch(() => "");
444
- const msg = `HTTP ${res.status} ${method} ${path}${detail ? `: ${detail.slice(0, 200)}` : ""}`;
445
- if (res.status === 401 || res.status === 403) throw new AuthError(msg, res.status);
446
- if (res.status === 410) throw new SessionExpiredError(msg);
447
- const retryAfter = Number(res.headers.get("retry-after"));
448
- const err = new TransportError(msg, {
449
- status: res.status,
450
- retryable: res.status === 408 || res.status === 429 || res.status >= 500
451
- });
452
- if (retryAfter > 0) err.retryAfterMs = retryAfter * 1e3;
453
- throw err;
454
- }
455
- };
456
- async function gzip(text) {
457
- try {
458
- const CS = globalThis.CompressionStream;
459
- if (!CS) return null;
460
- const body = new Response(text).body;
461
- if (!body) return null;
462
- return new Uint8Array(await new Response(body.pipeThrough(new CS("gzip"))).arrayBuffer());
463
- } catch {
464
- return null;
465
- }
466
- }
467
-
468
- // src/transport/EventQueue.ts
469
- var EventQueue = class {
470
- constructor(maxEvents, maxBytes) {
471
- this.maxEvents = maxEvents;
472
- this.maxBytes = maxBytes;
473
- this.staged = [];
474
- this.pendingCount = 0;
475
- this.pendingBytes = 0;
476
- this.dropped = 0;
477
- }
478
- canAccept(priority) {
479
- const factor = priority === "critical" ? 2 : priority === "normal" ? 1 : 0.5;
480
- return this.pendingCount < this.maxEvents * factor && this.pendingBytes < this.maxBytes * factor;
481
- }
482
- noteDropped() {
483
- this.dropped++;
484
- }
485
- push(e, size) {
486
- this.staged.push(e);
487
- this.pendingCount++;
488
- this.pendingBytes += size;
489
- }
490
- /** Devolve eventos ao staging sem recontar. */
491
- restore(events) {
492
- this.staged = [...events, ...this.staged];
493
- }
494
- takeStaged() {
495
- const s = this.staged;
496
- this.staged = [];
497
- return s;
498
- }
499
- peekStaged() {
500
- return this.staged;
501
- }
502
- get stagedCount() {
503
- return this.staged.length;
504
- }
505
- acked(count, bytes) {
506
- this.pendingCount = Math.max(0, this.pendingCount - count);
507
- this.pendingBytes = Math.max(0, this.pendingBytes - bytes);
508
- }
509
- reset(count, bytes) {
510
- this.pendingCount = count;
511
- this.pendingBytes = bytes;
512
- }
513
- get pending() {
514
- return this.pendingCount;
515
- }
516
- get bytes() {
517
- return this.pendingBytes;
518
- }
519
- };
520
-
521
- // src/transport/RetryManager.ts
522
- var RetryManager = class {
523
- constructor(baseMs, maxMs, random = Math.random) {
524
- this.baseMs = baseMs;
525
- this.maxMs = maxMs;
526
- this.random = random;
527
- }
528
- delay(attempt) {
529
- const cap = Math.min(this.maxMs, this.baseMs * 2 ** Math.max(0, attempt));
530
- return Math.round(this.random() * cap);
531
- }
532
- sleep(ms) {
533
- return new Promise((r) => setTimeout(r, ms));
534
- }
535
- };
536
-
537
- // src/transport/EventTransport.ts
538
- var EventTransport = class extends Emitter {
539
- constructor(cfg, api, buffer) {
540
- super();
541
- this.cfg = cfg;
542
- this.api = api;
543
- this.buffer = buffer;
544
- this.identity = null;
545
- this.running = null;
546
- this.again = false;
547
- this.failures = 0;
548
- this.stopped = true;
549
- this.fatal = false;
550
- this.online = true;
551
- this.lastDroppedReported = 0;
552
- this.queue = new EventQueue(cfg.maxMemoryEvents, cfg.maxBufferBytes);
553
- this.retry = new RetryManager(cfg.retryBaseMs, cfg.retryMaxMs);
554
- }
555
- get bufferKind() {
556
- return this.buffer.kind;
557
- }
558
- /** Inicia o transporte para uma sessão/aba e recupera o que ficou pendente no buffer. */
559
- async start(identity) {
560
- this.identity = identity;
561
- this.stopped = false;
562
- this.fatal = false;
563
- this.failures = 0;
564
- const st = await this.buffer.stats(identity.sessionId).catch(() => ({ count: 0, bytes: 0 }));
565
- this.queue.reset(st.count, st.bytes);
566
- this.scheduleFlush();
567
- }
568
- setOnline(online) {
569
- this.online = online;
570
- if (online && !this.stopped) void this.flush();
571
- }
572
- canAccept(priority) {
573
- return !this.stopped && !this.fatal && this.queue.canAccept(priority);
574
- }
575
- noteDropped() {
576
- this.queue.noteDropped();
577
- if (this.queue.dropped - this.lastDroppedReported >= 1) {
578
- this.lastDroppedReported = this.queue.dropped;
579
- this.emit("overflow", { dropped: this.queue.dropped });
580
- }
581
- }
582
- get pending() {
583
- return this.queue.pending;
584
- }
585
- enqueue(event) {
586
- if (this.stopped) return;
587
- this.queue.push(event, approxSize(event));
588
- if (this.queue.stagedCount >= 50) void this.commit();
589
- else if (!this.commitTimer) this.commitTimer = setTimeout(() => void this.commit(), 100);
590
- }
591
- async commit() {
592
- if (this.commitTimer) clearTimeout(this.commitTimer);
593
- this.commitTimer = void 0;
594
- const staged = this.queue.takeStaged();
595
- if (!staged.length) return;
596
- try {
597
- await this.buffer.put(staged);
598
- } catch {
599
- this.queue.restore(staged);
600
- throw new TransportError("Falha ao persistir eventos localmente", { retryable: true });
601
- }
602
- }
603
- scheduleFlush() {
604
- if (this.flushTimer || this.stopped) return;
605
- this.flushTimer = setTimeout(() => {
606
- this.flushTimer = void 0;
607
- void this.flush().finally(() => this.scheduleFlush());
608
- }, this.cfg.flushIntervalMs);
609
- }
610
- /** Flush de melhor esforço. Retorna true se tudo foi confirmado pelo backend. */
611
- flush(opts = { inlineRetries: 0 }) {
612
- if (this.running) {
613
- this.again = true;
614
- return this.running;
615
- }
616
- this.running = (async () => {
617
- try {
618
- let ok = await this.drain(opts);
619
- while (this.again && ok) {
620
- this.again = false;
621
- ok = await this.drain(opts);
622
- }
623
- return ok;
624
- } finally {
625
- this.running = null;
626
- this.again = false;
627
- }
628
- })();
629
- return this.running;
630
- }
631
- /** Flush final com retries inline e prazo máximo. */
632
- async flushFinal(maxWaitMs = 1e4) {
633
- if (this.retryTimer) clearTimeout(this.retryTimer);
634
- this.retryTimer = void 0;
635
- const running = this.running;
636
- if (running) await running.catch(() => false);
637
- const ok = await this.flush({ inlineRetries: this.cfg.maxRetries, deadline: Date.now() + maxWaitMs });
638
- return { ok, pending: this.queue.pending };
639
- }
640
- async drain(opts) {
641
- const id = this.identity;
642
- if (!id || this.fatal) return false;
643
- for (; ; ) {
644
- try {
645
- await this.commit();
646
- const { items } = await this.buffer.peek(id.sessionId, id.tabId, this.cfg.batchMaxEvents, this.cfg.batchMaxBytes);
647
- if (!items.length) {
648
- this.failures = 0;
649
- return true;
650
- }
651
- const events = items.map((i) => i.event);
652
- await this.sendWithRetry(events, opts);
653
- await this.buffer.remove(items.map((i) => i.key));
654
- this.queue.acked(items.length, items.reduce((n, i) => n + i.size, 0));
655
- this.failures = 0;
656
- } catch (error) {
657
- return this.onFailure(error);
658
- }
659
- }
660
- }
661
- onFailure(error) {
662
- if (error instanceof AuthError || error instanceof SessionExpiredError) {
663
- this.fatal = true;
664
- this.emit("fatal", { error });
665
- return false;
666
- }
667
- this.failures++;
668
- const retryable = !(error instanceof TransportError) || error.retryable;
669
- const willRetryInMs = retryable ? this.retry.delay(this.failures) : void 0;
670
- this.emit("failed", { error, willRetryInMs });
671
- if (retryable && !this.stopped) {
672
- if (this.retryTimer) clearTimeout(this.retryTimer);
673
- this.retryTimer = setTimeout(() => {
674
- this.retryTimer = void 0;
675
- void this.flush();
676
- }, willRetryInMs);
677
- }
678
- return false;
679
- }
680
- async sendWithRetry(events, opts) {
681
- const batch = this.buildBatch(events);
682
- for (let attempt = 0; ; attempt++) {
683
- try {
684
- if (!this.online) throw new TransportError("Offline", { retryable: true });
685
- const ack = await this.api.sendEvents(batch, { compress: this.cfg.compress });
686
- this.emit("flushed", { events: events.length, batchId: batch.batchId, duplicate: !!ack.duplicate });
687
- return;
688
- } catch (e) {
689
- if (e instanceof TransportError && e.status === 413 && events.length > 1) {
690
- const mid = Math.ceil(events.length / 2);
691
- await this.sendWithRetry(events.slice(0, mid), opts);
692
- await this.sendWithRetry(events.slice(mid), opts);
693
- return;
694
- }
695
- const retryable = !(e instanceof TransportError) || e.retryable;
696
- if (!retryable || attempt >= opts.inlineRetries) throw e;
697
- const wait = Math.max(this.retry.delay(attempt), e.retryAfterMs ?? 0);
698
- if (opts.deadline && Date.now() + wait > opts.deadline) throw e;
699
- await this.retry.sleep(wait);
700
- }
701
- }
702
- }
703
- buildBatch(events) {
704
- const id = this.identity;
705
- const first = events[0].sequence;
706
- const last = events[events.length - 1].sequence;
707
- return {
708
- schemaVersion: chunkKQVK5EJR_cjs.SCHEMA_VERSION,
709
- batchId: `${id.sessionId}.${id.tabId}.${first}-${last}.${events.length}`,
710
- sessionId: id.sessionId,
711
- executionId: id.executionId,
712
- tabId: id.tabId,
713
- sequenceStart: first,
714
- sequenceEnd: last,
715
- createdAt: Date.now(),
716
- sdkVersion: chunkKQVK5EJR_cjs.SDK_VERSION,
717
- events
718
- };
719
- }
720
- /**
721
- * Chamado em pagehide/beforeunload. Persiste o que está em staging (best effort, assíncrono) e
722
- * tenta enviar o final da fila com fetch keepalive (limite ~64KB). Sem ACK: os eventos continuam
723
- * no IndexedDB e serão reenviados (de forma idempotente) na próxima página.
724
- * Não há garantia de entrega se o navegador for encerrado abruptamente.
725
- */
726
- flushOnUnload() {
727
- const id = this.identity;
728
- if (!id || this.stopped || this.fatal) return;
729
- const staged = this.queue.takeStaged();
730
- if (!staged.length) return;
731
- safe(() => void this.buffer.put(staged).catch(() => void 0));
732
- const events = [];
733
- let bytes = 0;
734
- for (const e of staged) {
735
- const s = approxSize(e);
736
- if (bytes + s > this.cfg.keepaliveMaxBytes) break;
737
- events.push(e);
738
- bytes += s;
739
- }
740
- if (!events.length) return;
741
- safe(() => void this.api.sendEvents(this.buildBatch(events), { keepalive: true }).catch(() => void 0));
742
- }
743
- /** Para timers; eventos não confirmados permanecem no buffer. */
744
- stop() {
745
- this.stopped = true;
746
- for (const t of [this.commitTimer, this.flushTimer, this.retryTimer]) if (t) clearTimeout(t);
747
- this.commitTimer = this.flushTimer = this.retryTimer = void 0;
748
- }
749
- async clearSession(sessionId) {
750
- await this.buffer.clearSession(sessionId).catch(() => void 0);
751
- this.queue.reset(0, 0);
752
- }
753
- async purgeExpired(retentionMs) {
754
- await this.buffer.purgeOlderThan(Date.now() - retentionMs).catch(() => void 0);
755
- }
756
- close() {
757
- this.stop();
758
- this.buffer.close();
759
- }
760
- };
761
-
762
- // src/transport/PersistentBuffer.ts
763
- var eventKey = (sessionId, tabId, seq) => `${sessionId}|${tabId}|${String(seq).padStart(12, "0")}`;
764
- var toStored = (e) => ({
765
- key: eventKey(e.sessionId, e.tabId, e.sequence),
766
- sessionId: e.sessionId,
767
- tabId: e.tabId,
768
- sequence: e.sequence,
769
- storedAt: Date.now(),
770
- size: approxSize(e),
771
- event: e
772
- });
773
- var MemoryBuffer = class {
774
- constructor() {
775
- this.kind = "memory";
776
- this.map = /* @__PURE__ */ new Map();
777
- }
778
- async put(events) {
779
- for (const e of events) {
780
- const s = toStored(e);
781
- this.map.set(s.key, s);
782
- }
783
- }
784
- async peek(sessionId, tabId, maxEvents, maxBytes) {
785
- const prefix = `${sessionId}|${tabId}|`;
786
- const keys = Array.from(this.map.keys()).filter((k) => k.startsWith(prefix)).sort();
787
- const items = [];
788
- let bytes = 0;
789
- for (const k of keys) {
790
- const s = this.map.get(k);
791
- if (items.length >= maxEvents || items.length > 0 && bytes + s.size > maxBytes) break;
792
- items.push(s);
793
- bytes += s.size;
794
- }
795
- return { items, bytes };
796
- }
797
- async remove(keys) {
798
- for (const k of keys) this.map.delete(k);
799
- }
800
- async stats(sessionId) {
801
- let count = 0;
802
- let bytes = 0;
803
- for (const s of this.map.values()) {
804
- if (sessionId && s.sessionId !== sessionId) continue;
805
- count++;
806
- bytes += s.size;
807
- }
808
- return { count, bytes };
809
- }
810
- async purgeOlderThan(ts) {
811
- let n = 0;
812
- for (const [k, s] of this.map) if (s.storedAt < ts) this.map.delete(k), n++;
813
- return n;
814
- }
815
- async clearSession(sessionId) {
816
- for (const [k, s] of this.map) if (s.sessionId === sessionId) this.map.delete(k);
817
- }
818
- close() {
819
- }
820
- };
821
- var STORE = "events";
822
- function req(r) {
823
- return new Promise((resolve, reject) => {
824
- r.onsuccess = () => resolve(r.result);
825
- r.onerror = () => reject(r.error);
826
- });
827
- }
828
- function done(tx) {
829
- return new Promise((resolve, reject) => {
830
- tx.oncomplete = () => resolve();
831
- tx.onerror = () => reject(tx.error);
832
- tx.onabort = () => reject(tx.error);
833
- });
834
- }
835
- var IndexedDBBuffer = class _IndexedDBBuffer {
836
- constructor(db) {
837
- this.db = db;
838
- this.kind = "indexeddb";
839
- }
840
- static async open(name) {
841
- if (typeof indexedDB === "undefined") throw new Error("IndexedDB indispon\xEDvel");
842
- const db = await new Promise((resolve, reject) => {
843
- const r = indexedDB.open(name, 1);
844
- r.onupgradeneeded = () => {
845
- const store = r.result.createObjectStore(STORE, { keyPath: "key" });
846
- store.createIndex("bySession", "sessionId");
847
- store.createIndex("byStoredAt", "storedAt");
848
- };
849
- r.onsuccess = () => resolve(r.result);
850
- r.onerror = () => reject(r.error);
851
- r.onblocked = () => reject(new Error("IndexedDB bloqueado"));
852
- });
853
- return new _IndexedDBBuffer(db);
854
- }
855
- async put(events) {
856
- if (!events.length) return;
857
- const tx = this.db.transaction(STORE, "readwrite");
858
- const store = tx.objectStore(STORE);
859
- for (const e of events) store.put(toStored(e));
860
- await done(tx);
861
- }
862
- async peek(sessionId, tabId, maxEvents, maxBytes) {
863
- const prefix = `${sessionId}|${tabId}|`;
864
- const range = IDBKeyRange.bound(prefix, prefix + "\uFFFF");
865
- const tx = this.db.transaction(STORE, "readonly");
866
- const items = [];
867
- let bytes = 0;
868
- await new Promise((resolve, reject) => {
869
- const cur = tx.objectStore(STORE).openCursor(range);
870
- cur.onsuccess = () => {
871
- const c = cur.result;
872
- if (!c) return resolve();
873
- const s = c.value;
874
- if (items.length >= maxEvents || items.length > 0 && bytes + s.size > maxBytes) return resolve();
875
- items.push(s);
876
- bytes += s.size;
877
- c.continue();
878
- };
879
- cur.onerror = () => reject(cur.error);
880
- });
881
- return { items, bytes };
882
- }
883
- async remove(keys) {
884
- if (!keys.length) return;
885
- const tx = this.db.transaction(STORE, "readwrite");
886
- const store = tx.objectStore(STORE);
887
- for (const k of keys) store.delete(k);
888
- await done(tx);
889
- }
890
- async stats(sessionId) {
891
- const tx = this.db.transaction(STORE, "readonly");
892
- const store = tx.objectStore(STORE);
893
- if (!sessionId) return { count: await req(store.count()), bytes: 0 };
894
- let count = 0;
895
- let bytes = 0;
896
- await new Promise((resolve, reject) => {
897
- const cur = store.index("bySession").openCursor(IDBKeyRange.only(sessionId));
898
- cur.onsuccess = () => {
899
- const c = cur.result;
900
- if (!c) return resolve();
901
- count++;
902
- bytes += c.value.size;
903
- c.continue();
904
- };
905
- cur.onerror = () => reject(cur.error);
906
- });
907
- return { count, bytes };
908
- }
909
- async purgeOlderThan(ts) {
910
- const tx = this.db.transaction(STORE, "readwrite");
911
- let n = 0;
912
- await new Promise((resolve, reject) => {
913
- const cur = tx.objectStore(STORE).index("byStoredAt").openCursor(IDBKeyRange.upperBound(ts, true));
914
- cur.onsuccess = () => {
915
- const c = cur.result;
916
- if (!c) return resolve();
917
- c.delete();
918
- n++;
919
- c.continue();
920
- };
921
- cur.onerror = () => reject(cur.error);
922
- });
923
- await done(tx);
924
- return n;
925
- }
926
- async clearSession(sessionId) {
927
- const tx = this.db.transaction(STORE, "readwrite");
928
- await new Promise((resolve, reject) => {
929
- const cur = tx.objectStore(STORE).index("bySession").openCursor(IDBKeyRange.only(sessionId));
930
- cur.onsuccess = () => {
931
- const c = cur.result;
932
- if (!c) return resolve();
933
- c.delete();
934
- c.continue();
935
- };
936
- cur.onerror = () => reject(cur.error);
937
- });
938
- await done(tx);
939
- }
940
- close() {
941
- safe(() => this.db.close());
942
- }
943
- };
944
- async function openBuffer(name, persist) {
945
- if (persist) {
946
- try {
947
- return await IndexedDBBuffer.open(name);
948
- } catch {
949
- }
950
- }
951
- return new MemoryBuffer();
952
- }
953
-
954
322
  // src/recorders/types.ts
955
323
  function guard(sink, name, fn) {
956
324
  try {
@@ -979,7 +347,7 @@ var ActionRecorder = class {
979
347
  this.scrollState = /* @__PURE__ */ new Map();
980
348
  this.move = [];
981
349
  const r = sink.config.recording;
982
- this.gen = new chunkKQVK5EJR_cjs.SelectorGenerator({ testIdAttributes: r.testIdAttributes, stableAttributes: r.stableAttributes });
350
+ this.gen = new chunkHPDLEAAL_cjs.SelectorGenerator({ testIdAttributes: r.testIdAttributes, stableAttributes: r.stableAttributes });
983
351
  }
984
352
  start() {
985
353
  const d = this.d;
@@ -1043,7 +411,7 @@ var ActionRecorder = class {
1043
411
  emit(draft, el, extra) {
1044
412
  const s = this.sink.sanitizer;
1045
413
  const data = {
1046
- schemaVersion: chunkKQVK5EJR_cjs.ACTION_SCHEMA_VERSION,
414
+ schemaVersion: chunkHPDLEAAL_cjs.ACTION_SCHEMA_VERSION,
1047
415
  type: draft.type,
1048
416
  url: s.sanitizeUrl(location.href),
1049
417
  viewport: { width: window.innerWidth, height: window.innerHeight },
@@ -1280,7 +648,7 @@ function parseBrowser(ua) {
1280
648
  }
1281
649
  return {};
1282
650
  }
1283
- function collectContext(id, reason, appVersion, captureSensitiveValues = false) {
651
+ function collectContext(reason, captureSensitiveValues = false) {
1284
652
  const nav = navigator;
1285
653
  const ua = nav.userAgent ?? "";
1286
654
  let browser = parseBrowser(ua);
@@ -1304,18 +672,13 @@ function collectContext(id, reason, appVersion, captureSensitiveValues = false)
1304
672
  timezone: safe(() => Intl.DateTimeFormat().resolvedOptions().timeZone),
1305
673
  online: nav.onLine,
1306
674
  visibility: document.visibilityState,
1307
- platformId: id.platformId,
1308
- testCaseId: id.testCaseId,
1309
- executionId: id.executionId,
1310
- sdkVersion: chunkKQVK5EJR_cjs.SDK_VERSION,
1311
- appVersion,
675
+ sdkVersion: chunkHPDLEAAL_cjs.SDK_VERSION,
1312
676
  captureSensitiveValues
1313
677
  };
1314
678
  }
1315
679
  var ContextRecorder = class {
1316
- constructor(sink, identity) {
680
+ constructor(sink) {
1317
681
  this.sink = sink;
1318
- this.identity = identity;
1319
682
  this.name = "context";
1320
683
  this.d = new Disposer();
1321
684
  }
@@ -1328,7 +691,7 @@ var ContextRecorder = class {
1328
691
  }
1329
692
  record(reason) {
1330
693
  guard(this.sink, this.name, () => {
1331
- const data = collectContext(this.identity(), reason, this.sink.config.appVersion, this.sink.config.privacy.captureSensitiveValues);
694
+ const data = collectContext(reason, this.sink.config.privacy.captureSensitiveValues);
1332
695
  data.url = this.sink.sanitizer.sanitizeUrl(data.url);
1333
696
  if (data.referrer) data.referrer = this.sink.sanitizer.sanitizeUrl(data.referrer);
1334
697
  this.sink.emit("context", data, { priority: "normal" });
@@ -1487,23 +850,28 @@ function uid(prefix = "") {
1487
850
  }
1488
851
 
1489
852
  // src/recorders/HTTPRecorder.ts
1490
- var XHR_PATCHED = "__offscriptXhrOriginal";
1491
853
  var HTTPRecorder = class {
1492
- constructor(sink, ownApiUrl) {
854
+ constructor(sink) {
1493
855
  this.sink = sink;
1494
- this.ownApiUrl = ownApiUrl;
1495
856
  this.name = "http";
1496
857
  this.inflight = 0;
1497
858
  this.active = false;
859
+ this.delivering = false;
860
+ this.pending = /* @__PURE__ */ new Set();
1498
861
  this.restore = [];
1499
862
  }
1500
863
  start() {
1501
864
  if (this.active) return;
1502
865
  this.active = true;
866
+ this.delivering = true;
1503
867
  this.patchFetch();
1504
868
  this.patchXhr();
1505
869
  }
1506
870
  stop() {
871
+ this.delivering = false;
872
+ this.detach();
873
+ }
874
+ detach() {
1507
875
  this.active = false;
1508
876
  for (const r of this.restore.splice(0).reverse()) {
1509
877
  try {
@@ -1512,8 +880,30 @@ var HTTPRecorder = class {
1512
880
  }
1513
881
  }
1514
882
  }
883
+ /** Stops new capture and allows up to two seconds for pending HTTP/body capture. */
884
+ async drain() {
885
+ this.detach();
886
+ let timer;
887
+ const timeout = new Promise((resolve) => {
888
+ timer = setTimeout(resolve, 2e3);
889
+ });
890
+ const settle = async () => {
891
+ while (this.delivering && this.pending.size) {
892
+ await Promise.allSettled([...this.pending]);
893
+ }
894
+ };
895
+ try {
896
+ await Promise.race([settle(), timeout]);
897
+ } finally {
898
+ clearTimeout(timer);
899
+ this.delivering = false;
900
+ }
901
+ }
902
+ track(promise) {
903
+ const tracked = promise.catch(() => void 0).finally(() => this.pending.delete(tracked));
904
+ this.pending.add(tracked);
905
+ }
1515
906
  isIgnored(url) {
1516
- if (this.ownApiUrl && url.startsWith(this.ownApiUrl)) return true;
1517
907
  return this.sink.sanitizer.isIgnoredUrl(url);
1518
908
  }
1519
909
  // ---------------------------------------------------------------- fetch
@@ -1539,14 +929,13 @@ var HTTPRecorder = class {
1539
929
  }
1540
930
  if (ctx) {
1541
931
  const c = ctx;
1542
- p.then(
932
+ self.track(p.then(
1543
933
  (res) => self.endFetch(c, res),
1544
934
  (err) => self.failFetch(c, err)
1545
- ).catch(() => void 0);
935
+ ));
1546
936
  }
1547
937
  return p;
1548
938
  };
1549
- patched[ORIGINAL_FETCH] = orig[ORIGINAL_FETCH] ?? orig;
1550
939
  globalThis.fetch = patched;
1551
940
  this.restore.push(() => {
1552
941
  if (globalThis.fetch === patched) globalThis.fetch = orig;
@@ -1594,13 +983,13 @@ var HTTPRecorder = class {
1594
983
  base.responseBody = { kind: "omitted", reason: "body-used" };
1595
984
  return void this.emit(base);
1596
985
  }
1597
- this.readBounded(clone).then(({ text, size, truncated }) => {
986
+ this.track(this.readBounded(clone).then(({ text, size, truncated }) => {
1598
987
  const b = this.sink.sanitizer.sanitizeTextBody(text, ct, size);
1599
988
  b.truncated = truncated || b.truncated;
1600
989
  base.responseBody = b;
1601
990
  }).catch(() => {
1602
991
  base.responseBody = { kind: "omitted", reason: "read-failed" };
1603
- }).finally(() => guard(this.sink, this.name, () => this.emit(base)));
992
+ }).finally(() => guard(this.sink, this.name, () => this.emit(base))));
1604
993
  });
1605
994
  }
1606
995
  async readBounded(res) {
@@ -1611,8 +1000,8 @@ var HTTPRecorder = class {
1611
1000
  let size = 0;
1612
1001
  let truncated = false;
1613
1002
  for (; ; ) {
1614
- const { done: done2, value } = await reader.read();
1615
- if (done2) break;
1003
+ const { done, value } = await reader.read();
1004
+ if (done) break;
1616
1005
  size += value.byteLength;
1617
1006
  if (text.length < max) text += dec.decode(value, { stream: true });
1618
1007
  if (size > max * 4) {
@@ -1669,7 +1058,6 @@ var HTTPRecorder = class {
1669
1058
  }
1670
1059
  return origSend.call(this, body);
1671
1060
  };
1672
- open[XHR_PATCHED] = origOpen;
1673
1061
  proto.open = open;
1674
1062
  proto.setRequestHeader = setHeader;
1675
1063
  proto.send = send;
@@ -1691,9 +1079,14 @@ var HTTPRecorder = class {
1691
1079
  this.inflight++;
1692
1080
  xhr.addEventListener("abort", () => m.aborted = true);
1693
1081
  xhr.addEventListener("timeout", () => m.timedOut = true);
1082
+ let finishCapture;
1083
+ this.track(new Promise((resolve) => {
1084
+ finishCapture = resolve;
1085
+ }));
1694
1086
  xhr.addEventListener("loadend", () => {
1695
1087
  this.inflight = Math.max(0, this.inflight - 1);
1696
1088
  guard(this.sink, this.name, () => this.endXhr(xhr, m, ctx));
1089
+ finishCapture();
1697
1090
  });
1698
1091
  }
1699
1092
  endXhr(xhr, m, ctx) {
@@ -1737,6 +1130,7 @@ var HTTPRecorder = class {
1737
1130
  };
1738
1131
  }
1739
1132
  emit(data) {
1133
+ if (!this.delivering) return;
1740
1134
  this.sink.emit("http", data, { priority: "normal", at: data.startedAt });
1741
1135
  }
1742
1136
  describeBody(body, contentType) {
@@ -1986,11 +1380,12 @@ var LifecycleManager = class {
1986
1380
  this.lastUrl = location.href;
1987
1381
  const d = this.d;
1988
1382
  d.listen(window, "pagehide", () => safe(() => this.h.onPageHide()), { capture: true });
1989
- d.listen(window, "beforeunload", () => safe(() => this.h.onPageHide()), { capture: true });
1383
+ d.listen(window, "pageshow", (event) => {
1384
+ if (event.persisted) safe(() => this.h.onPageShow?.());
1385
+ });
1990
1386
  d.listen(document, "visibilitychange", () => {
1991
1387
  const hidden = document.visibilityState === "hidden";
1992
1388
  safe(() => this.h.onVisibility(hidden));
1993
- if (hidden) safe(() => this.h.onPageHide());
1994
1389
  });
1995
1390
  d.listen(window, "online", () => safe(() => this.h.onConnectivity(true)));
1996
1391
  d.listen(window, "offline", () => safe(() => this.h.onConnectivity(false)));
@@ -2027,282 +1422,119 @@ var LifecycleManager = class {
2027
1422
  }
2028
1423
  };
2029
1424
 
2030
- // src/core/SessionManager.ts
2031
- var STATE_VERSION = 1;
2032
- var SessionManager = class {
2033
- constructor(cfg) {
2034
- this.cfg = cfg;
2035
- this.state = null;
2036
- this.info = null;
2037
- this.seq = 0;
2038
- this.token = "";
2039
- this.storageKey = `${cfg.storagePrefix}:state:${cfg.platformId}`;
2040
- this.seqKey = `${cfg.storagePrefix}:seq:${cfg.platformId}`;
2041
- this.api = new ApiClient(cfg.apiUrl, () => this.token, cfg.transport.requestTimeoutMs);
2042
- }
2043
- get session() {
2044
- return this.info;
2045
- }
2046
- get tabId() {
2047
- return this.info?.tabId;
2048
- }
2049
- get currentToken() {
2050
- return this.token;
2051
- }
2052
- hasTokenAvailable() {
2053
- return Boolean(this.token || readQueryToken());
2054
- }
2055
- // ---- persistência -------------------------------------------------------
2056
- loadPersisted() {
2057
- const raw = safe(() => sessionStorage.getItem(this.storageKey));
2058
- if (!raw) return null;
2059
- try {
2060
- const s = JSON.parse(raw);
2061
- if (s.version !== STATE_VERSION || s.platformId !== this.cfg.platformId || !s.sessionId || !s.sessionToken) return null;
2062
- if (s.expiresAt && s.expiresAt < Date.now()) return null;
2063
- const seq = Number(safe(() => sessionStorage.getItem(this.seqKey)) ?? 0);
2064
- s.sequence = Math.max(s.sequence ?? 0, Number.isFinite(seq) ? seq : 0);
2065
- return s;
2066
- } catch {
2067
- return null;
1425
+ // src/core/RecordingStore.ts
1426
+ var RecordingStore = class _RecordingStore {
1427
+ constructor(database) {
1428
+ this.database = database;
1429
+ this.pending = /* @__PURE__ */ new Set();
1430
+ this.uncommitted = /* @__PURE__ */ new Map();
1431
+ }
1432
+ static async open(key) {
1433
+ const database = await new Promise((resolve, reject) => {
1434
+ const request = indexedDB.open(`offscript-recording-${key}`, 1);
1435
+ request.onupgradeneeded = () => {
1436
+ request.result.createObjectStore("events", { keyPath: "sequence" });
1437
+ request.result.createObjectStore("state");
1438
+ };
1439
+ request.onsuccess = () => resolve(request.result);
1440
+ request.onerror = () => reject(request.error);
1441
+ });
1442
+ return new _RecordingStore(database);
1443
+ }
1444
+ async load() {
1445
+ await this.flush();
1446
+ const checkpoint = sessionStorage.getItem(this.checkpointKey);
1447
+ if (checkpoint) {
1448
+ const saved = JSON.parse(checkpoint);
1449
+ await this.write(["events", "state"], (transaction) => {
1450
+ for (const event of saved.events) transaction.objectStore("events").put(event);
1451
+ transaction.objectStore("state").put(saved.status, "status");
1452
+ });
1453
+ sessionStorage.removeItem(this.checkpointKey);
2068
1454
  }
1455
+ return new Promise((resolve, reject) => {
1456
+ const transaction = this.database.transaction(["events", "state"], "readonly");
1457
+ const events = transaction.objectStore("events").getAll();
1458
+ const state = transaction.objectStore("state").get("status");
1459
+ transaction.oncomplete = () => {
1460
+ this.status = state.result;
1461
+ resolve(state.result ? { status: state.result, events: events.result } : null);
1462
+ };
1463
+ transaction.onabort = () => reject(transaction.error);
1464
+ });
2069
1465
  }
2070
- persist(status, extra = {}) {
2071
- if (!this.info) return;
2072
- this.state = {
2073
- version: STATE_VERSION,
2074
- tabId: this.info.tabId,
2075
- sessionId: this.info.sessionId,
2076
- executionId: this.info.executionId,
2077
- testCaseId: this.info.testCaseId,
2078
- platformId: this.cfg.platformId,
2079
- sessionToken: this.token,
2080
- status,
2081
- sequence: this.seq,
2082
- pageCount: this.state?.pageCount ?? 0,
2083
- heartbeatIntervalMs: this.state?.heartbeatIntervalMs,
2084
- expiresAt: this.state?.expiresAt,
2085
- updatedAt: Date.now(),
2086
- ...extra
2087
- };
2088
- safe(() => sessionStorage.setItem(this.storageKey, JSON.stringify(this.state)));
1466
+ append(event) {
1467
+ this.uncommitted.set(event.sequence, event);
1468
+ return this.write(["events"], (transaction) => {
1469
+ transaction.objectStore("events").put(event);
1470
+ }).then(() => {
1471
+ this.uncommitted.delete(event.sequence);
1472
+ });
2089
1473
  }
2090
1474
  setStatus(status) {
2091
- this.persist(status);
2092
- }
2093
- clearPersisted() {
2094
- safe(() => sessionStorage.removeItem(this.storageKey));
2095
- safe(() => sessionStorage.removeItem(this.seqKey));
2096
- this.state = null;
2097
- }
2098
- // ---- sequência ----------------------------------------------------------
2099
- nextSequence() {
2100
- this.seq++;
2101
- safe(() => sessionStorage.setItem(this.seqKey, String(this.seq)));
2102
- return this.seq;
2103
- }
2104
- get lastSequence() {
2105
- return this.seq;
2106
- }
2107
- /** Sobe a sequência até pelo menos `n` (ex.: maior sequência encontrada no buffer). */
2108
- ensureSequenceAtLeast(n) {
2109
- if (n > this.seq) this.seq = n;
2110
- }
2111
- newPage() {
2112
- if (!this.info) throw new InvalidStateError("Sem sess\xE3o ativa");
2113
- this.info = { ...this.info, pageId: uid("pg_") };
2114
- this.persist(this.state?.status ?? "recording", { pageCount: (this.state?.pageCount ?? 0) + 1 });
2115
- return this.info.pageId;
2116
- }
2117
- get pageCount() {
2118
- return this.state?.pageCount ?? 0;
2119
- }
2120
- // ---- ciclo de vida ------------------------------------------------------
2121
- /** Cria (ou obtém, de forma idempotente) a sessão no backend. */
2122
- async create(opts, context) {
2123
- const queryToken = consumeQueryToken();
2124
- const sessionToken = opts?.sessionToken || this.token || queryToken;
2125
- if (!sessionToken || !opts.executionId || !opts.testCaseId)
2126
- throw new AuthError("sessionToken, executionId e testCaseId s\xE3o obrigat\xF3rios");
2127
- this.token = sessionToken;
2128
- const tabId = await resolveTabId(this.cfg.storagePrefix, this.loadPersisted()?.tabId);
2129
- const res = await this.api.createSession({
2130
- platformId: this.cfg.platformId,
2131
- testCaseId: opts.testCaseId,
2132
- executionId: opts.executionId,
2133
- tabId,
2134
- sdkVersion: chunkKQVK5EJR_cjs.SDK_VERSION,
2135
- appVersion: this.cfg.appVersion,
2136
- context
2137
- });
2138
- if (!res.recording?.enabled) {
2139
- this.disabledReason = res.recording?.reason;
2140
- throw new SessionDisabledError(res.recording?.reason);
2141
- }
2142
- if (res.sessionToken) this.token = res.sessionToken;
2143
- this.seq = 0;
2144
- this.info = {
2145
- sessionId: res.sessionId,
2146
- executionId: opts.executionId,
2147
- testCaseId: opts.testCaseId,
2148
- platformId: this.cfg.platformId,
2149
- tabId,
2150
- pageId: uid("pg_"),
2151
- startedAt: Date.now()
2152
- };
2153
- safe(() => sessionStorage.removeItem(this.seqKey));
2154
- this.persist("recording", {
2155
- heartbeatIntervalMs: res.heartbeatIntervalMs,
2156
- expiresAt: res.expiresAt,
2157
- pageCount: 1,
2158
- sequence: 0
1475
+ this.status = status;
1476
+ return this.write(["state"], (transaction) => {
1477
+ transaction.objectStore("state").put(status, "status");
2159
1478
  });
2160
- return res;
2161
- }
2162
- /** Reidrata sessão persistida (reload/navegação MPA) após validar no backend. */
2163
- async resume(state) {
2164
- this.token = state.sessionToken;
2165
- const tabId = await resolveTabId(this.cfg.storagePrefix, state.tabId);
2166
- const res = await this.api.status(state.sessionId);
2167
- if (res.status === "expired") throw new SessionExpiredError();
2168
- if (res.status === "disabled") throw new SessionDisabledError();
2169
- if (res.sessionToken) this.token = res.sessionToken;
2170
- this.state = { ...state, tabId };
2171
- this.seq = tabId === state.tabId ? state.sequence : 0;
2172
- this.info = {
2173
- sessionId: state.sessionId,
2174
- executionId: state.executionId,
2175
- testCaseId: state.testCaseId,
2176
- platformId: state.platformId,
2177
- tabId,
2178
- pageId: uid("pg_"),
2179
- startedAt: Date.now()
2180
- };
2181
- this.persist(state.status, { sessionToken: this.token, expiresAt: res.expiresAt ?? state.expiresAt, pageCount: state.pageCount + 1, tabId });
2182
- return res;
2183
- }
2184
- startHeartbeat(onResult, onError, pending, status) {
2185
- this.stopHeartbeat();
2186
- const every = this.state?.heartbeatIntervalMs ?? this.cfg.heartbeatIntervalMs;
2187
- if (!every || every <= 0) return;
2188
- this.heartbeatTimer = setInterval(async () => {
2189
- if (!this.info) return;
2190
- try {
2191
- const r = await this.api.heartbeat(this.info.sessionId, {
2192
- tabId: this.info.tabId,
2193
- pageId: this.info.pageId,
2194
- status: status(),
2195
- pending: pending(),
2196
- lastSequence: this.seq
2197
- });
2198
- if (r.sessionToken) {
2199
- this.token = r.sessionToken;
2200
- this.persist(this.state?.status ?? "recording", { sessionToken: r.sessionToken, expiresAt: r.expiresAt });
2201
- }
2202
- onResult(r);
2203
- } catch (e) {
2204
- if (e instanceof AuthError || e instanceof SessionExpiredError) onError(e);
2205
- }
2206
- }, every);
2207
1479
  }
2208
- stopHeartbeat() {
2209
- if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
2210
- this.heartbeatTimer = void 0;
1480
+ async clear() {
1481
+ await this.write(["events", "state"], (transaction) => {
1482
+ transaction.objectStore("events").clear();
1483
+ transaction.objectStore("state").clear();
1484
+ });
1485
+ this.failure = void 0;
1486
+ this.status = void 0;
1487
+ this.uncommitted.clear();
1488
+ sessionStorage.removeItem(this.checkpointKey);
2211
1489
  }
2212
- async complete(summary) {
2213
- if (!this.info) return false;
2214
- const r = await this.api.complete(this.info.sessionId, { tabId: this.info.tabId, lastSequence: this.seq, ...summary });
2215
- return !!r.completed;
1490
+ /** Synchronous handoff of the last uncommitted events before document unload. */
1491
+ checkpoint() {
1492
+ if (!this.status) return;
1493
+ sessionStorage.setItem(this.checkpointKey, JSON.stringify({ status: this.status, events: [...this.uncommitted.values()] }));
2216
1494
  }
2217
- reset() {
2218
- this.stopHeartbeat();
2219
- this.info = null;
2220
- this.state = null;
2221
- this.token = "";
2222
- this.seq = 0;
1495
+ get checkpointKey() {
1496
+ return `${this.database.name}-handoff`;
2223
1497
  }
2224
- };
2225
- function consumeQueryToken() {
2226
- if (typeof window === "undefined") return "";
2227
- try {
2228
- const token = readQueryToken();
2229
- if (!token) return "";
2230
- const url = new URL(window.location.href);
2231
- url.searchParams.delete("offscript_token");
2232
- window.history.replaceState(window.history.state, typeof document === "undefined" ? "" : document.title, `${url.pathname}${url.search}${url.hash}`);
2233
- return token;
2234
- } catch {
2235
- return "";
2236
- }
2237
- }
2238
- function readQueryToken() {
2239
- if (typeof window === "undefined") return "";
2240
- try {
2241
- return new URL(window.location.href).searchParams.get("offscript_token")?.trim() ?? "";
2242
- } catch {
2243
- return "";
1498
+ async flush() {
1499
+ await Promise.allSettled([...this.pending]);
1500
+ if (this.failure) throw this.failure;
2244
1501
  }
2245
- }
2246
- var heldLocks = [];
2247
- async function resolveTabId(prefix, previous) {
2248
- const candidate = previous ?? uid("tab_");
2249
- const locks = globalThis.navigator?.locks;
2250
- if (!locks || !previous) {
2251
- if (locks) void claim(locks, `${prefix}:tab:${candidate}`);
2252
- return candidate;
2253
- }
2254
- if (heldLocks.includes(candidate)) return candidate;
2255
- const ok = await claim(locks, `${prefix}:tab:${candidate}`);
2256
- if (ok) return candidate;
2257
- const fresh = uid("tab_");
2258
- void claim(locks, `${prefix}:tab:${fresh}`);
2259
- return fresh;
2260
- }
2261
- function claim(locks, name) {
2262
- return new Promise((resolve) => {
1502
+ async close() {
2263
1503
  try {
2264
- void locks.request(name, { ifAvailable: true }, (lock) => {
2265
- if (!lock) {
2266
- resolve(false);
2267
- return void 0;
2268
- }
2269
- heldLocks.push(name.split(":tab:")[1]);
2270
- resolve(true);
2271
- return new Promise(() => void 0);
2272
- }).catch(() => resolve(true));
2273
- } catch {
2274
- resolve(true);
1504
+ await this.flush();
1505
+ } finally {
1506
+ this.database.close();
2275
1507
  }
2276
- });
2277
- }
1508
+ }
1509
+ write(stores, apply) {
1510
+ const operation = new Promise((resolve, reject) => {
1511
+ const transaction = this.database.transaction(stores, "readwrite");
1512
+ transaction.oncomplete = () => resolve();
1513
+ transaction.onabort = () => reject(transaction.error ?? new Error("Falha ao salvar grava\xE7\xE3o local"));
1514
+ try {
1515
+ apply(transaction);
1516
+ } catch (error) {
1517
+ transaction.abort();
1518
+ reject(error);
1519
+ }
1520
+ });
1521
+ this.pending.add(operation);
1522
+ void operation.catch((error) => {
1523
+ this.failure = error instanceof Error ? error : new Error(String(error));
1524
+ }).finally(() => this.pending.delete(operation));
1525
+ return operation;
1526
+ }
1527
+ };
2278
1528
 
2279
1529
  // src/core/config.ts
2280
1530
  function resolveConfig(input) {
2281
- if (!input || typeof input !== "object") throw new ConfigError("Configura\xE7\xE3o ausente");
2282
- if (!input.platformId) throw new ConfigError("platformId \xE9 obrigat\xF3rio");
2283
- if (!input.apiUrl) throw new ConfigError("apiUrl \xE9 obrigat\xF3rio");
2284
- let url;
2285
- try {
2286
- url = new URL(input.apiUrl);
2287
- } catch {
2288
- throw new ConfigError(`apiUrl inv\xE1lida: ${input.apiUrl}`);
2289
- }
2290
- const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
2291
- if (url.protocol !== "https:" && !(url.protocol === "http:" && local))
2292
- throw new ConfigError("apiUrl deve usar HTTPS (HTTP s\xF3 \xE9 aceito em localhost)");
2293
1531
  return {
2294
- apiUrl: url.origin + url.pathname.replace(/\/+$/, ""),
2295
- platformId: input.platformId,
2296
- appVersion: input.appVersion,
2297
1532
  recording: deepMerge(DEFAULT_RECORDING, input.recording),
2298
1533
  privacy: resolvePrivacy(input.privacy),
2299
- transport: deepMerge(DEFAULT_TRANSPORT, input.transport),
2300
1534
  correlationWindowMs: input.correlationWindowMs ?? 3e3,
2301
- autoResume: input.autoResume ?? true,
2302
- heartbeatIntervalMs: input.heartbeatIntervalMs ?? 3e4,
2303
- storagePrefix: input.storagePrefix ?? "offscript",
2304
- debug: input.debug ?? false,
2305
- controls: input.controls ?? true
1535
+ controls: input.controls ?? true,
1536
+ persistenceKey: input.persistenceKey,
1537
+ onComplete: input.onComplete
2306
1538
  };
2307
1539
  }
2308
1540
 
@@ -2371,6 +1603,22 @@ var RecordingControls = class {
2371
1603
  this.render();
2372
1604
  this.startEyes();
2373
1605
  }
1606
+ /** Independent notice so the host may destroy the controls after successful upload. */
1607
+ showSuccess() {
1608
+ const notice = document.createElement("div");
1609
+ notice.setAttribute("data-offscript-success", "");
1610
+ notice.setAttribute("data-offscript-ignore", "");
1611
+ notice.className = "offscript-ignore";
1612
+ notice.style.cssText = "position:fixed;right:16px;bottom:16px;max-width:calc(100vw - 32px);z-index:2147483647;";
1613
+ const shadow = notice.attachShadow({ mode: "closed" });
1614
+ shadow.innerHTML = SUCCESS_TEMPLATE;
1615
+ document.body.appendChild(notice);
1616
+ const timer = window.setTimeout(() => notice.remove(), 8e3);
1617
+ shadow.querySelector("button").addEventListener("click", () => {
1618
+ window.clearTimeout(timer);
1619
+ notice.remove();
1620
+ });
1621
+ }
2374
1622
  unmount() {
2375
1623
  this.unsubscribe?.();
2376
1624
  this.unsubscribe = void 0;
@@ -2422,15 +1670,15 @@ var RecordingControls = class {
2422
1670
  } else if (action === "close") {
2423
1671
  this.expanded = false;
2424
1672
  this.render();
2425
- } else if (action === "record") void this.run(() => this.sdk.startArmedRecording());
1673
+ } else if (action === "record") void this.run(() => this.sdk.startRecording());
2426
1674
  else if (action === "pause") void this.run(() => this.sdk.pauseRecording());
2427
1675
  else if (action === "resume") void this.run(() => this.sdk.resumeRecording());
2428
- else if (action === "stop") void this.run(() => this.sdk.stopRecording());
1676
+ else if (action === "stop") void this.run(() => this.sdk.finishRecording());
2429
1677
  else if (action === "new") this.openConfirm();
2430
1678
  else if (action === "cancel") this.closeConfirm();
2431
1679
  else if (action === "confirm") {
2432
1680
  this.closeConfirm();
2433
- void this.run(() => this.sdk.startNewSession());
1681
+ void this.run(() => this.sdk.discardRecording());
2434
1682
  } else if (action === "privacy-show") {
2435
1683
  this.privacyOpen = true;
2436
1684
  this.render();
@@ -2498,23 +1746,24 @@ var RecordingControls = class {
2498
1746
  }
2499
1747
  render() {
2500
1748
  this.place();
2501
- const status = this.sdk.getState().status;
1749
+ const { status, pending } = this.sdk.getState();
2502
1750
  const shell = this.shadow?.querySelector("[data-shell]");
2503
1751
  shell?.setAttribute("data-open", this.expanded ? "true" : "false");
2504
1752
  shell?.setAttribute("data-state", status);
2505
1753
  if (this.note) this.note.textContent = this.failure;
2506
1754
  const live = status === "recording" || status === "paused";
1755
+ const canFinish = live || status === "stopped" && pending > 0;
2507
1756
  if (!live) this.closeConfirm();
2508
1757
  this.paintPrivacy();
2509
1758
  this.setHidden("record", live || status === "initializing" || status === "stopping");
2510
1759
  this.setHidden("pause", status !== "recording");
2511
1760
  this.setHidden("resume", status !== "paused");
2512
1761
  this.setHidden("new", !live);
2513
- this.setHidden("stop", !live);
1762
+ this.setHidden("stop", !canFinish);
2514
1763
  this.setEnabled("record", !this.busy && !live && status !== "initializing" && status !== "stopping");
2515
1764
  this.setEnabled("pause", !this.busy && status === "recording");
2516
1765
  this.setEnabled("resume", !this.busy && status === "paused");
2517
- this.setEnabled("stop", !this.busy && live);
1766
+ this.setEnabled("stop", !this.busy && canFinish);
2518
1767
  this.setEnabled("new", !this.busy && live);
2519
1768
  }
2520
1769
  paintPrivacy() {
@@ -2904,7 +2153,7 @@ var TEMPLATE = `
2904
2153
  <button type="button" class="text record" data-action="record">Iniciar</button>
2905
2154
  <button type="button" data-action="pause" aria-label="Pausar" title="Pausar">${ICON.pause}</button>
2906
2155
  <button type="button" data-action="resume" aria-label="Retomar" title="Retomar">${ICON.play}</button>
2907
- <button type="button" class="text" data-action="new" title="Descarta o que foi gravado e volta ao in\xEDcio">Nova sess\xE3o</button>
2156
+ <button type="button" class="text" data-action="new" title="Descarta o que foi gravado e volta ao in\xEDcio">Nova grava\xE7\xE3o</button>
2908
2157
  <button type="button" class="text finish" data-action="stop" title="Encerra a coleta e envia o que foi gravado">Finalizar</button>
2909
2158
  <button type="button" data-action="close" aria-label="Fechar" title="Fechar">${ICON.close}</button>
2910
2159
  </div>
@@ -2912,7 +2161,7 @@ var TEMPLATE = `
2912
2161
  <div class="speak" data-confirm hidden>
2913
2162
  <div class="bubble" role="dialog" aria-labelledby="offscript-confirm-title">
2914
2163
  ${mark("offscript-trail-speak")}
2915
- <p id="offscript-confirm-title">Descartar esta sess\xE3o? O que foi gravado ser\xE1 ignorado.</p>
2164
+ <p id="offscript-confirm-title">Descartar esta grava\xE7\xE3o? O que foi gravado ser\xE1 ignorado.</p>
2916
2165
  <div class="choices">
2917
2166
  <button type="button" class="choice secondary" data-action="cancel">Cancelar</button>
2918
2167
  <button type="button" class="choice danger" data-action="confirm">Descartar</button>
@@ -2922,325 +2171,309 @@ var TEMPLATE = `
2922
2171
  <p class="note" data-note></p>
2923
2172
  </div>
2924
2173
  `;
2174
+ var SUCCESS_TEMPLATE = `
2175
+ <style>
2176
+ :host { color-scheme: dark; }
2177
+ .success {
2178
+ display: flex;
2179
+ align-items: center;
2180
+ gap: 12px;
2181
+ padding: 12px 14px;
2182
+ border: 1px solid #377c65;
2183
+ border-radius: 16px;
2184
+ background: #152c25;
2185
+ color: #dcfce7;
2186
+ box-shadow: 0 12px 32px rgba(3, 5, 12, 0.35);
2187
+ font: 500 13px/1.5 "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif;
2188
+ }
2189
+ p { margin: 0; }
2190
+ button {
2191
+ flex: none;
2192
+ display: grid;
2193
+ place-items: center;
2194
+ width: 28px;
2195
+ height: 28px;
2196
+ padding: 0;
2197
+ border: 0;
2198
+ border-radius: 999px;
2199
+ background: transparent;
2200
+ color: inherit;
2201
+ font: 20px/1 ui-sans-serif, system-ui, sans-serif;
2202
+ cursor: pointer;
2203
+ }
2204
+ button:hover { background: #244a3c; }
2205
+ button:focus-visible { outline: 2px solid #86efac; outline-offset: 2px; }
2206
+ </style>
2207
+ <div class="success">
2208
+ <p role="status">Grava\xE7\xE3o enviada com sucesso!</p>
2209
+ <button type="button" aria-label="Fechar confirma\xE7\xE3o">\xD7</button>
2210
+ </div>
2211
+ `;
2925
2212
 
2926
2213
  // src/core/OffscriptSDK.ts
2927
- var MAX_LOG = 5e3;
2928
2214
  var Offscript = class {
2929
- constructor(config) {
2215
+ constructor(config = {}) {
2930
2216
  this.bus = new Emitter();
2931
2217
  this.status = "idle";
2932
2218
  this.recorders = [];
2933
2219
  this.accepting = false;
2934
2220
  this.destroyed = false;
2221
+ this.suspended = false;
2222
+ this.events = [];
2935
2223
  this.recent = [];
2936
2224
  this.actionLog = [];
2937
2225
  this.contextLog = [];
2938
- this.eventCount = 0;
2939
- this.metrics = { emitMs: 0, emitted: 0, dropped: 0 };
2226
+ this.sequence = 0;
2227
+ this.pageId = uid("page_");
2228
+ this.submitted = false;
2940
2229
  this.config = resolveConfig(config);
2941
2230
  this.sanitizer = new DataSanitizer(this.config.privacy);
2942
- this.sessions = new SessionManager(this.config);
2943
2231
  }
2944
- // ------------------------------------------------------------------ API pública
2945
2232
  getState() {
2946
- return { status: this.status, session: this.sessions.session, pending: this.transport?.pending ?? 0, lastError: this.lastError };
2233
+ return { status: this.status, pending: this.submitted ? 0 : this.events.length, lastError: this.lastError };
2947
2234
  }
2948
2235
  capturesSensitiveValues() {
2949
- return this.config.privacy.captureSensitiveValues === true;
2950
- }
2951
- getMetrics() {
2952
- return { emitted: this.metrics.emitted, dropped: this.metrics.dropped, avgEmitMs: this.metrics.emitted ? this.metrics.emitMs / this.metrics.emitted : 0, bufferKind: this.transport?.bufferKind };
2236
+ return this.config.privacy.captureSensitiveValues;
2237
+ }
2238
+ async init() {
2239
+ this.assertAlive();
2240
+ if (typeof window === "undefined") throw new OffscriptError("NOT_SUPPORTED", "O SDK requer um navegador");
2241
+ this.initPromise ?? (this.initPromise = this.initializeDraft());
2242
+ await this.initPromise;
2243
+ if (this.restorePromise) await this.restorePromise;
2244
+ if (this.config.controls && !this.controls) {
2245
+ this.controls = new RecordingControls(this);
2246
+ this.controls.mount();
2247
+ }
2953
2248
  }
2954
- /** Idempotente. Prepara armazenamento/transporte e retoma sessão persistida (reload/MPA). */
2955
- init() {
2956
- if (this.destroyed) return Promise.reject(new InvalidStateError("SDK destru\xEDdo"));
2957
- this.initPromise ?? (this.initPromise = this.doInit());
2958
- return this.initPromise;
2249
+ async initializeDraft() {
2250
+ if (!this.config.persistenceKey) return;
2251
+ this.store = await RecordingStore.open(this.config.persistenceKey);
2252
+ await this.restoreDraft();
2959
2253
  }
2960
- async doInit() {
2961
- if (typeof window === "undefined") throw new OffscriptError("NOT_SUPPORTED", "O SDK requer um ambiente de navegador");
2962
- this.ensureControls();
2963
- this.setStatus("initializing");
2964
- try {
2965
- const buffer = await openBuffer(`${this.config.storagePrefix}-${this.config.platformId}`, this.config.transport.persist);
2966
- this.transport = new EventTransport(this.config.transport, this.sessions.api, buffer);
2967
- this.transport.on("flushed", (e) => this.bus.emit("flush", e));
2968
- this.transport.on("overflow", (e) => this.bus.emit("overflow", e));
2969
- this.transport.on("fatal", ({ error }) => void this.onFatal(error));
2970
- this.transport.on("failed", ({ error }) => this.debug("transport failed", error));
2971
- await this.transport.purgeExpired(this.config.privacy.localRetentionMs);
2254
+ async restoreDraft() {
2255
+ const saved = await this.store.load();
2256
+ if (this.suspended) return;
2257
+ this.clearRecording();
2258
+ if (!saved) {
2972
2259
  this.setStatus("idle");
2973
- const persisted = this.config.autoResume ? this.sessions.loadPersisted() : null;
2974
- if (persisted) await this.resumePersisted(persisted);
2975
- } catch (e) {
2976
- this.fail(e);
2977
- throw e;
2260
+ return;
2261
+ }
2262
+ this.events = saved.events;
2263
+ this.sequence = saved.events[saved.events.length - 1]?.sequence ?? 0;
2264
+ this.actionLog = saved.events.filter((event) => event.kind === "action").map((event) => event.data);
2265
+ this.contextLog = saved.events.filter((event) => event.kind === "context").map((event) => event.data);
2266
+ this.setStatus(saved.status);
2267
+ if (saved.status === "stopped") {
2268
+ this.payload = this.buildPayload();
2269
+ return;
2978
2270
  }
2271
+ this.accepting = saved.status === "recording";
2272
+ this.attach();
2273
+ await this.startRecorders();
2274
+ if (saved.status === "paused") this.recorders.forEach((recorder) => recorder.pause?.());
2275
+ }
2276
+ save(operation) {
2277
+ void operation.catch((error) => {
2278
+ const err = error instanceof Error ? error : new Error(String(error));
2279
+ this.lastError = { code: "LOCAL_STORAGE_FAILED", message: err.message };
2280
+ this.bus.emit("error", err);
2281
+ });
2979
2282
  }
2980
- async startRecording(opts) {
2981
- if (this.destroyed) throw new InvalidStateError("SDK destru\xEDdo");
2283
+ startRecording() {
2982
2284
  if (this.startPromise) return this.startPromise;
2983
- const p = this.doStart(opts).finally(() => this.startPromise = void 0);
2984
- this.startPromise = p;
2985
- return p;
2285
+ this.startPromise = this.doStart().finally(() => {
2286
+ this.startPromise = void 0;
2287
+ });
2288
+ return this.startPromise;
2986
2289
  }
2987
- async doStart(opts) {
2290
+ async doStart() {
2988
2291
  await this.init();
2989
- const cur = this.sessions.session;
2990
- if ((this.status === "recording" || this.status === "paused") && cur) {
2991
- if (cur.executionId === opts.executionId) return cur;
2992
- throw new InvalidStateError(`J\xE1 existe grava\xE7\xE3o ativa para a execu\xE7\xE3o ${cur.executionId}. Encerre-a antes.`);
2993
- }
2994
- if (this.status === "stopping") throw new InvalidStateError("Grava\xE7\xE3o em encerramento");
2995
- this.lastStart = { testCaseId: opts.testCaseId, executionId: opts.executionId, sessionToken: opts.sessionToken };
2292
+ if (this.status === "recording" || this.status === "paused") return;
2293
+ if (this.completePromise || this.stopPromise) throw new InvalidStateError("Finaliza\xE7\xE3o em andamento");
2294
+ await this.store?.clear();
2295
+ this.clearRecording();
2996
2296
  this.setStatus("initializing");
2997
- try {
2998
- const ctx = collectContext({ platformId: this.config.platformId, testCaseId: opts.testCaseId, executionId: opts.executionId }, "initial", this.config.appVersion, this.config.privacy.captureSensitiveValues);
2999
- await this.sessions.create(opts, { ...ctx, url: this.sanitizer.sanitizeUrl(ctx.url), referrer: void 0, title: void 0 });
3000
- const s = this.sessions.session;
3001
- await this.transport.start({ sessionId: s.sessionId, executionId: s.executionId, tabId: s.tabId });
3002
- this.attach();
3003
- this.accepting = true;
3004
- this.setStatus("recording");
3005
- this.emitLifecycle("session-start", { pageCount: 1 }, "critical");
3006
- await this.startRecorders();
3007
- this.sessions.startHeartbeat((r) => this.onHeartbeat(r.status), (e) => void this.onFatal(e), () => this.transport?.pending ?? 0, () => this.status);
3008
- this.bus.emit("sessionStart", s);
3009
- return s;
3010
- } catch (e) {
3011
- await this.teardown();
3012
- this.sessions.clearPersisted();
3013
- this.sessions.reset();
3014
- this.fail(e);
3015
- throw e;
3016
- }
3017
- }
3018
- async resumePersisted(state) {
3019
- this.setStatus("initializing");
3020
- try {
3021
- await this.sessions.resume(state);
3022
- const s = this.sessions.session;
3023
- await this.transport.start({ sessionId: s.sessionId, executionId: s.executionId, tabId: s.tabId });
3024
- this.attach();
3025
- this.accepting = state.status === "recording";
3026
- this.setStatus(state.status);
3027
- this.emitLifecycle("session-resume", { pageCount: this.sessions.pageCount }, "critical");
3028
- this.emitLifecycle("page-start", { navigationType: safe(() => performance.getEntriesByType("navigation")[0]?.type) }, "critical");
3029
- await this.startRecorders();
3030
- if (state.status === "paused") this.recorders.forEach((r) => r.pause?.());
3031
- this.actions?.recordPageLoad(safe(() => performance.getEntriesByType("navigation")[0]?.type), document.referrer);
3032
- this.sessions.startHeartbeat((r) => this.onHeartbeat(r.status), (e) => void this.onFatal(e), () => this.transport?.pending ?? 0, () => this.status);
3033
- const token = this.sessions.currentToken;
3034
- if (token) {
3035
- this.lastStart = { testCaseId: s.testCaseId, executionId: s.executionId, sessionToken: token };
3036
- this.armed ?? (this.armed = { testCaseId: s.testCaseId, sessionToken: token });
3037
- }
3038
- this.bus.emit("sessionStart", s);
3039
- } catch (e) {
3040
- await this.teardown();
3041
- this.sessions.clearPersisted();
3042
- this.sessions.reset();
3043
- this.fail(e);
3044
- if (!(e instanceof SessionExpiredError || e instanceof SessionDisabledError || e instanceof AuthError)) throw e;
3045
- }
3046
- }
3047
- /** Guarda caso de teste e token para o botão de gravar, sem abrir a sessão. */
3048
- armRecording(opts) {
3049
- this.armed = { testCaseId: opts.testCaseId, sessionToken: opts.sessionToken };
3050
- }
3051
- /** Abre a coleta com os dados armados, ou com os da última sessão. */
3052
- startArmedRecording() {
3053
- if ((this.status === "recording" || this.status === "paused") && this.sessions.session) return Promise.resolve(this.sessions.session);
3054
- return this.startRecording(this.freshStart());
3055
- }
3056
- /** Descarta a coleta atual e volta ao estado inicial, sem abrir outra. */
3057
- async startNewSession() {
3058
- if (this.status !== "recording" && this.status !== "paused") return;
3059
- await this.discardRecording();
3060
- }
3061
- freshStart() {
3062
- const base = this.armed ?? (this.lastStart ? { testCaseId: this.lastStart.testCaseId, sessionToken: this.lastStart.sessionToken } : void 0);
3063
- if (!base?.testCaseId || !base.sessionToken) throw new InvalidStateError("N\xE3o h\xE1 dados para iniciar a grava\xE7\xE3o");
3064
- return { testCaseId: base.testCaseId, sessionToken: base.sessionToken, executionId: uid("exec_") };
3065
- }
3066
- async discardRecording() {
3067
- const sessionId = this.sessions.session?.sessionId ?? "";
3068
- this.accepting = false;
3069
- this.stopRecorders();
3070
- this.sessions.stopHeartbeat();
3071
- this.transport?.stop();
3072
- if (sessionId) await this.transport?.clearSession(sessionId);
3073
- this.lifecycle?.uninstall();
3074
- this.sessions.clearPersisted();
3075
- this.sessions.reset();
3076
- this.setStatus("idle");
3077
- if (sessionId) this.bus.emit("sessionDiscard", { sessionId });
2297
+ await this.store?.setStatus("recording");
2298
+ this.accepting = true;
2299
+ this.attach();
2300
+ this.emitLifecycle("recording-start");
2301
+ await this.startRecorders();
2302
+ this.setStatus("recording");
2303
+ await this.store?.flush();
2304
+ this.bus.emit("recordingStart", void 0);
3078
2305
  }
3079
2306
  async pauseRecording() {
2307
+ this.assertAlive();
2308
+ if (this.restorePromise) await this.restorePromise;
3080
2309
  if (this.status === "paused") return;
3081
- if (this.status !== "recording") throw new InvalidStateError(`N\xE3o \xE9 poss\xEDvel pausar no estado ${this.status}`);
2310
+ if (this.status !== "recording") throw new InvalidStateError("Sem grava\xE7\xE3o ativa");
3082
2311
  this.actions?.flushAll();
3083
- this.emitLifecycle("pause", {}, "critical");
2312
+ this.emitLifecycle("pause");
3084
2313
  this.accepting = false;
3085
2314
  this.recorders.forEach((r) => safe(() => r.pause?.()));
3086
- this.sessions.setStatus("paused");
3087
2315
  this.setStatus("paused");
3088
- void this.transport?.flush();
2316
+ await this.store?.flush();
3089
2317
  }
3090
2318
  async resumeRecording() {
2319
+ this.assertAlive();
2320
+ if (this.restorePromise) await this.restorePromise;
3091
2321
  if (this.status === "recording") return;
3092
- if (this.status !== "paused") throw new InvalidStateError(`N\xE3o \xE9 poss\xEDvel retomar no estado ${this.status}`);
2322
+ if (this.status !== "paused") throw new InvalidStateError("Grava\xE7\xE3o n\xE3o est\xE1 pausada");
3093
2323
  this.accepting = true;
3094
- this.sessions.setStatus("recording");
2324
+ this.emitLifecycle("resume");
2325
+ for (const r of this.recorders) await r.resume?.();
3095
2326
  this.setStatus("recording");
3096
- this.emitLifecycle("resume", {}, "critical");
3097
- for (const r of this.recorders) await Promise.resolve(safe(() => r.resume?.()));
2327
+ await this.store?.flush();
3098
2328
  }
3099
2329
  stopRecording() {
3100
2330
  if (this.stopPromise) return this.stopPromise;
3101
- const s = this.sessions.session;
3102
- if (!s || this.status !== "recording" && this.status !== "paused" && this.status !== "error") {
3103
- if (this.status === "stopped" || this.status === "idle")
3104
- return Promise.resolve({ sessionId: s?.sessionId ?? "", flushed: true, pendingEvents: 0, completed: true });
3105
- return Promise.reject(new InvalidStateError(`N\xE3o \xE9 poss\xEDvel encerrar no estado ${this.status}`));
3106
- }
3107
- this.stopPromise = this.doStop().finally(() => this.stopPromise = void 0);
2331
+ this.stopPromise = this.doStop().finally(() => {
2332
+ this.stopPromise = void 0;
2333
+ });
3108
2334
  return this.stopPromise;
3109
2335
  }
3110
2336
  async doStop() {
3111
- const session = this.sessions.session;
2337
+ this.assertAlive();
2338
+ if (this.restorePromise) await this.restorePromise;
2339
+ if (this.startPromise) await this.startPromise;
2340
+ if (this.payload) return this.payload;
2341
+ if (this.status !== "recording" && this.status !== "paused") throw new InvalidStateError("Sem grava\xE7\xE3o ativa");
3112
2342
  this.setStatus("stopping");
3113
- if (this.status === "stopping") {
3114
- this.actions?.flushAll();
3115
- this.emitLifecycle("stop", { eventCount: this.eventCount }, "critical");
2343
+ this.actions?.flushAll();
2344
+ this.emitLifecycle("stop");
2345
+ const recorders = this.recorders.splice(0).reverse();
2346
+ this.lifecycle?.uninstall();
2347
+ for (const recorder of recorders) {
2348
+ if (!(recorder instanceof HTTPRecorder)) safe(() => recorder.stop());
2349
+ }
2350
+ for (const recorder of recorders) {
2351
+ if (recorder instanceof HTTPRecorder) await recorder.drain();
3116
2352
  }
3117
2353
  this.accepting = false;
3118
- this.stopRecorders();
3119
- this.sessions.stopHeartbeat();
3120
- const { ok, pending } = await this.transport?.flushFinal() ?? { ok: true, pending: 0 };
3121
- let completed = false;
3122
- if (ok) {
2354
+ this.rrweb = this.actions = this.context = void 0;
2355
+ this.payload = this.buildPayload();
2356
+ this.setStatus("stopped");
2357
+ await this.store?.flush();
2358
+ this.bus.emit("recordingEnd", this.payload);
2359
+ return this.payload;
2360
+ }
2361
+ complete(options) {
2362
+ this.assertAlive();
2363
+ if (this.completePromise) return this.completePromise;
2364
+ if (this.submitted) return Promise.resolve(this.response);
2365
+ this.completePromise = this.doComplete(options).finally(() => {
2366
+ this.completePromise = void 0;
2367
+ });
2368
+ return this.completePromise;
2369
+ }
2370
+ async doComplete({ endpoint, token }) {
2371
+ const url = new URL(endpoint, location.href);
2372
+ if (!["https:", "http:"].includes(url.protocol)) throw new ConfigError("Endpoint HTTP inv\xE1lido");
2373
+ if (!token?.trim()) throw new ConfigError("Token obrigat\xF3rio para envio");
2374
+ const payload = await this.stopRecording();
2375
+ try {
2376
+ const response = await fetch(url.href, {
2377
+ method: "POST",
2378
+ headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: `Bearer ${token.trim()}` },
2379
+ body: JSON.stringify(payload)
2380
+ });
2381
+ if (!response.ok) throw new OffscriptError("TRANSPORT_FAILED", `Falha ao enviar grava\xE7\xE3o: HTTP ${response.status}`, { status: response.status });
2382
+ this.submitted = true;
2383
+ await this.store?.clear();
2384
+ const text = await response.text();
3123
2385
  try {
3124
- completed = await this.sessions.complete({ eventCount: this.eventCount, pages: this.sessions.pageCount });
3125
- } catch (e) {
3126
- this.bus.emit("error", e);
2386
+ this.response = text ? JSON.parse(text) : null;
2387
+ } catch {
2388
+ this.response = text;
3127
2389
  }
2390
+ this.lastError = void 0;
2391
+ this.controls?.showSuccess();
2392
+ return this.response;
2393
+ } catch (error) {
2394
+ const err = error instanceof Error ? error : new Error(String(error));
2395
+ this.lastError = { code: err instanceof OffscriptError ? err.code : "INTERNAL", message: err.message };
2396
+ this.bus.emit("error", err);
2397
+ throw err;
3128
2398
  }
3129
- if (ok) await this.transport?.clearSession(session.sessionId);
3130
- this.transport?.stop();
2399
+ }
2400
+ async finishRecording() {
2401
+ if (this.config.onComplete) return this.config.onComplete();
2402
+ return this.stopRecording();
2403
+ }
2404
+ async discardRecording() {
2405
+ this.assertAlive();
2406
+ if (this.restorePromise) await this.restorePromise;
2407
+ if (this.startPromise) await this.startPromise;
2408
+ if (this.stopPromise) await this.stopPromise;
2409
+ if (this.completePromise) throw new InvalidStateError("Envio em andamento");
2410
+ this.accepting = false;
2411
+ this.stopRecorders();
3131
2412
  this.lifecycle?.uninstall();
3132
- this.sessions.clearPersisted();
3133
- this.sessions.reset();
3134
- const summary = { sessionId: session.sessionId, flushed: ok, pendingEvents: pending, completed };
3135
- this.setStatus("stopped");
3136
- this.bus.emit("sessionEnd", summary);
3137
- return summary;
3138
- }
3139
- /**
3140
- * Libera todos os recursos. Por padrão encerra a gravação ativa; com `{ complete: false }` apenas
3141
- * desanexa a instrumentação e mantém a sessão persistida para ser retomada por outra instância.
3142
- */
3143
- async destroy(opts = {}) {
2413
+ await this.store?.clear();
2414
+ this.clearRecording();
2415
+ this.setStatus("idle");
2416
+ }
2417
+ async destroy() {
3144
2418
  if (this.destroyed) return;
3145
- if (opts.complete !== false && (this.status === "recording" || this.status === "paused")) {
3146
- await this.stopRecording().catch(() => void 0);
3147
- } else {
3148
- this.actions?.flushAll();
3149
- if (this.accepting) this.emitLifecycle("page-end", {}, "critical");
3150
- this.accepting = false;
3151
- await this.transport?.flushFinal(2e3).catch(() => void 0);
3152
- }
3153
- this.destroyed = true;
3154
- await this.teardown();
2419
+ if (this.restorePromise) await this.restorePromise;
2420
+ if (this.startPromise) await this.startPromise;
2421
+ if (this.completePromise) await this.completePromise.catch(() => void 0);
2422
+ if (this.stopPromise) await this.stopPromise;
2423
+ this.accepting = false;
2424
+ this.stopRecorders();
2425
+ this.lifecycle?.uninstall();
3155
2426
  this.controls?.unmount();
3156
- this.controls = void 0;
3157
- this.sessions.reset();
3158
- this.transport?.close();
2427
+ await this.store?.close();
3159
2428
  this.bus.clear();
2429
+ this.clearRecording();
2430
+ this.destroyed = true;
3160
2431
  }
3161
- /** Dados estruturados para o motor Playwright, construídos a partir do que foi gravado nesta página. */
3162
2432
  exportPlaywrightData() {
3163
- const s = this.sessions.session;
3164
- if (!s) throw new InvalidStateError("Sem sess\xE3o ativa");
3165
- return chunkKQVK5EJR_cjs.buildPlaywrightContract({
3166
- session: { sessionId: s.sessionId, executionId: s.executionId, testCaseId: s.testCaseId, platformId: s.platformId },
3167
- actions: this.actionLog,
3168
- contexts: this.contextLog.map((e) => e.data)
3169
- });
2433
+ return chunkHPDLEAAL_cjs.buildPlaywrightContract({ actions: this.actionLog, contexts: this.contextLog });
3170
2434
  }
3171
2435
  getRecordedActions() {
3172
2436
  return this.actionLog;
3173
2437
  }
3174
- // ------------------------------------------------------------------ EventSink
3175
2438
  on(type, fn) {
3176
2439
  return this.bus.on(type, fn);
3177
2440
  }
3178
2441
  off(type, fn) {
3179
2442
  this.bus.off(type, fn);
3180
2443
  }
3181
- /** Usado pelos recorders. Não confundir com os eventos públicos (`on`). */
3182
2444
  emit(kind, data, opts = {}) {
3183
2445
  return this.record(kind, data, opts);
3184
2446
  }
3185
2447
  record(kind, data, opts) {
3186
- const t0 = typeof performance !== "undefined" ? performance.now() : 0;
3187
- try {
3188
- const session = this.sessions.session;
3189
- const transport = this.transport;
3190
- if (!this.accepting || !session || !transport) return null;
3191
- const priority = opts.priority ?? "normal";
3192
- if (!transport.canAccept(priority)) {
3193
- transport.noteDropped();
3194
- this.metrics.dropped++;
3195
- return null;
3196
- }
3197
- this.rrweb?.recoverIfNeeded();
3198
- const sequence = this.sessions.nextSequence();
3199
- const timestamp = opts.at ?? Date.now();
3200
- const id = `${session.tabId}:${sequence}`;
3201
- let payload = data;
3202
- let relatedActionId;
3203
- let relatedActionDeltaMs;
3204
- if (kind === "action") {
3205
- const action = { ...data, id, sessionId: session.sessionId, executionId: session.executionId, sequence, timestamp };
3206
- payload = action;
3207
- this.recent.push({ id, t: timestamp });
3208
- if (this.recent.length > 50) this.recent.shift();
3209
- this.actionLog.push(action);
3210
- if (this.actionLog.length > MAX_LOG) this.actionLog.shift();
3211
- } else if (kind === "http" || kind === "console" || kind === "error" || kind === "iframe") {
3212
- const rel = this.correlate(timestamp);
3213
- relatedActionId = rel?.id;
3214
- relatedActionDeltaMs = rel ? timestamp - rel.t : void 0;
3215
- }
3216
- const event = {
3217
- id,
3218
- kind,
3219
- sessionId: session.sessionId,
3220
- executionId: session.executionId,
3221
- tabId: session.tabId,
3222
- pageId: session.pageId,
3223
- sequence,
3224
- timestamp,
3225
- relatedActionId,
3226
- relatedActionDeltaMs,
3227
- data: payload
3228
- };
3229
- if (kind === "context") {
3230
- this.contextLog.push(event);
3231
- if (this.contextLog.length > 50) this.contextLog.shift();
3232
- }
3233
- this.eventCount++;
3234
- transport.enqueue(event);
3235
- this.metrics.emitted++;
3236
- if (this.config.debug) this.debug("event", kind, sequence);
3237
- return event;
3238
- } catch (e) {
3239
- this.debug("emit failed", e);
3240
- return null;
3241
- } finally {
3242
- if (t0) this.metrics.emitMs += performance.now() - t0;
2448
+ if (!this.accepting) return null;
2449
+ this.rrweb?.recoverIfNeeded();
2450
+ const sequence = ++this.sequence;
2451
+ const timestamp = opts.at ?? Date.now();
2452
+ const id = `${this.pageId}:${sequence}`;
2453
+ let payload = data;
2454
+ if (kind === "action") {
2455
+ const action = { ...data, id, sequence, timestamp };
2456
+ payload = action;
2457
+ this.actionLog.push(action);
2458
+ this.recent.push({ id, t: timestamp });
2459
+ if (this.recent.length > 50) this.recent.shift();
3243
2460
  }
2461
+ const related = ["http", "console", "error", "iframe"].includes(kind) ? this.correlate(timestamp) : void 0;
2462
+ const event = {
2463
+ id,
2464
+ kind,
2465
+ tabId: this.pageId,
2466
+ pageId: this.pageId,
2467
+ sequence,
2468
+ timestamp,
2469
+ relatedActionId: related?.id,
2470
+ relatedActionDeltaMs: related ? timestamp - related.t : void 0,
2471
+ data: payload
2472
+ };
2473
+ if (kind === "context") this.contextLog.push(data);
2474
+ this.events.push(event);
2475
+ if (this.store) this.save(this.store.append(event));
2476
+ return event;
3244
2477
  }
3245
2478
  correlate(at) {
3246
2479
  for (let i = this.recent.length - 1; i >= 0; i--) {
@@ -3256,18 +2489,27 @@ var Offscript = class {
3256
2489
  attach() {
3257
2490
  this.lifecycle ?? (this.lifecycle = new LifecycleManager({
3258
2491
  onPageHide: () => {
3259
- if (!this.accepting) return;
3260
2492
  this.actions?.flushAll();
3261
- this.emitLifecycle("page-end", { url: this.sanitizer.sanitizeUrl(location.href) }, "critical");
3262
- this.transport?.flushOnUnload();
2493
+ if (!this.store) return;
2494
+ this.accepting = false;
2495
+ this.stopRecorders();
2496
+ this.suspended = true;
2497
+ if (!this.restorePromise) this.store.checkpoint();
2498
+ },
2499
+ onPageShow: () => {
2500
+ if (!this.suspended) return;
2501
+ this.suspended = false;
2502
+ this.setStatus("initializing");
2503
+ this.restorePromise = this.restoreDraft().finally(() => {
2504
+ this.restorePromise = void 0;
2505
+ });
2506
+ this.save(this.restorePromise);
3263
2507
  },
3264
2508
  onVisibility: (hidden) => {
3265
- if (hidden && this.accepting) {
3266
- this.actions?.flushAll();
3267
- void this.transport?.flush();
3268
- }
2509
+ if (hidden) this.actions?.flushAll();
2510
+ },
2511
+ onConnectivity: () => {
3269
2512
  },
3270
- onConnectivity: (online) => this.transport?.setOnline(online),
3271
2513
  onNavigation: ({ from, to, kind }) => {
3272
2514
  if (!this.accepting) return;
3273
2515
  this.actions?.recordNavigation(from, to, kind);
@@ -3276,16 +2518,14 @@ var Offscript = class {
3276
2518
  }
3277
2519
  }));
3278
2520
  this.lifecycle.install();
3279
- this.transport?.setOnline(typeof navigator === "undefined" ? true : navigator.onLine !== false);
3280
2521
  }
3281
2522
  async startRecorders() {
3282
- this.ensureControls();
3283
2523
  const r = this.config.recording;
3284
2524
  const list = [];
3285
2525
  if (r.rrweb) list.push(this.rrweb = new RRWebRecorder(this));
3286
- if (r.context) list.push(this.context = new ContextRecorder(this, () => ({ platformId: this.config.platformId, testCaseId: this.sessions.session.testCaseId, executionId: this.sessions.session.executionId })));
2526
+ if (r.context) list.push(this.context = new ContextRecorder(this));
3287
2527
  if (r.actions) list.push(this.actions = new ActionRecorder(this));
3288
- if (r.http) list.push(new HTTPRecorder(this, this.config.apiUrl));
2528
+ if (r.http) list.push(new HTTPRecorder(this));
3289
2529
  if (r.console || r.errors) list.push(new ErrorRecorder(this));
3290
2530
  if (r.iframes) list.push(new IframeRecorder(this));
3291
2531
  this.recorders = list;
@@ -3298,81 +2538,48 @@ var Offscript = class {
3298
2538
  }
3299
2539
  }
3300
2540
  }
2541
+ buildPayload() {
2542
+ return {
2543
+ recording: this.events.filter((event) => event.kind !== "http"),
2544
+ request: this.events.filter((event) => event.kind === "http"),
2545
+ regression: this.exportPlaywrightData()
2546
+ };
2547
+ }
3301
2548
  stopRecorders() {
3302
2549
  for (const r of this.recorders.splice(0).reverse()) safe(() => r.stop());
3303
2550
  this.rrweb = this.actions = this.context = void 0;
3304
2551
  }
3305
- ensureControls() {
3306
- if (!this.config.controls || typeof document === "undefined" || !this.sessions.hasTokenAvailable()) return;
3307
- safe(() => {
3308
- this.controls ?? (this.controls = new RecordingControls(this));
3309
- this.controls.mount();
3310
- });
3311
- }
3312
- async teardown() {
3313
- this.accepting = false;
3314
- this.stopRecorders();
3315
- this.sessions.stopHeartbeat();
3316
- this.lifecycle?.uninstall();
3317
- this.lifecycle = void 0;
3318
- this.transport?.stop();
3319
- }
3320
- onHeartbeat(status) {
3321
- if (status === "disabled") void this.shutdownRemote(new SessionDisabledError());
3322
- else if (status === "expired") void this.onFatal(new SessionExpiredError());
3323
- }
3324
- /** Backend desativou a gravação: encerra com flush de melhor esforço. */
3325
- async shutdownRemote(err) {
3326
- if (this.status !== "recording" && this.status !== "paused") return;
3327
- this.accepting = false;
3328
- this.stopRecorders();
3329
- this.sessions.stopHeartbeat();
3330
- const session = this.sessions.session;
3331
- await this.transport?.flushFinal(3e3).catch(() => void 0);
3332
- this.transport?.stop();
3333
- this.lifecycle?.uninstall();
3334
- this.sessions.clearPersisted();
3335
- this.sessions.reset();
3336
- this.setStatus("stopped");
3337
- this.bus.emit("disabled", { reason: err.message });
3338
- if (session) this.bus.emit("sessionEnd", { sessionId: session.sessionId, flushed: false, pendingEvents: this.transport?.pending ?? 0, completed: false });
3339
- }
3340
- async onFatal(err) {
3341
- if (this.status === "error" || this.status === "stopped") return;
3342
- await this.teardown();
3343
- this.sessions.clearPersisted();
3344
- this.sessions.reset();
3345
- this.fail(err);
3346
- }
3347
- fail(e) {
3348
- const err = e instanceof Error ? e : new Error(String(e));
3349
- this.lastError = { code: err.code ?? "INTERNAL", message: err.message };
3350
- this.setStatus("error");
3351
- this.bus.emit("error", err);
2552
+ clearRecording() {
2553
+ this.events = [];
2554
+ this.actionLog = [];
2555
+ this.contextLog = [];
2556
+ this.recent = [];
2557
+ this.sequence = 0;
2558
+ this.pageId = uid("page_");
2559
+ this.payload = void 0;
2560
+ this.submitted = false;
2561
+ this.response = void 0;
2562
+ this.lastError = void 0;
2563
+ }
2564
+ assertAlive() {
2565
+ if (this.destroyed) throw new InvalidStateError("SDK destru\xEDdo");
3352
2566
  }
3353
2567
  setStatus(to) {
3354
2568
  const from = this.status;
3355
2569
  if (from === to) return;
3356
2570
  this.status = to;
2571
+ if (this.store && (to === "recording" || to === "paused" || to === "stopped")) this.save(this.store.setStatus(to));
3357
2572
  this.bus.emit("statusChange", { from, to });
3358
2573
  }
3359
- debug(...args) {
3360
- if (this.config.debug) console.debug("[offscript]", ...args);
3361
- }
3362
2574
  };
3363
- Offscript.version = chunkKQVK5EJR_cjs.SDK_VERSION;
2575
+ Offscript.version = chunkHPDLEAAL_cjs.SDK_VERSION;
3364
2576
 
3365
- exports.AuthError = AuthError;
3366
2577
  exports.ConfigError = ConfigError;
3367
2578
  exports.DEFAULT_PRIVACY = DEFAULT_PRIVACY;
3368
2579
  exports.DEFAULT_RECORDING = DEFAULT_RECORDING;
3369
- exports.DEFAULT_TRANSPORT = DEFAULT_TRANSPORT;
3370
2580
  exports.DataSanitizer = DataSanitizer;
3371
2581
  exports.InvalidStateError = InvalidStateError;
3372
2582
  exports.Offscript = Offscript;
3373
2583
  exports.OffscriptError = OffscriptError;
3374
- exports.SessionDisabledError = SessionDisabledError;
3375
- exports.SessionExpiredError = SessionExpiredError;
3376
- exports.TransportError = TransportError;
3377
- //# sourceMappingURL=chunk-I6JPZAQA.cjs.map
3378
- //# sourceMappingURL=chunk-I6JPZAQA.cjs.map
2584
+ //# sourceMappingURL=chunk-VYCDQLP7.cjs.map
2585
+ //# sourceMappingURL=chunk-VYCDQLP7.cjs.map