diff --git a/package-lock.json b/package-lock.json index 0944305e5..4df3da9a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4043,6 +4043,12 @@ "is-symbol": "^1.0.2" } }, + "esbuild": { + "version": "0.6.14", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.6.14.tgz", + "integrity": "sha512-coT5T5flVNaleZQAciBJCpkJ0xB+7TShhinPLUK0Zbz/yy385DKY3nGTNmHy6+oFARI3qnNlBRAbMYgxLN9/mA==", + "dev": true + }, "escape-goat": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-2.1.1.tgz", diff --git a/package.json b/package.json index 5c620f877..84a40d9af 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "cssnano": "^4.1.10", "devcert": "^1.1.2", "es-module-lexer": "^0.3.24", + "esbuild": "^0.6.14", "eslint": "^7.4.0", "eslint-config-developit": "^1.2.0", "eslint-config-prettier": "^6.11.0", diff --git a/src/bundler.js b/src/bundler.js index 083c5665f..6a8f0bd0a 100644 --- a/src/bundler.js +++ b/src/bundler.js @@ -18,6 +18,8 @@ import glob from 'tiny-glob'; import aliasesPlugin from './plugins/aliases-plugin.js'; import processGlobalPlugin from './plugins/process-global-plugin.js'; import urlPlugin from './plugins/url-plugin.js'; +// import * as esbuild from 'esbuild'; +// import greenlet from './lib/greenlet.js'; /** @param {string} p */ const pathToPosix = p => p.split(sep).join(posix.sep); @@ -213,11 +215,12 @@ export async function bundleProd({ cwd, publicDir, out, sourcemap, aliases, prof preserveEntrySignatures: 'allow-extension', manualChunks: npmChunks ? extractNpmChunks : undefined, plugins: [ - sucrasePlugin({ - typescript: true, - sourcemap, - production: true - }), + // sucrasePlugin({ + // typescript: true, + // sourcemap, + // production: true + // }), + terser({ compress: true, sourcemap }), htmlEntriesPlugin({ cwd, publicDir, publicPath: '/' }), publicPathPlugin({ publicPath: '/' }), aliasesPlugin({ aliases }), @@ -240,12 +243,9 @@ export async function bundleProd({ cwd, publicDir, out, sourcemap, aliases, prof ] }); - return await bundle.write({ - entryFileNames: '[name].[hash].js', - chunkFileNames: 'chunks/[name].[hash].js', - assetFileNames: 'assets/[name].[hash][extname]', + const outputOptions = { + dir: out || 'dist', compact: true, - plugins: [terser({ compress: true, sourcemap })], sourcemap, sourcemapPathTransform(p, mapPath) { let url = pathToPosix(relative(cwd, resolve(dirname(mapPath), p))); @@ -254,10 +254,69 @@ export async function bundleProd({ cwd, publicDir, out, sourcemap, aliases, prof // replace internal npm prefix url = url.replace(/^(\.?\.?\/)?[\b]npm\//, '@npm/'); return 'source:///' + url; - }, - preferConst: true, - dir: out || 'dist' + } + }; + + const modern = bundle.write({ + ...outputOptions, + entryFileNames: '[name].[hash].js', + chunkFileNames: 'chunks/[name].[hash].js', + assetFileNames: 'assets/[name].[hash][extname]', + preferConst: true + // plugins: [terser({ compress: true, sourcemap })] }); + + // const downlevel = greenlet(code => { + // const { transform } = require('sucrase'); + // const out = transform(code, { + // transforms: ['typescript'], + // production: true + // }); + // return out.code; + // }); + + // let esbuildService = ((esbuild && esbuild.default) || esbuild).startService(); + // async function downlevel(code, fileName, sourcemap) { + // const start = Date.now(); + // const { transform } = await esbuildService; + // const result = await transform(code, { + // define: { + // process: 'self', + // 'process.env': 'self', + // 'process.env.NODE_ENV': '"production"' + // }, + // minify: true, + // sourcefile: fileName, + // sourcemap: sourcemap === true, + // strict: false, + // target: 'es2015' // es5 not supported (yet?) + // }); + // console.log('esbuild(' + fileName + '): ', Date.now() - start); + // return { code: result.js, map: (sourcemap && result.jsSourceMap) || null }; + // } + + const legacy = bundle.write({ + ...outputOptions, + entryFileNames: '[name].[hash].legacy.js', + chunkFileNames: 'chunks/[name].[hash].legacy.js', + assetFileNames: 'assets/[name].[hash][extname]', + plugins: [ + // { + // name: 'lazy-sucrase', + // async renderChunk(code, chunk) { + // return await downlevel(code, chunk.fileName); + // } + // } + // terser({ compress: true, sourcemap }) + ] + }); + + const results = await Promise.all([modern, legacy]); + + // downlevel.terminate(); + // (await esbuildService).stop(); + + return results[0]; } /** @type {import('rollup').GetManualChunk} */ diff --git a/src/lib/esbuild-service.js b/src/lib/esbuild-service.js new file mode 100644 index 000000000..31c4cff82 --- /dev/null +++ b/src/lib/esbuild-service.js @@ -0,0 +1,59 @@ +import * as esbuild from 'esbuild'; + +/** @type {esbuild.Service} */ +let inst, + svc, + timer, + usage = 0; + +function free() { + if (--usage) return; + clearTimeout(timer); + timer = setTimeout(shutdown, 10); +} + +function use() { + clearTimeout(timer); + usage++; +} + +export function keepalive() { + use(); + free(); +} + +export function shutdown() { + if (inst) inst.stop(); + else if (svc) svc.then(inst => usage || inst.stop()); + inst = svc = null; +} +process.on('beforeExit', shutdown); + +/** + * This function is the same shape as Terser.minify, except that it is async. + * @param {string} code + * @param {esbuild.TransformOptions} opts + */ +export async function transform(code, opts) { + if (!inst) { + if (!svc) { + svc = ((esbuild && esbuild.default) || esbuild).startService(); + } + inst = await svc; + } + use(); + try { + const result = await inst.transform(code, opts); + return { + code: result.js, + map: result.jsSourceMap || null, + warnings: result.warnings.map(warning => warning.text) + }; + } catch (err) { + return { + error: err + }; + } finally { + free(); + } +} diff --git a/src/lib/greenlet.js b/src/lib/greenlet.js new file mode 100644 index 000000000..b15d3a0c6 --- /dev/null +++ b/src/lib/greenlet.js @@ -0,0 +1,35 @@ +import { Worker } from 'worker_threads'; + +/** + * @template {(...args: any) => any} T + * @param {T} fn + * @returns {(...args: Parameters) => Promise extends Promise ? R : ReturnType>} + */ +export default function greenlet(fn) { + let n = 0, + t = {}; + function a(port, fn) { + port.on('message', ([id, params]) => { + try { + port.postMessage([id, 0, fn(...params)]); + } catch (e) { + port.postMessage([id, 1, (e && e.stack) || e + '']); + } + }); + } + function g(...args) { + return new Promise((s, f) => ((t[++n] = [s, f]), w.postMessage([n, args]))); + } + g.terminate = () => w.terminate(); + let w = (g.worker = new Worker(`(${a})(require("worker_threads").parentPort,${fn})`, { eval: true })); + w.on('message', ([id, x, result]) => (t[id] = t[id][x](result))); + return g; +} + +/** @param {string} a @param {number} b */ +function y(a, b) { + return 42; +} +const f = greenlet(y); + +f(); diff --git a/src/plugins/fast-minify.js b/src/plugins/fast-minify.js index 8d2c96a9b..d470f92dd 100644 --- a/src/plugins/fast-minify.js +++ b/src/plugins/fast-minify.js @@ -1,30 +1,73 @@ -import terser from 'terser'; +// import terser from 'terser'; +import { transform, keepalive } from '../lib/esbuild-service.js'; + +/** + * @param {import('rollup').PluginContext} rollupContext + * @param {{ code?: string, map?: any, warnings?: any[], error?: any }} result + */ +function normalizeResult(rollupContext, result) { + if (result.error) rollupContext.error(result.error); + if (result.warnings) for (const warn of result.warnings) rollupContext.warn(warn); + const map = result.map && typeof result.map === 'string' ? JSON.parse(result.map) : result.map || null; + return { code: result.code, map }; +} /** @returns {import('rollup').Plugin} */ export default function fastMinifyPlugin({ sourcemap = false, warnThreshold = 50, compress = false } = {}) { return { name: 'fast-minify', - renderChunk(code, chunk) { + buildStart() { + keepalive(); + }, + async transform(code, id) { + if (!/\.tsx?$/.test(id)) return null; + const out = await transform(code, { + loader: 'tsx', + minify: false, + sourcefile: id, + sourcemap: false, + strict: false + }); + return normalizeResult(this, out); + }, + async renderChunk(code, chunk) { const start = Date.now(); - const out = terser.minify(code, { - sourceMap: sourcemap, - mangle: true, - compress, - module: true, - ecma: 9, - safari10: true, - output: { - comments: false - } + + const out = await transform(code, { + define: { + process: 'self', + 'process.env': 'self', + 'process.env.NODE_ENV': '"production"' + }, + minify: !!compress, + sourcefile: chunk.fileName, + sourcemap: sourcemap === true, + strict: false, + target: 'es6' + // target: 'es2015' // es5 not supported (yet?) }); + + // const out = terser.minify(code, { + // sourceMap: sourcemap, + // mangle: true, + // compress: compress && { + // // passes: 2, + // ...(compress === true ? {} : compress) + // }, + // module: true, + // ecma: 9, + // safari10: true, + // output: { + // comments: false + // } + // }); + const duration = Date.now() - start; - if (out.error) this.error(out.error); - if (out.warnings) for (const warn of out.warnings) this.warn(warn); if (duration > warnThreshold) { this.warn(`minify(${chunk.fileName}) took ${duration}ms`); } - const map = typeof out.map === 'string' ? JSON.parse(out.map) : out.map || null; - return { code: out.code, map }; + + return normalizeResult(this, out); } }; }