Skip to content

Commit

Permalink
feat: add writeBundle hook (#179)
Browse files Browse the repository at this point in the history
Co-authored-by: Anthony Fu <anthonyfu117@hotmail.com>
  • Loading branch information
lforst and antfu committed Oct 22, 2022
1 parent ab8fe74 commit 160ec72
Show file tree
Hide file tree
Showing 9 changed files with 178 additions and 7 deletions.
12 changes: 7 additions & 5 deletions README.md
Expand Up @@ -27,10 +27,12 @@ Currently supports:
| [`transform`](https://rollupjs.org/guide/en/#transformers) ||||| ✅ <sup>3</sup> |
| [`watchChange`](https://rollupjs.org/guide/en/#watchchange) ||||||
| [`buildEnd`](https://rollupjs.org/guide/en/#buildend) ||||||
| [`writeBundle`](https://rollupjs.org/guide/en/#writebundle)<sup>4</sup> ||||||

1. Rollup and esbuild do not support using `enforce` to control the order of plugins. Users need to maintain the order manually.
2. Webpack's id filter is outside of loader logic; an additional hook is needed for better perf on Webpack. In Rollup and Vite, this hook has been polyfilled to match the behaviors. See for following usage examples.
3. Although esbuild can handle both JavaScript and CSS and many other file formats, you can only return JavaScript in `load` and `transform` results.
4. Currently, `writeBundle` is only serves as a hook for the timing. It doesn't pass any arguments.

### Hook Context

Expand All @@ -40,13 +42,13 @@ Currently supports:
| ---- | :----: | :--: | :-------: | :-------: | :-----: |
| [`this.parse`](https://rollupjs.org/guide/en/#thisparse) ||||||
| [`this.addWatchFile`](https://rollupjs.org/guide/en/#thisaddwatchfile) ||||||
| [`this.emitFile`](https://rollupjs.org/guide/en/#thisemitfile)<sup>4</sup> ||||||
| [`this.emitFile`](https://rollupjs.org/guide/en/#thisemitfile)<sup>5</sup> ||||||
| [`this.getWatchFiles`](https://rollupjs.org/guide/en/#thisgetwatchfiles) ||||||
| [`this.warn`](https://rollupjs.org/guide/en/#thiswarn) ||||||
| [`this.error`](https://rollupjs.org/guide/en/#thiserror) ||||||


4. Currently, [`this.emitFile`](https://rollupjs.org/guide/en/#thisemitfile) only supports the `EmittedAsset` variant.
5. Currently, [`this.emitFile`](https://rollupjs.org/guide/en/#thisemitfile) only supports the `EmittedAsset` variant.

## Usage

Expand Down Expand Up @@ -83,10 +85,10 @@ Since `v0.10.0`, unplugin supports constructing multiple nested plugins to behav

| Rollup | Vite | Webpack 4 | Webpack 5 | esbuild |
| :----: | :--: | :-------: | :-------: | :-----: |
|`>=3.1`<sup>5</sup> |||| ⚠️<sup>6</sup> |
|`>=3.1`<sup>6</sup> |||| ⚠️<sup>7</sup> |

5. Rollup supports nested plugins since [v3.1.0](https://github.com/rollup/rollup/releases/tag/v3.1.0). Plugin aurthor should ask users to a have a Rollup version of `>=3.1.0` when using nested plugins. For singe plugin format, unplugin works for any versions of Rollup.
6. Since esbuild does not have a built-in transform phase, the `transform` hook of nested plugin will not work on esbuild yet. Other hooks like `load` or `resolveId` work fine. We will try to find a way to support it in the future.
6. Rollup supports nested plugins since [v3.1.0](https://github.com/rollup/rollup/releases/tag/v3.1.0). Plugin aurthor should ask users to a have a Rollup version of `>=3.1.0` when using nested plugins. For singe plugin format, unplugin works for any versions of Rollup.
7. Since esbuild does not have a built-in transform phase, the `transform` hook of nested plugin will not work on esbuild yet. Other hooks like `load` or `resolveId` work fine. We will try to find a way to support it in the future.

###### Usage

Expand Down
11 changes: 9 additions & 2 deletions src/esbuild/index.ts
Expand Up @@ -60,14 +60,21 @@ export function getEsbuildPlugin <UserOptions = {}> (
onStart(() => plugin.buildStart!.call(context))
}

if (plugin.buildEnd || initialOptions.watch) {
if (plugin.buildEnd || plugin.writeBundle || initialOptions.watch) {
const rebuild = () => build({
...initialOptions,
watch: false
})

onEnd(async () => {
await plugin.buildEnd!.call(context)
if (plugin.buildEnd) {
await plugin.buildEnd.call(context)
}

if (plugin.writeBundle) {
await plugin.writeBundle()
}

if (initialOptions.watch) {
Object.keys(watchListRecord).forEach((id) => {
if (!watchList.has(id)) {
Expand Down
4 changes: 4 additions & 0 deletions src/types.ts
Expand Up @@ -29,13 +29,17 @@ export interface UnpluginOptions {
name: string;
enforce?: 'post' | 'pre' | undefined;

// Build Hooks
buildStart?: (this: UnpluginBuildContext) => Promise<void> | void;
buildEnd?: (this: UnpluginBuildContext) => Promise<void> | void;
transform?: (this: UnpluginBuildContext & UnpluginContext, code: string, id: string) => Thenable<TransformResult>;
load?: (this: UnpluginBuildContext & UnpluginContext, id: string) => Thenable<TransformResult>
resolveId?: (id: string, importer: string | undefined, options: { isEntry: boolean }) => Thenable<string | ExternalIdResult | null | undefined>
watchChange?: (this: UnpluginBuildContext, id: string, change: { event: 'create' | 'update' | 'delete' }) => void

// Output Generation Hooks
writeBundle?: (this: void) => Promise<void> | void

/**
* Custom predicate function to filter modules to be loaded.
* When omitted, all modules will be included (might have potential perf impact on Webpack).
Expand Down
6 changes: 6 additions & 0 deletions src/webpack/index.ts
Expand Up @@ -218,6 +218,12 @@ export function getWebpackPlugin<UserOptions = {}> (
await plugin.buildEnd!.call(createContext(compilation))
})
}

if (plugin.writeBundle) {
compiler.hooks.afterEmit.tap(plugin.name, () => {
plugin.writeBundle!()
})
}
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions test/unit-tests/utils.ts
Expand Up @@ -10,6 +10,8 @@ export const rollupBuild = rollup.rollup
export const esbuildBuild = esbuild.build
export const webpackBuild = (webpack.webpack || (webpack as any).default || webpack) as typeof webpack.webpack

export const webpackVersion = ((webpack as any).default || webpack).version

export const build = {
webpack: webpackBuild,
rollup: rollupBuild,
Expand Down
1 change: 1 addition & 0 deletions test/unit-tests/write-bundle/.gitignore
@@ -0,0 +1 @@
test-out
3 changes: 3 additions & 0 deletions test/unit-tests/write-bundle/test-src/entry.js
@@ -0,0 +1,3 @@
import someString, { someOtherString } from './import'

process.stdout.write(JSON.stringify({ someString, someOtherString }))
3 changes: 3 additions & 0 deletions test/unit-tests/write-bundle/test-src/import.js
@@ -0,0 +1,3 @@
export default 'some string'

export const someOtherString = 'some other string'
143 changes: 143 additions & 0 deletions test/unit-tests/write-bundle/write-bundle.test.ts
@@ -0,0 +1,143 @@
import * as path from 'path'
import * as fs from 'fs'
import { it, describe, expect, vi, afterEach, Mock, beforeAll } from 'vitest'
import { build, toArray, webpackVersion } from '../utils'
import { createUnplugin, UnpluginOptions, VitePlugin } from 'unplugin'

function createUnpluginWithCallback (writeBundleCallback: UnpluginOptions['writeBundle']) {
return createUnplugin(() => ({
name: 'test-plugin',
writeBundle: writeBundleCallback
}))
}

function generateMockWriteBundleHook (outputPath: string) {
return () => {
// We want to check that at the time the `writeBundle` hook is called, all
// build-artifacts have already been written to disk.

const bundleExists = fs.existsSync(path.join(outputPath, 'output.js'))
const sourceMapExists = fs.existsSync(path.join(outputPath, 'output.js.map'))

expect(bundleExists).toBe(true)
expect(sourceMapExists).toBe(true)

return undefined
}
}

// We extract this check because all bundlers should behave the same
function checkWriteBundleHook (writeBundleCallback: Mock): void {
expect(writeBundleCallback).toHaveBeenCalledOnce()
}

describe('writeBundle hook', () => {
beforeAll(() => {
fs.rmSync(path.resolve(__dirname, 'test-out'), { recursive: true, force: true })
})

afterEach(() => {
vi.restoreAllMocks()
})

it('vite', async () => {
expect.assertions(3)
const mockWriteBundleHook = vi.fn(generateMockWriteBundleHook(path.resolve(__dirname, 'test-out/vite')))
const plugin = createUnpluginWithCallback(mockWriteBundleHook).vite
// we need to define `enforce` here for the plugin to be run
const plugins = toArray(plugin()).map((plugin): VitePlugin => ({ ...plugin, enforce: 'pre' }))

await build.vite({
clearScreen: false,
plugins: [plugins],
build: {
lib: {
entry: path.resolve(__dirname, 'test-src/entry.js'),
name: 'TestLib',
fileName: 'output',
formats: ['cjs']
},
outDir: path.resolve(__dirname, 'test-out/vite'),
sourcemap: true
}
})

checkWriteBundleHook(mockWriteBundleHook)
})

it('rollup', async () => {
expect.assertions(3)
const mockResolveIdHook = vi.fn(generateMockWriteBundleHook(path.resolve(__dirname, 'test-out/rollup')))
const plugin = createUnpluginWithCallback(mockResolveIdHook).rollup

const rollupBuild = await build.rollup({
input: path.resolve(__dirname, 'test-src/entry.js')
})

await rollupBuild.write({
plugins: [plugin()],
file: path.resolve(__dirname, 'test-out/rollup/output.js'),
format: 'cjs',
exports: 'named',
sourcemap: true
})

checkWriteBundleHook(mockResolveIdHook)
})

it('webpack', async () => {
expect.assertions(3)
const mockResolveIdHook = vi.fn(generateMockWriteBundleHook(path.resolve(__dirname, 'test-out/webpack')))
const plugin = createUnpluginWithCallback(mockResolveIdHook).webpack

const webpack4Options = {
entry: path.resolve(__dirname, 'test-src/entry.js'),
cache: false,
output: {
path: path.resolve(__dirname, 'test-out/webpack'),
filename: 'output.js',
libraryTarget: 'commonjs'
},
plugins: [plugin()],
devtool: 'source-map'
}

const webpack5Options = {
entry: path.resolve(__dirname, 'test-src/entry.js'),
plugins: [plugin()],
devtool: 'source-map',
output: {
path: path.resolve(__dirname, 'test-out/webpack'),
filename: 'output.js',
library: {
type: 'commonjs'
}
}
}

await new Promise((resolve) => {
build.webpack(webpackVersion!.startsWith('4') ? webpack4Options : webpack5Options,
resolve
)
})

checkWriteBundleHook(mockResolveIdHook)
})

it('esbuild', async () => {
expect.assertions(3)
const mockResolveIdHook = vi.fn(generateMockWriteBundleHook(path.resolve(__dirname, 'test-out/esbuild')))
const plugin = createUnpluginWithCallback(mockResolveIdHook).esbuild

await build.esbuild({
entryPoints: [path.resolve(__dirname, 'test-src/entry.js')],
plugins: [plugin()],
bundle: true, // actually traverse imports
outfile: path.resolve(__dirname, 'test-out/esbuild/output.js'),
format: 'cjs',
sourcemap: true
})

checkWriteBundleHook(mockResolveIdHook)
})
})

0 comments on commit 160ec72

Please sign in to comment.