Build a WASM extension in TypeScript

This tutorial walks through building a minimal Synapse proxy extension in TypeScript, compiling it to WebAssembly with the Extism JS PDK, and loading it into a running Synapse instance. For the payload shapes and full list of exports for every extension type, see Developing Webassembly extensions.

The Extism JS PDK bundles a QuickJS interpreter plus your JavaScript into a single .wasm file. The result behaves identically to a natively-compiled WASM module from Synapse’s perspective; the runtime differences (no btoa, no Node APIs) only matter when writing the extension.

Synapse creates multiple instances of each extension to serve parallel requests. Each instance has its own globals and is isolated from the other instances. The only way to share state is through the built-in cache or through an external data store.

Before you begin

You need:

  • Node.js 22 or later

  • Docker

  • A Synapse licence. If you do not already have one, sign up at cerbos.dev/workshop. Your credentials are issued together with the URL of the Cerbos distribution repository; substitute that URL wherever CERBOS_DISTRIBUTION_REPO appears below.

  • The extism-js CLI on your PATH. Install the binary for your platform from the releases page and confirm with extism-js --version.

  • curl and a JSON formatter such as python3 -m json.tool or jq (optional).

Log in to the Cerbos distribution repository:

$ docker login CERBOS_DISTRIBUTION_REPO --username=YOUR_LICENCE_USER --password=YOUR_LICENCE_KEY

Create the project

The npm project sits at the root alongside policies/ and extensions/, with TypeScript sources under src/:

$ mkdir -p wasm-ts-tutorial/policies wasm-ts-tutorial/extensions wasm-ts-tutorial/src
$ cd wasm-ts-tutorial
$ npm init -y
$ npm install --save-dev @extism/js-pdk esbuild typescript
$ npm pkg set type=commonjs
$ npm pkg set scripts.build="node esbuild.js && extism-js dist/index.js -i src/index.d.ts -o extensions/proxy.wasm"

The PDK requires CommonJS output. The build script bundles the TypeScript with esbuild, then wraps the bundle into a WASM reactor module at extensions/proxy.wasm.

Create esbuild.js:

const esbuild = require('esbuild');

esbuild.build({
  entryPoints: ['src/index.ts'],
  outdir: 'dist',
  bundle: true,
  format: 'cjs',
  target: ['es2020'],
});

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "es2020",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "moduleResolution": "node"
  },
  "include": ["src/**/*.ts"]
}

Declare the ABI contract

Create src/index.d.ts. This file tells extism-js which WASM symbols to export and which host functions the module may call.

/// <reference types="@extism/js-pdk" /> (1)

declare module "main" { (2)
  export function cerbosInit(): void;
  export function cerbosDeinit(): void;
  export function augmentCheckRequest(): void;
  export function augmentCheckResponse(): void;
}

declare module "extism:host" { (3)
  interface user {
    cacheGet(keyPtr: PTR): PTR;
    cacheSet(keyPtr: PTR, valuePtr: PTR, ttlMs: I32): I32;
    dataSourceLookup(reqPtr: PTR): PTR;
  }
}
1 Required on the first line. Pulls in Host, Memory, Config, PTR, I32 types from the PDK.
2 Every exported WASM symbol must be listed here in camelCase. The Synapse ABI has no snake_case names.
3 Every host function the module calls must be declared on interface user. Omitting one you call fails at link time; declaring one you never use is harmless.
extism-js generates a WASM stub for every function declared under declare module "main". If the TypeScript source does not export a matching function, the stub traps at runtime with unreachable. Only declare hooks you actually implement.

Write the extension

Create src/index.ts:

export function cerbosInit(): void {} (1)
export function cerbosDeinit(): void {}

export function augmentCheckRequest(): void {
  const req = JSON.parse(Host.inputString()); (2)
  req.principal = req.principal ?? {};
  req.principal.attr = {
    ...(req.principal.attr ?? {}),
    company: "Romaguera-Crona",
  };
  Host.outputString(JSON.stringify(req)); (3)
}

export function augmentCheckResponse(): void {
  Host.outputString(Host.inputString()); (4)
}
1 Lifecycle hooks are optional but harmless when empty. extism-js requires every declared export to have an implementation.
2 Host.inputString() returns the JSON-encoded CheckResources request.
3 Host.outputString Send the modified payload from this extension. Even in a pass-through scenario, this function must be called with the unmodified payload to complete the execution of the extension.
4 Passthrough for responses. Omit the export (and its declaration in index.d.ts) if you don’t modify responses.

Build the module

$ npm run build

This produces extensions/proxy.wasm. Rerun after every source change.

Write a policy

Create policies/invoice.yaml:

apiVersion: api.cerbos.dev/v1
resourcePolicy:
  resource: invoice
  version: default
  rules:
    - actions: [view]
      effect: EFFECT_ALLOW
      roles: [employee]
      condition:
        match:
          expr: request.resource.attr.company == request.principal.attr.company

The extension supplies principal.attr.company; the caller supplies request.resource.attr.company.

Configure and run Synapse

Create config.yaml:

server:
  listenAddress: ":3594"

pdp:
  inProcess:
    storage:
      driver: "disk"
      disk:
        directory: /policies
    audit:
      enabled: true
      backend: file
      file:
        path: stdout

extensions:
  proxyExtensions:
    principalEnricher: (1)
      extensionURL: /extensions/proxy.wasm (2)
      required: true (3)
1 Name for this extension as it appears in logs.
2 Path inside the container. The .wasm suffix tells Synapse to load the file with the WASM runtime. See Extension URLs for other supported schemes.
3 A failure inside a required extension short-circuits the chain and returns an error to the caller. Set this to false for advisory enrichment.

Start the container:

$ docker run --rm --name synapse-wasm-ts -p 3594:3594 \
    -v $(pwd)/config.yaml:/config/config.yaml:ro \
    -v $(pwd)/policies:/policies:ro \
    -v $(pwd)/extensions:/extensions:ro \
    CERBOS_DISTRIBUTION_REPO/synapse/synapse:latest \
    server --conf.path=/config/config.yaml --log.level=debug

Logs should show:

{"severity":"DEBUG","extension":"principalEnricher","runtime":"wasm","message":"Downloaded extension"}

In a second terminal, send a request without a principal.attr block:

$ curl -s -X POST http://localhost:3594/api/check/resources \
    -H 'Content-Type: application/json' \
    -d '{
          "requestId": "wasm-ts-001",
          "principal": {"id": "1", "roles": ["employee"]},
          "resources": [{
            "actions": ["view"],
            "resource": {"kind": "invoice", "id": "inv-42",
                         "attr": {"company": "Romaguera-Crona"}}
          }]
        }'
Response
{
  "requestId": "wasm-ts-001",
  "results": [
    { "resource": {"id": "inv-42", "kind": "invoice"},
      "actions": {"view": "EFFECT_ALLOW"} }
  ]
}

The extension injected principal.attr.company before the PDP evaluated the policy. Remove the extension or change the value and the decision flips to EFFECT_DENY.

Language-specific notes

  • Export names must be camelCase (cerbosInit, not cerbos_init). They are case-sensitive.

  • Every declaration under declare module "main" needs a matching export function in the source. Mismatches produce a WASM module that traps at runtime with unreachable.

  • The extism-js runtime has no btoa, atob, setTimeout, fetch, or Node APIs. Use Host.arrayBufferToBase64 / Host.base64ToArrayBuffer, plus TextEncoder / TextDecoder.

  • console.debug("msg") (and console.log/info/warn/error/trace) writes to the Synapse log, tagged with the extension name. Levels below --log.level are dropped on the host side.

  • Host functions are invoked via Host.getFunctions() and require manual memory handling (Memory.fromString, Memory.find). Full list and signatures: extensions/wasm-development.adoc#host-funcs.

  • Outbound HTTP via Http.request is blocked unless the target is listed in allowedHosts in config.yaml.

Going further

Refer to Developing Webassembly extensions for details about the functions that should be exported by different extension types and common operations such as calling host functions.