diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f8b9aa3e..5e0254d6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -222,11 +222,9 @@ jobs: curl -L -O https://github.com/WebAssembly/binaryen/releases/download/version_111/binaryen-version_111-x86_64-linux.tar.gz tar -xf binaryen-version_111-x86_64-linux.tar.gz - name: Build wasm - run: yarn wasm:build-release - - name: Optimize wasm run: | - ./binaryen-version_111/bin/wasm-opt wasm/lightningcss_node.wasm -Oz -o wasm/lightningcss_node.opt.wasm - mv wasm/lightningcss_node.opt.wasm wasm/lightningcss_node.wasm + export PATH="$PATH:./binaryen-version_111/bin" + yarn wasm:build-release - name: Upload artifacts uses: actions/upload-artifact@v3 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a7e2b629..275e55df 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,6 +19,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - run: cargo fmt - run: cargo test --all-features + test-js: runs-on: ubuntu-latest steps: @@ -32,3 +33,28 @@ jobs: - run: yarn build - run: yarn test - run: yarn tsc + + test-wasm: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 18 + - uses: bahmutov/npm-install@v1.8.32 + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + - name: Setup rust target + run: rustup target add wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + - name: Install wasm-opt + run: | + curl -L -O https://github.com/WebAssembly/binaryen/releases/download/version_111/binaryen-version_111-x86_64-linux.tar.gz + tar -xf binaryen-version_111-x86_64-linux.tar.gz + - name: Build wasm + run: | + export PATH="$PATH:./binaryen-version_111/bin" + yarn wasm:build-release + - run: TEST_WASM=node yarn test + - run: TEST_WASM=browser yarn test diff --git a/node/src/lib.rs b/node/src/lib.rs index 870b303f..bfc28344 100644 --- a/node/src/lib.rs +++ b/node/src/lib.rs @@ -420,14 +420,151 @@ mod bundle { } } +#[cfg(target_arch = "wasm32")] +mod bundle { + use super::*; + use napi::{Env, JsFunction, JsString, NapiRaw, NapiValue, Ref}; + use std::cell::UnsafeCell; + + #[js_function(1)] + pub fn bundle(ctx: CallContext) -> napi::Result { + use transformer::JsVisitor; + + let opts = ctx.get::(0)?; + let mut visitor = if let Ok(visitor) = opts.get_named_property::("visitor") { + Some(JsVisitor::new(*ctx.env, visitor)) + } else { + None + }; + + let resolver = opts.get_named_property::("resolver")?; + let read = resolver.get_named_property::("read")?; + let resolve = if resolver.has_named_property("resolve")? { + let resolve = resolver.get_named_property::("resolve")?; + Some(ctx.env.create_reference(resolve)?) + } else { + None + }; + let config: BundleConfig = ctx.env.from_js_value(opts)?; + + let provider = JsSourceProvider { + env: ctx.env.clone(), + resolve, + read: ctx.env.create_reference(read)?, + inputs: UnsafeCell::new(Vec::new()), + }; + + // This is pretty silly, but works around a rust limitation that you cannot + // explicitly annotate lifetime bounds on closures. + fn annotate<'i, 'o, F>(f: F) -> F + where + F: FnOnce(&mut StyleSheet<'i, 'o, AtRule<'i>>) -> napi::Result<()>, + { + f + } + + let res = compile_bundle( + &provider, + &config, + visitor.as_mut().map(|visitor| annotate(|stylesheet| stylesheet.visit(visitor))), + ); + + match res { + Ok(res) => res.into_js(*ctx.env), + Err(err) => Err(err.into_js_error(*ctx.env, None)?), + } + } + + struct JsSourceProvider { + env: Env, + resolve: Option>, + read: Ref<()>, + inputs: UnsafeCell>, + } + + impl Drop for JsSourceProvider { + fn drop(&mut self) { + if let Some(resolve) = &mut self.resolve { + drop(resolve.unref(self.env)); + } + drop(self.read.unref(self.env)); + } + } + + unsafe impl Sync for JsSourceProvider {} + unsafe impl Send for JsSourceProvider {} + + // This relies on Binaryen's Asyncify transform to allow Rust to call async JS functions from sync code. + // See the comments in async.mjs for more details about how this works. + extern "C" { + fn await_promise_sync( + promise: napi::sys::napi_value, + result: *mut napi::sys::napi_value, + error: *mut napi::sys::napi_value, + ); + } + + fn get_result(env: Env, mut value: JsUnknown) -> napi::Result { + if value.is_promise()? { + let mut result = std::ptr::null_mut(); + let mut error = std::ptr::null_mut(); + unsafe { await_promise_sync(value.raw(), &mut result, &mut error) }; + if !error.is_null() { + let error = unsafe { JsUnknown::from_raw(env.raw(), error)? }; + return Err(napi::Error::from(error)); + } + if result.is_null() { + return Err(napi::Error::new(napi::Status::GenericFailure, "No result".into())); + } + + value = unsafe { JsUnknown::from_raw(env.raw(), result)? }; + } + + value.try_into() + } + + impl SourceProvider for JsSourceProvider { + type Error = napi::Error; + + fn read<'a>(&'a self, file: &Path) -> Result<&'a str, Self::Error> { + let read: JsFunction = self.env.get_reference_value_unchecked(&self.read)?; + let file = self.env.create_string(file.to_str().unwrap())?; + let mut source: JsUnknown = read.call(None, &[file])?; + let source = get_result(self.env, source)?.into_utf8()?.into_owned()?; + + // cache the result + let ptr = Box::into_raw(Box::new(source)); + let inputs = unsafe { &mut *self.inputs.get() }; + inputs.push(ptr); + // SAFETY: this is safe because the pointer is not dropped + // until the JsSourceProvider is, and we never remove from the + // list of pointers stored in the vector. + Ok(unsafe { &*ptr }) + } + + fn resolve(&self, specifier: &str, originating_file: &Path) -> Result { + if let Some(resolve) = &self.resolve { + let resolve: JsFunction = self.env.get_reference_value_unchecked(resolve)?; + let specifier = self.env.create_string(specifier)?; + let originating_file = self.env.create_string(originating_file.to_str().unwrap())?; + let result: JsUnknown = resolve.call(None, &[specifier, originating_file])?; + let result = get_result(self.env, result)?.into_utf8()?; + Ok(PathBuf::from_str(result.as_str()?).unwrap()) + } else { + Ok(originating_file.with_file_name(specifier)) + } + } + } +} + #[cfg_attr(not(target_arch = "wasm32"), module_exports)] fn init(mut exports: JsObject) -> napi::Result<()> { exports.create_named_method("transform", transform)?; exports.create_named_method("transformStyleAttribute", transform_style_attribute)?; + exports.create_named_method("bundle", bundle::bundle)?; #[cfg(not(target_arch = "wasm32"))] { - exports.create_named_method("bundle", bundle::bundle)?; exports.create_named_method("bundleAsync", bundle::bundle_async)?; } diff --git a/node/test/bundle.test.mjs b/node/test/bundle.test.mjs index d42979c1..7199c327 100644 --- a/node/test/bundle.test.mjs +++ b/node/test/bundle.test.mjs @@ -1,9 +1,28 @@ import path from 'path'; import fs from 'fs'; -import { bundleAsync } from '../index.mjs'; import { test } from 'uvu'; import * as assert from 'uvu/assert'; +let bundleAsync; +if (process.env.TEST_WASM === 'node') { + bundleAsync = (await import('../../wasm/wasm-node.mjs')).bundleAsync; +} else if (process.env.TEST_WASM === 'browser') { + let wasm = await import('../../wasm/index.mjs'); + await wasm.default(); + bundleAsync = function (options) { + if (!options.resolver?.read) { + options.resolver = { + ...options.resolver, + read: (filePath) => fs.readFileSync(filePath, 'utf8') + }; + } + + return wasm.bundleAsync(options); + } +} else { + bundleAsync = (await import('../index.mjs')).bundleAsync; +} + test('resolver', async () => { const inMemoryFs = new Map(Object.entries({ 'foo.css': ` diff --git a/node/test/composeVisitors.test.mjs b/node/test/composeVisitors.test.mjs index f656c53e..af5fd99c 100644 --- a/node/test/composeVisitors.test.mjs +++ b/node/test/composeVisitors.test.mjs @@ -2,7 +2,17 @@ import { test } from 'uvu'; import * as assert from 'uvu/assert'; -import { transform, composeVisitors } from '../index.mjs'; + +let transform, composeVisitors; +if (process.env.TEST_WASM === 'node') { + ({transform, composeVisitors} = await import('../../wasm/wasm-node.mjs')); +} else if (process.env.TEST_WASM === 'browser') { + let wasm = await import('../../wasm/index.mjs'); + await wasm.default(); + ({transform, composeVisitors} = wasm); +} else { + ({transform, composeVisitors} = await import('../index.mjs')); +} test('different types', () => { let res = transform({ diff --git a/node/test/customAtRules.mjs b/node/test/customAtRules.mjs index d0c4ea58..04f8b0ed 100644 --- a/node/test/customAtRules.mjs +++ b/node/test/customAtRules.mjs @@ -2,7 +2,26 @@ import { test } from 'uvu'; import * as assert from 'uvu/assert'; -import { bundle, transform } from '../index.mjs'; +import fs from 'fs'; + +let bundle, transform; +if (process.env.TEST_WASM === 'node') { + ({ bundle, transform } = await import('../../wasm/wasm-node.mjs')); +} else if (process.env.TEST_WASM === 'browser') { + let wasm = await import('../../wasm/index.mjs'); + await wasm.default(); + transform = wasm.transform; + bundle = function(options) { + return wasm.bundle({ + ...options, + resolver: { + read: (filePath) => fs.readFileSync(filePath, 'utf8') + } + }); + } +} else { + ({bundle, transform} = await import('../index.mjs')); +} test('declaration list', () => { let definitions = new Map(); diff --git a/node/test/transform.test.mjs b/node/test/transform.test.mjs index 2eb1d1ae..ef03d8e5 100644 --- a/node/test/transform.test.mjs +++ b/node/test/transform.test.mjs @@ -1,7 +1,17 @@ -import { transform, Features } from 'lightningcss'; import { test } from 'uvu'; import * as assert from 'uvu/assert'; +let transform, Features; +if (process.env.TEST_WASM === 'node') { + ({transform, Features} = await import('../../wasm/wasm-node.mjs')); +} else if (process.env.TEST_WASM === 'browser') { + let wasm = await import('../../wasm/index.mjs'); + await wasm.default(); + ({transform, Features} = wasm); +} else { + ({transform, Features} = await import('../index.mjs')); +} + test('can enable non-standard syntax', () => { let res = transform({ filename: 'test.css', @@ -56,3 +66,5 @@ test('can disable prefixing', () => { assert.equal(res.code.toString(), '.foo{user-select:none}'); }); + +test.run(); diff --git a/node/test/visitor.test.mjs b/node/test/visitor.test.mjs index 312e98eb..98148fbe 100644 --- a/node/test/visitor.test.mjs +++ b/node/test/visitor.test.mjs @@ -2,7 +2,37 @@ import { test } from 'uvu'; import * as assert from 'uvu/assert'; -import { bundle, bundleAsync, transform, transformStyleAttribute } from '../index.mjs'; +import fs from 'fs'; + +let bundle, bundleAsync, transform, transformStyleAttribute; +if (process.env.TEST_WASM === 'node') { + ({ bundle, bundleAsync, transform, transformStyleAttribute } = await import('../../wasm/wasm-node.mjs')); +} else if (process.env.TEST_WASM === 'browser') { + let wasm = await import('../../wasm/index.mjs'); + await wasm.default(); + ({ transform, transformStyleAttribute } = wasm); + bundle = function(options) { + return wasm.bundle({ + ...options, + resolver: { + read: (filePath) => fs.readFileSync(filePath, 'utf8') + } + }); + } + + bundleAsync = function (options) { + if (!options.resolver?.read) { + options.resolver = { + ...options.resolver, + read: (filePath) => fs.readFileSync(filePath, 'utf8') + }; + } + + return wasm.bundleAsync(options); + } +} else { + ({ bundle, bundleAsync, transform, transformStyleAttribute } = await import('../index.mjs')); +} test('px to rem', () => { // Similar to https://github.com/cuth/postcss-pxtorem diff --git a/package.json b/package.json index 1c28b43d..ded07b85 100644 --- a/package.json +++ b/package.json @@ -84,8 +84,8 @@ "build": "node scripts/build.js && node scripts/build-flow.js", "build-release": "node scripts/build.js --release && node scripts/build-flow.js", "prepublishOnly": "node scripts/build-flow.js", - "wasm:build": "cargo build --target wasm32-unknown-unknown -p lightningcss_node && cp target/wasm32-unknown-unknown/debug/lightningcss_node.wasm wasm/. && node scripts/build-wasm.js", - "wasm:build-release": "cargo build --target wasm32-unknown-unknown -p lightningcss_node --release && cp target/wasm32-unknown-unknown/release/lightningcss_node.wasm wasm/. && node scripts/build-wasm.js", + "wasm:build": "cargo build --target wasm32-unknown-unknown -p lightningcss_node && wasm-opt target/wasm32-unknown-unknown/debug/lightningcss_node.wasm --asyncify --pass-arg=asyncify-imports@env.await_promise_sync -Oz -o wasm/lightningcss_node.wasm && node scripts/build-wasm.js", + "wasm:build-release": "cargo build --target wasm32-unknown-unknown -p lightningcss_node --release && wasm-opt target/wasm32-unknown-unknown/release/lightningcss_node.wasm --asyncify --pass-arg=asyncify-imports@env.await_promise_sync -Oz -o wasm/lightningcss_node.wasm && node scripts/build-wasm.js", "website:start": "parcel 'website/*.html' website/playground/index.html", "website:build": "yarn wasm:build-release && parcel build 'website/*.html' website/playground/index.html", "build-ast": "cargo run --example schema --features jsonschema && node scripts/build-ast.js", diff --git a/scripts/build-wasm.js b/scripts/build-wasm.js index 63394471..90b85e5f 100644 --- a/scripts/build-wasm.js +++ b/scripts/build-wasm.js @@ -12,6 +12,10 @@ let flags = fs.readFileSync(`${dir}/node/flags.js`, 'utf8'); flags = flags.replace('exports.Features =', 'export const Features ='); fs.writeFileSync(`${dir}/wasm/flags.js`, flags); +let composeVisitors = fs.readFileSync(`${dir}/node/composeVisitors.js`, 'utf8'); +composeVisitors = composeVisitors.replace('module.exports = composeVisitors', 'export { composeVisitors }'); +fs.writeFileSync(`${dir}/wasm/composeVisitors.js`, composeVisitors); + let dts = fs.readFileSync(`${dir}/node/index.d.ts`, 'utf8'); dts = dts.replace(/: Buffer/g, ': Uint8Array'); dts += ` diff --git a/wasm/.gitignore b/wasm/.gitignore index b903f18d..ec95bb2d 100644 --- a/wasm/.gitignore +++ b/wasm/.gitignore @@ -4,3 +4,4 @@ package.json README.md browserslistToTargets.js flags.js +composeVisitors.js diff --git a/wasm/async.mjs b/wasm/async.mjs new file mode 100644 index 00000000..f1f4df03 --- /dev/null +++ b/wasm/async.mjs @@ -0,0 +1,74 @@ +let cur_await_promise_sync; +export function await_promise_sync(promise_addr, result_addr, error_addr) { + cur_await_promise_sync(promise_addr, result_addr, error_addr); +} + +const State = { + None: 0, + Unwinding: 1, + Rewinding: 2 +}; + +// This uses Binaryen's Asyncify transform to suspend native code execution while a promise is resolving. +// That allows synchronous Rust code to call async JavaScript functions without multi-threading. +// When Rust wants to await a promise, it calls await_promise_sync, which saves the stack state and unwinds. +// That causes the bundle function to return early. If a promise has been queued, we can then await it +// and "rewind" the function back to where it was before by calling it again. This time the result of +// the promise can be returned, and the function can continue where it left off. +// See the docs in https://github.com/WebAssembly/binaryen/blob/main/src/passes/Asyncify.cpp +// The code here is also partially based on https://github.com/GoogleChromeLabs/asyncify +export function createBundleAsync(env) { + let {instance, exports} = env; + let {asyncify_get_state, asyncify_start_unwind, asyncify_stop_unwind, asyncify_start_rewind, asyncify_stop_rewind} = instance.exports; + + // allocate __asyncify_data + // Stack data goes right after the initial descriptor. + let DATA_ADDR = instance.exports.napi_wasm_malloc(8 + 4096); + let DATA_START = DATA_ADDR + 8; + let DATA_END = DATA_ADDR + 8 + 4096; + new Int32Array(env.memory.buffer, DATA_ADDR).set([DATA_START, DATA_END]); + + function assertNoneState() { + if (asyncify_get_state() !== State.None) { + throw new Error(`Invalid async state ${asyncify_get_state()}, expected 0.`); + } + } + + let promise, result, error; + cur_await_promise_sync = (promise_addr, result_addr, error_addr) => { + let state = asyncify_get_state(); + if (state === State.Rewinding) { + asyncify_stop_rewind(); + if (result != null) { + env.createValue(result, result_addr); + } + if (error != null) { + env.createValue(error, error_addr); + } + promise = result = error = null; + return; + } + assertNoneState(); + promise = env.get(promise_addr); + asyncify_start_unwind(DATA_ADDR); + }; + + return async function bundleAsync(options) { + assertNoneState(); + let res = exports.bundle(options); + while (asyncify_get_state() === State.Unwinding) { + asyncify_stop_unwind(); + try { + result = await promise; + } catch (err) { + error = err; + } + assertNoneState(); + asyncify_start_rewind(DATA_ADDR); + res = exports.bundle(options); + } + + assertNoneState(); + return res; + }; +} diff --git a/wasm/index.mjs b/wasm/index.mjs index 3d8f0f03..0b049b50 100644 --- a/wasm/index.mjs +++ b/wasm/index.mjs @@ -1,6 +1,7 @@ import { Environment, napi } from 'napi-wasm'; +import { await_promise_sync, createBundleAsync } from './async.mjs'; -let wasm; +let wasm, bundleAsyncInternal; export default async function init(input) { input = input ?? new URL('lightningcss_node.wasm', import.meta.url); @@ -9,11 +10,15 @@ export default async function init(input) { } const { instance } = await load(await input, { - env: napi + env: { + ...napi, + await_promise_sync + } }); let env = new Environment(instance); wasm = env.exports; + bundleAsyncInternal = createBundleAsync(env); } export function transform(options) { @@ -24,8 +29,17 @@ export function transformStyleAttribute(options) { return wasm.transformStyleAttribute(options); } -export { browserslistToTargets } from './browserslistToTargets.js' -export { Features } from './flags.js' +export function bundle(options) { + return wasm.bundle(options); +} + +export function bundleAsync(options) { + return bundleAsyncInternal(options); +} + +export { browserslistToTargets } from './browserslistToTargets.js'; +export { Features } from './flags.js'; +export { composeVisitors } from './composeVisitors.js'; async function load(module, imports) { if (typeof Response === 'function' && module instanceof Response) { diff --git a/wasm/wasm-node.mjs b/wasm/wasm-node.mjs index a887c4c9..eda68767 100644 --- a/wasm/wasm-node.mjs +++ b/wasm/wasm-node.mjs @@ -1,13 +1,18 @@ import { Environment, napi } from 'napi-wasm'; +import { await_promise_sync, createBundleAsync } from './async.mjs'; import fs from 'fs'; let wasmBytes = fs.readFileSync(new URL('lightningcss_node.wasm', import.meta.url)); let wasmModule = new WebAssembly.Module(wasmBytes); let instance = new WebAssembly.Instance(wasmModule, { - env: napi + env: { + ...napi, + await_promise_sync + }, }); let env = new Environment(instance); let wasm = env.exports; +let bundleAsyncInternal = createBundleAsync(env); export default async function init() { // do nothing. for backward compatibility. @@ -21,5 +26,26 @@ export function transformStyleAttribute(options) { return wasm.transformStyleAttribute(options); } +export function bundle(options) { + return wasm.bundle({ + ...options, + resolver: { + read: (filePath) => fs.readFileSync(filePath, 'utf8') + } + }); +} + +export async function bundleAsync(options) { + if (!options.resolver?.read) { + options.resolver = { + ...options.resolver, + read: (filePath) => fs.readFileSync(filePath, 'utf8') + }; + } + + return bundleAsyncInternal(options); +} + export { browserslistToTargets } from './browserslistToTargets.js' export { Features } from './flags.js' +export { composeVisitors } from './composeVisitors.js';