Skip to content

Commit 805182a

Browse files
authored
feat: integrate Partytown with first-party proxy mode (#654)
1 parent d25917e commit 805182a

7 files changed

Lines changed: 187 additions & 16 deletions

File tree

src/first-party/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1+
export { generatePartytownResolveUrl } from './partytown-resolve'
12
export { getAllProxyConfigs, PRIVACY_FULL, PRIVACY_HEATMAP, PRIVACY_IP_ONLY, PRIVACY_NONE, routesToInterceptRules } from './proxy-configs'
23
export { finalizeFirstParty, setupFirstParty } from './setup'
3-
export type { FirstPartyConfig, FirstPartyDevtoolsData, FirstPartyDevtoolsScript } from './setup'
4+
export type { FinalizeFirstPartyResult, FirstPartyConfig, FirstPartyDevtoolsData, FirstPartyDevtoolsScript } from './setup'
45
export type { FirstPartyOptions, FirstPartyPrivacy, InterceptRule, ProxyAutoInject, ProxyConfig, ProxyRewrite } from './types'
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { InterceptRule } from './types'
2+
3+
/**
4+
* Generate a Partytown `resolveUrl` function string from first-party intercept rules.
5+
* This is the web-worker equivalent of the intercept plugin — Partytown calls this
6+
* for every network request (fetch, XHR, sendBeacon, Image, script) made by worker-executed scripts.
7+
*/
8+
export function generatePartytownResolveUrl(interceptRules: InterceptRule[]): string {
9+
const rulesJson = JSON.stringify(interceptRules)
10+
// Return raw function body as a string — @nuxtjs/partytown inlines this into a <script> tag.
11+
// Must be self-contained with no external references.
12+
return `function(url, location, type) {
13+
var rules = ${rulesJson};
14+
for (var i = 0; i < rules.length; i++) {
15+
var rule = rules[i];
16+
if (url.hostname === rule.pattern || url.hostname.endsWith('.' + rule.pattern)) {
17+
if (rule.pathPrefix && !url.pathname.startsWith(rule.pathPrefix)) continue;
18+
var path = rule.pathPrefix ? url.pathname.slice(rule.pathPrefix.length) : url.pathname;
19+
var newPath = rule.target + (path.startsWith('/') ? '' : '/') + path + url.search;
20+
return new URL(newPath, location.origin);
21+
}
22+
}
23+
}`
24+
}

src/first-party/setup.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ProxyPrivacyInput } from '../runtime/server/utils/privacy'
22
import type { BuiltInRegistryScriptKey, NuxtConfigScriptRegistry, RegistryScript, RegistryScriptKey } from '../runtime/types'
3-
import type { ProxyAutoInject, ProxyConfig } from './types'
3+
import type { InterceptRule, ProxyAutoInject, ProxyConfig } from './types'
44
import { addPluginTemplate, addServerHandler } from '@nuxt/kit'
55
import { logger } from '../logger'
66
import { scriptMeta } from '../script-meta'
@@ -135,17 +135,22 @@ function computePrivacyLevel(privacy: Record<string, boolean>): 'full' | 'partia
135135
return 'none'
136136
}
137137

138+
export interface FinalizeFirstPartyResult {
139+
interceptRules: InterceptRule[]
140+
devtools?: FirstPartyDevtoolsData
141+
}
142+
138143
/**
139144
* Finalize first-party setup inside modules:done.
140145
* Uses pre-built proxyConfigs from setupFirstParty — no rebuild.
141-
* Returns devtools data when in dev mode.
146+
* Returns intercept rules (for partytown resolveUrl) and devtools data.
142147
*/
143148
export function finalizeFirstParty(opts: {
144149
firstParty: FirstPartyConfig
145150
registry: NuxtConfigScriptRegistry | undefined
146151
registryScripts: RegistryScript[]
147152
nuxtOptions: { dev: boolean, runtimeConfig: Record<string, any> }
148-
}): FirstPartyDevtoolsData | undefined {
153+
}): FinalizeFirstPartyResult {
149154
const { firstParty, registryScripts, nuxtOptions } = opts
150155
const { proxyConfigs, proxyPrefix } = firstParty
151156
const registryKeys = Object.keys(opts.registry || {})
@@ -292,15 +297,16 @@ export function finalizeFirstParty(opts: {
292297
)
293298
}
294299

295-
// Return devtools data in dev mode
300+
// Build devtools data in dev mode
301+
let devtools: FirstPartyDevtoolsData | undefined
296302
if (nuxtOptions.dev) {
297303
const allDomains = new Set<string>()
298304
for (const s of devtoolsScripts) {
299305
for (const d of s.domains)
300306
allDomains.add(d)
301307
}
302308

303-
return {
309+
devtools = {
304310
enabled: true,
305311
proxyPrefix,
306312
privacyMode: privacyLabel,
@@ -309,4 +315,6 @@ export function finalizeFirstParty(opts: {
309315
totalDomains: allDomains.size,
310316
}
311317
}
318+
319+
return { interceptRules, devtools }
312320
}

src/module.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { resolve as resolvePath_ } from 'pathe'
2525
import { readPackageJSON } from 'pkg-types'
2626
import { setupPublicAssetStrategy } from './assets'
2727
import { setupDevToolsUI } from './devtools'
28-
import { finalizeFirstParty, setupFirstParty } from './first-party'
28+
import { finalizeFirstParty, generatePartytownResolveUrl, setupFirstParty } from './first-party'
2929
import { installNuxtModule } from './kit'
3030
import { logger } from './logger'
3131
import { normalizeRegistryConfig } from './normalize'
@@ -485,7 +485,7 @@ export default defineNuxtModule<ModuleOptions>({
485485

486486
// Finalize first-party proxy setup
487487
if (firstParty.enabled) {
488-
const devtoolsData = finalizeFirstParty({
488+
const { interceptRules, devtools: devtoolsData } = finalizeFirstParty({
489489
firstParty,
490490
registry: config.registry,
491491
registryScripts,
@@ -495,6 +495,18 @@ export default defineNuxtModule<ModuleOptions>({
495495
if (devtoolsData) {
496496
nuxt.options.runtimeConfig.public['nuxt-scripts-devtools'] = devtoolsData as any
497497
}
498+
// Auto-configure Partytown resolveUrl for first-party proxy
499+
if (config.partytown?.length && hasNuxtModule('@nuxtjs/partytown') && interceptRules.length) {
500+
const partytownConfig = (nuxt.options as any).partytown || {}
501+
if (!partytownConfig.resolveUrl) {
502+
partytownConfig.resolveUrl = generatePartytownResolveUrl(interceptRules)
503+
;(nuxt.options as any).partytown = partytownConfig
504+
logger.info('[partytown] Auto-configured resolveUrl for first-party proxy')
505+
}
506+
else {
507+
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.')
508+
}
509+
}
498510
}
499511

500512
const moduleInstallPromises: Map<string, () => Promise<boolean> | undefined> = new Map()
@@ -507,6 +519,7 @@ export default defineNuxtModule<ModuleOptions>({
507519
registryConfig: nuxt.options.runtimeConfig.public.scripts as Record<string, any> | undefined,
508520
defaultBundle: firstParty.enabled || config.defaultScriptOptions?.bundle,
509521
proxyConfigs: firstParty.proxyConfigs,
522+
partytownScripts: new Set(config.partytown || []),
510523
moduleDetected(module) {
511524
if (nuxt.options.dev && module !== '@nuxt/scripts' && !moduleInstallPromises.has(module) && !hasNuxtModule(module))
512525
moduleInstallPromises.set(module, () => installNuxtModule(module))

src/plugins/rewrite-ast.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ function resolveCalleeTarget(callee: any, scopeTracker: ScopeTracker): string |
195195
* Uses oxc-walker with ScopeTracker to precisely identify string literals,
196196
* resolve aliased globals, and rewrite API calls through the proxy.
197197
*/
198-
export function rewriteScriptUrlsAST(content: string, filename: string, rewrites: ProxyRewrite[], postProcess?: (output: string, rewrites: ProxyRewrite[]) => string): string {
198+
export function rewriteScriptUrlsAST(content: string, filename: string, rewrites: ProxyRewrite[], postProcess?: (output: string, rewrites: ProxyRewrite[]) => string, options?: { skipApiRewrites?: boolean }): string {
199199
const s = new MagicString(content)
200200

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

274-
// API call rewriting
275-
if (node.type === 'CallExpression') {
274+
// API call rewriting — skip for partytown scripts (they use resolveUrl instead)
275+
if (node.type === 'CallExpression' && !options?.skipApiRewrites) {
276276
const callee = (node as any).callee
277277

278278
// Canvas fingerprinting neutralization — only affects downloaded third-party scripts.
@@ -341,7 +341,7 @@ export function rewriteScriptUrlsAST(content: string, filename: string, rewrites
341341
}
342342

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

347347
// new XMLHttpRequest — check it's truly global

src/plugins/transform.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ export interface AssetBundlerTransformerOptions {
8080
*/
8181
integrity?: boolean | IntegrityAlgorithm
8282
renderedScript?: Map<string, RenderedScriptMeta | Error>
83+
/**
84+
* Set of registry script keys that use Partytown.
85+
* Scripts in this set skip API call rewrites (__nuxtScripts.*) since Partytown's
86+
* resolveUrl hook handles network interception in the web worker instead.
87+
*/
88+
partytownScripts?: Set<string>
8389
}
8490

8591
function normalizeScriptData(src: string, assetsBaseURL: string = '/_scripts/assets'): { url: string, filename?: string } {
@@ -106,8 +112,9 @@ async function downloadScript(opts: {
106112
proxyRewrites?: ProxyRewrite[]
107113
postProcess?: ProxyConfig['postProcess']
108114
integrity?: boolean | IntegrityAlgorithm
115+
skipApiRewrites?: boolean
109116
}, renderedScript: NonNullable<AssetBundlerTransformerOptions['renderedScript']>, fetchOptions?: FetchOptions, cacheMaxAge?: number) {
110-
const { src, url, filename, forceDownload, integrity, proxyRewrites, postProcess } = opts
117+
const { src, url, filename, forceDownload, integrity, proxyRewrites, postProcess, skipApiRewrites } = opts
111118
if (src === url || !filename) {
112119
return
113120
}
@@ -151,7 +158,7 @@ async function downloadScript(opts: {
151158
// Apply URL rewrites for proxy mode (AST-based at build time)
152159
if (proxyRewrites?.length && res) {
153160
const content = res.toString('utf-8')
154-
const rewritten = rewriteScriptUrlsAST(content, filename || 'script.js', proxyRewrites, postProcess)
161+
const rewritten = rewriteScriptUrlsAST(content, filename || 'script.js', proxyRewrites, postProcess, { skipApiRewrites })
155162
res = Buffer.from(rewritten, 'utf-8')
156163
logger.debug(`Rewrote ${proxyRewrites.length} URL patterns in ${filename}`)
157164
}
@@ -424,12 +431,13 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti
424431
: undefined
425432
const proxyRewrites = proxyConfig?.rewrite
426433
const postProcess = proxyConfig?.postProcess
434+
const skipApiRewrites = !!(registryKey && options.partytownScripts?.has(registryKey))
427435

428436
// Defer async download + MagicString operations
429437
deferredOps.push(async () => {
430438
let url = _url
431439
try {
432-
await downloadScript({ src: src as string, url, filename, forceDownload, proxyRewrites, postProcess, integrity: options.integrity }, renderedScript, options.fetchOptions, options.cacheMaxAge)
440+
await downloadScript({ src: src as string, url, filename, forceDownload, proxyRewrites, postProcess, integrity: options.integrity, skipApiRewrites }, renderedScript, options.fetchOptions, options.cacheMaxAge)
433441
}
434442
catch (e: any) {
435443
if (options.fallbackOnSrcOnBundleFail) {

test/unit/rewrite-ast.test.ts

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest'
2-
import { getAllProxyConfigs } from '../../src/first-party'
2+
import { generatePartytownResolveUrl, getAllProxyConfigs } from '../../src/first-party'
33
import { rewriteScriptUrlsAST } from '../../src/plugins/rewrite-ast'
44

55
const rewrites = [
@@ -232,4 +232,121 @@ describe('rewriteScriptUrlsAST', () => {
232232
expect(rewrite('navigator.sendBeacon("https://example.com/collect", data)')).toContain('__nuxtScripts.sendBeacon')
233233
})
234234
})
235+
236+
describe('skipApiRewrites (partytown mode)', () => {
237+
function rewritePartytown(code: string): string {
238+
return rewriteScriptUrlsAST(code, 'test.js', rewrites, undefined, { skipApiRewrites: true })
239+
}
240+
241+
it('still rewrites URL string literals', () => {
242+
expect(rewritePartytown('"https://example.com/api"')).toContain('self.location.origin+"/_proxy/ex/api"')
243+
})
244+
245+
it('skips fetch rewriting', () => {
246+
const result = rewritePartytown('fetch("https://example.com/api")')
247+
expect(result).not.toContain('__nuxtScripts')
248+
expect(result).toContain('fetch(')
249+
// URL literal is still rewritten
250+
expect(result).toContain('self.location.origin+"/_proxy/ex/api"')
251+
})
252+
253+
it('skips navigator.sendBeacon rewriting', () => {
254+
const result = rewritePartytown('navigator.sendBeacon("https://example.com/collect", data)')
255+
expect(result).not.toContain('__nuxtScripts')
256+
expect(result).toContain('navigator.sendBeacon(')
257+
})
258+
259+
it('skips XMLHttpRequest rewriting', () => {
260+
const result = rewritePartytown('var a = new XMLHttpRequest();')
261+
expect(result).not.toContain('__nuxtScripts')
262+
expect(result).toContain('new XMLHttpRequest()')
263+
})
264+
265+
it('skips Image rewriting', () => {
266+
const result = rewritePartytown('var img = new Image();')
267+
expect(result).not.toContain('__nuxtScripts')
268+
expect(result).toContain('new Image()')
269+
})
270+
271+
it('skips canvas toDataURL neutralization', () => {
272+
const result = rewritePartytown('ctx.canvas.toDataURL()')
273+
expect(result).not.toContain('data:image/png')
274+
expect(result).toContain('toDataURL()')
275+
})
276+
})
277+
})
278+
279+
describe('generatePartytownResolveUrl', () => {
280+
it('generates a valid function string', () => {
281+
const rules = [
282+
{ pattern: 'www.google-analytics.com', pathPrefix: '', target: '/_scripts/p/ga' },
283+
]
284+
const fn = generatePartytownResolveUrl(rules)
285+
expect(fn).toContain('function(url, location, type)')
286+
expect(fn).toContain('www.google-analytics.com')
287+
expect(fn).toContain('/_scripts/p/ga')
288+
})
289+
290+
it('embeds all rules as JSON', () => {
291+
const rules = [
292+
{ pattern: 'www.google-analytics.com', pathPrefix: '', target: '/_scripts/p/ga' },
293+
{ pattern: 'www.facebook.com', pathPrefix: '/tr', target: '/_scripts/p/meta' },
294+
]
295+
const fn = generatePartytownResolveUrl(rules)
296+
expect(fn).toContain('"www.facebook.com"')
297+
expect(fn).toContain('"/tr"')
298+
})
299+
300+
it('returns undefined for non-matching URLs (no return statement for miss)', () => {
301+
const rules = [{ pattern: 'example.com', pathPrefix: '', target: '/_scripts/p/ex' }]
302+
const fn = generatePartytownResolveUrl(rules)
303+
// eslint-disable-next-line no-new-func
304+
const resolveUrl = new Function(`return ${fn}`)()
305+
const url = new URL('https://other.com/path')
306+
const location = new URL('https://mysite.com')
307+
expect(resolveUrl(url, location, 'fetch')).toBeUndefined()
308+
})
309+
310+
it('rewrites matching URLs to first-party proxy', () => {
311+
const rules = [{ pattern: 'example.com', pathPrefix: '', target: '/_scripts/p/ex' }]
312+
const fn = generatePartytownResolveUrl(rules)
313+
// eslint-disable-next-line no-new-func
314+
const resolveUrl = new Function(`return ${fn}`)()
315+
const url = new URL('https://example.com/collect?v=1')
316+
const location = new URL('https://mysite.com')
317+
const result = resolveUrl(url, location, 'fetch')
318+
expect(result).toBeInstanceOf(URL)
319+
expect(result.pathname).toBe('/_scripts/p/ex/collect')
320+
expect(result.search).toBe('?v=1')
321+
expect(result.origin).toBe('https://mysite.com')
322+
})
323+
324+
it('matches subdomains', () => {
325+
const rules = [{ pattern: 'google-analytics.com', pathPrefix: '', target: '/_scripts/p/ga' }]
326+
const fn = generatePartytownResolveUrl(rules)
327+
// eslint-disable-next-line no-new-func
328+
const resolveUrl = new Function(`return ${fn}`)()
329+
const url = new URL('https://www.google-analytics.com/g/collect')
330+
const location = new URL('https://mysite.com')
331+
const result = resolveUrl(url, location, 'fetch')
332+
expect(result.pathname).toBe('/_scripts/p/ga/g/collect')
333+
})
334+
335+
it('respects pathPrefix matching', () => {
336+
const rules = [{ pattern: 'www.facebook.com', pathPrefix: '/tr', target: '/_scripts/p/meta' }]
337+
const fn = generatePartytownResolveUrl(rules)
338+
// eslint-disable-next-line no-new-func
339+
const resolveUrl = new Function(`return ${fn}`)()
340+
341+
// Matching path prefix
342+
const url1 = new URL('https://www.facebook.com/tr?id=123')
343+
const location = new URL('https://mysite.com')
344+
const result1 = resolveUrl(url1, location, 'fetch')
345+
expect(result1.pathname).toBe('/_scripts/p/meta/')
346+
expect(result1.search).toBe('?id=123')
347+
348+
// Non-matching path prefix
349+
const url2 = new URL('https://www.facebook.com/other')
350+
expect(resolveUrl(url2, location, 'fetch')).toBeUndefined()
351+
})
235352
})

0 commit comments

Comments
 (0)