Embedding Traverse WASM in a Web App

Install the npm package, instantiate the engine, run queries, persist to OPFS — everything you need to drop Traverse into your own web app.

This page is for integrators. If you just want to use Traverse in a browser tab, open traverse.truespar.com — that's our hosted demo, no code required.

Installation

npm install @truespar/traverse-wasm

The package ships:

  • TraverseDb — the main async class.
  • A bundled Web Worker (worker.js) that hosts the WASM module.
  • The compiled WASM binary (~5 MB).
  • TypeScript declarations (index.d.ts) auto-picked-up by TS / Vite / webpack.

Instantiation

Minimal example

import { TraverseDb } from '@truespar/traverse-wasm'

const db = await TraverseDb.open({
  numThreads: Math.min(navigator.hardwareConcurrency, 16),
})

console.log('engine version:', db.version)
const r = await db.query('MATCH (n) RETURN count(n) AS n')
console.log(r.rows[0])  // [0]

The call initializes the engine and returns a ready-to-use instance. The bundle is cached on first load; subsequent opens are fast.

Optional parameters

OptionDefaultDescription
numThreadsmin(hardwareConcurrency, 16)Number of threads the engine may use. 1 works but is slow on algorithm runs.
workerUrlbundledOverride only if you need a custom hosting location for the worker script.
nameIf set, restores the named database from browser storage on open.

Vite

Vite needs two tweaks to bundle the package correctly:

// vite.config.ts
import { defineConfig } from 'vite'

export default defineConfig({
  // Treat .wasm as an emitted asset.
  assetsInclude: ['**/*.wasm'],
  // Required so Vite bundles the WASM binary correctly.
  optimizeDeps: {
    exclude: ['@truespar/traverse-wasm'],
  },
  server: {
    // Required so the engine can use threads during development.
    headers: {
      'Cross-Origin-Embedder-Policy': 'require-corp',
      'Cross-Origin-Opener-Policy': 'same-origin',
    },
    fs: {
      // If you install via `file:` (monorepo), let Vite read outside
      // the project root.
      allow: ['..'],
    },
  },
})

Cross-origin isolation

SharedArrayBuffer — the thread pool's backbone — is only available on cross-origin-isolated pages. Your hosting must send both headers on every response:

Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy:   same-origin

Copy-paste-ready snippets:

Vercel (vercel.json)

{
  "headers": [{
    "source": "/(.*)",
    "headers": [
      { "key": "Cross-Origin-Embedder-Policy", "value": "require-corp" },
      { "key": "Cross-Origin-Opener-Policy",   "value": "same-origin" }
    ]
  }]
}

Netlify (_headers)

/*
  Cross-Origin-Embedder-Policy: require-corp
  Cross-Origin-Opener-Policy: same-origin

Cloudflare Workers / Pages (_headers in /public)

Same syntax as Netlify above.

nginx

add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Opener-Policy   "same-origin" always;

Apache (.htaccess)

Header always set Cross-Origin-Embedder-Policy "require-corp"
Header always set Cross-Origin-Opener-Policy   "same-origin"

IIS (web.config)

<httpProtocol>
  <customHeaders>
    <add name="Cross-Origin-Embedder-Policy" value="require-corp" />
    <add name="Cross-Origin-Opener-Policy"   value="same-origin" />
  </customHeaders>
</httpProtocol>

If the headers are missing, TraverseDb.open() throws a clear "this browser blocks SharedArrayBuffer" error rather than failing deep inside the WASM init.

Running Cypher

Simple query

const r = await db.query('MATCH (n:Person) RETURN n.name AS name LIMIT 10')
for (const [name] of r.rows) console.log(name)

Parameters

const r = await db.query(
  'MATCH (n:Person {age: $age}) RETURN n',
  { age: 30 },
)

Per-query timeout and cancellation

const controller = new AbortController()
setTimeout(() => controller.abort(), 5_000)  // hard cancel after 5s

try {
  const r = await db.query(
    'CALL traverse.pageRank.stream({maxIterations: 50}) YIELD nodeId, score',
    null,
    { signal: controller.signal, timeoutMs: 30_000 },
  )
} catch (err) {
  if (err.name === 'AbortError') console.log('cancelled')
  else throw err
}

QueryResponse shape

interface QueryResponse {
  columns: string[]                  // column names from RETURN
  rows: unknown[][]                  // one row per result
  total_rows: number
  truncated: boolean
  nodes: unknown[]                   // distinct hydrated nodes
  edges: unknown[]                   // distinct hydrated edges
  entities_truncated: boolean
  query_type: 'Read' | 'Write' | 'ReadWrite' | 'Schema'
  stats: {
    nodes_created: number
    nodes_deleted: number
    relationships_created: number
    relationships_deleted: number
    properties_set: number
    labels_added: number
    labels_removed: number
    indexes_added: number
    indexes_removed: number
    constraints_added: number
    constraints_removed: number
  }
  plan?: unknown                     // EXPLAIN / PROFILE only
  time_ms: number
  parsing_time_ms: number
  planning_time_ms: number
  execution_time_ms: number
}

Nodes and edges are tagged JSON objects matching the HTTP server's shape:

{ _type: "node", id: 42, labels: ["Person"], properties: { name: "Alice" } }
{ _type: "edge", id: 7,  type: "KNOWS", source: 42, target: 51, properties: {} }
{ _type: "path", nodes: [...], edges: [...] }

Algorithm catalog and schema

// 32+ GDS procedures, same shape the HTTP /api/algorithms returns.
const { algorithms } = await db.algorithms()
algorithms.forEach(a => console.log(a.name, a.modes))

// Labels, relationship types, property keys, indexes, constraints.
const schema = await db.schema()
console.log(schema.labels)             // ['Person', 'Movie', ...]
console.log(schema.relationship_types) // ['KNOWS', 'ACTED_IN', ...]

Importing data

// Multi-statement Cypher import (same shape as /api/import).
const result = await db.importCypher(`
  CREATE (a:Person {name: 'Alice', age: 30})
  CREATE (b:Person {name: 'Bob',   age: 28})
  CREATE (a)-[:KNOWS]->(b)
`)
console.log(result.stats)  // counters per the QueryStats shape above

Persistence: OPFS

Databases live in the browser's Origin Private File System (OPFS) as .tvdb binary blobs. The format is byte-for-byte identical to what the native Traverse server writes.

Save to OPFS

// After making changes, persist under a name.
await db.commit('my-database')
// → { ok: true, bytes: 12345 }

Restore from OPFS

const result = await db.load('my-database')
if (result.ok) {
  console.log(`restored ${result.nodes} nodes / ${result.edges} edges`)
} else if (result.missing) {
  console.log('no database with that name')
}

List + delete

const dbs = await db.listDatabases()
// → [{ name: 'my-database', size: 12345, lastModified: 1715740800000 }, ...]

await db.deleteDatabase('old-one')

Round-trip with the native server

Export the current graph as a .tvdb byte buffer, hand it to the user to download, or upload to a server:

const bytes = await db.exportTvdb()

// Save to disk via the File API.
const blob = new Blob([bytes], { type: 'application/octet-stream' })
const url = URL.createObjectURL(blob)
Object.assign(document.createElement('a'), { href: url, download: 'my-graph.tvdb' }).click()

// Or load bytes the user uploaded.
const fromUser: Uint8Array = ...  // e.g. from <input type="file"> → arrayBuffer()
await db.loadTvdb(fromUser)

The same .tvdb file loads cleanly in the native traverse-server via --data or the HTTP /api/databases/load endpoint, and vice versa.

Tear-down

db.close()  // terminates the worker; pending requests reject with AbortError

Browser support

BrowserRequiredNotes
Chrome / Edgev95+Cross-origin isolation must be enabled on the hosting page.
Firefoxv98+Same.
Safariv16.4+Same; older Safari (and some locked-down corporate profiles) won't expose SharedArrayBuffer at all.

Detect support before opening:

if (typeof SharedArrayBuffer === 'undefined') {
  // The page isn't cross-origin-isolated, or the browser is too old.
  // Show your fallback (or a link to truespar.com to download the
  // native build).
}

Limits

  • ~4 GB linear memory. wasm32 ceiling; practical working set is closer to 2 GB before fragmentation hurts. A ~1.8M-edge graph (~100 MB .tvdb) fits comfortably.
  • OPFS is per-origin. Subdomains don't share storage.
  • One graph in memory. Switching databases is commit + load — both fast, but not concurrent.
  • No Bolt / gRPC. The browser tab is the engine; external clients aren't supported. Use the native server if you need that.

TypeScript

The package ships .d.ts declarations — nothing extra to install. All return types are typed; QueryResponse and friends are exported.

import {
  TraverseDb,
  type QueryResponse,
  type AlgorithmsResponse,
  type SchemaResponse,
} from '@truespar/traverse-wasm'

Reference