Skip to content

Commit 0db4fd3

Browse files
authored
Support bundle and bundleAsync APIs in wasm builds (parcel-bundler#583)
1 parent d49fadb commit 0db4fd3

File tree

14 files changed

+387
-17
lines changed

14 files changed

+387
-17
lines changed

.github/workflows/release.yml

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -222,11 +222,9 @@ jobs:
222222
curl -L -O https://github.com/WebAssembly/binaryen/releases/download/version_111/binaryen-version_111-x86_64-linux.tar.gz
223223
tar -xf binaryen-version_111-x86_64-linux.tar.gz
224224
- name: Build wasm
225-
run: yarn wasm:build-release
226-
- name: Optimize wasm
227225
run: |
228-
./binaryen-version_111/bin/wasm-opt wasm/lightningcss_node.wasm -Oz -o wasm/lightningcss_node.opt.wasm
229-
mv wasm/lightningcss_node.opt.wasm wasm/lightningcss_node.wasm
226+
export PATH="$PATH:./binaryen-version_111/bin"
227+
yarn wasm:build-release
230228
- name: Upload artifacts
231229
uses: actions/upload-artifact@v3
232230
with:

.github/workflows/test.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ jobs:
1919
- uses: Swatinem/rust-cache@v2
2020
- run: cargo fmt
2121
- run: cargo test --all-features
22+
2223
test-js:
2324
runs-on: ubuntu-latest
2425
steps:
@@ -32,3 +33,28 @@ jobs:
3233
- run: yarn build
3334
- run: yarn test
3435
- run: yarn tsc
36+
37+
test-wasm:
38+
runs-on: ubuntu-latest
39+
steps:
40+
- uses: actions/checkout@v3
41+
- uses: actions/setup-node@v3
42+
with:
43+
node-version: 18
44+
- uses: bahmutov/npm-install@v1.8.32
45+
- uses: dtolnay/rust-toolchain@stable
46+
with:
47+
targets: wasm32-unknown-unknown
48+
- name: Setup rust target
49+
run: rustup target add wasm32-unknown-unknown
50+
- uses: Swatinem/rust-cache@v2
51+
- name: Install wasm-opt
52+
run: |
53+
curl -L -O https://github.com/WebAssembly/binaryen/releases/download/version_111/binaryen-version_111-x86_64-linux.tar.gz
54+
tar -xf binaryen-version_111-x86_64-linux.tar.gz
55+
- name: Build wasm
56+
run: |
57+
export PATH="$PATH:./binaryen-version_111/bin"
58+
yarn wasm:build-release
59+
- run: TEST_WASM=node yarn test
60+
- run: TEST_WASM=browser yarn test

node/src/lib.rs

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -420,14 +420,151 @@ mod bundle {
420420
}
421421
}
422422

423+
#[cfg(target_arch = "wasm32")]
424+
mod bundle {
425+
use super::*;
426+
use napi::{Env, JsFunction, JsString, NapiRaw, NapiValue, Ref};
427+
use std::cell::UnsafeCell;
428+
429+
#[js_function(1)]
430+
pub fn bundle(ctx: CallContext) -> napi::Result<JsUnknown> {
431+
use transformer::JsVisitor;
432+
433+
let opts = ctx.get::<JsObject>(0)?;
434+
let mut visitor = if let Ok(visitor) = opts.get_named_property::<JsObject>("visitor") {
435+
Some(JsVisitor::new(*ctx.env, visitor))
436+
} else {
437+
None
438+
};
439+
440+
let resolver = opts.get_named_property::<JsObject>("resolver")?;
441+
let read = resolver.get_named_property::<JsFunction>("read")?;
442+
let resolve = if resolver.has_named_property("resolve")? {
443+
let resolve = resolver.get_named_property::<JsFunction>("resolve")?;
444+
Some(ctx.env.create_reference(resolve)?)
445+
} else {
446+
None
447+
};
448+
let config: BundleConfig = ctx.env.from_js_value(opts)?;
449+
450+
let provider = JsSourceProvider {
451+
env: ctx.env.clone(),
452+
resolve,
453+
read: ctx.env.create_reference(read)?,
454+
inputs: UnsafeCell::new(Vec::new()),
455+
};
456+
457+
// This is pretty silly, but works around a rust limitation that you cannot
458+
// explicitly annotate lifetime bounds on closures.
459+
fn annotate<'i, 'o, F>(f: F) -> F
460+
where
461+
F: FnOnce(&mut StyleSheet<'i, 'o, AtRule<'i>>) -> napi::Result<()>,
462+
{
463+
f
464+
}
465+
466+
let res = compile_bundle(
467+
&provider,
468+
&config,
469+
visitor.as_mut().map(|visitor| annotate(|stylesheet| stylesheet.visit(visitor))),
470+
);
471+
472+
match res {
473+
Ok(res) => res.into_js(*ctx.env),
474+
Err(err) => Err(err.into_js_error(*ctx.env, None)?),
475+
}
476+
}
477+
478+
struct JsSourceProvider {
479+
env: Env,
480+
resolve: Option<Ref<()>>,
481+
read: Ref<()>,
482+
inputs: UnsafeCell<Vec<*mut String>>,
483+
}
484+
485+
impl Drop for JsSourceProvider {
486+
fn drop(&mut self) {
487+
if let Some(resolve) = &mut self.resolve {
488+
drop(resolve.unref(self.env));
489+
}
490+
drop(self.read.unref(self.env));
491+
}
492+
}
493+
494+
unsafe impl Sync for JsSourceProvider {}
495+
unsafe impl Send for JsSourceProvider {}
496+
497+
// This relies on Binaryen's Asyncify transform to allow Rust to call async JS functions from sync code.
498+
// See the comments in async.mjs for more details about how this works.
499+
extern "C" {
500+
fn await_promise_sync(
501+
promise: napi::sys::napi_value,
502+
result: *mut napi::sys::napi_value,
503+
error: *mut napi::sys::napi_value,
504+
);
505+
}
506+
507+
fn get_result(env: Env, mut value: JsUnknown) -> napi::Result<JsString> {
508+
if value.is_promise()? {
509+
let mut result = std::ptr::null_mut();
510+
let mut error = std::ptr::null_mut();
511+
unsafe { await_promise_sync(value.raw(), &mut result, &mut error) };
512+
if !error.is_null() {
513+
let error = unsafe { JsUnknown::from_raw(env.raw(), error)? };
514+
return Err(napi::Error::from(error));
515+
}
516+
if result.is_null() {
517+
return Err(napi::Error::new(napi::Status::GenericFailure, "No result".into()));
518+
}
519+
520+
value = unsafe { JsUnknown::from_raw(env.raw(), result)? };
521+
}
522+
523+
value.try_into()
524+
}
525+
526+
impl SourceProvider for JsSourceProvider {
527+
type Error = napi::Error;
528+
529+
fn read<'a>(&'a self, file: &Path) -> Result<&'a str, Self::Error> {
530+
let read: JsFunction = self.env.get_reference_value_unchecked(&self.read)?;
531+
let file = self.env.create_string(file.to_str().unwrap())?;
532+
let mut source: JsUnknown = read.call(None, &[file])?;
533+
let source = get_result(self.env, source)?.into_utf8()?.into_owned()?;
534+
535+
// cache the result
536+
let ptr = Box::into_raw(Box::new(source));
537+
let inputs = unsafe { &mut *self.inputs.get() };
538+
inputs.push(ptr);
539+
// SAFETY: this is safe because the pointer is not dropped
540+
// until the JsSourceProvider is, and we never remove from the
541+
// list of pointers stored in the vector.
542+
Ok(unsafe { &*ptr })
543+
}
544+
545+
fn resolve(&self, specifier: &str, originating_file: &Path) -> Result<PathBuf, Self::Error> {
546+
if let Some(resolve) = &self.resolve {
547+
let resolve: JsFunction = self.env.get_reference_value_unchecked(resolve)?;
548+
let specifier = self.env.create_string(specifier)?;
549+
let originating_file = self.env.create_string(originating_file.to_str().unwrap())?;
550+
let result: JsUnknown = resolve.call(None, &[specifier, originating_file])?;
551+
let result = get_result(self.env, result)?.into_utf8()?;
552+
Ok(PathBuf::from_str(result.as_str()?).unwrap())
553+
} else {
554+
Ok(originating_file.with_file_name(specifier))
555+
}
556+
}
557+
}
558+
}
559+
423560
#[cfg_attr(not(target_arch = "wasm32"), module_exports)]
424561
fn init(mut exports: JsObject) -> napi::Result<()> {
425562
exports.create_named_method("transform", transform)?;
426563
exports.create_named_method("transformStyleAttribute", transform_style_attribute)?;
564+
exports.create_named_method("bundle", bundle::bundle)?;
427565

428566
#[cfg(not(target_arch = "wasm32"))]
429567
{
430-
exports.create_named_method("bundle", bundle::bundle)?;
431568
exports.create_named_method("bundleAsync", bundle::bundle_async)?;
432569
}
433570

node/test/bundle.test.mjs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,28 @@
11
import path from 'path';
22
import fs from 'fs';
3-
import { bundleAsync } from '../index.mjs';
43
import { test } from 'uvu';
54
import * as assert from 'uvu/assert';
65

6+
let bundleAsync;
7+
if (process.env.TEST_WASM === 'node') {
8+
bundleAsync = (await import('../../wasm/wasm-node.mjs')).bundleAsync;
9+
} else if (process.env.TEST_WASM === 'browser') {
10+
let wasm = await import('../../wasm/index.mjs');
11+
await wasm.default();
12+
bundleAsync = function (options) {
13+
if (!options.resolver?.read) {
14+
options.resolver = {
15+
...options.resolver,
16+
read: (filePath) => fs.readFileSync(filePath, 'utf8')
17+
};
18+
}
19+
20+
return wasm.bundleAsync(options);
21+
}
22+
} else {
23+
bundleAsync = (await import('../index.mjs')).bundleAsync;
24+
}
25+
726
test('resolver', async () => {
827
const inMemoryFs = new Map(Object.entries({
928
'foo.css': `

node/test/composeVisitors.test.mjs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,17 @@
22

33
import { test } from 'uvu';
44
import * as assert from 'uvu/assert';
5-
import { transform, composeVisitors } from '../index.mjs';
5+
6+
let transform, composeVisitors;
7+
if (process.env.TEST_WASM === 'node') {
8+
({transform, composeVisitors} = await import('../../wasm/wasm-node.mjs'));
9+
} else if (process.env.TEST_WASM === 'browser') {
10+
let wasm = await import('../../wasm/index.mjs');
11+
await wasm.default();
12+
({transform, composeVisitors} = wasm);
13+
} else {
14+
({transform, composeVisitors} = await import('../index.mjs'));
15+
}
616

717
test('different types', () => {
818
let res = transform({

node/test/customAtRules.mjs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,26 @@
22

33
import { test } from 'uvu';
44
import * as assert from 'uvu/assert';
5-
import { bundle, transform } from '../index.mjs';
5+
import fs from 'fs';
6+
7+
let bundle, transform;
8+
if (process.env.TEST_WASM === 'node') {
9+
({ bundle, transform } = await import('../../wasm/wasm-node.mjs'));
10+
} else if (process.env.TEST_WASM === 'browser') {
11+
let wasm = await import('../../wasm/index.mjs');
12+
await wasm.default();
13+
transform = wasm.transform;
14+
bundle = function(options) {
15+
return wasm.bundle({
16+
...options,
17+
resolver: {
18+
read: (filePath) => fs.readFileSync(filePath, 'utf8')
19+
}
20+
});
21+
}
22+
} else {
23+
({bundle, transform} = await import('../index.mjs'));
24+
}
625

726
test('declaration list', () => {
827
let definitions = new Map();

node/test/transform.test.mjs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
1-
import { transform, Features } from 'lightningcss';
21
import { test } from 'uvu';
32
import * as assert from 'uvu/assert';
43

4+
let transform, Features;
5+
if (process.env.TEST_WASM === 'node') {
6+
({transform, Features} = await import('../../wasm/wasm-node.mjs'));
7+
} else if (process.env.TEST_WASM === 'browser') {
8+
let wasm = await import('../../wasm/index.mjs');
9+
await wasm.default();
10+
({transform, Features} = wasm);
11+
} else {
12+
({transform, Features} = await import('../index.mjs'));
13+
}
14+
515
test('can enable non-standard syntax', () => {
616
let res = transform({
717
filename: 'test.css',
@@ -56,3 +66,5 @@ test('can disable prefixing', () => {
5666

5767
assert.equal(res.code.toString(), '.foo{user-select:none}');
5868
});
69+
70+
test.run();

node/test/visitor.test.mjs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,37 @@
22

33
import { test } from 'uvu';
44
import * as assert from 'uvu/assert';
5-
import { bundle, bundleAsync, transform, transformStyleAttribute } from '../index.mjs';
5+
import fs from 'fs';
6+
7+
let bundle, bundleAsync, transform, transformStyleAttribute;
8+
if (process.env.TEST_WASM === 'node') {
9+
({ bundle, bundleAsync, transform, transformStyleAttribute } = await import('../../wasm/wasm-node.mjs'));
10+
} else if (process.env.TEST_WASM === 'browser') {
11+
let wasm = await import('../../wasm/index.mjs');
12+
await wasm.default();
13+
({ transform, transformStyleAttribute } = wasm);
14+
bundle = function(options) {
15+
return wasm.bundle({
16+
...options,
17+
resolver: {
18+
read: (filePath) => fs.readFileSync(filePath, 'utf8')
19+
}
20+
});
21+
}
22+
23+
bundleAsync = function (options) {
24+
if (!options.resolver?.read) {
25+
options.resolver = {
26+
...options.resolver,
27+
read: (filePath) => fs.readFileSync(filePath, 'utf8')
28+
};
29+
}
30+
31+
return wasm.bundleAsync(options);
32+
}
33+
} else {
34+
({ bundle, bundleAsync, transform, transformStyleAttribute } = await import('../index.mjs'));
35+
}
636

737
test('px to rem', () => {
838
// Similar to https://github.com/cuth/postcss-pxtorem

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,8 @@
8484
"build": "node scripts/build.js && node scripts/build-flow.js",
8585
"build-release": "node scripts/build.js --release && node scripts/build-flow.js",
8686
"prepublishOnly": "node scripts/build-flow.js",
87-
"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",
88-
"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",
87+
"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",
88+
"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",
8989
"website:start": "parcel 'website/*.html' website/playground/index.html",
9090
"website:build": "yarn wasm:build-release && parcel build 'website/*.html' website/playground/index.html",
9191
"build-ast": "cargo run --example schema --features jsonschema && node scripts/build-ast.js",

scripts/build-wasm.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ let flags = fs.readFileSync(`${dir}/node/flags.js`, 'utf8');
1212
flags = flags.replace('exports.Features =', 'export const Features =');
1313
fs.writeFileSync(`${dir}/wasm/flags.js`, flags);
1414

15+
let composeVisitors = fs.readFileSync(`${dir}/node/composeVisitors.js`, 'utf8');
16+
composeVisitors = composeVisitors.replace('module.exports = composeVisitors', 'export { composeVisitors }');
17+
fs.writeFileSync(`${dir}/wasm/composeVisitors.js`, composeVisitors);
18+
1519
let dts = fs.readFileSync(`${dir}/node/index.d.ts`, 'utf8');
1620
dts = dts.replace(/: Buffer/g, ': Uint8Array');
1721
dts += `

0 commit comments

Comments
 (0)