# 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](https://github.com/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](https://docs.cerbos.dev/synapse/latest/extensions/wasm-development).

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](https://docs.cerbos.dev/synapse/latest/extensions/wasm-development#host-funcs) or through an external data store. |

## Before you begin

You need:

- [Node.js 22](https://nodejs.org/en/download) or later
- Docker
- A Synapse licence. If you do not already have one, sign up at [cerbos.dev/workshop](/content/workshop/index.html). 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](https://github.com/extism/js-pdk) 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:

```console
$ 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/`:

```console
$ 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`:

```javascript
const esbuild = require('esbuild');

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

Create `tsconfig.json`:

```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.

```typescript
/// <reference types="@extism/js-pdk" />

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

declare module "extism:host" {  
  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`:

```typescript
export function cerbosInit(): void {} 
export function cerbosDeinit(): void {}

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

export function augmentCheckResponse(): void {
  Host.outputString(Host.inputString());
}
```

|     |     |
| --- | --- |
| **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

```console
$ npm run build
```

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

## Write a policy

Create `policies/invoice.yaml`:

```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`:

```yaml
server:
  listenAddress: ":3594"

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

extensions:
  proxyExtensions:
    principalEnricher:  
      extensionURL: /extensions/proxy.wasm  
      required: true
```

|     |     |
| --- | --- |
| **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](https://docs.cerbos.dev/synapse/latest/extensions/url-format) 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:

```console
$ 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:

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

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

```console
$ 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

```json
{
  "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](https://docs.cerbos.dev/synapse/latest/extensions/wasm-development#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](https://docs.cerbos.dev/synapse/latest/extensions/wasm-development) for details about the functions that should be exported by different extension types and common operations such as calling host functions.
