From db86f6e5f1285150562b250b966b230a02d4315b Mon Sep 17 00:00:00 2001 From: 81reap Date: Thu, 21 May 2026 20:08:44 -0400 Subject: [PATCH] feat(bun)!: sanitize plugin names for Bun namespaces Bun only allows `\w`, `$`, and `-` in plugin namespaces, so coerce unsupported characters in `plugin.name` to `-` before using it as a namespace. BREAKING CHANGE: Sanitized plugin names may have namespace collisions if existing plugins map to the same name. --- src/bun/index.ts | 12 ++- test/unit-tests/bun/namespace.test.ts | 126 ++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 test/unit-tests/bun/namespace.test.ts diff --git a/src/bun/index.ts b/src/bun/index.ts index d0b98443..c961f9ba 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -6,6 +6,12 @@ import { normalizeObjectHook } from '../utils/filter' import { toArray } from '../utils/general' import { createBuildContext, createPluginContext, guessLoader } from './utils' +// Coerce plugin.name to satisfy Bun's namespace validator: +// https://github.com/oven-sh/bun/blob/12d77d1ac561771e9fa1d0822e954273248e7f9a/src/js/builtins/BundlerPlugin.ts#L215-L217 +function toBunNamespace(name: string): string { + return name.replace(/[^\w$-]/g, '-') +} + export function getBunPlugin>( factory: UnpluginFactory, ): UnpluginInstance['bun'] { @@ -102,7 +108,7 @@ export function getBunPlugin>( if (!isAbsolute(result)) { return { path: result, - namespace: plugin.name, + namespace: toBunNamespace(plugin.name), } } return { path: result } @@ -112,7 +118,7 @@ export function getBunPlugin>( return { path: result.id, external: result.external, - namespace: plugin.name, + namespace: toBunNamespace(plugin.name), } } return { @@ -217,7 +223,7 @@ export function getBunPlugin>( } for (const pluginName of virtualModulePlugins) { - build.onLoad({ filter: /.*/, namespace: pluginName }, async (args) => { + build.onLoad({ filter: /.*/, namespace: toBunNamespace(pluginName) }, async (args) => { return processLoadTransform(args.path, pluginName, args.loader) }) } diff --git a/test/unit-tests/bun/namespace.test.ts b/test/unit-tests/bun/namespace.test.ts new file mode 100644 index 00000000..20f8fe79 --- /dev/null +++ b/test/unit-tests/bun/namespace.test.ts @@ -0,0 +1,126 @@ +import { createUnplugin } from 'unplugin' +import { describe, expect, it, vi } from 'vitest' + +interface MockBuild { + build: Bun.PluginBuilder + resolveCallback: () => Bun.OnResolveCallback + loadCallbacks: Map +} + +function createMockBuild(): MockBuild { + let resolveCallback: Bun.OnResolveCallback | undefined + const loadCallbacks = new Map() + + const build = { + onResolve: vi.fn((_options, callback) => { + resolveCallback = callback + }), + onLoad: vi.fn((options: { namespace?: string }, callback: Bun.OnLoadCallback) => { + if (options.namespace) { + loadCallbacks.set(options.namespace, callback) + } + }), + onStart: vi.fn(), + config: { outdir: './dist' }, + } as never as Bun.PluginBuilder + + return { + build, + resolveCallback: () => { + if (!resolveCallback) { + throw new Error('onResolve was not registered') + } + return resolveCallback + }, + loadCallbacks, + } +} + +describe.skipIf(typeof Bun === 'undefined')('bun namespace sanitization', () => { + it('should sanitize invalid characters when resolveId returns a string', async () => { + const unplugin = createUnplugin(() => ({ + name: 'unplugin:my.plugin/name', + resolveId: () => 'virtual-id', + })) + const { build, resolveCallback } = createMockBuild() + + await unplugin.bun().setup(build) + const result = await resolveCallback()({ + path: 'foo', + importer: 'index.js', + kind: 'import-statement', + } as Bun.OnResolveArgs) + + expect(result).toEqual({ + path: 'virtual-id', + namespace: 'unplugin-my-plugin-name', + }) + }) + + it('should sanitize invalid characters when resolveId returns an object', async () => { + const unplugin = createUnplugin(() => ({ + name: '@scope/plugin.name', + resolveId: () => ({ id: 'virtual-id', external: false }), + })) + const { build, resolveCallback } = createMockBuild() + + await unplugin.bun().setup(build) + const result = await resolveCallback()({ + path: 'foo', + importer: 'index.js', + kind: 'import-statement', + } as Bun.OnResolveArgs) + + expect(result).toEqual({ + path: 'virtual-id', + external: false, + namespace: '-scope-plugin-name', + }) + }) + + it('should leave plugin names with only allowed characters untouched', async () => { + const unplugin = createUnplugin(() => ({ + name: 'valid_plugin-name$1', + resolveId: () => 'virtual-id', + })) + const { build, resolveCallback } = createMockBuild() + + await unplugin.bun().setup(build) + const result = await resolveCallback()({ + path: 'foo', + importer: 'index.js', + kind: 'import-statement', + } as Bun.OnResolveArgs) + + expect(result).toEqual({ + path: 'virtual-id', + namespace: 'valid_plugin-name$1', + }) + }) + + it('should invoke the original load hook when registered under a sanitized namespace', async () => { + const load = vi.fn(() => 'export default 1') + const unplugin = createUnplugin(() => ({ + name: 'unplugin:virtual.mod', + resolveId: () => 'virtual-id', + load, + })) + const { build, loadCallbacks } = createMockBuild() + + await unplugin.bun().setup(build) + + expect([...loadCallbacks.keys()]).toContain('unplugin-virtual-mod') + expect([...loadCallbacks.keys()]).not.toContain('unplugin:virtual.mod') + + const result = await loadCallbacks.get('unplugin-virtual-mod')!({ + path: 'virtual-id', + loader: 'js', + } as Bun.OnLoadArgs) + + expect(load).toHaveBeenCalledWith('virtual-id') + expect(result).toEqual({ + contents: 'export default 1', + loader: 'js', + }) + }) +})