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