From cc573f1f43650b1b6538ff0f3547702ef715fabf Mon Sep 17 00:00:00 2001 From: 81reap Date: Thu, 21 May 2026 20:50:52 -0400 Subject: [PATCH 1/2] fix(bun): respect loader returned from load hook Use the loader returned by object load results before falling back to Bun's provided loader or guessed loader. --- src/bun/index.ts | 8 +++--- src/types.ts | 6 +++-- test/unit-tests/bun/nested.test.ts | 42 ++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/bun/index.ts b/src/bun/index.ts index d0b98443..04291b04 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -1,5 +1,5 @@ import type { BunPlugin, Loader } from 'bun' -import type { TransformResult, UnpluginContextMeta, UnpluginFactory, UnpluginInstance } from '../types' +import type { LoadResult, TransformResult, UnpluginContextMeta, UnpluginFactory, UnpluginInstance } from '../types' import { isAbsolute } from 'node:path' import { version as unpluginVersion } from '../../package.json' import { normalizeObjectHook } from '../utils/filter' @@ -131,6 +131,7 @@ export function getBunPlugin>( ): Promise<{ contents: string, loader: Loader } | undefined> { let code: string | undefined let hasResult = false + let loaderOverride: Loader | undefined const namespaceLoadHooks = namespace === 'file' ? loadHooks @@ -143,7 +144,7 @@ export function getBunPlugin>( continue const { mixedContext, errors, warnings } = createPluginContext(context) - const result = await handler.call(mixedContext, id) + const result: LoadResult = await handler.call(mixedContext, id) for (const warning of warnings) { console.warn('[unplugin]', typeof warning === 'string' ? warning : warning.message) @@ -160,6 +161,7 @@ export function getBunPlugin>( } else if (typeof result === 'object' && result !== null) { code = result.code + loaderOverride = result.loader hasResult = true break } @@ -205,7 +207,7 @@ export function getBunPlugin>( if (hasResult && code !== undefined) { return { contents: code, - loader: loader ?? guessLoader(id), + loader: loaderOverride ?? loader ?? guessLoader(id), } } } diff --git a/src/types.ts b/src/types.ts index 83ab4311..92d50d2e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,6 @@ import type { CompilationContext as FarmCompilationContext, JsPlugin as FarmPlugin } from '@farmfe/core' import type { Compilation as RspackCompilation, Compiler as RspackCompiler, LoaderContext as RspackLoaderContext, RspackPluginInstance } from '@rspack/core' -import type { BunPlugin, PluginBuilder as BunPluginBuilder } from 'bun' +import type { Loader as BunLoader, BunPlugin, PluginBuilder as BunPluginBuilder } from 'bun' import type { BuildOptions, Plugin as EsbuildPlugin, Loader, PluginBuild } from 'esbuild' import type { Plugin as RolldownPlugin } from 'rolldown' import type { EmittedAsset, PluginContextMeta as RollupContextMeta, Plugin as RollupPlugin, SourceMapInput } from 'rollup' @@ -47,6 +47,8 @@ export interface SourceMapCompact { export type TransformResult = string | { code: string, map?: SourceMapInput | SourceMapCompact | null | undefined } | null | undefined | void +export type LoadResult = string | { code: string, map?: SourceMapInput | SourceMapCompact | null | undefined, loader?: BunLoader | undefined } | null | undefined | void + export interface ExternalIdResult { id: string, external?: boolean | undefined } export type NativeBuildContext @@ -89,7 +91,7 @@ export interface HookFnMap { buildEnd: (this: UnpluginBuildContext) => Thenable transform: (this: UnpluginBuildContext & UnpluginContext, code: string, id: string) => Thenable - load: (this: UnpluginBuildContext & UnpluginContext, id: string) => Thenable + load: (this: UnpluginBuildContext & UnpluginContext, id: string) => Thenable resolveId: ( this: UnpluginBuildContext & UnpluginContext, id: string, diff --git a/test/unit-tests/bun/nested.test.ts b/test/unit-tests/bun/nested.test.ts index 7458a519..e3284f96 100644 --- a/test/unit-tests/bun/nested.test.ts +++ b/test/unit-tests/bun/nested.test.ts @@ -131,4 +131,46 @@ describe.skipIf(typeof Bun === 'undefined')('bun nested plugin support', () => { Bun.file = originalFile }) + + it('should respect loader returned from a load hook', async () => { + const unplugin = createUnplugin(() => ({ + name: 'jsx-virtual', + resolveId(id: string) { + return id === 'virtual:component' ? id : null + }, + load(id: string) { + if (id === 'virtual:component') { + return { code: 'export default () =>

hi

', loader: 'tsx' as const } + } + return null + }, + })) + + const bunPlugin = unplugin.bun() + const onLoadCallbacks: Array<{ namespace?: string, cb: Bun.OnLoadCallback }> = [] + const mockBuild = { + onResolve: vi.fn(), + onLoad: vi.fn((options, callback) => { + onLoadCallbacks.push({ namespace: options.namespace, cb: callback }) + }), + onStart: vi.fn(), + config: { outdir: './dist' }, + } as never as Bun.PluginBuilder + + await bunPlugin.setup(mockBuild) + + const virtualHandler = onLoadCallbacks.find(c => c.namespace !== 'file')?.cb + expect(virtualHandler).toBeDefined() + + const result = await virtualHandler!({ + path: 'virtual:component', + // Bun's own guess for an id without a recognized extension + loader: 'js', + } as Bun.OnLoadArgs) + + expect(result).toEqual({ + contents: 'export default () =>

hi

', + loader: 'tsx', + }) + }) }) From 766898403c5466c8288031335a913c685068e7a3 Mon Sep 17 00:00:00 2001 From: 81reap Date: Fri, 22 May 2026 23:32:44 -0400 Subject: [PATCH 2/2] feat(bun): support plugin.bun.loader for per-plugin loader resolution Add `loader` to the `bun` plugin option block, accepting either a `Loader` literal or `(code, id) => Loader` function, mirroring the existing `plugin.esbuild.loader` API. --- src/bun/index.ts | 11 ++- src/bun/utils.ts | 11 +++ src/types.ts | 5 +- test/unit-tests/bun/nested.test.ts | 124 +++++++++++++++++++++++++++++ 4 files changed, 148 insertions(+), 3 deletions(-) diff --git a/src/bun/index.ts b/src/bun/index.ts index ce4b920e..bea148e0 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -4,7 +4,7 @@ import { isAbsolute } from 'node:path' import { version as unpluginVersion } from '../../package.json' import { normalizeObjectHook } from '../utils/filter' import { toArray } from '../utils/general' -import { createBuildContext, createPluginContext, guessLoader } from './utils' +import { createBuildContext, createPluginContext, guessLoader, unwrapLoader } 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 @@ -142,6 +142,7 @@ export function getBunPlugin>( let code: string | undefined let hasResult = false let loaderOverride: Loader | undefined + let activePlugin: typeof plugins[number] | undefined const namespaceLoadHooks = namespace === 'file' ? loadHooks @@ -167,12 +168,14 @@ export function getBunPlugin>( if (typeof result === 'string') { code = result hasResult = true + activePlugin = plugin break } else if (typeof result === 'object' && result !== null) { code = result.code loaderOverride = result.loader hasResult = true + activePlugin = plugin break } } @@ -215,9 +218,13 @@ export function getBunPlugin>( } if (hasResult && code !== undefined) { + const pluginLoader = activePlugin?.bun?.loader return { contents: code, - loader: loaderOverride ?? loader ?? guessLoader(id), + loader: loaderOverride + ?? (pluginLoader && unwrapLoader(pluginLoader, code, id)) + ?? loader + ?? guessLoader(id), } } } diff --git a/src/bun/utils.ts b/src/bun/utils.ts index 7bf2f7df..88541c51 100644 --- a/src/bun/utils.ts +++ b/src/bun/utils.ts @@ -26,6 +26,17 @@ export function guessLoader(id: string): Loader { return ExtToLoader[path.extname(id).toLowerCase()] || 'js' } +export function unwrapLoader( + loader: Loader | ((code: string, id: string) => Loader), + code: string, + id: string, +): Loader { + if (typeof loader === 'function') + return loader(code, id) + + return loader +} + export function createBuildContext(build: PluginBuilder): UnpluginBuildContext { const watchFiles: string[] = [] diff --git a/src/types.ts b/src/types.ts index 92d50d2e..676f95cf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -147,7 +147,10 @@ export interface UnpluginOptions { config?: ((options: BuildOptions) => void) | undefined } | undefined farm?: Partial | undefined - bun?: Partial | undefined + bun?: { + loader?: BunLoader | ((code: string, id: string) => BunLoader) | undefined + setup?: ((build: BunPluginBuilder) => void | Promise) | undefined + } | undefined } export interface ResolvedUnpluginOptions extends UnpluginOptions { diff --git a/test/unit-tests/bun/nested.test.ts b/test/unit-tests/bun/nested.test.ts index 6e457820..7665cad6 100644 --- a/test/unit-tests/bun/nested.test.ts +++ b/test/unit-tests/bun/nested.test.ts @@ -174,6 +174,130 @@ describe.skipIf(typeof Bun === 'undefined')('bun nested plugin support', () => { }) }) + it('should respect a static plugin.bun.loader', async () => { + const unplugin = createUnplugin(() => ({ + name: 'tsx-loader', + resolveId(id: string) { + return id === 'virtual:component' ? id : null + }, + load(id: string) { + if (id === 'virtual:component') { + return 'export default () =>

hi

' + } + return null + }, + bun: { loader: 'tsx' as const }, + })) + + const bunPlugin = unplugin.bun() + const onLoadCallbacks: Array<{ namespace?: string, cb: Bun.OnLoadCallback }> = [] + const mockBuild = { + onResolve: vi.fn(), + onLoad: vi.fn((options, callback) => { + onLoadCallbacks.push({ namespace: options.namespace, cb: callback }) + }), + onStart: vi.fn(), + config: { outdir: './dist' }, + } as never as Bun.PluginBuilder + + await bunPlugin.setup(mockBuild) + + const virtualHandler = onLoadCallbacks.find(c => c.namespace !== 'file')?.cb + expect(virtualHandler).toBeDefined() + + const result = await virtualHandler!({ + path: 'virtual:component', + loader: 'js', + } as Bun.OnLoadArgs) + + expect(result).toEqual({ + contents: 'export default () =>

hi

', + loader: 'tsx', + }) + }) + + it('should call plugin.bun.loader as a function with code and id', async () => { + const loaderFn = vi.fn((_code: string, _id: string) => 'tsx' as const) + const unplugin = createUnplugin(() => ({ + name: 'tsx-loader-fn', + resolveId(id: string) { + return id === 'virtual:component' ? id : null + }, + load(id: string) { + if (id === 'virtual:component') { + return 'export default () =>

hi

' + } + return null + }, + bun: { loader: loaderFn }, + })) + + const bunPlugin = unplugin.bun() + const onLoadCallbacks: Array<{ namespace?: string, cb: Bun.OnLoadCallback }> = [] + const mockBuild = { + onResolve: vi.fn(), + onLoad: vi.fn((options, callback) => { + onLoadCallbacks.push({ namespace: options.namespace, cb: callback }) + }), + onStart: vi.fn(), + config: { outdir: './dist' }, + } as never as Bun.PluginBuilder + + await bunPlugin.setup(mockBuild) + + const virtualHandler = onLoadCallbacks.find(c => c.namespace !== 'file')?.cb + const result = await virtualHandler!({ + path: 'virtual:component', + loader: 'js', + } as Bun.OnLoadArgs) + + expect(loaderFn).toHaveBeenCalledWith('export default () =>

hi

', 'virtual:component') + expect(result).toEqual({ + contents: 'export default () =>

hi

', + loader: 'tsx', + }) + }) + + it('should prefer load-hook loader over plugin.bun.loader', async () => { + const unplugin = createUnplugin(() => ({ + name: 'loader-priority', + resolveId(id: string) { + return id === 'virtual:component' ? id : null + }, + load(id: string) { + if (id === 'virtual:component') { + return { code: 'export default () =>

hi

', loader: 'tsx' as const } + } + return null + }, + bun: { loader: 'js' as const }, + })) + + const bunPlugin = unplugin.bun() + const onLoadCallbacks: Array<{ namespace?: string, cb: Bun.OnLoadCallback }> = [] + const mockBuild = { + onResolve: vi.fn(), + onLoad: vi.fn((options, callback) => { + onLoadCallbacks.push({ namespace: options.namespace, cb: callback }) + }), + onStart: vi.fn(), + config: { outdir: './dist' }, + } as never as Bun.PluginBuilder + + await bunPlugin.setup(mockBuild) + + const virtualHandler = onLoadCallbacks.find(c => c.namespace !== 'file')?.cb + const result = await virtualHandler!({ + path: 'virtual:component', + loader: 'js', + } as Bun.OnLoadArgs) + + expect(result).toEqual({ + contents: 'export default () =>

hi

', + loader: 'tsx', + }) + }) + it('should call plugin.bun.setup with the build before standard hooks', async () => { const callOrder: string[] = [] const bunSetup = vi.fn((_build: Bun.PluginBuilder) => {