From 89809a88817da4fb8326e2465f6aea4902771953 Mon Sep 17 00:00:00 2001
From: MickeyShnaiderman-RecoLabs
Date: Mon, 30 Mar 2026 15:54:06 +0300
Subject: [PATCH] Add npm package with WASM + TypeScript wrapper
Ship gnata WASM as an npm package (gnata-js) for browser consumption.
Same Go evaluator as backend services, for expression parity.
- npm/: TypeScript package with jsonata()-compatible API
- scripts/build-npm.sh: builds WASM + TS in one step
- .github/workflows/publish-npm.yml: publishes on tag via OIDC
Made-with: Cursor
---
.github/workflows/publish-npm.yml | 34 ++++++++++++++++
.gitignore | 4 ++
README.md | 1 +
npm/README.md | 47 +++++++++++++++++++++
npm/package-lock.json | 30 ++++++++++++++
npm/package.json | 43 +++++++++++++++++++
npm/src/index.ts | 38 +++++++++++++++++
npm/src/loader.ts | 68 +++++++++++++++++++++++++++++++
npm/src/types.ts | 21 ++++++++++
npm/tsconfig.json | 19 +++++++++
npm/wasm/.gitignore | 3 ++
scripts/build-npm.sh | 19 +++++++++
wasm/main.go | 14 +++++++
13 files changed, 341 insertions(+)
create mode 100644 .github/workflows/publish-npm.yml
create mode 100644 npm/README.md
create mode 100644 npm/package-lock.json
create mode 100644 npm/package.json
create mode 100644 npm/src/index.ts
create mode 100644 npm/src/loader.ts
create mode 100644 npm/src/types.ts
create mode 100644 npm/tsconfig.json
create mode 100644 npm/wasm/.gitignore
create mode 100755 scripts/build-npm.sh
diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml
new file mode 100644
index 00000000..c7787ba8
--- /dev/null
+++ b/.github/workflows/publish-npm.yml
@@ -0,0 +1,34 @@
+name: Publish NPM
+
+on:
+ push:
+ tags: ['v*']
+
+permissions:
+ id-token: write
+ contents: read
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ registry-url: 'https://registry.npmjs.org'
+
+ - name: Install latest npm (required for trusted publishing)
+ run: npm install -g npm@latest
+
+ - name: Build WASM and TypeScript
+ run: bash scripts/build-npm.sh
+
+ - name: Publish to npm
+ working-directory: npm
+ run: npm publish
diff --git a/.gitignore b/.gitignore
index 6b418a2f..9d0d9316 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,7 @@
*.wasm
.secret
.DS_Store
+
+# npm package build artifacts
+npm/dist/
+npm/node_modules/
diff --git a/README.md b/README.md
index 24f39c9d..80f8b210 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,7 @@
+
diff --git a/npm/README.md b/npm/README.md
new file mode 100644
index 00000000..062124d9
--- /dev/null
+++ b/npm/README.md
@@ -0,0 +1,47 @@
+# gnata-js
+
+Browser [JSONata](https://jsonata.org) evaluation via [gnata](https://github.com/RecoLabs/gnata) WebAssembly. Runs the same Go evaluator used by backend services in the browser for expression parity — not a performance optimization over the `jsonata` npm package.
+
+## Install
+
+```bash
+npm install gnata-js
+```
+
+## Usage
+
+```typescript
+import { jsonata } from 'gnata-js';
+
+const result = await jsonata('Account.Order.Product.Price').evaluate({
+ Account: {
+ Order: [
+ { Product: { Price: 34.45 } },
+ { Product: { Price: 21.67 } },
+ ],
+ },
+});
+// [34.45, 21.67]
+```
+
+## Asset Setup
+
+The package ships `gnata.wasm` and `wasm_exec.js` in `node_modules/gnata-js/wasm/`. Copy them so your web server serves them at `/wasm/`:
+
+```javascript
+// rspack / webpack
+new CopyPlugin({
+ patterns: [{ from: 'node_modules/gnata-js/wasm', to: 'wasm' }],
+});
+```
+
+Override paths with `configure()` if needed:
+
+```typescript
+import { jsonata, configure } from 'gnata-js';
+configure({ wasmUrl: '/assets/gnata.wasm', execJsUrl: '/assets/wasm_exec.js' });
+```
+
+## License
+
+MIT
diff --git a/npm/package-lock.json b/npm/package-lock.json
new file mode 100644
index 00000000..984016cf
--- /dev/null
+++ b/npm/package-lock.json
@@ -0,0 +1,30 @@
+{
+ "name": "gnata",
+ "version": "0.2.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "gnata",
+ "version": "0.2.0",
+ "license": "MIT",
+ "devDependencies": {
+ "typescript": "^5.8.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ }
+ }
+}
diff --git a/npm/package.json b/npm/package.json
new file mode 100644
index 00000000..08075997
--- /dev/null
+++ b/npm/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "gnata-js",
+ "version": "0.2.1",
+ "description": "Browser JSONata via gnata WASM for backend parity, not a performance optimization",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/RecoLabs/gnata.git",
+ "directory": "npm"
+ },
+ "type": "module",
+ "main": "dist/index.js",
+ "module": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ },
+ "./wasm/*": "./wasm/*"
+ },
+ "files": [
+ "dist/",
+ "wasm/gnata.wasm",
+ "wasm/wasm_exec.js"
+ ],
+ "scripts": {
+ "build": "tsc",
+ "prepublishOnly": "npm run build"
+ },
+ "devDependencies": {
+ "typescript": "^5.8.0"
+ },
+ "keywords": [
+ "jsonata",
+ "wasm",
+ "webassembly",
+ "gnata",
+ "json",
+ "query",
+ "transformation"
+ ]
+}
diff --git a/npm/src/index.ts b/npm/src/index.ts
new file mode 100644
index 00000000..8ce042fd
--- /dev/null
+++ b/npm/src/index.ts
@@ -0,0 +1,38 @@
+import type { GnataConfig } from './types.js';
+import { initGnata, gnataEval } from './loader.js';
+
+export type { GnataConfig } from './types.js';
+
+const defaultConfig: GnataConfig = {
+ wasmUrl: '/wasm/gnata.wasm',
+ execJsUrl: '/wasm/wasm_exec.js',
+};
+
+let config: GnataConfig = { ...defaultConfig };
+
+/**
+ * Override the default asset URLs for WASM and wasm_exec.js.
+ *
+ * Call before the first `jsonata()` evaluation if your assets are
+ * served from a non-default path.
+ */
+export function configure(overrides: Partial): void {
+ config = { ...config, ...overrides };
+}
+
+/**
+ * Evaluate a JSONata expression against data using gnata WASM.
+ *
+ * API-compatible with the `jsonata` npm package's default export:
+ * `jsonata(expr).evaluate(data)`.
+ */
+export function jsonata(expression: string): {
+ evaluate(data: unknown): Promise;
+} {
+ return {
+ async evaluate(data: unknown) {
+ await initGnata(config);
+ return gnataEval(expression, data);
+ },
+ };
+}
diff --git a/npm/src/loader.ts b/npm/src/loader.ts
new file mode 100644
index 00000000..f9eb4a3d
--- /dev/null
+++ b/npm/src/loader.ts
@@ -0,0 +1,68 @@
+import type { GnataConfig } from './types.js';
+
+let wasmReady = false;
+let loadingPromise: Promise | null = null;
+
+function loadScript(src: string): Promise {
+ return new Promise((resolve, reject) => {
+ const script = document.createElement('script');
+ script.src = src;
+ script.onload = () => resolve();
+ script.onerror = () => reject(new Error(`Failed to load ${src}`));
+ document.head.appendChild(script);
+ });
+}
+
+export async function initGnata(config: GnataConfig): Promise {
+ if (wasmReady) return;
+ if (loadingPromise) return loadingPromise;
+
+ loadingPromise = (async () => {
+ try {
+ if (typeof Go === 'undefined') {
+ await loadScript(config.execJsUrl);
+ }
+
+ const go = new Go();
+ const resp = await fetch(config.wasmUrl);
+ if (!resp.ok) {
+ throw new Error(
+ `Failed to fetch gnata.wasm: ${resp.status} ${resp.statusText}`,
+ );
+ }
+
+ const result = await WebAssembly.instantiateStreaming(
+ resp,
+ go.importObject,
+ );
+ go.run(result.instance).catch((err: unknown) => {
+ console.error('gnata WASM runtime exited unexpectedly:', err);
+ wasmReady = false;
+ loadingPromise = null;
+ });
+ wasmReady = true;
+ } catch (err) {
+ loadingPromise = null;
+ throw err instanceof Error
+ ? err
+ : new Error(`gnata init failed: ${String(err)}`);
+ }
+ })();
+
+ return loadingPromise;
+}
+
+function unwrapWasm(result: T | Error): T {
+ if (result instanceof Error) throw result;
+ return result;
+}
+
+export function gnataEval(expression: string, data: unknown): unknown {
+ const jsonData = data != null ? JSON.stringify(data) : '';
+ const raw = unwrapWasm(_gnataEval(expression, jsonData));
+ // gnata returns "" for undefined (non-matching paths) and "null" for
+ // actual JSON null. This preserves the jsonata npm semantic where
+ // non-matching expressions return undefined.
+ if (raw === '') return undefined;
+ return JSON.parse(raw);
+}
diff --git a/npm/src/types.ts b/npm/src/types.ts
new file mode 100644
index 00000000..770c5957
--- /dev/null
+++ b/npm/src/types.ts
@@ -0,0 +1,21 @@
+declare global {
+ class Go {
+ importObject: WebAssembly.Imports;
+ run(instance: WebAssembly.Instance): Promise;
+ }
+
+ // Raw WASM exports registered by gnata's Go main() on the global object.
+ // See wasm/main.go for the Go-side definitions.
+ function _gnataEval(expr: string, jsonData: string): string | Error;
+ function _gnataCompile(expr: string): number | Error;
+ function _gnataEvalHandle(
+ handle: number,
+ jsonData: string,
+ ): string | Error;
+ function _gnataReleaseHandle(handle: number): undefined | Error;
+}
+
+export interface GnataConfig {
+ wasmUrl: string;
+ execJsUrl: string;
+}
diff --git a/npm/tsconfig.json b/npm/tsconfig.json
new file mode 100644
index 00000000..a5e21d89
--- /dev/null
+++ b/npm/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ES2020",
+ "moduleResolution": "bundler",
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "outDir": "dist",
+ "rootDir": "src",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "lib": ["ES2020", "DOM"]
+ },
+ "include": ["src"],
+ "exclude": ["dist", "node_modules"]
+}
diff --git a/npm/wasm/.gitignore b/npm/wasm/.gitignore
new file mode 100644
index 00000000..5718e52a
--- /dev/null
+++ b/npm/wasm/.gitignore
@@ -0,0 +1,3 @@
+# Build artifacts generated by scripts/build-npm.sh
+gnata.wasm
+wasm_exec.js
diff --git a/scripts/build-npm.sh b/scripts/build-npm.sh
new file mode 100755
index 00000000..2edc2a16
--- /dev/null
+++ b/scripts/build-npm.sh
@@ -0,0 +1,19 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+echo "==> Building gnata.wasm..."
+GOOS=js GOARCH=wasm go build -ldflags="-s -w" -trimpath -o npm/wasm/gnata.wasm ./wasm/
+
+echo "==> Copying wasm_exec.js from Go toolchain..."
+cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" npm/wasm/wasm_exec.js
+
+echo "==> Installing npm dependencies..."
+cd npm && npm install
+
+echo "==> Building TypeScript..."
+npm run build
+
+echo "==> Done."
+echo " npm/wasm/ contains gnata.wasm + wasm_exec.js"
+echo " npm/dist/ contains JS + .d.ts"
diff --git a/wasm/main.go b/wasm/main.go
index 951cf3f3..9ba78a26 100644
--- a/wasm/main.go
+++ b/wasm/main.go
@@ -157,6 +157,12 @@ func doEvalHandle(handle uint32, jsonData string) (result string, err error) {
// evalAndMarshal evaluates expr against jsonData and marshals the result to JSON.
// Shared by doEval and doEvalHandle to avoid duplicating unmarshal/eval/marshal logic.
+//
+// Return values:
+// - ("", nil) → expression evaluated to undefined (no match).
+// - ("null", nil) → expression evaluated to JSON null (actual null value).
+// - (json, nil) → expression evaluated to a concrete value.
+// - ("", err) → evaluation or marshal error.
func evalAndMarshal(e *gnata.Expression, jsonData string) (string, error) {
var data any
if jsonData != "" && jsonData != "null" {
@@ -170,6 +176,14 @@ func evalAndMarshal(e *gnata.Expression, jsonData string) (string, error) {
return "", err
}
+ // Eval returns (nil, nil) for undefined results (non-matching paths).
+ // Actual JSON null is the evaluator.Null sentinel (jsonNullType),
+ // which marshals to "null" via MarshalJSON. Returning "" here lets
+ // the JS wrapper map it to JavaScript undefined.
+ if res == nil {
+ return "", nil
+ }
+
out, err := json.Marshal(res)
if err != nil {
return "", fmt.Errorf("cannot marshal result: %w", err)