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