Skip to content

Commit

Permalink
feat(terser): parallel execution (#1341)
Browse files Browse the repository at this point in the history
* feat(terser): parallel execution

* fix(terser): exit process on promise rejection of worker

* feat(terser): add woker-pool support

* docs(terser): updated docs

* fix(terser): worker count decrementation

* style(terser): removed unnecessary Promise.resolve wrapping

* docs(terser): enhanced options section

* docs(terser): fix typo
  • Loading branch information
tada5hi committed Dec 5, 2022
1 parent ca7a668 commit 140e06b
Show file tree
Hide file tree
Showing 9 changed files with 334 additions and 33 deletions.
37 changes: 29 additions & 8 deletions packages/terser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@

# @rollup/plugin-terser

🍣 A Rollup plugin to generate a minified output bundle.
🍣 A Rollup plugin to generate a minified bundle with terser.

## Requirements

This plugin requires an [LTS](https://github.com/nodejs/Release) Node version (v14.0.0+) and Rollup v1.20.0+.
This plugin requires an [LTS](https://github.com/nodejs/Release) Node version (v14.0.0+) and Rollup v2.0+.

## Install

Expand All @@ -27,7 +27,7 @@ npm install @rollup/plugin-terser --save-dev

Create a `rollup.config.js` [configuration file](https://www.rollupjs.org/guide/en/#configuration-files) and import the plugin:

```js
```typescript
import terser from '@rollup/plugin-terser';

export default {
Expand All @@ -47,13 +47,34 @@ Then call `rollup` either via the [CLI](https://www.rollupjs.org/guide/en/#comma
The plugin accepts a terser [Options](https://github.com/terser/terser#minify-options) object as input parameter,
to modify the default behaviour.

In addition to the `terser` options, it is also possible to provide the following options:

### `maxWorkers`

Type: `Number`<br>
Default: `undefined`

Instructs the plugin to use a specific amount of cpu threads.

```typescript
import terser from '@rollup/plugin-terser';

export default {
input: 'src/index.js',
output: {
dir: 'output',
format: 'cjs'
},
plugins: [
terser({
maxWorkers: 4
})
]
};
```

## Meta

[CONTRIBUTING](/.github/CONTRIBUTING.md)

[LICENSE (MIT)](/LICENSE)

## Credits

This package was originally developed by [https://github.com/TrySound](TrySound) but is not
maintained anymore.
3 changes: 3 additions & 0 deletions packages/terser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,12 @@
}
},
"dependencies": {
"serialize-javascript": "^6.0.0",
"smob": "^0.0.6",
"terser": "^5.15.1"
},
"devDependencies": {
"@types/serialize-javascript": "^5.0.2",
"rollup": "^3.0.0-7",
"typescript": "^4.8.3"
},
Expand Down
27 changes: 5 additions & 22 deletions packages/terser/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,8 @@
import type { NormalizedOutputOptions, RenderedChunk } from 'rollup';
import type { MinifyOptions } from 'terser';
import { minify } from 'terser';
import { runWorker } from './worker';
import terser from './module';

export default function terser(options?: MinifyOptions) {
return {
name: 'terser',
runWorker();

async renderChunk(code: string, chunk: RenderedChunk, outputOptions: NormalizedOutputOptions) {
const defaultOptions: MinifyOptions = {
sourceMap: outputOptions.sourcemap === true || typeof outputOptions.sourcemap === 'string'
};
export * from './type';

if (outputOptions.format === 'es') {
defaultOptions.module = true;
}

if (outputOptions.format === 'cjs') {
defaultOptions.toplevel = true;
}

return minify(code, { ...defaultOptions, ...(options || {}) });
}
};
}
export default terser;
72 changes: 72 additions & 0 deletions packages/terser/src/module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { NormalizedOutputOptions, RenderedChunk } from 'rollup';
import { hasOwnProperty, isObject, merge } from 'smob';

import type { Options } from './type';
import { WorkerPool } from './worker-pool';

export default function terser(options: Options = {}) {
const workerPool = new WorkerPool({
filePath: __filename,
maxWorkers: options.maxWorkers
});

return {
name: 'terser',

async renderChunk(code: string, chunk: RenderedChunk, outputOptions: NormalizedOutputOptions) {
const defaultOptions: Options = {
sourceMap: outputOptions.sourcemap === true || typeof outputOptions.sourcemap === 'string'
};

if (outputOptions.format === 'es') {
defaultOptions.module = true;
}

if (outputOptions.format === 'cjs') {
defaultOptions.toplevel = true;
}

try {
const { code: result, nameCache } = await workerPool.addAsync({
code,
options: merge({}, options || {}, defaultOptions)
});

if (options.nameCache && nameCache) {
let vars: Record<string, any> = {
props: {}
};

if (hasOwnProperty(options.nameCache, 'vars') && isObject(options.nameCache.vars)) {
vars = merge({}, options.nameCache.vars || {}, vars);
}

if (hasOwnProperty(nameCache, 'vars') && isObject(nameCache.vars)) {
vars = merge({}, nameCache.vars, vars);
}

// eslint-disable-next-line no-param-reassign
options.nameCache.vars = vars;

let props: Record<string, any> = {};

if (hasOwnProperty(options.nameCache, 'props') && isObject(options.nameCache.props)) {
// eslint-disable-next-line prefer-destructuring
props = options.nameCache.props;
}

if (hasOwnProperty(nameCache, 'props') && isObject(nameCache.props)) {
props = merge({}, nameCache.props, props);
}

// eslint-disable-next-line no-param-reassign
options.nameCache.props = props;
}

return result;
} catch (e) {
return Promise.reject(e);
}
}
};
}
33 changes: 33 additions & 0 deletions packages/terser/src/type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { MinifyOptions } from 'terser';

export interface Options extends MinifyOptions {
nameCache?: Record<string, any>;
maxWorkers?: number;
}

export interface WorkerContext {
code: string;
options: Options;
}

export type WorkerCallback = (err: Error | null, output?: WorkerOutput) => void;

export interface WorkerContextSerialized {
code: string;
options: string;
}

export interface WorkerOutput {
code: string;
nameCache?: Options['nameCache'];
}

export interface WorkerPoolOptions {
filePath: string;
maxWorkers?: number;
}

export interface WorkerPoolTask {
context: WorkerContext;
cb: WorkerCallback;
}
117 changes: 117 additions & 0 deletions packages/terser/src/worker-pool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { Worker } from 'worker_threads';
import { cpus } from 'os';
import { EventEmitter } from 'events';

import serializeJavascript from 'serialize-javascript';

import type {
WorkerCallback,
WorkerContext,
WorkerOutput,
WorkerPoolOptions,
WorkerPoolTask
} from './type';

const symbol = Symbol.for('FreeWoker');

export class WorkerPool extends EventEmitter {
protected maxInstances: number;

protected filePath: string;

protected tasks: WorkerPoolTask[] = [];

protected workers = 0;

constructor(options: WorkerPoolOptions) {
super();

this.maxInstances = options.maxWorkers || cpus().length;
this.filePath = options.filePath;

this.on(symbol, () => {
if (this.tasks.length > 0) {
this.run();
}
});
}

add(context: WorkerContext, cb: WorkerCallback) {
this.tasks.push({
context,
cb
});

if (this.workers >= this.maxInstances) {
return;
}

this.run();
}

async addAsync(context: WorkerContext): Promise<WorkerOutput> {
return new Promise((resolve, reject) => {
this.add(context, (err, output) => {
if (err) {
reject(err);
return;
}

if (!output) {
reject(new Error('The output is empty'));
return;
}

resolve(output);
});
});
}

private run() {
if (this.tasks.length === 0) {
return;
}

const task = this.tasks.shift();

if (typeof task === 'undefined') {
return;
}

this.workers += 1;

let called = false;
const callCallback = (err: Error | null, output?: WorkerOutput) => {
if (called) {
return;
}
called = true;

this.workers -= 1;

task.cb(err, output);
this.emit(symbol);
};

const worker = new Worker(this.filePath, {
workerData: {
code: task.context.code,
options: serializeJavascript(task.context.options)
}
});

worker.on('message', (data) => {
callCallback(null, data);
});

worker.on('error', (err) => {
callCallback(err);
});

worker.on('exit', (code) => {
if (code !== 0) {
callCallback(new Error(`Minify worker stopped with exit code ${code}`));
}
});
}
}
47 changes: 47 additions & 0 deletions packages/terser/src/worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import process from 'process';
import { isMainThread, parentPort, workerData } from 'worker_threads';

import { hasOwnProperty, isObject } from 'smob';

import { minify } from 'terser';

import type { WorkerContextSerialized, WorkerOutput } from './type';

/**
* Duck typing worker context.
*
* @param input
*/
function isWorkerContextSerialized(input: unknown): input is WorkerContextSerialized {
return (
isObject(input) &&
hasOwnProperty(input, 'code') &&
typeof input.code === 'string' &&
hasOwnProperty(input, 'options') &&
typeof input.options === 'string'
);
}

export async function runWorker() {
if (isMainThread || !parentPort || !isWorkerContextSerialized(workerData)) {
return;
}

try {
// eslint-disable-next-line no-eval
const eval2 = eval;

const options = eval2(`(${workerData.options})`);

const result = await minify(workerData.code, options);

const output: WorkerOutput = {
code: result.code || workerData.code,
nameCache: options.nameCache
};

parentPort.postMessage(output);
} catch (e) {
process.exit(1);
}
}
4 changes: 2 additions & 2 deletions packages/terser/test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ test.serial('throw error on terser fail', async (t) => {
await bundle.generate({ format: 'esm' });
t.falsy(true);
} catch (error) {
t.is(error.toString(), 'SyntaxError: Name expected');
t.is(error.toString(), 'Error: Minify worker stopped with exit code 1');
}
});

Expand All @@ -127,7 +127,7 @@ test.serial('throw error on terser fail with multiple outputs', async (t) => {
await Promise.all([bundle.generate({ format: 'cjs' }), bundle.generate({ format: 'esm' })]);
t.falsy(true);
} catch (error) {
t.is(error.toString(), 'SyntaxError: Name expected');
t.is(error.toString(), 'Error: Minify worker stopped with exit code 1');
}
});

Expand Down

0 comments on commit 140e06b

Please sign in to comment.