Skip to content

Commit

Permalink
Initial release
Browse files Browse the repository at this point in the history
  • Loading branch information
overlookmotel committed Jul 12, 2021
1 parent 3288ebe commit 5a1aa5d
Show file tree
Hide file tree
Showing 8 changed files with 745 additions and 76 deletions.
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,42 @@

# Execute Terser minify synchronously

## What is this?

A really hacky way of of getting [Terser](https://www.npmjs.com/package/terser) to run synchronously.

`terser.minify()` is async and returns a Promise. The reason for this is that Terser uses uses [source-map](https://www.npmjs.com/package/source-map) (which is implemented in WASM and is async) to parse input source maps. The entire rest of Terser's codebase is synchronous, but this one dependency forces Terser to be async too.

This package calls Terser with a specially crafted options object which tricks Terser into skipping over the async code and into using an older version of source-map (v0.5.7) which is pure JS and is synchronous.

`minifySync()` is born!

## Usage

This module is under development and not ready for use yet.
This package is a drop-in replacement for Terser which adds a `minifySync()` method.

```js
const terser = require('terser-sync');

// Original async terser.minify()
const {code} = await terser.minify('() => {}');

// Sync version - options are exactly the same
const {code} = terser.minifySync('() => {}');
```

### Errors

If Terser throws an error, it's not possible to obtain it synchronously. `minifySync()` will throw an error synchronously and that error has a `.promise` property which will resolve to the error Terser threw.

```js
try {
terser.minifySync( '() => {}', { nonExistentOption: true } );
} catch (e) {
const err = await e.promise;
// err.message === '`nonExistentOption` is not a supported option'
}
```

## Versioning

Expand Down
2 changes: 1 addition & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@

// Exports

module.exports = {};
module.exports = require('./lib/index.js');
178 changes: 178 additions & 0 deletions lib/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/* --------------------
* terser-sync module
* Entry point
* ------------------*/

'use strict';

// Modules
const terser = require('terser'),
{minify} = terser,
hasOwnProp = require('has-own-prop');

// Imports
const createSourceMap = require('./sourceMap.js');

// Exports

module.exports = {...terser, minifySync};

/**
* Synchronous version of `terser.minify()`.
* Output should be identical to `terser.minify()` and supports all options `terser.minify()` does.
*
* Calls `minify()` and uses some hacks to extract internal state from within `minify()` and fool
* it into using a synchronous implementation of source map parser to get the result synchronously.
*
* @param {string|Array|Object} files - Input files
* @param {Object} [options] - Options
* @returns {Object} - Properties depend on options
* @throws {Error} - If `minify()` errors or injection fails
*/
function minifySync(files, options) {
let result, err;

// Conform files
options = {...options};
if (
typeof files === 'string'
|| (options.parse && options.parse.spidermonkey && !Array.isArray(files))
) {
files = [files];
}

// First thing Terser does is clone the options object and add defaults to it.
// https://github.com/terser/terser/blob/d225b75e82770d0d7eedf011e4769d18a43de9c0/lib/minify.js#L64
// Capture the cloned options object via a setter on `Object.prototype.spidermonkey`
// which is triggered when Terser sets the `.spidermonkey` property on the cloned object.
// We need Terser's clone of options object in order to manipulate its `.sourceMap` property later.
const originalSpidermonkeyOpt = !!options.spidermonkey;
delete options.spidermonkey;

let hasCapturedClonedOptions = false;
captureObjectViaObjectPrototypeSetter('spidermonkey', (clonedOptions) => {
options = clonedOptions;
hasCapturedClonedOptions = true;
options.spidermonkey = originalSpidermonkeyOpt;
});

// Terser's code includes `if (options.format.ast) { result.ast = toplevel; }`.
// https://github.com/terser/terser/blob/d225b75e82770d0d7eedf011e4769d18a43de9c0/lib/minify.js#L216
// `result` is the result object returned by Terser that we want to capture.
// Define a getter on `options.format.ast` which returns `true` and then installs a setter
// on `Object.prototype.ast` to capture `result` in next statement `result.ast = toplevel`.
// This allows us to capture `result` before the promise returned by `minify()` resolves.
const format = options.format = options.format || {};
let hasSubtitutedSourceMap = false;

shimGetter(format, 'ast', true, () => {
captureObjectViaObjectPrototypeSetter('ast', (capturedResult, ast) => {
result = capturedResult;
if (format.ast) result.ast = ast;
substituteSourceMapImplementation();
});
});

function substituteSourceMapImplementation() {
// Skip if source map will not be created
const {sourceMap} = options;
if (!sourceMap || (hasOwnProp(format, 'code') && !format.code)) {
hasSubtitutedSourceMap = true;
return;
}

// Define getter for `options.sourceMap` that returns `false` to prevent Terser initializing
// source map asynchronously. Then create a source map synchronously instead.
// This short-circuits section of code which includes the async call:
// https://github.com/terser/terser/blob/d225b75e82770d0d7eedf011e4769d18a43de9c0/lib/minify.js#L223
shimGetter(options, 'sourceMap', false, () => {
hasSubtitutedSourceMap = true;

try {
createSourceMap(options, files);
} catch (e) {
err = e;
throw err;
}
});
}

// Run Terser
const promise = minify(files, options).catch(e => e);

// If failed, throw error with `.promise` property which resolves to the error
if (!err) {
if (!hasCapturedClonedOptions) {
err = new Error('Failed to capture cloned options');
} else if (!hasSubtitutedSourceMap) {
err = new Error('Failed to skip async source map initialization');
} else if (!result) {
err = new Error('Unknown error - await `error.promise` to get error details');
}
}

if (err) {
err.promise = promise;
throw err;
}

// Return result
return result;
}

/**
* Capture an Object internal to Terser when Terser sets a property on it.
* Works by creating a setter on `Object.prototype` which will be triggered when property is set
* on Object we're interested in.
* @param {string} propName - Property name to create setter for
* @param {Function} cb - Callback, called with object and value which was written to property
* @returns {undefined}
*/
function captureObjectViaObjectPrototypeSetter(propName, cb) {
shimObject(Object.prototype, propName, 'set', cb);
}

/**
* Catch when Terser reads a property on an Object.
* This is used to return a desired value to fool Terser into doing something it shouldn't,
* or to be alerted to when it's up to a particular point in execution so we can take some
* other action at that point.
* @param {Object} obj - Object to shim
* @param {string} propName - Property name to create getter/setter on
* @param {*} value - Value to return from getter
* @param {Function} cb - Callback, called with no args
* @returns {undefined}
*/
function shimGetter(obj, propName, value, cb) {
shimObject(obj, propName, 'get', () => {
cb();
return value;
});
}

/**
* Shim Object property with a getter/setter.
* As soon as getter/setter is called, the shim is removed again.
* Call callback with object and value setter was called with (if it's a setter).
* @param {Object} obj - Object to shim
* @param {string} propName - Property name to create getter/setter on
* @param {string} getOrSet - 'get' or 'set'
* @param {Function} cb - Callback, called with object and value setter was called with
* @returns {undefined}
*/
function shimObject(obj, propName, getOrSet, cb) {
const descriptor = Object.getOwnPropertyDescriptor(obj, propName);
Object.defineProperty(obj, propName, {
[getOrSet](value) {
// Restore to as it was before
if (descriptor) {
Object.defineProperty(obj, propName, descriptor);
} else {
delete obj[propName];
}

return cb(this, value);
},
configurable: true
});
}
159 changes: 159 additions & 0 deletions lib/sourceMap.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/* --------------------
* terser-sync module
* Create source map synchronously
* ------------------*/

/* eslint-disable camelcase */

'use strict';

// Modules
const MOZ_SourceMap = require('source-map'),
HOP = require('has-own-prop'),
{isObject} = require('is-it-type');

// Exports

/**
* Create source map synchronously.
* Logic copied from
* https://github.com/terser/terser/blob/d225b75e82770d0d7eedf011e4769d18a43de9c0/lib/minify.js#L224
* @param {Object} options - Options object
* @param {Array|Object} files - Files
* @returns {undefined}
* @throws {Error} - If error
*/
module.exports = function createSourceMap(options, files) {
options.format.source_map = SourceMap({
file: options.sourceMap.filename,
orig: options.sourceMap.content,
root: options.sourceMap.root
});

if (options.sourceMap.includeSources) {
if (isObject(files)) {
throw new Error('original source content unavailable');
} else {
for (const name in files) {
if (HOP(files, name)) {
options.format.source_map.get().setSourceContent(name, files[name]);
}
}
}
}
};

/**
* Create SourceMap interface.
* Copied verbatim from
* https://github.com/terser/terser/blob/d225b75e82770d0d7eedf011e4769d18a43de9c0/lib/sourcemap.js#L52
* except `await new MOZ_SourceMap.SourceMapConsumer()` substituted for a sync version.
* @param {Object} options - Options object
* @returns {Object} - SourceMap interface
*/
function SourceMap(options) {
options = defaults(options, {
file: null,
root: null,
orig: null,

orig_line_diff: 0,
dest_line_diff: 0
});

let orig_map;
const generator = new MOZ_SourceMap.SourceMapGenerator({
file: options.file,
sourceRoot: options.root
});

if (options.orig) {
orig_map = new MOZ_SourceMap.SourceMapConsumer(options.orig);
orig_map.sources.forEach((source) => {
const sourceContent = orig_map.sourceContentFor(source, true);
if (sourceContent) {
generator.setSourceContent(source, sourceContent);
}
});
}

function add(source, gen_line, gen_col, orig_line, orig_col, name) {
if (orig_map) {
const info = orig_map.originalPositionFor({
line: orig_line,
column: orig_col
});
if (info.source === null) {
return;
}
source = info.source;
orig_line = info.line;
orig_col = info.column;
name = info.name || name;
}
generator.addMapping({
generated: {line: gen_line + options.dest_line_diff, column: gen_col},
original: {line: orig_line + options.orig_line_diff, column: orig_col},
source,
name
});
}

return {
add,
get() { return generator; },
toString() { return generator.toString(); },
destroy() {
if (orig_map && orig_map.destroy) {
orig_map.destroy();
}
}
};
}

/**
* Apply defaults to options object.
* Copied verbatim from
* https://github.com/terser/terser/blob/d225b75e82770d0d7eedf011e4769d18a43de9c0/lib/utils/index.js#L64
* except one section which is not used here commented out.
* @param {Object|boolean} args - Args
* @param {Object} defs - Defaults definition
* @param {boolean} croak - Not used
* @returns {Object} - Conformed object
*/
function defaults(args, defs, croak) { // eslint-disable-line no-unused-vars
if (args === true) {
args = {};
} else if (args != null && typeof args === 'object') {
args = {...args};
}

const ret = args || {};

/*
// No need for this
if (croak) {
for (const i in ret) {
if (HOP(ret, i) && !HOP(defs, i)) {
throw new DefaultsError(`\`${i}\` is not a supported option`, defs);
}
}
}
*/

for (const i in defs) {
if (HOP(defs, i)) {
if (!args || !HOP(args, i)) {
ret[i] = defs[i];
} else if (i === 'ecma') {
let ecma = args[i] | 0; // eslint-disable-line no-bitwise
if (ecma > 5 && ecma < 2015) ecma += 2009;
ret[i] = ecma;
} else {
ret[i] = (args && HOP(args, i)) ? args[i] : defs[i];
}
}
}

return ret;
}
Loading

0 comments on commit 5a1aa5d

Please sign in to comment.