@leadping/consent 1.0.4 → 1.0.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.
- package/dist/browser/index.js +17 -17
- package/dist/browser/index.js.map +4 -4
- package/dist/browser/leadping-consent.min.js +17 -17
- package/dist/browser/leadping-consent.min.js.map +4 -4
- package/dist/esm/capture.d.ts +5 -0
- package/dist/esm/capture.js +42 -16
- package/dist/esm/embed.d.ts +1 -0
- package/dist/esm/embed.js +26 -11
- package/dist/esm/event-buffer.js +1 -1
- package/dist/esm/index.d.ts +1 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/replay-deadline.d.ts +10 -0
- package/dist/esm/replay-deadline.js +23 -0
- package/dist/esm/replay-limit-exceeded-error.d.ts +5 -0
- package/dist/esm/replay-limit-exceeded-error.js +8 -0
- package/dist/esm/transport.d.ts +1 -1
- package/dist/esm/transport.js +5 -2
- package/dist/esm/types.d.ts +6 -0
- package/dist/leadping-consent.js +105 -27
- package/dist/leadping-consent.js.map +4 -4
- package/dist/leadping-consent.min.js +35 -35
- package/dist/leadping-consent.min.js.map +4 -4
- package/package.json +1 -1
package/dist/esm/capture.d.ts
CHANGED
|
@@ -5,9 +5,14 @@ export declare class ConsentCapture {
|
|
|
5
5
|
private static active?;
|
|
6
6
|
private readonly browser;
|
|
7
7
|
private readonly buffer;
|
|
8
|
+
private readonly deadline;
|
|
8
9
|
private transport?;
|
|
9
10
|
/** Resolves when the service session is available and validated. Recording starts before this. */
|
|
10
11
|
readonly ready: Promise<void>;
|
|
12
|
+
/** Allocated as soon as the session is ready; this does not assert that submission is saved. */
|
|
13
|
+
get sessionId(): string | undefined;
|
|
14
|
+
/** The server-selected duration limit, available after ready resolves. */
|
|
15
|
+
get maxReplaySeconds(): number | undefined;
|
|
11
16
|
private session?;
|
|
12
17
|
private readonly observedDisclosure;
|
|
13
18
|
private disposed;
|
package/dist/esm/capture.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { record } from 'rrweb';
|
|
2
2
|
import { EventBuffer } from './event-buffer.js';
|
|
3
3
|
import { CaptureTransport } from './transport.js';
|
|
4
|
+
import { ReplayDeadline } from './replay-deadline.js';
|
|
5
|
+
import { ReplayLimitExceededError } from './replay-limit-exceeded-error.js';
|
|
4
6
|
/** One form recording per document, matching rrweb's document-level recorder. */
|
|
5
7
|
export class ConsentCapture {
|
|
6
8
|
options;
|
|
@@ -18,9 +20,14 @@ export class ConsentCapture {
|
|
|
18
20
|
maxTouchPoints: navigator.maxTouchPoints,
|
|
19
21
|
};
|
|
20
22
|
buffer = new EventBuffer();
|
|
23
|
+
deadline = new ReplayDeadline();
|
|
21
24
|
transport;
|
|
22
25
|
/** Resolves when the service session is available and validated. Recording starts before this. */
|
|
23
26
|
ready;
|
|
27
|
+
/** Allocated as soon as the session is ready; this does not assert that submission is saved. */
|
|
28
|
+
get sessionId() { return this.session?.sessionId; }
|
|
29
|
+
/** The server-selected duration limit, available after ready resolves. */
|
|
30
|
+
get maxReplaySeconds() { return this.session?.maxReplaySeconds; }
|
|
24
31
|
session;
|
|
25
32
|
observedDisclosure;
|
|
26
33
|
disposed = false;
|
|
@@ -52,13 +59,11 @@ export class ConsentCapture {
|
|
|
52
59
|
if (!/^[0-9a-f-]{36}$/i.test(session.sessionId) || session.form.origin !== location.origin ||
|
|
53
60
|
session.form.disclosure !== this.observedDisclosure)
|
|
54
61
|
throw new Error('Form does not match the approved disclosure.');
|
|
55
|
-
|
|
56
|
-
if (!Number.isFinite(remaining) || remaining <= 0 || remaining > 60 * 60 * 1000)
|
|
57
|
-
throw new Error('Invalid or expired capture session.');
|
|
62
|
+
this.deadline.configure(session.expiresAt, session.maxReplaySeconds);
|
|
58
63
|
this.session = session;
|
|
59
64
|
this.transport = new CaptureTransport(url.href.replace(/\/$/, '') + '/consent/sessions/' + session.sessionId, session.uploadToken);
|
|
60
65
|
if (!this.stopped)
|
|
61
|
-
this.expiry = setTimeout(() => this.fail(new
|
|
66
|
+
this.expiry = setTimeout(() => this.fail(new ReplayLimitExceededError()), this.deadline.remainingMilliseconds);
|
|
62
67
|
});
|
|
63
68
|
void this.ready.then(this.backgroundFlush).catch(error => this.fail(error));
|
|
64
69
|
ConsentCapture.active = this;
|
|
@@ -115,14 +120,24 @@ export class ConsentCapture {
|
|
|
115
120
|
async finishCore(recipient) {
|
|
116
121
|
if (this.stopped || this.failure)
|
|
117
122
|
throw this.failure ?? new Error('Recorder is stopped.');
|
|
123
|
+
try {
|
|
124
|
+
this.deadline.assertWithinLimit();
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
this.fail(error);
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
118
130
|
const observedDisclosure = this.options.disclosure.textContent ?? '';
|
|
119
131
|
if (observedDisclosure !== this.observedDisclosure)
|
|
120
132
|
throw new Error('The disclosure changed during capture. Start a new approved form session.');
|
|
121
133
|
const accepted = this.options.checkbox.checked;
|
|
122
134
|
record.addCustomEvent('leadping.submit', { accepted });
|
|
123
135
|
this.stop();
|
|
136
|
+
this.options.onProgress?.('uploading');
|
|
124
137
|
await this.flush();
|
|
125
|
-
|
|
138
|
+
this.deadline.assertWithinLimit();
|
|
139
|
+
this.options.onProgress?.('submitting');
|
|
140
|
+
await this.transport.put(this.options.issueCertificate ? '/submission/certificate' : '/submission', JSON.stringify({
|
|
126
141
|
lastBatchNumber: this.buffer.lastBatchNumber, lastEventNumber: this.buffer.lastEventNumber,
|
|
127
142
|
accepted, observedDisclosure, recipient, browser: this.browser,
|
|
128
143
|
}));
|
|
@@ -147,7 +162,7 @@ export class ConsentCapture {
|
|
|
147
162
|
const batch = this.buffer.seal();
|
|
148
163
|
if (!batch)
|
|
149
164
|
return;
|
|
150
|
-
// UTF-16 string budget including queued/retrying batches.
|
|
165
|
+
// UTF-16 string budget including queued/retrying batches. Up to three compressed copies are additional.
|
|
151
166
|
if (this.queuedBytes + batch.body.length * 2 > 2 * 1024 * 1024)
|
|
152
167
|
throw new Error('Recording upload backlog exceeded 2 MiB.');
|
|
153
168
|
this.pending.push(batch);
|
|
@@ -157,19 +172,25 @@ export class ConsentCapture {
|
|
|
157
172
|
// Defer so this.sending is assigned before this function can finish.
|
|
158
173
|
await Promise.resolve();
|
|
159
174
|
try {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
this.
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
this.
|
|
168
|
-
this.
|
|
175
|
+
// Batches are immutable and individually numbered. Completion order need not match
|
|
176
|
+
// recording order; submission still waits for every durable acknowledgment.
|
|
177
|
+
await Promise.all(Array.from({ length: 3 }, async () => {
|
|
178
|
+
while (!this.failure && !this.disposed && this.head < this.pending.length) {
|
|
179
|
+
const index = this.head++;
|
|
180
|
+
const batch = this.pending[index];
|
|
181
|
+
await this.transport.put('/batches/' + batch.number, batch.body);
|
|
182
|
+
this.queuedBytes -= batch.body.length * 2;
|
|
183
|
+
this.pending[index] = undefined;
|
|
169
184
|
}
|
|
185
|
+
}));
|
|
186
|
+
// A concurrent flush may append after the last worker exits but before this continuation.
|
|
187
|
+
if (this.head === this.pending.length) {
|
|
188
|
+
this.pending.length = 0;
|
|
189
|
+
this.head = 0;
|
|
170
190
|
}
|
|
171
191
|
}
|
|
172
192
|
catch (error) {
|
|
193
|
+
this.transport?.dispose();
|
|
173
194
|
this.fail(error instanceof Error ? error : new Error('Capture upload failed.'));
|
|
174
195
|
throw this.failure;
|
|
175
196
|
}
|
|
@@ -177,7 +198,12 @@ export class ConsentCapture {
|
|
|
177
198
|
this.sending = undefined;
|
|
178
199
|
}
|
|
179
200
|
}
|
|
180
|
-
consentChanged = () =>
|
|
201
|
+
consentChanged = () => {
|
|
202
|
+
record.addCustomEvent('leadping.consent', { accepted: this.options.checkbox.checked });
|
|
203
|
+
// Start draining while the visitor moves from consenting to submitting.
|
|
204
|
+
if (this.options.checkbox.checked)
|
|
205
|
+
this.backgroundFlush();
|
|
206
|
+
};
|
|
181
207
|
visibilityChanged = () => { if (document.visibilityState === 'hidden')
|
|
182
208
|
this.backgroundFlush(); };
|
|
183
209
|
backgroundFlush = () => { void this.flush().catch(error => this.fail(error)); };
|
package/dist/esm/embed.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { ConsentCapture } from './capture.js';
|
|
2
|
+
export { ReplayLimitExceededError } from './replay-limit-exceeded-error.js';
|
|
2
3
|
/** Attach the Leadping-hosted capture and submission flow to a form. */
|
|
3
4
|
export declare function attach(script: HTMLScriptElement): Promise<void>;
|
package/dist/esm/embed.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { CaptureRejectedError } from './capture-rejected-error.js';
|
|
2
2
|
import { ConsentCapture } from './capture.js';
|
|
3
|
+
import { ReplayLimitExceededError } from './replay-limit-exceeded-error.js';
|
|
3
4
|
export { ConsentCapture } from './capture.js';
|
|
5
|
+
export { ReplayLimitExceededError } from './replay-limit-exceeded-error.js';
|
|
4
6
|
/** Attach the Leadping-hosted capture and submission flow to a form. */
|
|
5
7
|
export async function attach(script) {
|
|
6
8
|
const domainId = script.dataset.domainId;
|
|
@@ -20,7 +22,8 @@ export async function attach(script) {
|
|
|
20
22
|
let capture;
|
|
21
23
|
let submitted = false;
|
|
22
24
|
const fail = (error) => {
|
|
23
|
-
status.textContent =
|
|
25
|
+
status.textContent = error instanceof ReplayLimitExceededError ? error.message
|
|
26
|
+
: 'Unable to complete consent capture. Please reload to start again.';
|
|
24
27
|
form.dispatchEvent(new CustomEvent('leadping:error', { detail: error }));
|
|
25
28
|
};
|
|
26
29
|
const origin = new URL(script.dataset.apiUrl ?? 'https://consent.leadping.ai');
|
|
@@ -31,6 +34,9 @@ export async function attach(script) {
|
|
|
31
34
|
if (portal.protocol !== 'https:' || portal.username || portal.password || portal.search || portal.hash || portal.pathname !== '/')
|
|
32
35
|
throw new Error('Leadping requires an HTTPS portal origin.');
|
|
33
36
|
const endpoint = `${origin.origin}/api/consent`;
|
|
37
|
+
const progress = (stage) => {
|
|
38
|
+
form.dispatchEvent(new CustomEvent('leadping:progress', { detail: { stage } }));
|
|
39
|
+
};
|
|
34
40
|
const post = async (path, body) => {
|
|
35
41
|
const attempts = path.endsWith('/sessions') ? 5 : 1;
|
|
36
42
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
@@ -71,15 +77,11 @@ export async function attach(script) {
|
|
|
71
77
|
if (typeof value === 'string' && value)
|
|
72
78
|
recipient[key] = value;
|
|
73
79
|
}
|
|
74
|
-
let issuingCertificate = false;
|
|
75
80
|
try {
|
|
76
81
|
const consentCapture = await capture.finish(recipient);
|
|
77
|
-
|
|
78
|
-
const certificate = await (await post('/certificates', consentCapture)).json();
|
|
79
|
-
if (!/^[0-9a-f-]{36}$/i.test(certificate.certificateId))
|
|
80
|
-
throw new Error('Invalid certificate response.');
|
|
82
|
+
const certificate = { certificateId: consentCapture.sessionId };
|
|
81
83
|
const certificateUrl = `${portal.origin}/${certificate.certificateId}`;
|
|
82
|
-
status.textContent = `Certificate
|
|
84
|
+
status.textContent = `Submission saved. Certificate: ${certificate.certificateId}. `;
|
|
83
85
|
const link = document.createElement('a');
|
|
84
86
|
link.href = certificateUrl;
|
|
85
87
|
link.textContent = 'View certificate and replay';
|
|
@@ -90,8 +92,8 @@ export async function attach(script) {
|
|
|
90
92
|
}
|
|
91
93
|
catch (error) {
|
|
92
94
|
fail(error);
|
|
93
|
-
if (
|
|
94
|
-
status.textContent = '
|
|
95
|
+
if (!(error instanceof ReplayLimitExceededError))
|
|
96
|
+
status.textContent = 'Submission could not be confirmed. Keep the certificate ID and check before starting another recording.';
|
|
95
97
|
}
|
|
96
98
|
finally {
|
|
97
99
|
capture.dispose();
|
|
@@ -105,12 +107,25 @@ export async function attach(script) {
|
|
|
105
107
|
const observedDisclosure = disclosure.textContent ?? '';
|
|
106
108
|
capture = new ConsentCapture({ apiUrl: origin.origin + '/api',
|
|
107
109
|
session: async () => (await post(`/domains/${encodeURIComponent(domainId)}/sessions`, { pageUrl, disclosure: observedDisclosure })).json(),
|
|
108
|
-
form, disclosure, checkbox, onError: fail
|
|
110
|
+
form, disclosure, checkbox, issueCertificate: true, onError: fail, onProgress: stage => {
|
|
111
|
+
status.textContent = stage === 'uploading' ? 'Saving recording…' : 'Saving submission and requesting certificate…';
|
|
112
|
+
progress(stage);
|
|
113
|
+
} });
|
|
109
114
|
window.addEventListener('pagehide', () => { capture?.dispose(); }, { once: true });
|
|
110
115
|
await capture.ready;
|
|
116
|
+
const certificateId = capture.sessionId;
|
|
117
|
+
const certificateUrl = `${portal.origin}/${certificateId}`;
|
|
118
|
+
for (const [name, value] of Object.entries({ leadping_certificate_id: certificateId, leadping_certificate_url: certificateUrl })) {
|
|
119
|
+
const input = document.createElement('input');
|
|
120
|
+
input.type = 'hidden';
|
|
121
|
+
input.name = name;
|
|
122
|
+
input.value = value;
|
|
123
|
+
form.append(input);
|
|
124
|
+
}
|
|
111
125
|
if (!submitted)
|
|
112
126
|
status.textContent = '';
|
|
113
|
-
form.dispatchEvent(new CustomEvent('leadping:ready'
|
|
127
|
+
form.dispatchEvent(new CustomEvent('leadping:ready', { detail: { certificateId, certificateUrl,
|
|
128
|
+
maxReplaySeconds: capture.maxReplaySeconds } }));
|
|
114
129
|
}
|
|
115
130
|
catch (error) {
|
|
116
131
|
capture?.dispose();
|
package/dist/esm/event-buffer.js
CHANGED
|
@@ -42,7 +42,7 @@ export class EventBuffer {
|
|
|
42
42
|
seal() {
|
|
43
43
|
if (this.events.length === 0)
|
|
44
44
|
return;
|
|
45
|
-
if (this.nextBatch >=
|
|
45
|
+
if (this.nextBatch >= 1024)
|
|
46
46
|
throw new Error('Recording exceeded the session batch limit.');
|
|
47
47
|
const first = this.nextEvent - this.events.length;
|
|
48
48
|
const body = `{"firstEventNumber":${first},"events":[${this.events.join(',')}]}`;
|
package/dist/esm/index.d.ts
CHANGED
package/dist/esm/index.js
CHANGED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Checks elapsed time even when a suspended tab has not run its expiry timer. */
|
|
2
|
+
export declare class ReplayDeadline {
|
|
3
|
+
private readonly startedAt;
|
|
4
|
+
private readonly startedTick;
|
|
5
|
+
private expiresAt;
|
|
6
|
+
private maximumMs;
|
|
7
|
+
configure(expiresAt: string, maxReplaySeconds: number): void;
|
|
8
|
+
get remainingMilliseconds(): number;
|
|
9
|
+
assertWithinLimit(): void;
|
|
10
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ReplayLimitExceededError } from './replay-limit-exceeded-error.js';
|
|
2
|
+
/** Checks elapsed time even when a suspended tab has not run its expiry timer. */
|
|
3
|
+
export class ReplayDeadline {
|
|
4
|
+
startedAt = Date.now();
|
|
5
|
+
startedTick = performance.now();
|
|
6
|
+
expiresAt = Infinity;
|
|
7
|
+
maximumMs = 1800_000;
|
|
8
|
+
configure(expiresAt, maxReplaySeconds) {
|
|
9
|
+
const deadline = Date.parse(expiresAt);
|
|
10
|
+
if (!Number.isFinite(deadline) || !Number.isInteger(maxReplaySeconds) || maxReplaySeconds < 1 || maxReplaySeconds > 1800)
|
|
11
|
+
throw new Error('Invalid recording time limit.');
|
|
12
|
+
this.expiresAt = deadline;
|
|
13
|
+
this.maximumMs = maxReplaySeconds * 1000;
|
|
14
|
+
this.assertWithinLimit();
|
|
15
|
+
}
|
|
16
|
+
get remainingMilliseconds() {
|
|
17
|
+
return Math.min(this.expiresAt - Date.now(), this.maximumMs - (Date.now() - this.startedAt), this.maximumMs - (performance.now() - this.startedTick));
|
|
18
|
+
}
|
|
19
|
+
assertWithinLimit() {
|
|
20
|
+
if (this.remainingMilliseconds <= 0)
|
|
21
|
+
throw new ReplayLimitExceededError();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** The recording cannot support this submission because its allotted time has elapsed. */
|
|
2
|
+
export class ReplayLimitExceededError extends Error {
|
|
3
|
+
code = 'replay_limit_exceeded';
|
|
4
|
+
constructor() {
|
|
5
|
+
super("The recording time limit was reached. This submission wasn't captured. Start a new recording and submit again.");
|
|
6
|
+
this.name = 'ReplayLimitExceededError';
|
|
7
|
+
}
|
|
8
|
+
}
|
package/dist/esm/transport.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Bounded retry transport. A resolved request means durable server acknowledgment. */
|
|
2
2
|
export declare class CaptureTransport {
|
|
3
3
|
private readonly baseUrl;
|
|
4
4
|
private readonly token;
|
package/dist/esm/transport.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { CaptureRejectedError } from './capture-rejected-error.js';
|
|
2
|
-
|
|
2
|
+
import { ReplayLimitExceededError } from './replay-limit-exceeded-error.js';
|
|
3
|
+
/** Bounded retry transport. A resolved request means durable server acknowledgment. */
|
|
3
4
|
export class CaptureTransport {
|
|
4
5
|
baseUrl;
|
|
5
6
|
token;
|
|
@@ -26,11 +27,13 @@ export class CaptureTransport {
|
|
|
26
27
|
});
|
|
27
28
|
if (response.ok)
|
|
28
29
|
return;
|
|
30
|
+
if (response.status === 410)
|
|
31
|
+
throw new ReplayLimitExceededError();
|
|
29
32
|
if (response.status < 500 && response.status !== 408 && response.status !== 429)
|
|
30
33
|
throw new CaptureRejectedError(`Capture rejected (${response.status}).`);
|
|
31
34
|
}
|
|
32
35
|
catch (error) {
|
|
33
|
-
if (error instanceof CaptureRejectedError || this.abort.signal.aborted)
|
|
36
|
+
if (error instanceof CaptureRejectedError || error instanceof ReplayLimitExceededError || this.abort.signal.aborted)
|
|
34
37
|
throw error;
|
|
35
38
|
}
|
|
36
39
|
if (attempt === 4)
|
package/dist/esm/types.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export interface CaptureSession {
|
|
|
12
12
|
sessionId: string;
|
|
13
13
|
uploadToken: string;
|
|
14
14
|
expiresAt: string;
|
|
15
|
+
/** Maximum elapsed recording time including idle time, supplied by the service. */
|
|
16
|
+
maxReplaySeconds: number;
|
|
15
17
|
form: ConsentForm;
|
|
16
18
|
}
|
|
17
19
|
export interface Recipient {
|
|
@@ -32,8 +34,12 @@ export interface CaptureOptions {
|
|
|
32
34
|
form: HTMLFormElement;
|
|
33
35
|
disclosure: HTMLElement;
|
|
34
36
|
checkbox: HTMLInputElement;
|
|
37
|
+
/** For public domain sessions, durably request a certificate with the final submission. */
|
|
38
|
+
issueCertificate?: boolean;
|
|
35
39
|
/** Called on terminal capture failures; never includes captured values. */
|
|
36
40
|
onError?: (error: Error) => void;
|
|
41
|
+
/** Submission stages; no captured values or upload credentials are included. */
|
|
42
|
+
onProgress?: (stage: 'uploading' | 'submitting') => void;
|
|
37
43
|
}
|
|
38
44
|
export interface SealedBatch {
|
|
39
45
|
number: number;
|
package/dist/leadping-consent.js
CHANGED
|
@@ -22,6 +22,7 @@ var LeadpingConsent = (() => {
|
|
|
22
22
|
var embed_exports = {};
|
|
23
23
|
__export(embed_exports, {
|
|
24
24
|
ConsentCapture: () => ConsentCapture,
|
|
25
|
+
ReplayLimitExceededError: () => ReplayLimitExceededError,
|
|
25
26
|
attach: () => attach
|
|
26
27
|
});
|
|
27
28
|
|
|
@@ -12763,7 +12764,7 @@ var LeadpingConsent = (() => {
|
|
|
12763
12764
|
}
|
|
12764
12765
|
seal() {
|
|
12765
12766
|
if (this.events.length === 0) return;
|
|
12766
|
-
if (this.nextBatch >=
|
|
12767
|
+
if (this.nextBatch >= 1024) throw new Error("Recording exceeded the session batch limit.");
|
|
12767
12768
|
const first = this.nextEvent - this.events.length;
|
|
12768
12769
|
const body = `{"firstEventNumber":${first},"events":[${this.events.join(",")}]}`;
|
|
12769
12770
|
this.events.length = 0;
|
|
@@ -12772,6 +12773,15 @@ var LeadpingConsent = (() => {
|
|
|
12772
12773
|
}
|
|
12773
12774
|
};
|
|
12774
12775
|
|
|
12776
|
+
// src/replay-limit-exceeded-error.ts
|
|
12777
|
+
var ReplayLimitExceededError = class extends Error {
|
|
12778
|
+
code = "replay_limit_exceeded";
|
|
12779
|
+
constructor() {
|
|
12780
|
+
super("The recording time limit was reached. This submission wasn't captured. Start a new recording and submit again.");
|
|
12781
|
+
this.name = "ReplayLimitExceededError";
|
|
12782
|
+
}
|
|
12783
|
+
};
|
|
12784
|
+
|
|
12775
12785
|
// src/transport.ts
|
|
12776
12786
|
var CaptureTransport = class {
|
|
12777
12787
|
constructor(baseUrl, token) {
|
|
@@ -12802,10 +12812,11 @@ var LeadpingConsent = (() => {
|
|
|
12802
12812
|
signal: AbortSignal.any([this.abort.signal, AbortSignal.timeout(15e3)])
|
|
12803
12813
|
});
|
|
12804
12814
|
if (response.ok) return;
|
|
12815
|
+
if (response.status === 410) throw new ReplayLimitExceededError();
|
|
12805
12816
|
if (response.status < 500 && response.status !== 408 && response.status !== 429)
|
|
12806
12817
|
throw new CaptureRejectedError(`Capture rejected (${response.status}).`);
|
|
12807
12818
|
} catch (error) {
|
|
12808
|
-
if (error instanceof CaptureRejectedError || this.abort.signal.aborted) throw error;
|
|
12819
|
+
if (error instanceof CaptureRejectedError || error instanceof ReplayLimitExceededError || this.abort.signal.aborted) throw error;
|
|
12809
12820
|
}
|
|
12810
12821
|
if (attempt === 4) break;
|
|
12811
12822
|
await this.delay(Math.min(8e3, 500 * 2 ** attempt) + Math.random() * 250);
|
|
@@ -12831,6 +12842,32 @@ var LeadpingConsent = (() => {
|
|
|
12831
12842
|
}
|
|
12832
12843
|
};
|
|
12833
12844
|
|
|
12845
|
+
// src/replay-deadline.ts
|
|
12846
|
+
var ReplayDeadline = class {
|
|
12847
|
+
startedAt = Date.now();
|
|
12848
|
+
startedTick = performance.now();
|
|
12849
|
+
expiresAt = Infinity;
|
|
12850
|
+
maximumMs = 18e5;
|
|
12851
|
+
configure(expiresAt, maxReplaySeconds) {
|
|
12852
|
+
const deadline = Date.parse(expiresAt);
|
|
12853
|
+
if (!Number.isFinite(deadline) || !Number.isInteger(maxReplaySeconds) || maxReplaySeconds < 1 || maxReplaySeconds > 1800)
|
|
12854
|
+
throw new Error("Invalid recording time limit.");
|
|
12855
|
+
this.expiresAt = deadline;
|
|
12856
|
+
this.maximumMs = maxReplaySeconds * 1e3;
|
|
12857
|
+
this.assertWithinLimit();
|
|
12858
|
+
}
|
|
12859
|
+
get remainingMilliseconds() {
|
|
12860
|
+
return Math.min(
|
|
12861
|
+
this.expiresAt - Date.now(),
|
|
12862
|
+
this.maximumMs - (Date.now() - this.startedAt),
|
|
12863
|
+
this.maximumMs - (performance.now() - this.startedTick)
|
|
12864
|
+
);
|
|
12865
|
+
}
|
|
12866
|
+
assertWithinLimit() {
|
|
12867
|
+
if (this.remainingMilliseconds <= 0) throw new ReplayLimitExceededError();
|
|
12868
|
+
}
|
|
12869
|
+
};
|
|
12870
|
+
|
|
12834
12871
|
// src/capture.ts
|
|
12835
12872
|
var ConsentCapture = class _ConsentCapture {
|
|
12836
12873
|
constructor(options) {
|
|
@@ -12846,12 +12883,10 @@ var LeadpingConsent = (() => {
|
|
|
12846
12883
|
if (this.disposed || this.failure) throw this.failure ?? new Error("Recorder is disposed.");
|
|
12847
12884
|
if (!/^[0-9a-f-]{36}$/i.test(session.sessionId) || session.form.origin !== location.origin || session.form.disclosure !== this.observedDisclosure)
|
|
12848
12885
|
throw new Error("Form does not match the approved disclosure.");
|
|
12849
|
-
|
|
12850
|
-
if (!Number.isFinite(remaining) || remaining <= 0 || remaining > 60 * 60 * 1e3)
|
|
12851
|
-
throw new Error("Invalid or expired capture session.");
|
|
12886
|
+
this.deadline.configure(session.expiresAt, session.maxReplaySeconds);
|
|
12852
12887
|
this.session = session;
|
|
12853
12888
|
this.transport = new CaptureTransport(url.href.replace(/\/$/, "") + "/consent/sessions/" + session.sessionId, session.uploadToken);
|
|
12854
|
-
if (!this.stopped) this.expiry = setTimeout(() => this.fail(new
|
|
12889
|
+
if (!this.stopped) this.expiry = setTimeout(() => this.fail(new ReplayLimitExceededError()), this.deadline.remainingMilliseconds);
|
|
12855
12890
|
});
|
|
12856
12891
|
void this.ready.then(this.backgroundFlush).catch((error) => this.fail(error));
|
|
12857
12892
|
_ConsentCapture.active = this;
|
|
@@ -12901,9 +12936,18 @@ var LeadpingConsent = (() => {
|
|
|
12901
12936
|
maxTouchPoints: navigator.maxTouchPoints
|
|
12902
12937
|
};
|
|
12903
12938
|
buffer = new EventBuffer();
|
|
12939
|
+
deadline = new ReplayDeadline();
|
|
12904
12940
|
transport;
|
|
12905
12941
|
/** Resolves when the service session is available and validated. Recording starts before this. */
|
|
12906
12942
|
ready;
|
|
12943
|
+
/** Allocated as soon as the session is ready; this does not assert that submission is saved. */
|
|
12944
|
+
get sessionId() {
|
|
12945
|
+
return this.session?.sessionId;
|
|
12946
|
+
}
|
|
12947
|
+
/** The server-selected duration limit, available after ready resolves. */
|
|
12948
|
+
get maxReplaySeconds() {
|
|
12949
|
+
return this.session?.maxReplaySeconds;
|
|
12950
|
+
}
|
|
12907
12951
|
session;
|
|
12908
12952
|
observedDisclosure;
|
|
12909
12953
|
disposed = false;
|
|
@@ -12940,14 +12984,23 @@ var LeadpingConsent = (() => {
|
|
|
12940
12984
|
}
|
|
12941
12985
|
async finishCore(recipient) {
|
|
12942
12986
|
if (this.stopped || this.failure) throw this.failure ?? new Error("Recorder is stopped.");
|
|
12987
|
+
try {
|
|
12988
|
+
this.deadline.assertWithinLimit();
|
|
12989
|
+
} catch (error) {
|
|
12990
|
+
this.fail(error);
|
|
12991
|
+
throw error;
|
|
12992
|
+
}
|
|
12943
12993
|
const observedDisclosure = this.options.disclosure.textContent ?? "";
|
|
12944
12994
|
if (observedDisclosure !== this.observedDisclosure)
|
|
12945
12995
|
throw new Error("The disclosure changed during capture. Start a new approved form session.");
|
|
12946
12996
|
const accepted = this.options.checkbox.checked;
|
|
12947
12997
|
record.addCustomEvent("leadping.submit", { accepted });
|
|
12948
12998
|
this.stop();
|
|
12999
|
+
this.options.onProgress?.("uploading");
|
|
12949
13000
|
await this.flush();
|
|
12950
|
-
|
|
13001
|
+
this.deadline.assertWithinLimit();
|
|
13002
|
+
this.options.onProgress?.("submitting");
|
|
13003
|
+
await this.transport.put(this.options.issueCertificate ? "/submission/certificate" : "/submission", JSON.stringify({
|
|
12951
13004
|
lastBatchNumber: this.buffer.lastBatchNumber,
|
|
12952
13005
|
lastEventNumber: this.buffer.lastEventNumber,
|
|
12953
13006
|
accepted,
|
|
@@ -12979,25 +13032,31 @@ var LeadpingConsent = (() => {
|
|
|
12979
13032
|
async send() {
|
|
12980
13033
|
await Promise.resolve();
|
|
12981
13034
|
try {
|
|
12982
|
-
|
|
12983
|
-
|
|
12984
|
-
|
|
12985
|
-
|
|
12986
|
-
|
|
12987
|
-
|
|
12988
|
-
|
|
12989
|
-
|
|
12990
|
-
|
|
12991
|
-
|
|
13035
|
+
await Promise.all(Array.from({ length: 3 }, async () => {
|
|
13036
|
+
while (!this.failure && !this.disposed && this.head < this.pending.length) {
|
|
13037
|
+
const index2 = this.head++;
|
|
13038
|
+
const batch = this.pending[index2];
|
|
13039
|
+
await this.transport.put("/batches/" + batch.number, batch.body);
|
|
13040
|
+
this.queuedBytes -= batch.body.length * 2;
|
|
13041
|
+
this.pending[index2] = void 0;
|
|
13042
|
+
}
|
|
13043
|
+
}));
|
|
13044
|
+
if (this.head === this.pending.length) {
|
|
13045
|
+
this.pending.length = 0;
|
|
13046
|
+
this.head = 0;
|
|
12992
13047
|
}
|
|
12993
13048
|
} catch (error) {
|
|
13049
|
+
this.transport?.dispose();
|
|
12994
13050
|
this.fail(error instanceof Error ? error : new Error("Capture upload failed."));
|
|
12995
13051
|
throw this.failure;
|
|
12996
13052
|
} finally {
|
|
12997
13053
|
this.sending = void 0;
|
|
12998
13054
|
}
|
|
12999
13055
|
}
|
|
13000
|
-
consentChanged = () =>
|
|
13056
|
+
consentChanged = () => {
|
|
13057
|
+
record.addCustomEvent("leadping.consent", { accepted: this.options.checkbox.checked });
|
|
13058
|
+
if (this.options.checkbox.checked) this.backgroundFlush();
|
|
13059
|
+
};
|
|
13001
13060
|
visibilityChanged = () => {
|
|
13002
13061
|
if (document.visibilityState === "hidden") this.backgroundFlush();
|
|
13003
13062
|
};
|
|
@@ -13038,7 +13097,7 @@ var LeadpingConsent = (() => {
|
|
|
13038
13097
|
let capture;
|
|
13039
13098
|
let submitted = false;
|
|
13040
13099
|
const fail = (error) => {
|
|
13041
|
-
status.textContent = "Unable to complete consent capture. Please reload to start again.";
|
|
13100
|
+
status.textContent = error instanceof ReplayLimitExceededError ? error.message : "Unable to complete consent capture. Please reload to start again.";
|
|
13042
13101
|
form.dispatchEvent(new CustomEvent("leadping:error", { detail: error }));
|
|
13043
13102
|
};
|
|
13044
13103
|
const origin = new URL(script2.dataset.apiUrl ?? "https://consent.leadping.ai");
|
|
@@ -13048,6 +13107,9 @@ var LeadpingConsent = (() => {
|
|
|
13048
13107
|
if (portal.protocol !== "https:" || portal.username || portal.password || portal.search || portal.hash || portal.pathname !== "/")
|
|
13049
13108
|
throw new Error("Leadping requires an HTTPS portal origin.");
|
|
13050
13109
|
const endpoint = `${origin.origin}/api/consent`;
|
|
13110
|
+
const progress = (stage) => {
|
|
13111
|
+
form.dispatchEvent(new CustomEvent("leadping:progress", { detail: { stage } }));
|
|
13112
|
+
};
|
|
13051
13113
|
const post = async (path, body) => {
|
|
13052
13114
|
const attempts = path.endsWith("/sessions") ? 5 : 1;
|
|
13053
13115
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
@@ -13086,14 +13148,11 @@ var LeadpingConsent = (() => {
|
|
|
13086
13148
|
const value = values.get(key);
|
|
13087
13149
|
if (typeof value === "string" && value) recipient[key] = value;
|
|
13088
13150
|
}
|
|
13089
|
-
let issuingCertificate = false;
|
|
13090
13151
|
try {
|
|
13091
13152
|
const consentCapture = await capture.finish(recipient);
|
|
13092
|
-
|
|
13093
|
-
const certificate = await (await post("/certificates", consentCapture)).json();
|
|
13094
|
-
if (!/^[0-9a-f-]{36}$/i.test(certificate.certificateId)) throw new Error("Invalid certificate response.");
|
|
13153
|
+
const certificate = { certificateId: consentCapture.sessionId };
|
|
13095
13154
|
const certificateUrl = `${portal.origin}/${certificate.certificateId}`;
|
|
13096
|
-
status.textContent = `Certificate
|
|
13155
|
+
status.textContent = `Submission saved. Certificate: ${certificate.certificateId}. `;
|
|
13097
13156
|
const link = document.createElement("a");
|
|
13098
13157
|
link.href = certificateUrl;
|
|
13099
13158
|
link.textContent = "View certificate and replay";
|
|
@@ -13103,7 +13162,8 @@ var LeadpingConsent = (() => {
|
|
|
13103
13162
|
form.dispatchEvent(new CustomEvent("leadping:success", { detail: { certificateId: certificate.certificateId, certificateUrl } }));
|
|
13104
13163
|
} catch (error) {
|
|
13105
13164
|
fail(error);
|
|
13106
|
-
if (
|
|
13165
|
+
if (!(error instanceof ReplayLimitExceededError))
|
|
13166
|
+
status.textContent = "Submission could not be confirmed. Keep the certificate ID and check before starting another recording.";
|
|
13107
13167
|
} finally {
|
|
13108
13168
|
capture.dispose();
|
|
13109
13169
|
}
|
|
@@ -13120,14 +13180,32 @@ var LeadpingConsent = (() => {
|
|
|
13120
13180
|
form,
|
|
13121
13181
|
disclosure,
|
|
13122
13182
|
checkbox,
|
|
13123
|
-
|
|
13183
|
+
issueCertificate: true,
|
|
13184
|
+
onError: fail,
|
|
13185
|
+
onProgress: (stage) => {
|
|
13186
|
+
status.textContent = stage === "uploading" ? "Saving recording\u2026" : "Saving submission and requesting certificate\u2026";
|
|
13187
|
+
progress(stage);
|
|
13188
|
+
}
|
|
13124
13189
|
});
|
|
13125
13190
|
window.addEventListener("pagehide", () => {
|
|
13126
13191
|
capture?.dispose();
|
|
13127
13192
|
}, { once: true });
|
|
13128
13193
|
await capture.ready;
|
|
13194
|
+
const certificateId = capture.sessionId;
|
|
13195
|
+
const certificateUrl = `${portal.origin}/${certificateId}`;
|
|
13196
|
+
for (const [name, value] of Object.entries({ leadping_certificate_id: certificateId, leadping_certificate_url: certificateUrl })) {
|
|
13197
|
+
const input2 = document.createElement("input");
|
|
13198
|
+
input2.type = "hidden";
|
|
13199
|
+
input2.name = name;
|
|
13200
|
+
input2.value = value;
|
|
13201
|
+
form.append(input2);
|
|
13202
|
+
}
|
|
13129
13203
|
if (!submitted) status.textContent = "";
|
|
13130
|
-
form.dispatchEvent(new CustomEvent("leadping:ready"
|
|
13204
|
+
form.dispatchEvent(new CustomEvent("leadping:ready", { detail: {
|
|
13205
|
+
certificateId,
|
|
13206
|
+
certificateUrl,
|
|
13207
|
+
maxReplaySeconds: capture.maxReplaySeconds
|
|
13208
|
+
} }));
|
|
13131
13209
|
} catch (error) {
|
|
13132
13210
|
capture?.dispose();
|
|
13133
13211
|
fail(error);
|