Skip to content

Commit 94dadfe

Browse files
authored
feat(bun): sanitize plugin names for namespaces (#599)
Bun only allows `\w`, `$`, and `-` in plugin namespaces, so coerce unsupported characters in `plugin.name` to `-` before using it as a namespace. MINOR BREAKING CHANGE: Sanitized plugin names may have namespace collisions if existing plugins map to the same name.
1 parent 33c628c commit 94dadfe

2 files changed

Lines changed: 135 additions & 3 deletions

File tree

src/bun/index.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ import { normalizeObjectHook } from '../utils/filter'
66
import { toArray } from '../utils/general'
77
import { createBuildContext, createPluginContext, guessLoader } from './utils'
88

9+
// Coerce plugin.name to satisfy Bun's namespace validator:
10+
// https://github.com/oven-sh/bun/blob/12d77d1ac561771e9fa1d0822e954273248e7f9a/src/js/builtins/BundlerPlugin.ts#L215-L217
11+
function toBunNamespace(name: string): string {
12+
return name.replace(/[^\w$-]/g, '-')
13+
}
14+
915
export function getBunPlugin<UserOptions = Record<string, never>>(
1016
factory: UnpluginFactory<UserOptions>,
1117
): UnpluginInstance<UserOptions>['bun'] {
@@ -106,7 +112,7 @@ export function getBunPlugin<UserOptions = Record<string, never>>(
106112
if (!isAbsolute(result)) {
107113
return {
108114
path: result,
109-
namespace: plugin.name,
115+
namespace: toBunNamespace(plugin.name),
110116
}
111117
}
112118
return { path: result }
@@ -116,7 +122,7 @@ export function getBunPlugin<UserOptions = Record<string, never>>(
116122
return {
117123
path: result.id,
118124
external: result.external,
119-
namespace: plugin.name,
125+
namespace: toBunNamespace(plugin.name),
120126
}
121127
}
122128
return {
@@ -221,7 +227,7 @@ export function getBunPlugin<UserOptions = Record<string, never>>(
221227
}
222228

223229
for (const pluginName of virtualModulePlugins) {
224-
build.onLoad({ filter: /.*/, namespace: pluginName }, async (args) => {
230+
build.onLoad({ filter: /.*/, namespace: toBunNamespace(pluginName) }, async (args) => {
225231
return processLoadTransform(args.path, pluginName, args.loader)
226232
})
227233
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { createUnplugin } from 'unplugin'
2+
import { describe, expect, it, vi } from 'vitest'
3+
4+
interface MockBuild {
5+
build: Bun.PluginBuilder
6+
resolveCallback: () => Bun.OnResolveCallback
7+
loadCallbacks: Map<string, Bun.OnLoadCallback>
8+
}
9+
10+
function createMockBuild(): MockBuild {
11+
let resolveCallback: Bun.OnResolveCallback | undefined
12+
const loadCallbacks = new Map<string, Bun.OnLoadCallback>()
13+
14+
const build = {
15+
onResolve: vi.fn((_options, callback) => {
16+
resolveCallback = callback
17+
}),
18+
onLoad: vi.fn((options: { namespace?: string }, callback: Bun.OnLoadCallback) => {
19+
if (options.namespace) {
20+
loadCallbacks.set(options.namespace, callback)
21+
}
22+
}),
23+
onStart: vi.fn(),
24+
config: { outdir: './dist' },
25+
} as never as Bun.PluginBuilder
26+
27+
return {
28+
build,
29+
resolveCallback: () => {
30+
if (!resolveCallback) {
31+
throw new Error('onResolve was not registered')
32+
}
33+
return resolveCallback
34+
},
35+
loadCallbacks,
36+
}
37+
}
38+
39+
describe.skipIf(typeof Bun === 'undefined')('bun namespace sanitization', () => {
40+
it('should sanitize invalid characters when resolveId returns a string', async () => {
41+
const unplugin = createUnplugin(() => ({
42+
name: 'unplugin:my.plugin/name',
43+
resolveId: () => 'virtual-id',
44+
}))
45+
const { build, resolveCallback } = createMockBuild()
46+
47+
await unplugin.bun().setup(build)
48+
const result = await resolveCallback()({
49+
path: 'foo',
50+
importer: 'index.js',
51+
kind: 'import-statement',
52+
} as Bun.OnResolveArgs)
53+
54+
expect(result).toEqual({
55+
path: 'virtual-id',
56+
namespace: 'unplugin-my-plugin-name',
57+
})
58+
})
59+
60+
it('should sanitize invalid characters when resolveId returns an object', async () => {
61+
const unplugin = createUnplugin(() => ({
62+
name: '@scope/plugin.name',
63+
resolveId: () => ({ id: 'virtual-id', external: false }),
64+
}))
65+
const { build, resolveCallback } = createMockBuild()
66+
67+
await unplugin.bun().setup(build)
68+
const result = await resolveCallback()({
69+
path: 'foo',
70+
importer: 'index.js',
71+
kind: 'import-statement',
72+
} as Bun.OnResolveArgs)
73+
74+
expect(result).toEqual({
75+
path: 'virtual-id',
76+
external: false,
77+
namespace: '-scope-plugin-name',
78+
})
79+
})
80+
81+
it('should leave plugin names with only allowed characters untouched', async () => {
82+
const unplugin = createUnplugin(() => ({
83+
name: 'valid_plugin-name$1',
84+
resolveId: () => 'virtual-id',
85+
}))
86+
const { build, resolveCallback } = createMockBuild()
87+
88+
await unplugin.bun().setup(build)
89+
const result = await resolveCallback()({
90+
path: 'foo',
91+
importer: 'index.js',
92+
kind: 'import-statement',
93+
} as Bun.OnResolveArgs)
94+
95+
expect(result).toEqual({
96+
path: 'virtual-id',
97+
namespace: 'valid_plugin-name$1',
98+
})
99+
})
100+
101+
it('should invoke the original load hook when registered under a sanitized namespace', async () => {
102+
const load = vi.fn(() => 'export default 1')
103+
const unplugin = createUnplugin(() => ({
104+
name: 'unplugin:virtual.mod',
105+
resolveId: () => 'virtual-id',
106+
load,
107+
}))
108+
const { build, loadCallbacks } = createMockBuild()
109+
110+
await unplugin.bun().setup(build)
111+
112+
expect([...loadCallbacks.keys()]).toContain('unplugin-virtual-mod')
113+
expect([...loadCallbacks.keys()]).not.toContain('unplugin:virtual.mod')
114+
115+
const result = await loadCallbacks.get('unplugin-virtual-mod')!({
116+
path: 'virtual-id',
117+
loader: 'js',
118+
} as Bun.OnLoadArgs)
119+
120+
expect(load).toHaveBeenCalledWith('virtual-id')
121+
expect(result).toEqual({
122+
contents: 'export default 1',
123+
loader: 'js',
124+
})
125+
})
126+
})

0 commit comments

Comments
 (0)