Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/first-party/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { generatePartytownResolveUrl } from './partytown-resolve'
export { getAllProxyConfigs, PRIVACY_FULL, PRIVACY_HEATMAP, PRIVACY_IP_ONLY, PRIVACY_NONE, routesToInterceptRules } from './proxy-configs'
export { finalizeFirstParty, setupFirstParty } from './setup'
export type { FirstPartyConfig, FirstPartyDevtoolsData, FirstPartyDevtoolsScript } from './setup'
export type { FinalizeFirstPartyResult, FirstPartyConfig, FirstPartyDevtoolsData, FirstPartyDevtoolsScript } from './setup'
export type { FirstPartyOptions, FirstPartyPrivacy, InterceptRule, ProxyAutoInject, ProxyConfig, ProxyRewrite } from './types'
24 changes: 24 additions & 0 deletions src/first-party/partytown-resolve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { InterceptRule } from './types'

/**
* Generate a Partytown `resolveUrl` function string from first-party intercept rules.
* This is the web-worker equivalent of the intercept plugin β€” Partytown calls this
* for every network request (fetch, XHR, sendBeacon, Image, script) made by worker-executed scripts.
*/
export function generatePartytownResolveUrl(interceptRules: InterceptRule[]): string {
const rulesJson = JSON.stringify(interceptRules)
// Return raw function body as a string β€” @nuxtjs/partytown inlines this into a <script> tag.
// Must be self-contained with no external references.
return `function(url, location, type) {
var rules = ${rulesJson};
for (var i = 0; i < rules.length; i++) {
var rule = rules[i];
if (url.hostname === rule.pattern || url.hostname.endsWith('.' + rule.pattern)) {
if (rule.pathPrefix && !url.pathname.startsWith(rule.pathPrefix)) continue;
var path = rule.pathPrefix ? url.pathname.slice(rule.pathPrefix.length) : url.pathname;
var newPath = rule.target + (path.startsWith('/') ? '' : '/') + path + url.search;
return new URL(newPath, location.origin);
}
}
}`
}
18 changes: 13 additions & 5 deletions src/first-party/setup.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ProxyPrivacyInput } from '../runtime/server/utils/privacy'
import type { BuiltInRegistryScriptKey, NuxtConfigScriptRegistry, RegistryScript, RegistryScriptKey } from '../runtime/types'
import type { ProxyAutoInject, ProxyConfig } from './types'
import type { InterceptRule, ProxyAutoInject, ProxyConfig } from './types'
import { addPluginTemplate, addServerHandler } from '@nuxt/kit'
import { logger } from '../logger'
import { scriptMeta } from '../script-meta'
Expand Down Expand Up @@ -135,17 +135,22 @@ function computePrivacyLevel(privacy: Record<string, boolean>): 'full' | 'partia
return 'none'
}

export interface FinalizeFirstPartyResult {
interceptRules: InterceptRule[]
devtools?: FirstPartyDevtoolsData
}

/**
* Finalize first-party setup inside modules:done.
* Uses pre-built proxyConfigs from setupFirstParty β€” no rebuild.
* Returns devtools data when in dev mode.
* Returns intercept rules (for partytown resolveUrl) and devtools data.
*/
export function finalizeFirstParty(opts: {
firstParty: FirstPartyConfig
registry: NuxtConfigScriptRegistry | undefined
registryScripts: RegistryScript[]
nuxtOptions: { dev: boolean, runtimeConfig: Record<string, any> }
}): FirstPartyDevtoolsData | undefined {
}): FinalizeFirstPartyResult {
const { firstParty, registryScripts, nuxtOptions } = opts
const { proxyConfigs, proxyPrefix } = firstParty
const registryKeys = Object.keys(opts.registry || {})
Expand Down Expand Up @@ -292,15 +297,16 @@ export function finalizeFirstParty(opts: {
)
}

// Return devtools data in dev mode
// Build devtools data in dev mode
let devtools: FirstPartyDevtoolsData | undefined
if (nuxtOptions.dev) {
const allDomains = new Set<string>()
for (const s of devtoolsScripts) {
for (const d of s.domains)
allDomains.add(d)
}

return {
devtools = {
enabled: true,
proxyPrefix,
privacyMode: privacyLabel,
Expand All @@ -309,4 +315,6 @@ export function finalizeFirstParty(opts: {
totalDomains: allDomains.size,
}
}

return { interceptRules, devtools }
}
17 changes: 15 additions & 2 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { resolve as resolvePath_ } from 'pathe'
import { readPackageJSON } from 'pkg-types'
import { setupPublicAssetStrategy } from './assets'
import { setupDevToolsUI } from './devtools'
import { finalizeFirstParty, setupFirstParty } from './first-party'
import { finalizeFirstParty, generatePartytownResolveUrl, setupFirstParty } from './first-party'
import { installNuxtModule } from './kit'
import { logger } from './logger'
import { normalizeRegistryConfig } from './normalize'
Expand Down Expand Up @@ -485,7 +485,7 @@ export default defineNuxtModule<ModuleOptions>({

// Finalize first-party proxy setup
if (firstParty.enabled) {
const devtoolsData = finalizeFirstParty({
const { interceptRules, devtools: devtoolsData } = finalizeFirstParty({
firstParty,
registry: config.registry,
registryScripts,
Expand All @@ -495,6 +495,18 @@ export default defineNuxtModule<ModuleOptions>({
if (devtoolsData) {
nuxt.options.runtimeConfig.public['nuxt-scripts-devtools'] = devtoolsData as any
}
// Auto-configure Partytown resolveUrl for first-party proxy
if (config.partytown?.length && hasNuxtModule('@nuxtjs/partytown') && interceptRules.length) {
const partytownConfig = (nuxt.options as any).partytown || {}
if (!partytownConfig.resolveUrl) {
partytownConfig.resolveUrl = generatePartytownResolveUrl(interceptRules)
;(nuxt.options as any).partytown = partytownConfig
logger.info('[partytown] Auto-configured resolveUrl for first-party proxy')
}
else {
logger.warn('[partytown] Custom resolveUrl already set β€” first-party proxy URLs will not be auto-rewritten in Partytown worker. Add first-party proxy rules to your resolveUrl manually.')
}
}
}

const moduleInstallPromises: Map<string, () => Promise<boolean> | undefined> = new Map()
Expand All @@ -507,6 +519,7 @@ export default defineNuxtModule<ModuleOptions>({
registryConfig: nuxt.options.runtimeConfig.public.scripts as Record<string, any> | undefined,
defaultBundle: firstParty.enabled || config.defaultScriptOptions?.bundle,
proxyConfigs: firstParty.proxyConfigs,
partytownScripts: new Set(config.partytown || []),
moduleDetected(module) {
if (nuxt.options.dev && module !== '@nuxt/scripts' && !moduleInstallPromises.has(module) && !hasNuxtModule(module))
moduleInstallPromises.set(module, () => installNuxtModule(module))
Expand Down
8 changes: 4 additions & 4 deletions src/plugins/rewrite-ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ function resolveCalleeTarget(callee: any, scopeTracker: ScopeTracker): string |
* Uses oxc-walker with ScopeTracker to precisely identify string literals,
* resolve aliased globals, and rewrite API calls through the proxy.
*/
export function rewriteScriptUrlsAST(content: string, filename: string, rewrites: ProxyRewrite[], postProcess?: (output: string, rewrites: ProxyRewrite[]) => string): string {
export function rewriteScriptUrlsAST(content: string, filename: string, rewrites: ProxyRewrite[], postProcess?: (output: string, rewrites: ProxyRewrite[]) => string, options?: { skipApiRewrites?: boolean }): string {
const s = new MagicString(content)

// In minified JS, keywords like `return` can directly precede string literals
Expand Down Expand Up @@ -271,8 +271,8 @@ export function rewriteScriptUrlsAST(content: string, filename: string, rewrites
}
}

// API call rewriting
if (node.type === 'CallExpression') {
// API call rewriting β€” skip for partytown scripts (they use resolveUrl instead)
if (node.type === 'CallExpression' && !options?.skipApiRewrites) {
const callee = (node as any).callee

// Canvas fingerprinting neutralization β€” only affects downloaded third-party scripts.
Expand Down Expand Up @@ -341,7 +341,7 @@ export function rewriteScriptUrlsAST(content: string, filename: string, rewrites
}

// new XMLHttpRequest / new Image / new x.XMLHttpRequest / new x.Image
if (node.type === 'NewExpression') {
if (node.type === 'NewExpression' && !options?.skipApiRewrites) {
const callee = (node as any).callee

// new XMLHttpRequest β€” check it's truly global
Expand Down
14 changes: 11 additions & 3 deletions src/plugins/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ export interface AssetBundlerTransformerOptions {
*/
integrity?: boolean | IntegrityAlgorithm
renderedScript?: Map<string, RenderedScriptMeta | Error>
/**
* Set of registry script keys that use Partytown.
* Scripts in this set skip API call rewrites (__nuxtScripts.*) since Partytown's
* resolveUrl hook handles network interception in the web worker instead.
*/
partytownScripts?: Set<string>
}

function normalizeScriptData(src: string, assetsBaseURL: string = '/_scripts/assets'): { url: string, filename?: string } {
Expand All @@ -106,8 +112,9 @@ async function downloadScript(opts: {
proxyRewrites?: ProxyRewrite[]
postProcess?: ProxyConfig['postProcess']
integrity?: boolean | IntegrityAlgorithm
skipApiRewrites?: boolean
}, renderedScript: NonNullable<AssetBundlerTransformerOptions['renderedScript']>, fetchOptions?: FetchOptions, cacheMaxAge?: number) {
const { src, url, filename, forceDownload, integrity, proxyRewrites, postProcess } = opts
const { src, url, filename, forceDownload, integrity, proxyRewrites, postProcess, skipApiRewrites } = opts
if (src === url || !filename) {
return
}
Expand Down Expand Up @@ -151,7 +158,7 @@ async function downloadScript(opts: {
// Apply URL rewrites for proxy mode (AST-based at build time)
if (proxyRewrites?.length && res) {
const content = res.toString('utf-8')
const rewritten = rewriteScriptUrlsAST(content, filename || 'script.js', proxyRewrites, postProcess)
const rewritten = rewriteScriptUrlsAST(content, filename || 'script.js', proxyRewrites, postProcess, { skipApiRewrites })
res = Buffer.from(rewritten, 'utf-8')
logger.debug(`Rewrote ${proxyRewrites.length} URL patterns in ${filename}`)
}
Expand Down Expand Up @@ -424,12 +431,13 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti
: undefined
const proxyRewrites = proxyConfig?.rewrite
const postProcess = proxyConfig?.postProcess
const skipApiRewrites = !!(registryKey && options.partytownScripts?.has(registryKey))

// Defer async download + MagicString operations
deferredOps.push(async () => {
let url = _url
try {
await downloadScript({ src: src as string, url, filename, forceDownload, proxyRewrites, postProcess, integrity: options.integrity }, renderedScript, options.fetchOptions, options.cacheMaxAge)
await downloadScript({ src: src as string, url, filename, forceDownload, proxyRewrites, postProcess, integrity: options.integrity, skipApiRewrites }, renderedScript, options.fetchOptions, options.cacheMaxAge)
}
catch (e: any) {
if (options.fallbackOnSrcOnBundleFail) {
Expand Down
119 changes: 118 additions & 1 deletion test/unit/rewrite-ast.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { getAllProxyConfigs } from '../../src/first-party'
import { generatePartytownResolveUrl, getAllProxyConfigs } from '../../src/first-party'
import { rewriteScriptUrlsAST } from '../../src/plugins/rewrite-ast'

const rewrites = [
Expand Down Expand Up @@ -232,4 +232,121 @@ describe('rewriteScriptUrlsAST', () => {
expect(rewrite('navigator.sendBeacon("https://example.com/collect", data)')).toContain('__nuxtScripts.sendBeacon')
})
})

describe('skipApiRewrites (partytown mode)', () => {
function rewritePartytown(code: string): string {
return rewriteScriptUrlsAST(code, 'test.js', rewrites, undefined, { skipApiRewrites: true })
}

it('still rewrites URL string literals', () => {
expect(rewritePartytown('"https://example.com/api"')).toContain('self.location.origin+"/_proxy/ex/api"')
})

it('skips fetch rewriting', () => {
const result = rewritePartytown('fetch("https://example.com/api")')
expect(result).not.toContain('__nuxtScripts')
expect(result).toContain('fetch(')
// URL literal is still rewritten
expect(result).toContain('self.location.origin+"/_proxy/ex/api"')
})

it('skips navigator.sendBeacon rewriting', () => {
const result = rewritePartytown('navigator.sendBeacon("https://example.com/collect", data)')
expect(result).not.toContain('__nuxtScripts')
expect(result).toContain('navigator.sendBeacon(')
})

it('skips XMLHttpRequest rewriting', () => {
const result = rewritePartytown('var a = new XMLHttpRequest();')
expect(result).not.toContain('__nuxtScripts')
expect(result).toContain('new XMLHttpRequest()')
})

it('skips Image rewriting', () => {
const result = rewritePartytown('var img = new Image();')
expect(result).not.toContain('__nuxtScripts')
expect(result).toContain('new Image()')
})

it('skips canvas toDataURL neutralization', () => {
const result = rewritePartytown('ctx.canvas.toDataURL()')
expect(result).not.toContain('data:image/png')
expect(result).toContain('toDataURL()')
})
})
})

describe('generatePartytownResolveUrl', () => {
it('generates a valid function string', () => {
const rules = [
{ pattern: 'www.google-analytics.com', pathPrefix: '', target: '/_scripts/p/ga' },
]
const fn = generatePartytownResolveUrl(rules)
expect(fn).toContain('function(url, location, type)')
expect(fn).toContain('www.google-analytics.com')
expect(fn).toContain('/_scripts/p/ga')
})

it('embeds all rules as JSON', () => {
const rules = [
{ pattern: 'www.google-analytics.com', pathPrefix: '', target: '/_scripts/p/ga' },
{ pattern: 'www.facebook.com', pathPrefix: '/tr', target: '/_scripts/p/meta' },
]
const fn = generatePartytownResolveUrl(rules)
expect(fn).toContain('"www.facebook.com"')
expect(fn).toContain('"/tr"')
})

it('returns undefined for non-matching URLs (no return statement for miss)', () => {
const rules = [{ pattern: 'example.com', pathPrefix: '', target: '/_scripts/p/ex' }]
const fn = generatePartytownResolveUrl(rules)
// eslint-disable-next-line no-new-func
const resolveUrl = new Function(`return ${fn}`)()
const url = new URL('https://other.com/path')
const location = new URL('https://mysite.com')
expect(resolveUrl(url, location, 'fetch')).toBeUndefined()
})

it('rewrites matching URLs to first-party proxy', () => {
const rules = [{ pattern: 'example.com', pathPrefix: '', target: '/_scripts/p/ex' }]
const fn = generatePartytownResolveUrl(rules)
// eslint-disable-next-line no-new-func
const resolveUrl = new Function(`return ${fn}`)()
const url = new URL('https://example.com/collect?v=1')
const location = new URL('https://mysite.com')
const result = resolveUrl(url, location, 'fetch')
expect(result).toBeInstanceOf(URL)
expect(result.pathname).toBe('/_scripts/p/ex/collect')
expect(result.search).toBe('?v=1')
expect(result.origin).toBe('https://mysite.com')
})

it('matches subdomains', () => {
const rules = [{ pattern: 'google-analytics.com', pathPrefix: '', target: '/_scripts/p/ga' }]
const fn = generatePartytownResolveUrl(rules)
// eslint-disable-next-line no-new-func
const resolveUrl = new Function(`return ${fn}`)()
const url = new URL('https://www.google-analytics.com/g/collect')
const location = new URL('https://mysite.com')
const result = resolveUrl(url, location, 'fetch')
expect(result.pathname).toBe('/_scripts/p/ga/g/collect')
})

it('respects pathPrefix matching', () => {
const rules = [{ pattern: 'www.facebook.com', pathPrefix: '/tr', target: '/_scripts/p/meta' }]
const fn = generatePartytownResolveUrl(rules)
// eslint-disable-next-line no-new-func
const resolveUrl = new Function(`return ${fn}`)()

// Matching path prefix
const url1 = new URL('https://www.facebook.com/tr?id=123')
const location = new URL('https://mysite.com')
const result1 = resolveUrl(url1, location, 'fetch')
expect(result1.pathname).toBe('/_scripts/p/meta/')
expect(result1.search).toBe('?id=123')

// Non-matching path prefix
const url2 = new URL('https://www.facebook.com/other')
expect(resolveUrl(url2, location, 'fetch')).toBeUndefined()
})
})
Loading