Skip to content

Commit 0e45852

Browse files
committed
feat: implement decodeNode function to convert WhatsApp binary format to BinaryNode object using packed LNP buffer
1 parent d1dc86e commit 0e45852

2 files changed

Lines changed: 197 additions & 0 deletions

File tree

src/binary.rs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,7 @@ impl InternalBinaryNode {
341341
thread_local! {
342342
static ENCODE_BUF: UnsafeCell<Vec<u8>> = const { UnsafeCell::new(Vec::new()) };
343343
static INPUT_BUF: UnsafeCell<Vec<u8>> = const { UnsafeCell::new(Vec::new()) };
344+
static DECODE_BUF: UnsafeCell<Vec<u8>> = const { UnsafeCell::new(Vec::new()) };
344345
}
345346

346347
// ── Zero-alloc result passing ───────────────────────────────────────────
@@ -486,3 +487,89 @@ pub fn decode_node(data: Vec<u8>) -> Result<InternalBinaryNode, JsValue> {
486487
cached_content: UnsafeCell::new(None),
487488
})
488489
}
490+
491+
// ── Packed decode: NodeRef → LNP buffer ─────────────────────────────────
492+
//
493+
// Reverse of parse_packed_node: serializes a NodeRef into the same packed
494+
// binary format that JS uses for encode input. JS reads this from WASM memory
495+
// and constructs plain { tag, attrs, content } objects — zero FFI wrappers.
496+
497+
#[inline]
498+
fn write_packed_str(buf: &mut Vec<u8>, s: &str) {
499+
buf.extend_from_slice(&(s.len() as u16).to_le_bytes());
500+
buf.extend_from_slice(s.as_bytes());
501+
}
502+
503+
fn write_packed_node(node: &NodeRef, buf: &mut Vec<u8>) {
504+
// Tag
505+
write_packed_str(buf, &node.tag);
506+
507+
// Attrs
508+
buf.extend_from_slice(&(node.attrs.len() as u16).to_le_bytes());
509+
for (k, v) in &node.attrs {
510+
write_packed_str(buf, k);
511+
match v.as_str() {
512+
Some(s) => write_packed_str(buf, s),
513+
None => {
514+
let s = v.to_string();
515+
write_packed_str(buf, &s);
516+
}
517+
}
518+
}
519+
520+
// Content
521+
match node.content.as_deref() {
522+
None => buf.push(0),
523+
Some(NodeContentRef::String(s)) => {
524+
buf.push(1);
525+
buf.extend_from_slice(&(s.len() as u32).to_le_bytes());
526+
buf.extend_from_slice(s.as_bytes());
527+
}
528+
Some(NodeContentRef::Bytes(b)) => {
529+
buf.push(2);
530+
buf.extend_from_slice(&(b.len() as u32).to_le_bytes());
531+
buf.extend_from_slice(b);
532+
}
533+
Some(NodeContentRef::Nodes(nodes)) => {
534+
buf.push(3);
535+
buf.extend_from_slice(&(nodes.len() as u16).to_le_bytes());
536+
for child in nodes.iter() {
537+
write_packed_node(child, buf);
538+
}
539+
}
540+
}
541+
}
542+
543+
/// Release memory held by internal buffers (after processing large messages).
544+
#[wasm_bindgen(js_name = shrinkBuffers)]
545+
pub fn shrink_buffers() {
546+
ENCODE_BUF.with(|c| unsafe { (&mut *c.get()).shrink_to_fit() });
547+
INPUT_BUF.with(|c| unsafe { (&mut *c.get()).shrink_to_fit() });
548+
DECODE_BUF.with(|c| unsafe { (&mut *c.get()).shrink_to_fit() });
549+
}
550+
551+
/// Decode WhatsApp binary format → packed LNP buffer in WASM memory.
552+
/// JS reads ptr+len from ENCODE_RESULT and constructs plain JS objects.
553+
#[wasm_bindgen(js_name = decodeNodeToPacked)]
554+
pub fn decode_node_to_packed(data: &[u8]) -> Result<(), JsValue> {
555+
if data.is_empty() {
556+
return Err(JsValue::from_str("Input data cannot be empty"));
557+
}
558+
559+
let unpacked_cow = unpack(data).map_err(|e| JsValue::from_str(&e.to_string()))?;
560+
let node_ref = unmarshal_ref(&unpacked_cow).map_err(|e| JsValue::from_str(&e.to_string()))?;
561+
562+
DECODE_BUF.with(|cell| {
563+
// SAFETY: WASM is single-threaded
564+
let buf = unsafe { &mut *cell.get() };
565+
buf.clear();
566+
write_packed_node(&node_ref, buf);
567+
unsafe {
568+
let result = &mut *ENCODE_RESULT.0.get();
569+
result[0] = buf.as_ptr() as u32;
570+
result[1] = buf.len() as u32;
571+
}
572+
});
573+
574+
Ok(())
575+
}

ts/index.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import {
33
inputBufGrow,
44
encodeFromInputBuf,
55
encodeResultPtr,
6+
decodeNodeToPacked,
7+
shrinkBuffers,
68
type BinaryNode,
79
} from "../pkg/whatsapp_rust_bridge.js";
810

@@ -194,3 +196,111 @@ export function encodeNode(node: BinaryNode): Uint8Array {
194196
// Single JS-native slice: one alloc + one memcpy, no FFI crossings
195197
return new Uint8Array(wasmMemory.buffer, ptr, len).slice();
196198
}
199+
200+
// ── Fast decodeNode via packed binary protocol ──────────────────────────
201+
//
202+
// Rust decodes WA binary → NodeRef → packed LNP buffer in WASM memory.
203+
// JS reads the buffer and constructs plain { tag, attrs, content } objects.
204+
// One FFI call, zero wrappers, zero FinalizationRegistry.
205+
206+
const textDecoder = new TextDecoder();
207+
208+
/** Read a u16-LE-prefixed UTF-8 string. ASCII fast path for short strings. */
209+
function readStr16(mem: Uint8Array, p: number[]): string {
210+
const len = mem[p[0]] | (mem[p[0] + 1] << 8);
211+
p[0] += 2;
212+
if (len < 64) {
213+
const start = p[0];
214+
for (let i = 0; i < len; i++) {
215+
if (mem[start + i] > 0x7f) {
216+
// Non-ASCII: fall back to TextDecoder
217+
const str = textDecoder.decode(
218+
new Uint8Array(mem.buffer, mem.byteOffset + start, len),
219+
);
220+
p[0] += len;
221+
return str;
222+
}
223+
}
224+
// All ASCII: build string from char codes
225+
let s = "";
226+
for (let i = 0; i < len; i++) {
227+
s += String.fromCharCode(mem[start + i]);
228+
}
229+
p[0] += len;
230+
return s;
231+
}
232+
const str = textDecoder.decode(
233+
new Uint8Array(mem.buffer, mem.byteOffset + p[0], len),
234+
);
235+
p[0] += len;
236+
return str;
237+
}
238+
239+
/** Unpack a single BinaryNode from packed LNP buffer. */
240+
function unpackNode(mem: Uint8Array, p: number[]): BinaryNode {
241+
const tag = readStr16(mem, p);
242+
243+
const attrCount = mem[p[0]] | (mem[p[0] + 1] << 8);
244+
p[0] += 2;
245+
const attrs: Record<string, string> = {};
246+
for (let i = 0; i < attrCount; i++) {
247+
const k = readStr16(mem, p);
248+
attrs[k] = readStr16(mem, p);
249+
}
250+
251+
const contentType = mem[p[0]++];
252+
let content: BinaryNode["content"] = undefined;
253+
254+
if (contentType === 1) {
255+
// String
256+
const len =
257+
mem[p[0]] |
258+
(mem[p[0] + 1] << 8) |
259+
(mem[p[0] + 2] << 16) |
260+
(mem[p[0] + 3] << 24);
261+
p[0] += 4;
262+
content = textDecoder.decode(
263+
new Uint8Array(mem.buffer, mem.byteOffset + p[0], len),
264+
);
265+
p[0] += len;
266+
} else if (contentType === 2) {
267+
// Bytes — .slice() to avoid retaining WASM memory
268+
const len =
269+
(mem[p[0]] |
270+
(mem[p[0] + 1] << 8) |
271+
(mem[p[0] + 2] << 16) |
272+
(mem[p[0] + 3] << 24)) >>>
273+
0;
274+
p[0] += 4;
275+
content = mem.slice(p[0], p[0] + len);
276+
p[0] += len;
277+
} else if (contentType === 3) {
278+
// Child nodes
279+
const count = mem[p[0]] | (mem[p[0] + 1] << 8);
280+
p[0] += 2;
281+
const children: BinaryNode[] = new Array(count);
282+
for (let i = 0; i < count; i++) {
283+
children[i] = unpackNode(mem, p);
284+
}
285+
content = children;
286+
}
287+
288+
return { tag, attrs, content };
289+
}
290+
291+
/**
292+
* Decode WhatsApp binary format to a plain BinaryNode object.
293+
* Rust decodes + serializes to packed LNP → JS reads from WASM memory.
294+
* One FFI call, zero wrappers, zero FinalizationRegistry.
295+
*/
296+
export function decodeNode(data: Uint8Array): BinaryNode {
297+
decodeNodeToPacked(data);
298+
299+
const mem = new Uint8Array(wasmMemory.buffer);
300+
const a = encResultAddr;
301+
const ptr =
302+
(mem[a] | (mem[a + 1] << 8) | (mem[a + 2] << 16) | (mem[a + 3] << 24)) >>>
303+
0;
304+
305+
return unpackNode(mem, [ptr]);
306+
}

0 commit comments

Comments
 (0)