@leadping/consent 1.0.3 → 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 +11 -1
- package/dist/esm/capture.js +71 -27
- package/dist/esm/embed.d.ts +1 -0
- package/dist/esm/embed.js +57 -28
- 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 +8 -1
- package/dist/leadping-consent.js +164 -56
- 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,7 +5,17 @@ export declare class ConsentCapture {
|
|
|
5
5
|
private static active?;
|
|
6
6
|
private readonly browser;
|
|
7
7
|
private readonly buffer;
|
|
8
|
-
private readonly
|
|
8
|
+
private readonly deadline;
|
|
9
|
+
private transport?;
|
|
10
|
+
/** Resolves when the service session is available and validated. Recording starts before this. */
|
|
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;
|
|
16
|
+
private session?;
|
|
17
|
+
private readonly observedDisclosure;
|
|
18
|
+
private disposed;
|
|
9
19
|
private readonly pending;
|
|
10
20
|
private head;
|
|
11
21
|
private queuedBytes;
|
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,7 +20,17 @@ export class ConsentCapture {
|
|
|
18
20
|
maxTouchPoints: navigator.maxTouchPoints,
|
|
19
21
|
};
|
|
20
22
|
buffer = new EventBuffer();
|
|
23
|
+
deadline = new ReplayDeadline();
|
|
21
24
|
transport;
|
|
25
|
+
/** Resolves when the service session is available and validated. Recording starts before this. */
|
|
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; }
|
|
31
|
+
session;
|
|
32
|
+
observedDisclosure;
|
|
33
|
+
disposed = false;
|
|
22
34
|
pending = [];
|
|
23
35
|
head = 0;
|
|
24
36
|
queuedBytes = 0;
|
|
@@ -36,15 +48,24 @@ export class ConsentCapture {
|
|
|
36
48
|
const url = new URL(options.apiUrl);
|
|
37
49
|
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash)
|
|
38
50
|
throw new Error('Capture API requires an HTTPS URL without credentials.');
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
51
|
+
if (!options.form.contains(options.checkbox) || !options.form.contains(options.disclosure) ||
|
|
52
|
+
options.checkbox.type !== 'checkbox' || options.checkbox.checked || options.checkbox.defaultChecked)
|
|
53
|
+
throw new Error('Form requires an unchecked consent control and disclosure.');
|
|
54
|
+
this.observedDisclosure = options.disclosure.textContent ?? '';
|
|
55
|
+
this.ready = Promise.resolve().then(() => typeof options.session === 'function' ? options.session() : options.session)
|
|
56
|
+
.then(session => {
|
|
57
|
+
if (this.disposed || this.failure)
|
|
58
|
+
throw this.failure ?? new Error('Recorder is disposed.');
|
|
59
|
+
if (!/^[0-9a-f-]{36}$/i.test(session.sessionId) || session.form.origin !== location.origin ||
|
|
60
|
+
session.form.disclosure !== this.observedDisclosure)
|
|
61
|
+
throw new Error('Form does not match the approved disclosure.');
|
|
62
|
+
this.deadline.configure(session.expiresAt, session.maxReplaySeconds);
|
|
63
|
+
this.session = session;
|
|
64
|
+
this.transport = new CaptureTransport(url.href.replace(/\/$/, '') + '/consent/sessions/' + session.sessionId, session.uploadToken);
|
|
65
|
+
if (!this.stopped)
|
|
66
|
+
this.expiry = setTimeout(() => this.fail(new ReplayLimitExceededError()), this.deadline.remainingMilliseconds);
|
|
67
|
+
});
|
|
68
|
+
void this.ready.then(this.backgroundFlush).catch(error => this.fail(error));
|
|
48
69
|
ConsentCapture.active = this;
|
|
49
70
|
try {
|
|
50
71
|
this.stopRecording = record({
|
|
@@ -69,11 +90,10 @@ export class ConsentCapture {
|
|
|
69
90
|
options.checkbox.addEventListener('change', this.consentChanged);
|
|
70
91
|
document.addEventListener('visibilitychange', this.visibilityChanged);
|
|
71
92
|
this.interval = setInterval(this.backgroundFlush, 3000);
|
|
72
|
-
|
|
73
|
-
record.addCustomEvent('leadping.disclosure', { formId: options.session.form.id });
|
|
93
|
+
record.addCustomEvent('leadping.disclosure', { disclosure: this.observedDisclosure });
|
|
74
94
|
}
|
|
75
95
|
catch (error) {
|
|
76
|
-
this.
|
|
96
|
+
this.dispose();
|
|
77
97
|
throw error;
|
|
78
98
|
}
|
|
79
99
|
}
|
|
@@ -86,6 +106,9 @@ export class ConsentCapture {
|
|
|
86
106
|
if (this.failure)
|
|
87
107
|
throw this.failure;
|
|
88
108
|
this.seal();
|
|
109
|
+
await this.ready;
|
|
110
|
+
if (this.failure || this.disposed)
|
|
111
|
+
throw this.failure ?? new Error('Recorder is disposed.');
|
|
89
112
|
while (this.head < this.pending.length || this.sending) {
|
|
90
113
|
this.sending ??= this.send();
|
|
91
114
|
await this.sending;
|
|
@@ -93,22 +116,32 @@ export class ConsentCapture {
|
|
|
93
116
|
if (this.failure)
|
|
94
117
|
throw this.failure;
|
|
95
118
|
}
|
|
96
|
-
dispose() { this.stop(); this.transport
|
|
119
|
+
dispose() { this.disposed = true; this.stop(); this.transport?.dispose(); }
|
|
97
120
|
async finishCore(recipient) {
|
|
98
121
|
if (this.stopped || this.failure)
|
|
99
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
|
+
}
|
|
100
130
|
const observedDisclosure = this.options.disclosure.textContent ?? '';
|
|
101
|
-
if (observedDisclosure !== this.
|
|
131
|
+
if (observedDisclosure !== this.observedDisclosure)
|
|
102
132
|
throw new Error('The disclosure changed during capture. Start a new approved form session.');
|
|
103
133
|
const accepted = this.options.checkbox.checked;
|
|
104
134
|
record.addCustomEvent('leadping.submit', { accepted });
|
|
105
135
|
this.stop();
|
|
136
|
+
this.options.onProgress?.('uploading');
|
|
106
137
|
await this.flush();
|
|
107
|
-
|
|
138
|
+
this.deadline.assertWithinLimit();
|
|
139
|
+
this.options.onProgress?.('submitting');
|
|
140
|
+
await this.transport.put(this.options.issueCertificate ? '/submission/certificate' : '/submission', JSON.stringify({
|
|
108
141
|
lastBatchNumber: this.buffer.lastBatchNumber, lastEventNumber: this.buffer.lastEventNumber,
|
|
109
142
|
accepted, observedDisclosure, recipient, browser: this.browser,
|
|
110
143
|
}));
|
|
111
|
-
return { sessionId: this.
|
|
144
|
+
return { sessionId: this.session.sessionId, uploadToken: this.session.uploadToken };
|
|
112
145
|
}
|
|
113
146
|
emit = (event) => {
|
|
114
147
|
if (this.stopped || this.failure)
|
|
@@ -129,7 +162,7 @@ export class ConsentCapture {
|
|
|
129
162
|
const batch = this.buffer.seal();
|
|
130
163
|
if (!batch)
|
|
131
164
|
return;
|
|
132
|
-
// UTF-16 string budget including queued/retrying batches.
|
|
165
|
+
// UTF-16 string budget including queued/retrying batches. Up to three compressed copies are additional.
|
|
133
166
|
if (this.queuedBytes + batch.body.length * 2 > 2 * 1024 * 1024)
|
|
134
167
|
throw new Error('Recording upload backlog exceeded 2 MiB.');
|
|
135
168
|
this.pending.push(batch);
|
|
@@ -139,19 +172,25 @@ export class ConsentCapture {
|
|
|
139
172
|
// Defer so this.sending is assigned before this function can finish.
|
|
140
173
|
await Promise.resolve();
|
|
141
174
|
try {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
this.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
this.
|
|
150
|
-
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;
|
|
151
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;
|
|
152
190
|
}
|
|
153
191
|
}
|
|
154
192
|
catch (error) {
|
|
193
|
+
this.transport?.dispose();
|
|
155
194
|
this.fail(error instanceof Error ? error : new Error('Capture upload failed.'));
|
|
156
195
|
throw this.failure;
|
|
157
196
|
}
|
|
@@ -159,7 +198,12 @@ export class ConsentCapture {
|
|
|
159
198
|
this.sending = undefined;
|
|
160
199
|
}
|
|
161
200
|
}
|
|
162
|
-
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
|
+
};
|
|
163
207
|
visibilityChanged = () => { if (document.visibilityState === 'hidden')
|
|
164
208
|
this.backgroundFlush(); };
|
|
165
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,5 +1,8 @@
|
|
|
1
|
+
import { CaptureRejectedError } from './capture-rejected-error.js';
|
|
1
2
|
import { ConsentCapture } from './capture.js';
|
|
3
|
+
import { ReplayLimitExceededError } from './replay-limit-exceeded-error.js';
|
|
2
4
|
export { ConsentCapture } from './capture.js';
|
|
5
|
+
export { ReplayLimitExceededError } from './replay-limit-exceeded-error.js';
|
|
3
6
|
/** Attach the Leadping-hosted capture and submission flow to a form. */
|
|
4
7
|
export async function attach(script) {
|
|
5
8
|
const domainId = script.dataset.domainId;
|
|
@@ -17,11 +20,10 @@ export async function attach(script) {
|
|
|
17
20
|
status.setAttribute('role', 'status');
|
|
18
21
|
form.append(status);
|
|
19
22
|
let capture;
|
|
20
|
-
let ready = false;
|
|
21
23
|
let submitted = false;
|
|
22
24
|
const fail = (error) => {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
status.textContent = error instanceof ReplayLimitExceededError ? error.message
|
|
26
|
+
: 'Unable to complete consent capture. Please reload to start again.';
|
|
25
27
|
form.dispatchEvent(new CustomEvent('leadping:error', { detail: error }));
|
|
26
28
|
};
|
|
27
29
|
const origin = new URL(script.dataset.apiUrl ?? 'https://consent.leadping.ai');
|
|
@@ -32,26 +34,41 @@ export async function attach(script) {
|
|
|
32
34
|
if (portal.protocol !== 'https:' || portal.username || portal.password || portal.search || portal.hash || portal.pathname !== '/')
|
|
33
35
|
throw new Error('Leadping requires an HTTPS portal origin.');
|
|
34
36
|
const endpoint = `${origin.origin}/api/consent`;
|
|
37
|
+
const progress = (stage) => {
|
|
38
|
+
form.dispatchEvent(new CustomEvent('leadping:progress', { detail: { stage } }));
|
|
39
|
+
};
|
|
35
40
|
const post = async (path, body) => {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
const attempts = path.endsWith('/sessions') ? 5 : 1;
|
|
42
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
43
|
+
try {
|
|
44
|
+
const response = await fetch(endpoint + path, {
|
|
45
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
46
|
+
credentials: 'omit', redirect: 'error', body: JSON.stringify(body),
|
|
47
|
+
signal: AbortSignal.timeout(attempts > 1 ? 15000 : 30000),
|
|
48
|
+
});
|
|
49
|
+
if (response.ok)
|
|
50
|
+
return response;
|
|
51
|
+
if (response.status < 500 && response.status !== 408 && response.status !== 429)
|
|
52
|
+
throw new CaptureRejectedError(`Leadping request failed (${response.status}).`);
|
|
53
|
+
throw new Error(`Leadping request failed (${response.status}).`);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error instanceof CaptureRejectedError || attempt === attempts - 1)
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
await new Promise(resolve => setTimeout(resolve, 500 * 2 ** attempt));
|
|
60
|
+
}
|
|
61
|
+
throw new Error('Leadping session could not be started.');
|
|
44
62
|
};
|
|
45
63
|
form.addEventListener('submit', async (event) => {
|
|
46
64
|
event.preventDefault();
|
|
47
|
-
if (
|
|
65
|
+
if (submitted || !capture || !form.reportValidity())
|
|
48
66
|
return;
|
|
49
67
|
if (!(checkbox instanceof HTMLInputElement) || !checkbox.checked) {
|
|
50
68
|
status.textContent = 'Please check the consent box before submitting.';
|
|
51
69
|
return;
|
|
52
70
|
}
|
|
53
71
|
submitted = true;
|
|
54
|
-
ready = false;
|
|
55
72
|
status.textContent = 'Submitting…';
|
|
56
73
|
const values = new FormData(form);
|
|
57
74
|
const recipient = {};
|
|
@@ -60,15 +77,11 @@ export async function attach(script) {
|
|
|
60
77
|
if (typeof value === 'string' && value)
|
|
61
78
|
recipient[key] = value;
|
|
62
79
|
}
|
|
63
|
-
let issuingCertificate = false;
|
|
64
80
|
try {
|
|
65
81
|
const consentCapture = await capture.finish(recipient);
|
|
66
|
-
|
|
67
|
-
const certificate = await (await post('/certificates', consentCapture)).json();
|
|
68
|
-
if (!/^[0-9a-f-]{36}$/i.test(certificate.certificateId))
|
|
69
|
-
throw new Error('Invalid certificate response.');
|
|
82
|
+
const certificate = { certificateId: consentCapture.sessionId };
|
|
70
83
|
const certificateUrl = `${portal.origin}/${certificate.certificateId}`;
|
|
71
|
-
status.textContent = `Certificate
|
|
84
|
+
status.textContent = `Submission saved. Certificate: ${certificate.certificateId}. `;
|
|
72
85
|
const link = document.createElement('a');
|
|
73
86
|
link.href = certificateUrl;
|
|
74
87
|
link.textContent = 'View certificate and replay';
|
|
@@ -79,8 +92,8 @@ export async function attach(script) {
|
|
|
79
92
|
}
|
|
80
93
|
catch (error) {
|
|
81
94
|
fail(error);
|
|
82
|
-
if (
|
|
83
|
-
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.';
|
|
84
97
|
}
|
|
85
98
|
finally {
|
|
86
99
|
capture.dispose();
|
|
@@ -90,13 +103,29 @@ export async function attach(script) {
|
|
|
90
103
|
try {
|
|
91
104
|
if (!(disclosure instanceof HTMLElement) || !(checkbox instanceof HTMLInputElement))
|
|
92
105
|
throw new Error('Add data-leadping-disclosure and data-leadping-consent inside the form.');
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
capture = new ConsentCapture({ apiUrl: origin.origin + '/api',
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
106
|
+
const pageUrl = location.href;
|
|
107
|
+
const observedDisclosure = disclosure.textContent ?? '';
|
|
108
|
+
capture = new ConsentCapture({ apiUrl: origin.origin + '/api',
|
|
109
|
+
session: async () => (await post(`/domains/${encodeURIComponent(domainId)}/sessions`, { pageUrl, disclosure: observedDisclosure })).json(),
|
|
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
|
+
} });
|
|
114
|
+
window.addEventListener('pagehide', () => { capture?.dispose(); }, { once: true });
|
|
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
|
+
}
|
|
125
|
+
if (!submitted)
|
|
126
|
+
status.textContent = '';
|
|
127
|
+
form.dispatchEvent(new CustomEvent('leadping:ready', { detail: { certificateId, certificateUrl,
|
|
128
|
+
maxReplaySeconds: capture.maxReplaySeconds } }));
|
|
100
129
|
}
|
|
101
130
|
catch (error) {
|
|
102
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 {
|
|
@@ -27,12 +29,17 @@ export interface CaptureReference {
|
|
|
27
29
|
export interface CaptureOptions {
|
|
28
30
|
/** Absolute HTTPS API origin; no credentials or query string. */
|
|
29
31
|
apiUrl: string;
|
|
30
|
-
session
|
|
32
|
+
/** A session or a factory that obtains one. Capture starts immediately while the factory is pending. */
|
|
33
|
+
session: CaptureSession | (() => Promise<CaptureSession>);
|
|
31
34
|
form: HTMLFormElement;
|
|
32
35
|
disclosure: HTMLElement;
|
|
33
36
|
checkbox: HTMLInputElement;
|
|
37
|
+
/** For public domain sessions, durably request a certificate with the final submission. */
|
|
38
|
+
issueCertificate?: boolean;
|
|
34
39
|
/** Called on terminal capture failures; never includes captured values. */
|
|
35
40
|
onError?: (error: Error) => void;
|
|
41
|
+
/** Submission stages; no captured values or upload credentials are included. */
|
|
42
|
+
onProgress?: (stage: 'uploading' | 'submitting') => void;
|
|
36
43
|
}
|
|
37
44
|
export interface SealedBatch {
|
|
38
45
|
number: number;
|