Skip to content

Commit

Permalink
Support bundle and bundleAsync APIs in wasm builds (#583)
Browse files Browse the repository at this point in the history
  • Loading branch information
devongovett committed Sep 11, 2023
1 parent d49fadb commit 0db4fd3
Show file tree
Hide file tree
Showing 14 changed files with 387 additions and 17 deletions.
6 changes: 2 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
26 changes: 26 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
139 changes: 138 additions & 1 deletion node/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<JsUnknown> {
use transformer::JsVisitor;

let opts = ctx.get::<JsObject>(0)?;
let mut visitor = if let Ok(visitor) = opts.get_named_property::<JsObject>("visitor") {
Some(JsVisitor::new(*ctx.env, visitor))
} else {
None
};

let resolver = opts.get_named_property::<JsObject>("resolver")?;
let read = resolver.get_named_property::<JsFunction>("read")?;
let resolve = if resolver.has_named_property("resolve")? {
let resolve = resolver.get_named_property::<JsFunction>("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<Ref<()>>,
read: Ref<()>,
inputs: UnsafeCell<Vec<*mut String>>,
}

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<JsString> {
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<PathBuf, Self::Error> {
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)?;
}

Expand Down
21 changes: 20 additions & 1 deletion node/test/bundle.test.mjs
Original file line number Diff line number Diff line change
@@ -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': `
Expand Down
12 changes: 11 additions & 1 deletion node/test/composeVisitors.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
21 changes: 20 additions & 1 deletion node/test/customAtRules.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
14 changes: 13 additions & 1 deletion node/test/transform.test.mjs
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -56,3 +66,5 @@ test('can disable prefixing', () => {

assert.equal(res.code.toString(), '.foo{user-select:none}');
});

test.run();
32 changes: 31 additions & 1 deletion node/test/visitor.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions scripts/build-wasm.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 += `
Expand Down
1 change: 1 addition & 0 deletions wasm/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ package.json
README.md
browserslistToTargets.js
flags.js
composeVisitors.js
Loading

0 comments on commit 0db4fd3

Please sign in to comment.