From 4783c43eb41a3850858a12eeb4abd305d7fb730f Mon Sep 17 00:00:00 2001 From: Giuseppe Date: Mon, 25 Jan 2021 10:24:01 +0100 Subject: [PATCH] Compile styles using shared worker thread (#49) --- .github/workflows/node.js.yml | 4 +- .npmignore | 2 +- .travis.yml | 5 - README.md | 15 +- compile.js | 105 ++++++++++++ error.js | 3 + fixture-postcss-plugin.js | 12 -- .../fixture-invalid-config}/postcss.config.js | 0 fixtures/fixture-postcss-plugin.js | 14 ++ fixture.css => fixtures/fixture.css | 0 fixtures/timeout/plugin.js | 11 ++ fixtures/timeout/postcss.config.js | 7 + index.js | 33 +--- postcss.config.js | 2 +- processor.js | 57 ++----- subprocess.js | 20 +++ test.js | 150 +++++++++++------- worker.js | 15 ++ 18 files changed, 309 insertions(+), 146 deletions(-) delete mode 100644 .travis.yml create mode 100644 compile.js create mode 100644 error.js delete mode 100644 fixture-postcss-plugin.js rename {fixture-invalid-config => fixtures/fixture-invalid-config}/postcss.config.js (100%) create mode 100644 fixtures/fixture-postcss-plugin.js rename fixture.css => fixtures/fixture.css (100%) create mode 100644 fixtures/timeout/plugin.js create mode 100644 fixtures/timeout/postcss.config.js create mode 100644 subprocess.js create mode 100644 worker.js diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 2a7adde..54c9abd 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -25,5 +25,5 @@ jobs: uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} - - run: npm ci - - run: npm test + - run: yarn install --frozen-lockfile + - run: yarn test diff --git a/.npmignore b/.npmignore index 6790312..5f06b45 100644 --- a/.npmignore +++ b/.npmignore @@ -1,3 +1,3 @@ postcss.config.js test.js -fixture.css \ No newline at end of file +fixtures diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 5c37cf0..0000000 --- a/.travis.yml +++ /dev/null @@ -1,5 +0,0 @@ -language: node_js -sudo: false -node_js: - - "10" - - "14" diff --git a/README.md b/README.md index b50f975..8d90d09 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,12 @@ Use [PostCSS](https://github.com/postcss/postcss) with ⚠️ **This plugin is not actively being maintained. If you want me to work on it please [consider donating](https://github.com/sponsors/giuseppeg).** +## Supporters + +Companies and individuals who sponsored some work on this library: + +🥇 [@swissredcross](https://github.com/swissredcross) + ## Usage Install this package first. @@ -39,7 +45,8 @@ With config: [ "styled-jsx-plugin-postcss", { - "path": "[PATH_PREFIX]/postcss.config.js" + "path": "[PATH_PREFIX]/postcss.config.js", + "compileEnv": "worker" } ] ] @@ -49,6 +56,12 @@ With config: } ``` +## compileEnv + +When using Node.js v12.3.0 and above the plugin defaults to compiling using a worker thread instead of a child process. This results in faster builds. + +If for any reason you want to force compiling using a child process (slower) you can register the plugin with the config option `compileEnv` set to `process`. + ### Example with CRA Usage with Create React App requires you to either _eject_ or use [react-app-rewired](https://github.com/timarney/react-app-rewired). diff --git a/compile.js b/compile.js new file mode 100644 index 0000000..5278fed --- /dev/null +++ b/compile.js @@ -0,0 +1,105 @@ +const { spawnSync } = require("child_process"); +const path = require("path"); +const { + Worker, + receiveMessageOnPort, + MessageChannel, +} = require("worker_threads"); +const error = require("./error"); + +let worker = null; +let unreftimeout = null; + +function compileWorker(...args) { + if (unreftimeout) { + clearTimeout(unreftimeout); + unreftimeout = null; + } + + if (!worker) { + worker = new Worker(require.resolve("./worker.js")); + } + + const signal = new Int32Array(new SharedArrayBuffer(4)); + signal[0] = 0; + + try { + const subChannel = new MessageChannel(); + worker.postMessage({ signal, port: subChannel.port1, args }, [ + subChannel.port1, + ]); + + const workStartedAt = Date.now(); + const settings = args[1] || {}; + const LOCK_TIMEOUT = settings.lockTimeout || 10000; + Atomics.wait(signal, 0, 0, LOCK_TIMEOUT); + + const result = receiveMessageOnPort(subChannel.port2); + + if (!result) { + if (Date.now() - workStartedAt >= LOCK_TIMEOUT) { + error( + `postcss is taking more than ${LOCK_TIMEOUT / + 1000}s to compile the following styles:\n\n` + + `Filename: ${(settings.babel || {}).filename || + "unknown filename"}\n\n` + + args[0] + ); + } + error(`postcss' compilation result is undefined`); + } + const { message } = result; + if (message.error) { + error(`postcss failed with ${message.error}`); + } + return message.result; + } catch (error) { + throw error; + } finally { + unreftimeout = setTimeout(() => { + worker.unref(); + worker = null; + unreftimeout = null; + }, 1000); + } +} + +function compileSubprocess(css, settings) { + const result = spawnSync("node", [path.resolve(__dirname, "subprocess.js")], { + input: JSON.stringify({ + css, + settings, + }), + encoding: "utf8", + }); + + if (result.status !== 0) { + if (result.stderr.includes("Invalid PostCSS Plugin")) { + let isNext = false; + try { + require.resolve("next"); + isNext = true; + } catch (err) {} + if (isNext) { + console.error( + "Next.js 9 default postcss support uses a non standard postcss config schema https://err.sh/next.js/postcss-shape, you must use the interoperable object-based format instead https://nextjs.org/docs/advanced-features/customizing-postcss-config" + ); + } + } + + error(`postcss failed with ${result.stderr}`); + } + + return result.stdout; +} + +module.exports = (css, settings) => { + if ( + typeof receiveMessageOnPort === "undefined" || + settings.compileEnv === "process" + ) { + return compileSubprocess(css, settings); + } else { + return compileWorker(css, settings); + } +}; diff --git a/error.js b/error.js new file mode 100644 index 0000000..128a3b0 --- /dev/null +++ b/error.js @@ -0,0 +1,3 @@ +module.exports = function error(message) { + throw new Error(`[styled-jsx-plugin-postcss] ${message}`); +}; diff --git a/fixture-postcss-plugin.js b/fixture-postcss-plugin.js deleted file mode 100644 index 0beb9b9..0000000 --- a/fixture-postcss-plugin.js +++ /dev/null @@ -1,12 +0,0 @@ -const postcss = require("postcss"); - -module.exports = (options = {}) => ({ - postcssPlugin: "postcss-csso", - Once(root, { result, postcss }) { - console.warn("warn"); - console.error("error"); - return root; - }, -}); - -module.exports.postcss = true; diff --git a/fixture-invalid-config/postcss.config.js b/fixtures/fixture-invalid-config/postcss.config.js similarity index 100% rename from fixture-invalid-config/postcss.config.js rename to fixtures/fixture-invalid-config/postcss.config.js diff --git a/fixtures/fixture-postcss-plugin.js b/fixtures/fixture-postcss-plugin.js new file mode 100644 index 0000000..24344df --- /dev/null +++ b/fixtures/fixture-postcss-plugin.js @@ -0,0 +1,14 @@ +const postcss = require("postcss"); + +module.exports = (options = {}) => ({ + postcssPlugin: "postcss-csso", + Rule(rule, { result, postcss }) { + rule.selector = rule.selector + .split(" ") + .map((s) => `${s}.plugin`) + .join(" "); + return rule; + }, +}); + +module.exports.postcss = true; diff --git a/fixture.css b/fixtures/fixture.css similarity index 100% rename from fixture.css rename to fixtures/fixture.css diff --git a/fixtures/timeout/plugin.js b/fixtures/timeout/plugin.js new file mode 100644 index 0000000..9492920 --- /dev/null +++ b/fixtures/timeout/plugin.js @@ -0,0 +1,11 @@ +const postcss = require("postcss"); + +module.exports = (options = {}) => ({ + postcssPlugin: "postcss-break", + async Rule(rule, { result, postcss }) { + await new Promise((resolve) => setTimeout(resolve, 20000)); + return rule; + }, +}); + +module.exports.postcss = true; diff --git a/fixtures/timeout/postcss.config.js b/fixtures/timeout/postcss.config.js new file mode 100644 index 0000000..a72770f --- /dev/null +++ b/fixtures/timeout/postcss.config.js @@ -0,0 +1,7 @@ +const path = require("path"); + +module.exports = { + plugins: { + [require.resolve("./plugin.js")]: {}, + }, +}; diff --git a/index.js b/index.js index f702fff..9a00a0c 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,5 @@ -const { spawnSync } = require("child_process"); -const path = require("path"); +const compile = require("./compile"); +const error = require("./error"); module.exports = (css, settings) => { const cssWithPlaceholders = css.replace( @@ -7,32 +7,15 @@ module.exports = (css, settings) => { (_, id) => `/*%%styled-jsx-placeholder-${id}%%*/` ); - const result = spawnSync("node", [path.resolve(__dirname, "processor.js")], { - input: JSON.stringify({ - css: cssWithPlaceholders, - settings, - }), - encoding: "utf8", - }); + const result = compile(cssWithPlaceholders, settings); - if (result.status !== 0) { - if (result.stderr.includes("Invalid PostCSS Plugin")) { - let isNext = false; - try { - require.resolve("next"); - isNext = true; - } catch (err) {} - if (isNext) { - console.error( - "Next.js 9 default postcss support uses a non standard postcss config schema https://err.sh/next.js/postcss-shape, you must use the interoperable object-based format instead https://nextjs.org/docs/advanced-features/customizing-postcss-config" - ); - } - } - - throw new Error(`postcss failed with ${result.stderr}`); + if (!result) { + error( + `did not compile the following CSS:\n\n${css.split("\n").join("\n\t")}\n` + ); } - return result.stdout.replace( + return result.replace( /\/\*%%styled-jsx-placeholder-(\d+)%%\*\//g, (_, id) => `%%styled-jsx-placeholder-${id}%%` ); diff --git a/postcss.config.js b/postcss.config.js index 32f940b..195436e 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -13,6 +13,6 @@ module.exports = { }, }, "postcss-calc": {}, - [path.resolve(__dirname, "./fixture-postcss-plugin.js")]: {}, + [path.resolve(__dirname, "./fixtures/fixture-postcss-plugin.js")]: {}, }, }; diff --git a/processor.js b/processor.js index 696c07b..caef4f6 100644 --- a/processor.js +++ b/processor.js @@ -1,47 +1,20 @@ -const postcss = require("postcss"); -const loader = require("postcss-load-config"); +const postcss = require('postcss') +const loader = require('postcss-load-config') -let plugins; -let _processor; +const loaderPromises = {} -function processor(src, options) { - options = options || {}; - let loaderPromise; - if (!plugins) { - loaderPromise = loader(options.env || process.env, options.path, { - argv: false, - }).then((pluginsInfo) => { - plugins = pluginsInfo.plugins || []; - }); - } else { - loaderPromise = Promise.resolve(); - } +module.exports = function processor(src, options) { + options = options || {} - return loaderPromise - .then(() => { - if (!_processor) { - _processor = postcss(plugins); - } - return _processor.process(src, { from: false }); - }) - .then((result) => result.css); -} + const loaderPromise = loaderPromises.hasOwnProperty(options.path || 'auto') + ? loaderPromises[options.path || 'auto'] + : loader(options.env || process.env, options.path, { + argv: false + }).then((pluginsInfo) => pluginsInfo.plugins || []) -let input = ""; -process.stdin.on("data", (data) => { - input += data.toString(); -}); + loaderPromises[options.path || 'auto'] = loaderPromise -process.stdin.on("end", () => { - const inputData = JSON.parse(input); - processor(inputData.css, inputData.settings) - .then((result) => { - process.stdout.write(result); - }) - .catch((err) => { - // NOTE: we console.erorr(err) and then process.exit(1) instead of throwing the error - // to avoid the UnhandledPromiseRejectionWarning message. - console.error(err); - process.exit(1); - }); -}); + return loaderPromise + .then((plugins) => postcss(plugins).process(src, { from: false })) + .then((result) => result.css) +} diff --git a/subprocess.js b/subprocess.js new file mode 100644 index 0000000..1411c86 --- /dev/null +++ b/subprocess.js @@ -0,0 +1,20 @@ +const processor = require("./processor"); + +let input = ""; +process.stdin.on("data", (data) => { + input += data.toString(); +}); + +process.stdin.on("end", () => { + const inputData = JSON.parse(input); + processor(inputData.css, inputData.settings) + .then((result) => { + process.stdout.write(result); + }) + .catch((err) => { + // NOTE: we console.error(err) and then process.exit(1) instead of throwing the error + // to avoid the UnhandledPromiseRejectionWarning message. + console.error(err); + process.exit(1); + }); +}); diff --git a/test.js b/test.js index 382120e..03210d5 100644 --- a/test.js +++ b/test.js @@ -1,75 +1,111 @@ const assert = require("assert"); const path = require("path"); const plugin = require("./"); +const { receiveMessageOnPort } = require("worker_threads"); describe("styled-jsx-plugin-postcss", () => { - it("applies browser list and preset-env features", () => { - assert.strictEqual( - plugin( - "p { color: color-mod(red alpha(90%)); & img { display: block } }" - ), - "p { color: rgba(255, 0, 0, 0.9) }\np img { display: block }" - ); - }); + const compileEnvs = ["process", "worker"]; - it("applies plugins", () => { - assert.strictEqual( - plugin("p { font-size: calc(2 * 20px); }"), - "p { font-size: 40px; }" - ); - }); + compileEnvs.forEach((compileEnv) => { + describe(`Compiling using a ${compileEnv}`, () => { + it("applies browser list and preset-env features", () => { + assert.strictEqual( + plugin( + "p { color: color-mod(red alpha(90%)); & img { display: block } }", + { compileEnv } + ), + "p.plugin { color: rgba(255, 0, 0, 0.9) }\np.plugin img.plugin { display: block }" + ); + }); - it("works with placeholders", () => { - assert.strictEqual( - plugin( - "p { color: %%styled-jsx-placeholder-0%%; & img { display: block; } } %%styled-jsx-placeholder-1%%" - ), - "p { color: %%styled-jsx-placeholder-0%% } p img { display: block; } %%styled-jsx-placeholder-1%%" - ); - }); + it("applies plugins", () => { + assert.strictEqual( + plugin("p { font-size: calc(2 * 20px); }", { compileEnv }), + "p.plugin { font-size: 40px; }" + ); + }); - it("works with @import", () => { - assert.strictEqual( - plugin('@import "./fixture.css"; p { color: red }'), - "div { color: red; } p { color: red }" - ); - }); + it("works with placeholders", () => { + assert.strictEqual( + plugin( + "p { color: %%styled-jsx-placeholder-0%%; & img { display: block; } } %%styled-jsx-placeholder-1%%", + { compileEnv } + ), + "p.plugin { color: %%styled-jsx-placeholder-0%% } p.plugin img.plugin { display: block; } %%styled-jsx-placeholder-1%%" + ); + }); + + it("works with @import", () => { + assert.strictEqual( + plugin('@import "./fixtures/fixture.css"; p { color: red }', { + compileEnv, + }), + "div.plugin { color: red; } p.plugin { color: red }" + ); + }); - it("works with quotes and other characters", () => { - assert.strictEqual( - plugin(`@import "./fixture.css"; * { color: red; font-family: 'Times New Roman'; } + it("works with quotes and other characters", () => { + assert.strictEqual( + plugin( + `@import "./fixtures/fixture.css"; * { color: red; font-family: 'Times New Roman'; } li:after{ content: "!@#$%^&*()_+"} - ul li:before{ content: "{res:{res:'korea'}}"; }`), - `div { color: red; } * { color: red; font-family: 'Times New Roman'; } li:after{ content: "!@#$%^&*()_+"} ul li:before{ content: "{res:{res:'korea'}}"; }` - ); - }); + ul li:before{ content: "{res:{res:'korea'}}"; }`, + { compileEnv } + ), + `div.plugin { color: red; } *.plugin { color: red; font-family: 'Times New Roman'; } li:after.plugin{ content: "!@#$%^&*()_+"} ul.plugin li:before.plugin{ content: "{res:{res:'korea'}}"; }` + ); + }); - it("throws with invalid css", () => { - assert.throws( - () => { - plugin('a {\n content: "\n}'); - }, - { - name: "Error", - message: /postcss failed with CssSyntaxError: :2:12: Unclosed string/, - } - ); - }); + it("throws with invalid css", () => { + assert.throws( + () => { + plugin('a {\n content: "\n}', { compileEnv }); + }, + { + name: "Error", + message: /postcss failed with CssSyntaxError: :2:12: Unclosed string/, + } + ); + }); - it("throws with invalid config", () => { - assert.throws( - () => { - plugin( - "p { color: color-mod(red alpha(90%)); & img { display: block } }", + it("throws with invalid config", () => { + assert.throws( + () => { + plugin( + "p { color: color-mod(red alpha(90%)); & img { display: block } }", + { + path: path.resolve("./fixtures/fixture-invalid-config"), + compileEnv, + } + ); + }, { - path: path.resolve("fixture-invalid-config"), + name: "Error", + message: /postcss failed with TypeError: Invalid PostCSS Plugin found at: plugins\[0]/, } ); - }, - { - name: "Error", - message: /postcss failed with TypeError: Invalid PostCSS Plugin found at: plugins\[0]/, + }); + + if ( + compileEnv === "worker" && + typeof receiveMessageOnPort !== "undefined" + ) { + it("worker mode timeouts after 3s", () => { + assert.throws( + () => { + plugin("p { color: red; }", { + path: path.resolve("fixtures/timeout"), + compileEnv, + lockTimeout: 3000, + }); + }, + { + name: "Error", + message: /postcss is taking more than/, + } + ); + }); } - ); + }); }); }); diff --git a/worker.js b/worker.js new file mode 100644 index 0000000..c1c0837 --- /dev/null +++ b/worker.js @@ -0,0 +1,15 @@ +const { parentPort } = require("worker_threads"); +const processor = require("./processor"); + +parentPort.addListener("message", async ({ signal, port, args }) => { + try { + const result = await processor(...args); + port.postMessage({ result }); + } catch (error) { + port.postMessage({ error: error.toString() }); + } finally { + port.close(); + Atomics.store(signal, 0, 1); + Atomics.notify(signal, 0); + } +});