diff --git a/.gitignore b/.gitignore index 5ca0757..33e1b10 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ ____*/ ____* bindings/wasm/pkg/ sdk/js/server/pkg/ -sdk/js/server/dist/ +dist/ sdk/js/web/pkg/ -pnpm-lock.yaml \ No newline at end of file +pnpm-lock.yaml +actra_wasm.wasm \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c37ad3..e676574 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +### v0.6.2 - 21 March 2026 +- updated wasm bindings for raw wasm abi +- removed wasm bindgen +- added raw tests for wasm - JS wrapper + ### v0.6.1 - 19 March 2026 - added wasm bindings for Node, Bun, Browser, Deno and Edge - Bug fix in wasm binding diff --git a/Cargo.toml b/Cargo.toml index 06c0483..b06b97b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.6.1" +version = "0.6.2" edition = "2021" license = "Apache-2.0" authors = ["Amit Saxena"] diff --git a/bindings/wasm/Cargo.toml b/bindings/wasm/Cargo.toml index 4cb9bf6..c0ea578 100644 --- a/bindings/wasm/Cargo.toml +++ b/bindings/wasm/Cargo.toml @@ -8,15 +8,14 @@ description.workspace = true authors.workspace = true [lib] -crate-type = ["cdylib"] +crate-type = ["cdylib", "rlib"] [dependencies] actra = { path = "../../core" } -wasm-bindgen = "0.2" console_error_panic_hook = "0.1" serde_yaml = "0.9" -serde-wasm-bindgen = "0.6.5" serde = "1.0.228" +serde_json = "1.0.149" [package.metadata.wasm-pack.profile.release] wasm-opt = false diff --git a/bindings/wasm/package.json b/bindings/wasm/package.json index 7844ba2..0ce1dc1 100644 --- a/bindings/wasm/package.json +++ b/bindings/wasm/package.json @@ -2,27 +2,17 @@ "name": "wasm-binding-test", "version": "1.0.0", "type": "module", - "files": [ - "pkg" - ], "scripts": { "build": "node scripts/build.mjs", + "build:webpack": "webpack --config test/webpack/webpack.config.js", "test:webpack": "webpack serve --config test/webpack/webpack.config.js", - "test:node": "node test/test-node.mjs", - "test:bun": "bun test/test-bun.mjs", - "test:deno": "deno run --allow-read test/test-deno.ts", - "test:bundler": "node test/test-bundler.mjs", + "test:node": "node test/raw-test.mjs", + "test:bun": "bun test/raw-test.mjs", + "test:deno": "deno run --allow-read test/raw-test.mjs", "test:browser": "npx serve ." }, "exports": { - ".": { - "types": "./pkg/node/actra_wasm.d.ts", - "node": "./pkg/node/actra_wasm.js", - "deno": "./pkg/deno/actra_wasm.js", - "browser": "./pkg/web/actra_wasm.js", - "worker": "./pkg/web/actra_wasm.js", - "default": "./pkg/bundler/actra_wasm.js" - } + ".": "./js/actra.mjs" }, "devDependencies": { "html-webpack-plugin": "^5.6.6", diff --git a/bindings/wasm/scripts/build.mjs b/bindings/wasm/scripts/build.mjs index 479c1f9..4250559 100644 --- a/bindings/wasm/scripts/build.mjs +++ b/bindings/wasm/scripts/build.mjs @@ -1,24 +1,21 @@ -import { execSync } from "child_process"; -import { rmSync, existsSync } from "fs"; +import fs from "fs/promises"; +import path from "path"; -const builds = [ - { target: "bundler", out: "pkg/bundler" }, - { target: "web", out: "pkg/web" }, - { target: "nodejs", out: "pkg/node" }, - { target: "deno", out: "pkg/deno" } +const src = path.resolve( + "../../target/wasm32-unknown-unknown/release/actra_wasm.wasm" +); + +// destinations +const targets = [ + "./test/actra_wasm.wasm", + "./test/webpack/actra_wasm.wasm" ]; -console.log("CWD:", process.cwd()); +for (const dest of targets) { + const out = path.resolve(dest); -if (existsSync("pkg")) { - console.log("\nCleaning pkg directory..."); - rmSync("pkg", { recursive: true, force: true }); -} + await fs.mkdir(path.dirname(out), { recursive: true }); + await fs.copyFile(src, out); -for (const b of builds) { - console.log(`\nBuilding ${b.target}...`); - execSync( - `wasm-pack build . --target ${b.target} --out-dir ${b.out} --release`, - { stdio: "inherit" } - ); + console.log("Copied WASM ->", dest); } \ No newline at end of file diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 6fd2d44..a5e57ef 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -1,7 +1,6 @@ -//! WebAssembly bindings for Actra. -//! +//! WebAssembly bindings for Actra using raw WASM ABI //! This module exposes the Actra policy engine to JavaScript environments -//! using `wasm-bindgen`. The binding layer performs minimal work and +//! The binding layer performs minimal work and //! delegates all semantic processing to the Rust core. //! //! Responsibilities: @@ -12,9 +11,10 @@ //! //! All validation and evaluation logic resides in `actra-core`. -use wasm_bindgen::prelude::*; use serde::{Deserialize, Serialize}; -use serde_wasm_bindgen::{from_value, to_value}; +use std::sync::{Mutex, OnceLock}; +use std::sync::Arc; +use std::panic::{catch_unwind, AssertUnwindSafe}; use actra::ast::PolicyAst; use actra::compiler::{compile_policy, compile_with_governance}; @@ -24,6 +24,270 @@ use actra::ir::{CompiledPolicy, Effect}; use actra::schema::{Schema, SchemaAst}; use actra::compiler_version as core_compiler_version; + + + +fn safe_exec(f: F) -> u64 +where + T: Serialize, + F: FnOnce() -> Result, +{ + match catch_unwind(AssertUnwindSafe(f)) { + Ok(result) => match result { + Ok(data) => ok(data), + Err(err_msg) => err(err_msg), + }, + Err(_) => err("internal panic occurred".to_string()), + } +} + +//Whenever done with JS instance +//wasm.actra_free(instanceId); +#[no_mangle] +pub extern "C" fn actra_free(instance_id: i32) { + let _ = catch_unwind(AssertUnwindSafe(|| { + if instance_id < 0 { + return; + } + + let mut instances = get_instances().lock().unwrap(); + + if let Some(slot) = instances.get_mut(instance_id as usize) { + *slot = None; + } + })); +} + +fn read_str(ptr: *const u8, len: usize, name: &str) -> Result<&str, String> { + if len > 0 && ptr.is_null() { + return Err(format!("{} null pointer", name)); + } + + if len == 0 { + return Ok(""); + } + + unsafe { + std::str::from_utf8(std::slice::from_raw_parts(ptr, len)) + .map_err(|_| format!("Invalid {}", name)) + } +} + +//wasm is 32 bit +fn pack(ptr: *mut u8, len: usize) -> u64 { + ((ptr as u64) << 32) | (len as u64) +} + +fn to_buffer(s: String) -> u64 { + let mut bytes = s.into_bytes(); + let len = bytes.len(); + + let mut full = Vec::with_capacity(8 + len); + + full.extend_from_slice(&(len as u64).to_le_bytes()); + full.append(&mut bytes); + + let ptr = full.as_mut_ptr(); + + std::mem::forget(full); + + pack(ptr, len + 8) +} + +//To free allocated strings via WasmBuffer +//Important contract at JS Layer +// +/*function readWasmString(wasm, memory, val) { + const ptr = Number(val >> 32n); + const totalLen = Number(val & 0xffffffffn); + + // read actual string length + const lenView = new DataView(memory.buffer, ptr, 8); + const strLen = Number(lenView.getBigUint64(0, true)); + + const bytes = new Uint8Array(memory.buffer, ptr + 8, strLen); + const str = new TextDecoder().decode(bytes); + + wasm.actra_string_free(ptr, totalLen); + + return str; +} +//Usage example +const buffer = wasm.actra_create( + schemaPtr, schemaLen, + policyPtr, policyLen, + govPtr, govLen +); + +const result = readWasmString(wasm, memory, buffer); + +//policy hash +const buffer = wasm.actra_policy_hash(instanceId); +const hash = readWasmString(wasm, memory, buffer); +*/ +#[no_mangle] +pub extern "C" fn actra_string_free(ptr: *mut u8, total_len: usize) { + // len is len+8 with len prefix + if ptr.is_null() || total_len == 0 { + return; + } + + unsafe { + let _ = Vec::from_raw_parts(ptr, total_len, total_len); + } +} + +//Raw memory alloc +#[no_mangle] +pub extern "C" fn actra_alloc(size: usize) -> *mut u8 { + if size == 0 { + return std::ptr::null_mut(); + } + + let mut buf = Vec::with_capacity(size); + let ptr = buf.as_mut_ptr(); + std::mem::forget(buf); + ptr +} + +//Raw memory dealloc +#[no_mangle] +pub extern "C" fn actra_dealloc(ptr: *mut u8, size: usize) { + if ptr.is_null() || size == 0 { + return; + } + + unsafe { + let _ = Vec::from_raw_parts(ptr, 0, size); + } +} + +struct ActraInstance { + compiled_policy: Arc, +} + +//Allows freeing slots without shifting indices +//Prevents ID corruption +//Keeps instance IDs stable +static INSTANCES: OnceLock>>> = OnceLock::new(); + +fn get_instances() -> &'static Mutex>> { + INSTANCES.get_or_init(|| Mutex::new(Vec::new())) +} + +#[no_mangle] +pub extern "C" fn actra_create( + schema_ptr: *const u8, + schema_len: usize, + policy_ptr: *const u8, + policy_len: usize, + gov_ptr: *const u8, + gov_len: usize, +) -> u64 { + safe_exec(|| { + + let schema_yaml = read_str(schema_ptr, schema_len, "schema")?; + let policy_yaml = read_str(policy_ptr, policy_len, "policy")?; + + let governance_yaml = if gov_len > 0 { + Some(read_str(gov_ptr, gov_len, "governance")?) + } else { + None + }; + + let compiled_policy = Actra::compile( + schema_yaml, + policy_yaml, + governance_yaml, + )?; + + let mut instances = get_instances().lock().unwrap(); + + //Slot reuse + let instance = ActraInstance { + compiled_policy: Arc::new(compiled_policy), + }; + + if let Some((idx, slot)) = instances.iter_mut().enumerate().find(|(_, s)| s.is_none()) { + *slot = Some(instance); + Ok(idx.to_string()) + } else { + instances.push(Some(instance)); + Ok((instances.len() - 1).to_string()) + } + + }) +} + +#[no_mangle] +pub extern "C" fn actra_evaluate( + instance_id: i32, + input_ptr: *const u8, + input_len: usize, +) -> u64 { +safe_exec(|| { + + if instance_id < 0 { + return Err("invalid instance_id".to_string()); + } + + let input_str = read_str(input_ptr, input_len, "input")?; + + let input: JsEvaluationInput = + serde_json::from_str(input_str).map_err(|e| e.to_string())?; + + let compiled_policy = { + let instances = get_instances().lock().unwrap(); + + match instances.get(instance_id as usize) { + Some(Some(i)) => Arc::clone(&i.compiled_policy), + _ => return Err("invalid instance_id".to_string()), + } + }; + + let eval_input = EvaluationInput { + action: input.action, + actor: input.actor, + snapshot: input.snapshot, + }; + + let result = evaluate(compiled_policy.as_ref(), &eval_input); + + Ok(JsEvaluationOutput { + effect: effect_to_str(&result.effect).to_string(), + matched_rule: result.matched_rule.unwrap_or_default(), + }) +}) +} + + +#[no_mangle] +pub extern "C" fn actra_policy_hash(instance_id: i32) -> u64 { + safe_exec(|| { + if instance_id < 0 { + return Err("invalid instance_id".to_string()); + } + + let compiled_policy = { + let instances = get_instances().lock().unwrap(); + + match instances.get(instance_id as usize) { + Some(Some(i)) => Arc::clone(&i.compiled_policy), + _ => return Err("invalid instance_id".to_string()), + } + }; + + Ok(compiled_policy.policy_hash()) +}) +} + +#[no_mangle] +pub extern "C" fn actra_compiler_version() -> u64 { + safe_exec(|| { + Ok(core_compiler_version().to_string()) + }) +} + /// JavaScript-facing evaluation input structure. #[derive(Deserialize)] pub struct JsEvaluationInput { @@ -39,17 +303,89 @@ pub struct JsEvaluationOutput { pub matched_rule: String, } +//JSON response +#[derive(Serialize)] +#[serde(tag = "ok")] +pub enum WasmResponse { + #[serde(rename = "true")] + Ok { data: T }, + + #[serde(rename = "false")] + Err { error: String }, +} + +//JSON Return helper +fn ok(data: T) -> u64 { + let res = WasmResponse::Ok { data }; + to_buffer(serde_json::to_string(&res).unwrap()) +} + +//JSON Return helper +fn err(msg: String) -> u64 { + let res: WasmResponse<()> = WasmResponse::Err { error: msg }; + to_buffer(serde_json::to_string(&res).unwrap()) +} + /// JavaScript-exposed Actra class. /// /// The compiled policy is stored internally. /// Evaluation operations are pure and deterministic. -#[wasm_bindgen (js_name = Actra)] pub struct Actra { compiled_policy: CompiledPolicy, } -#[wasm_bindgen] impl Actra { + + pub fn compile( + schema_yaml: &str, + policy_yaml: &str, + governance_yaml: Option<&str>, + ) -> Result { + + if schema_yaml.trim().is_empty() { + return Err("Schema cannot be empty".to_string()); + } + + if policy_yaml.trim().is_empty() { + return Err("Policy cannot be empty".to_string()); + } + + if let Some(gov) = governance_yaml { + if gov.trim().is_empty() { + return Err("Governance cannot be empty".to_string()); + } + } + + let schema_ast: SchemaAst = + serde_yaml::from_str(schema_yaml).map_err(|e| e.to_string())?; + + let schema = Schema::from_ast(schema_ast); + + let policy_ast: PolicyAst = + serde_yaml::from_str(policy_yaml).map_err(|e| e.to_string())?; + + let compiled_policy = if let Some(gov_yaml) = governance_yaml { + + let governance_ast: GovernanceAst = + serde_yaml::from_str(gov_yaml).map_err(|e| e.to_string())?; + + compile_with_governance( + &schema, + policy_ast, + &governance_ast, + ).map_err(|e| e.to_string())? + + } else { + + compile_policy( + &schema, + policy_ast, + ).map_err(|e| e.to_string())? + }; + + Ok(compiled_policy) + } + /// Creates a new Actra engine instance. /// /// Compilation happens immediately during construction. @@ -72,39 +408,17 @@ impl Actra { /// Throws /// ------ /// JavaScript Error if parsing or compilation fails. - #[wasm_bindgen(constructor)] pub fn new( - schema_yaml: String, - policy_yaml: String, - governance_yaml: Option, - ) -> Result { + schema_yaml: &str, + policy_yaml: &str, + governance_yaml: Option<&str>, + ) -> Result { - let schema_ast: SchemaAst = - serde_yaml::from_str(&schema_yaml).map_err(to_js_err)?; - - let schema = Schema::from_ast(schema_ast); - - let policy_ast: PolicyAst = - serde_yaml::from_str(&policy_yaml).map_err(to_js_err)?; - - let compiled_policy = if let Some(gov_yaml) = governance_yaml { - - let governance_ast: GovernanceAst = - serde_yaml::from_str(&gov_yaml).map_err(to_js_err)?; - - compile_with_governance( - &schema, - policy_ast, - &governance_ast, - ).map_err(to_js_err)? - - } else { - - compile_policy( - &schema, - policy_ast, - ).map_err(to_js_err)? - }; + let compiled_policy = Self::compile( + schema_yaml, + policy_yaml, + governance_yaml, + )?; Ok(Self { compiled_policy }) } @@ -113,7 +427,7 @@ impl Actra { /// /// Expected input format: /// - /// ``` + /// ```text /// { /// action: { ... }, /// actor: { ... }, @@ -123,20 +437,16 @@ impl Actra { /// /// Returns: /// - /// ``` + /// ```text /// { /// effect: "allow" | "block" | "require_approval", /// matched_rule: string /// } /// ``` - #[wasm_bindgen] - pub fn evaluate(&self, input: JsValue) -> Result { + pub fn evaluate(&self, input: String) -> Result { - let js_input: JsEvaluationInput = - from_value(input).map_err(|e| JsValue::from_str(&format!( - "Invalid evaluation input: {}", - e - )))?; + let js_input: JsEvaluationInput = + serde_json::from_str(input.as_str()).map_err(|e| e.to_string())?; let eval_input = EvaluationInput { action: js_input.action, @@ -144,17 +454,12 @@ impl Actra { snapshot: js_input.snapshot, }; - let result = evaluate( - &self.compiled_policy, - &eval_input, - ); + let result = evaluate(&self.compiled_policy, &eval_input); - let output = JsEvaluationOutput { + Ok(JsEvaluationOutput { effect: effect_to_str(&result.effect).to_string(), matched_rule: result.matched_rule.unwrap_or_default(), - }; - - to_value(&output).map_err(|e| e.into()) + }) } /// Returns the deterministic policy hash. @@ -163,7 +468,6 @@ impl Actra { /// - caching /// - auditing /// - reproducibility - #[wasm_bindgen] pub fn policy_hash(&self) -> String { self.compiled_policy.policy_hash() } @@ -172,17 +476,11 @@ impl Actra { /// /// This can be used to verify compatibility between /// compiled policies and runtime engines. - #[wasm_bindgen] pub fn compiler_version() -> String { core_compiler_version().to_string() } } -/// Converts Rust errors into JavaScript errors. -fn to_js_err(err: E) -> JsValue { - JsValue::from_str(&err.to_string()) -} - /// Converts internal effect enum to string representation. fn effect_to_str(effect: &Effect) -> &'static str { match effect { diff --git a/bindings/wasm/test/actra.mjs b/bindings/wasm/test/actra.mjs new file mode 100644 index 0000000..b36e7a4 --- /dev/null +++ b/bindings/wasm/test/actra.mjs @@ -0,0 +1,89 @@ +export class Actra { + constructor(wasm, memory, { schema, policy, governance }) { + this.wasm = wasm; + this.memory = memory; + + this.instanceId = this.#createInstance(schema, policy, governance); + } + + #allocString(str) { + const encoder = new TextEncoder(); + const bytes = encoder.encode(str); + + const ptr = this.wasm.actra_alloc(bytes.length); + const mem = new Uint8Array(this.memory.buffer, ptr, bytes.length); + mem.set(bytes); + + return { ptr, len: bytes.length }; + } + + #readBuffer(buffer) { + const bytes = new Uint8Array(this.memory.buffer, buffer.ptr, buffer.len); + const str = new TextDecoder().decode(bytes); + + this.wasm.actra_string_free(buffer.ptr, buffer.len); + + return str; + } + + #callCreate(schema, policy, governance) { + const s = this.#allocString(schema); + const p = this.#allocString(policy); + const g = governance ? this.#allocString(governance) : { ptr: 0, len: 0 }; + + const buf = this.wasm.actra_create( + s.ptr, s.len, + p.ptr, p.len, + g.ptr, g.len + ); + + return this.#readBuffer(buf); + } + + #createInstance(schema, policy, governance) { + const out = this.#callCreate(schema, policy, governance); + const parsed = JSON.parse(out); + + if (parsed.ok === "false") { + throw new Error(parsed.error); + } + + return parseInt(parsed.data, 10); + } + + evaluate(input) { + const inputStr = JSON.stringify(input); + const i = this.#allocString(inputStr); + + const buf = this.wasm.actra_evaluate( + this.instanceId, + i.ptr, + i.len + ); + + const out = this.#readBuffer(buf); + const parsed = JSON.parse(out); + + if (parsed.ok === "false") { + throw new Error(parsed.error); + } + + return parsed.data; + } + + policyHash() { + const buf = this.wasm.actra_policy_hash(this.instanceId); + const out = this.#readBuffer(buf); + const parsed = JSON.parse(out); + + if (parsed.ok === "false") { + throw new Error(parsed.error); + } + + return parsed.data; + } + + free() { + this.wasm.actra_free(this.instanceId); + } +} \ No newline at end of file diff --git a/bindings/wasm/test/loader.mjs b/bindings/wasm/test/loader.mjs new file mode 100644 index 0000000..0346f7a --- /dev/null +++ b/bindings/wasm/test/loader.mjs @@ -0,0 +1,58 @@ +export async function loadActraWasm(path) { + let instance; + + const imports = { + env: { + abort: () => { + throw new Error("WASM abort"); + } + } + }; + + // 1. Deno + if (typeof Deno !== "undefined" && typeof Deno.readFile === "function") { + const bytes = await Deno.readFile(path); + const result = await WebAssembly.instantiate(bytes, imports); + instance = result.instance; + } + + // 2. Node / Bun + else if (typeof process !== "undefined" && process.versions?.node) { + const fs = await import("fs/promises"); + const bytes = await fs.readFile(path); + + const result = await WebAssembly.instantiate(bytes, imports); + instance = result.instance; + } + + // 3. Browser / Workers / Edge + else { + const res = await fetch(path); + + if (!res.ok) { + throw new Error(`Failed to fetch WASM: ${res.status}`); + } + + // Try streaming first (fast path) + if (WebAssembly.instantiateStreaming) { + try { + const result = await WebAssembly.instantiateStreaming(res, imports); + instance = result.instance; + } catch { + // Fallback if MIME type is wrong + const bytes = await res.arrayBuffer(); + const result = await WebAssembly.instantiate(bytes, imports); + instance = result.instance; + } + } else { + const bytes = await res.arrayBuffer(); + const result = await WebAssembly.instantiate(bytes, imports); + instance = result.instance; + } + } + + return { + exports: instance.exports, + memory: instance.exports.memory + }; +} \ No newline at end of file diff --git a/bindings/wasm/test/raw-test.mjs b/bindings/wasm/test/raw-test.mjs new file mode 100644 index 0000000..77e3d53 --- /dev/null +++ b/bindings/wasm/test/raw-test.mjs @@ -0,0 +1,165 @@ +import { loadActraWasm } from "./loader.mjs"; + +const wasmUrl = new URL("./actra_wasm.wasm", import.meta.url); + +const { exports: wasm, memory } = + await loadActraWasm(wasmUrl); + +//helpers + +function formatResult(obj) { + const matchedRule = + obj.matched_rule === "" || obj.matched_rule == null + ? null + : obj.matched_rule; + + return { + effect: obj.effect, + matched_rule: matchedRule + }; +} + +function allocString(str) { + const encoder = new TextEncoder(); + const bytes = encoder.encode(str); + + const ptr = wasm.actra_alloc(bytes.length); + + const mem = new Uint8Array(memory.buffer, ptr, bytes.length); + mem.set(bytes); + + return { ptr, len: bytes.length }; +} + +function readBuffer(val) { + const v = BigInt(val); + + //len first + const ptr = Number(v >> 32n); + + if(ptr < 0){ + throw new Error("Invalid WASM pointer"); + } + + const lenView = new DataView(memory.buffer, ptr, 8); + const len = Number(lenView.getBigUint64(0, true)); + + //check valid len + if (len <= 0 || len > memory.buffer.byteLength) { + throw new Error("Invalid WASM length"); + } + + const bytes = new Uint8Array(memory.buffer, ptr + 8, len); + const str = new TextDecoder().decode(bytes); + + wasm.actra_string_free(ptr, len + 8); + + return str; +} + +// test + +const schema = ` +version: 1 + +actions: + refund: + fields: + amount: number + +actor: + fields: + role: string + +snapshot: + fields: + fraud_flag: boolean +`; + +const policy = ` +version: 1 + +rules: + - id: block_large_refund + scope: + action: refund + when: + subject: + domain: action + field: amount + operator: greater_than + value: + literal: 1000 + effect: block +`; + +// create +const s = allocString(schema); +const p = allocString(policy); + +const createBuf = wasm.actra_create( + s.ptr, s.len, + p.ptr, p.len, + 0, 0 +); + +//check +if (!createBuf) { + throw new Error("actra_create returned null/0"); +} + +//DALLOAC !! IMPORTANT +wasm.actra_dealloc(s.ptr, s.len); +wasm.actra_dealloc(p.ptr, p.len); + +console.log("RAW RETURN:", BigInt(createBuf).toString()); + +const createOut = readBuffer(createBuf); +const createParsed = JSON.parse(createOut); + +console.log("CREATE:", createParsed); + +if (createParsed.ok !== "true") { + throw new Error(createParsed.error); +} + +const instanceId = parseInt(createParsed.data, 10); + +// eval +const input = { + action: { type: "refund", amount: 1500 }, + actor: { role: "support" }, + snapshot: {} +}; + +const inputStr = JSON.stringify(input); +const i = allocString(inputStr); + +const evalBuf = wasm.actra_evaluate( + instanceId, + i.ptr, + i.len +); + +if (!evalBuf) { + throw new Error("actra_evaluate returned null/0"); +} + +wasm.actra_dealloc(i.ptr, i.len); + +const evalOut = readBuffer(evalBuf); +const evalParsed = JSON.parse(evalOut); + +console.log("EVAL:", formatResult(evalParsed.data)); + +// EXPECT BLOCK +if (evalParsed.data.effect !== "block") { + // CLEANUP + wasm.actra_free(instanceId); + throw new Error("Expected block"); +} + +// CLEANUP +wasm.actra_free(instanceId); + +console.log("Test passed"); \ No newline at end of file diff --git a/bindings/wasm/test/test-bun.mjs b/bindings/wasm/test/test-bun.mjs deleted file mode 100644 index e4da964..0000000 --- a/bindings/wasm/test/test-bun.mjs +++ /dev/null @@ -1,11 +0,0 @@ -async function run() { - try { - const wasm = await import("../pkg/node/actra_wasm.js"); - const version = wasm.Actra.compiler_version(); - console.log("Actra WASM compiler version:", version); - } catch (err) { - console.error(err); - } -} - -run(); \ No newline at end of file diff --git a/bindings/wasm/test/test-bundler.mjs b/bindings/wasm/test/test-bundler.mjs deleted file mode 100644 index 44f5f93..0000000 --- a/bindings/wasm/test/test-bundler.mjs +++ /dev/null @@ -1,11 +0,0 @@ -async function run() { - try { - const wasm = await import("../pkg/bundler/actra_wasm.js"); - const version = wasm.Actra.compiler_version(); - console.log("Actra WASM compiler version:", version); - } catch (err) { - console.error(err); - } -} - -run(); \ No newline at end of file diff --git a/bindings/wasm/test/test-deno.ts b/bindings/wasm/test/test-deno.ts deleted file mode 100644 index c37a6e8..0000000 --- a/bindings/wasm/test/test-deno.ts +++ /dev/null @@ -1,11 +0,0 @@ -async function run() { - try { - const wasm = await import("../pkg/deno/actra_wasm.js"); - const version = wasm.Actra.compiler_version(); - console.log("Actra WASM compiler version:", version); - } catch (err) { - console.error(err); - } -} - -run(); \ No newline at end of file diff --git a/bindings/wasm/test/test-node.mjs b/bindings/wasm/test/test-node.mjs deleted file mode 100644 index e4da964..0000000 --- a/bindings/wasm/test/test-node.mjs +++ /dev/null @@ -1,11 +0,0 @@ -async function run() { - try { - const wasm = await import("../pkg/node/actra_wasm.js"); - const version = wasm.Actra.compiler_version(); - console.log("Actra WASM compiler version:", version); - } catch (err) { - console.error(err); - } -} - -run(); \ No newline at end of file diff --git a/bindings/wasm/test/test_browser.html b/bindings/wasm/test/test_browser.html index ebd1cdd..6b9d139 100644 --- a/bindings/wasm/test/test_browser.html +++ b/bindings/wasm/test/test_browser.html @@ -1,24 +1,13 @@ - + + Actra WASM Test + -

Browser Test

-
- - +

Check Console

+ - \ No newline at end of file diff --git a/bindings/wasm/test/webpack/index.html b/bindings/wasm/test/webpack/index.html index 138467d..9d5e4e9 100644 --- a/bindings/wasm/test/webpack/index.html +++ b/bindings/wasm/test/webpack/index.html @@ -1,9 +1,17 @@ + + + Actra WASM Test + -

Webpack WASM Test

-
Loading...
- +

Webpack WASM Test

+ +
Loading...
+ +

+
+
 
\ No newline at end of file
diff --git a/bindings/wasm/test/webpack/index.js b/bindings/wasm/test/webpack/index.js
index 04ff32e..5079a73 100644
--- a/bindings/wasm/test/webpack/index.js
+++ b/bindings/wasm/test/webpack/index.js
@@ -1,18 +1,204 @@
-async function run() {
-  const el = document.getElementById("output");
+import { loadActraWasm } from "./loader.js";
+
+// UI helpers
+
+function log(msg) {
+  const el = document.getElementById("log");
+  el.textContent += msg + "\n";
+}
+
+function setOutput(msg) {
+  document.getElementById("output").textContent = msg;
+}
+
+// Normalize result
+function formatResult(obj) {
+  return {
+    effect: obj.effect,
+    matched_rule:
+      obj.matched_rule === "" || obj.matched_rule == null
+        ? null
+        : obj.matched_rule
+  };
+}
+
+// WASM helpers
+
+function allocString(wasm, memory, str) {
+  const encoder = new TextEncoder();
+  const bytes = encoder.encode(str);
+
+  const ptr = wasm.actra_alloc(bytes.length);
+
+  const mem = new Uint8Array(memory.buffer, ptr, bytes.length);
+  mem.set(bytes);
+
+  return { ptr, len: bytes.length };
+}
+
+function readBuffer(wasm, memory, val) {
+  const v = BigInt(val);
+
+  const ptr = Number(v >> 32n);
 
+  if (ptr <= 0) {
+    throw new Error("Invalid WASM pointer");
+  }
+
+  // Read length prefix (8 bytes)
+  const lenView = new DataView(memory.buffer, ptr, 8);
+  const len = Number(lenView.getBigUint64(0, true));
+
+  if (len <= 0 || len > memory.buffer.byteLength) {
+    throw new Error("Invalid WASM length");
+  }
+
+  const bytes = new Uint8Array(memory.buffer, ptr + 8, len);
+  const str = new TextDecoder().decode(bytes);
+
+  // Free (len + 8 prefix)
+  wasm.actra_string_free(ptr, len + 8);
+
+  return str;
+}
+
+// MAIN TEST
+
+async function run() {
   try {
-    el.textContent = "Loading WASM...";
+    setOutput("Loading WASM...");
+
+    const wasmUrl = new URL("./actra_wasm.wasm", import.meta.url);
+
+    const { exports: wasm, memory } =
+      await loadActraWasm(wasmUrl);
+
+    log("WASM loaded");
+
+    //Schema & Policy
 
-    const wasm = await import("../../pkg/bundler/actra_wasm.js");
+    const schema = `
+version: 1
 
-    const version = wasm.Actra.compiler_version();
+actions:
+  refund:
+    fields:
+      amount: number
 
-    el.textContent = "Compiler Version: " + version;
+actor:
+  fields:
+    role: string
+
+snapshot:
+  fields:
+    fraud_flag: boolean
+`;
+
+    const policy = `
+version: 1
+
+rules:
+  - id: block_large_refund
+    scope:
+      action: refund
+    when:
+      subject:
+        domain: action
+        field: amount
+      operator: greater_than
+      value:
+        literal: 1000
+    effect: block
+`;
+
+    // CREATE
+
+    const s = allocString(wasm, memory, schema);
+    const p = allocString(wasm, memory, policy);
+
+    const createBuf = wasm.actra_create(
+      s.ptr, s.len,
+      p.ptr, p.len,
+      0, 0
+    );
+
+    // Free input buffers immediately
+    wasm.actra_dealloc(s.ptr, s.len);
+    wasm.actra_dealloc(p.ptr, p.len);
+
+    if (!createBuf) {
+      throw new Error("actra_create returned null/0");
+    }
+
+    const createOut = readBuffer(wasm, memory, createBuf);
+    const createParsed = JSON.parse(createOut);
+
+    log("CREATE RAW: " + createOut);
+
+    if (createParsed.ok !== "true") {
+      throw new Error(createParsed.error);
+    }
+
+    const instanceId = parseInt(createParsed.data, 10);
+
+    log("Instance ID: " + instanceId);
+
+    // EVALUATE
+
+    const input = {
+      action: { type: "refund", amount: 1500 },
+      actor: { role: "support" },
+      snapshot: {}
+    };
+
+    const inputStr = JSON.stringify(input);
+    const i = allocString(wasm, memory, inputStr);
+
+    const evalBuf = wasm.actra_evaluate(
+      instanceId,
+      i.ptr,
+      i.len
+    );
+
+    wasm.actra_dealloc(i.ptr, i.len);
+
+    if (!evalBuf) {
+      throw new Error("actra_evaluate returned null/0");
+    }
+
+    const evalOut = readBuffer(wasm, memory, evalBuf);
+    const evalParsed = JSON.parse(evalOut);
+
+    log("EVAL RAW: " + evalOut);
+
+    if (evalParsed.ok !== "true") {
+      throw new Error(evalParsed.error);
+    }
+
+    const result = formatResult(evalParsed.data);
+
+    log("EVAL FORMATTED: " + JSON.stringify(result, null, 2));
+
+    // ASSERT
+
+    if (result.effect !== "block") {
+      throw new Error("Expected block");
+    }
+
+    // CLEANUP
+
+    wasm.actra_free(instanceId);
+
+    setOutput("Test Passed");
   } catch (err) {
     console.error(err);
-    el.textContent = "Error loading WASM";
+    log("ERROR: " + err.message);
+    setOutput("Failed");
   }
 }
 
-run();
\ No newline at end of file
+// auto-run
+run();
+
+// optional button
+document.getElementById("runBtn")?.addEventListener("click", run);
\ No newline at end of file
diff --git a/bindings/wasm/test/webpack/loader.js b/bindings/wasm/test/webpack/loader.js
new file mode 100644
index 0000000..0346f7a
--- /dev/null
+++ b/bindings/wasm/test/webpack/loader.js
@@ -0,0 +1,58 @@
+export async function loadActraWasm(path) {
+  let instance;
+
+  const imports = {
+    env: {
+      abort: () => {
+        throw new Error("WASM abort");
+      }
+    }
+  };
+
+  // 1. Deno
+  if (typeof Deno !== "undefined" && typeof Deno.readFile === "function") {
+    const bytes = await Deno.readFile(path);
+    const result = await WebAssembly.instantiate(bytes, imports);
+    instance = result.instance;
+  }
+
+  // 2. Node / Bun
+  else if (typeof process !== "undefined" && process.versions?.node) {
+    const fs = await import("fs/promises");
+    const bytes = await fs.readFile(path);
+
+    const result = await WebAssembly.instantiate(bytes, imports);
+    instance = result.instance;
+  }
+
+  // 3. Browser / Workers / Edge
+  else {
+    const res = await fetch(path);
+
+    if (!res.ok) {
+      throw new Error(`Failed to fetch WASM: ${res.status}`);
+    }
+
+    // Try streaming first (fast path)
+    if (WebAssembly.instantiateStreaming) {
+      try {
+        const result = await WebAssembly.instantiateStreaming(res, imports);
+        instance = result.instance;
+      } catch {
+        // Fallback if MIME type is wrong
+        const bytes = await res.arrayBuffer();
+        const result = await WebAssembly.instantiate(bytes, imports);
+        instance = result.instance;
+      }
+    } else {
+      const bytes = await res.arrayBuffer();
+      const result = await WebAssembly.instantiate(bytes, imports);
+      instance = result.instance;
+    }
+  }
+
+  return {
+    exports: instance.exports,
+    memory: instance.exports.memory
+  };
+}
\ No newline at end of file
diff --git a/bindings/wasm/test/webpack/webpack.config.js b/bindings/wasm/test/webpack/webpack.config.js
index 69b5a08..9cda821 100644
--- a/bindings/wasm/test/webpack/webpack.config.js
+++ b/bindings/wasm/test/webpack/webpack.config.js
@@ -2,29 +2,54 @@ import path from "path";
 import HtmlWebpackPlugin from "html-webpack-plugin";
 
 export default {
-    mode: "development",
-    devtool: "source-map",
+  mode: "development",
+  devtool: "source-map",
 
-    entry: "./test/webpack/index.js",
+  entry: "./test/webpack/index.js",
 
-    output: {
-        path: path.resolve("./test/webpack/dist"),
-        filename: "bundle.js",
-        publicPath: "/"
-    },
+  output: {
+    path: path.resolve("./test/webpack/dist"),
+    filename: "bundle.js",
+    publicPath: "/",
+    clean: true
+  },
 
-    experiments: {
-        asyncWebAssembly: true
-    },
+  experiments: {
+    asyncWebAssembly: true
+  },
+
+  module: {
+    rules: [
+      {
+        test: /\.wasm$/,
+        type: "asset/resource" //ensures wasm is emitted correctly
+      }
+    ]
+  },
+
+  resolve: {
+    fallback: {
+      fs: false //prevent Node-only modules from breaking browser build
+    }
+  },
+
+  plugins: [
+    new HtmlWebpackPlugin({
+      template: "./test/webpack/index.html"
+    })
+  ],
 
-    plugins: [
-        new HtmlWebpackPlugin({
-            template: "./test/webpack/index.html"
-        })
-    ],
+  devServer: {
+    port: 8080,
+    open: true,
+
+    static: {
+      directory: path.resolve("./test/webpack/dist")
+    },
 
-    devServer: {
-        port: 8080,
-        open: true
+    headers: {
+      "Cross-Origin-Opener-Policy": "same-origin",
+      "Cross-Origin-Embedder-Policy": "require-corp"
     }
+  }
 };
\ No newline at end of file
diff --git a/bindings/wasm/webpack.config.js b/bindings/wasm/webpack.config.js
deleted file mode 100644
index c8f80dc..0000000
--- a/bindings/wasm/webpack.config.js
+++ /dev/null
@@ -1,6 +0,0 @@
-export default {
-  mode: "development",
-  experiments: {
-    asyncWebAssembly: true
-  }
-};
\ No newline at end of file
diff --git a/scripts/wasm_pack.sh b/scripts/wasm_pack.sh
index 2f2176e..47a08e8 100644
--- a/scripts/wasm_pack.sh
+++ b/scripts/wasm_pack.sh
@@ -1,6 +1,8 @@
 # Install wasm target
 rustup target add wasm32-unknown-unknown
 
+cargo build --release --target wasm32-unknown-unknown
+
 # Install wasm-pack
 cargo install wasm-pack