@leadping/consent 1.0.3 → 1.0.4

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.
@@ -5,7 +5,12 @@ export declare class ConsentCapture {
5
5
  private static active?;
6
6
  private readonly browser;
7
7
  private readonly buffer;
8
- private readonly transport;
8
+ private transport?;
9
+ /** Resolves when the service session is available and validated. Recording starts before this. */
10
+ readonly ready: Promise<void>;
11
+ private session?;
12
+ private readonly observedDisclosure;
13
+ private disposed;
9
14
  private readonly pending;
10
15
  private head;
11
16
  private queuedBytes;
@@ -19,6 +19,11 @@ export class ConsentCapture {
19
19
  };
20
20
  buffer = new EventBuffer();
21
21
  transport;
22
+ /** Resolves when the service session is available and validated. Recording starts before this. */
23
+ ready;
24
+ session;
25
+ observedDisclosure;
26
+ disposed = false;
22
27
  pending = [];
23
28
  head = 0;
24
29
  queuedBytes = 0;
@@ -36,15 +41,26 @@ export class ConsentCapture {
36
41
  const url = new URL(options.apiUrl);
37
42
  if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash)
38
43
  throw new Error('Capture API requires an HTTPS URL without credentials.');
39
- if (!/^[0-9a-f-]{36}$/i.test(options.session.sessionId) || options.session.form.origin !== location.origin ||
40
- !options.form.contains(options.checkbox) || !options.form.contains(options.disclosure) ||
41
- options.checkbox.type !== 'checkbox' || options.checkbox.checked || options.checkbox.defaultChecked ||
42
- options.disclosure.textContent !== options.session.form.disclosure)
43
- throw new Error('Form does not match the approved disclosure and unchecked consent control.');
44
- const remaining = Date.parse(options.session.expiresAt) - Date.now();
45
- if (!Number.isFinite(remaining) || remaining <= 0 || remaining > 60 * 60 * 1000)
46
- throw new Error('Invalid or expired capture session.');
47
- this.transport = new CaptureTransport(url.href.replace(/\/$/, '') + '/consent/sessions/' + options.session.sessionId, options.session.uploadToken);
44
+ if (!options.form.contains(options.checkbox) || !options.form.contains(options.disclosure) ||
45
+ options.checkbox.type !== 'checkbox' || options.checkbox.checked || options.checkbox.defaultChecked)
46
+ throw new Error('Form requires an unchecked consent control and disclosure.');
47
+ this.observedDisclosure = options.disclosure.textContent ?? '';
48
+ this.ready = Promise.resolve().then(() => typeof options.session === 'function' ? options.session() : options.session)
49
+ .then(session => {
50
+ if (this.disposed || this.failure)
51
+ throw this.failure ?? new Error('Recorder is disposed.');
52
+ if (!/^[0-9a-f-]{36}$/i.test(session.sessionId) || session.form.origin !== location.origin ||
53
+ session.form.disclosure !== this.observedDisclosure)
54
+ throw new Error('Form does not match the approved disclosure.');
55
+ const remaining = Date.parse(session.expiresAt) - Date.now();
56
+ if (!Number.isFinite(remaining) || remaining <= 0 || remaining > 60 * 60 * 1000)
57
+ throw new Error('Invalid or expired capture session.');
58
+ this.session = session;
59
+ this.transport = new CaptureTransport(url.href.replace(/\/$/, '') + '/consent/sessions/' + session.sessionId, session.uploadToken);
60
+ if (!this.stopped)
61
+ this.expiry = setTimeout(() => this.fail(new Error('Capture session expired.')), remaining);
62
+ });
63
+ void this.ready.then(this.backgroundFlush).catch(error => this.fail(error));
48
64
  ConsentCapture.active = this;
49
65
  try {
50
66
  this.stopRecording = record({
@@ -69,11 +85,10 @@ export class ConsentCapture {
69
85
  options.checkbox.addEventListener('change', this.consentChanged);
70
86
  document.addEventListener('visibilitychange', this.visibilityChanged);
71
87
  this.interval = setInterval(this.backgroundFlush, 3000);
72
- this.expiry = setTimeout(() => this.fail(new Error('Capture session expired.')), remaining);
73
- record.addCustomEvent('leadping.disclosure', { formId: options.session.form.id });
88
+ record.addCustomEvent('leadping.disclosure', { disclosure: this.observedDisclosure });
74
89
  }
75
90
  catch (error) {
76
- this.stop();
91
+ this.dispose();
77
92
  throw error;
78
93
  }
79
94
  }
@@ -86,6 +101,9 @@ export class ConsentCapture {
86
101
  if (this.failure)
87
102
  throw this.failure;
88
103
  this.seal();
104
+ await this.ready;
105
+ if (this.failure || this.disposed)
106
+ throw this.failure ?? new Error('Recorder is disposed.');
89
107
  while (this.head < this.pending.length || this.sending) {
90
108
  this.sending ??= this.send();
91
109
  await this.sending;
@@ -93,12 +111,12 @@ export class ConsentCapture {
93
111
  if (this.failure)
94
112
  throw this.failure;
95
113
  }
96
- dispose() { this.stop(); this.transport.dispose(); }
114
+ dispose() { this.disposed = true; this.stop(); this.transport?.dispose(); }
97
115
  async finishCore(recipient) {
98
116
  if (this.stopped || this.failure)
99
117
  throw this.failure ?? new Error('Recorder is stopped.');
100
118
  const observedDisclosure = this.options.disclosure.textContent ?? '';
101
- if (observedDisclosure !== this.options.session.form.disclosure)
119
+ if (observedDisclosure !== this.observedDisclosure)
102
120
  throw new Error('The disclosure changed during capture. Start a new approved form session.');
103
121
  const accepted = this.options.checkbox.checked;
104
122
  record.addCustomEvent('leadping.submit', { accepted });
@@ -108,7 +126,7 @@ export class ConsentCapture {
108
126
  lastBatchNumber: this.buffer.lastBatchNumber, lastEventNumber: this.buffer.lastEventNumber,
109
127
  accepted, observedDisclosure, recipient, browser: this.browser,
110
128
  }));
111
- return { sessionId: this.options.session.sessionId, uploadToken: this.options.session.uploadToken };
129
+ return { sessionId: this.session.sessionId, uploadToken: this.session.uploadToken };
112
130
  }
113
131
  emit = (event) => {
114
132
  if (this.stopped || this.failure)
package/dist/esm/embed.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { CaptureRejectedError } from './capture-rejected-error.js';
1
2
  import { ConsentCapture } from './capture.js';
2
3
  export { ConsentCapture } from './capture.js';
3
4
  /** Attach the Leadping-hosted capture and submission flow to a form. */
@@ -17,10 +18,8 @@ export async function attach(script) {
17
18
  status.setAttribute('role', 'status');
18
19
  form.append(status);
19
20
  let capture;
20
- let ready = false;
21
21
  let submitted = false;
22
22
  const fail = (error) => {
23
- ready = false;
24
23
  status.textContent = 'Unable to complete consent capture. Please reload to start again.';
25
24
  form.dispatchEvent(new CustomEvent('leadping:error', { detail: error }));
26
25
  };
@@ -33,25 +32,37 @@ export async function attach(script) {
33
32
  throw new Error('Leadping requires an HTTPS portal origin.');
34
33
  const endpoint = `${origin.origin}/api/consent`;
35
34
  const post = async (path, body) => {
36
- const response = await fetch(endpoint + path, {
37
- method: 'POST', headers: { 'Content-Type': 'application/json' },
38
- credentials: 'omit', redirect: 'error', body: JSON.stringify(body),
39
- signal: AbortSignal.timeout(30000),
40
- });
41
- if (!response.ok)
42
- throw new Error(`Leadping request failed (${response.status}).`);
43
- return response;
35
+ const attempts = path.endsWith('/sessions') ? 5 : 1;
36
+ for (let attempt = 0; attempt < attempts; attempt++) {
37
+ try {
38
+ const response = await fetch(endpoint + path, {
39
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
40
+ credentials: 'omit', redirect: 'error', body: JSON.stringify(body),
41
+ signal: AbortSignal.timeout(attempts > 1 ? 15000 : 30000),
42
+ });
43
+ if (response.ok)
44
+ return response;
45
+ if (response.status < 500 && response.status !== 408 && response.status !== 429)
46
+ throw new CaptureRejectedError(`Leadping request failed (${response.status}).`);
47
+ throw new Error(`Leadping request failed (${response.status}).`);
48
+ }
49
+ catch (error) {
50
+ if (error instanceof CaptureRejectedError || attempt === attempts - 1)
51
+ throw error;
52
+ }
53
+ await new Promise(resolve => setTimeout(resolve, 500 * 2 ** attempt));
54
+ }
55
+ throw new Error('Leadping session could not be started.');
44
56
  };
45
57
  form.addEventListener('submit', async (event) => {
46
58
  event.preventDefault();
47
- if (!ready || submitted || !capture || !form.reportValidity())
59
+ if (submitted || !capture || !form.reportValidity())
48
60
  return;
49
61
  if (!(checkbox instanceof HTMLInputElement) || !checkbox.checked) {
50
62
  status.textContent = 'Please check the consent box before submitting.';
51
63
  return;
52
64
  }
53
65
  submitted = true;
54
- ready = false;
55
66
  status.textContent = 'Submitting…';
56
67
  const values = new FormData(form);
57
68
  const recipient = {};
@@ -90,12 +101,15 @@ export async function attach(script) {
90
101
  try {
91
102
  if (!(disclosure instanceof HTMLElement) || !(checkbox instanceof HTMLInputElement))
92
103
  throw new Error('Add data-leadping-disclosure and data-leadping-consent inside the form.');
93
- const session = await (await post(`/domains/${encodeURIComponent(domainId)}/sessions`, { pageUrl: location.href, disclosure: disclosure.textContent ?? '' })).json();
94
- checkbox.checked = false;
95
- capture = new ConsentCapture({ apiUrl: origin.origin + '/api', session, form, disclosure, checkbox, onError: fail });
96
- window.addEventListener('pagehide', () => { ready = false; capture?.dispose(); }, { once: true });
97
- ready = true;
98
- status.textContent = '';
104
+ const pageUrl = location.href;
105
+ const observedDisclosure = disclosure.textContent ?? '';
106
+ capture = new ConsentCapture({ apiUrl: origin.origin + '/api',
107
+ session: async () => (await post(`/domains/${encodeURIComponent(domainId)}/sessions`, { pageUrl, disclosure: observedDisclosure })).json(),
108
+ form, disclosure, checkbox, onError: fail });
109
+ window.addEventListener('pagehide', () => { capture?.dispose(); }, { once: true });
110
+ await capture.ready;
111
+ if (!submitted)
112
+ status.textContent = '';
99
113
  form.dispatchEvent(new CustomEvent('leadping:ready'));
100
114
  }
101
115
  catch (error) {
@@ -27,7 +27,8 @@ export interface CaptureReference {
27
27
  export interface CaptureOptions {
28
28
  /** Absolute HTTPS API origin; no credentials or query string. */
29
29
  apiUrl: string;
30
- session: CaptureSession;
30
+ /** A session or a factory that obtains one. Capture starts immediately while the factory is pending. */
31
+ session: CaptureSession | (() => Promise<CaptureSession>);
31
32
  form: HTMLFormElement;
32
33
  disclosure: HTMLElement;
33
34
  checkbox: HTMLInputElement;
@@ -25,6 +25,10 @@ var LeadpingConsent = (() => {
25
25
  attach: () => attach
26
26
  });
27
27
 
28
+ // src/capture-rejected-error.ts
29
+ var CaptureRejectedError = class extends Error {
30
+ };
31
+
28
32
  // node_modules/rrweb/dist/rrweb.js
29
33
  var __defProp2 = Object.defineProperty;
30
34
  var __defNormalProp = (obj, key, value) => key in obj ? __defProp2(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
@@ -12768,10 +12772,6 @@ var LeadpingConsent = (() => {
12768
12772
  }
12769
12773
  };
12770
12774
 
12771
- // src/capture-rejected-error.ts
12772
- var CaptureRejectedError = class extends Error {
12773
- };
12774
-
12775
12775
  // src/transport.ts
12776
12776
  var CaptureTransport = class {
12777
12777
  constructor(baseUrl, token) {
@@ -12839,12 +12839,21 @@ var LeadpingConsent = (() => {
12839
12839
  const url = new URL(options.apiUrl);
12840
12840
  if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash)
12841
12841
  throw new Error("Capture API requires an HTTPS URL without credentials.");
12842
- if (!/^[0-9a-f-]{36}$/i.test(options.session.sessionId) || options.session.form.origin !== location.origin || !options.form.contains(options.checkbox) || !options.form.contains(options.disclosure) || options.checkbox.type !== "checkbox" || options.checkbox.checked || options.checkbox.defaultChecked || options.disclosure.textContent !== options.session.form.disclosure)
12843
- throw new Error("Form does not match the approved disclosure and unchecked consent control.");
12844
- const remaining = Date.parse(options.session.expiresAt) - Date.now();
12845
- if (!Number.isFinite(remaining) || remaining <= 0 || remaining > 60 * 60 * 1e3)
12846
- throw new Error("Invalid or expired capture session.");
12847
- this.transport = new CaptureTransport(url.href.replace(/\/$/, "") + "/consent/sessions/" + options.session.sessionId, options.session.uploadToken);
12842
+ if (!options.form.contains(options.checkbox) || !options.form.contains(options.disclosure) || options.checkbox.type !== "checkbox" || options.checkbox.checked || options.checkbox.defaultChecked)
12843
+ throw new Error("Form requires an unchecked consent control and disclosure.");
12844
+ this.observedDisclosure = options.disclosure.textContent ?? "";
12845
+ this.ready = Promise.resolve().then(() => typeof options.session === "function" ? options.session() : options.session).then((session) => {
12846
+ if (this.disposed || this.failure) throw this.failure ?? new Error("Recorder is disposed.");
12847
+ if (!/^[0-9a-f-]{36}$/i.test(session.sessionId) || session.form.origin !== location.origin || session.form.disclosure !== this.observedDisclosure)
12848
+ throw new Error("Form does not match the approved disclosure.");
12849
+ const remaining = Date.parse(session.expiresAt) - Date.now();
12850
+ if (!Number.isFinite(remaining) || remaining <= 0 || remaining > 60 * 60 * 1e3)
12851
+ throw new Error("Invalid or expired capture session.");
12852
+ this.session = session;
12853
+ this.transport = new CaptureTransport(url.href.replace(/\/$/, "") + "/consent/sessions/" + session.sessionId, session.uploadToken);
12854
+ if (!this.stopped) this.expiry = setTimeout(() => this.fail(new Error("Capture session expired.")), remaining);
12855
+ });
12856
+ void this.ready.then(this.backgroundFlush).catch((error) => this.fail(error));
12848
12857
  _ConsentCapture.active = this;
12849
12858
  try {
12850
12859
  this.stopRecording = record({
@@ -12871,10 +12880,9 @@ var LeadpingConsent = (() => {
12871
12880
  options.checkbox.addEventListener("change", this.consentChanged);
12872
12881
  document.addEventListener("visibilitychange", this.visibilityChanged);
12873
12882
  this.interval = setInterval(this.backgroundFlush, 3e3);
12874
- this.expiry = setTimeout(() => this.fail(new Error("Capture session expired.")), remaining);
12875
- record.addCustomEvent("leadping.disclosure", { formId: options.session.form.id });
12883
+ record.addCustomEvent("leadping.disclosure", { disclosure: this.observedDisclosure });
12876
12884
  } catch (error) {
12877
- this.stop();
12885
+ this.dispose();
12878
12886
  throw error;
12879
12887
  }
12880
12888
  }
@@ -12894,6 +12902,11 @@ var LeadpingConsent = (() => {
12894
12902
  };
12895
12903
  buffer = new EventBuffer();
12896
12904
  transport;
12905
+ /** Resolves when the service session is available and validated. Recording starts before this. */
12906
+ ready;
12907
+ session;
12908
+ observedDisclosure;
12909
+ disposed = false;
12897
12910
  pending = [];
12898
12911
  head = 0;
12899
12912
  queuedBytes = 0;
@@ -12912,6 +12925,8 @@ var LeadpingConsent = (() => {
12912
12925
  async flush() {
12913
12926
  if (this.failure) throw this.failure;
12914
12927
  this.seal();
12928
+ await this.ready;
12929
+ if (this.failure || this.disposed) throw this.failure ?? new Error("Recorder is disposed.");
12915
12930
  while (this.head < this.pending.length || this.sending) {
12916
12931
  this.sending ??= this.send();
12917
12932
  await this.sending;
@@ -12919,13 +12934,14 @@ var LeadpingConsent = (() => {
12919
12934
  if (this.failure) throw this.failure;
12920
12935
  }
12921
12936
  dispose() {
12937
+ this.disposed = true;
12922
12938
  this.stop();
12923
- this.transport.dispose();
12939
+ this.transport?.dispose();
12924
12940
  }
12925
12941
  async finishCore(recipient) {
12926
12942
  if (this.stopped || this.failure) throw this.failure ?? new Error("Recorder is stopped.");
12927
12943
  const observedDisclosure = this.options.disclosure.textContent ?? "";
12928
- if (observedDisclosure !== this.options.session.form.disclosure)
12944
+ if (observedDisclosure !== this.observedDisclosure)
12929
12945
  throw new Error("The disclosure changed during capture. Start a new approved form session.");
12930
12946
  const accepted = this.options.checkbox.checked;
12931
12947
  record.addCustomEvent("leadping.submit", { accepted });
@@ -12939,7 +12955,7 @@ var LeadpingConsent = (() => {
12939
12955
  recipient,
12940
12956
  browser: this.browser
12941
12957
  }));
12942
- return { sessionId: this.options.session.sessionId, uploadToken: this.options.session.uploadToken };
12958
+ return { sessionId: this.session.sessionId, uploadToken: this.session.uploadToken };
12943
12959
  }
12944
12960
  emit = (event) => {
12945
12961
  if (this.stopped || this.failure) return;
@@ -13020,10 +13036,8 @@ var LeadpingConsent = (() => {
13020
13036
  status.setAttribute("role", "status");
13021
13037
  form.append(status);
13022
13038
  let capture;
13023
- let ready = false;
13024
13039
  let submitted = false;
13025
13040
  const fail = (error) => {
13026
- ready = false;
13027
13041
  status.textContent = "Unable to complete consent capture. Please reload to start again.";
13028
13042
  form.dispatchEvent(new CustomEvent("leadping:error", { detail: error }));
13029
13043
  };
@@ -13035,26 +13049,36 @@ var LeadpingConsent = (() => {
13035
13049
  throw new Error("Leadping requires an HTTPS portal origin.");
13036
13050
  const endpoint = `${origin.origin}/api/consent`;
13037
13051
  const post = async (path, body) => {
13038
- const response = await fetch(endpoint + path, {
13039
- method: "POST",
13040
- headers: { "Content-Type": "application/json" },
13041
- credentials: "omit",
13042
- redirect: "error",
13043
- body: JSON.stringify(body),
13044
- signal: AbortSignal.timeout(3e4)
13045
- });
13046
- if (!response.ok) throw new Error(`Leadping request failed (${response.status}).`);
13047
- return response;
13052
+ const attempts = path.endsWith("/sessions") ? 5 : 1;
13053
+ for (let attempt = 0; attempt < attempts; attempt++) {
13054
+ try {
13055
+ const response = await fetch(endpoint + path, {
13056
+ method: "POST",
13057
+ headers: { "Content-Type": "application/json" },
13058
+ credentials: "omit",
13059
+ redirect: "error",
13060
+ body: JSON.stringify(body),
13061
+ signal: AbortSignal.timeout(attempts > 1 ? 15e3 : 3e4)
13062
+ });
13063
+ if (response.ok) return response;
13064
+ if (response.status < 500 && response.status !== 408 && response.status !== 429)
13065
+ throw new CaptureRejectedError(`Leadping request failed (${response.status}).`);
13066
+ throw new Error(`Leadping request failed (${response.status}).`);
13067
+ } catch (error) {
13068
+ if (error instanceof CaptureRejectedError || attempt === attempts - 1) throw error;
13069
+ }
13070
+ await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** attempt));
13071
+ }
13072
+ throw new Error("Leadping session could not be started.");
13048
13073
  };
13049
13074
  form.addEventListener("submit", async (event) => {
13050
13075
  event.preventDefault();
13051
- if (!ready || submitted || !capture || !form.reportValidity()) return;
13076
+ if (submitted || !capture || !form.reportValidity()) return;
13052
13077
  if (!(checkbox instanceof HTMLInputElement) || !checkbox.checked) {
13053
13078
  status.textContent = "Please check the consent box before submitting.";
13054
13079
  return;
13055
13080
  }
13056
13081
  submitted = true;
13057
- ready = false;
13058
13082
  status.textContent = "Submitting\u2026";
13059
13083
  const values = new FormData(form);
13060
13084
  const recipient = {};
@@ -13088,15 +13112,21 @@ var LeadpingConsent = (() => {
13088
13112
  try {
13089
13113
  if (!(disclosure instanceof HTMLElement) || !(checkbox instanceof HTMLInputElement))
13090
13114
  throw new Error("Add data-leadping-disclosure and data-leadping-consent inside the form.");
13091
- const session = await (await post(`/domains/${encodeURIComponent(domainId)}/sessions`, { pageUrl: location.href, disclosure: disclosure.textContent ?? "" })).json();
13092
- checkbox.checked = false;
13093
- capture = new ConsentCapture({ apiUrl: origin.origin + "/api", session, form, disclosure, checkbox, onError: fail });
13115
+ const pageUrl = location.href;
13116
+ const observedDisclosure = disclosure.textContent ?? "";
13117
+ capture = new ConsentCapture({
13118
+ apiUrl: origin.origin + "/api",
13119
+ session: async () => (await post(`/domains/${encodeURIComponent(domainId)}/sessions`, { pageUrl, disclosure: observedDisclosure })).json(),
13120
+ form,
13121
+ disclosure,
13122
+ checkbox,
13123
+ onError: fail
13124
+ });
13094
13125
  window.addEventListener("pagehide", () => {
13095
- ready = false;
13096
13126
  capture?.dispose();
13097
13127
  }, { once: true });
13098
- ready = true;
13099
- status.textContent = "";
13128
+ await capture.ready;
13129
+ if (!submitted) status.textContent = "";
13100
13130
  form.dispatchEvent(new CustomEvent("leadping:ready"));
13101
13131
  } catch (error) {
13102
13132
  capture?.dispose();