Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/publish-npm.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
*.wasm
.secret
.DS_Store

# npm package build artifacts
npm/dist/
npm/node_modules/
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<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>
<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>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT License" /></a>
<a href="https://www.npmjs.com/package/gnata-js"><img src="https://img.shields.io/npm/v/gnata-js" alt="npm version" /></a>
</p>

<p align="center">
Expand Down
47 changes: 47 additions & 0 deletions npm/README.md
Original file line number Diff line number Diff line change
@@ -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
30 changes: 30 additions & 0 deletions npm/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

43 changes: 43 additions & 0 deletions npm/package.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
38 changes: 38 additions & 0 deletions npm/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<GnataConfig>): 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<unknown>;
} {
return {
async evaluate(data: unknown) {
await initGnata(config);
return gnataEval(expression, data);
},
};
}
68 changes: 68 additions & 0 deletions npm/src/loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { GnataConfig } from './types.js';

let wasmReady = false;
let loadingPromise: Promise<void> | null = null;

function loadScript(src: string): Promise<void> {
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<void> {
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<T>(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);
}
21 changes: 21 additions & 0 deletions npm/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
declare global {
class Go {
importObject: WebAssembly.Imports;
run(instance: WebAssembly.Instance): Promise<void>;
}

// 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;
}
19 changes: 19 additions & 0 deletions npm/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
3 changes: 3 additions & 0 deletions npm/wasm/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Build artifacts generated by scripts/build-npm.sh
gnata.wasm
wasm_exec.js
19 changes: 19 additions & 0 deletions scripts/build-npm.sh
Original file line number Diff line number Diff line change
@@ -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"
14 changes: 14 additions & 0 deletions wasm/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand All @@ -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)
Expand Down
Loading