git.delta.rocks / jrsonnet / refs/commits / 9aabcd49c223

difftreelog

refactor(web) idiomatic js api

zwxrzuuxYaroslav Bolyukin2026-05-05parent: #6ac56ea.patch.diff
in: master

10 files changed

modifiedCargo.lockdiffbeforeafterboth
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1119,6 +1119,7 @@
  "jrsonnet-stdlib",
  "jrsonnet-types",
  "js-sys",
+ "rustc-hash 2.1.2",
  "url",
  "wasm-bindgen",
  "wasm-bindgen-futures",
modifiedREADME.adocdiffbeforeafterboth
--- a/README.adoc
+++ b/README.adoc
@@ -88,6 +88,21 @@
 
 Jrsonnet is written in rust itself, so just add it as dependency
 
+=== JavaScript/TypeScript (WASM)
+
+// image:https://img.shields.io/npm/v/jrsonnet[alt=npm, link=https://www.npmjs.com/package/jrsonnet]
+image:https://jsr.io/badges/@jrsonnet/jrsonnet[alt=JSR, link=https://jsr.io/@jrsonnet/jrsonnet]
+image:https://jsr.io/badges/@jrsonnet/jrsonnet/score[alt=JSR score, link=https://jsr.io/@jrsonnet/jrsonnet]
+
+WASM bindings are published to JSR as `@jrsonnet/jrsonnet`.
+
+[source,sh]
+----
+deno add jsr:@jrsonnet/jrsonnet
+----
+
+// npm package (`jrsonnet`) is not yet published.
+
 === Python
 
 image:https://img.shields.io/pypi/v/rjsonnet[alt=crates.io, link=https://pypi.org/project/rjsonnet/]
@@ -100,6 +115,6 @@
 
 === Other
 
-WASM bingings are also available, Java bindings (Both JNI and WASM compiled to `.class`) are in progress
+Java bindings (Both JNI and WASM compiled to `.class`) are in progress
 
 See link:./bindings/[bindings] for more information.
modifiedbindings/jrsonnet-web/Cargo.tomldiffbeforeafterboth
--- a/bindings/jrsonnet-web/Cargo.toml
+++ b/bindings/jrsonnet-web/Cargo.toml
@@ -1,6 +1,8 @@
 [package]
 name = "jrsonnet-web"
-description = "WASM JS bindings for jrsonnet"
+description = "WebAssembly JavaScript bindings for jrsonnet"
+keywords = ["jsonnet", "wasm", "web"]
+categories = ["wasm"]
 authors.workspace = true
 edition.workspace = true
 license.workspace = true
@@ -17,6 +19,7 @@
 jrsonnet-stdlib.workspace = true
 jrsonnet-types.workspace = true
 js-sys.workspace = true
+rustc-hash.workspace = true
 url.workspace = true
 wasm-bindgen.workspace = true
 wasm-bindgen-futures.workspace = true
modifiedbindings/jrsonnet-web/deno.jsondiffbeforeafterboth
--- a/bindings/jrsonnet-web/deno.json
+++ b/bindings/jrsonnet-web/deno.json
@@ -1,7 +1,7 @@
 {
 	"name": "@jrsonnet/jrsonnet",
 	"license": "MIT",
-	"version": "0.0.1",
+	"version": "0.0.2",
 	"tasks": {
 		"wasmbuild": "deno run -A @deno/wasmbuild -p jrsonnet-web --skip-opt"
 	},
@@ -11,11 +11,15 @@
 		"@std/assert": "jsr:@std/assert@^1.0.19"
 	},
 	"exports": {
-		".": "./mod.ts"
+		".": "./mod.ts",
+		"./fmt": "./fmt.ts"
 	},
 	"publish": {
 		"exclude": [
 			"!lib"
 		]
+	},
+	"fmt": {
+		"useTabs": true
 	}
 }
modifiedbindings/jrsonnet-web/fmt.test.tsdiffbeforeafterboth
--- a/bindings/jrsonnet-web/fmt.test.ts
+++ b/bindings/jrsonnet-web/fmt.test.ts
@@ -1,7 +1,7 @@
 import { assertEquals } from "@std/assert";
-import { format, FormatOptions } from "./mod.ts";
+import { format, FormatOptions } from "./fmt.ts";
 
 Deno.test("format", () => {
-  const opts = new FormatOptions();
-  assertEquals(format("{a:1+1}", opts), "{ a: 1 + 1 }\n");
+	const opts = new FormatOptions();
+	assertEquals(format("{a:1+1}", opts), "{ a: 1 + 1 }\n");
 });
modifiedbindings/jrsonnet-web/fmt.tsdiffbeforeafterboth
--- a/bindings/jrsonnet-web/fmt.ts
+++ b/bindings/jrsonnet-web/fmt.ts
@@ -0,0 +1 @@
+export { format, FormatOptions } from "./lib/jsonnet_web.js";
modifiedbindings/jrsonnet-web/mod.test.tsdiffbeforeafterboth
--- a/bindings/jrsonnet-web/mod.test.ts
+++ b/bindings/jrsonnet-web/mod.test.ts
@@ -1,8 +1,139 @@
-import { assertEquals } from "@std/assert";
-import { WasmState } from "./mod.ts";
+import { assertEquals, assertRejects, assertThrows } from "@std/assert";
+import { type ImportResolver, JrsonnetError, State, ValKind } from "./mod.ts";
+
+Deno.test("evaluateSnippet returns numbers", () => {
+	const state = new State();
+	const v = state.evaluateSnippet("test.jsonnet", "1 + 2");
+	assertEquals(v.kind, ValKind.Num);
+	assertEquals(v.asNum(), 3);
+});
+
+Deno.test("evaluateSnippet returns booleans", () => {
+	const state = new State();
+	const v = state.evaluateSnippet("test.jsonnet", "true && !false");
+	assertEquals(v.kind, ValKind.Bool);
+	assertEquals(v.asBool(), true);
+});
+
+Deno.test("evaluateSnippet returns strings", () => {
+	const state = new State();
+	const v = state.evaluateSnippet("test.jsonnet", "'hello ' + 'world'");
+	assertEquals(v.kind, ValKind.Str);
+	assertEquals(v.asString(), "hello world");
+});
 
-Deno.test("basic", () => {
-  const state = new WasmState();
+Deno.test("evaluateSnippet returns null", () => {
+	const state = new State();
+	const v = state.evaluateSnippet("test.jsonnet", "null");
+	assertEquals(v.kind, ValKind.Null);
+	assertEquals(v.asNum(), undefined);
+});
 
-  assertEquals(state.evaluate_snippet("test.jsonnet", "1 + 2").as_num(), 3);
+Deno.test("Val.asArr exposes ArrValue", () => {
+	const state = new State();
+	const arr = state.evaluateSnippet("test.jsonnet", "[10, 20, 30]").asArr();
+	if (!arr) throw new Error("expected array");
+	assertEquals(arr.length, 3);
+	assertEquals(arr.at(1)?.asNum(), 20);
+	assertEquals(arr.at(99), undefined);
+});
+
+Deno.test("Val.asObj exposes ObjValue", () => {
+	const state = new State();
+	const obj = state.evaluateSnippet("test.jsonnet", "{a: 1, b: 'two'}").asObj();
+	if (!obj) throw new Error("expected object");
+	assertEquals(obj.keys().sort(), ["a", "b"]);
+	assertEquals(obj.get("a")?.asNum(), 1);
+	assertEquals(obj.get("b")?.asString(), "two");
+	assertEquals(obj.get("missing"), undefined);
+});
+
+Deno.test("evaluateSnippet manifests JSON", () => {
+	const state = new State();
+	const v = state.evaluateSnippet("test.jsonnet", "{a: 1, b: [2, 3]}");
+	assertEquals(v.manifestJson(0), '{"a":1,"b":[2,3]}');
+});
+
+Deno.test("evaluateSnippet propagates jsonnet errors", () => {
+	const state = new State();
+	assertThrows(() => state.evaluateSnippet("test.jsonnet", "error 'boom'"));
+});
+
+Deno.test("evaluateFile without resolver rejects", async () => {
+	const state = new State();
+	await assertRejects(() => state.evaluateFile("anything.jsonnet"));
+});
+
+Deno.test("resolver errors become JrsonnetError with cause", async () => {
+	const original = new Error("disk on fire");
+	const resolver: ImportResolver = {
+		resolveFrom(_from, path) {
+			return Promise.resolve(`memory:///${path}`);
+		},
+		loadFileContents(_resolved) {
+			throw original;
+		},
+	};
+	const state = new State(resolver);
+	const err = await assertRejects(
+		() => state.evaluateFile("anything.jsonnet"),
+		JrsonnetError,
+		"loadFileContents",
+	);
+	assertEquals(err.cause, original);
+	assertEquals(err.frames[0]?.desc, "loadFileContents");
+	// The wrapped error's own stack must not mention internal wasm frames.
+	assertEquals((err.stack ?? "").includes(".wasm"), false);
+});
+
+Deno.test("Val.applyTla calls function with named args", () => {
+	const state = new State();
+	const fn = state.evaluateSnippet(
+		"test.jsonnet",
+		"function(x, y) x + y",
+	);
+	const result = fn.applyTla({
+		x: state.evaluateSnippet("x.jsonnet", "10"),
+		y: state.evaluateSnippet("y.jsonnet", "32"),
+	});
+	assertEquals(result.asNum(), 42);
+});
+
+Deno.test("Val.applyTla borrows args without consuming them", () => {
+	const state = new State();
+	const fn = state.evaluateSnippet("test.jsonnet", "function(x) x * 2");
+	const x = state.evaluateSnippet("x.jsonnet", "21");
+	assertEquals(fn.applyTla({ x }).asNum(), 42);
+	assertEquals(x.asNum(), 21);
+	assertEquals(fn.applyTla({ x }).asNum(), 42);
+});
+
+Deno.test("Val.applyTla on non-function returns the value unchanged", () => {
+	const state = new State();
+	const v = state.evaluateSnippet("test.jsonnet", "123");
+	assertEquals(v.applyTla({}).asNum(), 123);
+});
+
+Deno.test("evaluateFileFrom resolves relative paths", async () => {
+	const files: Record<string, string> = {
+		"memory:///root/main.jsonnet": "import 'lib.jsonnet'",
+		"memory:///root/lib.jsonnet": "{ answer: 42 }",
+	};
+	const resolver: ImportResolver = {
+		resolveFrom(from, path) {
+			const base = from ?? "memory:///root/";
+			return Promise.resolve(new URL(path, base).toString());
+		},
+		loadFileContents(resolved) {
+			const code = files[resolved];
+			if (code === undefined) throw new Error(`missing ${resolved}`);
+			return Promise.resolve(new TextEncoder().encode(code));
+		},
+	};
+	const state = new State(resolver);
+	const v = await state.evaluateFileFrom(
+		"memory:///root/main.jsonnet",
+		"./lib.jsonnet",
+	);
+	assertEquals(v.asObj()?.get("answer")?.asNum(), 42);
 });
modifiedbindings/jrsonnet-web/mod.tsdiffbeforeafterboth
--- a/bindings/jrsonnet-web/mod.ts
+++ b/bindings/jrsonnet-web/mod.ts
@@ -1,60 +1,71 @@
 import { assert } from "@std/assert";
 import {
-  format as formatRaw,
-  type ImportResolver,
-  WasmFormatOptions,
-  WasmState,
-  WasmVal,
+	ArrValue,
+	type ImportResolver,
+	ObjValue,
+	setErrorFactory,
+	State,
+	Val,
+	ValKind,
 } from "./lib/jsonnet_web.js";
 
-export { type ImportResolver, WasmFormatOptions, WasmState, WasmVal };
+export interface JrsonnetFrame {
+	desc: string;
+	path?: string;
+	line?: number;
+	column?: number;
+}
 
-class FetchImportResolver implements ImportResolver {
-  constructor(public base: string) {}
+export class JrsonnetError extends Error {
+	override name = "JrsonnetError" as const;
+	frames: JrsonnetFrame[];
 
-  resolution = new Map<string, URL>();
-  response = new Map<string, Response>();
+	constructor(message: string, frames: JrsonnetFrame[], cause?: unknown) {
+		super(message, cause !== undefined ? { cause } : undefined);
+		this.frames = frames;
+	}
+}
 
-  async resolveFrom(from: string | undefined, path: string): Promise<string> {
-    let resolved: URL;
-    if (from) {
-      resolved = new URL(path, from);
-    } else {
-      resolved = new URL(path, this.base);
-    }
-    const resolvingStr = resolved.toString();
-    resolved = this.resolution.get(resolvingStr) ?? resolved;
+setErrorFactory(
+	(message: string, frames: JrsonnetFrame[], cause: unknown) =>
+		new JrsonnetError(message, frames, cause),
+);
+
+export { ArrValue, type ImportResolver, ObjValue, State, Val, ValKind };
 
-    const resolvedStr = resolved.toString();
-    if (!this.response.has(resolvedStr)) {
-      console.log(resolved);
-      const v = await fetch(resolved);
-      this.response.set(resolvedStr, v);
-      resolved = new URL(v.url);
-      this.resolution.set(resolvingStr, resolved);
-    }
-    return resolved.toString();
-  }
-  loadFileContents(resolved: string): Promise<Uint8Array> {
-    console.log(resolved);
-    const v = this.response.get(resolved);
-    assert(v, "should be resolved");
-    return v.bytes();
-  }
-}
+export class FetchImportResolver implements ImportResolver {
+	constructor(base: URL | string) {
+		this.#base = new URL(base);
+	}
 
-//
-// try {
-//   console.log("eval file");
-//   await state.evaluate_file("example.jsonnet");
-//   console.log("eval file done");
-// } catch (e) {
-//   console.log(e);
-// }
-//
-export function format(
-  code: string,
-  opts: WasmFormatOptions = new WasmFormatOptions(),
-): string {
-  return formatRaw(code, opts);
+	#base: URL;
+	#resolution = new Map<string, string>();
+	#bytes = new Map<string, Uint8Array>();
+
+	async resolveFrom(from: string | undefined, path: string): Promise<string> {
+		const base = from !== undefined ? from : this.#base;
+		const requestStr = new URL(path, base).toString();
+
+		const cached = this.#resolution.get(requestStr);
+		if (cached !== undefined) return cached;
+
+		const resp = await fetch(requestStr);
+		if (!resp.ok) {
+			throw new Error(
+				`fetch ${requestStr}: HTTP ${resp.status} ${resp.statusText}`,
+			);
+		}
+		const canonical = resp.url;
+		if (!this.#bytes.has(canonical)) {
+			this.#bytes.set(canonical, await resp.bytes());
+		}
+		this.#resolution.set(requestStr, canonical);
+		return canonical;
+	}
+
+	loadFileContents(resolved: string): Promise<Uint8Array> {
+		const bytes = this.#bytes.get(resolved);
+		assert(bytes, `not loaded: ${resolved}`);
+		return Promise.resolve(bytes);
+	}
 }
modifiedbindings/jrsonnet-web/scripts/build_npm.tsdiffbeforeafterboth
before · bindings/jrsonnet-web/scripts/build_npm.ts
1import { build, emptyDir } from "@deno/dnt";23await emptyDir("./npm");45await build({6  entryPoints: ["./mod.ts"],7  outDir: "./npm",8  shims: {9    // see JS docs for overview and more options10    deno: true,11  },12  package: {13    // package.json properties14    name: "jrsonnet",15    version: Deno.args[0],16    description: "Jrsonnet.",17    license: "MIT",18    repository: {19      type: "git",20      url: "git+https://github.com/CertainLach/jrsonnet.git",21    },22    bugs: {23      url: "https://github.com/CertainLach/jrsonnet/issues",24    },25  },26  postBuild() {27    Deno.copyFileSync("../../LICENSE", "npm/LICENSE");28  },29});
after · bindings/jrsonnet-web/scripts/build_npm.ts
1import { build, emptyDir } from "@deno/dnt";23await emptyDir("./npm");45await build({6	entryPoints: ["./mod.ts"],7	outDir: "./npm",8	shims: {9		// see JS docs for overview and more options10		deno: true,11	},12	package: {13		// package.json properties14		name: "jrsonnet",15		version: Deno.args[0],16		description: "Jrsonnet.",17		license: "MIT",18		repository: {19			type: "git",20			url: "git+https://github.com/CertainLach/jrsonnet.git",21		},22		bugs: {23			url: "https://github.com/CertainLach/jrsonnet/issues",24		},25	},26	postBuild() {27		Deno.copyFileSync("../../LICENSE", "npm/LICENSE");28	},29});
modifiedbindings/jrsonnet-web/src/lib.rsdiffbeforeafterboth
--- a/bindings/jrsonnet-web/src/lib.rs
+++ b/bindings/jrsonnet-web/src/lib.rs
@@ -1,19 +1,25 @@
-use std::result::Result;
+#![allow(clippy::future_not_send, reason = "we work with js promises anyway")]
 
+use std::{cell::RefCell, result::Result};
+
 use jrsonnet_evaluator::{
-	NumValue, Result as JrResult, SourcePath, SourceUrl, State, StateBuilder, Val,
+	IStr, NumValue, ObjValue, Result as JrResult, SourcePath, SourceUrl, State, StateBuilder, Val,
 	async_import::{ResolvedImportResolver, async_import},
 	error,
 	function::builtin::{NativeCallback, NativeCallbackHandler},
 	manifest::{JsonFormat, ManifestFormat, StringFormat, ToStringFormat, YamlStreamFormat},
-	trace::{JsFormat, PathResolver, TraceFormat},
+	tla::{TlaArg, apply_tla},
+	trace::PathResolver,
+	val::ArrValue,
 	with_state,
 };
 use jrsonnet_formatter::FormatOptions;
 use jrsonnet_gcmodule::Trace;
 use jrsonnet_stdlib::{IniFormat, TomlFormat, XmlJsonmlFormat, YamlFormat};
 use jrsonnet_types::ValType;
-use wasm_bindgen::prelude::*;
+use js_sys::Reflect::get;
+use rustc_hash::FxHashMap;
+use wasm_bindgen::{convert::RefFromWasmAbi, prelude::*};
 
 #[wasm_bindgen]
 #[derive(Clone, Copy)]
@@ -27,37 +33,60 @@
 	Func,
 }
 
-#[wasm_bindgen(inline_js = r"
-export class JrsonnetError extends Error {
-	constructor(message, frames) {
-		super(message);
-		this.name = 'JrsonnetError';
-		this.frames = frames;
-	}
+thread_local! {
+	static ERR_FACTORY: RefCell<Option<js_sys::Function>> = const { RefCell::new(None) };
 }
-export function makeJrsonnetError(message, frames) {
-	return new JrsonnetError(message, frames);
+#[wasm_bindgen(js_name = setErrorFactory)]
+pub fn set_error_factory(f: js_sys::Function) {
+	ERR_FACTORY.with(|c| *c.borrow_mut() = Some(f));
 }
-")]
-extern "C" {
-	#[wasm_bindgen(js_name = makeJrsonnetError)]
-	fn make_jrsonnet_error(message: &str, frames: js_sys::Array) -> JsValue;
+fn make_jrsonnet_error(message: &str, frames: js_sys::Array, cause: &JsValue) -> JsValue {
+	ERR_FACTORY.with(|c| {
+		c.borrow().as_ref().map_or_else(
+			|| js_sys::Error::new(message).into(),
+			|f| {
+				let args = js_sys::Array::new();
+				args.push(&JsValue::from_str(message));
+				args.push(&frames);
+				args.push(cause);
+				f.apply(&JsValue::NULL, &args)
+					.unwrap_or_else(|e| js_sys::Error::new(&format!("{e:?}")).into())
+			},
+		)
+	})
 }
 
-#[wasm_bindgen(typescript_custom_section)]
-const TS_JRSONNET_ERROR: &'static str = r"
-export interface JrsonnetFrame {
-	desc: string;
-	path?: string;
-	line?: number;
-	column?: number;
+fn js_error_message(e: &JsValue) -> String {
+	e.dyn_ref::<js_sys::Error>().map_or_else(
+		|| e.as_string().unwrap_or_else(|| format!("{e:?}")),
+		|err| String::from(err.message()),
+	)
 }
-export class JrsonnetError extends Error {
-	name: 'JrsonnetError';
-	frames: JrsonnetFrame[];
+
+fn unwrap_val_ref(value: &JsValue) -> Result<<WasmVal as RefFromWasmAbi>::Anchor, JsValue> {
+	let ptr = get(value, &JsValue::from_str("__wbg_ptr"))
+		.ok()
+		.and_then(|v| v.as_f64())
+		.ok_or_else(|| JsValue::from_str("expected a Val instance"))? as u32;
+	if ptr == 0 {
+		return Err(JsValue::from_str("Val has been freed"));
+	}
+	Ok(unsafe { <WasmVal as RefFromWasmAbi>::ref_from_abi(ptr) })
 }
-";
 
+fn js_resolver_error(prefix: &str, e: JsValue) -> JsValue {
+	let msg = format!("{prefix}: {}", js_error_message(&e));
+	let frames = js_sys::Array::new();
+	let frame = js_sys::Object::new();
+	let _ = js_sys::Reflect::set(
+		&frame,
+		&JsValue::from_str("desc"),
+		&JsValue::from_str(prefix),
+	);
+	frames.push(&frame);
+	make_jrsonnet_error(&msg, frames, &e)
+}
+
 fn jrsonnet_js_error(e: &jrsonnet_evaluator::Error) -> JsValue {
 	let msg = e.error().to_string();
 	// let msg = format.format(e).unwrap_or_else(|_| e.to_string());
@@ -90,7 +119,7 @@
 		}
 		frames.push(&frame);
 	}
-	make_jrsonnet_error(&msg, frames)
+	make_jrsonnet_error(&msg, frames, &JsValue::UNDEFINED)
 }
 
 impl From<ValType> for ValKind {
@@ -107,7 +136,7 @@
 	}
 }
 
-#[wasm_bindgen]
+#[wasm_bindgen(js_name = Val)]
 pub struct WasmVal {
 	val: Val,
 	state: Option<State>,
@@ -121,12 +150,6 @@
 		Self {
 			val,
 			state: Some(state),
-		}
-	}
-	fn child(&self, val: Val) -> Self {
-		Self {
-			val,
-			state: self.state.clone(),
 		}
 	}
 	fn run<R>(&self, f: impl FnOnce(&Val) -> R) -> R {
@@ -143,7 +166,7 @@
 	}
 }
 
-#[wasm_bindgen]
+#[wasm_bindgen(js_class = Val)]
 impl WasmVal {
 	pub fn null() -> Self {
 		Self::new(Val::Null)
@@ -167,7 +190,7 @@
 	pub fn func(
 		params: Vec<String>,
 
-		#[wasm_bindgen(unchecked_param_type = "(...args: WasmVal[]) => WasmVal")]
+		#[wasm_bindgen(unchecked_param_type = "(...args: Val[]) => Val")]
 		callback: js_sys::Function,
 	) -> Self {
 		#[allow(deprecated)]
@@ -181,52 +204,76 @@
 	pub fn kind(&self) -> ValKind {
 		self.val.value_type().into()
 	}
+	#[wasm_bindgen(js_name = asBool)]
 	pub fn as_bool(&self) -> Option<bool> {
 		self.val.as_bool()
 	}
+	#[wasm_bindgen(js_name = asNum)]
 	pub fn as_num(&self) -> Option<f64> {
 		self.val.as_num()
 	}
+	#[wasm_bindgen(js_name = asString)]
 	pub fn as_string(&self) -> Option<String> {
 		self.val.as_str().map(|s| s.to_string())
-	}
-	pub fn arr_len(&self) -> Option<u32> {
-		self.val.as_arr().map(|a| a.len())
 	}
-	pub fn arr_at(&self, index: u32) -> Result<Option<WasmVal>, JsValue> {
-		let Some(a) = self.val.as_arr() else {
-			return Ok(None);
-		};
-		self.run(|_| a.get(index))
-			.map(|opt| opt.map(|v| self.child(v)))
-			.map_err(|e| jrsonnet_js_error(&e))
+	#[wasm_bindgen(js_name = asArr)]
+	pub fn as_arr(&self) -> Option<WasmArrValue> {
+		self.val.as_arr().map(|arr| WasmArrValue {
+			arr,
+			state: self.state.clone(),
+		})
 	}
-	pub fn obj_keys(&self) -> Option<Vec<String>> {
-		self.val
-			.as_obj()
-			.map(|o| o.fields().into_iter().map(|s| s.to_string()).collect())
+	#[wasm_bindgen(js_name = asObj)]
+	pub fn as_obj(&self) -> Option<WasmObjValue> {
+		self.val.as_obj().map(|obj| WasmObjValue {
+			obj,
+			state: self.state.clone(),
+		})
 	}
-	pub fn obj_get(&self, key: String) -> Result<Option<WasmVal>, JsValue> {
-		let Some(o) = self.val.as_obj() else {
-			return Ok(None);
-		};
-		self.run(|_| o.get(key.into()))
-			.map(|opt| opt.map(|v| self.child(v)))
+
+	#[wasm_bindgen(js_name = applyTla)]
+	pub fn apply_tla(
+		&self,
+		#[wasm_bindgen(unchecked_param_type = "Record<string, Val>")] args: &js_sys::Object,
+	) -> Result<WasmVal, JsValue> {
+		let mut map: FxHashMap<IStr, TlaArg> = FxHashMap::default();
+		for entry in js_sys::Object::entries(args).iter() {
+			let pair: js_sys::Array = entry
+				.dyn_into()
+				.map_err(|_| JsValue::from_str("expected [key, value] entry"))?;
+			let key = pair
+				.get(0)
+				.as_string()
+				.ok_or_else(|| JsValue::from_str("TLA arg key must be a string"))?;
+			let value = unwrap_val_ref(&pair.get(1))?;
+			map.insert(key.into(), TlaArg::Val(value.val.clone()));
+		}
+		let val = self.val.clone();
+		self.run(|_| apply_tla(&map, val))
+			.map(|v| WasmVal {
+				val: v,
+				state: self.state.clone(),
+			})
 			.map_err(|e| jrsonnet_js_error(&e))
 	}
 
+	#[wasm_bindgen(js_name = manifestJson)]
 	pub fn manifest_json(&self, indent: u32) -> Result<String, JsValue> {
 		self.manifest_with(JsonFormat::cli(indent as usize))
 	}
+	#[wasm_bindgen(js_name = manifestToString)]
 	pub fn manifest_to_string(&self) -> Result<String, JsValue> {
 		self.manifest_with(ToStringFormat)
 	}
+	#[wasm_bindgen(js_name = manifestString)]
 	pub fn manifest_string(&self) -> Result<String, JsValue> {
 		self.manifest_with(StringFormat)
 	}
+	#[wasm_bindgen(js_name = manifestYaml)]
 	pub fn manifest_yaml(&self, indent: u32, quote_keys: bool) -> Result<String, JsValue> {
 		self.manifest_with(YamlFormat::std_to_yaml(indent != 0, quote_keys))
 	}
+	#[wasm_bindgen(js_name = manifestYamlStream)]
 	pub fn manifest_yaml_stream(
 		&self,
 		indent: u32,
@@ -238,17 +285,84 @@
 			c_document_end,
 		))
 	}
+	#[wasm_bindgen(js_name = manifestXmlJsonml)]
 	pub fn manifest_xml_jsonml(&self) -> Result<String, JsValue> {
 		self.manifest_with(XmlJsonmlFormat::std_to_xml())
 	}
+	#[wasm_bindgen(js_name = manifestToml)]
 	pub fn manifest_toml(&self, indent: u32) -> Result<String, JsValue> {
 		self.manifest_with(TomlFormat::std_to_toml(" ".repeat(indent as usize)))
 	}
+	#[wasm_bindgen(js_name = manifestIni)]
 	pub fn manifest_ini(&self) -> Result<String, JsValue> {
 		self.manifest_with(IniFormat::std())
 	}
 }
 
+#[wasm_bindgen(js_name = ArrValue)]
+pub struct WasmArrValue {
+	arr: ArrValue,
+	state: Option<State>,
+}
+
+#[wasm_bindgen(js_class = ArrValue)]
+impl WasmArrValue {
+	#[wasm_bindgen(getter)]
+	pub fn length(&self) -> u32 {
+		self.arr.len()
+	}
+	pub fn at(&self, index: u32) -> Result<Option<WasmVal>, JsValue> {
+		let result = self.state.as_ref().map_or_else(
+			|| self.arr.get(index),
+			|state| {
+				let _guard = state.try_enter();
+				self.arr.get(index)
+			},
+		);
+		result
+			.map(|opt: Option<Val>| {
+				opt.map(|v| WasmVal {
+					val: v,
+					state: self.state.clone(),
+				})
+			})
+			.map_err(|e| jrsonnet_js_error(&e))
+	}
+}
+
+#[wasm_bindgen(js_name = ObjValue)]
+pub struct WasmObjValue {
+	obj: ObjValue,
+	state: Option<State>,
+}
+
+#[wasm_bindgen(js_class = ObjValue)]
+impl WasmObjValue {
+	pub fn keys(&self) -> Vec<String> {
+		self.obj
+			.fields()
+			.into_iter()
+			.map(|s| s.to_string())
+			.collect()
+	}
+	pub fn get(&self, key: String) -> Result<Option<WasmVal>, JsValue> {
+		let result = if let Some(state) = &self.state {
+			let _guard = state.try_enter();
+			self.obj.get(key.into())
+		} else {
+			self.obj.get(key.into())
+		};
+		result
+			.map(|opt: Option<Val>| {
+				opt.map(|v| WasmVal {
+					val: v,
+					state: self.state.clone(),
+				})
+			})
+			.map_err(|e| jrsonnet_js_error(&e))
+	}
+}
+
 #[derive(Trace)]
 struct JsHandler {
 	#[trace(skip)]
@@ -292,15 +406,15 @@
 	}
 }
 
-#[wasm_bindgen]
+#[wasm_bindgen(js_name = State)]
 pub struct WasmState {
 	state: State,
-	resolver: JsAsyncResolver,
+	resolver: Option<JsAsyncResolver>,
 }
-#[wasm_bindgen]
+#[wasm_bindgen(js_class = State)]
 impl WasmState {
 	#[wasm_bindgen(constructor)]
-	pub fn new(resolver: ImportResolverJs) -> Self {
+	pub fn new(resolver: Option<ImportResolverJs>) -> Self {
 		console_error_panic_hook::set_once();
 		let mut state = StateBuilder::default();
 		state.import_resolver(ResolvedImportResolver::new());
@@ -309,11 +423,11 @@
 		let state = state.build();
 		Self {
 			state,
-			resolver: JsAsyncResolver { js: resolver },
+			resolver: resolver.map(|js| JsAsyncResolver { js }),
 		}
 	}
 
-	#[wasm_bindgen]
+	#[wasm_bindgen(js_name = evaluateSnippet)]
 	pub fn evaluate_snippet(&self, name: &str, snippet: &str) -> Result<WasmVal, JsValue> {
 		let _guard = self.state.enter();
 		self.state
@@ -322,8 +436,35 @@
 			.map_err(|e| jrsonnet_js_error(&e))
 	}
 
+	#[wasm_bindgen(js_name = evaluateFile)]
 	pub async fn evaluate_file(&self, path: String) -> Result<WasmVal, JsValue> {
-		let path = async_import(self.state.clone(), self.resolver.clone(), &path.as_str()).await?;
+		self.evaluate_file_from_impl(None, path).await
+	}
+
+	#[wasm_bindgen(js_name = evaluateFileFrom)]
+	pub async fn evaluate_file_from(&self, from: String, path: String) -> Result<WasmVal, JsValue> {
+		self.evaluate_file_from_impl(Some(from), path).await
+	}
+}
+
+impl WasmState {
+	async fn evaluate_file_from_impl(
+		&self,
+		from: Option<String>,
+		path: String,
+	) -> Result<WasmVal, JsValue> {
+		let resolver = self
+			.resolver
+			.clone()
+			.ok_or_else(|| JsValue::from_str("file evaluation requires an ImportResolver"))?;
+		let from = match from {
+			Some(s) => {
+				let url = url::Url::parse(&s).map_err(|e| JsValue::from_str(&e.to_string()))?;
+				SourcePath::new(SourceUrl::new(url))
+			}
+			None => SourcePath::default(),
+		};
+		let path = async_import(self.state.clone(), resolver, &from, &path.as_str()).await?;
 		let _guard = self.state.enter();
 		self.state
 			.import_resolved(path)
@@ -375,8 +516,13 @@
 	) -> Result<SourcePath, JsValue> {
 		let from_js = (!from.is_default()).then(|| from.to_string());
 		let path_str = path.as_path().as_ref().to_string_lossy().into_owned();
-		let promise = self.js.resolve_from(from_js, &path_str)?;
-		let resolved_js = wasm_bindgen_futures::JsFuture::from(promise).await?;
+		let promise = self
+			.js
+			.resolve_from(from_js, &path_str)
+			.map_err(|e| js_resolver_error("resolveFrom", e))?;
+		let resolved_js = wasm_bindgen_futures::JsFuture::from(promise)
+			.await
+			.map_err(|e| js_resolver_error("resolveFrom", e))?;
 		let resolved_str = resolved_js
 			.as_string()
 			.ok_or_else(|| JsValue::from_str("resolveFrom must return string"))?;
@@ -386,8 +532,13 @@
 
 	async fn load_file_contents(&self, resolved: &SourcePath) -> Result<Vec<u8>, JsValue> {
 		let resolved_str = resolved.to_string();
-		let promise = self.js.load_file_contents(&resolved_str)?;
-		let bytes_js = wasm_bindgen_futures::JsFuture::from(promise).await?;
+		let promise = self
+			.js
+			.load_file_contents(&resolved_str)
+			.map_err(|e| js_resolver_error("loadFileContents", e))?;
+		let bytes_js = wasm_bindgen_futures::JsFuture::from(promise)
+			.await
+			.map_err(|e| js_resolver_error("loadFileContents", e))?;
 		let arr = bytes_js
 			.dyn_into::<js_sys::Uint8Array>()
 			.map_err(|_| JsValue::from_str("loadFileContents must return Uint8Array"))?;
@@ -395,17 +546,27 @@
 	}
 }
 
-#[wasm_bindgen]
-pub struct WasmFormatOptions {}
-#[wasm_bindgen]
+#[wasm_bindgen(js_name = FormatOptions)]
+pub struct WasmFormatOptions {
+	indent: u8,
+}
+#[wasm_bindgen(js_class = FormatOptions)]
 impl WasmFormatOptions {
 	#[wasm_bindgen(constructor)]
 	pub fn new() -> Self {
-		Self {}
+		Self { indent: 0 }
 	}
 
 	fn build(&self) -> FormatOptions {
-		FormatOptions { indent: 0 }
+		FormatOptions {
+			indent: self.indent,
+		}
+	}
+}
+
+impl Default for WasmFormatOptions {
+	fn default() -> Self {
+		Self::new()
 	}
 }