Skip to content
Open
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
17 changes: 13 additions & 4 deletions src/bun/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
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'
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
Expand Down Expand Up @@ -141,6 +141,8 @@ export function getBunPlugin<UserOptions = Record<string, never>>(
): Promise<{ contents: string, loader: Loader } | undefined> {
let code: string | undefined
let hasResult = false
let loaderOverride: Loader | undefined
let activePlugin: typeof plugins[number] | undefined

const namespaceLoadHooks = namespace === 'file'
? loadHooks
Expand All @@ -153,7 +155,7 @@ export function getBunPlugin<UserOptions = Record<string, never>>(
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)
Expand All @@ -166,11 +168,14 @@ export function getBunPlugin<UserOptions = Record<string, never>>(
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
}
}
Expand Down Expand Up @@ -213,9 +218,13 @@ export function getBunPlugin<UserOptions = Record<string, never>>(
}

if (hasResult && code !== undefined) {
const pluginLoader = activePlugin?.bun?.loader
return {
contents: code,
loader: loader ?? guessLoader(id),
loader: loaderOverride
?? (pluginLoader && unwrapLoader(pluginLoader, code, id))
?? loader
?? guessLoader(id),
}
}
}
Expand Down
11 changes: 11 additions & 0 deletions src/bun/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = []

Expand Down
11 changes: 8 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -89,7 +91,7 @@ export interface HookFnMap {
buildEnd: (this: UnpluginBuildContext) => Thenable<void>

transform: (this: UnpluginBuildContext & UnpluginContext, code: string, id: string) => Thenable<TransformResult>
load: (this: UnpluginBuildContext & UnpluginContext, id: string) => Thenable<TransformResult>
load: (this: UnpluginBuildContext & UnpluginContext, id: string) => Thenable<LoadResult>
resolveId: (
this: UnpluginBuildContext & UnpluginContext,
id: string,
Expand Down Expand Up @@ -145,7 +147,10 @@ export interface UnpluginOptions {
config?: ((options: BuildOptions) => void) | undefined
} | undefined
farm?: Partial<FarmPlugin> | undefined
bun?: Partial<BunPlugin> | undefined
bun?: {
loader?: BunLoader | ((code: string, id: string) => BunLoader) | undefined
setup?: ((build: BunPluginBuilder) => void | Promise<void>) | undefined
} | undefined
}

export interface ResolvedUnpluginOptions extends UnpluginOptions {
Expand Down
166 changes: 166 additions & 0 deletions test/unit-tests/bun/nested.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,172 @@ 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 () => <h1>hi</h1>', 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 () => <h1>hi</h1>',
loader: 'tsx',
})
})

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 () => <h1>hi</h1>'
}
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 () => <h1>hi</h1>',
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 () => <h1>hi</h1>'
}
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 () => <h1>hi</h1>', 'virtual:component')
expect(result).toEqual({
contents: 'export default () => <h1>hi</h1>',
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 () => <h1>hi</h1>', 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 () => <h1>hi</h1>',
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) => {
Expand Down
Loading