Skip to content

Commit ec6b74e

Browse files
antfubotantfuedemaineclaude
authored
fix: unescape webpack/rspack resource paths in load and transform (#631)
* fix: unescape webpack/rspack resource paths in load and transform Co-authored-by: edemaine <edemaine@mit.edu> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * test: run the escaped-path builds through the shared error-checking runners --------- Co-authored-by: Anthony Fu <github@antfu.me> Co-authored-by: edemaine <edemaine@mit.edu> Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com> Co-authored-by: Anthony Fu <anthonyfu117@hotmail.com>
1 parent 0ecc344 commit ec6b74e

6 files changed

Lines changed: 137 additions & 8 deletions

File tree

‎src/rspack/loaders/load.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { LoaderContext } from '@rspack/core'
22
import type { Buffer } from 'node:buffer'
33
import type { ResolvedUnpluginOptions } from '../../types'
4-
import { normalizeAbsolutePath } from '../../utils/webpack-like'
4+
import { normalizeAbsolutePath, unescapeResourcePath } from '../../utils/webpack-like'
55
import { createBuildContext, createContext } from '../context'
66
import { decodeVirtualModuleId, isVirtualModuleId } from '../utils'
77

@@ -20,6 +20,8 @@ export default async function load(this: LoaderContext, source: string | Buffer,
2020

2121
if (isVirtualModuleId(id, plugin))
2222
id = decodeVirtualModuleId(id, plugin)
23+
else
24+
id = unescapeResourcePath(id, 'rspack')
2325

2426
const context = createContext(this)
2527
const hook = plugin.load

‎src/rspack/loaders/transform.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { HookFnMap, ResolvedUnpluginOptions } from '../../types'
44
import type { TransformHookFilter } from '../../utils/filter'
55
import { normalizeObjectHook } from '../../utils/filter'
66
import { mergeSourceMaps } from '../../utils/sourcemap'
7+
import { unescapeResourcePath } from '../../utils/webpack-like'
78
import { createBuildContext, createContext } from '../context'
89

910
const filters = new WeakMap<
@@ -26,7 +27,7 @@ export default async function transform(
2627
if (!plugin?.transform)
2728
return callback(null, source, map)
2829

29-
const id = this.resource
30+
const id = unescapeResourcePath(this.resource, 'rspack')
3031
const context = createContext(this)
3132
const hook = plugin.transform
3233
const cwd = process.cwd()
@@ -47,7 +48,7 @@ export default async function transform(
4748
const code = typeof source === 'string'
4849
? source
4950
: source.toString('utf-8')
50-
if (!filter(this.resource, code))
51+
if (!filter(id, code))
5152
return callback(null, source, map)
5253

5354
try {
@@ -67,7 +68,7 @@ export default async function transform(
6768
else if (typeof res !== 'string') {
6869
callback(null, res.code, this.sourceMap === false
6970
? map
70-
: mergeSourceMaps(res.map, map, this.resource))
71+
: mergeSourceMaps(res.map, map, id))
7172
}
7273
else {
7374
callback(null, res, map)

‎src/utils/webpack-like.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,21 @@ export function normalizeAbsolutePath(path: string): string {
5656
else
5757
return path
5858
}
59+
60+
/**
61+
* Webpack escapes special characters in a resource path, such as the `#` of `msg#hash.js`, by
62+
* prefixing them with an escape character. Rspack does the same with a different one.
63+
* Hooks should see the real path, so the escape characters have to be removed again.
64+
*
65+
* Only use this on resource paths, not on virtual module ids: those can legitimately
66+
* start with `\0`, like `\0virtual:foo` in the Rollup convention.
67+
*
68+
* @param path - Resource path of the loader context.
69+
* @param framework - Bundler that produced the path.
70+
* @returns the path without escape characters.
71+
*/
72+
export function unescapeResourcePath(path: string, framework: 'webpack' | 'rspack'): string {
73+
return framework === 'webpack'
74+
? path.replace(/\0([\s\S])/g, '$1')
75+
: path.replace(/\u200B([\s\S])/g, '$1')
76+
}

‎src/webpack/loaders/load.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Buffer } from 'node:buffer'
22
import type { LoaderContext } from 'webpack'
33
import type { ResolvedUnpluginOptions } from '../../types'
4-
import { normalizeAbsolutePath } from '../../utils/webpack-like'
4+
import { normalizeAbsolutePath, unescapeResourcePath } from '../../utils/webpack-like'
55
import { createBuildContext, createContext } from '../context'
66

77
// Receive the source as a Buffer. Without this, webpack decodes every file it passes through
@@ -19,6 +19,8 @@ export default async function load(this: LoaderContext<any>, source: string | Bu
1919

2020
if (id.startsWith(plugin.__virtualModulePrefix))
2121
id = decodeURIComponent(id.slice(plugin.__virtualModulePrefix.length))
22+
else
23+
id = unescapeResourcePath(id, 'webpack')
2224

2325
const context = createContext(this)
2426
const hook = plugin.load

‎src/webpack/loaders/transform.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { HookFnMap, ResolvedUnpluginOptions } from '../../types'
44
import type { TransformHookFilter } from '../../utils/filter'
55
import { normalizeObjectHook } from '../../utils/filter'
66
import { mergeSourceMaps } from '../../utils/sourcemap'
7+
import { unescapeResourcePath } from '../../utils/webpack-like'
78
import { createBuildContext, createContext } from '../context'
89

910
const filters = new WeakMap<
@@ -23,6 +24,7 @@ export default async function transform(this: LoaderContext<any>, source: string
2324
if (!plugin?.transform)
2425
return callback(null, source, map)
2526

27+
const id = unescapeResourcePath(this.resource, 'webpack')
2628
const context = createContext(this)
2729
const hook = plugin.transform
2830
const cwd = process.cwd()
@@ -43,7 +45,7 @@ export default async function transform(this: LoaderContext<any>, source: string
4345
const code = typeof source === 'string'
4446
? source
4547
: source.toString('utf-8')
46-
if (!filter(this.resource, code))
48+
if (!filter(id, code))
4749
return callback(null, source, map)
4850

4951
try {
@@ -57,7 +59,7 @@ export default async function transform(this: LoaderContext<any>, source: string
5759
},
5860
}, this._compiler!, this._compilation, this, map), context),
5961
code,
60-
this.resource,
62+
id,
6163
)
6264

6365
if (res == null) {
@@ -66,7 +68,7 @@ export default async function transform(this: LoaderContext<any>, source: string
6668
else if (typeof res !== 'string') {
6769
callback(null, res.code, this.sourceMap === false
6870
? map
69-
: mergeSourceMaps(res.map, map, this.resource))
71+
: mergeSourceMaps(res.map, map, id))
7072
}
7173
else {
7274
callback(null, res, map)
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'node:path'
4+
import { createUnplugin } from 'unplugin'
5+
import { afterAll, describe, expect, it } from 'vitest'
6+
import { unescapeResourcePath } from '../../../src/utils/webpack-like'
7+
import { runRspack, runWebpack } from '../utils'
8+
9+
const root = realpathSync(mkdtempSync(join(tmpdir(), 'unplugin-escaped-path-')))
10+
const hashFile = join(root, 'msg#hash.js')
11+
const virtualId = '\0virtual-msg'
12+
13+
mkdirSync(root, { recursive: true })
14+
writeFileSync(join(root, 'entry.js'), [
15+
`import hash from './msg#hash.js'`,
16+
`import virtual from 'virtual-msg'`,
17+
`console.log(hash, virtual)`,
18+
].join('\n'))
19+
writeFileSync(hashFile, `export default 'hash'`)
20+
21+
afterAll(() => rmSync(root, { recursive: true, force: true }))
22+
23+
function createPlugin() {
24+
const seen: Record<'resolveId' | 'load' | 'transform', string[]> = { resolveId: [], load: [], transform: [] }
25+
const plugin = createUnplugin(() => ({
26+
name: 'escaped-path',
27+
resolveId(id) {
28+
seen.resolveId.push(id)
29+
if (id === 'virtual-msg')
30+
return virtualId
31+
},
32+
load(id) {
33+
seen.load.push(id)
34+
if (id === virtualId)
35+
return `export default 'virtual'`
36+
},
37+
transform(code, id) {
38+
seen.transform.push(id)
39+
return code
40+
},
41+
}))
42+
return { seen, plugin }
43+
}
44+
45+
const frameworks = [
46+
[
47+
'webpack',
48+
(plugin: ReturnType<typeof createPlugin>['plugin']) => runWebpack({
49+
mode: 'development',
50+
context: root,
51+
entry: join(root, 'entry.js'),
52+
output: {
53+
path: join(root, 'dist-webpack'),
54+
},
55+
plugins: [
56+
plugin.webpack(),
57+
],
58+
}),
59+
],
60+
[
61+
'rspack',
62+
(plugin: ReturnType<typeof createPlugin>['plugin']) => runRspack({
63+
mode: 'development',
64+
context: root,
65+
entry: join(root, 'entry.js'),
66+
output: {
67+
path: join(root, 'dist-rspack'),
68+
},
69+
plugins: [
70+
plugin.rspack(),
71+
],
72+
}),
73+
],
74+
] as const
75+
76+
describe('escaped paths', () => {
77+
for (const [name, run] of frameworks) {
78+
it(name, async () => {
79+
const { seen, plugin } = createPlugin()
80+
await run(plugin)
81+
82+
// A `#` in a real file name must reach the hooks as is, without the loader escape
83+
for (const hook of ['load', 'transform'] as const) {
84+
expect(seen[hook]).toContain(hashFile)
85+
}
86+
// and a virtual id with a leading `\0` must stay untouched
87+
expect(seen.load).toContain(virtualId)
88+
})
89+
}
90+
})
91+
92+
describe('unescapeResourcePath', () => {
93+
it('removes the escape character of each bundler', () => {
94+
expect(unescapeResourcePath('/a/msg\0#hash.js', 'webpack')).toBe('/a/msg#hash.js')
95+
expect(unescapeResourcePath('/a/msg\0?x\0\0y.js', 'webpack')).toBe('/a/msg?x\0y.js')
96+
expect(unescapeResourcePath('/a/msg\u200B#hash.js', 'rspack')).toBe('/a/msg#hash.js')
97+
expect(unescapeResourcePath('/a/msg\u200B?x\u200B\u200By.js', 'rspack')).toBe('/a/msg?x\u200By.js')
98+
})
99+
100+
it('leaves the escape character of the other bundler alone', () => {
101+
expect(unescapeResourcePath('/a/msg\u200B#hash.js', 'webpack')).toBe('/a/msg\u200B#hash.js')
102+
expect(unescapeResourcePath('/a/msg\0#hash.js', 'rspack')).toBe('/a/msg\0#hash.js')
103+
})
104+
})

0 commit comments

Comments
 (0)