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: bundle closing #3883

Merged
merged 21 commits into from Dec 14, 2020
Merged
Show file tree
Hide file tree
Changes from 20 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 cli/run/build.ts
Expand Up @@ -62,6 +62,7 @@ export default async function build(
}

await Promise.all(outputOptions.map(bundle.write));
await bundle.close();
if (!silent) {
warnings.flush();
stderr(green(`created ${bold(files.join(', '))} in ${bold(ms(Date.now() - start))}`));
Expand Down
1 change: 1 addition & 0 deletions cli/run/watch-cli.ts
Expand Up @@ -129,6 +129,7 @@ export async function watch(command: any) {
if (event.result && event.result.getTimings) {
printTimings(event.result.getTimings());
}
event.result.close().catch(error => handleError(error, true));
break;

case 'END':
Expand Down
23 changes: 22 additions & 1 deletion docs/02-javascript-api.md
Expand Up @@ -10,6 +10,8 @@ The `rollup.rollup` function receives an input options object as parameter and r

On a `bundle` object, you can call `bundle.generate` multiple times with different output options objects to generate different bundles in-memory. If you directly want to write them to disk, use `bundle.write` instead.

Once you're finished with the `bundle` object, you should call `bundle.close()`, which will let plugins clean up their external processes or services via the [`closeBundle`](guide/en/#closebundle) hook.

```javascript
const rollup = require('rollup');

Expand Down Expand Up @@ -69,6 +71,9 @@ async function build() {

// or write the bundle to disk
await bundle.write(outputOptions);

// closes the bundle
await bundle.close();
}

build();
Expand Down Expand Up @@ -160,7 +165,7 @@ const outputOptions = {

### rollup.watch

Rollup also provides a `rollup.watch` function that rebuilds your bundle when it detects that the individual modules have changed on disk. It is used internally when you run Rollup from the command line with the `--watch` flag.
Rollup also provides a `rollup.watch` function that rebuilds your bundle when it detects that the individual modules have changed on disk. It is used internally when you run Rollup from the command line with the `--watch` flag. Note that when using watch mode via the JavaScript API, it is your responsibility to call `event.result.close()` in response to the `BUNDLE_END` event to allow plugins to clean up resources in the [`closeBundle`](guide/en/#closebundle) hook, see below.

```js
const rollup = require('rollup');
Expand All @@ -172,9 +177,25 @@ watcher.on('event', event => {
// event.code can be one of:
// START — the watcher is (re)starting
// BUNDLE_START — building an individual bundle
// * event.input will be the input options object if present
// * event.outputFiles cantains an array of the "file" or
// "dir" option values of the generated outputs
// BUNDLE_END — finished building a bundle
// * event.input will be the input options object if present
// * event.outputFiles cantains an array of the "file" or
// "dir" option values of the generated outputs
// * event.duration is the build duration in milliseconds
// * event.result contains the bundle object that can be
// used to generate additional outputs by calling
// bundle.generate or bundle.write. This is especially
// important when the watch.skipWrite option is used.
// You should call "event.result.close()" once you are done
// generating outputs, or if you do not generate outputs.
// This will allow plugins to clean up resources via the
// "closeBundle" hook.
// END — finished building all bundles
// ERROR — encountered an error while bundling
// * event.error contains the error that was thrown
});

// stop watching
Expand Down
17 changes: 14 additions & 3 deletions docs/05-plugin-development.md
Expand Up @@ -72,9 +72,9 @@ To interact with the build process, your plugin object includes 'hooks'. Hooks a
* `sequential`: If several plugins implement this hook, all of them will be run in the specified plugin order. If a hook is async, subsequent hooks of this kind will wait until the current hook is resolved.
* `parallel`: If several plugins implement this hook, all of them will be run in the specified plugin order. If a hook is async, subsequent hooks of this kind will be run in parallel and not wait for the current hook.

Build hooks are run during the build phase, which is triggered by `rollup.rollup(inputOptions)`. They are mainly concerned with locating, providing and transforming input files before they are processed by Rollup. The first hook of the build phase is [options](guide/en/#options), the last one is always [buildEnd](guide/en/#buildend).
Build hooks are run during the build phase, which is triggered by `rollup.rollup(inputOptions)`. They are mainly concerned with locating, providing and transforming input files before they are processed by Rollup. The first hook of the build phase is [`options`](guide/en/#options), the last one is always [`buildEnd`](guide/en/#buildend), unless there is a build error in which case [`closeBundle`](guide/en/#closebundle) will be called after that.

Additionally, in watch mode the [watchChange](guide/en/#watchchange) hook can be triggered at any time to notify a new run will be triggered once the current run has generated its outputs. Also, when watcher closes, the [closeWatcher](guide/en/#closewatcher) hook will be triggered.
Additionally, in watch mode the [`watchChange`](guide/en/#watchchange) hook can be triggered at any time to notify a new run will be triggered once the current run has generated its outputs. Also, when watcher closes, the [`closeWatcher`](guide/en/#closewatcher) hook will be triggered.

See [Output Generation Hooks](guide/en/#output-generation-hooks) for hooks that run during the output generation phase to modify the generated output.

Expand Down Expand Up @@ -255,7 +255,9 @@ Notifies a plugin whenever rollup has detected a change to a monitored file in `

Output generation hooks can provide information about a generated bundle and modify a build once complete. They work the same way and have the same types as [Build Hooks](guide/en/#build-hooks) but are called separately for each call to `bundle.generate(outputOptions)` or `bundle.write(outputOptions)`. Plugins that only use output generation hooks can also be passed in via the output options and therefore run only for certain outputs.

The first hook of the output generation phase is [outputOptions](guide/en/#outputoptions), the last one is either [generateBundle](guide/en/#generatebundle) if the output was successfully generated via `bundle.generate(...)`, [writeBundle](guide/en/#writebundle) if the output was successfully generated via `bundle.write(...)`, or [renderError](guide/en/#rendererror) if an error occurred at any time during the output generation.
The first hook of the output generation phase is [`outputOptions`](guide/en/#outputoptions), the last one is either [`generateBundle`](guide/en/#generatebundle) if the output was successfully generated via `bundle.generate(...)`, [`writeBundle`](guide/en/#writebundle) if the output was successfully generated via `bundle.write(...)`, or [`renderError`](guide/en/#rendererror) if an error occurred at any time during the output generation.

Additionally, [`closeBundle`](guide/en/#closebundle) can be called as the very last hook, but it is the responsibility of the User to manually call [`bundle.close()`](guide/en/#rolluprollup) to trigger this. The CLI will always make sure this is the case.

#### `augmentChunkHash`
Type: `(chunkInfo: ChunkInfo) => string`<br>
Expand Down Expand Up @@ -284,6 +286,15 @@ Next Hook: [`renderDynamicImport`](guide/en/#renderdynamicimport) for each dynam

Cf. [`output.banner/output.footer`](guide/en/#outputbanneroutputfooter).

#### `closeBundle`
Type: `closeBundle: () => Promise<void> | void`<br>
Kind: `async, parallel`<br>
Previous Hook: [`buildEnd`](guide/en/#buildend) if there was a build error, otherwise when [`bundle.close()`](guide/en/#rolluprollup) is called, in which case this would be the last hook to be triggered.

Can be used to clean up any external service that may be running. Rollup's CLI will make sure this hook is called after each run, but it is the responsibility of users of the JavaScript API to manually call `bundle.close()` once they are done generating bundles. For that reason, any plugin relying on this feature should carefully mention this in its documentation.

If a plugin wants to retain resources across builds in watch mode, they can check for [`this.meta.watchMode`](guide/en/#thismeta-rollupversion-string-watchmode-boolean) in this hook and perform the necessary cleanup for watch mode in [`closeWatcher`](guide/en/#closewatcher).

#### `footer`
Type: `string | (() => string)`<br>
Kind: `async, parallel`<br>
Expand Down
15 changes: 14 additions & 1 deletion src/rollup/rollup.ts
Expand Up @@ -2,7 +2,7 @@ import { version as rollupVersion } from 'package.json';
import Bundle from '../Bundle';
import Graph from '../Graph';
import { ensureArray } from '../utils/ensureArray';
import { errCannotEmitFromOptionsHook, error } from '../utils/error';
import { errAlreadyClosed, errCannotEmitFromOptionsHook, error } from '../utils/error';
import { writeFile } from '../utils/fs';
import { normalizeInputOptions } from '../utils/options/normalizeInputOptions';
import { normalizeOutputOptions } from '../utils/options/normalizeOutputOptions';
Expand Down Expand Up @@ -56,6 +56,7 @@ export async function rollupInternal(
err.watchFiles = watchFiles;
}
await graph.pluginDriver.hookParallel('buildEnd', [err]);
await graph.pluginDriver.hookParallel('closeBundle', []);
throw err;
}

Expand All @@ -65,7 +66,17 @@ export async function rollupInternal(

const result: RollupBuild = {
cache: useCache ? graph.getCache() : undefined,
closed: false,
async close() {
if (result.closed) return;

result.closed = true;

await graph.pluginDriver.hookParallel('closeBundle', []);
},
async generate(rawOutputOptions: OutputOptions) {
if (result.closed) return error(errAlreadyClosed());

return handleGenerateWrite(
false,
inputOptions,
Expand All @@ -76,6 +87,8 @@ export async function rollupInternal(
},
watchFiles: Object.keys(graph.watchFiles),
async write(rawOutputOptions: OutputOptions) {
if (result.closed) return error(errAlreadyClosed());

return handleGenerateWrite(
true,
inputOptions,
Expand Down
9 changes: 7 additions & 2 deletions src/rollup/types.d.ts
Expand Up @@ -354,6 +354,7 @@ export interface OutputBundleWithPlaceholders {
export interface PluginHooks extends OutputPluginHooks {
buildEnd: (this: PluginContext, err?: Error) => Promise<void> | void;
buildStart: (this: PluginContext, options: NormalizedInputOptions) => Promise<void> | void;
closeBundle: (this: PluginContext) => Promise<void> | void;
closeWatcher: (this: PluginContext) => void;
load: LoadHook;
moduleParsed: ModuleParsedHook;
Expand Down Expand Up @@ -416,7 +417,8 @@ export type AsyncPluginHooks =
| 'resolveDynamicImport'
| 'resolveId'
| 'transform'
| 'writeBundle';
| 'writeBundle'
| 'closeBundle';

export type PluginValueHooks = 'banner' | 'footer' | 'intro' | 'outro';

Expand Down Expand Up @@ -451,7 +453,8 @@ export type ParallelPluginHooks =
| 'outro'
| 'renderError'
| 'renderStart'
| 'writeBundle';
| 'writeBundle'
| 'closeBundle';

interface OutputPluginValueHooks {
banner: AddonHook;
Expand Down Expand Up @@ -769,6 +772,8 @@ export interface RollupOutput {

export interface RollupBuild {
cache: RollupCache | undefined;
close: () => Promise<void>;
closed: boolean;
generate: (outputOptions: OutputOptions) => Promise<RollupOutput>;
getTimings?: () => SerializedTimings;
watchFiles: string[];
Expand Down
1 change: 1 addition & 0 deletions src/utils/PluginDriver.ts
Expand Up @@ -47,6 +47,7 @@ const inputHookNames: {
} = {
buildEnd: 1,
buildStart: 1,
closeBundle: 1,
closeWatcher: 1,
load: 1,
moduleParsed: 1,
Expand Down
16 changes: 12 additions & 4 deletions src/utils/error.ts
Expand Up @@ -37,6 +37,7 @@ export function augmentCodeLocation(
}

export enum Errors {
ALREADY_CLOSED = 'ALREADY_CLOSED',
ASSET_NOT_FINALISED = 'ASSET_NOT_FINALISED',
ASSET_NOT_FOUND = 'ASSET_NOT_FOUND',
ASSET_SOURCE_ALREADY_SET = 'ASSET_SOURCE_ALREADY_SET',
Expand All @@ -45,8 +46,9 @@ export enum Errors {
CANNOT_EMIT_FROM_OPTIONS_HOOK = 'CANNOT_EMIT_FROM_OPTIONS_HOOK',
CHUNK_NOT_GENERATED = 'CHUNK_NOT_GENERATED',
DEPRECATED_FEATURE = 'DEPRECATED_FEATURE',
FILE_NOT_FOUND = 'FILE_NOT_FOUND',
EXTERNAL_SYNTHETIC_EXPORTS = 'EXTERNAL_SYNTHETIC_EXPORTS',
FILE_NAME_CONFLICT = 'FILE_NAME_CONFLICT',
FILE_NOT_FOUND = 'FILE_NOT_FOUND',
INPUT_HOOK_IN_OUTPUT_PLUGIN = 'INPUT_HOOK_IN_OUTPUT_PLUGIN',
INVALID_CHUNK = 'INVALID_CHUNK',
INVALID_EXPORT_OPTION = 'INVALID_EXPORT_OPTION',
Expand All @@ -60,12 +62,11 @@ export enum Errors {
NO_TRANSFORM_MAP_OR_AST_WITHOUT_CODE = 'NO_TRANSFORM_MAP_OR_AST_WITHOUT_CODE',
PLUGIN_ERROR = 'PLUGIN_ERROR',
PREFER_NAMED_EXPORTS = 'PREFER_NAMED_EXPORTS',
SYNTHETIC_NAMED_EXPORTS_NEED_NAMESPACE_EXPORT = 'SYNTHETIC_NAMED_EXPORTS_NEED_NAMESPACE_EXPORT',
UNEXPECTED_NAMED_IMPORT = 'UNEXPECTED_NAMED_IMPORT',
UNRESOLVED_ENTRY = 'UNRESOLVED_ENTRY',
UNRESOLVED_IMPORT = 'UNRESOLVED_IMPORT',
VALIDATION_ERROR = 'VALIDATION_ERROR',
EXTERNAL_SYNTHETIC_EXPORTS = 'EXTERNAL_SYNTHETIC_EXPORTS',
SYNTHETIC_NAMED_EXPORTS_NEED_NAMESPACE_EXPORT = 'SYNTHETIC_NAMED_EXPORTS_NEED_NAMESPACE_EXPORT'
VALIDATION_ERROR = 'VALIDATION_ERROR'
}

export function errAssetNotFinalisedForFileName(name: string) {
Expand Down Expand Up @@ -377,6 +378,13 @@ export function errFailedValidation(message: string) {
};
}

export function errAlreadyClosed() {
return {
code: Errors.ALREADY_CLOSED,
message: 'Bundle is already closed, no more calls to "generate" or "write" are allowed.'
};
}

export function warnDeprecation(
deprecation: string | RollupWarning,
activeDeprecation: boolean,
Expand Down
9 changes: 9 additions & 0 deletions test/cli/samples/watch/close-error/_config.js
@@ -0,0 +1,9 @@
module.exports = {
description: 'displays errors when closing the watcher',
command: 'rollup -cw',
abortOnStderr(data) {
if (data.includes('[!] (plugin faulty-close) Error: Close bundle failed')) {
return true;
}
}
};
3 changes: 3 additions & 0 deletions test/cli/samples/watch/close-error/_expected/main.js
@@ -0,0 +1,3 @@
var main = 42;

export default main;
1 change: 1 addition & 0 deletions test/cli/samples/watch/close-error/main.js
@@ -0,0 +1 @@
export default 42;
15 changes: 15 additions & 0 deletions test/cli/samples/watch/close-error/rollup.config.js
@@ -0,0 +1,15 @@
export default {
input: 'main.js',
plugins: [
{
name: 'faulty-close',
closeBundle() {
throw new Error('Close bundle failed');
}
}
],
output: {
dir: "_actual",
format: "es"
}
};
66 changes: 66 additions & 0 deletions test/hooks/index.js
Expand Up @@ -1034,6 +1034,72 @@ describe('hooks', () => {
})
.then(bundle => bundle.generate({ format: 'es' })));

it('supports closeBundle hook', () => {
let closeBundleCalls = 0;
return rollup
.rollup({
input: 'input',
plugins: [
loader({ input: `alert('hello')` }),
{
closeBundle() {
closeBundleCalls++;
}
}
]
})
.then(bundle => bundle.close())
.then(() => {
assert.strictEqual(closeBundleCalls, 1);
});
});

it('calls closeBundle hook on build error', () => {
let closeBundleCalls = 0;
return rollup
.rollup({
input: 'input',
plugins: [
loader({ input: `alert('hello')` }),
{
buildStart() {
this.error('build start error');
},
closeBundle() {
closeBundleCalls++;
}
}
]
})
.catch(() => {
assert.strictEqual(closeBundleCalls, 1);
});
});

it('passes errors from closeBundle hook', () => {
let handledError = false;
return rollup
.rollup({
input: 'input',
plugins: [
loader({ input: `alert('hello')` }),
{
closeBundle() {
this.error('close bundle error');
}
}
]
})
.then(bundle => bundle.close())
.catch(error => {
assert.strictEqual(error.message, 'close bundle error');
handledError = true;
})
.then(() => {
assert.ok(handledError);
});
});

describe('deprecated', () => {
it('caches chunk emission in transform hook', () => {
let cache;
Expand Down
35 changes: 34 additions & 1 deletion test/misc/sanity-checks.js
@@ -1,6 +1,6 @@
const assert = require('assert');
const rollup = require('../../dist/rollup');
const { loader } = require('../utils.js');
const { loader, compareError } = require('../utils.js');

describe('sanity checks', () => {
it('exists', () => {
Expand Down Expand Up @@ -237,6 +237,39 @@ describe('sanity checks', () => {
);
});

it('prevents generating and writing from a closed bundle', async () => {
let error = null;
const bundle = await rollup.rollup({
input: 'x',
plugins: [loader({ x: 'console.log( "x" );' })]
});
bundle.close();
// Can be safely called multiple times
bundle.close();

try {
await bundle.generate({ file: 'x', format: 'es' });
} catch (generateError) {
error = generateError;
}
compareError(error, {
code: 'ALREADY_CLOSED',
message: 'Bundle is already closed, no more calls to "generate" or "write" are allowed.'
});
error = null;

try {
await bundle.write({ file: 'x', format: 'es' });
} catch (writeError) {
error = writeError;
}
compareError(error, {
code: 'ALREADY_CLOSED',
message: 'Bundle is already closed, no more calls to "generate" or "write" are allowed.'
});
error = null;
});

it('triggers a warning when using output.amd.id together with the "dir" option', async () => {
let warning = null;
const bundle = await rollup.rollup({
Expand Down