Skip to content

Commit 25ceb1b

Browse files
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
1 parent c52e70d commit 25ceb1b

12 files changed

Lines changed: 327 additions & 0 deletions

File tree

.github/workflows/publish-npm.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: Publish NPM
2+
3+
on:
4+
push:
5+
tags: ['v*']
6+
7+
permissions:
8+
id-token: write
9+
contents: read
10+
11+
jobs:
12+
publish:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v4
16+
17+
- uses: actions/setup-go@v5
18+
with:
19+
go-version-file: go.mod
20+
21+
- uses: actions/setup-node@v4
22+
with:
23+
node-version: '22'
24+
registry-url: 'https://registry.npmjs.org'
25+
26+
- name: Install latest npm (required for trusted publishing)
27+
run: npm install -g npm@latest
28+
29+
- name: Build WASM and TypeScript
30+
run: bash scripts/build-npm.sh
31+
32+
- name: Publish to npm
33+
working-directory: npm
34+
run: npm publish

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
11
*.wasm
22
.secret
33
.DS_Store
4+
5+
# npm package build artifacts
6+
npm/dist/
7+
npm/node_modules/

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
<a href="https://pkg.go.dev/github.com/recolabs/gnata"><img src="https://pkg.go.dev/badge/github.com/recolabs/gnata.svg" alt="Go Reference" /></a>
1616
<a href="https://github.com/RecoLabs/gnata/blob/main/go.mod"><img src="https://img.shields.io/github/go-mod/go-version/RecoLabs/gnata" alt="Go version" /></a>
1717
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT License" /></a>
18+
<a href="https://www.npmjs.com/package/gnata-js"><img src="https://img.shields.io/npm/v/gnata-js" alt="npm version" /></a>
1819
</p>
1920

2021
<p align="center">

npm/README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# gnata-js
2+
3+
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.
4+
5+
## Install
6+
7+
```bash
8+
npm install gnata-js
9+
```
10+
11+
## Usage
12+
13+
```typescript
14+
import { jsonata } from 'gnata-js';
15+
16+
const result = await jsonata('Account.Order.Product.Price').evaluate({
17+
Account: {
18+
Order: [
19+
{ Product: { Price: 34.45 } },
20+
{ Product: { Price: 21.67 } },
21+
],
22+
},
23+
});
24+
// [34.45, 21.67]
25+
```
26+
27+
## Asset Setup
28+
29+
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/`:
30+
31+
```javascript
32+
// rspack / webpack
33+
new CopyPlugin({
34+
patterns: [{ from: 'node_modules/gnata-js/wasm', to: 'wasm' }],
35+
});
36+
```
37+
38+
Override paths with `configure()` if needed:
39+
40+
```typescript
41+
import { jsonata, configure } from 'gnata-js';
42+
configure({ wasmUrl: '/assets/gnata.wasm', execJsUrl: '/assets/wasm_exec.js' });
43+
```
44+
45+
## License
46+
47+
MIT

npm/package-lock.json

Lines changed: 30 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

npm/package.json

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"name": "gnata-js",
3+
"version": "0.2.0",
4+
"description": "Browser JSONata via gnata WASM for backend parity, not a performance optimization",
5+
"license": "MIT",
6+
"repository": {
7+
"type": "git",
8+
"url": "https://github.com/RecoLabs/gnata.git",
9+
"directory": "npm"
10+
},
11+
"type": "module",
12+
"main": "dist/index.js",
13+
"module": "dist/index.js",
14+
"types": "dist/index.d.ts",
15+
"exports": {
16+
".": {
17+
"types": "./dist/index.d.ts",
18+
"import": "./dist/index.js"
19+
},
20+
"./wasm/*": "./wasm/*"
21+
},
22+
"files": [
23+
"dist/",
24+
"wasm/gnata.wasm",
25+
"wasm/wasm_exec.js"
26+
],
27+
"scripts": {
28+
"build": "tsc",
29+
"prepublishOnly": "npm run build"
30+
},
31+
"devDependencies": {
32+
"typescript": "^5.8.0"
33+
},
34+
"keywords": [
35+
"jsonata",
36+
"wasm",
37+
"webassembly",
38+
"gnata",
39+
"json",
40+
"query",
41+
"transformation"
42+
]
43+
}

npm/src/index.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { GnataConfig } from './types.js';
2+
import { initGnata, gnataEval } from './loader.js';
3+
4+
export type { GnataConfig } from './types.js';
5+
6+
const defaultConfig: GnataConfig = {
7+
wasmUrl: '/wasm/gnata.wasm',
8+
execJsUrl: '/wasm/wasm_exec.js',
9+
};
10+
11+
let config: GnataConfig = { ...defaultConfig };
12+
13+
/**
14+
* Override the default asset URLs for WASM and wasm_exec.js.
15+
*
16+
* Call before the first `jsonata()` evaluation if your assets are
17+
* served from a non-default path.
18+
*/
19+
export function configure(overrides: Partial<GnataConfig>): void {
20+
config = { ...config, ...overrides };
21+
}
22+
23+
/**
24+
* Evaluate a JSONata expression against data using gnata WASM.
25+
*
26+
* API-compatible with the `jsonata` npm package's default export:
27+
* `jsonata(expr).evaluate(data)`.
28+
*/
29+
export function jsonata(expression: string): {
30+
evaluate(data: unknown): Promise<unknown>;
31+
} {
32+
return {
33+
async evaluate(data: unknown) {
34+
await initGnata(config);
35+
return gnataEval(expression, data);
36+
},
37+
};
38+
}

npm/src/loader.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type { GnataConfig } from './types.js';
2+
3+
let wasmReady = false;
4+
let loadingPromise: Promise<void> | null = null;
5+
6+
function loadScript(src: string): Promise<void> {
7+
return new Promise((resolve, reject) => {
8+
const script = document.createElement('script');
9+
script.src = src;
10+
script.onload = () => resolve();
11+
script.onerror = () => reject(new Error(`Failed to load ${src}`));
12+
document.head.appendChild(script);
13+
});
14+
}
15+
16+
export async function initGnata(config: GnataConfig): Promise<void> {
17+
if (wasmReady) return;
18+
if (loadingPromise) return loadingPromise;
19+
20+
loadingPromise = (async () => {
21+
try {
22+
if (typeof Go === 'undefined') {
23+
await loadScript(config.execJsUrl);
24+
}
25+
26+
const go = new Go();
27+
const resp = await fetch(config.wasmUrl);
28+
if (!resp.ok) {
29+
throw new Error(
30+
`Failed to fetch gnata.wasm: ${resp.status} ${resp.statusText}`,
31+
);
32+
}
33+
34+
const result = await WebAssembly.instantiateStreaming(
35+
resp,
36+
go.importObject,
37+
);
38+
go.run(result.instance).catch((err: unknown) => {
39+
console.error('gnata WASM runtime exited unexpectedly:', err);
40+
wasmReady = false;
41+
loadingPromise = null;
42+
});
43+
wasmReady = true;
44+
} catch (err) {
45+
loadingPromise = null;
46+
throw err instanceof Error
47+
? err
48+
: new Error(`gnata init failed: ${String(err)}`);
49+
}
50+
})();
51+
52+
return loadingPromise;
53+
}
54+
55+
function unwrapWasm<T>(result: T | Error): T {
56+
if (result instanceof Error) throw result;
57+
return result;
58+
}
59+
60+
export function gnataEval(expression: string, data: unknown): unknown {
61+
const jsonData = data != null ? JSON.stringify(data) : '';
62+
const raw = unwrapWasm(_gnataEval(expression, jsonData));
63+
// gnata returns "" for undefined (non-matching paths) and "null" for
64+
// actual JSON null. This preserves the jsonata npm semantic where
65+
// non-matching expressions return undefined.
66+
if (raw === '') return undefined;
67+
return JSON.parse(raw);
68+
}

npm/src/types.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
declare global {
2+
class Go {
3+
importObject: WebAssembly.Imports;
4+
run(instance: WebAssembly.Instance): Promise<void>;
5+
}
6+
7+
// Raw WASM exports registered by gnata's Go main() on the global object.
8+
// See wasm/main.go for the Go-side definitions.
9+
function _gnataEval(expr: string, jsonData: string): string | Error;
10+
function _gnataCompile(expr: string): number | Error;
11+
function _gnataEvalHandle(
12+
handle: number,
13+
jsonData: string,
14+
): string | Error;
15+
function _gnataReleaseHandle(handle: number): undefined | Error;
16+
}
17+
18+
export interface GnataConfig {
19+
wasmUrl: string;
20+
execJsUrl: string;
21+
}

npm/tsconfig.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2020",
4+
"module": "ES2020",
5+
"moduleResolution": "bundler",
6+
"declaration": true,
7+
"declarationMap": true,
8+
"sourceMap": true,
9+
"outDir": "dist",
10+
"rootDir": "src",
11+
"strict": true,
12+
"esModuleInterop": true,
13+
"skipLibCheck": true,
14+
"forceConsistentCasingInFileNames": true,
15+
"lib": ["ES2020", "DOM"]
16+
},
17+
"include": ["src"],
18+
"exclude": ["dist", "node_modules"]
19+
}

0 commit comments

Comments
 (0)