@tokenoftrust/storefront-runner 2.2.69 → 2.2.70
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/package.json +1 -1
- package/packages/public-runtime/src/integration/consent-broker.ts +11 -0
- package/packages/public-runtime/src/integration/lifecycle-boot.ts +10 -0
- package/packages/public-runtime/src/integration/native-amplitude.ts +201 -14
- package/packages/public-runtime/src/integration/platform-adapter-runtime.ts +9 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.70",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|
|
@@ -507,6 +507,7 @@ interface ViewerConsentWindow {
|
|
|
507
507
|
localStorage?: {
|
|
508
508
|
getItem(key: string): string | null;
|
|
509
509
|
setItem(key: string, value: string): void;
|
|
510
|
+
removeItem?(key: string): void;
|
|
510
511
|
};
|
|
511
512
|
document?: Document;
|
|
512
513
|
addEventListener?: (type: string, listener: (event: { key?: string | null }) => void) => void;
|
|
@@ -649,6 +650,16 @@ export function applyEarlyViewerAnalyticsConsent(
|
|
|
649
650
|
: rawEligible.includes(purpose)),
|
|
650
651
|
);
|
|
651
652
|
const approved = canonicalPurposes.filter((purpose) => rawApproved.includes(purpose));
|
|
653
|
+
// Analytics ineligible here (denial, GPC, tenant not approved) forgets this context's
|
|
654
|
+
// Amplitude visitor id and session before any adapter can run. The key is the
|
|
655
|
+
// native-amplitude.ts VISITOR_CONTINUITY_STORAGE_KEY prefix over the same contextKey.
|
|
656
|
+
if (!eligible.includes("analytics")) {
|
|
657
|
+
try {
|
|
658
|
+
win.localStorage?.removeItem?.(`tot.analytics-continuity.v1:${encodeURIComponent(contextKey)}`);
|
|
659
|
+
} catch {
|
|
660
|
+
// Storage denial leaves nothing readable to clear.
|
|
661
|
+
}
|
|
662
|
+
}
|
|
652
663
|
win[consentGlobal] = Object.freeze({
|
|
653
664
|
schemaVersion: 2,
|
|
654
665
|
snapshotId: snapshotIdFor(eligible, approved),
|
|
@@ -42,6 +42,7 @@ import type { NavigationCompletedEvent, NavigationKind } from "./events.js";
|
|
|
42
42
|
import { CommerceEventBroker } from "./commerce-broker.js";
|
|
43
43
|
import { validateBrowserEnvironment, BROWSER_ENVIRONMENT_GLOBAL, type BrowserEnvironment } from "./environment.js";
|
|
44
44
|
import { makePlatformAdapterRuntimeLoader } from "./platform-adapter-runtime.js";
|
|
45
|
+
import { createVisitorContinuity, type VisitorContinuityStorage } from "./native-amplitude.js";
|
|
45
46
|
import { resolveNativeGa4SandboxUrl, type NativeGa4Transport } from "./native-ga4.js";
|
|
46
47
|
import {
|
|
47
48
|
createBrowserNativeGa4SandboxHost,
|
|
@@ -138,6 +139,11 @@ export interface BootLifecycleOptions {
|
|
|
138
139
|
* queued in the transport. Injectable so a test can hold the mount.
|
|
139
140
|
*/
|
|
140
141
|
scheduleGa4Sandbox?: NativeGa4SandboxScheduler;
|
|
142
|
+
/**
|
|
143
|
+
* The persistent store behind the Amplitude visitor id and session; defaults to the
|
|
144
|
+
* window's origin storage. Read lazily and only while analytics is eligible.
|
|
145
|
+
*/
|
|
146
|
+
visitorStorage?: () => VisitorContinuityStorage | undefined;
|
|
141
147
|
}
|
|
142
148
|
|
|
143
149
|
/**
|
|
@@ -431,6 +437,10 @@ export function bootIntegrationLifecycle(
|
|
|
431
437
|
fetchCapability: hostFetch ? deferFetchCapability(hostFetch, schedulePlatformAdapterStart) : hostFetch,
|
|
432
438
|
importModule: options.importModule,
|
|
433
439
|
fallback: ordinaryLoader,
|
|
440
|
+
visitorContinuity: createVisitorContinuity({
|
|
441
|
+
storage: options.visitorStorage ??
|
|
442
|
+
(() => win["localStorage"] as VisitorContinuityStorage | undefined),
|
|
443
|
+
}),
|
|
434
444
|
nativeGa4Transport: () => {
|
|
435
445
|
if (options.nativeGa4Transport) return options.nativeGa4Transport;
|
|
436
446
|
if (typeof document === "undefined" || typeof MessageChannel === "undefined") {
|
|
@@ -2,10 +2,22 @@
|
|
|
2
2
|
* Storefront-owned Amplitude HTTP API v2 adapter.
|
|
3
3
|
*
|
|
4
4
|
* This module intentionally uses no vendor SDK or browser discovery surface. It
|
|
5
|
-
* consumes only validated commerce-broker events and sends a closed
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* consumes only validated commerce-broker events and sends a closed payload to one
|
|
6
|
+
* exact collection endpoint.
|
|
7
|
+
*
|
|
8
|
+
* VISITOR CONTINUITY. `device_id` is a pseudonymous browser id and `session_id` the
|
|
9
|
+
* epoch-ms start of a 30-minute-inactivity session, both from `VisitorContinuity`
|
|
10
|
+
* below. The id is a random UUID — never derived from a person, account, network
|
|
11
|
+
* address, or device signal, never joined to identity-verification records — with a
|
|
12
|
+
* fixed 90-day lifetime (no sliding renewal). It is scoped to one tenant, environment
|
|
13
|
+
* and site surface, so two tenants sharing an origin get independent, unlinkable ids.
|
|
14
|
+
* It is read or minted only while analytics is eligible; an update into ineligibility
|
|
15
|
+
* clears it, and the renderer's early consent boot clears it on every load where
|
|
16
|
+
* analytics is ineligible (denial, Global Privacy Control, tenant not approved).
|
|
17
|
+
* Storage is injected by the host and never touched by name here; when it is
|
|
18
|
+
* unavailable the id lives in memory for this document only and the event carries
|
|
19
|
+
* no session (`NO_SESSION_ID`), overcounting visitors rather than failing the event.
|
|
20
|
+
* There is no `user_id`.
|
|
9
21
|
*
|
|
10
22
|
* Page views also carry channel ATTRIBUTION (UTM tokens + the referrer's origin)
|
|
11
23
|
* and the page LOCATION (`page_hostname` / `page_url`, from the server-stamped
|
|
@@ -14,10 +26,7 @@
|
|
|
14
26
|
* mappings apply unchanged. These are labels about the page and the campaign, not
|
|
15
27
|
* about the visitor: attribution is minimized and validated upstream
|
|
16
28
|
* (`events.ts` `validatePageAttribution`) and re-screened for PII here, and the
|
|
17
|
-
* host never comes from the browser.
|
|
18
|
-
* it stays out, on purpose: `device_id` remains the fixed, non-identifying value
|
|
19
|
-
* below and there is still no `session_id`, `user_id`, or any persisted client
|
|
20
|
-
* value anywhere in this file.
|
|
29
|
+
* host never comes from the browser.
|
|
21
30
|
*/
|
|
22
31
|
import type { CommerceEventBroker, CommerceEventSubscription } from "./commerce-broker.js";
|
|
23
32
|
import {
|
|
@@ -52,7 +61,18 @@ export const AMPLITUDE_COLLECTION_URL = `${AMPLITUDE_COLLECTION_ORIGIN}/2/httpap
|
|
|
52
61
|
export const AMPLITUDE_HTTP_ENTRY_URL = "/platform/amplitude-http/v1/entry.mjs" as const;
|
|
53
62
|
export const AMPLITUDE_HTTP_ADAPTER_VERSION = "1.0.0" as const;
|
|
54
63
|
export const AMPLITUDE_HTTP_RUNTIME_ID = "native-amplitude-http-v1" as const;
|
|
64
|
+
/** Sent only when the browser offers no randomness to mint a visitor id from. */
|
|
55
65
|
const ANONYMOUS_DEVICE_ID = "storefront-anonymous" as const;
|
|
66
|
+
/** Amplitude's documented "not in a session" value. */
|
|
67
|
+
export const NO_SESSION_ID = -1 as const;
|
|
68
|
+
/**
|
|
69
|
+
* Storage key prefix for the visitor record. The early consent boot
|
|
70
|
+
* (`consent-broker.ts` `applyEarlyViewerAnalyticsConsent`) removes the same key as a
|
|
71
|
+
* literal; a test pins the two together.
|
|
72
|
+
*/
|
|
73
|
+
export const VISITOR_CONTINUITY_STORAGE_KEY = "tot.analytics-continuity.v1" as const;
|
|
74
|
+
export const VISITOR_ID_LIFETIME_MS = 90 * 24 * 60 * 60 * 1000;
|
|
75
|
+
export const SESSION_INACTIVITY_MS = 30 * 60 * 1000;
|
|
56
76
|
export const AMPLITUDE_HTTP_EVENT_TYPES = Object.freeze([
|
|
57
77
|
"storefront.page.viewed.v1",
|
|
58
78
|
"storefront.page.viewed.v2",
|
|
@@ -79,7 +99,10 @@ export interface AmplitudePageLocationProperties {
|
|
|
79
99
|
|
|
80
100
|
export interface AmplitudeHttpEvent {
|
|
81
101
|
event_type: "page_view" | (typeof APPROVED_MARKETING_ACTION_NAMES)[number];
|
|
82
|
-
|
|
102
|
+
/** Pseudonymous visitor UUID, or the anonymous value when no randomness exists. */
|
|
103
|
+
device_id: string;
|
|
104
|
+
/** Session start, epoch milliseconds, or `NO_SESSION_ID`. */
|
|
105
|
+
session_id: number;
|
|
83
106
|
insert_id: string;
|
|
84
107
|
/** Occurrence time, epoch milliseconds. */
|
|
85
108
|
time: number;
|
|
@@ -134,6 +157,141 @@ class BoundedInsertIds {
|
|
|
134
157
|
}
|
|
135
158
|
}
|
|
136
159
|
|
|
160
|
+
/** The host-supplied persistent store; the browser host passes its own origin storage. */
|
|
161
|
+
export interface VisitorContinuityStorage {
|
|
162
|
+
getItem(key: string): string | null;
|
|
163
|
+
setItem(key: string, value: string): void;
|
|
164
|
+
removeItem(key: string): void;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The consent context a visitor id belongs to: the consent broker's `contextKey` triple. */
|
|
168
|
+
export interface VisitorContinuityScope {
|
|
169
|
+
tenantPublicId: string;
|
|
170
|
+
environment: string;
|
|
171
|
+
siteSurface: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface VisitorContinuityIds {
|
|
175
|
+
deviceId: string;
|
|
176
|
+
sessionId: number;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface VisitorContinuity {
|
|
180
|
+
/** Read the scope's visitor, minting or rolling it as needed, and record activity at `now`. */
|
|
181
|
+
readOrMint(scope: VisitorContinuityScope, now: number): VisitorContinuityIds;
|
|
182
|
+
/** Forget the scope's visitor, persisted and in memory. */
|
|
183
|
+
clear(scope: VisitorContinuityScope): void;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface CreateVisitorContinuityOptions {
|
|
187
|
+
/** Absent, or returning undefined, keeps the visitor in memory for this document only. */
|
|
188
|
+
storage?: () => VisitorContinuityStorage | undefined;
|
|
189
|
+
randomUUID?: () => string;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
interface VisitorRecord {
|
|
193
|
+
schemaVersion: 1;
|
|
194
|
+
deviceId: string;
|
|
195
|
+
createdAt: number;
|
|
196
|
+
sessionStartedAt: number;
|
|
197
|
+
lastActivityAt: number;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const VISITOR_RECORD_KEYS = "createdAt,deviceId,lastActivityAt,schemaVersion,sessionStartedAt";
|
|
201
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
202
|
+
|
|
203
|
+
export function visitorContinuityStorageKey(scope: VisitorContinuityScope): string {
|
|
204
|
+
const contextKey = `${scope.tenantPublicId}:${scope.environment}:${scope.siteSurface}`;
|
|
205
|
+
return `${VISITOR_CONTINUITY_STORAGE_KEY}:${encodeURIComponent(contextKey)}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** A stored record that is malformed, from the future, or past its fixed lifetime is not a visitor. */
|
|
209
|
+
function liveVisitorRecord(raw: string | null, now: number): VisitorRecord | null {
|
|
210
|
+
if (!raw) return null;
|
|
211
|
+
let parsed: unknown;
|
|
212
|
+
try { parsed = JSON.parse(raw); } catch { return null; }
|
|
213
|
+
if (!isPlainObject(parsed) || Object.keys(parsed).sort().join(",") !== VISITOR_RECORD_KEYS) return null;
|
|
214
|
+
const { schemaVersion, deviceId, createdAt, sessionStartedAt, lastActivityAt } = parsed;
|
|
215
|
+
if (
|
|
216
|
+
schemaVersion !== 1 || typeof deviceId !== "string" || !UUID_PATTERN.test(deviceId) ||
|
|
217
|
+
typeof createdAt !== "number" || typeof sessionStartedAt !== "number" ||
|
|
218
|
+
typeof lastActivityAt !== "number" ||
|
|
219
|
+
!Number.isFinite(createdAt) || !Number.isFinite(sessionStartedAt) || !Number.isFinite(lastActivityAt) ||
|
|
220
|
+
createdAt > now || now - createdAt >= VISITOR_ID_LIFETIME_MS ||
|
|
221
|
+
sessionStartedAt < createdAt || lastActivityAt < sessionStartedAt
|
|
222
|
+
) return null;
|
|
223
|
+
return { schemaVersion, deviceId, createdAt, sessionStartedAt, lastActivityAt };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function defaultRandomUUID(): string {
|
|
227
|
+
return globalThis.crypto.randomUUID();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The visitor/session store behind `device_id` + `session_id`. Every storage access is
|
|
232
|
+
* guarded: a store that is missing, blocked, full, or throwing degrades to a
|
|
233
|
+
* memory-only id for this document with no session, and never fails the event.
|
|
234
|
+
*/
|
|
235
|
+
export function createVisitorContinuity(options: CreateVisitorContinuityOptions = {}): VisitorContinuity {
|
|
236
|
+
const randomUUID = options.randomUUID ?? defaultRandomUUID;
|
|
237
|
+
const memory = new Map<string, string>();
|
|
238
|
+
const storage = (): VisitorContinuityStorage | undefined => {
|
|
239
|
+
try { return options.storage?.(); } catch { return undefined; }
|
|
240
|
+
};
|
|
241
|
+
const mint = (): string | null => {
|
|
242
|
+
try {
|
|
243
|
+
const id = randomUUID().toLowerCase();
|
|
244
|
+
return UUID_PATTERN.test(id) ? id : null;
|
|
245
|
+
} catch {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
readOrMint(scope, now) {
|
|
252
|
+
const key = visitorContinuityStorageKey(scope);
|
|
253
|
+
const store = storage();
|
|
254
|
+
if (store) {
|
|
255
|
+
try {
|
|
256
|
+
const existing = liveVisitorRecord(store.getItem(key), now);
|
|
257
|
+
let record: VisitorRecord | null = existing;
|
|
258
|
+
if (!record) {
|
|
259
|
+
const deviceId = mint();
|
|
260
|
+
if (deviceId) {
|
|
261
|
+
record = { schemaVersion: 1, deviceId, createdAt: now, sessionStartedAt: now, lastActivityAt: now };
|
|
262
|
+
}
|
|
263
|
+
} else {
|
|
264
|
+
const sessionStartedAt = now - record.lastActivityAt > SESSION_INACTIVITY_MS
|
|
265
|
+
? now
|
|
266
|
+
: record.sessionStartedAt;
|
|
267
|
+
record = { ...record, sessionStartedAt, lastActivityAt: Math.max(now, record.lastActivityAt) };
|
|
268
|
+
}
|
|
269
|
+
if (record) {
|
|
270
|
+
store.setItem(key, JSON.stringify(record));
|
|
271
|
+
memory.delete(key);
|
|
272
|
+
return { deviceId: record.deviceId, sessionId: record.sessionStartedAt };
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
// Blocked or full storage: fall through to this document's memory-only visitor.
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
let deviceId = memory.get(key);
|
|
279
|
+
if (!deviceId) {
|
|
280
|
+
const minted = mint();
|
|
281
|
+
if (!minted) return { deviceId: ANONYMOUS_DEVICE_ID, sessionId: NO_SESSION_ID };
|
|
282
|
+
deviceId = minted;
|
|
283
|
+
memory.set(key, deviceId);
|
|
284
|
+
}
|
|
285
|
+
return { deviceId, sessionId: NO_SESSION_ID };
|
|
286
|
+
},
|
|
287
|
+
clear(scope) {
|
|
288
|
+
const key = visitorContinuityStorageKey(scope);
|
|
289
|
+
memory.delete(key);
|
|
290
|
+
try { storage()?.removeItem(key); } catch { /* the early consent boot clears on the next load */ }
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
137
295
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
138
296
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
139
297
|
}
|
|
@@ -187,7 +345,9 @@ function opaqueEventTime(epochMs: number): string {
|
|
|
187
345
|
/**
|
|
188
346
|
* Reduce a broker event to the complete Amplitude allowlist. Returning null is
|
|
189
347
|
* fail-closed for unknown events, invalid/extra data, consent loss, environment
|
|
190
|
-
* disagreement, unsafe paths, or unapproved action names.
|
|
348
|
+
* disagreement, unsafe paths, or unapproved action names. The result carries the
|
|
349
|
+
* anonymous identity; the sending handler replaces it with the visitor's ids only
|
|
350
|
+
* after the event is admitted, so a refused event never reads or mints one.
|
|
191
351
|
*/
|
|
192
352
|
export function mapCommerceEventToAmplitude(
|
|
193
353
|
input: unknown,
|
|
@@ -220,6 +380,7 @@ export function mapCommerceEventToAmplitude(
|
|
|
220
380
|
if (!Number.isFinite(time)) return null;
|
|
221
381
|
const base = {
|
|
222
382
|
device_id: ANONYMOUS_DEVICE_ID,
|
|
383
|
+
session_id: NO_SESSION_ID,
|
|
223
384
|
insert_id: `${eventDedupeKey(event)}-${opaqueEventTime(time)}`,
|
|
224
385
|
time,
|
|
225
386
|
};
|
|
@@ -255,6 +416,8 @@ export interface RegisterAmplitudeHttpDestinationInput {
|
|
|
255
416
|
environment: BrowserEnvironment;
|
|
256
417
|
fetch: AmplitudeFetch;
|
|
257
418
|
onDiagnostic?: AmplitudeDiagnosticObserver;
|
|
419
|
+
/** Defaults to a memory-only store: one visitor per document, no session. */
|
|
420
|
+
continuity?: VisitorContinuity;
|
|
258
421
|
/** Test seam; production is clamped to the fixed default. */
|
|
259
422
|
dedupeCapacity?: number;
|
|
260
423
|
}
|
|
@@ -277,15 +440,27 @@ interface AmplitudeHandlerInput {
|
|
|
277
440
|
fetcher: AmplitudeFetch;
|
|
278
441
|
observer?: AmplitudeDiagnosticObserver;
|
|
279
442
|
dedupe: BoundedInsertIds;
|
|
443
|
+
continuity: VisitorContinuity;
|
|
280
444
|
isActive(): boolean;
|
|
281
445
|
}
|
|
282
446
|
|
|
447
|
+
function continuityScope(tenantPublicId: string, environment: BrowserEnvironment): VisitorContinuityScope {
|
|
448
|
+
return { tenantPublicId, environment: environment.environment, siteSurface: environment.siteSurface };
|
|
449
|
+
}
|
|
450
|
+
|
|
283
451
|
function createAmplitudeHandler(input: AmplitudeHandlerInput): (event: CommerceEvent) => void {
|
|
452
|
+
const scope = continuityScope(input.tenantPublicId, input.environment);
|
|
284
453
|
return (event) => {
|
|
285
454
|
if (!input.isActive()) return;
|
|
286
455
|
const mapped = mapCommerceEventToAmplitude(event, input.environment, input.tenantPublicId);
|
|
287
456
|
if (!mapped || input.dedupe.seenOrRemember(mapped.insert_id)) return;
|
|
288
|
-
|
|
457
|
+
let identity: VisitorContinuityIds;
|
|
458
|
+
try { identity = input.continuity.readOrMint(scope, mapped.time); }
|
|
459
|
+
catch { identity = { deviceId: ANONYMOUS_DEVICE_ID, sessionId: NO_SESSION_ID }; }
|
|
460
|
+
const body = JSON.stringify({
|
|
461
|
+
api_key: input.destinationId,
|
|
462
|
+
events: [{ ...mapped, device_id: identity.deviceId, session_id: identity.sessionId }],
|
|
463
|
+
});
|
|
289
464
|
try {
|
|
290
465
|
void input.fetcher(AMPLITUDE_COLLECTION_URL, {
|
|
291
466
|
method: "POST",
|
|
@@ -352,6 +527,7 @@ export function registerAmplitudeHttpDestination(
|
|
|
352
527
|
fetcher,
|
|
353
528
|
observer,
|
|
354
529
|
dedupe,
|
|
530
|
+
continuity: input.continuity ?? createVisitorContinuity(),
|
|
355
531
|
isActive: () => !disposed,
|
|
356
532
|
}),
|
|
357
533
|
},
|
|
@@ -372,6 +548,8 @@ export function registerAmplitudeHttpDestination(
|
|
|
372
548
|
export interface CreateAmplitudeLifecycleModuleOptions {
|
|
373
549
|
fetch?: AmplitudeFetch;
|
|
374
550
|
onDiagnostic?: AmplitudeDiagnosticObserver;
|
|
551
|
+
/** The host's visitor store; defaults to memory-only (one visitor per document, no session). */
|
|
552
|
+
continuity?: VisitorContinuity;
|
|
375
553
|
/** Narrow test seam; every value is clamped to a finite production ceiling. */
|
|
376
554
|
dedupeCapacity?: number;
|
|
377
555
|
}
|
|
@@ -390,6 +568,7 @@ export function createAmplitudeLifecycleModule(
|
|
|
390
568
|
const fetcher: AmplitudeFetch = options.fetch ?? ((url, init) => globalThis.fetch(url, init));
|
|
391
569
|
const observer = options.onDiagnostic;
|
|
392
570
|
const dedupe = new BoundedInsertIds(options.dedupeCapacity ?? AMPLITUDE_DEDUPE_CAPACITY);
|
|
571
|
+
const continuity = options.continuity ?? createVisitorContinuity();
|
|
393
572
|
let state: AmplitudeLifecycleState = "idle";
|
|
394
573
|
let generation = 0;
|
|
395
574
|
let unsubscribe: (() => void) | undefined;
|
|
@@ -407,7 +586,8 @@ export function createAmplitudeLifecycleModule(
|
|
|
407
586
|
}
|
|
408
587
|
};
|
|
409
588
|
|
|
410
|
-
|
|
589
|
+
/** `clearIfIneligible`: an update that loses analytics eligibility forgets the visitor. */
|
|
590
|
+
const bind = (ctx: IntegrationLifecycleContext, clearIfIneligible: boolean): void => {
|
|
411
591
|
let subscribe: IntegrationEventSubscribe | undefined;
|
|
412
592
|
let environment: ReturnType<typeof validateBrowserEnvironment>;
|
|
413
593
|
let destinationId: string | null;
|
|
@@ -415,6 +595,12 @@ export function createAmplitudeLifecycleModule(
|
|
|
415
595
|
environment = validateBrowserEnvironment(ctx.environment);
|
|
416
596
|
const eligible = ctx.consent.unknownState === "block" &&
|
|
417
597
|
ctx.consent.eligiblePurposes.includes("analytics");
|
|
598
|
+
if (
|
|
599
|
+
!eligible && clearIfIneligible && environment.ok &&
|
|
600
|
+
typeof ctx.tenantPublicId === "string" && ctx.tenantPublicId.length > 0
|
|
601
|
+
) {
|
|
602
|
+
continuity.clear(continuityScope(ctx.tenantPublicId, environment.value));
|
|
603
|
+
}
|
|
418
604
|
subscribe = ctx.subscribe;
|
|
419
605
|
destinationId = environment.ok
|
|
420
606
|
? soleDestinationId(ctx.config, environment.value.environment)
|
|
@@ -436,6 +622,7 @@ export function createAmplitudeLifecycleModule(
|
|
|
436
622
|
fetcher,
|
|
437
623
|
observer,
|
|
438
624
|
dedupe,
|
|
625
|
+
continuity,
|
|
439
626
|
isActive: () => state === "initialized" && generation === ownGeneration,
|
|
440
627
|
});
|
|
441
628
|
try {
|
|
@@ -456,12 +643,12 @@ export function createAmplitudeLifecycleModule(
|
|
|
456
643
|
init(ctx) {
|
|
457
644
|
if (state !== "idle") return;
|
|
458
645
|
state = "initialized";
|
|
459
|
-
bind(ctx);
|
|
646
|
+
bind(ctx, false);
|
|
460
647
|
},
|
|
461
648
|
update(_reason, ctx) {
|
|
462
649
|
if (state !== "initialized") return;
|
|
463
650
|
if (!teardown()) return;
|
|
464
|
-
bind(ctx);
|
|
651
|
+
bind(ctx, true);
|
|
465
652
|
},
|
|
466
653
|
dispose() {
|
|
467
654
|
if (state === "disposed") return;
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
AMPLITUDE_HTTP_ENTRY_URL,
|
|
16
16
|
AMPLITUDE_HTTP_RUNTIME_ID,
|
|
17
17
|
createAmplitudeLifecycleModule,
|
|
18
|
+
type VisitorContinuity,
|
|
18
19
|
} from "./native-amplitude.js";
|
|
19
20
|
import {
|
|
20
21
|
createNativeGa4Module,
|
|
@@ -40,6 +41,8 @@ export interface PlatformAdapterEntryMarker {
|
|
|
40
41
|
export interface PlatformAdapterRuntimeHost {
|
|
41
42
|
fetch?: typeof fetch;
|
|
42
43
|
nativeGa4Transport?: NativeGa4Transport;
|
|
44
|
+
/** The host's persistent visitor store for Amplitude; absent keeps visitors memory-only. */
|
|
45
|
+
visitorContinuity?: VisitorContinuity;
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
export interface PlatformAdapterRuntimeRegistration {
|
|
@@ -78,9 +81,9 @@ export const PLATFORM_ADAPTER_RUNTIME_REGISTRY: readonly PlatformAdapterRuntimeR
|
|
|
78
81
|
}),
|
|
79
82
|
Object.freeze({
|
|
80
83
|
marker: AMPLITUDE_MARKER,
|
|
81
|
-
create: ({ fetch: fetchCapability }: PlatformAdapterRuntimeHost) => {
|
|
84
|
+
create: ({ fetch: fetchCapability, visitorContinuity }: PlatformAdapterRuntimeHost) => {
|
|
82
85
|
if (!fetchCapability) throw new Error("platform_adapter_fetch_unavailable");
|
|
83
|
-
return createAmplitudeLifecycleModule({ fetch: fetchCapability });
|
|
86
|
+
return createAmplitudeLifecycleModule({ fetch: fetchCapability, continuity: visitorContinuity });
|
|
84
87
|
},
|
|
85
88
|
}),
|
|
86
89
|
]);
|
|
@@ -88,6 +91,7 @@ export const PLATFORM_ADAPTER_RUNTIME_REGISTRY: readonly PlatformAdapterRuntimeR
|
|
|
88
91
|
export interface PlatformAdapterRuntimeLoaderOptions {
|
|
89
92
|
fetchCapability?: typeof fetch;
|
|
90
93
|
nativeGa4Transport?: () => NativeGa4Transport;
|
|
94
|
+
visitorContinuity?: VisitorContinuity;
|
|
91
95
|
importModule?: (url: string) => Promise<unknown>;
|
|
92
96
|
fallback: IntegrationModuleLoader;
|
|
93
97
|
}
|
|
@@ -150,6 +154,9 @@ export function makePlatformAdapterRuntimeLoader(
|
|
|
150
154
|
nativeGa4Transport: registration.marker.appId === GOOGLE_MEASUREMENT_ADAPTER.adapterId
|
|
151
155
|
? options.nativeGa4Transport?.()
|
|
152
156
|
: undefined,
|
|
157
|
+
visitorContinuity: registration.marker.appId === AMPLITUDE_HTTP_ADAPTER.adapterId
|
|
158
|
+
? options.visitorContinuity
|
|
159
|
+
: undefined,
|
|
153
160
|
});
|
|
154
161
|
};
|
|
155
162
|
}
|