Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions src/bun/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '-')
}
Comment thread
sxzz marked this conversation as resolved.

export function getBunPlugin<UserOptions = Record<string, never>>(
factory: UnpluginFactory<UserOptions>,
): UnpluginInstance<UserOptions>['bun'] {
Expand Down Expand Up @@ -106,7 +112,7 @@ export function getBunPlugin<UserOptions = Record<string, never>>(
if (!isAbsolute(result)) {
return {
path: result,
namespace: plugin.name,
namespace: toBunNamespace(plugin.name),
}
}
return { path: result }
Expand All @@ -116,7 +122,7 @@ export function getBunPlugin<UserOptions = Record<string, never>>(
return {
path: result.id,
external: result.external,
namespace: plugin.name,
namespace: toBunNamespace(plugin.name),
}
}
return {
Expand Down Expand Up @@ -221,7 +227,7 @@ export function getBunPlugin<UserOptions = Record<string, never>>(
}

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)
})
}
Comment on lines 229 to 233
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot May 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Potential namespace collision risk with multiple plugins.

If two plugins have names that sanitize to the same namespace (e.g., "foo@bar" and "foo-bar" both become "foo-bar"), the second build.onLoad registration could conflict with the first. This may result in one plugin's load hook shadowing another or runtime errors.

Consider adding collision detection:

🛡️ Proposed collision detection
+    const sanitizedNamespaces = new Map<string, string>()
     for (const pluginName of virtualModulePlugins) {
+      const sanitized = toBunNamespace(pluginName)
+      const existing = sanitizedNamespaces.get(sanitized)
+      if (existing) {
+        console.warn(
+          `[unplugin] Namespace collision detected: plugins "${existing}" and "${pluginName}" both sanitize to "${sanitized}". This may cause unexpected behavior.`
+        )
+      }
+      sanitizedNamespaces.set(sanitized, pluginName)
       build.onLoad({ filter: /.*/, namespace: toBunNamespace(pluginName) }, async (args) => {
         return processLoadTransform(args.path, pluginName, args.loader)
       })
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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)
})
}
const sanitizedNamespaces = new Map<string, string>()
for (const pluginName of virtualModulePlugins) {
const sanitized = toBunNamespace(pluginName)
const existing = sanitizedNamespaces.get(sanitized)
if (existing) {
console.warn(
`[unplugin] Namespace collision detected: plugins "${existing}" and "${pluginName}" both sanitize to "${sanitized}". This may cause unexpected behavior.`
)
}
sanitizedNamespaces.set(sanitized, pluginName)
build.onLoad({ filter: /.*/, namespace: toBunNamespace(pluginName) }, async (args) => {
return processLoadTransform(args.path, pluginName, args.loader)
})
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/bun/index.ts` around lines 225 - 229, The loop registering build.onLoad
for virtualModulePlugins can produce duplicate namespaces via
toBunNamespace(pluginName) which causes collisions; before registering, compute
the namespace for each pluginName (using toBunNamespace) and detect duplicates
(e.g., build a Set or map keyed by namespace), and if a duplicate is found
either throw a clear error mentioning the conflicting plugin names or generate a
deterministic unique namespace (e.g., append a stable suffix) and use that when
calling build.onLoad; update any related uses of processLoadTransform to receive
the chosen namespace mapping so the correct pluginName is passed to
processLoadTransform.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, this is an intentional side effect of doing this

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

@81reap, understood — thanks for clarifying! I'll go ahead and resolve this comment.


🏁 Script executed:

#!/bin/bash
# List CodeRabbit's comments on PR `#599` to find the one to resolve
gh pr view 599 --repo unjs/unplugin --comments --json comments --jq '.comments[] | select(.author.login == "coderabbitai[bot]") | {id: .id, url: .url, body: .body[:100]}'

Length of output: 0


🏁 Script executed:

#!/bin/bash
# List review comments (inline) on PR `#599`
gh api repos/unjs/unplugin/pulls/599/comments --jq '.[] | select(.user.login == "coderabbitai[bot]") | {id: .id, url: .html_url, body: .body[:80]}'

Length of output: 390


🏁 Script executed:

#!/bin/bash
# Get the pull request review thread node ID for the comment
gh api graphql -f query='
{
  repository(owner: "unjs", name: "unplugin") {
    pullRequest(number: 599) {
      reviewThreads(first: 50) {
        nodes {
          id
          isResolved
          comments(first: 1) {
            nodes {
              databaseId
              body
            }
          }
        }
      }
    }
  }
}' --jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.comments.nodes[0].databaseId == 3285099044) | {threadId: .id, isResolved: .isResolved}'

Length of output: 56


🏁 Script executed:

#!/bin/bash
# Resolve the review thread
gh api graphql -f query='
mutation {
  resolveReviewThread(input: {threadId: "PRRT_kwDOFvaecc6D9-0h"}) {
    thread {
      id
      isResolved
    }
  }
}'

Length of output: 266


@81reap, understood — thanks for clarifying! I wasn't able to resolve the thread programmatically due to permission restrictions, but I'll note this as intentional behavior.

The trade-off is clear: sanitizing plugin names to meet Bun's namespace requirements is the primary goal, and any potential collisions from two distinctly-named plugins mapping to the same sanitized namespace are an accepted edge case. If collisions ever become a real problem in practice, a warning or deduplication strategy can be revisited in a follow-up.


🧬 Code Graph Analysis Results
  • File: test/fixtures/load/bun.config.js (lines 1-1)
const Bun = require('bun')
  • File: test/fixtures/transform/bun.config.js (lines 1-1)
const Bun = require('bun')
  • File: test/fixtures/virtual-module/bun.config.js (lines 1-1)
const Bun = require('bun')
  • File: src/bun/utils.ts (lines 29-55)
export function createBuildContext(build: PluginBuilder): UnpluginBuildContext {
  const watchFiles: string[] = []

  return {
    addWatchFile(file) {
      watchFiles.push(file)
    },
    getWatchFiles() {
      return watchFiles
    },
    emitFile(emittedFile) {
      const outFileName = emittedFile.fileName || emittedFile.name
      const outdir = build?.config?.outdir
      if (outdir && emittedFile.source && outFileName) {
        const outPath = path.resolve(outdir, outFileName)
        const outDir = path.dirname(outPath)
        if (!fs.existsSync(outDir))
          fs.mkdirSync(outDir, { recursive: true })
        fs.writeFileSync(outPath, emittedFile.source)
      }
    },
    parse,
    getNativeBuildContext() {
      return { framework: 'bun', build }
    },
  }
}
  • File: src/bun/utils.ts (lines 57-82)
export function createPluginContext(
  buildContext: UnpluginBuildContext,
): {
  errors: Array<string | UnpluginMessage>
  warnings: Array<string | UnpluginMessage>
  mixedContext: UnpluginBuildContext & UnpluginContext
} {
  const errors: Array<string | UnpluginMessage> = []
  const warnings: Array<string | UnpluginMessage> = []

  const mixedContext: UnpluginBuildContext & UnpluginContext = {
    ...buildContext,
    error(error) {
      errors.push(error)
    },
    warn(warning) {
      warnings.push(warning)
    },
  }

  return {
    errors,
    warnings,
    mixedContext,
  }
}
  • File: src/utils/general.ts (lines 3-8)
export function toArray<T>(array?: Nullable<Arrayable<T>>): Array<T> {
  array = array || []
  if (Array.isArray(array))
    return array
  return [array]
}
  • File: src/utils/filter.ts (lines 153-181)
export function normalizeObjectHook<T extends (...args: any[]) => any, F extends keyof HookFilter>(
  name: 'resolveId' | 'load' | 'transform',
  hook: Hook<T, F>,
): {
  handler: T
  filter: PluginFilter | TransformHookFilter
} {
  let handler: T
  let filter: PluginFilter | TransformHookFilter | undefined

  if (typeof hook === 'function') {
    handler = hook
  }
  else {
    handler = hook.handler
    const hookFilter = hook.filter as HookFilter | undefined
    if (name === 'resolveId' || name === 'load') {
      filter = createFilterForId(hookFilter?.id)
    }
    else {
      filter = createFilterForTransform(hookFilter?.id, hookFilter?.code)
    }
  }

  return {
    handler,
    filter: filter || (() => true),
  }
}
  • File: test/unit-tests/utils.ts (lines 24-49)
export const build: {
  webpack: typeof webpack.webpack
  rspack: typeof rspackBuild
  rollup: typeof rollupBuild
  rolldown: typeof rolldownBuild
  vite: typeof viteBuild
  esbuild: typeof esbuildBuild
  bun: typeof bunBuild
} = {
  webpack: webpackBuild,
  rspack: rspackBuild,
  rollup: rollupBuild,
  rolldown: rolldownBuild,
  vite(config) {
    return viteBuild(vite.mergeConfig(config || {}, {
      build: {
        rollupOptions: {
          logLevel: 'silent',
        },
      },
      logLevel: 'silent',
    }))
  },
  esbuild: esbuildBuild,
  bun: bunBuild,
}
  • File: src/types.ts (lines 158-161)
export type UnpluginFactory<UserOptions, Nested extends boolean = boolean> = (options: UserOptions, meta: UnpluginContextMeta) =>
Nested extends true
  ? Array<UnpluginOptions>
  : UnpluginOptions
  • File: src/types.ts (lines 166-177)
export interface UnpluginInstance<UserOptions, Nested extends boolean = boolean> {
  rollup: UnpluginFactoryOutput<UserOptions, Nested extends true ? Array<RollupPlugin> : RollupPlugin>
  vite: UnpluginFactoryOutput<UserOptions, Nested extends true ? Array<VitePlugin> : VitePlugin>
  rolldown: UnpluginFactoryOutput<UserOptions, Nested extends true ? Array<RolldownPlugin> : RolldownPlugin>
  webpack: UnpluginFactoryOutput<UserOptions, WebpackPluginInstance>
  rspack: UnpluginFactoryOutput<UserOptions, RspackPluginInstance>
  esbuild: UnpluginFactoryOutput<UserOptions, EsbuildPlugin>
  unloader: UnpluginFactoryOutput<UserOptions, Nested extends true ? Array<UnloaderPlugin> : UnloaderPlugin>
  farm: UnpluginFactoryOutput<UserOptions, FarmPlugin>
  bun: UnpluginFactoryOutput<UserOptions, BunPlugin>
  raw: UnpluginFactory<UserOptions, Nested>
}
  • File: src/types.ts (lines 181-209)
export type UnpluginContextMeta = Partial<RollupContextMeta> & {
  /**
   * Version information for frameworks.
   * Access the current framework version via: `meta.versions[meta.framework]`
   *
   * For Vite, includes both Vite's version and the underlying bundler (Rollup/Rolldown).
   * For Rollup-compatible frameworks (vite, rollup, rolldown, unloader),
   * versions are only available after the `buildStart` hook.
   *
   * The `unplugin` version is always available for all frameworks.
   */
  versions: Partial<Record<SupportedFramework | 'unplugin', string>>
} & ({
  framework: 'rollup' | 'vite' | 'rolldown' | 'farm' | 'unloader'
} | {
  framework: 'webpack'
  webpack: { compiler: WebpackCompiler }
} | {
  framework: 'esbuild'
  /** Set the host plugin name of esbuild when returning multiple plugins */
  esbuildHostName?: string | undefined
} | {
  framework: 'bun'
  /** Set the host plugin name of bun when returning multiple plugins */
  bunHostName?: string | undefined
} | {
  framework: 'rspack'
  rspack: { compiler: RspackCompiler }
})
  • File: src/types.ts (lines 48-48)
export type TransformResult = string | { code: string, map?: SourceMapInput | SourceMapCompact | null | undefined } | null | undefined | void

✏️ Learnings added
Learnt from: 81reap
Repo: unjs/unplugin PR: 599
File: src/bun/index.ts:225-229
Timestamp: 2026-05-22T00:41:53.946Z
Learning: In `src/bun/index.ts` of the unjs/unplugin repository, the `toBunNamespace()` helper replaces invalid characters in plugin names to produce Bun-compatible namespace identifiers. The potential for two different plugin names sanitizing to the same namespace (e.g., "foobar" and "foo-bar" both becoming "foo-bar") is an intentional, accepted side effect of this sanitization approach — not a bug to be fixed.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Copy link
Copy Markdown
Contributor Author

@81reap 81reap May 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also @sxzz I would like to talk more about this fix before merging (the other two are straight forward). there are intentional side effects being added to the codebase. Not sure if your aligned with it or have recommended safety precautions to keep a high bar for good UX/DX

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is acceptable in this case. Previously, namespaces containing @ would directly cause Bun to throw an error, so this change will not make the situation worse.

Expand Down
126 changes: 126 additions & 0 deletions test/unit-tests/bun/namespace.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, Bun.OnLoadCallback>
}

function createMockBuild(): MockBuild {
let resolveCallback: Bun.OnResolveCallback | undefined
const loadCallbacks = new Map<string, Bun.OnLoadCallback>()

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',
})
})
})
Loading