Skip to content

Commit 5acc6ec

Browse files
committed
fix(wasm): transfer AST to JS as JSON string (#9259)
In [@oxc-parser/wasm](https://www.npmjs.com/package/@oxc-parser/wasm) NPM package, transfer the AST to JS as a JSON string, and parse it to a `Program` object on JS side. This fixes a couple of bugs - previously `RegExp`s and `BigInt`s were deserialized as `null`. The mechanism for doing this is pretty hacky. After `wasm-pack` generates the JS binding files, a script modifies these files to add a getter for `program` property on `ParseResult`. That getter deserializes the JSON to a `Program`. The hackyness is not ideal, but I'm not familiar enough with WASM build systems to see if there's a better way. This does at least seem to work!
1 parent 464762e commit 5acc6ec

6 files changed

Lines changed: 87 additions & 9 deletions

File tree

napi/parser/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ function wrap(result) {
1313
return {
1414
get program() {
1515
if (!program) {
16+
// Note: This code is repeated in `wasm/parser/update-bindings.mjs`.
17+
// Any changes should be applied in both places.
1618
program = JSON.parse(result.program, function(key, value) {
1719
// Set `value` field of `Literal`s for `BigInt`s and `RegExp`s.
1820
// This is not possible to do on Rust side, as neither can be represented correctly in JSON.

npm/parser-wasm/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@oxc-parser/wasm",
3-
"version": "0.38.0",
3+
"version": "0.51.0",
44
"description": "Wasm target for the oxc parser.",
55
"keywords": [
66
"JavaScript",
@@ -29,10 +29,11 @@
2929
"@oxc-project/types": "workspace:^"
3030
},
3131
"scripts": {
32-
"build": "pnpm run build-node && pnpm run build-web && pnpm run copy-files && pnpm run clean-files",
32+
"build": "pnpm run build-node && pnpm run build-web && pnpm run update-bindings && pnpm run copy-files && pnpm run clean-files",
3333
"build-node": "pnpm run build-base --target nodejs --out-dir ../../npm/parser-wasm/node .",
3434
"build-web": "pnpm run build-base --target web --out-dir ../../npm/parser-wasm/web .",
3535
"build-base": "wasm-pack build --release --no-pack",
36+
"update-bindings": "node ./update-bindings.mjs",
3637
"copy-files": "cp ./package.json ../../npm/parser-wasm/package.json && cp ./README.md ../../npm/parser-wasm/README.md",
3738
"clean-files": "rm ../../npm/parser-wasm/*/.gitignore",
3839
"test": "node ./test-node.mjs",

wasm/parser/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,11 @@
2929
"@oxc-project/types": "workspace:^"
3030
},
3131
"scripts": {
32-
"build": "pnpm run build-node && pnpm run build-web && pnpm run copy-files && pnpm run clean-files",
32+
"build": "pnpm run build-node && pnpm run build-web && pnpm run update-bindings && pnpm run copy-files && pnpm run clean-files",
3333
"build-node": "pnpm run build-base --target nodejs --out-dir ../../npm/parser-wasm/node .",
3434
"build-web": "pnpm run build-base --target web --out-dir ../../npm/parser-wasm/web .",
3535
"build-base": "wasm-pack build --release --no-pack",
36+
"update-bindings": "node ./update-bindings.mjs",
3637
"copy-files": "cp ./package.json ../../npm/parser-wasm/package.json && cp ./README.md ../../npm/parser-wasm/README.md",
3738
"clean-files": "rm ../../npm/parser-wasm/*/.gitignore",
3839
"test": "node ./test-node.mjs",

wasm/parser/src/lib.rs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,15 @@ pub struct ParserOptions {
2626
#[derive(Default, Tsify)]
2727
#[wasm_bindgen(getter_with_clone)]
2828
pub struct ParseResult {
29-
#[wasm_bindgen(readonly, skip_typescript)]
29+
// Dummy field, only present to make `tsify` include it in the type definition for `ParseResult`.
30+
// The getter for this field in WASM bindings is generated by `update-bindings.mjs` script.
31+
#[wasm_bindgen(skip)]
3032
#[tsify(type = "Program")]
31-
pub program: JsValue,
33+
pub program: (),
34+
35+
#[wasm_bindgen(readonly, skip_typescript, js_name = programJson)]
36+
#[serde(rename = "programJson")]
37+
pub program_json: String,
3238

3339
#[wasm_bindgen(readonly, skip_typescript)]
3440
#[tsify(type = "Comment[]")]
@@ -95,7 +101,7 @@ pub fn parse_sync(
95101

96102
let serializer = serde_wasm_bindgen::Serializer::json_compatible();
97103

98-
let program = ret.program.serialize(&serializer)?;
104+
let program_json = ret.program.to_json();
99105

100106
let comments: Vec<JsValue> = if ret.program.comments.is_empty() {
101107
vec![]
@@ -143,5 +149,5 @@ pub fn parse_sync(
143149
.collect::<Vec<JsValue>>()
144150
};
145151

146-
Ok(ParseResult { program, comments, errors })
152+
Ok(ParseResult { program: (), program_json, comments, errors })
147153
}

wasm/parser/test-node.mjs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
11
import assert from 'assert';
22
import { parseSync } from '../../npm/parser-wasm/node/oxc_parser_wasm.js';
33

4-
const code = 'let foo';
4+
const code = '/abc/gu; 123n;';
55
const result = parseSync(code, { sourceFilename: 'test.ts' });
6+
67
assert(result.errors.length === 0);
7-
assert(result.program.body.length === 1);
8+
9+
// Check `program` getter caches result
10+
const program = result.program;
11+
assert(result.program === program);
12+
13+
// Check output is correct
14+
assert(program.type === 'Program');
15+
assert(program.body.length === 2);
16+
17+
// Check `RegExp`s and `BigInt`s are deserialized correctly
18+
assert(program.body[0].expression.value instanceof RegExp);
19+
assert(typeof program.body[1].expression.value === 'bigint');

wasm/parser/update-bindings.mjs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Script to inject code for an extra `program` getter on `class ParseResult` in WASM binding files.
2+
3+
import assert from 'assert';
4+
import { readFileSync, writeFileSync } from 'fs';
5+
import { join as pathJoin } from 'path';
6+
import { fileURLToPath } from 'url';
7+
8+
const pkgDirPath = pathJoin(fileURLToPath(import.meta.url), '../../../npm/parser-wasm');
9+
10+
const bindingFilename = 'oxc_parser_wasm.js';
11+
12+
// Extra getter on `ParseResult` `get program() { ... }` that gets the program as JSON string,
13+
// and parses it to a `Program` object.
14+
//
15+
// JSON parsing uses a reviver function that sets `value` field of `Literal`s for `BigInt`s and `RegExp`s.
16+
// This is not possible to do on Rust side, as neither can be represented correctly in JSON.
17+
// Invalid regexp, or valid regexp using syntax not supported by the platform is ignored.
18+
//
19+
// The getter caches the result to avoid re-parsing JSON every time `result.program` is accessed.
20+
//
21+
// Note: This code is repeated in `napi/parser/index.js`.
22+
// Any changes should be applied in both places.
23+
const getterCode = `
24+
__program;
25+
26+
get program() {
27+
if (this.__program) return this.__program;
28+
return this.__program = JSON.parse(this.programJson, function(key, value) {
29+
if (value === null && key === 'value' && Object.hasOwn(this, 'type') && this.type === 'Literal') {
30+
if (Object.hasOwn(this, 'bigint')) {
31+
return BigInt(this.bigint);
32+
}
33+
if (Object.hasOwn(this, 'regex')) {
34+
const { regex } = this;
35+
try {
36+
return RegExp(regex.pattern, regex.flags);
37+
} catch (_err) {}
38+
}
39+
}
40+
return value;
41+
});
42+
}
43+
`.trimEnd().replace(/ /g, ' ');
44+
45+
const insertGetterAfter = 'class ParseResult {';
46+
47+
for (const dirName of ['node', 'web']) {
48+
const path = pathJoin(pkgDirPath, dirName, bindingFilename);
49+
const code = readFileSync(path, 'utf8');
50+
51+
const parts = code.split(insertGetterAfter);
52+
assert(parts.length === 2);
53+
const [before, after] = parts;
54+
const updatedCode = [before, insertGetterAfter, getterCode, after].join('');
55+
writeFileSync(path, updatedCode);
56+
}

0 commit comments

Comments
 (0)