@corbado/observe 1.9.1 → 1.10.1

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,422 @@
1
+ // src/cross-origin-session.ts
2
+ import { readCookieScope } from "./readers/page.mjs";
3
+ import { cookieJar } from "./readers/storage.mjs";
4
+ var CROSS_ORIGIN_COOKIE_PREFIX = "cbo_xs";
5
+ var CROSS_ORIGIN_COOKIE_VERSION = "1";
6
+ var CROSS_ORIGIN_SEQ_RESERVE = 20;
7
+ var CROSS_ORIGIN_CONTEXT_MAX = 128;
8
+ var CONTEXT_RE = /^[A-Za-z0-9._~-]{1,128}$/;
9
+ var CROSS_ORIGIN_ACTIVITY_WRITE_INTERVAL_MS = 1e3;
10
+ var FUTURE_TOLERANCE_MS = 5 * 60 * 1e3;
11
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12
+ var PROJECT_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
13
+ var DOMAIN_RE = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/;
14
+ var INT_RE = /^\d{1,15}$/;
15
+ var CROSS_ORIGIN_MAX_SEQ_MARK = 1e9;
16
+ var CROSS_ORIGIN_FOREIGN_GAP = 1e3;
17
+ var CROSS_ORIGIN_MAX_SEQ_JUMP = 1e5;
18
+ function isValidCrossOriginContext(value) {
19
+ return typeof value === "string" && CONTEXT_RE.test(value);
20
+ }
21
+ function parseCrossOriginCookie(value) {
22
+ const parts = value.split(".");
23
+ const [version, sessionId, lastActiveAt, seqMark] = parts;
24
+ if (parts.length < 4 || version !== CROSS_ORIGIN_COOKIE_VERSION || !sessionId || !UUID_RE.test(sessionId) || !lastActiveAt || !INT_RE.test(lastActiveAt) || !seqMark || !INT_RE.test(seqMark) || Number(seqMark) > CROSS_ORIGIN_MAX_SEQ_MARK) {
25
+ return void 0;
26
+ }
27
+ const context = parts.length > 4 ? parts.slice(4).join(".") : void 0;
28
+ if (context !== void 0 && !isValidCrossOriginContext(context)) {
29
+ return void 0;
30
+ }
31
+ return {
32
+ sessionId: sessionId.toLowerCase(),
33
+ lastActiveAt: Number(lastActiveAt),
34
+ seqMark: Number(seqMark),
35
+ ...context !== void 0 && { context }
36
+ };
37
+ }
38
+ function formatCrossOriginCookie(record) {
39
+ const parts = [
40
+ CROSS_ORIGIN_COOKIE_VERSION,
41
+ record.sessionId,
42
+ String(Math.floor(record.lastActiveAt)),
43
+ String(Math.floor(record.seqMark))
44
+ ];
45
+ if (record.context !== void 0) {
46
+ parts.push(record.context);
47
+ }
48
+ return parts.join(".");
49
+ }
50
+ function resolveCrossOriginDomain(raw, hostname) {
51
+ const domain = raw.trim().toLowerCase().replace(/^\./, "");
52
+ const host = hostname.toLowerCase();
53
+ if (!DOMAIN_RE.test(domain)) {
54
+ return void 0;
55
+ }
56
+ return host === domain || host.endsWith(`.${domain}`) ? domain : void 0;
57
+ }
58
+ var CrossOriginSession = class _CrossOriginSession {
59
+ constructor(name, domain, jar, contextJar, deps) {
60
+ this.name = name;
61
+ this.domain = domain;
62
+ this.jar = jar;
63
+ this.contextJar = contextJar;
64
+ this.deps = deps;
65
+ }
66
+ enabled = true;
67
+ /** Session the in-memory reservation, highest seen mark and context belong to. */
68
+ reservedFor;
69
+ /** Seq this page may allocate up to (exclusive) without rewriting the cookie. */
70
+ reservedMark = 0;
71
+ /** Highest plausible mark of {@link reservedFor} this page has seen or written. */
72
+ highestSeen = 0;
73
+ lastWriteAt = 0;
74
+ /** Context of {@link reservedFor} as last known to this page. */
75
+ context;
76
+ /**
77
+ * The feature for this page, or `undefined` when it cannot work here (invalid project id or domain, a
78
+ * domain that is not the page's host or a parent of it, a non-HTTPS page, no document). Never throws.
79
+ */
80
+ static create(deps) {
81
+ try {
82
+ if (typeof document === "undefined" || typeof window === "undefined") {
83
+ return void 0;
84
+ }
85
+ if (!PROJECT_ID_RE.test(deps.projectId)) {
86
+ deps.logger.debug("Cross-origin session off: project id is not cookie-safe");
87
+ return void 0;
88
+ }
89
+ const scope = deps.scope ? deps.scope() : readCookieScope({
90
+ key: "page:location.cookie-scope",
91
+ title: "Page host name for the shared session cookie",
92
+ accessNote: "location.hostname and location.protocol, compared with the configured shared cookie domain; not sent",
93
+ group: "Observe SDK state",
94
+ origin: "observe-external",
95
+ fields: [
96
+ {
97
+ path: "hostname",
98
+ label: "Host name of the page",
99
+ exposure: "none",
100
+ transform: { note: "only compared with the configured parent domain; never sent or stored" }
101
+ },
102
+ {
103
+ path: "https",
104
+ label: "Whether the page uses a secure connection",
105
+ exposure: "none",
106
+ transform: { note: "the shared cookie is only used on secure pages; never sent or stored" }
107
+ }
108
+ ]
109
+ });
110
+ if (!scope.https) {
111
+ deps.logger.debug("Cross-origin session off: page is not served over HTTPS");
112
+ return void 0;
113
+ }
114
+ const domain = resolveCrossOriginDomain(deps.cookieDomain, scope.hostname);
115
+ if (!domain) {
116
+ deps.logger.warn(`Cross-origin session off: "${deps.cookieDomain}" is not the page's domain or a parent of it`);
117
+ deps.report("info", "cross-origin session: cookieDomain does not match the page");
118
+ return void 0;
119
+ }
120
+ const jar = deps.jar ?? cookieJar({
121
+ key: "storage:sdk.cross-origin-session",
122
+ title: "Observe session shared across your subdomains",
123
+ accessNote: "document.cookie, Observe's own cbo_xs_ cookie on the configured parent domain (Secure, SameSite Lax, Path /, expires after the session inactivity window); the browser sends it with every request to every subdomain of that domain",
124
+ group: "Observe SDK state",
125
+ origin: "observe-internal",
126
+ fields: [
127
+ {
128
+ path: "sessionId",
129
+ label: "Random Observe session id",
130
+ exposure: "token",
131
+ transform: {
132
+ pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
133
+ max: 36,
134
+ note: "generated by the SDK; sent to Corbado as the session id of every batch; the cookie carries it to every subdomain of the configured domain"
135
+ }
136
+ },
137
+ {
138
+ path: "lastActiveAt",
139
+ label: "Time of the last tracked activity",
140
+ exposure: "none",
141
+ transform: {
142
+ note: "decides whether the session is still active; not sent to Corbado, but the cookie carries it to every subdomain of the configured domain"
143
+ }
144
+ },
145
+ {
146
+ path: "seqMark",
147
+ label: "Event counter position",
148
+ exposure: "none",
149
+ transform: {
150
+ note: "the next page continues event numbering above it; not sent to Corbado, but the cookie carries it to every subdomain of the configured domain"
151
+ }
152
+ }
153
+ ]
154
+ });
155
+ const contextJar = deps.jar ?? cookieJar({
156
+ key: "storage:sdk.cross-origin-context",
157
+ title: "Reference your integration keeps with the shared session",
158
+ accessNote: "document.cookie, the optional context part of Observe's cbo_xs_ cookie, set and read by the integration",
159
+ group: "Observe SDK state",
160
+ origin: "host-config",
161
+ fields: [
162
+ {
163
+ path: "context",
164
+ label: "Optional reference set by your integration",
165
+ exposure: "none",
166
+ transform: {
167
+ note: "opaque string of at most 128 cookie-safe characters chosen by the integration; not sent to Corbado, but the cookie carries it to every subdomain of the configured domain"
168
+ }
169
+ }
170
+ ]
171
+ });
172
+ return new _CrossOriginSession(`${CROSS_ORIGIN_COOKIE_PREFIX}_${deps.projectId}`, domain, jar, contextJar, deps);
173
+ } catch (error) {
174
+ deps.logger.error("Cross-origin session setup failed", error);
175
+ return void 0;
176
+ }
177
+ }
178
+ /** Whether the cookie is in use on this page (false after a failed write check or {@link stop}). */
179
+ isEnabled() {
180
+ return this.enabled;
181
+ }
182
+ /** No cookie access from now on (tracker destroyed). */
183
+ stop() {
184
+ this.enabled = false;
185
+ }
186
+ /**
187
+ * Decide the session of this load before its first event and claim the cookie for it.
188
+ *
189
+ * `local.lastActiveAt` is the local session's last activity BEFORE this load (0 when the local session
190
+ * was created by this load). The cookie's session is adopted when the local one is new or was active
191
+ * earlier than the cookie's; the same session continues at or above the cookie's mark. A local session
192
+ * that was active more recently takes the cookie over. Never throws; `undefined` when the feature is off.
193
+ */
194
+ boot(local, now = Date.now()) {
195
+ if (!this.enabled) {
196
+ return void 0;
197
+ }
198
+ try {
199
+ const cookie = this.read(this.jar, now);
200
+ let sessionId = local.sessionId;
201
+ let adopted = false;
202
+ if (cookie && cookie.sessionId !== sessionId && (!sessionId || local.lastActiveAt <= 0 || cookie.lastActiveAt > local.lastActiveAt)) {
203
+ sessionId = cookie.sessionId;
204
+ adopted = true;
205
+ }
206
+ let seq = adopted ? cookie?.seqMark ?? 0 : local.seq;
207
+ if (sessionId) {
208
+ const written = this.sync(this.jar, cookie, sessionId, seq, now, true, false);
209
+ seq = written.seq;
210
+ if (!this.isStored(written.value)) {
211
+ this.enabled = false;
212
+ this.deps.logger.debug("Cross-origin session off: the cookie could not be stored");
213
+ this.deps.report("info", "cross-origin session: cookie not stored");
214
+ }
215
+ }
216
+ return { sessionId, seq, adopted };
217
+ } catch (error) {
218
+ this.enabled = false;
219
+ this.deps.logger.error("Cross-origin session boot failed", error);
220
+ this.deps.report("error", "cross-origin session: boot failed");
221
+ return void 0;
222
+ }
223
+ }
224
+ /**
225
+ * Called for every seq allocation with the seq about to be used; returns the seq to use instead. Reads
226
+ * the cookie on every call (a string scan).
227
+ *
228
+ * - Cookie of this session: continue above another page's reservation once one exists above this
229
+ * page's, renew the reservation when it is used up, otherwise refresh the activity at most once per
230
+ * {@link CROSS_ORIGIN_ACTIVITY_WRITE_INTERVAL_MS}. The mark is never lowered.
231
+ * - No (valid) cookie: written as for this session.
232
+ * - Cookie of another session: left alone (see the module notes); the seq stays above the highest mark
233
+ * this page saw for its session.
234
+ *
235
+ * Never throws.
236
+ */
237
+ claim(sessionId, seq, now = Date.now()) {
238
+ if (!this.enabled || !sessionId) {
239
+ return seq;
240
+ }
241
+ try {
242
+ const cookie = this.read(this.jar, now);
243
+ this.follow(sessionId);
244
+ const same = cookie?.sessionId === sessionId;
245
+ const mark = same ? this.plausibleMark(cookie.seqMark, seq) : void 0;
246
+ if (mark !== void 0) {
247
+ this.highestSeen = Math.max(this.highestSeen, mark);
248
+ }
249
+ if (cookie && !same) {
250
+ if (seq < this.reservedMark) {
251
+ return seq;
252
+ }
253
+ const next = Math.max(seq, this.highestSeen) + CROSS_ORIGIN_FOREIGN_GAP;
254
+ this.reservedMark = next + CROSS_ORIGIN_FOREIGN_GAP;
255
+ this.highestSeen = this.reservedMark;
256
+ return next;
257
+ }
258
+ const renew = seq >= this.reservedMark || mark !== void 0 && mark > this.reservedMark;
259
+ if (!renew && now - this.lastWriteAt < CROSS_ORIGIN_ACTIVITY_WRITE_INTERVAL_MS) {
260
+ return seq;
261
+ }
262
+ return this.sync(this.jar, cookie, sessionId, seq, now, renew, false).seq;
263
+ } catch (error) {
264
+ this.deps.logger.error("Cross-origin session cookie update failed", error);
265
+ return seq;
266
+ }
267
+ }
268
+ /** A new session of this page (`resetSession`): the cookie starts over with it, without context. */
269
+ reset(sessionId, now = Date.now()) {
270
+ if (!this.enabled || !sessionId) {
271
+ return;
272
+ }
273
+ try {
274
+ this.reservedFor = void 0;
275
+ this.follow(sessionId);
276
+ this.sync(this.jar, this.read(this.jar, now), sessionId, 0, now, true, true);
277
+ } catch (error) {
278
+ this.deps.logger.error("Cross-origin session cookie reset failed", error);
279
+ }
280
+ }
281
+ /**
282
+ * Store (or with `undefined` clear) the integration context of `sessionId`; this explicit call may take
283
+ * the cookie over from another session. `seq` is the page's best known next seq. Returns the next seq the
284
+ * page must continue at (a renewed reservation can move it up), or `undefined` when nothing was written.
285
+ */
286
+ setContext(sessionId, seq, context, now = Date.now()) {
287
+ if (!this.enabled || !sessionId) {
288
+ return void 0;
289
+ }
290
+ try {
291
+ const cookie = this.read(this.contextJar, now);
292
+ this.follow(sessionId);
293
+ this.context = context;
294
+ return this.sync(this.contextJar, cookie, sessionId, seq, now, seq >= this.reservedMark, true).seq;
295
+ } catch (error) {
296
+ this.deps.logger.error("Cross-origin session context update failed", error);
297
+ return void 0;
298
+ }
299
+ }
300
+ /** The integration context of `sessionId` (the cookie's when it holds that session, else this page's). */
301
+ getContext(sessionId, now = Date.now()) {
302
+ if (!this.enabled || !sessionId) {
303
+ return void 0;
304
+ }
305
+ try {
306
+ const cookie = this.read(this.contextJar, now);
307
+ if (cookie?.sessionId === sessionId) {
308
+ if (this.reservedFor === sessionId) {
309
+ this.context = cookie.context;
310
+ }
311
+ return cookie.context;
312
+ }
313
+ } catch (error) {
314
+ this.deps.logger.error("Cross-origin session context read failed", error);
315
+ }
316
+ return this.reservedFor === sessionId ? this.context : void 0;
317
+ }
318
+ /** Switch the in-memory state to `sessionId` (fresh reservation, nothing seen, no context) when it changed. */
319
+ follow(sessionId) {
320
+ if (this.reservedFor !== sessionId) {
321
+ this.reservedFor = sessionId;
322
+ this.reservedMark = 0;
323
+ this.highestSeen = 0;
324
+ this.context = void 0;
325
+ }
326
+ }
327
+ /** `mark` when it is plausible next to the page's own counter `seq`; a jump beyond the cap is reported and ignored. */
328
+ plausibleMark(mark, seq) {
329
+ if (mark - seq > CROSS_ORIGIN_MAX_SEQ_JUMP) {
330
+ this.deps.report("info", "cross-origin session: implausible seq mark ignored");
331
+ return void 0;
332
+ }
333
+ return mark;
334
+ }
335
+ /**
336
+ * Merge the cookie (already read) with this page's state and write it back for `sessionId`.
337
+ *
338
+ * - `renew`: allocate a new reservation from `seq` (jumping above the cookie's plausible mark of the same
339
+ * session and anything seen before). Otherwise the mark only grows.
340
+ * - `ownContext`: this page's context wins (it was just set); otherwise the cookie's context of the same
341
+ * session is taken over.
342
+ */
343
+ sync(jar, cookie, sessionId, seq, now, renew, ownContext) {
344
+ this.follow(sessionId);
345
+ const same = cookie?.sessionId === sessionId;
346
+ const cookieMark = same ? this.plausibleMark(cookie.seqMark, seq) : void 0;
347
+ if (cookieMark !== void 0) {
348
+ this.highestSeen = Math.max(this.highestSeen, cookieMark);
349
+ }
350
+ if (same && !ownContext) {
351
+ this.context = cookie.context;
352
+ }
353
+ let next = seq;
354
+ let mark;
355
+ if (renew) {
356
+ next = Math.max(seq, cookieMark ?? 0, this.highestSeen);
357
+ mark = next + CROSS_ORIGIN_SEQ_RESERVE;
358
+ } else {
359
+ mark = Math.max(this.reservedMark, this.highestSeen, seq);
360
+ }
361
+ const value = formatCrossOriginCookie({
362
+ sessionId,
363
+ lastActiveAt: now,
364
+ seqMark: mark,
365
+ ...this.context !== void 0 && { context: this.context }
366
+ });
367
+ this.write(jar, value);
368
+ if (renew) {
369
+ this.reservedMark = mark;
370
+ }
371
+ this.highestSeen = Math.max(this.highestSeen, mark);
372
+ this.lastWriteAt = now;
373
+ return { seq: next, value };
374
+ }
375
+ maxAgeSeconds() {
376
+ return Math.max(60, Math.ceil(this.deps.inactivityMs() / 1e3));
377
+ }
378
+ write(jar, value) {
379
+ jar.write(
380
+ `${this.name}=${value}; Domain=${this.domain}; Path=/; Max-Age=${this.maxAgeSeconds()}; SameSite=Lax; Secure`
381
+ );
382
+ }
383
+ /** Raw values of every cookie with this name (another path or a host-only copy can shadow it). */
384
+ rawValues(jar) {
385
+ const prefix = `${this.name}=`;
386
+ return jar.read().split(";").map((part) => part.trim()).filter((part) => part.startsWith(prefix)).map((part) => part.substring(prefix.length));
387
+ }
388
+ isStored(value) {
389
+ try {
390
+ return this.rawValues(this.jar).includes(value);
391
+ } catch {
392
+ return false;
393
+ }
394
+ }
395
+ /** The valid, unexpired record with the latest activity, or `undefined`. Throws when the cookie read throws. */
396
+ read(jar, now) {
397
+ const inactivityMs = this.deps.inactivityMs();
398
+ let best;
399
+ for (const raw of this.rawValues(jar)) {
400
+ const record = parseCrossOriginCookie(raw);
401
+ if (record && now - record.lastActiveAt < inactivityMs && record.lastActiveAt <= now + FUTURE_TOLERANCE_MS && (!best || record.lastActiveAt > best.lastActiveAt)) {
402
+ best = record;
403
+ }
404
+ }
405
+ return best;
406
+ }
407
+ };
408
+ export {
409
+ CROSS_ORIGIN_ACTIVITY_WRITE_INTERVAL_MS,
410
+ CROSS_ORIGIN_CONTEXT_MAX,
411
+ CROSS_ORIGIN_COOKIE_PREFIX,
412
+ CROSS_ORIGIN_COOKIE_VERSION,
413
+ CROSS_ORIGIN_FOREIGN_GAP,
414
+ CROSS_ORIGIN_MAX_SEQ_JUMP,
415
+ CROSS_ORIGIN_MAX_SEQ_MARK,
416
+ CROSS_ORIGIN_SEQ_RESERVE,
417
+ CrossOriginSession,
418
+ formatCrossOriginCookie,
419
+ isValidCrossOriginContext,
420
+ parseCrossOriginCookie,
421
+ resolveCrossOriginDomain
422
+ };
@@ -7,7 +7,6 @@ import {
7
7
  readConditionalMediationAvailable,
8
8
  readJavaScriptHighEntropy,
9
9
  readMaxTouchPoints,
10
- readUserAgent,
11
10
  readUserVerifyingPlatformAuthenticatorAvailable,
12
11
  readWebdriver
13
12
  } from "./readers/device.mjs";
@@ -515,84 +514,6 @@ var DeviceCollector = class {
515
514
  this.logger.error("Failed to report device info timing", error);
516
515
  }
517
516
  }
518
- /**
519
- * Error situations:
520
- * - navigator is not available: user agent is treated as empty, osName and browserName are "other"
521
- * - navigator is available but userAgent is null or undefined: same as above
522
- */
523
- static getOsAndBrowserInfo() {
524
- return {
525
- osName: this.getOsName(),
526
- browserName: this.getBrowserName()
527
- };
528
- }
529
- static getUserAgent() {
530
- return readUserAgent({
531
- key: "browser-api:device.user-agent",
532
- title: "Browser and operating system",
533
- accessNote: "navigator.userAgent",
534
- group: "Device information",
535
- origin: "observe-external",
536
- fields: [
537
- {
538
- path: "result",
539
- label: "Browser and operating system",
540
- exposure: "class",
541
- transform: {
542
- classes: [
543
- "iOS",
544
- "Android",
545
- "Windows",
546
- "macOS",
547
- "Linux",
548
- "ChromeOS",
549
- "Chrome",
550
- "Firefox",
551
- "Safari",
552
- "Edge",
553
- "Opera",
554
- "IE",
555
- "other"
556
- ]
557
- }
558
- }
559
- ]
560
- });
561
- }
562
- static getOsName() {
563
- const ua = this.getUserAgent();
564
- if (ua.includes("iOS")) {
565
- return "iOS";
566
- } else if (ua.includes("Android")) {
567
- return "Android";
568
- } else if (ua.includes("Windows")) {
569
- return "Windows";
570
- } else if (ua.includes("Macintosh")) {
571
- return "macOS";
572
- } else if (ua.includes("Linux")) {
573
- return "Linux";
574
- } else if (ua.includes("ChromeOS")) {
575
- return "ChromeOS";
576
- }
577
- return "other";
578
- }
579
- static getBrowserName() {
580
- const ua = this.getUserAgent();
581
- if (ua.includes("Chrome")) {
582
- return "Chrome";
583
- } else if (ua.includes("Firefox")) {
584
- return "Firefox";
585
- } else if (ua.includes("Safari")) {
586
- return "Safari";
587
- } else if (ua.includes("Edge")) {
588
- return "Edge";
589
- } else if (ua.includes("Opera")) {
590
- return "Opera";
591
- } else if (ua.includes("IE")) {
592
- return "IE";
593
- }
594
- return "other";
595
- }
596
517
  getClientEnvHandle() {
597
518
  const existingClientEnvHandle = this.storage.getItem("cbo_client_env_handle");
598
519
  if (existingClientEnvHandle) {
@@ -99,7 +99,7 @@ var LowEventGroupedInputTracker = class {
99
99
  this.composing ||= composing;
100
100
  if (this.changeTimer !== null) return;
101
101
  this.changeTime = Date.now();
102
- this.changeSeq = sink.reserveCaptureSeq?.();
102
+ this.changes.observe();
103
103
  this.changeTimer = setTimeout(() => this.safe(() => this.flushChanges()), 0);
104
104
  },
105
105
  focus: (relatedTarget) => {
@@ -174,7 +174,6 @@ var LowEventGroupedInputTracker = class {
174
174
  listeners = [];
175
175
  changeTimer = null;
176
176
  changeTime = 0;
177
- changeSeq;
178
177
  composing = false;
179
178
  destroyed = false;
180
179
  contains(target) {
@@ -214,7 +213,7 @@ var LowEventGroupedInputTracker = class {
214
213
  flushChanges() {
215
214
  if (this.changeTimer === null) return;
216
215
  this.cancelChange();
217
- if (!this.paste.pending) this.changes.record(this.length(), this.changeTime, this.composing, this.changeSeq);
216
+ if (!this.paste.pending) this.changes.record(this.length(), this.changeTime, this.composing);
218
217
  this.composing = false;
219
218
  }
220
219
  emit(lowType) {
@@ -18,9 +18,28 @@ var InputChangeRecorder = class {
18
18
  autofillTimestamps = [];
19
19
  autofillStreak = null;
20
20
  idleTimer = null;
21
+ /** Order of the pending run of changes: reserved when it starts, taken by the first event it sends. */
22
+ runSeq;
21
23
  markInputObserved() {
22
24
  this.prefillSettled = true;
23
25
  }
26
+ /**
27
+ * Observation of a change begins: reserves the capture order of a new run of changes. One number per
28
+ * run, not per change: every reserved number is sent, so the gaps between sent events never count
29
+ * keystrokes (which would reveal the length of the typed value).
30
+ */
31
+ observe() {
32
+ if (this.runSeq !== void 0 || this.inputBatch || this.autofillTimestamps.length > 0 || this.autofillStreak) {
33
+ return;
34
+ }
35
+ this.runSeq = this.reserveCaptureSeq?.();
36
+ }
37
+ /** The run's reserved order for its first event; later events of the run reserve when they are created. */
38
+ takeSeq() {
39
+ const seq = this.runSeq;
40
+ this.runSeq = void 0;
41
+ return seq ?? this.reserveCaptureSeq?.();
42
+ }
24
43
  capturePrefill(length, newCapture = false) {
25
44
  if (newCapture) this.prefillSettled = false;
26
45
  if (this.prefillSettled) return;
@@ -50,17 +69,17 @@ var InputChangeRecorder = class {
50
69
  }
51
70
  }, INPUT_IDLE_MS);
52
71
  }
53
- extendInputBatch(timestamp, effect = this.currentEffect, captureSeq) {
72
+ extendInputBatch(timestamp, effect = this.currentEffect) {
54
73
  const batch = this.inputBatch;
55
74
  if (!batch) {
56
- this.inputBatch = { firstTimestamp: timestamp, lastTimestamp: timestamp, effect, captureSeq };
75
+ this.inputBatch = { firstTimestamp: timestamp, lastTimestamp: timestamp, effect, captureSeq: this.takeSeq() };
57
76
  return;
58
77
  }
59
78
  batch.lastTimestamp = timestamp;
60
79
  }
61
80
  drainAutofillRingToBatch() {
62
81
  for (const ts of this.autofillTimestamps) {
63
- this.extendInputBatch(ts.ts, this.ringEffect, ts.captureSeq);
82
+ this.extendInputBatch(ts, this.ringEffect);
64
83
  }
65
84
  this.autofillTimestamps.length = 0;
66
85
  }
@@ -101,7 +120,7 @@ var InputChangeRecorder = class {
101
120
  let minInterval = Infinity;
102
121
  let maxInterval = -Infinity;
103
122
  for (let i = 1; i < ring.length; i++) {
104
- const interval = ring[i].ts - ring[i - 1].ts;
123
+ const interval = ring[i] - ring[i - 1];
105
124
  if (interval > AUTOFILL_FAST_INTERVAL_MS) {
106
125
  return false;
107
126
  }
@@ -114,7 +133,8 @@ var InputChangeRecorder = class {
114
133
  }
115
134
  return maxInterval - minInterval <= AUTOFILL_UNIFORM_SPREAD_MS;
116
135
  }
117
- record(newLength, timestamp = Date.now(), composing = false, captureSeq = this.reserveCaptureSeq?.()) {
136
+ record(newLength, timestamp = Date.now(), composing = false) {
137
+ this.observe();
118
138
  this.markInputObserved();
119
139
  this.scheduleIdle();
120
140
  const oldLength = this.previousLength;
@@ -123,6 +143,7 @@ var InputChangeRecorder = class {
123
143
  this.previousLength = newLength;
124
144
  if (newLength === 0 && oldLength > 0) {
125
145
  this.flush();
146
+ const captureSeq = this.takeSeq();
126
147
  this.pushLow({
127
148
  ...captureSeq === void 0 ? {} : { captureSeq },
128
149
  lowType: "input-clear",
@@ -134,12 +155,13 @@ var InputChangeRecorder = class {
134
155
  if (composing) {
135
156
  this.flushAutofillStreak();
136
157
  this.drainAutofillRingToBatch();
137
- this.extendInputBatch(timestamp, this.currentEffect, captureSeq);
158
+ this.extendInputBatch(timestamp, this.currentEffect);
138
159
  return;
139
160
  }
140
161
  if (delta >= BIG_INPUT_DELTA_THRESHOLD) {
141
162
  this.flushInputBatch();
142
163
  this.flushAutofillStreak();
164
+ const captureSeq = this.takeSeq();
143
165
  this.pushLow({
144
166
  ...captureSeq === void 0 ? {} : { captureSeq },
145
167
  lowType: "big-input-add",
@@ -151,6 +173,7 @@ var InputChangeRecorder = class {
151
173
  if (delta <= -BIG_INPUT_DELTA_THRESHOLD) {
152
174
  this.flushInputBatch();
153
175
  this.flushAutofillStreak();
176
+ const captureSeq = this.takeSeq();
154
177
  this.pushLow({
155
178
  ...captureSeq === void 0 ? {} : { captureSeq },
156
179
  lowType: "big-input-rem",
@@ -169,15 +192,15 @@ var InputChangeRecorder = class {
169
192
  }
170
193
  if (delta !== 1) {
171
194
  this.drainAutofillRingToBatch();
172
- this.extendInputBatch(timestamp, this.currentEffect, captureSeq);
195
+ this.extendInputBatch(timestamp, this.currentEffect);
173
196
  return;
174
197
  }
175
198
  const ring = this.autofillTimestamps;
176
199
  if (ring.length === 0) this.ringEffect = this.currentEffect;
177
- ring.push({ ts: timestamp, captureSeq });
200
+ ring.push(timestamp);
178
201
  if (ring.length > AUTOFILL_STREAK_LENGTH) {
179
202
  const graduated = ring.shift();
180
- this.extendInputBatch(graduated.ts, this.ringEffect, graduated.captureSeq);
203
+ this.extendInputBatch(graduated, this.ringEffect);
181
204
  this.ringEffect = "modify";
182
205
  }
183
206
  if (!this.detectAutofillStreak()) {
@@ -188,9 +211,9 @@ var InputChangeRecorder = class {
188
211
  ring.length = 0;
189
212
  this.flushInputBatch();
190
213
  this.autofillStreak = {
191
- firstTimestamp: streakStart.ts,
192
- captureSeq: streakStart.captureSeq,
193
- lastTimestamp: streakLast.ts,
214
+ firstTimestamp: streakStart,
215
+ captureSeq: this.takeSeq(),
216
+ lastTimestamp: streakLast,
194
217
  effect: this.ringEffect
195
218
  };
196
219
  }