Skip to content

Commit ca96cbc

Browse files
authored
fix(optimizer): map relative new URL paths to correct relative file location (vitejs#21434)
1 parent 2c00fa9 commit ca96cbc

File tree

11 files changed

+274
-0
lines changed

11 files changed

+274
-0
lines changed
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { describe, expect, test } from 'vitest'
2+
import { rolldownDepPlugin } from '../../optimizer/rolldownDepPlugin'
3+
import { normalizePath } from '../../utils'
4+
5+
async function createRolldownDepPluginTransform(cacheDir: string) {
6+
const baseConfig = {
7+
cacheDir: normalizePath(cacheDir),
8+
optimizeDeps: { extensions: [] },
9+
server: { fs: { allow: [] } },
10+
resolve: { builtins: [] },
11+
createResolver: () => ({}),
12+
}
13+
14+
const mockEnvironment = {
15+
config: baseConfig,
16+
getTopLevelConfig: () => baseConfig,
17+
} as any
18+
19+
const plugins = rolldownDepPlugin(mockEnvironment, {}, [])
20+
const plugin = plugins.find(
21+
(p: any) => p.name === 'vite:dep-pre-bundle',
22+
) as any
23+
24+
if (!plugin || !plugin.transform) {
25+
throw new Error('Could not find vite:dep-pre-bundle plugin')
26+
}
27+
28+
const handler = plugin.transform.handler
29+
30+
return async (code: string, id: string) => {
31+
const result = await handler.call({}, code, normalizePath(id))
32+
return result?.code || result
33+
}
34+
}
35+
36+
describe('rolldownDepPlugin transform', async () => {
37+
const transform = await createRolldownDepPluginTransform('/root/.vite')
38+
39+
test('rewrite various relative asset formats', async () => {
40+
const code = `
41+
const img = new URL('./logo.png', import.meta.url).href
42+
const icon = new URL('./icons/search.svg', import.meta.url)
43+
const worker = new URL('./worker.js', import.meta.url)
44+
const wasm = new URL('./module.wasm', import.meta.url)
45+
`
46+
expect(await transform(code, '/root/node_modules/my-lib/dist/index.js'))
47+
.toMatchInlineSnapshot(`
48+
"
49+
const img = new URL('' + "../../node_modules/my-lib/dist/logo.png", import.meta.url).href
50+
const icon = new URL('' + "../../node_modules/my-lib/dist/icons/search.svg", import.meta.url)
51+
const worker = new URL('' + "../../node_modules/my-lib/dist/worker.js", import.meta.url)
52+
const wasm = new URL('' + "../../node_modules/my-lib/dist/module.wasm", import.meta.url)
53+
"
54+
`)
55+
})
56+
57+
test('respects /* @vite-ignore */', async () => {
58+
expect(
59+
await transform(
60+
"new URL(/* @vite-ignore */ './worker.js', import.meta.url)",
61+
'/root/node_modules/my-lib/index.js',
62+
),
63+
).toBeUndefined()
64+
})
65+
66+
test('skips non-relative URLs (absolute, data, protocols)', async () => {
67+
const code = `
68+
new URL('/absolute/path.png', import.meta.url)
69+
new URL('https://example.com/worker.js', import.meta.url)
70+
new URL('data:text/javascript;base64,Y29uc29sZS5sb2coMSk=', import.meta.url)
71+
`
72+
expect(
73+
await transform(code, '/root/node_modules/my-lib/index.js'),
74+
).toBeUndefined()
75+
})
76+
77+
test('skips dynamic template strings', async () => {
78+
expect(
79+
await transform(
80+
'new URL(`./${name}.js`, import.meta.url)',
81+
'/root/node_modules/my-lib/index.js',
82+
),
83+
).toBeUndefined()
84+
})
85+
86+
test('handles backticks for static relative strings', async () => {
87+
expect(
88+
await transform(
89+
'new URL(`./static.js`, import.meta.url)',
90+
'/root/node_modules/my-lib/index.js',
91+
),
92+
).toMatchInlineSnapshot(
93+
`"new URL('' + "../../node_modules/my-lib/static.js", import.meta.url)"`,
94+
)
95+
})
96+
97+
test('handles assets with query parameters and hashes', async () => {
98+
const code = `
99+
const url1 = new URL('./style.css?raw', import.meta.url)
100+
const url2 = new URL('./data.json#config', import.meta.url)
101+
`
102+
expect(await transform(code, '/root/node_modules/my-lib/index.js'))
103+
.toMatchInlineSnapshot(`
104+
"
105+
const url1 = new URL('' + "../../node_modules/my-lib/style.css?raw", import.meta.url)
106+
const url2 = new URL('' + "../../node_modules/my-lib/data.json#config", import.meta.url)
107+
"
108+
`)
109+
})
110+
111+
test('handles deeply nested relative paths', async () => {
112+
expect(
113+
await transform(
114+
"new URL('../../../assets/file.js', import.meta.url)",
115+
'/root/node_modules/my-lib/dist/deep/folder/index.js',
116+
),
117+
).toMatchInlineSnapshot(
118+
`"new URL('' + "../../node_modules/my-lib/assets/file.js", import.meta.url)"`,
119+
)
120+
})
121+
122+
test('rewrite relative URLs even if id is not in node_modules', async () => {
123+
expect(
124+
await transform(
125+
"new URL('./asset.js', import.meta.url)",
126+
'/root/src/local-dep.js',
127+
),
128+
).toMatchInlineSnapshot(
129+
`"new URL('' + "../../src/asset.js", import.meta.url)"`,
130+
)
131+
})
132+
})

packages/vite/src/node/optimizer/rolldownDepPlugin.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
11
import path from 'node:path'
22
import type { ImportKind, Plugin, RolldownPlugin } from 'rolldown'
33
import { prefixRegex } from '@rolldown/pluginutils'
4+
import MagicString from 'magic-string'
5+
import { stripLiteral } from 'strip-literal'
46
import { JS_TYPES_RE, KNOWN_ASSET_TYPES } from '../constants'
57
import type { PackageCache } from '../packages'
68
import {
79
escapeRegex,
810
flattenId,
911
isBuiltin,
1012
isCSSRequest,
13+
isDataUrl,
1114
isExternalUrl,
1215
isNodeBuiltin,
1316
moduleListContains,
17+
normalizePath,
1418
} from '../utils'
1519
import { browserExternalId, optionalPeerDepId } from '../plugins/resolve'
1620
import { isModuleCSSRequest } from '../plugins/css'
1721
import type { Environment } from '../environment'
1822
import { createBackCompatIdResolver } from '../idResolver'
1923
import { isWindows } from '../../shared/utils'
24+
import { hasViteIgnoreRE } from '../plugins/importAnalysis'
2025

2126
const externalWithConversionNamespace =
2227
'vite:dep-pre-bundle:external-conversion'
@@ -139,6 +144,8 @@ export function rolldownDepPlugin(
139144
}
140145
}
141146

147+
const bundleOutputDir = path.join(environment.config.cacheDir, 'deps')
148+
142149
return [
143150
{
144151
name: 'vite:dep-pre-bundle-assets',
@@ -298,6 +305,59 @@ export function rolldownDepPlugin(
298305
}
299306
},
300307
},
308+
transform: {
309+
filter: {
310+
code: /new\s+URL.+import\.meta\.url/s,
311+
},
312+
async handler(code, id) {
313+
let s: MagicString | undefined
314+
const assetImportMetaUrlRE =
315+
/\bnew\s+URL\s*\(\s*('[^']+'|"[^"]+"|`[^`]+`)\s*,\s*import\.meta\.url\s*(?:,\s*)?\)/dg
316+
const cleanString = stripLiteral(code)
317+
318+
let match: RegExpExecArray | null
319+
while ((match = assetImportMetaUrlRE.exec(cleanString))) {
320+
const [[startIndex, endIndex], [urlStart, urlEnd]] = match.indices!
321+
if (hasViteIgnoreRE.test(code.slice(startIndex, urlStart))) continue
322+
323+
const rawUrl = code.slice(urlStart, urlEnd)
324+
325+
if (rawUrl[0] === '`' && rawUrl.includes('${')) {
326+
// We skip dynamic template strings in the optimizer for now as they
327+
// require complex glob transformation that is handled by the main asset plugin.
328+
continue
329+
}
330+
331+
const url = rawUrl.slice(1, -1)
332+
if (isDataUrl(url) || isExternalUrl(url) || url.startsWith('/')) {
333+
continue
334+
}
335+
336+
if (!s) s = new MagicString(code)
337+
338+
// we resolve the relative path from the original library file (id) and
339+
// then rewrite it relative to the bundle (deps) directory.
340+
const absolutePath = path.resolve(path.dirname(id), url)
341+
const relativePath = path.relative(bundleOutputDir, absolutePath)
342+
const normalizedRelativePath = normalizePath(relativePath)
343+
s.update(
344+
startIndex,
345+
endIndex,
346+
// NOTE: add `'' +` to opt-out rolldown's transform: https://github.com/rolldown/rolldown/issues/2745
347+
`new URL('' + ${JSON.stringify(
348+
normalizedRelativePath,
349+
)}, import.meta.url)`,
350+
)
351+
}
352+
353+
if (s) {
354+
return {
355+
code: s.toString(),
356+
map: s.generateMap({ hires: 'boundary' }),
357+
}
358+
}
359+
},
360+
},
301361
},
302362
]
303363
}

playground/optimize-deps/__tests__/optimize-deps.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,3 +370,21 @@ test.runIf(isServe)(
370370
expect(scanErrors).toHaveLength(0)
371371
},
372372
)
373+
374+
test('should fix relative worker paths in optimized dependencies', async () => {
375+
await expect
376+
.poll(() => page.textContent('.worker-lib'))
377+
.toBe('worker-success')
378+
await expect
379+
.poll(() => page.textContent('.worker-nested'))
380+
.toBe('worker-success')
381+
382+
const assetMatcher = isBuild
383+
? /assets\/logo-[-\w]+\.png/
384+
: /\/node_modules\/@vitejs\/test-dep-with-assets\/logo\.png/
385+
await expect.poll(() => page.textContent('.asset-url')).toMatch(assetMatcher)
386+
387+
const url = await page.textContent('.asset-url')
388+
const res = await page.request.get(url)
389+
expect(res.status()).toBe(200)
390+
})
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export function urlImportWorker() {
2+
const worker = new Worker(new URL('./worker.js', import.meta.url), {
3+
type: 'module',
4+
})
5+
return new Promise((res) => {
6+
worker.onmessage = (e) => res(e.data)
7+
})
8+
}
9+
10+
export function getAssetUrl() {
11+
return new URL('./logo.png', import.meta.url).href
12+
}
12.5 KB
Loading
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export function urlImportWorker() {
2+
// Testing the ../ path traversal
3+
const worker = new Worker(new URL('../worker.js', import.meta.url), {
4+
type: 'module',
5+
})
6+
return new Promise((res) => {
7+
worker.onmessage = (e) => res(e.data)
8+
})
9+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"name": "@vitejs/test-dep-with-assets",
3+
"version": "1.0.0",
4+
"type": "module",
5+
"exports": {
6+
".": "./index.js",
7+
"./nested": "./nested/index.js"
8+
}
9+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
self.postMessage('worker-success')

playground/optimize-deps/index.html

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,3 +386,25 @@ <h2>Virtual module with .vue extension</h2>
386386
`${VirtualComponent.name === 'VirtualComponent' ? 'ok' : 'ng'}`,
387387
)
388388
</script>
389+
390+
<h2>Relative asset URLs in optimized dependencies</h2>
391+
<div>
392+
Message from lib worker:
393+
<span class="worker-lib"></span>
394+
</div>
395+
<div>
396+
Message from nested worker:
397+
<span class="worker-nested"></span>
398+
</div>
399+
<h2>Non-worker asset from optimized dep</h2>
400+
<div>
401+
Asset URL from lib:
402+
<span class="asset-url"></span>
403+
</div>
404+
<script type="module">
405+
import * as lib from '@vitejs/test-dep-with-assets'
406+
import * as nested from '@vitejs/test-dep-with-assets/nested'
407+
lib.urlImportWorker().then((m) => text('.worker-lib', m))
408+
nested.urlImportWorker().then((m) => text('.worker-nested', m))
409+
text('.asset-url', lib.getAssetUrl())
410+
</script>

playground/optimize-deps/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"@vitejs/test-dep-non-optimized": "file:./dep-non-optimized",
4343
"@vitejs/test-added-in-entries": "file:./added-in-entries",
4444
"@vitejs/test-dep-cjs-external-package-omit-js-suffix": "file:./dep-cjs-external-package-omit-js-suffix",
45+
"@vitejs/test-dep-with-assets": "file:./dep-with-assets",
4546
"lodash-es": "^4.17.23",
4647
"@vitejs/test-nested-exclude": "file:./nested-exclude",
4748
"phoenix": "^1.8.3",

0 commit comments

Comments
 (0)