@useparallel/agent-identification 0.0.0-stage → 0.9.0

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.
@@ -0,0 +1,646 @@
1
+ "use client";
2
+
3
+ // src/react/index.tsx
4
+ import { useEffect, useRef } from "react";
5
+
6
+ // src/protocol.ts
7
+ var VERSION = "0.9.0";
8
+ var CHOICES = ["ai_assistant", "person", "prefer_not_to_say"];
9
+ function isChoice(value) {
10
+ return typeof value === "string" && CHOICES.includes(value);
11
+ }
12
+ var MAX_PATH = 512;
13
+ var ID_SEGMENTS = [
14
+ /^\d+$/,
15
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
16
+ /^[0-9a-f]{12,}$/i,
17
+ /@/,
18
+ // Letters mixed with three or more digits, as in most generated IDs and tokens.
19
+ /^(?=(?:[^\d]*\d){3})(?=.*[a-z])[a-z0-9_-]{8,}$/i
20
+ ];
21
+ function routePath(path) {
22
+ const bare = path.replace(/[?#][\s\S]*$/, "");
23
+ const masked = bare.split("/").map((segment) => {
24
+ let text = segment;
25
+ try {
26
+ text = decodeURIComponent(segment);
27
+ } catch {
28
+ }
29
+ return ID_SEGMENTS.some((pattern) => pattern.test(text)) ? ":id" : segment;
30
+ }).join("/");
31
+ return (masked.startsWith("/") ? masked : `/${masked}`).slice(0, MAX_PATH);
32
+ }
33
+ function isRoutePattern(value) {
34
+ return typeof value === "string" && /^\/[A-Za-z0-9_.~:/{}*-]{0,255}$/.test(value);
35
+ }
36
+
37
+ // src/browser/shared.ts
38
+ var memorySessions = /* @__PURE__ */ new Map();
39
+ var requestTimeoutMs = 15e3;
40
+ var HttpError = class extends Error {
41
+ constructor(status) {
42
+ super(`Identification service returned HTTP ${status}.`);
43
+ this.status = status;
44
+ this.name = "HttpError";
45
+ }
46
+ status;
47
+ };
48
+ function endpoint(value, name) {
49
+ let url;
50
+ try {
51
+ url = new URL(value, window.location.href);
52
+ } catch {
53
+ throw new Error(`${name} must be a valid URL.`);
54
+ }
55
+ const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
56
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && local) || url.username || url.password || url.search || url.hash) {
57
+ throw new Error(
58
+ `${name} must use HTTPS (or localhost HTTP), without credentials, query or fragment.`
59
+ );
60
+ }
61
+ return url.href.replace(/\/$/, "");
62
+ }
63
+ function siteId(value) {
64
+ if (!/^[a-zA-Z0-9_-]{1,100}$/.test(value)) {
65
+ throw new Error("siteId must contain 1\u2013100 letters, numbers, underscores or hyphens.");
66
+ }
67
+ return value;
68
+ }
69
+ function pageOrigin() {
70
+ if (window.location.origin === "null") {
71
+ throw new Error("Serve this website over HTTPS (or localhost HTTP) to use identification.");
72
+ }
73
+ return window.location.origin;
74
+ }
75
+ function record(value) {
76
+ return typeof value === "object" && value !== null && !Array.isArray(value);
77
+ }
78
+ function opaque(value, min = 1, max = 512) {
79
+ return typeof value === "string" && value.length >= min && value.length <= max && /^[A-Za-z0-9._~-]+$/.test(value);
80
+ }
81
+ function uuid(value) {
82
+ return typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
83
+ }
84
+ function sessionToken(value) {
85
+ return typeof value === "string" && /^[A-Za-z0-9_-]{32,512}$/.test(value);
86
+ }
87
+ function futureDate(value) {
88
+ return typeof value === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value) && Number.isFinite(Date.parse(value)) && Date.parse(value) > Date.now();
89
+ }
90
+ function readStored(value) {
91
+ if (!record(value) || !sessionToken(value.token) || !uuid(value.sessionId) || !futureDate(value.expiresAt)) {
92
+ return null;
93
+ }
94
+ return { token: value.token, sessionId: value.sessionId, expiresAt: value.expiresAt };
95
+ }
96
+ async function storageKey(target, context) {
97
+ const scope = JSON.stringify([target.apiUrl, target.siteId, target.origin, context ?? null]);
98
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(scope));
99
+ return `parallel-agent-id:v1:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
100
+ }
101
+ function loadSession(key, storage) {
102
+ let found = memorySessions.get(key) ?? null;
103
+ if (storage === "session") {
104
+ try {
105
+ const serialized = sessionStorage.getItem(key);
106
+ found = serialized ? readStored(JSON.parse(serialized)) : found;
107
+ if (serialized && !found) sessionStorage.removeItem(key);
108
+ } catch {
109
+ }
110
+ }
111
+ return readStored(found);
112
+ }
113
+ function persistSession(key, storage, value) {
114
+ for (const [stored, session] of memorySessions) {
115
+ if (!futureDate(session.expiresAt)) memorySessions.delete(stored);
116
+ }
117
+ if (value) memorySessions.set(key, { ...value });
118
+ else memorySessions.delete(key);
119
+ if (storage === "session") {
120
+ try {
121
+ if (value) sessionStorage.setItem(key, JSON.stringify(value));
122
+ else sessionStorage.removeItem(key);
123
+ } catch {
124
+ }
125
+ }
126
+ }
127
+ function asError(value) {
128
+ return value instanceof Error ? value : new Error("Identification request failed.");
129
+ }
130
+ async function send(target, operation, path, method, body, token) {
131
+ const request = new AbortController();
132
+ const abort = () => request.abort();
133
+ operation.addEventListener("abort", abort, { once: true });
134
+ if (operation.aborted) request.abort();
135
+ const timeout = setTimeout(() => request.abort(), requestTimeoutMs);
136
+ try {
137
+ const response = await fetch(`${target.apiUrl}${path}`, {
138
+ method,
139
+ headers: {
140
+ ...body === void 0 ? {} : { "Content-Type": "application/json" },
141
+ ...token ? { Authorization: `Bearer ${token}`, "X-Agent-Site": target.siteId } : {}
142
+ },
143
+ ...body === void 0 ? {} : { body: JSON.stringify(body) },
144
+ credentials: "omit",
145
+ cache: "no-store",
146
+ mode: "cors",
147
+ referrerPolicy: "no-referrer",
148
+ signal: request.signal
149
+ });
150
+ if (!response.ok) throw new HttpError(response.status);
151
+ if (method === "DELETE") {
152
+ if (response.status !== 204) throw new Error("Session revocation was not confirmed.");
153
+ return null;
154
+ }
155
+ if (!response.headers.get("content-type")?.toLowerCase().includes("application/json")) {
156
+ throw new Error("Identification service did not return JSON.");
157
+ }
158
+ return await response.json();
159
+ } catch (error) {
160
+ if (request.signal.aborted && !operation.aborted)
161
+ throw new Error("Identification service timed out. Please retry.", { cause: error });
162
+ throw error;
163
+ } finally {
164
+ clearTimeout(timeout);
165
+ operation.removeEventListener("abort", abort);
166
+ }
167
+ }
168
+ function confirmed(body, id) {
169
+ return record(body) && Array.isArray(body.results) && body.results.some(
170
+ (result) => record(result) && result.id === id && (result.status === "recorded" || result.status === "duplicate")
171
+ );
172
+ }
173
+ function automationSignals() {
174
+ const found = [];
175
+ if (navigator.webdriver === true)
176
+ found.push({ signal: "webdriver", detail: "navigator.webdriver" });
177
+ if (/HeadlessChrome\//.test(navigator.userAgent)) {
178
+ found.push({ signal: "headless_browser", detail: "HeadlessChrome" });
179
+ }
180
+ return found;
181
+ }
182
+
183
+ // src/browser/index.ts
184
+ var instances = /* @__PURE__ */ new WeakMap();
185
+ var gates = /* @__PURE__ */ new WeakMap();
186
+ var labels = {
187
+ ai_assistant: "AI assistant",
188
+ person: "Person",
189
+ prefer_not_to_say: "Prefer not to say"
190
+ };
191
+ function element(value, name) {
192
+ const found = typeof value === "string" ? document.querySelector(value) : value;
193
+ if (!(found instanceof HTMLElement)) {
194
+ throw new Error(`${name} must identify an existing HTML element.`);
195
+ }
196
+ return found;
197
+ }
198
+ function configure(options) {
199
+ const container = element(options.container, "container");
200
+ const gate = options.gate === void 0 ? void 0 : element(options.gate, "gate");
201
+ const apiUrl = endpoint(options.apiUrl, "apiUrl");
202
+ const mode = options.mode ?? "optional";
203
+ const storage = options.storage ?? "session";
204
+ if (mode !== "optional" && mode !== "required") throw new Error("Invalid identification mode.");
205
+ if (storage !== "session" && storage !== "memory") throw new Error("Invalid storage option.");
206
+ if (gate && (gate === container || gate.contains(container))) {
207
+ throw new Error("The gate must not contain the identification widget.");
208
+ }
209
+ const origin = pageOrigin();
210
+ if (options.route !== void 0 && !isRoutePattern(options.route)) {
211
+ throw new Error("route must be a path pattern such as /tickets/:id.");
212
+ }
213
+ return {
214
+ container,
215
+ gate,
216
+ apiUrl,
217
+ siteId: siteId(options.siteId),
218
+ origin,
219
+ mode,
220
+ storage,
221
+ styleUrl: endpoint(options.styleUrl ?? `${apiUrl}/sdk/v${VERSION}/agent-id.css`, "styleUrl"),
222
+ bindingContext: options.bindingContext,
223
+ onRecorded: options.onRecorded,
224
+ onError: options.onError,
225
+ pageViews: options.pageViews ?? true,
226
+ route: options.route,
227
+ automationSignals: options.automationSignals ?? false
228
+ };
229
+ }
230
+ function sameConfiguration(left, right) {
231
+ return Object.keys(left).every((key) => {
232
+ if ((key === "onRecorded" || key === "onError") && right[key] === void 0) return true;
233
+ return left[key] === right[key];
234
+ });
235
+ }
236
+ function validateStatus(value, config, expectedId, expectedChoice) {
237
+ if (!record(value) || !uuid(value.sessionId) || expectedId !== void 0 && value.sessionId !== expectedId || value.siteId !== config.siteId || value.origin !== config.origin || !futureDate(value.expiresAt) || value.evidence !== "self_reported") {
238
+ throw new Error("Identification service returned an invalid or mismatched session.");
239
+ }
240
+ const unanswered = value.choice === null && value.receipt === null && value.eventId === null;
241
+ const answered = isChoice(value.choice) && opaque(value.receipt, 1, 8192) && uuid(value.eventId);
242
+ if (!unanswered && !answered || expectedChoice !== void 0 && value.choice !== expectedChoice) {
243
+ throw new Error("Identification service returned an invalid or mismatched response.");
244
+ }
245
+ return {
246
+ sessionId: value.sessionId,
247
+ siteId: config.siteId,
248
+ origin: config.origin,
249
+ expiresAt: value.expiresAt,
250
+ choice: value.choice,
251
+ receipt: value.receipt,
252
+ eventId: value.eventId,
253
+ evidence: "self_reported"
254
+ };
255
+ }
256
+ var Controller = class {
257
+ constructor(config) {
258
+ this.config = config;
259
+ this.originalInert = config.gate?.inert ?? false;
260
+ this.build();
261
+ this.lock();
262
+ this.ready = this.start(this.version);
263
+ void this.ready.catch(() => {
264
+ });
265
+ }
266
+ config;
267
+ ready;
268
+ host = document.createElement("div");
269
+ root = this.host.attachShadow({ mode: "open" });
270
+ status = document.createElement("p");
271
+ retry = document.createElement("button");
272
+ buttons = [];
273
+ originalInert;
274
+ version = 0;
275
+ controller = new AbortController();
276
+ destroyed = false;
277
+ state = null;
278
+ session = null;
279
+ key = null;
280
+ pending = null;
281
+ retryAction = null;
282
+ expiry;
283
+ /** The session this page load has recorded its automatic page view in. */
284
+ viewed = null;
285
+ /** The session this page load has reported its automation signals in. */
286
+ signalled = null;
287
+ build() {
288
+ this.host.dataset.agentIdentification = VERSION;
289
+ const stylesheet = document.createElement("link");
290
+ stylesheet.rel = "stylesheet";
291
+ stylesheet.href = this.config.styleUrl;
292
+ const panel = document.createElement("section");
293
+ panel.className = "agent-identification";
294
+ panel.setAttribute("aria-label", "Visitor identification");
295
+ const title = document.createElement("h2");
296
+ title.textContent = this.config.mode === "required" ? "Before continuing" : "How are you visiting?";
297
+ const description = document.createElement("p");
298
+ description.textContent = "Choose one option. Your answer is self-reported.";
299
+ const actions = document.createElement("div");
300
+ actions.className = "choices";
301
+ for (const choice of Object.keys(labels)) {
302
+ const button = document.createElement("button");
303
+ button.type = "button";
304
+ button.textContent = labels[choice];
305
+ button.disabled = true;
306
+ button.addEventListener("click", () => {
307
+ this.pending = { choice, requestId: crypto.randomUUID() };
308
+ void this.submit(this.version).catch(() => {
309
+ });
310
+ });
311
+ this.buttons.push(button);
312
+ actions.append(button);
313
+ }
314
+ this.status.className = "status";
315
+ this.status.setAttribute("role", "status");
316
+ this.status.setAttribute("aria-live", "polite");
317
+ this.status.setAttribute("aria-atomic", "true");
318
+ this.retry.type = "button";
319
+ this.retry.textContent = "Retry";
320
+ this.retry.hidden = true;
321
+ this.retry.addEventListener("click", () => {
322
+ const action = this.retryAction;
323
+ if (action) void action().catch(() => {
324
+ });
325
+ });
326
+ panel.append(title, description, actions, this.status, this.retry);
327
+ this.root.append(stylesheet, panel);
328
+ this.config.container.append(this.host);
329
+ }
330
+ active(version) {
331
+ if (this.destroyed || version !== this.version)
332
+ throw new DOMException("Widget operation was superseded.", "AbortError");
333
+ }
334
+ lock() {
335
+ if (this.config.mode === "required" && this.config.gate) this.config.gate.inert = true;
336
+ }
337
+ busy(message) {
338
+ this.status.textContent = message;
339
+ this.retry.hidden = true;
340
+ this.retryAction = null;
341
+ for (const button of this.buttons) button.disabled = true;
342
+ }
343
+ fail(error, version, action, prefix) {
344
+ if (this.destroyed || version !== this.version) return;
345
+ const failure = asError(error);
346
+ this.status.textContent = `${prefix} ${failure.message}`;
347
+ this.retry.hidden = false;
348
+ this.retryAction = action;
349
+ this.notifyError(failure);
350
+ }
351
+ notifyError(error) {
352
+ try {
353
+ this.config.onError?.(error);
354
+ } catch {
355
+ }
356
+ }
357
+ accepted(status) {
358
+ this.state = status;
359
+ clearTimeout(this.expiry);
360
+ this.expiry = setTimeout(
361
+ () => {
362
+ if (this.destroyed || this.state?.sessionId !== status.sessionId) return;
363
+ this.state = null;
364
+ this.lock();
365
+ this.busy("Your identification session expired.");
366
+ this.retry.hidden = false;
367
+ this.retryAction = () => this.restart();
368
+ },
369
+ Math.min(Date.parse(status.expiresAt) - Date.now(), 2147483647)
370
+ );
371
+ this.retry.hidden = true;
372
+ this.retryAction = null;
373
+ if (status.choice !== null) {
374
+ this.status.textContent = `Response recorded: ${labels[status.choice]}.`;
375
+ if (this.config.mode === "required" && this.config.gate)
376
+ this.config.gate.inert = this.originalInert;
377
+ try {
378
+ this.config.onRecorded?.({ ...status });
379
+ } catch {
380
+ this.notifyError(
381
+ new Error("Your onRecorded callback failed after the response was recorded.")
382
+ );
383
+ }
384
+ } else {
385
+ this.status.textContent = this.config.mode === "required" ? "Select any option to continue." : "You can choose an option or continue without answering.";
386
+ }
387
+ for (const button of this.buttons) button.disabled = status.choice !== null;
388
+ return { ...status };
389
+ }
390
+ load() {
391
+ return this.key ? loadSession(this.key, this.config.storage) : null;
392
+ }
393
+ persist(value) {
394
+ if (this.key) persistSession(this.key, this.config.storage, value);
395
+ }
396
+ request(path, method, body, token) {
397
+ return send(this.config, this.controller.signal, path, method, body, token);
398
+ }
399
+ async start(version) {
400
+ this.busy("Loading identification\u2026");
401
+ this.lock();
402
+ try {
403
+ const context = typeof this.config.bindingContext === "function" ? await this.config.bindingContext() : this.config.bindingContext;
404
+ this.active(version);
405
+ if (context !== void 0 && (typeof context !== "string" || context.length === 0 || context.length > 8192)) {
406
+ throw new Error("bindingContext must be a nonempty signed context.");
407
+ }
408
+ const key = await storageKey(this.config, context);
409
+ this.active(version);
410
+ this.key = key;
411
+ this.session = this.load();
412
+ if (this.session) {
413
+ try {
414
+ const body2 = await this.request(
415
+ "/v1/sessions/current",
416
+ "GET",
417
+ void 0,
418
+ this.session.token
419
+ );
420
+ this.active(version);
421
+ const status2 = validateStatus(body2, this.config, this.session.sessionId);
422
+ this.session.expiresAt = status2.expiresAt;
423
+ this.persist(this.session);
424
+ return this.view(this.accepted(status2));
425
+ } catch (error) {
426
+ this.active(version);
427
+ if (!(error instanceof HttpError) || ![401, 404, 410].includes(error.status)) throw error;
428
+ this.persist(null);
429
+ this.session = null;
430
+ }
431
+ }
432
+ const body = await this.request("/v1/sessions", "POST", {
433
+ siteId: this.config.siteId,
434
+ ...context === void 0 ? {} : { bindingContext: context }
435
+ });
436
+ this.active(version);
437
+ const status = validateStatus(body, this.config);
438
+ if (!record(body) || !sessionToken(body.sessionToken) || status.choice !== null) {
439
+ throw new Error("Identification service returned an invalid new session.");
440
+ }
441
+ this.session = {
442
+ token: body.sessionToken,
443
+ sessionId: status.sessionId,
444
+ expiresAt: status.expiresAt
445
+ };
446
+ this.persist(this.session);
447
+ return this.view(this.accepted(status));
448
+ } catch (error) {
449
+ this.fail(error, version, () => this.restart(), "Could not load identification.");
450
+ throw error;
451
+ }
452
+ }
453
+ async submit(version) {
454
+ if (!this.pending || !this.session) throw new Error("Identification session is not ready.");
455
+ const pending = this.pending;
456
+ const session = this.session;
457
+ this.busy("Recording your response\u2026");
458
+ try {
459
+ const body = await this.request("/v1/declarations", "POST", pending, session.token);
460
+ this.active(version);
461
+ const status = validateStatus(body, this.config, session.sessionId, pending.choice);
462
+ session.expiresAt = status.expiresAt;
463
+ this.persist(session);
464
+ this.pending = null;
465
+ return this.accepted(status);
466
+ } catch (error) {
467
+ this.fail(error, version, () => this.submit(this.version), "Could not record your response.");
468
+ throw error;
469
+ }
470
+ }
471
+ /** Records this page load's automatic view once per session; failures reach onError. */
472
+ view(status) {
473
+ if (this.config.pageViews && this.viewed !== status.sessionId) {
474
+ this.viewed = status.sessionId;
475
+ void this.pageView({ route: this.config.route }).catch(() => {
476
+ });
477
+ }
478
+ if (this.config.automationSignals && this.signalled !== status.sessionId) {
479
+ this.signalled = status.sessionId;
480
+ void this.reportSignals().catch(() => {
481
+ });
482
+ }
483
+ return status;
484
+ }
485
+ async pageView(options = {}) {
486
+ this.active(this.version);
487
+ if (options.route !== void 0 && !isRoutePattern(options.route)) {
488
+ throw new Error("route must be a path pattern such as /tickets/:id.");
489
+ }
490
+ const view = {
491
+ // The same ID on every attempt, so a retry is recorded once.
492
+ id: crypto.randomUUID(),
493
+ path: routePath(window.location.pathname),
494
+ ...options.route ? { route: options.route } : {},
495
+ occurredAt: (/* @__PURE__ */ new Date()).toISOString()
496
+ };
497
+ await this.deliver("/v1/page-views", { views: [view] }, view.id, "page view");
498
+ }
499
+ /**
500
+ * Sends one event and waits for the collector to confirm that exact ID. It retries with the same
501
+ * ID, so a retry is recorded once, and reports to onError before rejecting.
502
+ */
503
+ async deliver(path, payload, id, what) {
504
+ const operation = this.controller.signal;
505
+ for (let attempt = 0; ; attempt++) {
506
+ const session = this.session;
507
+ try {
508
+ if (!session) throw new Error("Identification session is not ready.");
509
+ const body = await this.request(path, "POST", payload, session.token);
510
+ if (!confirmed(body, id))
511
+ throw new Error(`Identification service did not confirm the ${what}.`);
512
+ return;
513
+ } catch (error) {
514
+ if (operation.aborted || this.destroyed) throw error;
515
+ const retryable = session !== null && (!(error instanceof HttpError) || error.status === 429 || error.status >= 500);
516
+ if (!retryable || attempt >= 2) {
517
+ const failure = asError(error);
518
+ this.notifyError(failure);
519
+ throw failure;
520
+ }
521
+ await new Promise((resolve) => setTimeout(resolve, 300 * 3 ** attempt));
522
+ if (operation.aborted || this.destroyed) throw error;
523
+ }
524
+ }
525
+ }
526
+ /** Opt-in: what this browser shows of automation, as evidence and never as identity. */
527
+ async reportSignals() {
528
+ for (const signal of automationSignals()) {
529
+ const event = { id: crypto.randomUUID(), ...signal, occurredAt: (/* @__PURE__ */ new Date()).toISOString() };
530
+ await this.deliver("/v1/browser-signals", { signals: [event] }, event.id, "signal");
531
+ }
532
+ }
533
+ restart() {
534
+ this.active(this.version);
535
+ this.controller.abort();
536
+ clearTimeout(this.expiry);
537
+ this.controller = new AbortController();
538
+ this.version += 1;
539
+ return this.start(this.version);
540
+ }
541
+ getState() {
542
+ return this.state ? { ...this.state } : null;
543
+ }
544
+ async reset() {
545
+ this.active(this.version);
546
+ this.controller.abort();
547
+ clearTimeout(this.expiry);
548
+ this.controller = new AbortController();
549
+ const version = ++this.version;
550
+ this.busy("Resetting identification\u2026");
551
+ this.state = null;
552
+ this.pending = null;
553
+ this.lock();
554
+ try {
555
+ if (this.session) {
556
+ await this.request("/v1/sessions/current", "DELETE", void 0, this.session.token);
557
+ this.active(version);
558
+ }
559
+ this.persist(null);
560
+ this.session = null;
561
+ } catch (error) {
562
+ this.fail(error, version, () => this.reset(), "Could not reset identification.");
563
+ throw error;
564
+ }
565
+ return this.start(version);
566
+ }
567
+ destroy() {
568
+ if (this.destroyed) return;
569
+ this.destroyed = true;
570
+ this.version += 1;
571
+ this.controller.abort();
572
+ clearTimeout(this.expiry);
573
+ this.host.remove();
574
+ if (this.config.mode === "required" && this.config.gate) {
575
+ this.config.gate.inert = this.originalInert;
576
+ gates.delete(this.config.gate);
577
+ }
578
+ instances.delete(this.config.container);
579
+ this.retryAction = null;
580
+ }
581
+ };
582
+ function init(options) {
583
+ const config = configure(options);
584
+ const existing = instances.get(config.container);
585
+ if (existing) {
586
+ if (!sameConfiguration(existing.config, config))
587
+ throw new Error(
588
+ "This container already has an identification widget with different options."
589
+ );
590
+ return existing;
591
+ }
592
+ if (config.mode === "required" && config.gate && gates.has(config.gate)) {
593
+ throw new Error("This gate is already controlled by another identification widget.");
594
+ }
595
+ const controller = new Controller(config);
596
+ instances.set(config.container, controller);
597
+ if (config.mode === "required" && config.gate) gates.set(config.gate, controller);
598
+ return controller;
599
+ }
600
+
601
+ // src/react/index.tsx
602
+ import { jsx } from "react/jsx-runtime";
603
+ function AgentIdentification({
604
+ className,
605
+ id,
606
+ onRecorded,
607
+ onError,
608
+ ...options
609
+ }) {
610
+ const container = useRef(null);
611
+ const callbacks = useRef({ onRecorded, onError });
612
+ callbacks.current = { onRecorded, onError };
613
+ useEffect(() => {
614
+ if (!container.current) return;
615
+ let widget;
616
+ try {
617
+ widget = init({
618
+ ...options,
619
+ container: container.current,
620
+ onRecorded: (response) => callbacks.current.onRecorded?.(response),
621
+ onError: (error) => callbacks.current.onError?.(error)
622
+ });
623
+ } catch (error) {
624
+ const failure = error instanceof Error ? error : new Error("Identification could not initialize.");
625
+ const notice = document.createElement("p");
626
+ notice.setAttribute("role", "alert");
627
+ notice.textContent = `Visitor identification unavailable. ${failure.message}`;
628
+ container.current.append(notice);
629
+ callbacks.current.onError?.(failure);
630
+ return () => notice.remove();
631
+ }
632
+ return () => widget.destroy();
633
+ }, [
634
+ options.apiUrl,
635
+ options.siteId,
636
+ options.mode,
637
+ options.gate,
638
+ options.styleUrl,
639
+ options.bindingContext,
640
+ options.storage
641
+ ]);
642
+ return /* @__PURE__ */ jsx("div", { ref: container, className, id });
643
+ }
644
+ export {
645
+ AgentIdentification
646
+ };