Skip to content

Commit a4cbaf7

Browse files
committed
feat: dual-wasm build with runtime SIMD fallback
Some users on virtualized x86 hosts (e.g. QEMU CPUs without SSE4.1) hit `CompileError: Wasm SIMD unsupported` at module load time, breaking any consumer (notably Baileys 7.0.0-rc10). Build now produces two wasms — `assets/wasm/simd.wasm` and `assets/wasm/nosimd.wasm` — both inlined into `dist/index.js`. At init time we probe SIMD via `WebAssembly.validate` and fall back to the non-SIMD module on unsupported engines (with a try/catch around `initSync` as a safety net). `WHATSAPP_RUST_BRIDGE_FORCE_NOSIMD=1` forces the non-SIMD path for testing. Empirical tests run both paths in subprocesses and assert byte-identical encode output between them. Also cleaned up the npm tarball: dropped 4 MB of stale build artifacts that the previous `dist/**/*` glob was leaking (old `_bg.wasm`, stale `.d.ts` files referencing removed APIs, leaked macro types). Tarball went from 2.4 MB / 6.97 MB unpacked to 837 KB / 2.04 MB unpacked.
1 parent d503cf7 commit a4cbaf7

9 files changed

Lines changed: 303 additions & 14 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
dist
33
node_modules
44
pkg
5+
assets/wasm
56
*.tgz
67
*.xml
78
docs

package.json

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
},
1111
"type": "module",
1212
"files": [
13-
"dist/**/*",
13+
"dist/index.js",
14+
"dist/index.d.ts",
1415
"pkg/whatsapp_rust_bridge.d.ts"
1516
],
1617
"exports": {
@@ -22,10 +23,11 @@
2223
"scripts": {
2324
"bench": "bun run build && bun run benches/binary.ts && bun run benches/signal.ts && bun run benches/curve.ts && bun run benches/crypto.ts",
2425
"bench:node": "bun run build && node --expose-gc benches/binary.ts && node --expose-gc benches/signal.ts && node --expose-gc benches/curve.ts && node --expose-gc benches/crypto.ts",
25-
"build:wasm": "wasm-pack build --target web --out-dir pkg --no-pack",
26+
"build:wasm": "node scripts/build-wasm.mjs",
2627
"build:ts": "bun build ts/index.ts --outfile dist/index.js",
27-
"postbuild": "tsc -p tsconfig.json --outDir dist && rm -f dist/macro.d.ts",
28-
"build": "bun run build:wasm && bun run build:ts && bun run postbuild",
28+
"prebuild": "rm -rf dist",
29+
"postbuild": "tsc -p tsconfig.json --outDir dist",
30+
"build": "bun run prebuild && bun run build:wasm && bun run build:ts && bun run postbuild",
2931
"prepublishOnly": "bun run build"
3032
},
3133
"devDependencies": {

scripts/build-wasm.mjs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
#!/usr/bin/env node
2+
import { spawnSync } from "node:child_process";
3+
import { copyFileSync, existsSync, mkdirSync, statSync } from "node:fs";
4+
import { dirname, resolve } from "node:path";
5+
import { fileURLToPath } from "node:url";
6+
7+
const __dirname = dirname(fileURLToPath(import.meta.url));
8+
const root = resolve(__dirname, "..");
9+
const pkgWasm = resolve(root, "pkg/whatsapp_rust_bridge_bg.wasm");
10+
const outDir = resolve(root, "assets/wasm");
11+
12+
const wasmOptFlags = [
13+
"-O4",
14+
"--gufa-optimizing",
15+
"--inlining-optimizing",
16+
"--ignore-implicit-traps",
17+
"--traps-never-happen",
18+
"--coalesce-locals-learning",
19+
"--converge",
20+
"--enable-bulk-memory",
21+
"--enable-nontrapping-float-to-int",
22+
"--enable-sign-ext",
23+
"--enable-mutable-globals",
24+
"--enable-multivalue",
25+
"--fast-math",
26+
"--zero-filled-memory",
27+
"--dce",
28+
"--vacuum",
29+
"--directize",
30+
"--optimize-stack-ir",
31+
"--strip-debug",
32+
];
33+
34+
function run(cmd, args, env = {}) {
35+
console.log(`\n$ ${cmd} ${args.join(" ")}`);
36+
const r = spawnSync(cmd, args, {
37+
cwd: root,
38+
stdio: "inherit",
39+
env: { ...process.env, ...env },
40+
});
41+
if (r.status !== 0) {
42+
process.exit(r.status ?? 1);
43+
}
44+
}
45+
46+
function build(variant) {
47+
const isSimd = variant === "simd";
48+
const rustflags = isSimd
49+
? "-C target-feature=+simd128"
50+
: "-C target-feature=-simd128";
51+
52+
console.log(`\n=== Building ${variant} ===`);
53+
run(
54+
"wasm-pack",
55+
["build", "--target", "web", "--out-dir", "pkg", "--no-pack", "--no-opt"],
56+
{ RUSTFLAGS: rustflags },
57+
);
58+
59+
const outFile = resolve(outDir, `${variant}.wasm`);
60+
const optFlags = [
61+
...wasmOptFlags,
62+
isSimd ? "--enable-simd" : "--disable-simd",
63+
pkgWasm,
64+
"-o",
65+
outFile,
66+
];
67+
run("wasm-opt", optFlags);
68+
69+
const size = statSync(outFile).size;
70+
console.log(` → ${outFile} (${(size / 1024).toFixed(1)} KB)`);
71+
}
72+
73+
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
74+
75+
build("simd");
76+
build("nosimd");
77+
78+
// Make pkg/whatsapp_rust_bridge_bg.wasm point to the SIMD variant for the
79+
// wasm-bindgen JS wrapper's default URL resolution (rebuild simd last so
80+
// pkg ends in a clean state for `wasm-pack publish`-style consumers).
81+
copyFileSync(resolve(outDir, "simd.wasm"), pkgWasm);
82+
83+
console.log("\nDual wasm build complete.");

test/helpers/probe-encode-bytes.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import {
2+
__wasmSimdActive,
3+
decodeNode,
4+
encodeNode,
5+
type BinaryNode,
6+
} from "../../dist/index.js";
7+
8+
const node: BinaryNode = {
9+
tag: "message",
10+
attrs: {
11+
to: "5511999999999@s.whatsapp.net",
12+
from: "5511888888888-1@g.us",
13+
id: "ABCDEF1234567890",
14+
type: "text",
15+
t: "1700000000",
16+
},
17+
content: [
18+
{
19+
tag: "conversation",
20+
attrs: {},
21+
content: "Hello, mundo! Acentos: ção, ã, é, ü.",
22+
},
23+
{
24+
tag: "raw",
25+
attrs: { kind: "binary" },
26+
content: new Uint8Array([0xff, 0x00, 0x7f, 0x80, 0x12, 0x34, 0x56, 0x78]),
27+
},
28+
],
29+
};
30+
31+
const enc = encodeNode(node);
32+
const dec = decodeNode(enc);
33+
34+
const hex = Array.from(enc)
35+
.map((b) => b.toString(16).padStart(2, "0"))
36+
.join("");
37+
38+
// Stable JSON of decoded structure (Uint8Array → hex for deterministic output).
39+
function normalize(n: BinaryNode): unknown {
40+
return {
41+
tag: n.tag,
42+
attrs: n.attrs,
43+
content:
44+
typeof n.content === "string"
45+
? { kind: "string", value: n.content }
46+
: n.content instanceof Uint8Array
47+
? {
48+
kind: "bytes",
49+
value: Array.from(n.content)
50+
.map((b) => b.toString(16).padStart(2, "0"))
51+
.join(""),
52+
}
53+
: Array.isArray(n.content)
54+
? { kind: "children", value: n.content.map(normalize) }
55+
: { kind: "none" },
56+
};
57+
}
58+
59+
console.log(
60+
JSON.stringify({
61+
simd: __wasmSimdActive,
62+
encHex: hex,
63+
decoded: normalize(dec),
64+
}),
65+
);

test/helpers/probe-runtime.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import {
2+
__wasmSimdActive,
3+
decodeNode,
4+
encodeNode,
5+
hkdf,
6+
md5,
7+
type BinaryNode,
8+
} from "../../dist/index.js";
9+
10+
const node: BinaryNode = {
11+
tag: "iq",
12+
attrs: { id: "abc-123", to: "s.whatsapp.net", type: "get" },
13+
content: [
14+
{ tag: "ping", attrs: {}, content: undefined },
15+
{ tag: "data", attrs: { fmt: "raw" }, content: new Uint8Array([1, 2, 3, 4, 5]) },
16+
],
17+
};
18+
19+
const encoded = encodeNode(node);
20+
const decoded = decodeNode(encoded);
21+
22+
const decodedAttrsOk =
23+
decoded.tag === "iq" &&
24+
decoded.attrs.id === "abc-123" &&
25+
decoded.attrs.to === "s.whatsapp.net" &&
26+
decoded.attrs.type === "get";
27+
28+
const md5Result = md5(new Uint8Array([0x61, 0x62, 0x63])); // "abc" → 900150983cd24fb0d6963f7d28e17f72
29+
const md5Hex = Array.from(md5Result).map((b) => b.toString(16).padStart(2, "0")).join("");
30+
31+
const hkdfResult = hkdf(new Uint8Array([1, 2, 3]), 32, new Uint8Array(0));
32+
33+
console.log(
34+
JSON.stringify({
35+
simdActive: __wasmSimdActive,
36+
encodedLen: encoded.length,
37+
decodedTag: decoded.tag,
38+
decodedAttrsOk,
39+
md5Hex,
40+
hkdfLen: hkdfResult.length,
41+
}),
42+
);

test/simd-fallback.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { spawnSync } from "node:child_process";
3+
import { resolve } from "node:path";
4+
5+
const probe = resolve(import.meta.dir, "helpers/probe-runtime.ts");
6+
7+
function runProbe(extraEnv: Record<string, string> = {}) {
8+
const r = spawnSync("bun", ["run", probe], {
9+
env: { ...process.env, ...extraEnv },
10+
encoding: "utf8",
11+
});
12+
if (r.status !== 0) {
13+
throw new Error(
14+
`probe exited with ${r.status}\nstdout: ${r.stdout}\nstderr: ${r.stderr}`,
15+
);
16+
}
17+
const lines = r.stdout.trim().split("\n");
18+
return JSON.parse(lines[lines.length - 1]);
19+
}
20+
21+
describe("SIMD / non-SIMD initialization paths", () => {
22+
test("default path uses SIMD wasm and exposes working bridge", () => {
23+
const out = runProbe();
24+
expect(out.simdActive).toBe(true);
25+
expect(out.encodedLen).toBeGreaterThan(0);
26+
expect(out.decodedTag).toBe("iq");
27+
expect(out.decodedAttrsOk).toBe(true);
28+
expect(out.md5Hex).toBe("900150983cd24fb0d6963f7d28e17f72");
29+
expect(out.hkdfLen).toBe(32);
30+
});
31+
32+
test("forced non-SIMD path falls back to nosimd wasm with identical output", () => {
33+
const out = runProbe({ WHATSAPP_RUST_BRIDGE_FORCE_NOSIMD: "1" });
34+
expect(out.simdActive).toBe(false);
35+
expect(out.encodedLen).toBeGreaterThan(0);
36+
expect(out.decodedTag).toBe("iq");
37+
expect(out.decodedAttrsOk).toBe(true);
38+
expect(out.md5Hex).toBe("900150983cd24fb0d6963f7d28e17f72");
39+
expect(out.hkdfLen).toBe(32);
40+
});
41+
42+
test("SIMD and non-SIMD paths produce byte-identical encode output", () => {
43+
const probeBytes = resolve(import.meta.dir, "helpers/probe-encode-bytes.ts");
44+
const simd = spawnSync("bun", ["run", probeBytes], {
45+
env: { ...process.env },
46+
encoding: "utf8",
47+
});
48+
const noSimd = spawnSync("bun", ["run", probeBytes], {
49+
env: { ...process.env, WHATSAPP_RUST_BRIDGE_FORCE_NOSIMD: "1" },
50+
encoding: "utf8",
51+
});
52+
expect(simd.status).toBe(0);
53+
expect(noSimd.status).toBe(0);
54+
55+
const simdLines = simd.stdout.trim().split("\n");
56+
const noSimdLines = noSimd.stdout.trim().split("\n");
57+
const simdOut = JSON.parse(simdLines[simdLines.length - 1]);
58+
const noSimdOut = JSON.parse(noSimdLines[noSimdLines.length - 1]);
59+
expect(simdOut.simd).toBe(true);
60+
expect(noSimdOut.simd).toBe(false);
61+
// Byte-identical wire output and structurally identical decoded form.
62+
expect(simdOut.encHex).toBe(noSimdOut.encHex);
63+
expect(simdOut.decoded).toEqual(noSimdOut.decoded);
64+
});
65+
});

ts/index.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
import { initSync } from "../pkg/whatsapp_rust_bridge.js";
22

3-
import { base64Wasm } from "./macro.js" with { type: "macro" };
3+
import {
4+
nosimdWasmBase64,
5+
simdWasmBase64,
6+
} from "./macro.js" with { type: "macro" };
7+
8+
// Minimal WASM module that uses i8x16.splat + i8x16.popcnt. WebAssembly.validate
9+
// returns false on engines without SIMD (e.g. V8 on x86 without SSE4.1).
10+
const SIMD_PROBE = new Uint8Array([
11+
0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 96, 0, 1, 123, 3, 2, 1, 0, 10, 10, 1,
12+
8, 0, 65, 0, 253, 15, 253, 98, 11,
13+
]);
414

515
function base64ToUint8Array(base64: string): Uint8Array {
616
const binaryString = atob(base64);
@@ -11,8 +21,27 @@ function base64ToUint8Array(base64: string): Uint8Array {
1121
return bytes;
1222
}
1323

14-
const bytes = base64ToUint8Array(base64Wasm());
24+
function tryInit(b64: string): boolean {
25+
try {
26+
initSync({ module: base64ToUint8Array(b64) });
27+
return true;
28+
} catch {
29+
return false;
30+
}
31+
}
32+
33+
const forceNoSimd =
34+
typeof process !== "undefined" &&
35+
process.env?.WHATSAPP_RUST_BRIDGE_FORCE_NOSIMD === "1";
36+
37+
const simdSupported = !forceNoSimd && WebAssembly.validate(SIMD_PROBE);
1538

16-
initSync({ module: bytes });
39+
let simdUsed = false;
40+
if (simdSupported && tryInit(simdWasmBase64())) {
41+
simdUsed = true;
42+
} else {
43+
initSync({ module: base64ToUint8Array(nosimdWasmBase64()) });
44+
}
1745

46+
export const __wasmSimdActive: boolean = simdUsed;
1847
export * from "../pkg/whatsapp_rust_bridge.js";

ts/macro.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import {readFileSync} from "fs";
1+
import { readFileSync } from "fs";
22

3-
export const base64Wasm = () => {
4-
const bytes = readFileSync("./pkg/whatsapp_rust_bridge_bg.wasm")
5-
6-
const base64 = bytes.toBase64();
3+
export const simdWasmBase64 = () => {
4+
return readFileSync("./assets/wasm/simd.wasm").toBase64();
5+
};
76

8-
return base64;
7+
export const nosimdWasmBase64 = () => {
8+
return readFileSync("./assets/wasm/nosimd.wasm").toBase64();
99
};

tsconfig.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88
"esModuleInterop": true,
99
"skipLibCheck": true,
1010
"forceConsistentCasingInFileNames": true,
11+
"rootDir": "./ts",
1112
/* These two options are crucial for the postbuild step */
1213
"declaration": true,
1314
"emitDeclarationOnly": true
1415
},
15-
"include": ["ts/**/*.ts"]
16+
"include": ["ts/**/*.ts"],
17+
"exclude": ["ts/macro.ts"]
1618
}

0 commit comments

Comments
 (0)