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