/** * Opt-in analytics injection for SSR streams. * Supported environment variables: * - `GA_MEASUREMENT_ID` — Google Analytics 4 measurement ID * - `GTM_CONTAINER_ID` — Google Tag Manager web container ID * * Netlify configuration-file env vars are build-time only for serverless * functions, so the Vite/Nitro build paths also bake this public value into * SSR bundles. * * Amplitude and Sentry are initialized client-side via their npm packages * (see `packages/core/src/client/analytics.ts`). GTM and GA require script * tag injection because their loaders must be `` + `` ); } function getGtmHeadScript(containerId: string): string { const jsId = JSON.stringify(containerId); return ``; } function getGtmBodyFallback(containerId: string): string { const src = encodeURIComponent(containerId); return ``; } type AnalyticsInjection = { head: string; body: string; }; function getAnalyticsInjection(): AnalyticsInjection | null { const containerId = getGtmContainerId(); if (containerId) { return { head: getGtmHeadScript(containerId), body: getGtmBodyFallback(containerId), }; } const gaScript = getGaScript(); return gaScript ? { head: gaScript, body: "" } : null; } const HEAD_CLOSE_PATTERN = /<\/head>/i; const BODY_OPEN_PATTERN = /]*>/i; /** * Add the configured analytics scripts to a complete HTML document. * * The normal app document is streamed through `wrapWithAnalytics`, but * framework-owned documents such as `/signup` are returned as strings by the * auth guard and need the same injection path. */ export function injectAnalyticsIntoHtml(html: string): string { const injection = getAnalyticsInjection(); if (!injection) return html; const headCloseMatch = HEAD_CLOSE_PATTERN.exec(html); if (!headCloseMatch || headCloseMatch.index === undefined) return html; let output = html.slice(0, headCloseMatch.index) + injection.head + html.slice(headCloseMatch.index); if (injection.body) { const bodyOpenMatch = BODY_OPEN_PATTERN.exec(output); if (bodyOpenMatch && bodyOpenMatch.index !== undefined) { const bodyEnd = bodyOpenMatch.index + bodyOpenMatch[0].length; output = output.slice(0, bodyEnd) + injection.body + output.slice(bodyEnd); } } return output; } export function wrapWithAnalytics( body: ReadableStream, ): ReadableStream { const injection = getAnalyticsInjection(); if (!injection) return body; const decoder = new TextDecoder(); const encoder = new TextEncoder(); let pending = ""; let headInjected = false; let bodyInjected = !injection.body; return body.pipeThrough( new TransformStream({ transform(chunk, controller) { pending += decoder.decode(chunk, { stream: true }); if (!headInjected) { const headCloseMatch = HEAD_CLOSE_PATTERN.exec(pending); if (!headCloseMatch || headCloseMatch.index === undefined) return; const headEnd = headCloseMatch.index + headCloseMatch[0].length; controller.enqueue( encoder.encode( pending.slice(0, headCloseMatch.index) + injection.head + pending.slice(headCloseMatch.index, headEnd), ), ); pending = pending.slice(headEnd); headInjected = true; } if (!bodyInjected) { const bodyOpenMatch = BODY_OPEN_PATTERN.exec(pending); if (!bodyOpenMatch || bodyOpenMatch.index === undefined) return; const bodyEnd = bodyOpenMatch.index + bodyOpenMatch[0].length; controller.enqueue( encoder.encode(pending.slice(0, bodyEnd) + injection.body), ); pending = pending.slice(bodyEnd); bodyInjected = true; } if (pending) { controller.enqueue(encoder.encode(pending)); pending = ""; } }, flush(controller) { pending += decoder.decode(); if (!headInjected) { const headCloseMatch = HEAD_CLOSE_PATTERN.exec(pending); if (headCloseMatch && headCloseMatch.index !== undefined) { const headEnd = headCloseMatch.index + headCloseMatch[0].length; controller.enqueue( encoder.encode( pending.slice(0, headCloseMatch.index) + injection.head + pending.slice(headCloseMatch.index, headEnd), ), ); pending = pending.slice(headEnd); headInjected = true; } } if (headInjected && !bodyInjected) { const bodyOpenMatch = BODY_OPEN_PATTERN.exec(pending); if (bodyOpenMatch && bodyOpenMatch.index !== undefined) { const bodyEnd = bodyOpenMatch.index + bodyOpenMatch[0].length; controller.enqueue( encoder.encode(pending.slice(0, bodyEnd) + injection.body), ); pending = pending.slice(bodyEnd); bodyInjected = true; } } if (pending) controller.enqueue(encoder.encode(pending)); }, }), ); }