Write Extism plugins in JavaScript & TypeScript
92
stars
330
commits
Rust
primary language
Aug 17, 2026
updated
Build Extism Plug-ins in JavaScript. The extism-js compiler takes your JS source and compiles it to a Wasm module using QuickJS-ng (via rquickjs) and Wizer.
Extism JS plugins run inside a WebAssembly sandbox. There is no event loop, no I/O, and no access to the operating system. This means:
setTimeout, and no background work.async/await works, but only over values that are already resolved. fetch() returns a Promise, but the underlying HTTP call completes synchronously before it's handed to you. This means libraries that use await fetch(...) will work, but nothing actually runs concurrently.fs, path, net, child_process, etc. (Buffer is available as a polyfill.)window, localStorage, Worker, WebSocket, etc.Promise.allSettled, etc.). Target es2020 in your bundler.module.exports when not using a bundler. With a bundler, you can write ESM and compile to CJS.The PDK provides a curated set of Web-standard APIs (see table below) alongside Extism-specific APIs for host communication. Many npm packages that are pure JavaScript will work out of the box when bundled. Packages that depend on Node.js built-ins or browser APIs will not.
| API | Support | Notes |
|---|---|---|
fetch() | Full | Wraps Extism HTTP; supports Request init, returns Response with .text(), .json(), .arrayBuffer() |
Headers | Full | Case-insensitive, append/delete/get/has/set/forEach/entries/keys/values |
Response | Partial | No .blob() or .formData(); no streaming |
URL | Full | Spec-compliant via core-js polyfill |
URLSearchParams | Full | Spec-compliant via core-js polyfill |
URLPattern | Full | Via urlpattern-polyfill |
TextEncoder | Full | .encode() and .encodeInto() (UTF-8 only) |
TextDecoder | Partial | UTF-8 only; no streaming mode |
console | Full | .log .info .warn .error .debug .trace .assert .time/.timeEnd/.timeLog .count/.countReset .table |
atob / btoa | Full | Throws DOMException on invalid input |
structuredClone | Partial | Primitives, Date, RegExp, ArrayBuffer, TypedArrays, Map, Set, Array, Error, plain objects. No DOM nodes, functions, or symbols. |
crypto.getRandomValues() | Full | Max 65,536 bytes; integer TypedArrays only |
crypto.randomUUID() | Full | RFC 4122 v4 |
crypto.subtle.digest() | Partial | SHA-1, SHA-256, SHA-384, SHA-512 only. No encrypt/decrypt/sign/verify/key operations. |
performance.now() | Full | Millisecond precision via WASI clock |
performance.timeOrigin | Full | |
DOMException | Full | Standard name/message/code properties |
Event | Full | Constructor with bubbles/cancelable options, preventDefault, stopPropagation, stopImmediatePropagation |
EventTarget | Full | addEventListener (with once), removeEventListener, dispatchEvent |
queueMicrotask | Sync | Executes the callback immediately (no event loop) |
globalThis.self | Full | Alias for globalThis |
Date | Full | Host-provided current time via WASI |
JSON / Math / RegExp / Promise / Proxy / Reflect | Full | ES2020 standard library |
Map / Set / WeakMap / WeakSet | Full | |
ArrayBuffer / DataView / Typed Arrays | Full | All standard TypedArray types |
BigInt | Full | |
Buffer | Full | Node.js-compatible. from/alloc/concat/isBuffer, all encodings (utf8, hex, base64, base64url, latin1, ascii), read/write integer methods, slice, copy, indexOf, fill, equals, compare. Works with npm packages that use require('buffer') via esbuild alias. |
| API | Description |
|---|---|
Host.inputString() / Host.inputBytes() | Read plug-in input |
Host.outputString(s) / Host.outputBytes(buf) | Set plug-in output |
Host.getFunctions() | Access host-provided functions |
Config.get(key) | Read host-provided configuration |
Var.getString(key) / Var.getBytes(key) / Var.set(key, val) | Persistent key-value storage across calls |
Http.request(req) | Low-level synchronous HTTP (prefer fetch()) |
Memory.fromString(s) / Memory.fromBuffer(buf) / Memory.find(offset) | Manual memory management for host function interop |
module.exports = { fn } | Export functions callable by the host |
setTimeout / setInterval, fs, path, net, child_process, Worker, WebSocket, DOM APIs, localStorage, Streams, Canvas, import() dynamic imports.
curl -O https://raw.githubusercontent.com/extism/js-pdk/main/install.sh
bash install.sh
7zip is required.
powershell Invoke-WebRequest -Uri https://raw.githubusercontent.com/extism/js-pdk/main/install-windows.ps1 -OutFile install-windows.ps1
powershell -executionpolicy bypass -File .\install-windows.ps1
Binaryen (wasm-merge and wasm-opt) must be on your PATH. Install with brew install binaryen on macOS, or grab a release from the Binaryen releases page.
Verify the install:
extism-js --help
Note: On macOS you may need to allow the unsigned binary in System Settings > Privacy & Security.
Write a plugin.js that exports functions the host can call:
function greet() {
const name = Host.inputString();
Host.outputString(`Hello, ${name}!`);
}
module.exports = { greet };
Declare the Wasm interface in plugin.d.ts:
declare module "main" {
export function greet(): I32;
}
Compile and run:
extism-js plugin.js -i plugin.d.ts -o plugin.wasm
extism call plugin.wasm greet --input="Benjamin" --wasi
# => Hello, Benjamin!
Note:
--wasiis currently required for all JavaScript plug-ins.
Thrown exceptions are returned as errors to the host:
function greet() {
const name = Host.inputString();
if (name === "Benjamin") {
throw new Error("Sorry, we don't greet Benjamins!");
}
Host.outputString(`Hello, ${name}!`);
}
module.exports = { greet };
extism call plugin.wasm greet --input="Benjamin" --wasi
# => Error: Uncaught Error: Sorry, we don't greet Benjamins!
echo $?
# => 1
Use JSON.parse and JSON.stringify for complex types:
function sum() {
const params = JSON.parse(Host.inputString());
Host.outputString(JSON.stringify({ sum: params.a + params.b }));
}
module.exports = { sum };
extism call plugin.wasm sum --input='{"a": 20, "b": 21}' --wasi
# => {"sum":41}
The fetch() API wraps the Extism HTTP interface and works with both .then() and async/await:
async function callApi() {
const response = await fetch("https://jsonplaceholder.typicode.com/todos/1");
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const todo = await response.json();
Host.outputString(`Todo: ${todo.title}`);
}
module.exports = { callApi };
The host must allow the target domain via
--allow-host.
Read host-provided key-value configuration with Config.get:
function greet() {
const user = Config.get("user");
Host.outputString(`Hello, ${user}!`);
}
module.exports = { greet };
extism call plugin.wasm greet --config user=Benjamin --wasi
# => Hello, Benjamin!
Mutable key-value storage that persists across function calls within a plug-in's lifetime:
function count() {
let count = parseInt(Var.getString("count") || "0", 10);
count += 1;
Var.set("count", count.toString());
Host.outputString(count.toString());
}
module.exports = { count };
function logStuff() {
console.log("Info-level log");
console.debug("Debug-level log");
console.warn("Warning");
console.error("Error");
console.table([{ name: "Alice", age: 30 }, { name: "Bob", age: 25 }]);
}
module.exports = { logStuff };
extism call plugin.wasm logStuff --wasi --log-level=debug
Declare host functions in your .d.ts file and call them through Host.getFunctions():
declare module "main" {
export function greet(): I32;
}
declare module "extism:host" {
interface user {
myHostFunction1(ptr: I64): I64;
myHostFunction2(ptr: I64): I64;
}
}
const { myHostFunction1, myHostFunction2 } = Host.getFunctions();
function greet() {
let mem = Memory.fromString("Hello from JS");
let offset = myHostFunction1(mem.offset);
let response = Memory.find(offset).readString();
Host.outputString(response);
}
module.exports = { greet };
Host functions accept up to 5
I64arguments. You manage memory manually using theMemoryAPI.
Use a bundler to write in TypeScript, use ESM syntax, or import npm packages. Two constraints:
mkdir extism-plugin && cd extism-plugin
npm init -y
npm install esbuild @extism/js-pdk --save-dev
mkdir src dist
Add a jsconfig.json or tsconfig.json for IDE support:
{
"compilerOptions": {
"lib": [],
"types": ["@extism/js-pdk"],
"noEmit": true
},
"include": ["src/**/*"]
}
Add esbuild.js:
const esbuild = require("esbuild");
esbuild.build({
entryPoints: ["src/index.js"],
outdir: "dist",
bundle: true,
sourcemap: true,
minify: false,
format: "cjs",
target: ["es2020"],
});
Add a build script to package.json:
{
"scripts": {
"build": "node esbuild.js && extism-js dist/index.js -i src/index.d.ts -o dist/plugin.wasm"
}
}
Now you can use ESM imports and npm packages:
import { closest } from "fastest-levenshtein";
export function get_closest() {
let input = Host.inputString();
Host.outputString(closest(input, ["slow", "faster", "fastest"]));
}
npm install fastest-levenshtein
npm run build
extism call dist/plugin.wasm get_closest --input="fest" --wasi
# => fastest
You can use React for server-side rendering in plug-ins:
npm install react-dom --save
npm install @types/react --save-dev
import { renderToString } from "react-dom/server";
import React from "react";
function App({ name }: { name: string }) {
return <p>Hello {name}!</p>;
}
export function render() {
const props = JSON.parse(Host.inputString());
Host.outputString(renderToString(<App {...props} />));
}
See examples/react for a complete example.
XTP Bindgen can generate type-safe PDK bindings from an OpenAPI-inspired schema:
version: v1-draft
exports:
CountVowels:
input:
type: string
contentType: text/plain; charset=utf-8
output:
$ref: "#/components/schemas/VowelReport"
contentType: application/json
xtp plugin init --schema-file ./example-schema.yaml
# Select "TypeScript" and implement the generated stubs
xtp plugin build
See the XTP Schema docs for more.
JavaScript can't compile directly to Wasm because it doesn't have the right type system. Instead, the extism-js compiler:
The result is a self-contained Wasm module that can be used with any Extism host SDK.
rustup target add --toolchain stable wasm32-wasip1make download-wasi-sdkbrew install cmake on macOS)make # builds core engine + CLI
make test # compiles examples and runs test suite
Rust
50.0%
TypeScript
35.8%
Makefile
6.9%
Shell
4.1%
PowerShell
3.0%
Write Extism plugins in JavaScript & TypeScript
92
stars
330
commits
Rust
primary language
Aug 17, 2026
updated
Build Extism Plug-ins in JavaScript. The extism-js compiler takes your JS source and compiles it to a Wasm module using QuickJS-ng (via rquickjs) and Wizer.
Extism JS plugins run inside a WebAssembly sandbox. There is no event loop, no I/O, and no access to the operating system. This means:
setTimeout, and no background work.async/await works, but only over values that are already resolved. fetch() returns a Promise, but the underlying HTTP call completes synchronously before it's handed to you. This means libraries that use await fetch(...) will work, but nothing actually runs concurrently.fs, path, net, child_process, etc. (Buffer is available as a polyfill.)window, localStorage, Worker, WebSocket, etc.Promise.allSettled, etc.). Target es2020 in your bundler.module.exports when not using a bundler. With a bundler, you can write ESM and compile to CJS.The PDK provides a curated set of Web-standard APIs (see table below) alongside Extism-specific APIs for host communication. Many npm packages that are pure JavaScript will work out of the box when bundled. Packages that depend on Node.js built-ins or browser APIs will not.
| API | Support | Notes |
|---|---|---|
fetch() | Full | Wraps Extism HTTP; supports Request init, returns Response with .text(), .json(), .arrayBuffer() |
Headers | Full | Case-insensitive, append/delete/get/has/set/forEach/entries/keys/values |
Response | Partial | No .blob() or .formData(); no streaming |
URL | Full | Spec-compliant via core-js polyfill |
URLSearchParams | Full | Spec-compliant via core-js polyfill |
URLPattern | Full | Via urlpattern-polyfill |
TextEncoder | Full | .encode() and .encodeInto() (UTF-8 only) |
TextDecoder | Partial | UTF-8 only; no streaming mode |
console | Full | .log .info .warn .error .debug .trace .assert .time/.timeEnd/.timeLog .count/.countReset .table |
atob / btoa | Full | Throws DOMException on invalid input |
structuredClone | Partial | Primitives, Date, RegExp, ArrayBuffer, TypedArrays, Map, Set, Array, Error, plain objects. No DOM nodes, functions, or symbols. |
crypto.getRandomValues() | Full | Max 65,536 bytes; integer TypedArrays only |
crypto.randomUUID() | Full | RFC 4122 v4 |
crypto.subtle.digest() | Partial | SHA-1, SHA-256, SHA-384, SHA-512 only. No encrypt/decrypt/sign/verify/key operations. |
performance.now() | Full | Millisecond precision via WASI clock |
performance.timeOrigin | Full | |
DOMException | Full | Standard name/message/code properties |
Event | Full | Constructor with bubbles/cancelable options, preventDefault, stopPropagation, stopImmediatePropagation |
EventTarget | Full | addEventListener (with once), removeEventListener, dispatchEvent |
queueMicrotask | Sync | Executes the callback immediately (no event loop) |
globalThis.self | Full | Alias for globalThis |
Date | Full | Host-provided current time via WASI |
JSON / Math / RegExp / Promise / Proxy / Reflect | Full | ES2020 standard library |
Map / Set / WeakMap / WeakSet | Full | |
ArrayBuffer / DataView / Typed Arrays | Full | All standard TypedArray types |
BigInt | Full | |
Buffer | Full | Node.js-compatible. from/alloc/concat/isBuffer, all encodings (utf8, hex, base64, base64url, latin1, ascii), read/write integer methods, slice, copy, indexOf, fill, equals, compare. Works with npm packages that use require('buffer') via esbuild alias. |
| API | Description |
|---|---|
Host.inputString() / Host.inputBytes() | Read plug-in input |
Host.outputString(s) / Host.outputBytes(buf) | Set plug-in output |
Host.getFunctions() | Access host-provided functions |
Config.get(key) | Read host-provided configuration |
Var.getString(key) / Var.getBytes(key) / Var.set(key, val) | Persistent key-value storage across calls |
Http.request(req) | Low-level synchronous HTTP (prefer fetch()) |
Memory.fromString(s) / Memory.fromBuffer(buf) / Memory.find(offset) | Manual memory management for host function interop |
module.exports = { fn } | Export functions callable by the host |
setTimeout / setInterval, fs, path, net, child_process, Worker, WebSocket, DOM APIs, localStorage, Streams, Canvas, import() dynamic imports.
curl -O https://raw.githubusercontent.com/extism/js-pdk/main/install.sh
bash install.sh
7zip is required.
powershell Invoke-WebRequest -Uri https://raw.githubusercontent.com/extism/js-pdk/main/install-windows.ps1 -OutFile install-windows.ps1
powershell -executionpolicy bypass -File .\install-windows.ps1
Binaryen (wasm-merge and wasm-opt) must be on your PATH. Install with brew install binaryen on macOS, or grab a release from the Binaryen releases page.
Verify the install:
extism-js --help
Note: On macOS you may need to allow the unsigned binary in System Settings > Privacy & Security.
Write a plugin.js that exports functions the host can call:
function greet() {
const name = Host.inputString();
Host.outputString(`Hello, ${name}!`);
}
module.exports = { greet };
Declare the Wasm interface in plugin.d.ts:
declare module "main" {
export function greet(): I32;
}
Compile and run:
extism-js plugin.js -i plugin.d.ts -o plugin.wasm
extism call plugin.wasm greet --input="Benjamin" --wasi
# => Hello, Benjamin!
Note:
--wasiis currently required for all JavaScript plug-ins.
Thrown exceptions are returned as errors to the host:
function greet() {
const name = Host.inputString();
if (name === "Benjamin") {
throw new Error("Sorry, we don't greet Benjamins!");
}
Host.outputString(`Hello, ${name}!`);
}
module.exports = { greet };
extism call plugin.wasm greet --input="Benjamin" --wasi
# => Error: Uncaught Error: Sorry, we don't greet Benjamins!
echo $?
# => 1
Use JSON.parse and JSON.stringify for complex types:
function sum() {
const params = JSON.parse(Host.inputString());
Host.outputString(JSON.stringify({ sum: params.a + params.b }));
}
module.exports = { sum };
extism call plugin.wasm sum --input='{"a": 20, "b": 21}' --wasi
# => {"sum":41}
The fetch() API wraps the Extism HTTP interface and works with both .then() and async/await:
async function callApi() {
const response = await fetch("https://jsonplaceholder.typicode.com/todos/1");
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const todo = await response.json();
Host.outputString(`Todo: ${todo.title}`);
}
module.exports = { callApi };
The host must allow the target domain via
--allow-host.
Read host-provided key-value configuration with Config.get:
function greet() {
const user = Config.get("user");
Host.outputString(`Hello, ${user}!`);
}
module.exports = { greet };
extism call plugin.wasm greet --config user=Benjamin --wasi
# => Hello, Benjamin!
Mutable key-value storage that persists across function calls within a plug-in's lifetime:
function count() {
let count = parseInt(Var.getString("count") || "0", 10);
count += 1;
Var.set("count", count.toString());
Host.outputString(count.toString());
}
module.exports = { count };
function logStuff() {
console.log("Info-level log");
console.debug("Debug-level log");
console.warn("Warning");
console.error("Error");
console.table([{ name: "Alice", age: 30 }, { name: "Bob", age: 25 }]);
}
module.exports = { logStuff };
extism call plugin.wasm logStuff --wasi --log-level=debug
Declare host functions in your .d.ts file and call them through Host.getFunctions():
declare module "main" {
export function greet(): I32;
}
declare module "extism:host" {
interface user {
myHostFunction1(ptr: I64): I64;
myHostFunction2(ptr: I64): I64;
}
}
const { myHostFunction1, myHostFunction2 } = Host.getFunctions();
function greet() {
let mem = Memory.fromString("Hello from JS");
let offset = myHostFunction1(mem.offset);
let response = Memory.find(offset).readString();
Host.outputString(response);
}
module.exports = { greet };
Host functions accept up to 5
I64arguments. You manage memory manually using theMemoryAPI.
Use a bundler to write in TypeScript, use ESM syntax, or import npm packages. Two constraints:
mkdir extism-plugin && cd extism-plugin
npm init -y
npm install esbuild @extism/js-pdk --save-dev
mkdir src dist
Add a jsconfig.json or tsconfig.json for IDE support:
{
"compilerOptions": {
"lib": [],
"types": ["@extism/js-pdk"],
"noEmit": true
},
"include": ["src/**/*"]
}
Add esbuild.js:
const esbuild = require("esbuild");
esbuild.build({
entryPoints: ["src/index.js"],
outdir: "dist",
bundle: true,
sourcemap: true,
minify: false,
format: "cjs",
target: ["es2020"],
});
Add a build script to package.json:
{
"scripts": {
"build": "node esbuild.js && extism-js dist/index.js -i src/index.d.ts -o dist/plugin.wasm"
}
}
Now you can use ESM imports and npm packages:
import { closest } from "fastest-levenshtein";
export function get_closest() {
let input = Host.inputString();
Host.outputString(closest(input, ["slow", "faster", "fastest"]));
}
npm install fastest-levenshtein
npm run build
extism call dist/plugin.wasm get_closest --input="fest" --wasi
# => fastest
You can use React for server-side rendering in plug-ins:
npm install react-dom --save
npm install @types/react --save-dev
import { renderToString } from "react-dom/server";
import React from "react";
function App({ name }: { name: string }) {
return <p>Hello {name}!</p>;
}
export function render() {
const props = JSON.parse(Host.inputString());
Host.outputString(renderToString(<App {...props} />));
}
See examples/react for a complete example.
XTP Bindgen can generate type-safe PDK bindings from an OpenAPI-inspired schema:
version: v1-draft
exports:
CountVowels:
input:
type: string
contentType: text/plain; charset=utf-8
output:
$ref: "#/components/schemas/VowelReport"
contentType: application/json
xtp plugin init --schema-file ./example-schema.yaml
# Select "TypeScript" and implement the generated stubs
xtp plugin build
See the XTP Schema docs for more.
JavaScript can't compile directly to Wasm because it doesn't have the right type system. Instead, the extism-js compiler:
The result is a self-contained Wasm module that can be used with any Extism host SDK.
rustup target add --toolchain stable wasm32-wasip1make download-wasi-sdkbrew install cmake on macOS)make # builds core engine + CLI
make test # compiles examples and runs test suite
Rust
50.0%
TypeScript
35.8%
Makefile
6.9%
Shell
4.1%
PowerShell
3.0%