This project contains a tool that can be used to create Extism Plug-ins in JavaScript.
This PDK uses QuickJS and wizer to run javascript as an Extism Plug-in.
This is essentially a fork of Javy by Shopify. We may wish to collaborate and upstream some things to them. For the time being I built this up from scratch using some of their crates, namely quickjs-wasm-rs.
Warning: This is a very bare-bones runtime. It's only for running pure JS code and it does not expose node APIs or the browser APIs. We have limited support for some W3C APIs (e.g. we support
Text{Encoder,Decoder}
but notfetch
), but many modules you take from npm will not work out of the box. There is no support for node APIs or anything that makes syscalls typically. You'll need to polyfill any APIs with a pure JS implementation, which is often possible, but some things, such as controlling sockets, are not possible. Feel free to file an issue if you think an API should be supported though.
We release the compiler as native binaries you can download and run. Check the releases page for the latest.
curl -O https://raw.githubusercontent.com/extism/js-pdk/main/install.sh
bash install.sh
7zip is required, you can find it here.
Open the Command Prompt as Administrator, then run :
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
This will install extism-js and binaryen dependency under Program File
folder (i.e. C:\Program Files\Binaryen and C:\Program Files\Extism). You must add these paths to your PATH environment variable.
Note: Binaryen, specifically the
wasm-merge
andwasm-opt
tools are required as a dependency. We will try to package this up eventually but for now it must be reachable on your machine. You can install on mac withbrew install binaryen
or see their releases page.
Then run command with -h
to see the help:
extism-js 1.1.1
Extism JavaScript PDK Plugin Compiler
USAGE:
extism-js [FLAGS] [OPTIONS] <input-js>
FLAGS:
-h, --help Prints help information
--skip-opt Skip final optimization pass
-V, --version Prints version information
OPTIONS:
-i <interface-file> [default: index.d.ts]
-o <output> [default: index.wasm]
ARGS:
<input-js>
Note: If you are using mac, you may need to tell your security system this unsigned binary is fine. If you think this is dangerous, or can't get it to work, see the "compile from source" section below.
The goal of writing an Extism plug-in is to compile your JavaScript code to a Wasm module with exported functions that the host application can invoke. The first thing you should understand is creating an export.
Let's write a simple program that exports a greet
function which will take a name as a string and return a greeting string. Paste this into a file plugin.js
:
function greet() {
const name = Host.inputString()
Host.outputString(`Hello, ${name}`)
}
module.exports = {greet}
Some things to note about this code:
- We can export functions by name using the normal
module.exports
object. This allows the host to invoke this function. Like a normal js module, functions cannot be seen from the outside without exporting them. - Currently, you must use CJS Module syntax when not using a bundler. So the
export
keyword is not directly supported. See the Using with a Bundler section for more. - In this PDK we code directly to the ABI. We get input from the using using
Host.input*
functions and we return data back with theHost.output*
functions.
We must also describe the Wasm interface for our plug-in. We do this with a typescript module DTS file.
Here is our plugin.d.ts
file:
declare module 'main' {
// Extism exports take no params and return an I32
export function greet(): I32;
}
Let's compile this to Wasm now using the extism-js
tool:
extism-js plugin.js -i plugin.d.ts -o plugin.wasm
We can now test plugin.wasm
using the Extism CLI's run
command:
extism call plugin.wasm greet --input="Benjamin" --wasi
# => Hello, Benjamin!
Note: Currently
wasi
must be provided for all JavaScript plug-ins even if they don't need system access, however we're looking at how to make this optional.
Note: We also have a web-based, plug-in tester called the Extism Playground
We catch any exceptions thrown and return them as errors to the host. Suppose we want to re-write our greeting module to never greet Benjamins:
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 }
Now compile and run:
extism-js plugin.js -i plugin.d.ts -o plugin.wasm
extism call plugin.wasm greet --input="Benjamin" --wasi
# => Error: Uncaught Error: Sorry, we don't greet Benjamins!
# => at greet (script.js:4)
# => at <eval> (script.js)
echo $? # print last status code
# => 1
extism call plugin.wasm greet --input="Zach" --wasi
# => Hello, Zach!
echo $?
# => 0
If you want to handle more complex types, the plug-in can input and output bytes with Host.inputBytes
and Host.outputBytes
respectively. Those bytes can represent any complex type. A common format to use is JSON:
function sum() {
const params = JSON.parse(Host.inputString())
Host.outputString(JSON.stringify({ sum: params.a + params.b }))
}
extism call plugin.wasm sum --input='{"a": 20, "b": 21}' --wasi
# => {"sum":41}
Configs are key-value pairs that can be passed in by the host when creating a
plug-in. These can be useful to statically configure the plug-in with some data that exists across every function call. Here is a trivial example using Config.get
:
function greet() {
const user = Config.get("user")
Host.outputString(`Hello, ${user}!`)
}
module.exports = { greet }
To test it, the Extism CLI has a --config
option that lets you pass in key=value
pairs:
extism call plugin.wasm greet --config user=Benjamin --wasi
# => Hello, Benjamin!
Variables are another key-value mechanism but it's a mutable data store that
will persist across function calls. These variables will persist as long as the
host has loaded and not freed the plug-in.
You can use Var.getBytes
, Var.getString
, and Var.set
to manipulate vars:
function count() {
let count = Var.getString("count") || '0'
count = parseInt(count, 10)
count += 1
Var.set("count", count.toString())
Host.outputString(count.toString())
}
module.exports = { count }
At the current time, calling console.log
emits an info
log. Please file an issue or PR if you want to expose the raw logging interface:
function logStuff() {
console.log('Hello, World!')
}
module.exports = { logStuff }
Running it, you need to pass a log-level flag:
extism call plugin.wasm logStuff --wasi --log-level=info
# => 2023/10/17 14:25:00 Hello, World!
HTTP calls can be made using the synchronous API Http.request
:
function callHttp() {
const request = {
method: "GET",
url: "https://jsonplaceholder.typicode.com/todos/1"
}
const response = Http.request(request)
if (response.status != 200) throw new Error(`Got non 200 response ${response.status}`)
Host.outputString(response.body)
}
module.exports = { callHttp }
Until the js-pdk hits 1.0, we may make changes to this API. To use host functions you need to declare a TypeScript interface extism:host/user
:
declare module 'main' {
export function greet(): I32;
}
declare module 'extism:host' {
interface user {
myHostFunction1(ptr: I64): I64;
myHostFunction2(ptr: I64): I64;
}
}
Note: These functions may only use I64
arguments, up to 5 arguments.
To use these you need to use Host.getFunctions()
:
const { myHostFunction1, myHostFunction2 } = Host.getFunctions()
Calling them is a similar process to other PDKs. You need to manage the memory with the Memory object and pass across an offset as the I64
ptr. Using the return value means dereferencing the returned I64
ptr from Memory.
function greet() {
let msg = "Hello from js 1"
let mem = Memory.fromString(msg)
let offset = myHostFunction1(mem.offset)
let response = Memory.find(offset).readString()
if (response != "myHostFunction1: " + msg) {
throw Error(`wrong message came back from myHostFunction1: ${response}`)
}
msg = { hello: "world!" }
mem = Memory.fromJsonObject(msg)
offset = myHostFunction2(mem.offset)
response = Memory.find(offset).readJsonObject()
if (response.hello != "myHostFunction2") {
throw Error(`wrong message came back from myHostFunction2: ${response}`)
}
Host.outputString(`Hello, World!`)
}
module.exports = { greet }
IMPORTANT: Currently, a limitation in the js-pdk is that host functions may only have up to 5 arguments.
The compiler cli and core engine can now run bundled code. You will want to use a bundler if you want to want to or include modules from NPM, or write the plugin in Typescript, for example.
There are 2 primary constraints to using a bundler:
- Your compiled output must be CJS format, not ESM
- You must target es2020 or lower
The easiest way to set this up would be to use esbuild. The following is a quickstart guide to setting up a project:
# Make a new JS project
mkdir extism-plugin
cd extism-plugin
npm init -y
npm install esbuild @extism/js-pdk --save-dev
mkdir src
mkdir dist
Optionally add a jsconfig.json
or tsconfig.json
to improve intellisense:
{
"compilerOptions": {
"lib": [], // this ensures unsupported globals aren't suggested
"types": ["@extism/js-pdk"], // while this makes the IDE aware of the ones that are
"noEmit": true // this is only relevant for tsconfig.json
},
"include": ["src/**/*"]
}
Add esbuild.js
:
const esbuild = require('esbuild');
// include this if you need some node support:
// npm i @esbuild-plugins/node-modules-polyfill --save-dev
// const { NodeModulesPolyfillPlugin } = require('@esbuild-plugins/node-modules-polyfill')
esbuild
.build({
// supports other types like js or ts
entryPoints: ['src/index.js'],
outdir: 'dist',
bundle: true,
sourcemap: true,
//plugins: [NodeModulesPolyfillPlugin()], // include this if you need some node support
minify: false, // might want to use true for production build
format: 'cjs', // needs to be CJS for now
target: ['es2020'] // don't go over es2020 because quickjs doesn't support it
})
Add a build
script to your package.json
:
{
"name": "extism-plugin",
// ...
"scripts": {
// ...
"build": "node esbuild.js && extism-js dist/index.js -i src/index.d.ts -o dist/plugin.wasm"
},
// ...
}
Let's import a module from NPM:
npm install --save fastest-levenshtein
Now make some code in src/index.js
. You can use import
to load node_modules:
Note: This module uses the ESM Module syntax. The bundler will transform all the code to CJS for us
import {distance, closest} from 'fastest-levenshtein'
// this function is private to the module
function privateFunc() { return 'world' }
// use any export syntax to export a function be callable by the extism host
export function get_closest() {
let input = Host.inputString()
let result = closest(input, ['slow', 'faster', 'fastest'])
Host.outputString(result + ' ' + privateFunc())
}
And a d.ts file for it at src/index.d.ts
:
declare module "main" {
// Extism exports take no params and return an I32
export function get_closest(): I32;
}
# Run the build script and the plugin will be compiled to dist/plugin.wasm
npm run build
# You can now call from the extism cli or a host SDK
extism call dist/plugin.wasm get_closest --input="fest" --wasi
# => faster World
Oftentimes people want their JS plug-ins to control or create views. React and JSX are a great way to do this. Here is the simplest example. Let's just render a simple view in a typescript plugin.
First declare a render
export:
declare module "main" {
export function render(): I32;
}
Now install the deps:
npm install react-dom --save
npm install @types/react --save-dev
Now we can make an index.tsx:
import { renderToString } from 'react-dom/server';
import React from 'react'
interface AppProps {
name: string;
}
function App(props: AppProps) {
return <>
<p>Hello ${props.name}!</p>
</>
}
export function render() {
const props = JSON.parse(Host.inputString()) as AppProps
const app = <App {...props} />
Host.outputString(renderToString(app))
}
To see a more complex example of how you might build something real, see examples/react
Before compiling the compiler, you need to install prerequisites.
- Install Rust using rustup
- Install the WASI target platform via
rustup target add --toolchain stable wasm32-wasi
- Install the wasi sdk using the makefile command:
make download-wasi-sdk
- Install CMake (on macOS with homebrew,
brew install cmake
) - Install Binaryen and add it's install location to your PATH (only wasm-opt is required for build process)
- Install 7zip(only for Windows)
Run make to compile the core crate (the engine) and the cli:
make
To test the built compiler (ensure you have Extism installed):
./target/release/extism-js bundle.js -i bundle.d.ts -o out.wasm
extism call out.wasm count_vowels --wasi --input='Hello World Test!'
# => "{\"count\":4}"
This works a little differently than other PDKs. You cannot compile JS to Wasm because it doesn't have an appropriate type system to do this. Something like Assemblyscript is better suited for this. Instead, we have compiled QuickJS to Wasm. The extism-js
command we have provided here is a little compiler / wrapper that does a series of things for you:
- It loads an "engine" Wasm program containing the QuickJS runtime
- It initializes a QuickJS context
- It loads your js source code into memory
- It parses the js source code for exports and generates 1-to-1 proxy export functions in Wasm
- It freezes and emits the machine state as a new Wasm file at this post-initialized point in time
This new Wasm file can be used just like any other Extism plugin.
Javy, and many other high level language Wasm tools, assume use of the command pattern. This is when the Wasm module only exports a main function and communicates with the host through stdin and stdout. With Extism, we have more of a shared library interface. The module exposes multiple entry points through exported functions. Furthermore, Javy has many Javy and Shopify specific things it's doing that we will not need. However, the core idea is the same, and we can possibly contribute by adding support to Javy for non-command-pattern modules. Then separating the Extism PDK specific stuff into another repo.