Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add writeBundle hook #179

Merged
merged 9 commits into from Oct 22, 2022
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Expand Up @@ -27,6 +27,7 @@ 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) | ✅ | ✅ | ✅ | ✅ | ✅ |
Copy link
Member

Choose a reason for hiding this comment

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

I think it would be better to have a note saying this hook does not pass any arguments.

Copy link
Contributor Author

@lforst lforst Oct 22, 2022

Choose a reason for hiding this comment

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

Good point! I added a note in d1f9fd0.

Btw feel free to make any edits to this PR as you see fit!


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.
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?: () => 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)
})
})