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 @@ Go Reference Go version MIT License + npm version

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)