Skip to content

Commit 720447e

Browse files
authored
fix: handle encoded base paths (#17577)
1 parent 1025bb6 commit 720447e

File tree

10 files changed

+311
-19
lines changed

10 files changed

+311
-19
lines changed

packages/plugin-legacy/src/index.ts

+13-1
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ function toOutputFilePathInHtml(
8383
if (relative && !config.build.ssr) {
8484
return toRelative(filename, hostId)
8585
} else {
86-
return config.base + filename
86+
return joinUrlSegments(config.decodedBase, filename)
8787
}
8888
}
8989
function getBaseInHTML(urlRelativePath: string, config: ResolvedConfig) {
@@ -96,6 +96,18 @@ function getBaseInHTML(urlRelativePath: string, config: ResolvedConfig) {
9696
)
9797
: config.base
9898
}
99+
function joinUrlSegments(a: string, b: string): string {
100+
if (!a || !b) {
101+
return a || b || ''
102+
}
103+
if (a[a.length - 1] === '/') {
104+
a = a.substring(0, a.length - 1)
105+
}
106+
if (b[0] !== '/') {
107+
b = '/' + b
108+
}
109+
return a + b
110+
}
99111

100112
function toAssetPathFromHtml(
101113
filename: string,

packages/vite/src/node/build.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1226,7 +1226,7 @@ export function toOutputFilePathInJS(
12261226
if (relative && !config.build.ssr) {
12271227
return toRelative(filename, hostId)
12281228
}
1229-
return joinUrlSegments(config.base, filename)
1229+
return joinUrlSegments(config.decodedBase, filename)
12301230
}
12311231

12321232
export function createToImportMetaURLBasedRelativeRuntime(
@@ -1275,7 +1275,7 @@ export function toOutputFilePathWithoutRuntime(
12751275
if (relative && !config.build.ssr) {
12761276
return toRelative(filename, hostId)
12771277
} else {
1278-
return joinUrlSegments(config.base, filename)
1278+
return joinUrlSegments(config.decodedBase, filename)
12791279
}
12801280
}
12811281

packages/vite/src/node/config.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,8 @@ export type ResolvedConfig = Readonly<
365365
root: string
366366
base: string
367367
/** @internal */
368+
decodedBase: string
369+
/** @internal */
368370
rawBase: string
369371
publicDir: string
370372
cacheDir: string
@@ -763,14 +765,17 @@ export async function resolveConfig(
763765
rollupOptions: config.worker?.rollupOptions || {},
764766
}
765767

768+
const base = withTrailingSlash(resolvedBase)
769+
766770
resolved = {
767771
configFile: configFile ? normalizePath(configFile) : undefined,
768772
configFileDependencies: configFileDependencies.map((name) =>
769773
normalizePath(path.resolve(name)),
770774
),
771775
inlineConfig,
772776
root: resolvedRoot,
773-
base: withTrailingSlash(resolvedBase),
777+
base,
778+
decodedBase: decodeURI(base),
774779
rawBase: resolvedBase,
775780
resolve: resolveOptions,
776781
publicDir: resolvedPublicDir,

packages/vite/src/node/plugins/asset.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ function fileToDevUrl(id: string, config: ResolvedConfig) {
281281
// (this is special handled by the serve static middleware
282282
rtn = path.posix.join(FS_PREFIX, id)
283283
}
284-
const base = joinUrlSegments(config.server?.origin ?? '', config.base)
284+
const base = joinUrlSegments(config.server?.origin ?? '', config.decodedBase)
285285
return joinUrlSegments(base, removeLeadingSlash(rtn))
286286
}
287287

@@ -306,7 +306,7 @@ export function publicFileToBuiltUrl(
306306
): string {
307307
if (config.command !== 'build') {
308308
// We don't need relative base or renderBuiltUrl support during dev
309-
return joinUrlSegments(config.base, url)
309+
return joinUrlSegments(config.decodedBase, url)
310310
}
311311
const hash = getHash(url)
312312
let cache = publicAssetUrlCache.get(config)

packages/vite/src/node/server/middlewares/indexHtml.ts

+19-11
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,13 @@ const processNodeUrl = (
167167
)
168168
}
169169
if (preTransformUrl) {
170-
preTransformRequest(server, preTransformUrl, config.base)
170+
try {
171+
preTransformUrl = decodeURI(preTransformUrl)
172+
} catch (err) {
173+
// Malformed uri. Skip pre-transform.
174+
return url
175+
}
176+
preTransformRequest(server, preTransformUrl, config.decodedBase)
171177
}
172178
}
173179
return url
@@ -184,6 +190,7 @@ const devHtmlHook: IndexHtmlTransformHook = async (
184190
) => {
185191
const { config, moduleGraph, watcher } = server!
186192
const base = config.base || '/'
193+
const decodedBase = config.decodedBase || '/'
187194

188195
let proxyModulePath: string
189196
let proxyModuleUrl: string
@@ -202,7 +209,7 @@ const devHtmlHook: IndexHtmlTransformHook = async (
202209
proxyModulePath = `\0${validPath}`
203210
proxyModuleUrl = wrapId(proxyModulePath)
204211
}
205-
proxyModuleUrl = joinUrlSegments(base, proxyModuleUrl)
212+
proxyModuleUrl = joinUrlSegments(decodedBase, proxyModuleUrl)
206213

207214
const s = new MagicString(html)
208215
let inlineModuleIndex = -1
@@ -252,7 +259,7 @@ const devHtmlHook: IndexHtmlTransformHook = async (
252259
node.sourceCodeLocation!.endOffset,
253260
`<script type="module" src="${modulePath}"></script>`,
254261
)
255-
preTransformRequest(server!, modulePath, base)
262+
preTransformRequest(server!, modulePath, decodedBase)
256263
}
257264

258265
await traverseHtml(html, filename, (node) => {
@@ -447,15 +454,16 @@ export function indexHtmlMiddleware(
447454
}
448455
}
449456

450-
function preTransformRequest(server: ViteDevServer, url: string, base: string) {
457+
// NOTE: We usually don't prefix `url` and `base` with `decoded`, but in this file particularly
458+
// we're dealing with mixed encoded/decoded paths often, so we make this explicit for now.
459+
function preTransformRequest(
460+
server: ViteDevServer,
461+
decodedUrl: string,
462+
decodedBase: string,
463+
) {
451464
if (!server.config.server.preTransformRequests) return
452465

453466
// transform all url as non-ssr as html includes client-side assets only
454-
try {
455-
url = unwrapId(stripBase(decodeURI(url), base))
456-
} catch {
457-
// ignore
458-
return
459-
}
460-
server.warmupRequest(url)
467+
decodedUrl = unwrapId(stripBase(decodedUrl, decodedBase))
468+
server.warmupRequest(decodedUrl)
461469
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import { beforeAll, describe, expect, test } from 'vitest'
2+
import {
3+
browserLogs,
4+
findAssetFile,
5+
getBg,
6+
getColor,
7+
isBuild,
8+
page,
9+
} from '~utils'
10+
11+
const urlAssetMatch = isBuild
12+
? /\/foo%20bar\/other-assets\/asset-[-\w]{8}\.png/
13+
: '/nested/asset.png'
14+
15+
const iconMatch = '/icon.png'
16+
17+
const absoluteIconMatch = isBuild
18+
? /\/foo%20bar\/.*\/icon-[-\w]{8}\.png/
19+
: '/nested/icon.png'
20+
21+
const absolutePublicIconMatch = isBuild ? /\/foo%20bar\/icon\.png/ : '/icon.png'
22+
23+
test('should have no 404s', () => {
24+
browserLogs.forEach((msg) => {
25+
expect(msg).not.toMatch('404')
26+
})
27+
})
28+
29+
describe('raw references from /public', () => {
30+
test('load raw js from /public', async () => {
31+
expect(await page.textContent('.raw-js')).toMatch('[success]')
32+
})
33+
34+
test('load raw css from /public', async () => {
35+
expect(await getColor('.raw-css')).toBe('red')
36+
})
37+
})
38+
39+
test('import-expression from simple script', async () => {
40+
expect(await page.textContent('.import-expression')).toMatch(
41+
'[success][success]',
42+
)
43+
})
44+
45+
describe('asset imports from js', () => {
46+
test('relative', async () => {
47+
expect(await page.textContent('.asset-import-relative')).toMatch(
48+
urlAssetMatch,
49+
)
50+
})
51+
52+
test('absolute', async () => {
53+
expect(await page.textContent('.asset-import-absolute')).toMatch(
54+
urlAssetMatch,
55+
)
56+
})
57+
58+
test('from /public', async () => {
59+
expect(await page.textContent('.public-import')).toMatch(
60+
absolutePublicIconMatch,
61+
)
62+
})
63+
})
64+
65+
describe('css url() references', () => {
66+
test('fonts', async () => {
67+
expect(
68+
await page.evaluate(() => document.fonts.check('700 32px Inter')),
69+
).toBe(true)
70+
})
71+
72+
test('relative', async () => {
73+
const bg = await getBg('.css-url-relative')
74+
expect(bg).toMatch(urlAssetMatch)
75+
})
76+
77+
test('image-set relative', async () => {
78+
const imageSet = await getBg('.css-image-set-relative')
79+
imageSet.split(', ').forEach((s) => {
80+
expect(s).toMatch(urlAssetMatch)
81+
})
82+
})
83+
84+
test('image-set without the url() call', async () => {
85+
const imageSet = await getBg('.css-image-set-without-url-call')
86+
imageSet.split(', ').forEach((s) => {
87+
expect(s).toMatch(urlAssetMatch)
88+
})
89+
})
90+
91+
test('image-set with var', async () => {
92+
const imageSet = await getBg('.css-image-set-with-var')
93+
imageSet.split(', ').forEach((s) => {
94+
expect(s).toMatch(urlAssetMatch)
95+
})
96+
})
97+
98+
test('image-set with mix', async () => {
99+
const imageSet = await getBg('.css-image-set-mix-url-var')
100+
imageSet.split(', ').forEach((s) => {
101+
expect(s).toMatch(urlAssetMatch)
102+
})
103+
})
104+
105+
test('relative in @import', async () => {
106+
expect(await getBg('.css-url-relative-at-imported')).toMatch(urlAssetMatch)
107+
})
108+
109+
test('absolute', async () => {
110+
expect(await getBg('.css-url-absolute')).toMatch(urlAssetMatch)
111+
})
112+
113+
test('from /public', async () => {
114+
expect(await getBg('.css-url-public')).toMatch(iconMatch)
115+
})
116+
117+
test('multiple urls on the same line', async () => {
118+
const bg = await getBg('.css-url-same-line')
119+
expect(bg).toMatch(urlAssetMatch)
120+
expect(bg).toMatch(iconMatch)
121+
})
122+
123+
test('aliased', async () => {
124+
const bg = await getBg('.css-url-aliased')
125+
expect(bg).toMatch(urlAssetMatch)
126+
})
127+
})
128+
129+
describe.runIf(isBuild)('index.css URLs', () => {
130+
let css: string
131+
beforeAll(() => {
132+
css = findAssetFile(/index.*\.css$/, 'encoded-base', 'other-assets')
133+
})
134+
135+
test('use base URL for asset URL', () => {
136+
expect(css).toMatch(urlAssetMatch)
137+
})
138+
139+
test('preserve postfix query/hash', () => {
140+
expect(css).toMatch('woff2?#iefix')
141+
})
142+
})
143+
144+
describe('image', () => {
145+
test('srcset', async () => {
146+
const img = await page.$('.img-src-set')
147+
const srcset = await img.getAttribute('srcset')
148+
srcset.split(', ').forEach((s) => {
149+
expect(s).toMatch(
150+
isBuild
151+
? /\/foo%20bar\/other-assets\/asset-[-\w]{8}\.png \dx/
152+
: /\/foo%20bar\/nested\/asset\.png \dx/,
153+
)
154+
})
155+
})
156+
})
157+
158+
describe('svg fragments', () => {
159+
// 404 is checked already, so here we just ensure the urls end with #fragment
160+
test('img url', async () => {
161+
const img = await page.$('.svg-frag-img')
162+
expect(await img.getAttribute('src')).toMatch(/svg#icon-clock-view$/)
163+
})
164+
165+
test('via css url()', async () => {
166+
const bg = await page.evaluate(
167+
() => getComputedStyle(document.querySelector('.icon')).backgroundImage,
168+
)
169+
expect(bg).toMatch(/svg#icon-clock-view"\)$/)
170+
})
171+
172+
test('from js import', async () => {
173+
const img = await page.$('.svg-frag-import')
174+
expect(await img.getAttribute('src')).toMatch(/svg#icon-heart-view$/)
175+
})
176+
})
177+
178+
test('?raw import', async () => {
179+
expect(await page.textContent('.raw')).toMatch('SVG')
180+
})
181+
182+
test('?url import', async () => {
183+
expect(await page.textContent('.url')).toMatch(
184+
isBuild ? /\/foo%20bar\/other-assets\/foo-[-\w]{8}\.js/ : '/foo.js',
185+
)
186+
})
187+
188+
test('?url import on css', async () => {
189+
const txt = await page.textContent('.url-css')
190+
expect(txt).toMatch(
191+
isBuild
192+
? /\/foo%20bar\/other-assets\/icons-[-\w]{8}\.css/
193+
: '/css/icons.css',
194+
)
195+
})
196+
197+
test('new URL(..., import.meta.url)', async () => {
198+
expect(await page.textContent('.import-meta-url')).toMatch(urlAssetMatch)
199+
})
200+
201+
test('new URL(`${dynamic}`, import.meta.url)', async () => {
202+
const dynamic1 = await page.textContent('.dynamic-import-meta-url-1')
203+
expect(dynamic1).toMatch(absoluteIconMatch)
204+
const dynamic2 = await page.textContent('.dynamic-import-meta-url-2')
205+
expect(dynamic2).toMatch(urlAssetMatch)
206+
})
207+
208+
test('new URL(`non-existent`, import.meta.url)', async () => {
209+
expect(await page.textContent('.non-existent-import-meta-url')).toMatch(
210+
'/non-existent',
211+
)
212+
})
213+
214+
test('inline style test', async () => {
215+
expect(await getBg('.inline-style')).toMatch(urlAssetMatch)
216+
expect(await getBg('.style-url-assets')).toMatch(urlAssetMatch)
217+
})
218+
219+
test('html import word boundary', async () => {
220+
expect(await page.textContent('.obj-import-express')).toMatch(
221+
'ignore object import prop',
222+
)
223+
expect(await page.textContent('.string-import-express')).toMatch('no load')
224+
})
225+
226+
test('relative path in html asset', async () => {
227+
expect(await page.textContent('.relative-js')).toMatch('hello')
228+
expect(await getColor('.relative-css')).toMatch('red')
229+
})

playground/assets/package.json

+3
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
"dev": "vite",
99
"build": "vite build",
1010
"preview": "vite preview",
11+
"dev:encoded-base": "vite --config ./vite.config-encoded-base.js dev",
12+
"build:encoded-base": "vite --config ./vite.config-encoded-base.js build",
13+
"preview:encoded-base": "vite --config ./vite.config-encoded-base.js preview",
1114
"dev:relative-base": "vite --config ./vite.config-relative-base.js dev",
1215
"build:relative-base": "vite --config ./vite.config-relative-base.js build",
1316
"preview:relative-base": "vite --config ./vite.config-relative-base.js preview",

0 commit comments

Comments
 (0)