@aglyn/shared-util-first-touch 1.0.0-beta.186

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,677 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * First-touch capture: where a visitor first arrived from, kept on their
18
+ * device until an account is created and the platform writes it down.
19
+ *
20
+ * ## The one rule everything else follows from
21
+ *
22
+ * A visitor rarely signs up on the page they landed on, and the page they
23
+ * landed on is not always the marketing site. They find a guide on the docs
24
+ * host through a search engine, read the pricing page, then sign up on the
25
+ * console. Three hosts, and only the first one saw where they came from.
26
+ *
27
+ * So every surface the platform serves includes this capture, and every one
28
+ * of them agrees on which referrers are its own. **An internal referrer is
29
+ * never a first touch.** Only an external referrer, or a landing with no
30
+ * referrer at all, starts the record; a hop between two of our own hosts
31
+ * carries the record forward and never replaces it. "docs → pricing →
32
+ * signup" therefore still reports the search engine that started it.
33
+ *
34
+ * ## Where the record lives
35
+ *
36
+ * - **A cookie on the registrable domain** of the surface (`.example.com`),
37
+ * found by asking the browser rather than by carrying a public-suffix list:
38
+ * the broadest domain that accepts a cookie is the registrable one. Every
39
+ * subdomain, and so every console door under it, reads the same value.
40
+ * - **`sessionStorage`**, when the browser refuses the cookie. It dies with
41
+ * the tab and does not cross subdomains, which is the price of a browser
42
+ * that refuses storage.
43
+ * - **Memory**, while the visitor's consent is unresolved or has been refused
44
+ * for this surface. Nothing is written to the device. A link to another of
45
+ * our hosts can still carry the record, sealed, in its URL (below).
46
+ *
47
+ * The cookie is re-written on every load with a fresh lifetime, because some
48
+ * browsers cap the life of a script-written cookie at a week of inactivity;
49
+ * the VALUE never changes once set.
50
+ *
51
+ * ## Crossing to a host the cookie cannot reach
52
+ *
53
+ * A surface on a different registrable domain, or any hop made while the
54
+ * record is held in memory, cannot read the cookie. For those links the
55
+ * capture asks the platform to SEAL the record (an HMAC over it and an
56
+ * expiry, signed with a secret no page holds) and appends the sealed token as
57
+ * `_ft` at the moment the link is followed. The receiving surface strips the
58
+ * parameter from its address bar at once, asks the platform to OPEN it, and
59
+ * adopts what comes back. Adoption is a merge, and the merge keeps the
60
+ * earlier record, so replaying a token can never move a first touch later.
61
+ *
62
+ * What the seal proves is narrow and worth stating exactly: that this install
63
+ * produced the record, recently, and nobody edited it on the way. It does not
64
+ * prove the record is TRUE — a visitor can put any `utm_*` they like on a URL
65
+ * and always could. Attribution is a label, and it grants nothing.
66
+ *
67
+ * ## What is never recorded
68
+ *
69
+ * No identifier. The record says how a visit began, and two visitors who
70
+ * arrived the same way carry the same record give or take a timestamp. Click
71
+ * ids are kept as PRESENCE only — "this arrived from a paid click" — and their
72
+ * values never leave the URL they came on. Referrers are kept as a HOST, never
73
+ * a path, because a referring path can carry a search query or an account
74
+ * page. `utm_*` values are trimmed, refused when shaped like an email
75
+ * address, and capped, the same scrub the signup campaign parser applies.
76
+ *
77
+ * ## Why the whole runtime is one function
78
+ *
79
+ * The same code must run as a module a Next app imports and as a script tag
80
+ * on a page no bundler touches (a hosted forum, a status page). So the
81
+ * runtime is ONE self-contained function, {@link createFirstTouchKit}, that
82
+ * references nothing outside itself: the served script is that function's own
83
+ * source text followed by a call to it. The constraint that keeps it working
84
+ * is the same one `next-themes` lives under — no imports, no module-level
85
+ * values, and no syntax a compiler would lower into a shared helper (object
86
+ * spread, classes, `async`). `first-touch-script.spec.ts` runs the
87
+ * stringified function in a bare context to hold that.
88
+ */ /** Click identifiers whose PRESENCE the record keeps; their values never leave the URL. */ export const FIRST_TOUCH_CLICK_IDS = [
89
+ 'gclid',
90
+ 'fbclid',
91
+ 'msclkid'
92
+ ];
93
+ /** The `utm_*` parameters the record keeps, named without their prefix. */ export const FIRST_TOUCH_UTM_KEYS = [
94
+ 'source',
95
+ 'medium',
96
+ 'campaign',
97
+ 'content',
98
+ 'term'
99
+ ];
100
+ /** The cookie the record is kept in, on the surface's registrable domain. */ export const FIRST_TOUCH_COOKIE = 'aglyn_ft';
101
+ /** The query parameter a sealed hand-off rides on. */ export const FIRST_TOUCH_HANDOFF_PARAM = '_ft';
102
+ /**
103
+ * Build the capture. Self-contained by construction: nothing in the body may
104
+ * reference a value declared outside it (types are erased and do not count).
105
+ */ export function createFirstTouchKit() {
106
+ const CLICK_IDS = [
107
+ 'gclid',
108
+ 'fbclid',
109
+ 'msclkid'
110
+ ];
111
+ const UTM_KEYS = [
112
+ 'source',
113
+ 'medium',
114
+ 'campaign',
115
+ 'content',
116
+ 'term'
117
+ ];
118
+ const COOKIE = 'aglyn_ft';
119
+ const PROBE = 'aglyn_ft_probe';
120
+ const SESSION_KEY = 'aglyn:first-touch';
121
+ const GLOBAL_KEY = '__aglynFirstTouch';
122
+ const PARAM = '_ft';
123
+ // A visit worth attributing can take months between the first read and the
124
+ // signup; the value is refreshed on every visit, so this bounds inactivity.
125
+ const MAX_AGE_SECONDS = 180 * 24 * 60 * 60;
126
+ const MAX_VALUE = 100;
127
+ const MAX_PATH = 200;
128
+ const MAX_HOST = 253;
129
+ const MAX_TOKEN = 4096;
130
+ const FUTURE_SKEW_MS = 24 * 60 * 60 * 1000;
131
+ const TOKEN_MARGIN_MS = 60 * 1000;
132
+ const EMAIL_SHAPED = /[^\s@]+@[^\s@]+\.[^\s@]+/;
133
+ const HOST_SHAPE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
134
+ // Control characters are exactly what this pattern exists to find: a
135
+ // `utm_*` value is refused its line breaks, NULs and escapes, not its text.
136
+ // eslint-disable-next-line no-control-regex
137
+ const CONTROL = /[\u0000-\u001f\u007f]/g;
138
+ const state = {
139
+ booted: false,
140
+ hosts: [],
141
+ storage: true,
142
+ handoffUrl: '',
143
+ touch: null,
144
+ tier: null,
145
+ domain: undefined,
146
+ token: null,
147
+ sealing: false
148
+ };
149
+ function normalizeHost(value) {
150
+ if (typeof value !== 'string') return '';
151
+ let host = value.trim().toLowerCase();
152
+ if (host.charAt(host.length - 1) === '.') host = host.slice(0, -1);
153
+ if (!host || host.length > MAX_HOST || !HOST_SHAPE.test(host)) return '';
154
+ return host;
155
+ }
156
+ function normalizePattern(value) {
157
+ if (typeof value !== 'string') return '';
158
+ let raw = value.trim().toLowerCase();
159
+ const negated = raw.charAt(0) === '!';
160
+ if (negated) raw = raw.slice(1).trim();
161
+ const base = raw.indexOf('*.') === 0 ? normalizeHost(raw.slice(2)) : '';
162
+ const pattern = base ? '*.' + base : raw.indexOf('*.') === 0 ? '' : normalizeHost(raw);
163
+ return pattern && negated ? '!' + pattern : pattern;
164
+ }
165
+ function patternMatches(host, pattern) {
166
+ if (pattern.indexOf('*.') !== 0) return host === pattern;
167
+ const suffix = pattern.slice(1);
168
+ return host.length > suffix.length && host.slice(-suffix.length) === suffix;
169
+ }
170
+ function normalizeHostList(hosts) {
171
+ const out = [];
172
+ if (!hosts || typeof hosts.length !== 'number') return out;
173
+ const list = hosts;
174
+ for(let i = 0; i < list.length; i++){
175
+ const pattern = normalizePattern(list[i]);
176
+ if (pattern && out.indexOf(pattern) < 0) out.push(pattern);
177
+ }
178
+ return out;
179
+ }
180
+ function isFirstPartyHost(host, hosts) {
181
+ const bare = normalizeHost(host);
182
+ if (!bare) return false;
183
+ const patterns = normalizeHostList(hosts);
184
+ let included = false;
185
+ for(let i = 0; i < patterns.length; i++){
186
+ const pattern = patterns[i];
187
+ if (pattern.charAt(0) === '!') {
188
+ if (patternMatches(bare, pattern.slice(1))) return false;
189
+ } else if (!included && patternMatches(bare, pattern)) {
190
+ included = true;
191
+ }
192
+ }
193
+ return included;
194
+ }
195
+ function scrub(value) {
196
+ if (typeof value !== 'string') return '';
197
+ const clean = value.replace(CONTROL, '').trim();
198
+ if (!clean || EMAIL_SHAPED.test(clean)) return '';
199
+ return clean.slice(0, MAX_VALUE);
200
+ }
201
+ function scrubPath(value) {
202
+ if (typeof value !== 'string') return '/';
203
+ let clean = value.replace(CONTROL, '');
204
+ const cut = clean.search(/[?#]/);
205
+ if (cut >= 0) clean = clean.slice(0, cut);
206
+ if (clean.charAt(0) !== '/') clean = '/' + clean;
207
+ return clean.slice(0, MAX_PATH);
208
+ }
209
+ function parseUrl(value, base) {
210
+ if (typeof value !== 'string' || !value) return null;
211
+ try {
212
+ const url = base ? new URL(value, base) : new URL(value);
213
+ return url.protocol === 'http:' || url.protocol === 'https:' ? url : null;
214
+ } catch (unused) {
215
+ return null;
216
+ }
217
+ }
218
+ function readUtm(params) {
219
+ const utm = {};
220
+ let found = false;
221
+ for(let i = 0; i < UTM_KEYS.length; i++){
222
+ const value = scrub(params.get('utm_' + UTM_KEYS[i]));
223
+ if (value) {
224
+ utm[UTM_KEYS[i]] = value;
225
+ found = true;
226
+ }
227
+ }
228
+ return found ? utm : null;
229
+ }
230
+ function readClickIds(params) {
231
+ const present = [];
232
+ for(let i = 0; i < CLICK_IDS.length; i++){
233
+ const value = params.get(CLICK_IDS[i]);
234
+ if (value && value.trim()) present.push(CLICK_IDS[i]);
235
+ }
236
+ return present;
237
+ }
238
+ function buildFirstTouch(landing) {
239
+ if (!landing) return null;
240
+ const url = parseUrl(landing.href);
241
+ const host = url ? normalizeHost(url.hostname) : '';
242
+ if (!url || !host) return null;
243
+ const at = typeof landing.now === 'number' && landing.now > 0 ? landing.now : 0;
244
+ if (!at) return null;
245
+ const touch = {
246
+ v: 1,
247
+ at: at,
248
+ host: host,
249
+ path: scrubPath(url.pathname),
250
+ ref: null
251
+ };
252
+ const referrer = parseUrl(landing.referrer);
253
+ const refHost = referrer ? normalizeHost(referrer.hostname) : '';
254
+ if (refHost && refHost !== host) {
255
+ if (isFirstPartyHost(refHost, landing.hosts)) touch.via = refHost;
256
+ else touch.ref = refHost;
257
+ }
258
+ const utm = readUtm(url.searchParams);
259
+ if (utm) touch.utm = utm;
260
+ const click = readClickIds(url.searchParams);
261
+ if (click.length) touch.click = click;
262
+ return touch;
263
+ }
264
+ function sanitizeFirstTouch(value, now) {
265
+ if (!value || typeof value !== 'object') return null;
266
+ const raw = value;
267
+ if (raw['v'] !== 1) return null;
268
+ const at = raw['at'];
269
+ if (typeof at !== 'number' || !isFinite(at) || at <= 0) return null;
270
+ if (typeof now === 'number' && at > now + FUTURE_SKEW_MS) return null;
271
+ const host = normalizeHost(raw['host']);
272
+ if (!host) return null;
273
+ const touch = {
274
+ v: 1,
275
+ at: Math.floor(at),
276
+ host: host,
277
+ path: scrubPath(raw['path']),
278
+ ref: normalizeHost(raw['ref']) || null
279
+ };
280
+ const via = normalizeHost(raw['via']);
281
+ if (via) touch.via = via;
282
+ const rawUtm = raw['utm'];
283
+ if (rawUtm && typeof rawUtm === 'object') {
284
+ const utm = {};
285
+ let found = false;
286
+ for(let i = 0; i < UTM_KEYS.length; i++){
287
+ const utmValue = scrub(rawUtm[UTM_KEYS[i]]);
288
+ if (utmValue) {
289
+ utm[UTM_KEYS[i]] = utmValue;
290
+ found = true;
291
+ }
292
+ }
293
+ if (found) touch.utm = utm;
294
+ }
295
+ const rawClick = raw['click'];
296
+ if (rawClick && typeof rawClick.length === 'number') {
297
+ const click = [];
298
+ for(let j = 0; j < CLICK_IDS.length; j++){
299
+ if (rawClick.indexOf(CLICK_IDS[j]) >= 0) {
300
+ click.push(CLICK_IDS[j]);
301
+ }
302
+ }
303
+ if (click.length) touch.click = click;
304
+ }
305
+ return touch;
306
+ }
307
+ function mergeFirstTouch(a, b) {
308
+ if (!a) return b || null;
309
+ if (!b) return a;
310
+ return b.at < a.at ? b : a;
311
+ }
312
+ function encodeFirstTouch(touch) {
313
+ return encodeURIComponent(JSON.stringify(touch));
314
+ }
315
+ function decodeFirstTouch(value) {
316
+ if (typeof value !== 'string' || !value) return null;
317
+ try {
318
+ return sanitizeFirstTouch(JSON.parse(decodeURIComponent(value)));
319
+ } catch (unused) {
320
+ return null;
321
+ }
322
+ }
323
+ function cookieValue(header, name) {
324
+ if (typeof header !== 'string' || !header) return '';
325
+ const parts = header.split(';');
326
+ for(let i = 0; i < parts.length; i++){
327
+ const part = parts[i].trim();
328
+ if (part.indexOf(name + '=') === 0) return part.slice(name.length + 1);
329
+ }
330
+ return '';
331
+ }
332
+ function readFirstTouchCookie(cookieHeader) {
333
+ return decodeFirstTouch(cookieValue(cookieHeader, COOKIE));
334
+ }
335
+ // Never `typeof window`, or `typeof` of any host global. This kit reaches
336
+ // the browser as the text of a function compiled into a SERVER bundle, and a
337
+ // server compile may settle those checks in advance: Next's replaces
338
+ // `typeof window` with "undefined", after which the minifier drops every DOM
339
+ // path as dead code. Reading the global is something no compiler can decide:
340
+ // it throws where the global is missing and is an object where it is not.
341
+ function hasDom() {
342
+ try {
343
+ return Boolean(document && location);
344
+ } catch (unused) {
345
+ return false;
346
+ }
347
+ }
348
+ function hasFetch() {
349
+ try {
350
+ return Boolean(fetch);
351
+ } catch (unused) {
352
+ return false;
353
+ }
354
+ }
355
+ function secureSuffix() {
356
+ return location.protocol === 'https:' ? '; Secure' : '';
357
+ }
358
+ function cookieDomain() {
359
+ if (state.domain !== undefined) return state.domain;
360
+ const host = location.hostname;
361
+ let found = '';
362
+ if (host && host.indexOf('.') > 0 && !/^[\d.]+$/.test(host)) {
363
+ const labels = host.split('.');
364
+ for(let i = labels.length - 2; i >= 0 && !found; i--){
365
+ const candidate = labels.slice(i).join('.');
366
+ try {
367
+ document.cookie = PROBE + '=1; Path=/; Domain=' + candidate + '; SameSite=Lax' + secureSuffix();
368
+ if (cookieValue(document.cookie, PROBE) === '1') {
369
+ found = candidate;
370
+ document.cookie = PROBE + '=; Path=/; Domain=' + candidate + '; Max-Age=0' + secureSuffix();
371
+ }
372
+ } catch (unused) {
373
+ break;
374
+ }
375
+ }
376
+ }
377
+ state.domain = found;
378
+ return found;
379
+ }
380
+ function writeCookie(touch) {
381
+ try {
382
+ const domain = cookieDomain();
383
+ const encoded = encodeFirstTouch(touch);
384
+ document.cookie = COOKIE + '=' + encoded + '; Path=/' + (domain ? '; Domain=' + domain : '') + '; Max-Age=' + MAX_AGE_SECONDS + '; SameSite=Lax' + secureSuffix();
385
+ return cookieValue(document.cookie, COOKIE) === encoded;
386
+ } catch (unused) {
387
+ return false;
388
+ }
389
+ }
390
+ function sessionStore() {
391
+ try {
392
+ return window.sessionStorage || null;
393
+ } catch (unused) {
394
+ return null;
395
+ }
396
+ }
397
+ function writeSession(touch) {
398
+ const store = sessionStore();
399
+ if (!store) return false;
400
+ try {
401
+ store.setItem(SESSION_KEY, JSON.stringify(touch));
402
+ return true;
403
+ } catch (unused) {
404
+ return false;
405
+ }
406
+ }
407
+ function readSession() {
408
+ const store = sessionStore();
409
+ if (!store) return null;
410
+ try {
411
+ const raw = store.getItem(SESSION_KEY);
412
+ return raw ? sanitizeFirstTouch(JSON.parse(raw)) : null;
413
+ } catch (unused) {
414
+ return null;
415
+ }
416
+ }
417
+ function eraseStored() {
418
+ try {
419
+ const domain = cookieDomain();
420
+ document.cookie = COOKIE + '=; Path=/; Max-Age=0' + secureSuffix();
421
+ if (domain) {
422
+ document.cookie = COOKIE + '=; Path=/; Domain=' + domain + '; Max-Age=0' + secureSuffix();
423
+ }
424
+ } catch (unused) {
425
+ // A cookie the browser will not let us touch is one it did not keep.
426
+ }
427
+ const store = sessionStore();
428
+ if (store) {
429
+ try {
430
+ store.removeItem(SESSION_KEY);
431
+ } catch (unused) {
432
+ // Same: an unreadable store holds nothing to erase.
433
+ }
434
+ }
435
+ }
436
+ function exposed() {
437
+ try {
438
+ return sanitizeFirstTouch(window[GLOBAL_KEY]);
439
+ } catch (unused) {
440
+ return null;
441
+ }
442
+ }
443
+ function expose(touch) {
444
+ try {
445
+ ;
446
+ window[GLOBAL_KEY] = touch;
447
+ } catch (unused) {
448
+ // A frozen window costs a second reader on this page, never the capture.
449
+ }
450
+ }
451
+ function readCookie() {
452
+ try {
453
+ return decodeFirstTouch(cookieValue(document.cookie, COOKIE));
454
+ } catch (unused) {
455
+ return null;
456
+ }
457
+ }
458
+ function loadStored() {
459
+ return mergeFirstTouch(mergeFirstTouch(readCookie(), readSession()), exposed());
460
+ }
461
+ function save() {
462
+ const touch = state.touch;
463
+ expose(touch);
464
+ if (!touch) {
465
+ state.tier = null;
466
+ return;
467
+ }
468
+ if (state.storage !== true) state.tier = 'memory';
469
+ else if (writeCookie(touch)) state.tier = 'cookie';
470
+ else if (writeSession(touch)) state.tier = 'session';
471
+ else state.tier = 'memory';
472
+ }
473
+ function adopt(touch) {
474
+ const next = mergeFirstTouch(state.touch, touch);
475
+ if (next !== state.touch) {
476
+ state.touch = next;
477
+ state.token = null;
478
+ }
479
+ save();
480
+ }
481
+ function takeHandoffToken() {
482
+ try {
483
+ const params = new URLSearchParams(location.search);
484
+ const token = params.get(PARAM) || '';
485
+ if (!token) return '';
486
+ params.delete(PARAM);
487
+ const search = params.toString();
488
+ history.replaceState(history.state, '', location.pathname + (search ? '?' + search : '') + location.hash);
489
+ return token.length <= MAX_TOKEN ? token : '';
490
+ } catch (unused) {
491
+ return '';
492
+ }
493
+ }
494
+ function post(body) {
495
+ if (!state.handoffUrl || !hasFetch()) return Promise.resolve(null);
496
+ return fetch(state.handoffUrl, {
497
+ method: 'POST',
498
+ body: JSON.stringify(body),
499
+ headers: {
500
+ 'content-type': 'text/plain;charset=UTF-8'
501
+ },
502
+ credentials: 'omit',
503
+ keepalive: true
504
+ }).then(function(response) {
505
+ return response.ok ? response.json() : null;
506
+ }).catch(function() {
507
+ return null;
508
+ });
509
+ }
510
+ function tokenIsFresh() {
511
+ return Boolean(state.token && Date.now() < state.token.exp - TOKEN_MARGIN_MS);
512
+ }
513
+ function requestToken() {
514
+ if (!state.touch || state.sealing || tokenIsFresh()) return;
515
+ state.sealing = true;
516
+ const sealed = state.touch;
517
+ post({
518
+ seal: sealed
519
+ }).then(function(result) {
520
+ state.sealing = false;
521
+ if (result && typeof result['token'] === 'string' && typeof result['exp'] === 'number' && state.touch === sealed) {
522
+ state.token = {
523
+ value: result['token'],
524
+ exp: result['exp']
525
+ };
526
+ }
527
+ });
528
+ }
529
+ function underCookieDomain(host) {
530
+ const domain = state.domain;
531
+ if (!domain) return false;
532
+ return host === domain || host.slice(-(domain.length + 1)) === '.' + domain;
533
+ }
534
+ function handoffTarget(node) {
535
+ const element = node;
536
+ if (!element || typeof element.closest !== 'function') return null;
537
+ const anchor = element.closest('a[href]');
538
+ if (!anchor) return null;
539
+ const url = parseUrl(anchor.getAttribute('href') || '', location.href);
540
+ if (!url) return null;
541
+ const host = normalizeHost(url.hostname);
542
+ if (!host || host === normalizeHost(location.hostname)) return null;
543
+ if (!isFirstPartyHost(host, state.hosts)) return null;
544
+ if (state.tier === 'cookie' && underCookieDomain(host)) return null;
545
+ return {
546
+ anchor: anchor,
547
+ url: url
548
+ };
549
+ }
550
+ function installDecoration() {
551
+ const onIntent = function(event) {
552
+ try {
553
+ if (handoffTarget(event.target)) requestToken();
554
+ } catch (unused) {
555
+ // Attribution never costs a navigation.
556
+ }
557
+ };
558
+ const onActivate = function(event) {
559
+ try {
560
+ const target = handoffTarget(event.target);
561
+ if (!target) return;
562
+ if (!tokenIsFresh()) {
563
+ requestToken();
564
+ return;
565
+ }
566
+ target.url.searchParams.set(PARAM, state.token.value);
567
+ target.anchor.setAttribute('href', target.url.toString());
568
+ } catch (unused) {
569
+ // Same: a link that throws here would be a link that does not work.
570
+ }
571
+ };
572
+ document.addEventListener('pointerover', onIntent, true);
573
+ document.addEventListener('focusin', onIntent, true);
574
+ document.addEventListener('pointerdown', onActivate, true);
575
+ document.addEventListener('click', onActivate, true);
576
+ const links = document.links;
577
+ for(let i = 0; i < links.length; i++){
578
+ if (handoffTarget(links[i])) {
579
+ requestToken();
580
+ break;
581
+ }
582
+ }
583
+ }
584
+ function configure(config) {
585
+ const input = config || {};
586
+ state.hosts = normalizeHostList(input.hosts);
587
+ state.storage = input.storage === false ? false : input.storage === null ? null : true;
588
+ const url = typeof input.handoffUrl === 'string' ? input.handoffUrl.trim() : '';
589
+ state.handoffUrl = url.charAt(0) === '/' && url.charAt(1) !== '/' ? url : parseUrl(url) ? url : '';
590
+ }
591
+ const runtime = {
592
+ read: function() {
593
+ return state.touch;
594
+ },
595
+ setStorage: function(allowed) {
596
+ const next = allowed === true ? true : allowed === false ? false : null;
597
+ const granted = next === true && state.storage !== true;
598
+ state.storage = next;
599
+ if (!hasDom()) return;
600
+ if (next === false) {
601
+ eraseStored();
602
+ state.tier = state.touch ? 'memory' : null;
603
+ return;
604
+ }
605
+ if (granted) {
606
+ state.touch = mergeFirstTouch(loadStored(), state.touch);
607
+ save();
608
+ }
609
+ },
610
+ tier: function() {
611
+ return state.tier;
612
+ }
613
+ };
614
+ function boot(config) {
615
+ const hadHandoff = Boolean(state.handoffUrl);
616
+ configure(config);
617
+ if (!hasDom()) return runtime;
618
+ if (state.booted) {
619
+ if (!hadHandoff && state.handoffUrl) installDecoration();
620
+ return runtime;
621
+ }
622
+ // Only a host the install names as its own captures. A copy of the tag
623
+ // pasted onto anybody else's page records nothing and decorates nothing.
624
+ if (!isFirstPartyHost(location.hostname, state.hosts)) return runtime;
625
+ state.booted = true;
626
+ const token = takeHandoffToken();
627
+ const current = buildFirstTouch({
628
+ href: location.href,
629
+ referrer: document.referrer,
630
+ hosts: state.hosts,
631
+ now: Date.now()
632
+ });
633
+ state.touch = mergeFirstTouch(loadStored(), current);
634
+ save();
635
+ if (token && state.handoffUrl) {
636
+ post({
637
+ open: token
638
+ }).then(function(result) {
639
+ adopt(sanitizeFirstTouch(result && result['touch'], Date.now()));
640
+ });
641
+ }
642
+ if (state.handoffUrl) installDecoration();
643
+ return runtime;
644
+ }
645
+ function read() {
646
+ if (state.touch) return state.touch;
647
+ return hasDom() ? loadStored() : null;
648
+ }
649
+ return {
650
+ normalizeHost: normalizeHost,
651
+ isFirstPartyHost: isFirstPartyHost,
652
+ buildFirstTouch: buildFirstTouch,
653
+ sanitizeFirstTouch: sanitizeFirstTouch,
654
+ mergeFirstTouch: mergeFirstTouch,
655
+ encodeFirstTouch: encodeFirstTouch,
656
+ decodeFirstTouch: decodeFirstTouch,
657
+ readFirstTouchCookie: readFirstTouchCookie,
658
+ boot: boot,
659
+ read: read
660
+ };
661
+ }
662
+ /**
663
+ * The page's one capture. A bundle that imports this module shares it, so a
664
+ * surface that boots it and a form that reads it see the same record.
665
+ */ const kit = createFirstTouchKit();
666
+ export const normalizeFirstTouchHost = kit.normalizeHost;
667
+ export const isFirstPartyHost = kit.isFirstPartyHost;
668
+ export const buildFirstTouch = kit.buildFirstTouch;
669
+ export const sanitizeFirstTouch = kit.sanitizeFirstTouch;
670
+ export const mergeFirstTouch = kit.mergeFirstTouch;
671
+ export const encodeFirstTouch = kit.encodeFirstTouch;
672
+ export const decodeFirstTouch = kit.decodeFirstTouch;
673
+ export const readFirstTouchCookie = kit.readFirstTouchCookie;
674
+ export const bootFirstTouch = kit.boot;
675
+ export const readFirstTouch = kit.read;
676
+
677
+ //# sourceMappingURL=first-touch.js.map