diff --git a/CHANGELOG.md b/CHANGELOG.md index f46d8abc..3469c60d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## v1.0.0-beta.5...main + +[compare changes](https://github.com/nuxt/scripts/compare/v1.0.0-beta.5...main) + +### 💅 Refactors + +- Replace SW + beacon monkey-patch with AST-based API rewriting ([#614](https://github.com/nuxt/scripts/pull/614)) + +### 🏡 Chore + +- Bump deps ([814bbf6](https://github.com/nuxt/scripts/commit/814bbf6)) + +### ❤️ Contributors + +- Harlan Wilton ([@harlan-zw](https://github.com/harlan-zw)) + ## v1.0.0-beta.4...main [compare changes](https://github.com/nuxt/scripts/compare/v1.0.0-beta.4...main) diff --git a/src/plugins/transform.ts b/src/plugins/transform.ts index 5b521202..e05b8ab1 100644 --- a/src/plugins/transform.ts +++ b/src/plugins/transform.ts @@ -83,9 +83,10 @@ function normalizeScriptData(src: string, assetsBaseURL: string = '/_scripts'): if (hasProtocol(src, { acceptRelative: true })) { src = src.replace(/^\/\//, 'https://') const url = parseURL(src) - const file = [ - `${ohash(url)}.js`, // force an extension - ].filter(Boolean).join('-') + const h = ohash(url) + // Prefix hashes starting with '-' — Nitro's publicAssets handler cannot serve + // files whose names begin with a dash (they get omitted from the asset manifest). + const file = `${h.startsWith('-') ? `_${h.slice(1)}` : h}.js` const nuxt = tryUseNuxt() // Use cdnURL if available, otherwise fall back to baseURL const cdnURL = nuxt?.options.runtimeConfig?.app?.cdnURL || nuxt?.options.app?.cdnURL || '' diff --git a/src/proxy-configs.ts b/src/proxy-configs.ts index 70137f47..81f3ad99 100644 --- a/src/proxy-configs.ts +++ b/src/proxy-configs.ts @@ -146,9 +146,11 @@ function buildProxyConfig(collectPrefix: string) { privacy: { ip: true, userAgent: true, language: true, screen: true, timezone: true, hardware: true }, rewrite: [ { from: 'alb.reddit.com', to: `${collectPrefix}/reddit` }, + { from: 'pixel-config.reddit.com', to: `${collectPrefix}/reddit-cfg` }, ], routes: { [`${collectPrefix}/reddit/**`]: { proxy: 'https://alb.reddit.com/**' }, + [`${collectPrefix}/reddit-cfg/**`]: { proxy: 'https://pixel-config.reddit.com/**' }, }, }, diff --git a/src/runtime/server/proxy-handler.ts b/src/runtime/server/proxy-handler.ts index 4f0b2ef1..3aaf3e18 100644 --- a/src/runtime/server/proxy-handler.ts +++ b/src/runtime/server/proxy-handler.ts @@ -1,4 +1,4 @@ -import { defineEventHandler, getHeaders, getRequestIP, readBody, getQuery, setResponseHeader, createError } from 'h3' +import { defineEventHandler, getHeaders, getRequestIP, readBody, getRequestWebStream, getQuery, setResponseHeader, createError } from 'h3' import { useRuntimeConfig } from '#imports' import { useNitroApp } from 'nitropack/runtime' import { @@ -102,6 +102,20 @@ export default defineEventHandler(async (event) => { const privacy = globalPrivacy !== undefined ? mergePrivacy(perScriptResolved, globalPrivacy) : perScriptResolved const anyPrivacy = privacy.ip || privacy.userAgent || privacy.language || privacy.screen || privacy.timezone || privacy.hardware + // Detect binary/compressed bodies that cannot be safely parsed as text. + // These must be passed through as raw bytes to avoid corruption: + // - content-encoding: transport-level compression (gzip, br, etc.) + // - application/octet-stream: explicitly binary content + // - ?compression=gzip-js: client-side compression (e.g. PostHog sends gzip bytes as text/plain) + const originalHeaders = getHeaders(event) + const contentType = originalHeaders['content-type'] || '' + const compressionParam = new URL(event.path, 'http://localhost').searchParams.get('compression') + const isBinaryBody = Boolean( + originalHeaders['content-encoding'] + || contentType.includes('octet-stream') + || (compressionParam && /gzip|deflate|br|compress|base64/i.test(compressionParam)), + ) + // Build target URL with stripped query params let targetPath = path.slice(matchedPrefix.length) // Ensure path starts with / @@ -121,8 +135,6 @@ export default defineEventHandler(async (event) => { } } - // Get original headers - const originalHeaders = getHeaders(event) const headers: Record = {} // Process headers based on per-flag privacy @@ -133,8 +145,13 @@ export default defineEventHandler(async (event) => { // SENSITIVE_HEADERS always stripped regardless of privacy flags if (SENSITIVE_HEADERS.includes(lowerKey)) continue - // Skip content-length when any privacy is active — body may be modified - if (anyPrivacy && lowerKey === 'content-length') continue + // Skip content-length when body will be modified by privacy transforms + // (preserved for binary passthrough and no-privacy paths) + if (lowerKey === 'content-length') { + if (anyPrivacy && !isBinaryBody) continue + headers[lowerKey] = value + continue + } // IP-revealing headers — controlled by ip flag if (lowerKey === 'x-forwarded-for' || lowerKey === 'x-real-ip' || lowerKey === 'forwarded' @@ -198,64 +215,85 @@ export default defineEventHandler(async (event) => { .join(', ') } - // Read and process request body if present - let body: string | Record | undefined + // Process request body: either stream through raw or read + transform + let body: string | Record | unknown[] | undefined let rawBody: unknown - const contentType = originalHeaders['content-type'] || '' + // When true, body is not read — the raw request stream is piped directly to upstream + let passthroughBody = false const method = event.method?.toUpperCase() const originalQuery = getQuery(event) + const isWriteMethod = method === 'POST' || method === 'PUT' || method === 'PATCH' - if (method === 'POST' || method === 'PUT' || method === 'PATCH') { - rawBody = await readBody(event) - - if (anyPrivacy && rawBody) { - if (typeof rawBody === 'object') { - // JSON body - strip fingerprinting recursively - body = stripPayloadFingerprinting(rawBody as Record, privacy) - } - else if (typeof rawBody === 'string') { - // Try parsing as JSON first (sendBeacon often sends JSON with text/plain content-type) - if (rawBody.startsWith('{') || rawBody.startsWith('[')) { - let parsed: unknown = null - try { - parsed = JSON.parse(rawBody) + if (isWriteMethod) { + if (isBinaryBody || !anyPrivacy) { + // No transforms needed — don't read the body at all, stream it through directly. + passthroughBody = true + } + else { + // Text body with privacy transforms — parse and strip fingerprinting + rawBody = await readBody(event) + + if (rawBody != null) { + if (Array.isArray(rawBody)) { + // JSON array body (e.g. batch payloads) — strip each element individually + body = rawBody.map(item => + item && typeof item === 'object' && !Array.isArray(item) + ? stripPayloadFingerprinting(item as Record, privacy) + : item, + ) + } + else if (typeof rawBody === 'object') { + // JSON object body - strip fingerprinting recursively + body = stripPayloadFingerprinting(rawBody as Record, privacy) + } + else if (typeof rawBody === 'string') { + // Try parsing as JSON first (sendBeacon often sends JSON with text/plain content-type) + if (rawBody.startsWith('{') || rawBody.startsWith('[')) { + let parsed: unknown = null + try { + parsed = JSON.parse(rawBody) + } + catch { /* not valid JSON */ } + + if (Array.isArray(parsed)) { + body = parsed.map(item => + item && typeof item === 'object' && !Array.isArray(item) + ? stripPayloadFingerprinting(item as Record, privacy) + : item, + ) + } + else if (parsed && typeof parsed === 'object') { + body = stripPayloadFingerprinting(parsed as Record, privacy) + } + else { + body = rawBody + } } - catch { /* not valid JSON */ } - - if (parsed && typeof parsed === 'object') { - body = stripPayloadFingerprinting(parsed as Record, privacy) + else if (contentType.includes('application/x-www-form-urlencoded')) { + // URL-encoded form data + const params = new URLSearchParams(rawBody) + const obj: Record = {} + params.forEach((value, key) => { + obj[key] = value + }) + const stripped = stripPayloadFingerprinting(obj, privacy) + // Convert all values to strings — URLSearchParams coerces non-strings + // to "[object Object]" which corrupts nested objects/arrays + const stringified: Record = {} + for (const [k, v] of Object.entries(stripped)) { + if (v === undefined || v === null) continue + stringified[k] = typeof v === 'string' ? v : JSON.stringify(v) + } + body = new URLSearchParams(stringified).toString() } else { body = rawBody } } - else if (contentType.includes('application/x-www-form-urlencoded')) { - // URL-encoded form data - const params = new URLSearchParams(rawBody) - const obj: Record = {} - params.forEach((value, key) => { - obj[key] = value - }) - const stripped = stripPayloadFingerprinting(obj, privacy) - // Convert all values to strings — URLSearchParams coerces non-strings - // to "[object Object]" which corrupts nested objects/arrays - const stringified: Record = {} - for (const [k, v] of Object.entries(stripped)) { - if (v === undefined || v === null) continue - stringified[k] = typeof v === 'string' ? v : JSON.stringify(v) - } - body = new URLSearchParams(stringified).toString() - } else { - body = rawBody + body = rawBody as string } } - else { - body = rawBody as string - } - } - else { - body = rawBody as string | Record } } @@ -266,15 +304,16 @@ export default defineEventHandler(async (event) => { targetUrl, method: method || 'GET', privacy, + passthroughBody, original: { headers: { ...originalHeaders }, query: originalQuery, - body: rawBody ?? null, + body: passthroughBody ? '' : (rawBody ?? null), }, stripped: { headers, query: anyPrivacy ? stripPayloadFingerprinting(originalQuery, privacy) : originalQuery, - body: body ?? null, + body: passthroughBody ? '' : (body ?? null), }, }) @@ -284,14 +323,25 @@ export default defineEventHandler(async (event) => { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 15000) // 15s timeout + // Resolve the fetch body: passthrough streams the raw request, otherwise serialize + let fetchBody: BodyInit | undefined + if (passthroughBody) { + fetchBody = getRequestWebStream(event) as BodyInit | undefined + } + else if (body !== undefined) { + fetchBody = typeof body === 'string' ? body : JSON.stringify(body) + } + let response: Response try { response = await fetch(targetUrl, { method: method || 'GET', headers, - body: body ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined, + body: fetchBody, credentials: 'omit', // Don't send cookies to third parties signal: controller.signal, + // @ts-expect-error Node fetch supports duplex for streaming request bodies + duplex: passthroughBody ? 'half' : undefined, }) } catch (err: unknown) { diff --git a/test/e2e/__snapshots__/proxy/clarity.json b/test/e2e/__snapshots__/proxy/clarity.json new file mode 100644 index 00000000..562e2de2 --- /dev/null +++ b/test/e2e/__snapshots__/proxy/clarity.json @@ -0,0 +1,23 @@ +[ + { + "method": "GET", + "original": { + "body": null, + "query": {}, + }, + "path": "/_proxy/clarity-scripts/0.8.56/clarity.js", + "privacy": { + "hardware": true, + "ip": true, + "language": true, + "screen": false, + "timezone": false, + "userAgent": false, + }, + "stripped": { + "body": null, + "query": {}, + }, + "targetUrl": "https://scripts.clarity.ms/0.8.56/clarity.js", + }, +] \ No newline at end of file diff --git a/test/e2e/__snapshots__/proxy/clarity/scripts.clarity.ms~0.8.56~clarity.js.diff.json b/test/e2e/__snapshots__/proxy/clarity/scripts.clarity.ms~0.8.56~clarity.js.diff.json new file mode 100644 index 00000000..a786114e --- /dev/null +++ b/test/e2e/__snapshots__/proxy/clarity/scripts.clarity.ms~0.8.56~clarity.js.diff.json @@ -0,0 +1,10 @@ +{ + "headers": { + "x-forwarded-for": { + "anonymized": "127.0.0.0", + "original": "", + }, + }, + "method": "GET", + "target": "scripts.clarity.ms/0.8.56/clarity.js", +} \ No newline at end of file diff --git a/test/e2e/__snapshots__/proxy/metaPixel.json b/test/e2e/__snapshots__/proxy/metaPixel.json index fdbd72fe..6a4b951b 100644 --- a/test/e2e/__snapshots__/proxy/metaPixel.json +++ b/test/e2e/__snapshots__/proxy/metaPixel.json @@ -5,13 +5,13 @@ "body": null, "query": { "domain": "127.0.0.1", - "ex_m": "100,192,141,22,69,70,134,65,64,11,149,86,16,128,121,72,75,127,146,151,8,4,5,7,6,3,87,97,152,157,206,59,173,174,52,250,30,71,218,217,216,23,32,99,58,10,60,93,94,95,101,124,31,29,126,123,122,142,73,145,143,144,47,57,117,15,148,42,238,239,237,26,27,28,45,135,74,108,18,20,41,37,39,38,80,88,92,106,133,136,43,107,24,21,113,66,35,138,137,139,130,129,25,34,56,105,147,67,17,140,110,78,63,19,81,82,33,262,199,188,189,187,265,257,49,200,103,125,77,115,51,44,46,109,114,120,55,61,50,53,96,150,1,118,14,116,12,2,54,89,62,112,85,84,153,154,90,91,9,119,98,48,131,83,76,68,111,102,40,132,0,79,36,104,13,155", - "hme": "243a8305e15c8bf3a50c0d350a428553388d507240cf13e95dd0abfb8651365d", + "ex_m": "100,193,142,22,69,70,135,65,64,11,150,86,16,129,122,72,75,128,147,152,8,4,5,7,6,3,87,97,153,158,207,59,174,175,52,251,30,71,219,218,217,23,32,99,58,10,60,93,94,95,101,125,31,29,127,124,123,143,73,146,144,145,47,57,118,15,149,42,239,240,238,26,27,28,45,136,74,108,18,20,41,37,39,38,80,88,92,106,134,137,43,107,24,21,114,66,35,139,138,140,131,130,25,34,56,105,148,67,17,141,110,78,63,19,81,82,111,33,264,200,189,190,188,267,259,49,201,103,126,77,116,51,44,46,109,115,121,55,61,50,53,96,151,1,119,14,117,12,2,54,89,62,113,85,84,154,155,90,91,9,120,98,48,132,83,76,68,112,102,40,133,0,79,36,104,13,156", + "hme": "8830461b0a3fda5230edea4335366eb6d682f53a525e54f7adf6ff7b70c96c39", "r": "stable", - "v": "2.9.272", + "v": "2.9.274", }, }, - "path": "/_proxy/meta/signals/config/3925006?v=2.9.272&r=stable&domain=127.0.0.1&hme=243a8305e15c8bf3a50c0d350a428553388d507240cf13e95dd0abfb8651365d&ex_m=100%2C192%2C141%2C22%2C69%2C70%2C134%2C65%2C64%2C11%2C149%2C86%2C16%2C128%2C121%2C72%2C75%2C127%2C146%2C151%2C8%2C4%2C5%2C7%2C6%2C3%2C87%2C97%2C152%2C157%2C206%2C59%2C173%2C174%2C52%2C250%2C30%2C71%2C218%2C217%2C216%2C23%2C32%2C99%2C58%2C10%2C60%2C93%2C94%2C95%2C101%2C124%2C31%2C29%2C126%2C123%2C122%2C142%2C73%2C145%2C143%2C144%2C47%2C57%2C117%2C15%2C148%2C42%2C238%2C239%2C237%2C26%2C27%2C28%2C45%2C135%2C74%2C108%2C18%2C20%2C41%2C37%2C39%2C38%2C80%2C88%2C92%2C106%2C133%2C136%2C43%2C107%2C24%2C21%2C113%2C66%2C35%2C138%2C137%2C139%2C130%2C129%2C25%2C34%2C56%2C105%2C147%2C67%2C17%2C140%2C110%2C78%2C63%2C19%2C81%2C82%2C33%2C262%2C199%2C188%2C189%2C187%2C265%2C257%2C49%2C200%2C103%2C125%2C77%2C115%2C51%2C44%2C46%2C109%2C114%2C120%2C55%2C61%2C50%2C53%2C96%2C150%2C1%2C118%2C14%2C116%2C12%2C2%2C54%2C89%2C62%2C112%2C85%2C84%2C153%2C154%2C90%2C91%2C9%2C119%2C98%2C48%2C131%2C83%2C76%2C68%2C111%2C102%2C40%2C132%2C0%2C79%2C36%2C104%2C13%2C155", + "path": "/_proxy/meta/signals/config/3925006?v=2.9.274&r=stable&domain=127.0.0.1&hme=8830461b0a3fda5230edea4335366eb6d682f53a525e54f7adf6ff7b70c96c39&ex_m=100%2C193%2C142%2C22%2C69%2C70%2C135%2C65%2C64%2C11%2C150%2C86%2C16%2C129%2C122%2C72%2C75%2C128%2C147%2C152%2C8%2C4%2C5%2C7%2C6%2C3%2C87%2C97%2C153%2C158%2C207%2C59%2C174%2C175%2C52%2C251%2C30%2C71%2C219%2C218%2C217%2C23%2C32%2C99%2C58%2C10%2C60%2C93%2C94%2C95%2C101%2C125%2C31%2C29%2C127%2C124%2C123%2C143%2C73%2C146%2C144%2C145%2C47%2C57%2C118%2C15%2C149%2C42%2C239%2C240%2C238%2C26%2C27%2C28%2C45%2C136%2C74%2C108%2C18%2C20%2C41%2C37%2C39%2C38%2C80%2C88%2C92%2C106%2C134%2C137%2C43%2C107%2C24%2C21%2C114%2C66%2C35%2C139%2C138%2C140%2C131%2C130%2C25%2C34%2C56%2C105%2C148%2C67%2C17%2C141%2C110%2C78%2C63%2C19%2C81%2C82%2C111%2C33%2C264%2C200%2C189%2C190%2C188%2C267%2C259%2C49%2C201%2C103%2C126%2C77%2C116%2C51%2C44%2C46%2C109%2C115%2C121%2C55%2C61%2C50%2C53%2C96%2C151%2C1%2C119%2C14%2C117%2C12%2C2%2C54%2C89%2C62%2C113%2C85%2C84%2C154%2C155%2C90%2C91%2C9%2C120%2C98%2C48%2C132%2C83%2C76%2C68%2C112%2C102%2C40%2C133%2C0%2C79%2C36%2C104%2C13%2C156", "privacy": { "hardware": true, "ip": true, @@ -24,13 +24,13 @@ "body": null, "query": { "domain": "127.0.0.1", - "ex_m": "100,192,141,22,69,70,134,65,64,11,149,86,16,128,121,72,75,127,146,151,8,4,5,7,6,3,87,97,152,157,206,59,173,174,52,250,30,71,218,217,216,23,32,99,58,10,60,93,94,95,101,124,31,29,126,123,122,142,73,145,143,144,47,57,117,15,148,42,238,239,237,26,27,28,45,135,74,108,18,20,41,37,39,38,80,88,92,106,133,136,43,107,24,21,113,66,35,138,137,139,130,129,25,34,56,105,147,67,17,140,110,78,63,19,81,82,33,262,199,188,189,187,265,257,49,200,103,125,77,115,51,44,46,109,114,120,55,61,50,53,96,150,1,118,14,116,12,2,54,89,62,112,85,84,153,154,90,91,9,119,98,48,131,83,76,68,111,102,40,132,0,79,36,104,13,155", - "hme": "243a8305e15c8bf3a50c0d350a428553388d507240cf13e95dd0abfb8651365d", + "ex_m": "100,193,142,22,69,70,135,65,64,11,150,86,16,129,122,72,75,128,147,152,8,4,5,7,6,3,87,97,153,158,207,59,174,175,52,251,30,71,219,218,217,23,32,99,58,10,60,93,94,95,101,125,31,29,127,124,123,143,73,146,144,145,47,57,118,15,149,42,239,240,238,26,27,28,45,136,74,108,18,20,41,37,39,38,80,88,92,106,134,137,43,107,24,21,114,66,35,139,138,140,131,130,25,34,56,105,148,67,17,141,110,78,63,19,81,82,111,33,264,200,189,190,188,267,259,49,201,103,126,77,116,51,44,46,109,115,121,55,61,50,53,96,151,1,119,14,117,12,2,54,89,62,113,85,84,154,155,90,91,9,120,98,48,132,83,76,68,112,102,40,133,0,79,36,104,13,156", + "hme": "8830461b0a3fda5230edea4335366eb6d682f53a525e54f7adf6ff7b70c96c39", "r": "stable", - "v": "2.9.272", + "v": "2.9.274", }, }, - "targetUrl": "https://connect.facebook.net/signals/config/3925006?v=2.9.272&r=stable&domain=127.0.0.1&hme=243a8305e15c8bf3a50c0d350a428553388d507240cf13e95dd0abfb8651365d&ex_m=100%2C192%2C141%2C22%2C69%2C70%2C134%2C65%2C64%2C11%2C149%2C86%2C16%2C128%2C121%2C72%2C75%2C127%2C146%2C151%2C8%2C4%2C5%2C7%2C6%2C3%2C87%2C97%2C152%2C157%2C206%2C59%2C173%2C174%2C52%2C250%2C30%2C71%2C218%2C217%2C216%2C23%2C32%2C99%2C58%2C10%2C60%2C93%2C94%2C95%2C101%2C124%2C31%2C29%2C126%2C123%2C122%2C142%2C73%2C145%2C143%2C144%2C47%2C57%2C117%2C15%2C148%2C42%2C238%2C239%2C237%2C26%2C27%2C28%2C45%2C135%2C74%2C108%2C18%2C20%2C41%2C37%2C39%2C38%2C80%2C88%2C92%2C106%2C133%2C136%2C43%2C107%2C24%2C21%2C113%2C66%2C35%2C138%2C137%2C139%2C130%2C129%2C25%2C34%2C56%2C105%2C147%2C67%2C17%2C140%2C110%2C78%2C63%2C19%2C81%2C82%2C33%2C262%2C199%2C188%2C189%2C187%2C265%2C257%2C49%2C200%2C103%2C125%2C77%2C115%2C51%2C44%2C46%2C109%2C114%2C120%2C55%2C61%2C50%2C53%2C96%2C150%2C1%2C118%2C14%2C116%2C12%2C2%2C54%2C89%2C62%2C112%2C85%2C84%2C153%2C154%2C90%2C91%2C9%2C119%2C98%2C48%2C131%2C83%2C76%2C68%2C111%2C102%2C40%2C132%2C0%2C79%2C36%2C104%2C13%2C155", + "targetUrl": "https://connect.facebook.net/signals/config/3925006?v=2.9.274&r=stable&domain=127.0.0.1&hme=8830461b0a3fda5230edea4335366eb6d682f53a525e54f7adf6ff7b70c96c39&ex_m=100%2C193%2C142%2C22%2C69%2C70%2C135%2C65%2C64%2C11%2C150%2C86%2C16%2C129%2C122%2C72%2C75%2C128%2C147%2C152%2C8%2C4%2C5%2C7%2C6%2C3%2C87%2C97%2C153%2C158%2C207%2C59%2C174%2C175%2C52%2C251%2C30%2C71%2C219%2C218%2C217%2C23%2C32%2C99%2C58%2C10%2C60%2C93%2C94%2C95%2C101%2C125%2C31%2C29%2C127%2C124%2C123%2C143%2C73%2C146%2C144%2C145%2C47%2C57%2C118%2C15%2C149%2C42%2C239%2C240%2C238%2C26%2C27%2C28%2C45%2C136%2C74%2C108%2C18%2C20%2C41%2C37%2C39%2C38%2C80%2C88%2C92%2C106%2C134%2C137%2C43%2C107%2C24%2C21%2C114%2C66%2C35%2C139%2C138%2C140%2C131%2C130%2C25%2C34%2C56%2C105%2C148%2C67%2C17%2C141%2C110%2C78%2C63%2C19%2C81%2C82%2C111%2C33%2C264%2C200%2C189%2C190%2C188%2C267%2C259%2C49%2C201%2C103%2C126%2C77%2C116%2C51%2C44%2C46%2C109%2C115%2C121%2C55%2C61%2C50%2C53%2C96%2C151%2C1%2C119%2C14%2C117%2C12%2C2%2C54%2C89%2C62%2C113%2C85%2C84%2C154%2C155%2C90%2C91%2C9%2C120%2C98%2C48%2C132%2C83%2C76%2C68%2C112%2C102%2C40%2C133%2C0%2C79%2C36%2C104%2C13%2C156", }, { "method": "GET", @@ -52,10 +52,10 @@ "sh": "720", "sw": "1280", "ts": "", - "v": "2.9.272", + "v": "2.9.274", }, }, - "path": "/_proxy/meta-tr/?id=3925006&ev=PageView&dl=http%3A%2F%2F127.0.0.1%3A%2Fmeta&rl=&if=false&ts=&sw=1280&sh=720&v=2.9.272&r=stable&ec=0&o=156&it=&coo=false&expv2=&rqm=GET", + "path": "/_proxy/meta-tr/?id=3925006&ev=PageView&dl=http%3A%2F%2F127.0.0.1%3A%2Fmeta&rl=&if=false&ts=&sw=1280&sh=720&v=2.9.274&r=stable&ec=0&o=156&it=&coo=false&expv2=&rqm=GET", "privacy": { "hardware": true, "ip": true, @@ -82,10 +82,10 @@ "sh": "1080", "sw": "1920", "ts": "", - "v": "2.9.272", + "v": "2.9.274", }, }, - "targetUrl": "https://www.facebook.com/tr/?id=3925006&ev=PageView&dl=http%3A%2F%2F127.0.0.1%3A%2Fmeta&rl=&if=false&ts=&sw=1920&sh=1080&v=2.9.272&r=stable&ec=0&o=156&it=&coo=false&expv2=&rqm=GET", + "targetUrl": "https://www.facebook.com/tr/?id=3925006&ev=PageView&dl=http%3A%2F%2F127.0.0.1%3A%2Fmeta&rl=&if=false&ts=&sw=1920&sh=1080&v=2.9.274&r=stable&ec=0&o=156&it=&coo=false&expv2=&rqm=GET", }, { "method": "GET", @@ -109,10 +109,10 @@ "sh": "720", "sw": "1280", "ts": "", - "v": "2.9.272", + "v": "2.9.274", }, }, - "path": "/_proxy/meta-tr/?id=3925006&ev=ViewContent&dl=http%3A%2F%2F127.0.0.1%3A%2Fmeta&rl=&if=false&ts=&cd[content_name]=Test%20Product&cd[content_category]=Testing&sw=1280&sh=720&v=2.9.272&r=stable&ec=1&o=156&it=&coo=false&expv2=&rqm=GET", + "path": "/_proxy/meta-tr/?id=3925006&ev=ViewContent&dl=http%3A%2F%2F127.0.0.1%3A%2Fmeta&rl=&if=false&ts=&cd[content_name]=Test%20Product&cd[content_category]=Testing&sw=1280&sh=720&v=2.9.274&r=stable&ec=1&o=156&it=&coo=false&expv2=&rqm=GET", "privacy": { "hardware": true, "ip": true, @@ -141,9 +141,9 @@ "sh": "1080", "sw": "1920", "ts": "", - "v": "2.9.272", + "v": "2.9.274", }, }, - "targetUrl": "https://www.facebook.com/tr/?id=3925006&ev=ViewContent&dl=http%3A%2F%2F127.0.0.1%3A%2Fmeta&rl=&if=false&ts=&cd%5Bcontent_name%5D=Test+Product&cd%5Bcontent_category%5D=Testing&sw=1920&sh=1080&v=2.9.272&r=stable&ec=1&o=156&it=&coo=false&expv2=&rqm=GET", + "targetUrl": "https://www.facebook.com/tr/?id=3925006&ev=ViewContent&dl=http%3A%2F%2F127.0.0.1%3A%2Fmeta&rl=&if=false&ts=&cd%5Bcontent_name%5D=Test+Product&cd%5Bcontent_category%5D=Testing&sw=1920&sh=1080&v=2.9.274&r=stable&ec=1&o=156&it=&coo=false&expv2=&rqm=GET", }, ] \ No newline at end of file diff --git a/test/e2e/__snapshots__/proxy/metaPixel/connect.facebook.net~signals~config~3925006.diff.json b/test/e2e/__snapshots__/proxy/metaPixel/connect.facebook.net~signals~config~3925006.diff.json index d438c984..d2ffcb28 100644 --- a/test/e2e/__snapshots__/proxy/metaPixel/connect.facebook.net~signals~config~3925006.diff.json +++ b/test/e2e/__snapshots__/proxy/metaPixel/connect.facebook.net~signals~config~3925006.diff.json @@ -1,9 +1,5 @@ { "headers": { - "cookie": { - "anonymized": "", - "original": "_rdt_uuid=; _scid=; _scid_r=", - }, "user-agent": { "anonymized": "Mozilla/5.0 (compatible; Chrome/145.0)", "original": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/145.0.7632.6 Safari/537.36", diff --git a/test/e2e/__snapshots__/proxy/metaPixel/facebook.com~tr.diff.json b/test/e2e/__snapshots__/proxy/metaPixel/facebook.com~tr.diff.json index 7ad82303..96b04b46 100644 --- a/test/e2e/__snapshots__/proxy/metaPixel/facebook.com~tr.diff.json +++ b/test/e2e/__snapshots__/proxy/metaPixel/facebook.com~tr.diff.json @@ -1,9 +1,5 @@ { "headers": { - "cookie": { - "anonymized": "", - "original": "_rdt_uuid=; _scid=; _scid_r=; _ga=; _ga_TR58L0EF8P=", - }, "user-agent": { "anonymized": "Mozilla/5.0 (compatible; Chrome/145.0)", "original": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/145.0.7632.6 Safari/537.36", diff --git a/test/e2e/__snapshots__/proxy/metaPixel/facebook.com~tr~2.diff.json b/test/e2e/__snapshots__/proxy/metaPixel/facebook.com~tr~2.diff.json index 311e5618..96b04b46 100644 --- a/test/e2e/__snapshots__/proxy/metaPixel/facebook.com~tr~2.diff.json +++ b/test/e2e/__snapshots__/proxy/metaPixel/facebook.com~tr~2.diff.json @@ -1,9 +1,5 @@ { "headers": { - "cookie": { - "anonymized": "", - "original": "_rdt_uuid=; _scid=; _scid_r=; _ga=; _ga_TR58L0EF8P=; ajs_anonymous_id=", - }, "user-agent": { "anonymized": "Mozilla/5.0 (compatible; Chrome/145.0)", "original": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/145.0.7632.6 Safari/537.36", diff --git a/test/e2e/__snapshots__/proxy/xPixel.json b/test/e2e/__snapshots__/proxy/xPixel.json index 3fd30d70..fe586956 100644 --- a/test/e2e/__snapshots__/proxy/xPixel.json +++ b/test/e2e/__snapshots__/proxy/xPixel.json @@ -21,7 +21,7 @@ "version": "2.3.37", }, }, - "path": "/_proxy/x/1/i/adsct?bci=4&dv=&eci=3&event=%7B%7D&event_id=&integration=advertiser&p_id=Twitter&p_user_id=0&pl_id=&pt=X%20Pixel%20-%20First%20Party&tw_document_href=http%3A%2F%2F127.0.0.1%3A%2Fx&tw_iframe_status=0&txn_id=ol7lz&type=javascript&version=2.3.37", + "path": "/_proxy/x-t/1/i/adsct?bci=4&dv=&eci=3&event=%7B%7D&event_id=&integration=advertiser&p_id=Twitter&p_user_id=0&pl_id=&pt=X%20Pixel%20-%20First%20Party&tw_document_href=http%3A%2F%2F127.0.0.1%3A%2Fx&tw_iframe_status=0&txn_id=ol7lz&type=javascript&version=2.3.37", "privacy": { "hardware": true, "ip": true, @@ -50,6 +50,6 @@ "version": "2.3.37", }, }, - "targetUrl": "https://analytics.twitter.com/1/i/adsct?bci=4&dv=UTC%26en-GB%26Google+Inc.%26Linux+x86_64%26255%261920%261080%2624%2624%261920%261080%260%26na&eci=3&event=%7B%7D&event_id=&integration=advertiser&p_id=Twitter&p_user_id=0&pl_id=&pt=X+Pixel+-+First+Party&tw_document_href=http%3A%2F%2F127.0.0.1%3A%2Fx&tw_iframe_status=0&txn_id=ol7lz&type=javascript&version=2.3.37", + "targetUrl": "https://t.co/1/i/adsct?bci=4&dv=UTC%26en-GB%26Google+Inc.%26Linux+x86_64%26255%261920%261080%2624%2624%261920%261080%260%26na&eci=3&event=%7B%7D&event_id=&integration=advertiser&p_id=Twitter&p_user_id=0&pl_id=&pt=X+Pixel+-+First+Party&tw_document_href=http%3A%2F%2F127.0.0.1%3A%2Fx&tw_iframe_status=0&txn_id=ol7lz&type=javascript&version=2.3.37", }, ] \ No newline at end of file diff --git a/test/e2e/first-party.test.ts b/test/e2e/first-party.test.ts index 333defc4..fb0b9691 100644 --- a/test/e2e/first-party.test.ts +++ b/test/e2e/first-party.test.ts @@ -930,42 +930,6 @@ describe('first-party privacy stripping', () => { * so unit tests alone are insufficient. */ describe('no script errors from proxy rewrites', () => { - /** - * Errors from third-party scripts unrelated to our rewrite logic. - * These occur in headless browsers regardless of proxy mode. - */ - const KNOWN_THIRD_PARTY_NOISE = [ - /Failed to load resource/i, // Network errors, 404s, CDN auth - /net::ERR_/i, // Chrome network errors - /Refused to connect/i, // CSP - /Tracking Prevention/i, // Browser tracking prevention - /favicon/i, // favicon 404 - /The source list for Content Security Policy/i, - /Permissions policy/i, - /third-party cookie/i, - ] - - /** Patterns that indicate the error is from a proxy-rewritten script (high confidence) */ - const PROXY_ERROR_INDICATORS = [ - /_proxy/, - /_scripts\/c/, - /self\.location/, - /SyntaxError/i, - /cannot be parsed as a URL/i, - /Invalid URL/i, - /Unexpected token/i, - /ERR_NAME_NOT_RESOLVED/i, - /Failed to construct 'URL'/i, - ] - - function isKnownNoise(text: string): boolean { - return KNOWN_THIRD_PARTY_NOISE.some(p => p.test(text)) - } - - function isProxyRelated(text: string): boolean { - return PROXY_ERROR_INDICATORS.some(p => p.test(text)) - } - const providerPages = [ { name: 'googleAnalytics', path: '/ga' }, { name: 'googleTagManager', path: '/gtm' }, @@ -985,6 +949,7 @@ describe('first-party privacy stripping', () => { { name: 'fathomAnalytics', path: '/fathom' }, { name: 'intercom', path: '/intercom-test' }, { name: 'crisp', path: '/crisp-test' }, + { name: 'posthog', path: '/posthog' }, ] it.each(providerPages)('$name page has no script errors', async ({ name, path: pagePath }) => { @@ -992,48 +957,34 @@ describe('first-party privacy stripping', () => { const page = await browser.newPage() page.setDefaultTimeout(5000) - const consoleErrors: { type: string, text: string }[] = [] const uncaughtErrors: string[] = [] - const failedProxyRequests: { url: string, status: number }[] = [] - - // Capture console errors - page.on('console', (msg) => { - const type = msg.type() - if (type === 'error') { - const text = msg.text() - if (!isKnownNoise(text)) { - consoleErrors.push({ type, text }) - } - } - }) + const failedLocalRequests: { url: string, status: number }[] = [] + let serverOrigin = '' - // Capture ALL uncaught exceptions — any uncaught error from a rewritten - // script is a bug (SyntaxError, TypeError, ReferenceError, etc.) page.on('pageerror', (err) => { - const text = err.message || String(err) - if (!isKnownNoise(text)) { - uncaughtErrors.push(text) - } + uncaughtErrors.push(err.message || String(err)) }) - // Capture failed proxy requests — 404s from /_proxy/ paths indicate - // broken rewrite rules or missing route handlers + // Catch failed responses from our server (/_proxy/ and /_scripts/). + // External 4xx from third-party services with test keys is expected. page.on('response', (response) => { const reqUrl = response.url() const status = response.status() - if (reqUrl.includes('/_proxy/') && status >= 400) { - failedProxyRequests.push({ url: reqUrl, status }) + if (!serverOrigin) { + try { serverOrigin = new URL(reqUrl).origin } + catch {} + } + if (status >= 400 && serverOrigin && reqUrl.startsWith(serverOrigin)) { + failedLocalRequests.push({ url: new URL(reqUrl).pathname, status }) } }) await page.goto(url(pagePath), { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {}) - // Verify page actually rendered — if not, the test is meaningless const pageRendered = await page.waitForSelector('#status', { timeout: 8000 }) .then(() => true) .catch(() => false) - // Wait for scripts to load and execute if (pageRendered) { await page.waitForSelector('#status:has-text("loaded")', { timeout: 8000 }).catch(() => {}) } @@ -1041,28 +992,16 @@ describe('first-party privacy stripping', () => { await page.close() - // Guard: if the page didn't render at all, something is wrong with the - // test infrastructure — fail explicitly instead of passing vacuously. - expect(pageRendered, `${name}: Page did not render — test is meaningless without a rendered page`).toBe(true) + expect(pageRendered, `${name}: Page did not render`).toBe(true) - // Assert no uncaught exceptions at all — these indicate broken scripts - // regardless of whether the error message mentions proxying expect( uncaughtErrors, - `${name}: Uncaught exceptions detected:\n${uncaughtErrors.map(e => ` ${e}`).join('\n')}`, + `${name}: Uncaught exceptions:\n${uncaughtErrors.map(e => ` ${e}`).join('\n')}`, ).toEqual([]) - // Assert no proxy-related console errors - const proxyConsoleErrors = consoleErrors.filter(e => isProxyRelated(e.text)) expect( - proxyConsoleErrors, - `${name}: Proxy-related console errors:\n${proxyConsoleErrors.map(e => ` [${e.type}] ${e.text}`).join('\n')}`, - ).toEqual([]) - - // Assert no failed proxy requests (404s, 500s from /_proxy/ paths) - expect( - failedProxyRequests, - `${name}: Failed proxy requests:\n${failedProxyRequests.map(r => ` ${r.status} ${r.url}`).join('\n')}`, + failedLocalRequests, + `${name}: Failed local requests:\n${failedLocalRequests.map(r => ` ${r.status} ${r.url}`).join('\n')}`, ).toEqual([]) }, 30000) }) @@ -1102,4 +1041,81 @@ describe('first-party privacy stripping', () => { } }) }) + + /** + * Diagnostic: verify each provider loads a bundled script and/or makes proxy requests. + * This test documents the observed bundle/proxy behavior for every provider. + */ + describe('bundle and proxy coverage', () => { + const allProviders = [ + { name: 'googleAnalytics', path: '/ga' }, + { name: 'googleTagManager', path: '/gtm' }, + { name: 'metaPixel', path: '/meta' }, + { name: 'tiktokPixel', path: '/tiktok' }, + { name: 'clarity', path: '/clarity' }, + { name: 'hotjar', path: '/hotjar' }, + { name: 'segment', path: '/segment' }, + { name: 'xPixel', path: '/x' }, + { name: 'snapchatPixel', path: '/snap' }, + { name: 'redditPixel', path: '/reddit' }, + { name: 'plausibleAnalytics', path: '/plausible' }, + { name: 'cloudflareWebAnalytics', path: '/cfwa' }, + { name: 'rybbitAnalytics', path: '/rybbit' }, + { name: 'umamiAnalytics', path: '/umami' }, + { name: 'databuddyAnalytics', path: '/databuddy' }, + { name: 'fathomAnalytics', path: '/fathom' }, + { name: 'intercom', path: '/intercom-test' }, + { name: 'crisp', path: '/crisp-test' }, + { name: 'posthog', path: '/posthog' }, + ] + + it.each(allProviders)('$name loads bundled script from /_scripts/', async ({ name, path: pagePath }) => { + const browser = await getBrowser() + const page = await browser.newPage() + page.setDefaultTimeout(5000) + + const scriptRequests: { url: string, status: number }[] = [] + const proxyRequests: { url: string, status: number }[] = [] + const consoleErrors: string[] = [] + const consoleWarnings: string[] = [] + + page.on('console', (msg) => { + if (msg.type() === 'error') consoleErrors.push(msg.text()) + if (msg.type() === 'warning') consoleWarnings.push(msg.text()) + }) + + page.on('response', (response) => { + const reqUrl = response.url() + const status = response.status() + const pathname = new URL(reqUrl).pathname + if (pathname.startsWith('/_scripts/')) + scriptRequests.push({ url: pathname, status }) + if (pathname.startsWith('/_proxy/')) + proxyRequests.push({ url: pathname, status }) + }) + + await page.goto(url(pagePath), { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {}) + await page.waitForSelector('#status:has-text("loaded")', { timeout: 8000 }).catch(() => {}) + await page.waitForTimeout(2000) + await page.close() + + // Every provider should load at least one bundled script from /_scripts/ + const okScripts = scriptRequests.filter(r => r.status < 400) + expect( + okScripts.length, + `${name}: No bundled scripts loaded.\n script requests: ${JSON.stringify(scriptRequests)}\n proxy requests: ${JSON.stringify(proxyRequests.slice(0, 5))}`, + ).toBeGreaterThan(0) + + // Filter browser-level network errors (SSL, CORS, 404) from third-party SDKs + // hitting external servers with test keys — not JS errors from our proxy rewrites + const jsErrors = consoleErrors.filter(e => + !e.startsWith('Failed to load resource') + && !e.includes('has been blocked by CORS policy'), + ) + expect( + jsErrors, + `${name}: Console errors:\n${jsErrors.map(e => ` ${e}`).join('\n')}`, + ).toEqual([]) + }, 30000) + }) }) diff --git a/test/fixtures/first-party/nuxt.config.ts b/test/fixtures/first-party/nuxt.config.ts index b60eb1dd..746deced 100644 --- a/test/fixtures/first-party/nuxt.config.ts +++ b/test/fixtures/first-party/nuxt.config.ts @@ -1,10 +1,44 @@ import { defineNuxtConfig } from 'nuxt/config' +// trigger: 'manual' prevents the auto-generated plugin from loading all 18 +// scripts globally on every page. Each page's composable call then overrides +// the trigger and loads only its own script, eliminating cross-provider noise. +const manual = { trigger: 'manual' as const } + export default defineNuxtConfig({ modules: [ '@nuxt/scripts', ], + // The module merges registry into runtimeConfig.public.scripts via defu, but + // [input, options] arrays don't spread correctly. Explicit objects here ensure + // the bundler's registryConfig lookup gets proper {key: value} objects. + runtimeConfig: { + public: { + scripts: { + googleAnalytics: { id: 'G-TR58L0EF8P' }, + googleTagManager: { id: 'GTM-MWW974PF' }, + metaPixel: { id: '3925006' }, + segment: { writeKey: 'KBXOGxgqMFjm2mxtJDJg0iDn5AnGYb9C' }, + xPixel: { id: 'ol7lz' }, + snapchatPixel: { id: '2295cbcc-cb3f-4727-8c09-1133b742722c' }, + clarity: { id: 'mqk2m9dr2v' }, + hotjar: { id: 3925006, sv: 6 }, + tiktokPixel: { id: 'TEST_PIXEL_ID' }, + redditPixel: { id: 'a2_ilz4u0kbdr3v' }, + plausibleAnalytics: { domain: 'example.com' }, + cloudflareWebAnalytics: { token: 'test-token' }, + rybbitAnalytics: { siteId: '874' }, + umamiAnalytics: { websiteId: 'ae15c227-67e8-434a-831f-67e6df88bd6c' }, + databuddyAnalytics: { id: 'test-id' }, + fathomAnalytics: { site: 'TEST' }, + posthog: { apiKey: 'phc_CkMaDU6dr11eJoQdAiSJb1rC324dogk3T952gJ6fD9W' }, + intercom: { app_id: 'test-app' }, + crisp: { id: 'test-id' }, + }, + }, + }, + compatibilityDate: '2024-07-05', // Force unhead to be bundled into the server code instead of externalized. @@ -17,47 +51,27 @@ export default defineNuxtConfig({ }, scripts: { - firstParty: true, // Uses per-script privacy defaults from registry + firstParty: true, registry: { - googleAnalytics: { - id: 'G-TR58L0EF8P', - }, - googleTagManager: { - id: 'GTM-MWW974PF', - }, - metaPixel: { - id: '3925006', - }, - segment: { - writeKey: 'KBXOGxgqMFjm2mxtJDJg0iDn5AnGYb9C', - }, - xPixel: { - id: 'ol7lz', - }, - snapchatPixel: { - id: '2295cbcc-cb3f-4727-8c09-1133b742722c', - }, - clarity: { - id: 'mqk2m9dr2v', - }, - hotjar: { - id: 3925006, - sv: 6, - }, - tiktokPixel: { - id: 'TEST_PIXEL_ID', - }, - redditPixel: { - id: 't2_test_advertiser_id', - }, - plausibleAnalytics: { domain: 'example.com' }, - cloudflareWebAnalytics: { token: 'test-token' }, - rybbitAnalytics: { analyticsId: 'test-id' }, - umamiAnalytics: { websiteId: 'test-id' }, - databuddyAnalytics: { id: 'test-id' }, - fathomAnalytics: { site: 'TEST' }, - intercom: { app_id: 'test-app' }, - crisp: { id: 'test-id' }, + googleAnalytics: [{ id: 'G-TR58L0EF8P' }, manual], + googleTagManager: [{ id: 'GTM-MWW974PF' }, manual], + metaPixel: [{ id: '3925006' }, manual], + segment: [{ writeKey: 'KBXOGxgqMFjm2mxtJDJg0iDn5AnGYb9C' }, manual], + xPixel: [{ id: 'ol7lz' }, manual], + snapchatPixel: [{ id: '2295cbcc-cb3f-4727-8c09-1133b742722c' }, manual], + clarity: [{ id: 'mqk2m9dr2v' }, manual], + hotjar: [{ id: 3925006, sv: 6 }, manual], + tiktokPixel: [{ id: 'TEST_PIXEL_ID' }, manual], + redditPixel: [{ id: 'a2_ilz4u0kbdr3v' }, manual], + plausibleAnalytics: [{ domain: 'example.com' }, manual], + cloudflareWebAnalytics: [{ token: 'test-token' }, manual], + rybbitAnalytics: [{ siteId: '874' }, manual], + umamiAnalytics: [{ websiteId: 'ae15c227-67e8-434a-831f-67e6df88bd6c' }, manual], + databuddyAnalytics: [{ id: 'test-id' }, manual], + fathomAnalytics: [{ site: 'TEST' }, manual], + posthog: [{ apiKey: 'phc_CkMaDU6dr11eJoQdAiSJb1rC324dogk3T952gJ6fD9W' }, manual], + intercom: [{ app_id: 'test-app' }, manual], + crisp: [{ id: 'test-id' }, manual], }, }, }) diff --git a/test/fixtures/first-party/package.json b/test/fixtures/first-party/package.json index 3162623c..ec9d83ca 100644 --- a/test/fixtures/first-party/package.json +++ b/test/fixtures/first-party/package.json @@ -1,4 +1,7 @@ { "name": "first-party-fixture", - "private": true + "private": true, + "devDependencies": { + "posthog-js": "^1.309.1" + } } diff --git a/test/fixtures/first-party/pages/posthog.vue b/test/fixtures/first-party/pages/posthog.vue new file mode 100644 index 00000000..fc3d3be7 --- /dev/null +++ b/test/fixtures/first-party/pages/posthog.vue @@ -0,0 +1,24 @@ + + + diff --git a/test/fixtures/first-party/pages/reddit.vue b/test/fixtures/first-party/pages/reddit.vue index 34428f93..8ff8f5ae 100644 --- a/test/fixtures/first-party/pages/reddit.vue +++ b/test/fixtures/first-party/pages/reddit.vue @@ -6,7 +6,7 @@ useHead({ }) const { proxy, status } = useScriptRedditPixel({ - id: 't2_test_advertiser_id', + id: 'a2_ilz4u0kbdr3v', }) function trackPageVisit() { diff --git a/test/fixtures/first-party/pages/rybbit.vue b/test/fixtures/first-party/pages/rybbit.vue index 62c7f249..7f43d17c 100644 --- a/test/fixtures/first-party/pages/rybbit.vue +++ b/test/fixtures/first-party/pages/rybbit.vue @@ -2,7 +2,7 @@ import { useHead, useScriptRybbitAnalytics } from '#imports' useHead({ title: 'Rybbit - First Party' }) -const { status } = useScriptRybbitAnalytics({ analyticsId: 'test-id' }) +const { status } = useScriptRybbitAnalytics({ siteId: '874' })