apps/web/src/lib/stape.ts

1/**
2 * Stape Custom Loader boundary.
3 *
4 * Idempotent, browser-only. Loads the Custom Loader once after Consent Mode
5 * defaults are established. Does **not** gate on marketing/`ad_storage` —
6 * analytics-only users still need the container; identity tags gate themselves.
7 *
8 * Cutover: when {@link isStapeCutoverEnabled} (valid `VITE_STAPE_LOADER_*` at
9 * build time), {@link runApprovedCustomLoader} injects the approved loader URL
10 * (no Cookie Keeper / `kp`). Incomplete/missing config keeps Google `gtm.js`.
11 */
12
13import {
14	parseStapeConfig,
15	readStapeConfigFromEnv,
16	type StapeConfigResult,
17	type StapeLoaderConfig,
18} from './stape-config'
19
20declare const __GTM_PREVIEW__: boolean
21
22/** Stable DOM id so duplicate injection is observable and preventable. */
23export const STAPE_LOADER_SCRIPT_ID = 'stape-custom-loader'
24
25export type StapeLoadOutcome =
26	| 'loaded'
27	| 'already-loaded'
28	| 'not-configured'
29	| 'blocked-pending-approved-snippet'
30	| 'ssr'
31	| 'executor-failed'
32
33export type StapeLoaderExecutor = (
34	config: StapeLoaderConfig,
35	doc: Document,
36) => boolean
37
38export type LoadStapeOnceOptions = {
39	/** Pre-parsed result, raw config, or omit to read from `import.meta.env`. */
40	config?: StapeConfigResult | StapeLoaderConfig
41	document?: Document | null
42	/** Defaults to {@link runApprovedCustomLoader}; inject in tests. */
43	executor?: StapeLoaderExecutor
44	/** Force diagnostics on/off (tests). Default: DEV or preview. */
45	diagnostics?: boolean
46}
47
48let stapeAttemptSucceeded = false
49
50function isGtmPreviewBuild(): boolean {
51	try {
52		return typeof __GTM_PREVIEW__ !== 'undefined' && __GTM_PREVIEW__ === true
53	} catch {
54		return false
55	}
56}
57
58function diagnosticsEnabled(override?: boolean): boolean {
59	if (typeof override === 'boolean') {
60		return override
61	}
62	return Boolean(import.meta.env?.DEV) || isGtmPreviewBuild()
63}
64
65/** Never log tokens, full URLs, cookies, or user ids. */
66function logStape(
67	enabled: boolean,
68	message: string,
69	detail?: Record<string, string | boolean | number>,
70): void {
71	if (!enabled) {
72		return
73	}
74	if (detail) {
75		console.info('[stape]', message, detail)
76		return
77	}
78	console.info('[stape]', message)
79}
80
81function resolveConfig(
82	input: LoadStapeOnceOptions['config'],
83): StapeConfigResult {
84	if (!input) {
85		return readStapeConfigFromEnv()
86	}
87	if ('ok' in input) {
88		return input
89	}
90	return parseStapeConfig(input)
91}
92
93/**
94 * Mutually exclusive cutover: valid build-time Stape config → Custom Loader.
95 * Missing/invalid/placeholder config → Google `gtm.js` remains active.
96 */
97export function isStapeCutoverEnabled(): boolean {
98	const config = readStapeConfigFromEnv()
99	const enabled = config.ok
100
101	// Temporary cutover debugging — never log the query token value.
102	console.info('[stape] cutover check', {
103		previewBuild: isGtmPreviewBuild(),
104		gtmPreviewDefine:
105			typeof __GTM_PREVIEW__ !== 'undefined' ? __GTM_PREVIEW__ : 'undeclared',
106		configOk: config.ok,
107		configReason: config.ok ? undefined : config.reason,
108		enabled,
109	})
110
111	return enabled
112}
113
114/** @deprecated Use {@link isStapeCutoverEnabled}. */
115export const isStapePreviewCutoverEnabled = isStapeCutoverEnabled
116
117/**
118 * Injects the approved Stape Custom Loader from config.
119 *
120 * Uses the configured origin + path + query as-is (no Safari Cookie Keeper /
121 * `kp` rewriting). Keeps {@link STAPE_LOADER_SCRIPT_ID} for duplicate detection.
122 */
123export function runApprovedCustomLoader(
124	config: StapeLoaderConfig,
125	doc: Document,
126): boolean {
127	if (typeof window === 'undefined') {
128		return false
129	}
130
131	if (!window.dataLayer) {
132		window.dataLayer = []
133	}
134	const dataLayer = window.dataLayer as Record<string, unknown>[]
135	dataLayer.push({
136		'gtm.start': Date.now(),
137		event: 'gtm.js',
138	})
139
140	const firstScript = doc.getElementsByTagName('script')[0]
141	const script = doc.createElement('script')
142	script.id = STAPE_LOADER_SCRIPT_ID
143	script.async = true
144	script.src = `${config.origin}${config.path}?${config.queryKey}=${config.queryToken}`
145
146	if (firstScript?.parentNode) {
147		firstScript.parentNode.insertBefore(script, firstScript)
148	} else {
149		;(doc.head ?? doc.documentElement).appendChild(script)
150	}
151
152	return true
153}
154
155/** Test helper — reset module guards between cases. */
156export function resetStapeLoaderForTests(): void {
157	stapeAttemptSucceeded = false
158}
159
160/**
161 * Attempt to load the Stape Custom Loader once.
162 *
163 * Returns an explicit outcome for diagnostics and tests. Incomplete config never
164 * injects a script.
165 */
166export function loadStapeOnce(
167	options: LoadStapeOnceOptions = {},
168): StapeLoadOutcome {
169	const diag = diagnosticsEnabled(options.diagnostics)
170
171	if (typeof window === 'undefined') {
172		logStape(diag, 'skipped', { reason: 'ssr' })
173		return 'ssr'
174	}
175
176	const doc =
177		options.document !== undefined
178			? options.document
179			: typeof document !== 'undefined'
180				? document
181				: null
182	if (!doc) {
183		logStape(diag, 'skipped', { reason: 'ssr' })
184		return 'ssr'
185	}
186
187	if (stapeAttemptSucceeded || doc.getElementById(STAPE_LOADER_SCRIPT_ID)) {
188		stapeAttemptSucceeded = true
189		logStape(diag, 'duplicate prevented', { reason: 'already-loaded' })
190		return 'already-loaded'
191	}
192
193	const parsed = resolveConfig(options.config)
194	if (!parsed.ok) {
195		logStape(diag, 'skipped', { reason: parsed.reason })
196		return 'not-configured'
197	}
198
199	const executor = options.executor ?? runApprovedCustomLoader
200
201	try {
202		const didLoad = executor(parsed.config, doc)
203		if (!didLoad) {
204			logStape(diag, 'blocked pending approved snippet')
205			return 'blocked-pending-approved-snippet'
206		}
207		stapeAttemptSucceeded = true
208		logStape(diag, 'loader initialized', {
209			previewCutover: isGtmPreviewBuild(),
210		})
211		return 'loaded'
212	} catch {
213		logStape(diag, 'executor failed')
214		return 'executor-failed'
215	}
216}
217