@tokenoftrust/storefront-runner 2.0.30 → 2.0.32
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/apps/storefront/astro.config.mjs +2 -0
- package/apps/storefront/env.d.ts +0 -2
- package/apps/storefront/integrations/google-measurement-sandbox-asset.mjs +97 -0
- package/apps/storefront/public/platform/google-measurement/v2/entry.mjs +13 -0
- package/apps/storefront/src/layouts/Layout.astro +3 -48
- package/apps/storefront/src/lib/analytics/budgets.json +0 -1
- package/apps/storefront/src/lib/analytics/index.ts +1 -4
- package/apps/storefront/src/lib/analytics/webVitals.ts +42 -4
- package/apps/storefront/src/lib/integration/tenantDeclarations.ts +1 -1
- package/apps/storefront/src/lib/monitoring/manifest.ts +1 -2
- package/package.json +1 -1
- package/packages/public-runtime/package.json +2 -1
- package/packages/public-runtime/src/csp.ts +2 -2
- package/packages/public-runtime/src/integration/consent-broker.ts +370 -13
- package/packages/public-runtime/src/integration/csp-compiler.ts +3 -6
- package/packages/public-runtime/src/integration/events.ts +5 -5
- package/packages/public-runtime/src/integration/index.ts +1 -0
- package/packages/public-runtime/src/integration/interaction-tracking-gate.ts +1 -2
- package/packages/public-runtime/src/integration/lifecycle-boot.ts +28 -14
- package/packages/public-runtime/src/integration/lifecycle-host-entry.ts +2 -0
- package/packages/public-runtime/src/integration/lifecycle-runtime.ts +256 -22
- package/packages/public-runtime/src/integration/managed-analytics-value-safety.ts +9 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.d.mts +2 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +3 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +207 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +168 -0
- package/packages/public-runtime/src/integration/native-ga4.ts +111 -215
- package/packages/public-runtime/src/integration/platform-adapter-runtime.ts +18 -9
- package/packages/public-runtime/src/integration/platform-adapters.ts +20 -9
- package/packages/public-runtime/src/integration/render-phases.ts +28 -2
- package/packages/public-runtime/src/integration/version.ts +2 -2
- package/packages/public-runtime/tests/fixtures/integration/commerce-event.example.json +1 -1
- package/packages/public-runtime/tests/fixtures/integration/commerce-order-event.example.json +1 -1
- package/packages/public-runtime/tests/fixtures/integration/integration-manifest-v2.example.json +1 -1
- package/packages/public-runtime/tests/fixtures/integration/tenant-local-example/manifest.json +1 -1
- package/scripts/tenant/lint-embeds.mjs +0 -8
- package/apps/storefront/public/platform/google-measurement/v1/entry.mjs +0 -19
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/** Closed parent/iframe protocol for the reviewed GA4 null-origin sandbox. */
|
|
2
|
+
export const NATIVE_GA4_SANDBOX_PROTOCOL = "tot.ga4-sandbox/1" as const;
|
|
3
|
+
export const NATIVE_GA4_SANDBOX_MAX_DESTINATIONS = 4 as const;
|
|
4
|
+
|
|
5
|
+
export interface NativeGa4SandboxConfig {
|
|
6
|
+
schemaVersion: 1;
|
|
7
|
+
destinationIds: string[];
|
|
8
|
+
canonicalOrigin: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface NativeGa4PageViewCommand {
|
|
12
|
+
schemaVersion: 1;
|
|
13
|
+
type: "page_view";
|
|
14
|
+
path: string;
|
|
15
|
+
referrerOrigin?: string;
|
|
16
|
+
campaign?: {
|
|
17
|
+
source?: string;
|
|
18
|
+
medium?: string;
|
|
19
|
+
campaign?: string;
|
|
20
|
+
term?: string;
|
|
21
|
+
content?: string;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface NativeGa4ActionCommand {
|
|
26
|
+
schemaVersion: 1;
|
|
27
|
+
type: "action";
|
|
28
|
+
name: string;
|
|
29
|
+
pageLocation: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type NativeGa4SandboxCommand = NativeGa4PageViewCommand | NativeGa4ActionCommand;
|
|
33
|
+
|
|
34
|
+
export type NativeGa4SandboxPortMessage =
|
|
35
|
+
| ({ type: "configure" } & NativeGa4SandboxConfig)
|
|
36
|
+
| NativeGa4SandboxCommand
|
|
37
|
+
| { schemaVersion: 1; type: "disable" }
|
|
38
|
+
| { schemaVersion: 1; type: "dispose" };
|
|
39
|
+
|
|
40
|
+
export interface NativeGa4MessagePort {
|
|
41
|
+
postMessage(message: NativeGa4SandboxPortMessage): void;
|
|
42
|
+
close(): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface NativeGa4SandboxFrame {
|
|
46
|
+
contentWindow: { postMessage(message: unknown, targetOrigin: string, transfer: Transferable[]): void } | null;
|
|
47
|
+
setAttribute(name: string, value: string): void;
|
|
48
|
+
addEventListener(type: "load" | "error", listener: () => void, options?: { once?: boolean }): void;
|
|
49
|
+
remove(): void;
|
|
50
|
+
src: string;
|
|
51
|
+
hidden: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface NativeGa4SandboxHost {
|
|
55
|
+
createFrame(): NativeGa4SandboxFrame;
|
|
56
|
+
appendFrame(frame: NativeGa4SandboxFrame): void;
|
|
57
|
+
createChannel(): { port1: NativeGa4MessagePort; port2: Transferable };
|
|
58
|
+
setTimer(callback: () => void, milliseconds: number): unknown;
|
|
59
|
+
clearTimer(handle: unknown): void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface NativeGa4SandboxTransport {
|
|
63
|
+
configure(config: NativeGa4SandboxConfig): void;
|
|
64
|
+
send(command: NativeGa4SandboxCommand): void;
|
|
65
|
+
disable(): void;
|
|
66
|
+
dispose(): void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createBrowserNativeGa4SandboxHost(
|
|
70
|
+
doc: Document,
|
|
71
|
+
messageChannel: typeof MessageChannel,
|
|
72
|
+
): NativeGa4SandboxHost {
|
|
73
|
+
return {
|
|
74
|
+
createFrame: () => doc.createElement("iframe"),
|
|
75
|
+
appendFrame: (frame) => (doc.body ?? doc.documentElement).append(frame as HTMLIFrameElement),
|
|
76
|
+
createChannel: () => new messageChannel(),
|
|
77
|
+
setTimer: (callback, milliseconds) => globalThis.setTimeout(callback, milliseconds),
|
|
78
|
+
clearTimer: (handle) => globalThis.clearTimeout(handle as ReturnType<typeof setTimeout>),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Mount one hidden same-origin iframe and transfer exactly one MessagePort after load.
|
|
84
|
+
* The wildcard target is required because `allow-scripts` without `allow-same-origin`
|
|
85
|
+
* gives the receiver an opaque origin; the exact iframe window and one-shot transfer
|
|
86
|
+
* are the authority boundary. No general parent `message` listener is installed.
|
|
87
|
+
*/
|
|
88
|
+
export function createNativeGa4SandboxTransport(
|
|
89
|
+
host: NativeGa4SandboxHost,
|
|
90
|
+
sandboxUrl: string,
|
|
91
|
+
): NativeGa4SandboxTransport {
|
|
92
|
+
const frame = host.createFrame();
|
|
93
|
+
frame.setAttribute("sandbox", "allow-scripts");
|
|
94
|
+
frame.setAttribute("referrerpolicy", "no-referrer");
|
|
95
|
+
frame.setAttribute("aria-hidden", "true");
|
|
96
|
+
frame.setAttribute("tabindex", "-1");
|
|
97
|
+
frame.setAttribute("title", "Analytics transport");
|
|
98
|
+
frame.hidden = true;
|
|
99
|
+
frame.src = sandboxUrl;
|
|
100
|
+
|
|
101
|
+
let port: NativeGa4MessagePort | null = null;
|
|
102
|
+
let disposed = false;
|
|
103
|
+
let unavailable = false;
|
|
104
|
+
let queue: NativeGa4SandboxPortMessage[] = [];
|
|
105
|
+
let timer: unknown;
|
|
106
|
+
|
|
107
|
+
const reject = (): void => {
|
|
108
|
+
unavailable = true;
|
|
109
|
+
queue = [];
|
|
110
|
+
host.clearTimer(timer);
|
|
111
|
+
frame.remove();
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
frame.addEventListener("load", () => {
|
|
115
|
+
if (disposed || port) return;
|
|
116
|
+
const target = frame.contentWindow;
|
|
117
|
+
if (!target) return reject();
|
|
118
|
+
const channel = host.createChannel();
|
|
119
|
+
port = channel.port1;
|
|
120
|
+
try {
|
|
121
|
+
target.postMessage(
|
|
122
|
+
Object.freeze({ protocol: NATIVE_GA4_SANDBOX_PROTOCOL, type: "bind" }),
|
|
123
|
+
"*",
|
|
124
|
+
[channel.port2],
|
|
125
|
+
);
|
|
126
|
+
} catch {
|
|
127
|
+
port.close();
|
|
128
|
+
port = null;
|
|
129
|
+
return reject();
|
|
130
|
+
}
|
|
131
|
+
host.clearTimer(timer);
|
|
132
|
+
const pending = queue;
|
|
133
|
+
queue = [];
|
|
134
|
+
for (const message of pending) port.postMessage(Object.freeze(message));
|
|
135
|
+
}, { once: true });
|
|
136
|
+
frame.addEventListener("error", reject, { once: true });
|
|
137
|
+
timer = host.setTimer(reject, 5_000);
|
|
138
|
+
host.appendFrame(frame);
|
|
139
|
+
|
|
140
|
+
const post = (message: NativeGa4SandboxPortMessage): void => {
|
|
141
|
+
if (disposed || unavailable) throw new Error("ga4_sandbox_unavailable");
|
|
142
|
+
if (!port) queue.push(message);
|
|
143
|
+
else port.postMessage(Object.freeze(message));
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
configure(config) { post({ ...config, type: "configure" }); },
|
|
148
|
+
send(command) { post(command); },
|
|
149
|
+
disable() {
|
|
150
|
+
if (disposed || unavailable) return;
|
|
151
|
+
if (!port) queue = [];
|
|
152
|
+
post({ schemaVersion: 1, type: "disable" });
|
|
153
|
+
},
|
|
154
|
+
dispose() {
|
|
155
|
+
if (disposed) return;
|
|
156
|
+
if (port) {
|
|
157
|
+
post({ schemaVersion: 1, type: "dispose" });
|
|
158
|
+
port.close();
|
|
159
|
+
} else {
|
|
160
|
+
queue = [];
|
|
161
|
+
}
|
|
162
|
+
port = null;
|
|
163
|
+
disposed = true;
|
|
164
|
+
host.clearTimer(timer);
|
|
165
|
+
frame.remove();
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -1,19 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Storefront-owned GA4 browser adapter.
|
|
3
3
|
*
|
|
4
|
-
* The adapter consumes only
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* and logical-event dedupe.
|
|
9
|
-
*
|
|
10
|
-
* A stage may configure SEVERAL measurement ids (a primary plus a rollup). GA4 has no
|
|
11
|
-
* multi-property hit — `tid` names exactly one measurement id — so one logical event
|
|
12
|
-
* becomes one POST per id, identical but for `tid`. The fan-out sits below the broker's
|
|
13
|
-
* dedupe, so a logical action is still measured exactly once per property.
|
|
4
|
+
* The adapter consumes only manually-authored page/action events from the scoped
|
|
5
|
+
* lifecycle subscription. It never loads Google code into the regulated parent.
|
|
6
|
+
* Instead, a closed command crosses one private MessagePort into the reviewed,
|
|
7
|
+
* null-origin GA4 sandbox after consent and logical-event dedupe have passed.
|
|
14
8
|
*/
|
|
15
9
|
import type { IntegrationLifecycleContext, IntegrationLifecycleModule, LifecycleUpdateReason } from "./lifecycle.js";
|
|
16
|
-
import type {
|
|
10
|
+
import type { CommerceEventType } from "./events.js";
|
|
17
11
|
import { APPROVED_MARKETING_ACTION_NAMES, validateCommerceEvent } from "./events.js";
|
|
18
12
|
import { isPurposeEligible, parseConsentSnapshot } from "./consent-broker.js";
|
|
19
13
|
import {
|
|
@@ -22,13 +16,17 @@ import {
|
|
|
22
16
|
resolveAdapterDestinationIds,
|
|
23
17
|
} from "./platform-adapters.js";
|
|
24
18
|
import { INTEGRATION_ENVIRONMENTS, type IntegrationEnvironment } from "./environment.js";
|
|
25
|
-
import {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
19
|
+
import { resemblesManagedAnalyticsProtectedValue } from "./managed-analytics-value-safety.js";
|
|
20
|
+
import type {
|
|
21
|
+
NativeGa4SandboxCommand,
|
|
22
|
+
NativeGa4SandboxConfig,
|
|
23
|
+
} from "./native-ga4-sandbox.js";
|
|
24
|
+
import { NATIVE_GA4_SANDBOX_ASSET_URL } from "./native-ga4-sandbox-asset.mjs";
|
|
25
|
+
|
|
26
|
+
export const NATIVE_GA4_ENTRY_URL = "/platform/google-measurement/v2/entry.mjs" as const;
|
|
27
|
+
export const NATIVE_GA4_SANDBOX_URL = NATIVE_GA4_SANDBOX_ASSET_URL;
|
|
30
28
|
export const NATIVE_GA4_ADAPTER_VERSION = "1.0.0" as const;
|
|
31
|
-
export const NATIVE_GA4_RUNTIME_ID = "native-ga4-
|
|
29
|
+
export const NATIVE_GA4_RUNTIME_ID = "native-ga4-v2" as const;
|
|
32
30
|
|
|
33
31
|
export const NATIVE_GA4_EVENT_TYPES = Object.freeze([
|
|
34
32
|
"storefront.page.viewed.v1",
|
|
@@ -38,9 +36,7 @@ export const NATIVE_GA4_EVENT_TYPES = Object.freeze([
|
|
|
38
36
|
|
|
39
37
|
const NATIVE_GA4_EVENT_SET: ReadonlySet<string> = new Set(NATIVE_GA4_EVENT_TYPES);
|
|
40
38
|
const APPROVED_ACTION_SET: ReadonlySet<string> = new Set(APPROVED_MARKETING_ACTION_NAMES);
|
|
41
|
-
const CONFIG_KEYS: ReadonlySet<string> = new Set(
|
|
42
|
-
INTEGRATION_ENVIRONMENTS.map(destinationIdConfigKey),
|
|
43
|
-
);
|
|
39
|
+
const CONFIG_KEYS: ReadonlySet<string> = new Set(INTEGRATION_ENVIRONMENTS.map(destinationIdConfigKey));
|
|
44
40
|
|
|
45
41
|
export type NativeGa4DiagnosticCode =
|
|
46
42
|
| "configuration_invalid"
|
|
@@ -50,62 +46,45 @@ export type NativeGa4DiagnosticCode =
|
|
|
50
46
|
| "teardown_failed"
|
|
51
47
|
| "transport_failed";
|
|
52
48
|
|
|
53
|
-
export interface NativeGa4Observation {
|
|
54
|
-
code: NativeGa4DiagnosticCode;
|
|
55
|
-
}
|
|
49
|
+
export interface NativeGa4Observation { code: NativeGa4DiagnosticCode }
|
|
56
50
|
|
|
57
|
-
export interface
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
51
|
+
export interface NativeGa4Transport {
|
|
52
|
+
send(command: NativeGa4SandboxCommand): void | Promise<void>;
|
|
53
|
+
configure?(config: NativeGa4SandboxConfig): void | Promise<void>;
|
|
54
|
+
disable?(): void | Promise<void>;
|
|
55
|
+
dispose?(): void | Promise<void>;
|
|
61
56
|
}
|
|
62
|
-
|
|
63
|
-
export type NativeGa4Transport = (
|
|
64
|
-
request: NativeGa4CollectRequest,
|
|
65
|
-
) => void | Promise<void>;
|
|
57
|
+
export type NativeGa4TransportSend = (command: NativeGa4SandboxCommand) => void | Promise<void>;
|
|
66
58
|
|
|
67
59
|
export type NativeGa4Observer = (observation: NativeGa4Observation) => void;
|
|
68
60
|
|
|
69
61
|
export interface NativeGa4ModuleOptions {
|
|
70
|
-
transport: NativeGa4Transport;
|
|
62
|
+
transport: NativeGa4Transport | NativeGa4TransportSend;
|
|
71
63
|
observe?: NativeGa4Observer;
|
|
72
64
|
}
|
|
73
65
|
|
|
74
66
|
interface ActiveGa4Config {
|
|
75
|
-
/** Every measurement id this stage collects to, in configured order. */
|
|
76
67
|
destinationIds: readonly string[];
|
|
77
68
|
environment: IntegrationEnvironment;
|
|
78
69
|
siteSurface: IntegrationLifecycleContext["environment"]["siteSurface"];
|
|
70
|
+
canonicalOrigin: string;
|
|
79
71
|
}
|
|
80
72
|
|
|
81
73
|
function observeSafely(observer: NativeGa4Observer | undefined, code: NativeGa4DiagnosticCode): void {
|
|
82
|
-
try {
|
|
83
|
-
observer?.(Object.freeze({ code }));
|
|
84
|
-
} catch {
|
|
85
|
-
// Diagnostics are fail-soft and carry no exception text or payload.
|
|
86
|
-
}
|
|
74
|
+
try { observer?.(Object.freeze({ code })); } catch { /* diagnostics are fail-soft */ }
|
|
87
75
|
}
|
|
88
76
|
|
|
89
77
|
function configuredFor(ctx: IntegrationLifecycleContext): ActiveGa4Config | null {
|
|
90
|
-
if (ctx.appId !== GOOGLE_MEASUREMENT_ADAPTER.adapterId) return null;
|
|
91
|
-
if (!
|
|
92
|
-
|
|
93
|
-
for (const key of Object.keys(ctx.config)) {
|
|
94
|
-
if (!CONFIG_KEYS.has(key)) return null;
|
|
95
|
-
}
|
|
78
|
+
if (ctx.appId !== GOOGLE_MEASUREMENT_ADAPTER.adapterId || !hasAnalyticsConsent(ctx)) return null;
|
|
79
|
+
for (const key of Object.keys(ctx.config)) if (!CONFIG_KEYS.has(key)) return null;
|
|
96
80
|
|
|
97
|
-
// Every stage present must decode to a valid, in-cap set, and no non-live stage may
|
|
98
|
-
// reuse ANY live id — checked across the whole config, not just the active stage, so a
|
|
99
|
-
// misconfigured neighbour resolves the entire integration to nothing rather than
|
|
100
|
-
// collecting from a page whose other stages would leak.
|
|
101
81
|
const liveIds = resolveAdapterDestinationIds(GOOGLE_MEASUREMENT_ADAPTER, ctx.config, "live");
|
|
102
82
|
if (liveIds.length === 0) return null;
|
|
103
83
|
const live = new Set(liveIds);
|
|
104
84
|
for (const environment of INTEGRATION_ENVIRONMENTS) {
|
|
105
85
|
if (ctx.config[destinationIdConfigKey(environment)] === undefined) continue;
|
|
106
86
|
const ids = resolveAdapterDestinationIds(GOOGLE_MEASUREMENT_ADAPTER, ctx.config, environment);
|
|
107
|
-
if (ids.length === 0) return null;
|
|
108
|
-
if (environment !== "live" && ids.some((id) => live.has(id))) return null;
|
|
87
|
+
if (ids.length === 0 || (environment !== "live" && ids.some((id) => live.has(id)))) return null;
|
|
109
88
|
}
|
|
110
89
|
|
|
111
90
|
const destinationIds = resolveAdapterDestinationIds(
|
|
@@ -113,11 +92,16 @@ function configuredFor(ctx: IntegrationLifecycleContext): ActiveGa4Config | null
|
|
|
113
92
|
ctx.config,
|
|
114
93
|
ctx.environment.environment,
|
|
115
94
|
);
|
|
116
|
-
|
|
95
|
+
const canonicalHost = ctx.environment.canonicalHost;
|
|
96
|
+
if (
|
|
97
|
+
destinationIds.length === 0 || typeof canonicalHost !== "string" || canonicalHost.length === 0 ||
|
|
98
|
+
canonicalHost.includes("/") || canonicalHost.includes("?") || canonicalHost.includes("#")
|
|
99
|
+
) return null;
|
|
117
100
|
return {
|
|
118
101
|
destinationIds,
|
|
119
102
|
environment: ctx.environment.environment,
|
|
120
103
|
siteSurface: ctx.environment.siteSurface,
|
|
104
|
+
canonicalOrigin: `https://${canonicalHost}`,
|
|
121
105
|
};
|
|
122
106
|
}
|
|
123
107
|
|
|
@@ -126,108 +110,96 @@ function hasAnalyticsConsent(ctx: IntegrationLifecycleContext): boolean {
|
|
|
126
110
|
return consent !== null && isPurposeEligible(consent, "analytics");
|
|
127
111
|
}
|
|
128
112
|
|
|
129
|
-
function fnv1a32(value: string): number {
|
|
130
|
-
let hash = 0x811c9dc5;
|
|
131
|
-
for (let index = 0; index < value.length; index += 1) {
|
|
132
|
-
const code = value.charCodeAt(index);
|
|
133
|
-
hash = Math.imul(hash ^ (code & 0xff), 0x01000193);
|
|
134
|
-
hash = Math.imul(hash ^ ((code >> 8) & 0xff), 0x01000193);
|
|
135
|
-
}
|
|
136
|
-
return hash >>> 0;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** GA4 requires a client id. Derive an opaque document/session value without storage. */
|
|
140
|
-
function anonymousClientId(event: CommerceEvent): string {
|
|
141
|
-
const high = fnv1a32(`ga4:${event.correlation.id}`);
|
|
142
|
-
const low = fnv1a32(`ga4:${event.correlation.expiresAt}`);
|
|
143
|
-
return `ga4.${high.toString(16)}.${low.toString(16)}`;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
113
|
function exactKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
|
|
147
114
|
const actual = Object.keys(value);
|
|
148
115
|
return actual.length <= allowed.length && actual.every((key) => allowed.includes(key));
|
|
149
116
|
}
|
|
150
117
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
118
|
+
function safePagePath(value: unknown): value is string {
|
|
119
|
+
return typeof value === "string" && value.startsWith("/") && !value.startsWith("//") &&
|
|
120
|
+
!value.includes("\\") && !value.includes("?") && !value.includes("#") &&
|
|
121
|
+
!resemblesManagedAnalyticsProtectedValue(value);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function mapEvent(
|
|
125
|
+
config: ActiveGa4Config,
|
|
126
|
+
input: unknown,
|
|
127
|
+
currentPageLocation: string | null,
|
|
128
|
+
): NativeGa4SandboxCommand | null {
|
|
160
129
|
const validated = validateCommerceEvent(input);
|
|
161
|
-
if (!validated.ok) return
|
|
130
|
+
if (!validated.ok) return null;
|
|
162
131
|
const event = validated.value;
|
|
163
|
-
if (
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
// `tid` is the one field that differs between the fan-out requests; it is added per
|
|
170
|
-
// destination below.
|
|
171
|
-
const fields: Record<string, string> = {
|
|
172
|
-
v: "2",
|
|
173
|
-
cid: anonymousClientId(event),
|
|
174
|
-
};
|
|
132
|
+
if (
|
|
133
|
+
!NATIVE_GA4_EVENT_SET.has(event.type) || event.storefront.emitter !== "browser" ||
|
|
134
|
+
event.storefront.environment !== config.environment || event.storefront.siteSurface !== config.siteSurface ||
|
|
135
|
+
!event.consent.eligiblePurposes.includes("analytics")
|
|
136
|
+
) return null;
|
|
175
137
|
|
|
176
138
|
if (event.type === "storefront.page.viewed.v1") {
|
|
177
|
-
if (!exactKeys(event.data, ["path"]) ||
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
139
|
+
if (!exactKeys(event.data, ["path"]) || !safePagePath(event.data["path"])) return null;
|
|
140
|
+
return Object.freeze({ schemaVersion: 1, type: "page_view", path: event.data["path"] });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (event.type === "storefront.page.viewed.v2") {
|
|
144
|
+
if (!exactKeys(event.data, ["path", "attribution"]) || !safePagePath(event.data["path"])) return null;
|
|
145
|
+
const command: {
|
|
146
|
+
schemaVersion: 1; type: "page_view"; path: string; referrerOrigin?: string;
|
|
147
|
+
campaign?: { source?: string; medium?: string; campaign?: string; term?: string; content?: string };
|
|
148
|
+
} = { schemaVersion: 1, type: "page_view", path: event.data["path"] };
|
|
184
149
|
const attribution = event.data["attribution"];
|
|
185
150
|
if (typeof attribution === "object" && attribution !== null && !Array.isArray(attribution)) {
|
|
186
151
|
const record = attribution as Record<string, unknown>;
|
|
187
152
|
const referrer = record["referrer"];
|
|
188
153
|
if (typeof referrer === "object" && referrer !== null && !Array.isArray(referrer)) {
|
|
189
154
|
const origin = (referrer as Record<string, unknown>)["origin"];
|
|
190
|
-
if (typeof origin === "string")
|
|
155
|
+
if (typeof origin === "string") command.referrerOrigin = origin;
|
|
191
156
|
}
|
|
192
157
|
const utm = record["utm"];
|
|
193
158
|
if (typeof utm === "object" && utm !== null && !Array.isArray(utm)) {
|
|
159
|
+
const campaign: NonNullable<typeof command.campaign> = {};
|
|
194
160
|
for (const key of ["source", "medium", "campaign", "term", "content"] as const) {
|
|
195
161
|
const value = (utm as Record<string, unknown>)[key];
|
|
196
|
-
if (typeof value === "string")
|
|
162
|
+
if (typeof value === "string" && !resemblesManagedAnalyticsProtectedValue(value)) campaign[key] = value;
|
|
197
163
|
}
|
|
164
|
+
if (Object.keys(campaign).length > 0) command.campaign = campaign;
|
|
198
165
|
}
|
|
199
166
|
}
|
|
200
|
-
|
|
201
|
-
if (!exactKeys(event.data, ["name"])) return [];
|
|
202
|
-
const name = event.data["name"];
|
|
203
|
-
if (typeof name !== "string" || !APPROVED_ACTION_SET.has(name)) return [];
|
|
204
|
-
fields["en"] = name;
|
|
167
|
+
return Object.freeze(command);
|
|
205
168
|
}
|
|
206
169
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
})
|
|
170
|
+
if (!exactKeys(event.data, ["name"])) return null;
|
|
171
|
+
const name = event.data["name"];
|
|
172
|
+
if (typeof name !== "string" || !APPROVED_ACTION_SET.has(name) || !currentPageLocation) return null;
|
|
173
|
+
return Object.freeze({ schemaVersion: 1, type: "action", name, pageLocation: currentPageLocation });
|
|
211
174
|
}
|
|
212
175
|
|
|
213
|
-
/**
|
|
214
|
-
* Build the lifecycle entry module used by the platform-owned GA4 asset. The host
|
|
215
|
-
* supplies fetch as a capability; this module never reaches through a browser global.
|
|
216
|
-
*/
|
|
217
176
|
export function createNativeGa4Module(options: NativeGa4ModuleOptions): IntegrationLifecycleModule {
|
|
218
177
|
let unsubscribe: (() => void) | null = null;
|
|
219
178
|
let active: ActiveGa4Config | null = null;
|
|
220
179
|
let disposed = false;
|
|
180
|
+
let currentPageLocation: string | null = null;
|
|
221
181
|
const delivered = new Set<string>();
|
|
222
182
|
const deliveredOrder: string[] = [];
|
|
223
183
|
|
|
184
|
+
const transport = typeof options.transport === "function" ? undefined : options.transport;
|
|
185
|
+
const send = typeof options.transport === "function" ? options.transport : options.transport.send.bind(options.transport);
|
|
186
|
+
|
|
187
|
+
const runTransportEffect = (effect: (() => void | Promise<void>) | undefined): void => {
|
|
188
|
+
if (!effect) return;
|
|
189
|
+
const result = effect();
|
|
190
|
+
if (result && typeof result.then === "function") {
|
|
191
|
+
void result.catch(() => observeSafely(options.observe, "transport_failed"));
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
224
195
|
const stop = (): boolean => {
|
|
225
196
|
const release = unsubscribe;
|
|
226
197
|
active = null;
|
|
227
|
-
|
|
198
|
+
currentPageLocation = null;
|
|
228
199
|
try {
|
|
229
|
-
release();
|
|
200
|
+
if (release) release();
|
|
230
201
|
unsubscribe = null;
|
|
202
|
+
runTransportEffect(transport?.disable?.bind(transport));
|
|
231
203
|
return true;
|
|
232
204
|
} catch {
|
|
233
205
|
observeSafely(options.observe, "teardown_failed");
|
|
@@ -236,20 +208,27 @@ export function createNativeGa4Module(options: NativeGa4ModuleOptions): Integrat
|
|
|
236
208
|
};
|
|
237
209
|
|
|
238
210
|
const apply = (ctx: IntegrationLifecycleContext): void => {
|
|
239
|
-
if (!stop()) return;
|
|
240
|
-
if (disposed) return;
|
|
211
|
+
if (!stop() || disposed) return;
|
|
241
212
|
const next = configuredFor(ctx);
|
|
242
213
|
if (!next) {
|
|
243
|
-
observeSafely(
|
|
244
|
-
options.observe,
|
|
245
|
-
hasAnalyticsConsent(ctx) ? "configuration_invalid" : "consent_ineligible",
|
|
246
|
-
);
|
|
214
|
+
observeSafely(options.observe, hasAnalyticsConsent(ctx) ? "configuration_invalid" : "consent_ineligible");
|
|
247
215
|
return;
|
|
248
216
|
}
|
|
249
217
|
if (!ctx.subscribe) {
|
|
250
218
|
observeSafely(options.observe, "configuration_invalid");
|
|
251
219
|
return;
|
|
252
220
|
}
|
|
221
|
+
try {
|
|
222
|
+
const configuration = {
|
|
223
|
+
schemaVersion: 1 as const,
|
|
224
|
+
destinationIds: [...next.destinationIds],
|
|
225
|
+
canonicalOrigin: next.canonicalOrigin,
|
|
226
|
+
};
|
|
227
|
+
runTransportEffect(transport?.configure ? () => transport.configure!(configuration) : undefined);
|
|
228
|
+
} catch {
|
|
229
|
+
observeSafely(options.observe, "transport_failed");
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
253
232
|
active = next;
|
|
254
233
|
unsubscribe = ctx.subscribe({
|
|
255
234
|
types: NATIVE_GA4_EVENT_TYPES,
|
|
@@ -263,29 +242,25 @@ export function createNativeGa4Module(options: NativeGa4ModuleOptions): Integrat
|
|
|
263
242
|
observeSafely(options.observe, "duplicate_suppressed");
|
|
264
243
|
return;
|
|
265
244
|
}
|
|
266
|
-
const
|
|
267
|
-
if (
|
|
245
|
+
const command = mapEvent(current, event, currentPageLocation);
|
|
246
|
+
if (!command) {
|
|
268
247
|
observeSafely(options.observe, "event_rejected");
|
|
269
248
|
return;
|
|
270
249
|
}
|
|
250
|
+
if (command.type === "page_view") currentPageLocation = `${current.canonicalOrigin}${command.path}`;
|
|
271
251
|
delivered.add(event.id);
|
|
272
252
|
deliveredOrder.push(event.id);
|
|
273
253
|
if (deliveredOrder.length > 1_024) {
|
|
274
254
|
const oldest = deliveredOrder.shift();
|
|
275
255
|
if (oldest !== undefined) delivered.delete(oldest);
|
|
276
256
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
try {
|
|
282
|
-
const result = options.transport(request);
|
|
283
|
-
if (result && typeof result.then === "function") {
|
|
284
|
-
void result.catch(() => observeSafely(options.observe, "transport_failed"));
|
|
285
|
-
}
|
|
286
|
-
} catch {
|
|
287
|
-
observeSafely(options.observe, "transport_failed");
|
|
257
|
+
try {
|
|
258
|
+
const result = send(command);
|
|
259
|
+
if (result && typeof result.then === "function") {
|
|
260
|
+
void result.catch(() => observeSafely(options.observe, "transport_failed"));
|
|
288
261
|
}
|
|
262
|
+
} catch {
|
|
263
|
+
observeSafely(options.observe, "transport_failed");
|
|
289
264
|
}
|
|
290
265
|
},
|
|
291
266
|
},
|
|
@@ -293,91 +268,12 @@ export function createNativeGa4Module(options: NativeGa4ModuleOptions): Integrat
|
|
|
293
268
|
};
|
|
294
269
|
|
|
295
270
|
return {
|
|
296
|
-
init
|
|
297
|
-
|
|
298
|
-
},
|
|
299
|
-
update(_reason: LifecycleUpdateReason, ctx) {
|
|
300
|
-
apply(ctx);
|
|
301
|
-
},
|
|
271
|
+
init: apply,
|
|
272
|
+
update(_reason: LifecycleUpdateReason, ctx) { return apply(ctx); },
|
|
302
273
|
dispose() {
|
|
303
274
|
disposed = true;
|
|
304
275
|
stop();
|
|
276
|
+
runTransportEffect(transport?.dispose?.bind(transport));
|
|
305
277
|
},
|
|
306
278
|
};
|
|
307
279
|
}
|
|
308
|
-
|
|
309
|
-
/** A fixed-origin, credentialless browser transport for the native lifecycle entry. */
|
|
310
|
-
export function createNativeGa4FetchTransport(
|
|
311
|
-
fetchCapability: typeof fetch,
|
|
312
|
-
): NativeGa4Transport {
|
|
313
|
-
return async (request) => {
|
|
314
|
-
if (request.endpoint !== GA4_COLLECTION_ENDPOINT) throw new Error("ga4_transport_invalid");
|
|
315
|
-
if (!safeTransportFields(request.fields)) throw new Error("ga4_transport_invalid");
|
|
316
|
-
const response = await fetchCapability(request.endpoint, {
|
|
317
|
-
method: "POST",
|
|
318
|
-
body: new URLSearchParams(request.fields).toString(),
|
|
319
|
-
mode: "no-cors",
|
|
320
|
-
credentials: "omit",
|
|
321
|
-
referrerPolicy: "no-referrer",
|
|
322
|
-
keepalive: true,
|
|
323
|
-
headers: { "content-type": "text/plain;charset=UTF-8" },
|
|
324
|
-
});
|
|
325
|
-
// Opaque no-cors responses are expected. A non-opaque explicit failure is stable.
|
|
326
|
-
if (response.type !== "opaque" && !response.ok) throw new Error("ga4_transport_failed");
|
|
327
|
-
};
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
const GA4_OPTIONAL_PAGE_FIELDS = [
|
|
331
|
-
"ep.referrer_origin",
|
|
332
|
-
"ep.utm_source",
|
|
333
|
-
"ep.utm_medium",
|
|
334
|
-
"ep.utm_campaign",
|
|
335
|
-
"ep.utm_term",
|
|
336
|
-
"ep.utm_content",
|
|
337
|
-
] as const;
|
|
338
|
-
const SAFE_CAMPAIGN_VALUE = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$/;
|
|
339
|
-
|
|
340
|
-
function safeTransportFields(fields: Readonly<Record<string, string>>): boolean {
|
|
341
|
-
const keys = Object.keys(fields);
|
|
342
|
-
const allowed = new Set(["v", "tid", "cid", "en", "ep.page_path", ...GA4_OPTIONAL_PAGE_FIELDS]);
|
|
343
|
-
if (keys.some((key) => !allowed.has(key))) return false;
|
|
344
|
-
if (fields["v"] !== "2") return false;
|
|
345
|
-
if (!GOOGLE_MEASUREMENT_ADAPTER.validateId(fields["tid"] ?? "")) return false;
|
|
346
|
-
if (!/^ga4\.[a-f0-9]{1,8}\.[a-f0-9]{1,8}$/.test(fields["cid"] ?? "")) return false;
|
|
347
|
-
if (Object.values(fields).some((value) => resemblesManagedAnalyticsPii(value))) return false;
|
|
348
|
-
|
|
349
|
-
const eventName = fields["en"];
|
|
350
|
-
if (eventName !== "page_view") {
|
|
351
|
-
return (
|
|
352
|
-
eventName !== undefined &&
|
|
353
|
-
APPROVED_ACTION_SET.has(eventName) &&
|
|
354
|
-
keys.length === 4 &&
|
|
355
|
-
keys.every((key) => ["v", "tid", "cid", "en"].includes(key))
|
|
356
|
-
);
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
const path = fields["ep.page_path"];
|
|
360
|
-
if (
|
|
361
|
-
typeof path !== "string" ||
|
|
362
|
-
!path.startsWith("/") ||
|
|
363
|
-
path.startsWith("//") ||
|
|
364
|
-
path.includes("\\") ||
|
|
365
|
-
path.includes("?") ||
|
|
366
|
-
path.includes("#")
|
|
367
|
-
) return false;
|
|
368
|
-
const origin = fields["ep.referrer_origin"];
|
|
369
|
-
if (origin !== undefined) {
|
|
370
|
-
try {
|
|
371
|
-
const parsed = new URL(origin);
|
|
372
|
-
if (!["http:", "https:"].includes(parsed.protocol) || parsed.origin !== origin) return false;
|
|
373
|
-
} catch {
|
|
374
|
-
return false;
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
for (const key of GA4_OPTIONAL_PAGE_FIELDS) {
|
|
378
|
-
if (key === "ep.referrer_origin") continue;
|
|
379
|
-
const value = fields[key];
|
|
380
|
-
if (value !== undefined && !SAFE_CAMPAIGN_VALUE.test(value)) return false;
|
|
381
|
-
}
|
|
382
|
-
return true;
|
|
383
|
-
}
|