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_REPOappears below. -
The
extism-jsCLI on yourPATH. Install the binary for your platform from the releases page and confirm withextism-js --version. -
curland a JSON formatter such aspython3 -m json.toolorjq(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"}}
}]
}'
{
"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, notcerbos_init). They are case-sensitive. -
Every declaration under
declare module "main"needs a matchingexport functionin the source. Mismatches produce a WASM module that traps at runtime withunreachable. -
The extism-js runtime has no
btoa,atob,setTimeout,fetch, or Node APIs. UseHost.arrayBufferToBase64/Host.base64ToArrayBuffer, plusTextEncoder/TextDecoder. -
console.debug("msg")(andconsole.log/info/warn/error/trace) writes to the Synapse log, tagged with the extension name. Levels below--log.levelare 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.requestis blocked unless the target is listed inallowedHostsinconfig.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.