@tokenoftrust/storefront-runner 2.0.29 → 2.0.31

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,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 the manually-authored page/action events exposed by the
5
- * scoped lifecycle subscription. It does not load vendor code, inspect the document,
6
- * or persist browser identity. A reviewed direct POST sends a closed, anonymous
7
- * payload to one exact collection origin after the shared broker has applied consent
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 { CommerceEvent, CommerceEventType } from "./events.js";
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 { resemblesManagedAnalyticsPii } from "./managed-analytics-value-safety.js";
26
-
27
- export const GA4_COLLECTION_ORIGIN = "https://www.google-analytics.com" as const;
28
- export const GA4_COLLECTION_ENDPOINT = `${GA4_COLLECTION_ORIGIN}/g/collect` as const;
29
- export const NATIVE_GA4_ENTRY_URL = "/platform/google-measurement/v1/entry.mjs" as const;
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-v1" as const;
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 NativeGa4CollectRequest {
58
- endpoint: typeof GA4_COLLECTION_ENDPOINT;
59
- /** Closed GA4 request body. Values are anonymous strings, never raw event envelopes. */
60
- fields: Readonly<Record<string, string>>;
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 (!hasAnalyticsConsent(ctx)) return null;
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
- if (destinationIds.length === 0) return null;
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
- * Build the collect requests one event becomes — ONE per configured measurement id.
153
- *
154
- * GA4 has no multi-property hit: `tid` names exactly one measurement id, so reaching a
155
- * primary and a rollup means repeating the same closed payload with a different `tid`.
156
- * Returns an empty list when the event does not map, so a caller cannot mistake "not
157
- * measured" for "measured once".
158
- */
159
- function mapEvent(config: ActiveGa4Config, input: unknown): NativeGa4CollectRequest[] {
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 (!NATIVE_GA4_EVENT_SET.has(event.type)) return [];
164
- if (event.storefront.emitter !== "browser") return [];
165
- if (event.storefront.environment !== config.environment) return [];
166
- if (event.storefront.siteSurface !== config.siteSurface) return [];
167
- if (!event.consent.eligiblePurposes.includes("analytics")) return [];
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"]) || typeof event.data["path"] !== "string") return [];
178
- fields["en"] = "page_view";
179
- fields["ep.page_path"] = event.data["path"];
180
- } else if (event.type === "storefront.page.viewed.v2") {
181
- if (!exactKeys(event.data, ["path", "attribution"]) || typeof event.data["path"] !== "string") return [];
182
- fields["en"] = "page_view";
183
- fields["ep.page_path"] = event.data["path"];
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") fields["ep.referrer_origin"] = origin;
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") fields[`ep.utm_${key}`] = value;
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
- } else {
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
- return config.destinationIds.map((tid) => Object.freeze({
208
- endpoint: GA4_COLLECTION_ENDPOINT,
209
- fields: Object.freeze({ ...fields, tid }),
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
- if (!release) return true;
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 requests = mapEvent(current, event);
267
- if (requests.length === 0) {
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
- // One logical event, one send per configured measurement id. Each send is
278
- // independent: a destination whose transport fails is observed on its own and
279
- // never suppresses the others.
280
- for (const request of requests) {
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(ctx) {
297
- apply(ctx);
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
- }