├── .eslintrc.yaml ├── .github ├── CODEOWNERS └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── DEVELOPING.md ├── LICENSE ├── README.md ├── benches ├── deno │ └── main.ts └── node │ ├── main.js │ ├── package-lock.json │ └── package.json ├── examples ├── deno.ts ├── index.html └── node.js ├── jsr.json ├── justfile ├── package-lock.json ├── package.json ├── playwright.config.js ├── src ├── background-plugin.ts ├── call-context.ts ├── foreground-plugin.ts ├── http-context.ts ├── interfaces.ts ├── manifest.ts ├── mod.test.ts ├── mod.ts ├── polyfills │ ├── browser-capabilities.ts │ ├── browser-fs.ts │ ├── browser-wasi.ts │ ├── bun-capabilities.ts │ ├── bun-response-to-module.ts │ ├── bun-worker-url.ts │ ├── deno-capabilities.ts │ ├── deno-minimatch.ts │ ├── deno-wasi.ts │ ├── host-node-worker_threads.ts │ ├── node-capabilities.ts │ ├── node-fs.ts │ ├── node-minimatch.ts │ ├── node-wasi.ts │ ├── response-to-module.ts │ └── worker-node-worker_threads.ts ├── worker-url.ts └── worker.ts ├── tests ├── data │ └── test.txt └── playwright.test.js ├── tsconfig.json ├── types └── deno │ └── index.d.ts └── wasm ├── 02-var-reflected.wasm ├── alloc.wasm ├── circular-lhs.wasm ├── circular-rhs.wasm ├── circular.wasm ├── code-functions.wasm ├── code.wasm ├── config.wasm ├── consume.wasm ├── corpus ├── 00-circular-lhs.wat ├── 01-circular-rhs.wat ├── 02-var-reflected.wat ├── circular.wat ├── fs-link.wat ├── loop-forever-init.wat └── loop-forever.wat ├── exit.wasm ├── fail.wasm ├── fs-link.wasm ├── fs.wasm ├── hello.wasm ├── hello_haskell.wasm ├── http.wasm ├── http_headers.wasm ├── input_offset.wasm ├── log.wasm ├── loop-forever-init.wasm ├── loop-forever.wasm ├── memory.wasm ├── reflect.wasm ├── sleep.wasm ├── upper.wasm ├── var.wasm └── wasistdout.wasm /.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: true 3 | es2021: true 4 | node: true 5 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'] 6 | parser: '@typescript-eslint/parser' 7 | root: true 8 | ignorePatterns: [] 9 | rules: 10 | no-constant-condition: 'off' 11 | no-unused-vars: 'off' 12 | '@typescript-eslint/no-unused-vars': 13 | - 'error' 14 | - argsIgnorePattern: '_.*' 15 | varsIgnorePattern: '_.*' 16 | '@typescript-eslint/no-explicit-any': 'off' 17 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @chrisdickinson @mhmd-azeez 2 | 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: JS CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest, macos-latest, windows-latest] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - uses: extractions/setup-just@v1 21 | 22 | - uses: actions/setup-node@v3.8.1 23 | with: 24 | node-version: lts/* 25 | check-latest: true 26 | 27 | - uses: denoland/setup-deno@v1.1.2 28 | 29 | - uses: oven-sh/setup-bun@v1 30 | if: ${{ matrix.os != 'windows-latest' }} 31 | with: 32 | bun-version: latest 33 | 34 | - name: Test 35 | run: npm run test 36 | 37 | jsr: 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@v3 41 | 42 | - uses: denoland/setup-deno@v1.1.2 43 | 44 | - name: jsr slow types check 45 | run: deno publish --dry-run 46 | 47 | docs: 48 | runs-on: ubuntu-latest 49 | steps: 50 | - uses: actions/checkout@v3 51 | 52 | - uses: extractions/setup-just@v1 53 | 54 | - uses: actions/setup-node@v3.8.1 55 | with: 56 | node-version: lts/* 57 | check-latest: true 58 | 59 | - uses: denoland/setup-deno@v1.1.2 60 | 61 | - uses: oven-sh/setup-bun@v1 62 | if: ${{ matrix.os != 'windows-latest' }} 63 | with: 64 | bun-version: latest 65 | 66 | - name: Docs 67 | shell: bash 68 | run: | 69 | just prepare 70 | just docs 71 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish package to NPM 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | permissions: 13 | contents: write 14 | id-token: write 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - uses: extractions/setup-just@v1 20 | 21 | - name: Setup Node.js environment 22 | uses: actions/setup-node@v3.8.1 23 | 24 | - name: Setup Deno 25 | uses: denoland/setup-deno@v1.1.2 26 | 27 | - name: Build 28 | run: npm run build 29 | 30 | - name: remove dist/ package for now 31 | run: rm dist/*.tgz 32 | 33 | - name: Update package version 34 | run: | 35 | tag="${{ github.ref }}" 36 | tag="${tag/refs\/tags\/v/}" 37 | npm version $tag --no-git-tag-version --no-commit-hooks 38 | jsr="$(jsr.json 40 | 41 | - name: npm publish @extism/extism 42 | uses: JS-DevTools/npm-publish@v2.2.2 43 | with: 44 | token: ${{ secrets.NPM_TOKEN }} 45 | 46 | - name: jsr publish @extism/extism 47 | run: | 48 | npx jsr publish --allow-dirty 49 | 50 | - name: Update package name 51 | run: | 52 | pkg="$(package.json 54 | 55 | - name: npm publish extism 56 | uses: JS-DevTools/npm-publish@v2.2.2 57 | with: 58 | token: ${{ secrets.NPM_TOKEN }} 59 | 60 | - name: typedoc 61 | run: just docs 62 | 63 | - name: Deploy docs to GitHub Pages 64 | uses: peaceiris/actions-gh-pages@v3 65 | with: 66 | github_token: ${{ secrets.GITHUB_TOKEN }} 67 | publish_dir: ./docs 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # test artifacts 2 | tests/artifacts 3 | 4 | # generated docs 5 | docs/ 6 | 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | .pnpm-debug.log* 15 | 16 | # Diagnostic reports (https://nodejs.org/api/report.html) 17 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 18 | 19 | # Runtime data 20 | pids 21 | *.pid 22 | *.seed 23 | *.pid.lock 24 | 25 | # Directory for instrumented libs generated by jscoverage/JSCover 26 | lib-cov 27 | 28 | # Coverage directory used by tools like istanbul 29 | coverage 30 | *.lcov 31 | 32 | # nyc test coverage 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | .grunt 37 | 38 | # Bower dependency directory (https://bower.io/) 39 | bower_components 40 | 41 | # node-waf configuration 42 | .lock-wscript 43 | 44 | # Compiled binary addons (https://nodejs.org/api/addons.html) 45 | build/Release 46 | 47 | # Dependency directories 48 | node_modules/ 49 | jspm_packages/ 50 | 51 | # Snowpack dependency directory (https://snowpack.dev/) 52 | web_modules/ 53 | 54 | # TypeScript cache 55 | *.tsbuildinfo 56 | 57 | # Optional npm cache directory 58 | .npm 59 | 60 | # Optional eslint cache 61 | .eslintcache 62 | 63 | # Optional stylelint cache 64 | .stylelintcache 65 | 66 | # Microbundle cache 67 | .rpt2_cache/ 68 | .rts2_cache_cjs/ 69 | .rts2_cache_es/ 70 | .rts2_cache_umd/ 71 | 72 | # Optional REPL history 73 | .node_repl_history 74 | 75 | # Output of 'npm pack' 76 | *.tgz 77 | 78 | # Yarn Integrity file 79 | .yarn-integrity 80 | 81 | # dotenv environment variable files 82 | .env 83 | .env.development.local 84 | .env.test.local 85 | .env.production.local 86 | .env.local 87 | 88 | # parcel-bundler cache (https://parceljs.org/) 89 | .cache 90 | .parcel-cache 91 | 92 | # Next.js build output 93 | .next 94 | out 95 | 96 | # Nuxt.js build / generate output 97 | .nuxt 98 | dist 99 | 100 | # Gatsby files 101 | .cache/ 102 | # Comment in the public line in if your project uses Gatsby and not Next.js 103 | # https://nextjs.org/blog/next-9-1#public-directory-support 104 | # public 105 | 106 | # vuepress build output 107 | .vuepress/dist 108 | 109 | # vuepress v2.x temp and cache directory 110 | .temp 111 | .cache 112 | 113 | # Docusaurus cache and generated files 114 | .docusaurus 115 | 116 | # Serverless directories 117 | .serverless/ 118 | 119 | # FuseBox cache 120 | .fusebox/ 121 | 122 | # DynamoDB Local files 123 | .dynamodb/ 124 | 125 | # TernJS port file 126 | .tern-port 127 | 128 | # Stores VSCode versions used for testing VSCode extensions 129 | .vscode-test 130 | 131 | # yarn v2 132 | .yarn/cache 133 | .yarn/unplugged 134 | .yarn/build-state.yml 135 | .yarn/install-state.gz 136 | .pnp.* 137 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "trailingComma": "all", 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.lint": true, 4 | "deno.unstable": true, 5 | "deno.enablePaths": [ 6 | "./src/mod.ts", 7 | "./examples/deno.ts", 8 | "./src/mod.test.ts" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /DEVELOPING.md: -------------------------------------------------------------------------------- 1 | # Developing 2 | 3 | ## The build process 4 | 5 | The Extism SDK targets several platforms: 6 | 7 | - Deno 8 | - Node ECMAScript Modules ("ESM") 9 | - Node CommonJS Modules ("CJS") 10 | - Browser ECMAScript Modules 11 | 12 | The source of this library is written as valid TypeScript, which may be 13 | consumed and run directly by Deno. The latter three platforms are treated as 14 | compile targets. There are two other compile targets: 15 | 16 | - The source of the [Worker](https://mdn.io/worker), compiled for the browser. 17 | - The source of the [Worker](https://mdn.io/worker), compiled for node. 18 | - Tests 19 | 20 | For compiled targets, the worker is compiled to a single artifact with an entry 21 | point starting at `src/worker.ts`, base64-encoded, and included in the 22 | resulting artifact. 23 | 24 | Builds are orchestrated by the `justfile` and `esbuild`: each build target recipe accepts 25 | incoming `esbuild` flags as an array of JSON data and prepends its own configuration. 26 | This allows dependent recipes to override earlier flags. An annotated example: 27 | 28 | ``` 29 | build_worker_browser out='worker/browser' args='[]': # <-- we accept args and an out dir 30 | #!/bin/bash 31 | config="$(<<<'{{ args }}' jq -cM ' 32 | [{ 33 | "format": "esm", 34 | "alias": { 35 | "node:worker_threads": "./src/polyfills/worker-node-worker_threads.ts", 36 | }, 37 | "polyfills": { 38 | "./src/polyfills/deno-capabilities.ts": "./src/polyfills/browser-capabilities.ts", 39 | "./src/polyfills/deno-fs.ts": "./src/polyfills/browser-fs.ts", 40 | "./src/polyfills/deno-wasi.ts": "./src/polyfills/browser-wasi.ts", 41 | } 42 | }] + . # <--- add this recipe's flags to the incoming flags. 43 | ')" 44 | just build_worker {{ out }} "$config" 45 | ``` 46 | 47 | There is a `_build` recipe that all other build targets depend on, at 48 | varying degrees of indirection. This `_build` fixes all Deno-style `.ts` 49 | import statements, invokes `esbuild`, and emits TypeScript declarations 50 | via `tsc`. 51 | 52 | ### Polyfills 53 | 54 | We use `esbuild` to compile to these targets. This allows us to abstract 55 | differences at module boundaries and replace them as-needed. For example: each 56 | of Node, Deno, and the Browser have different WASI libraries with slightly different 57 | interfaces. We default to Deno's definition of the polyfills, then map to polyfills 58 | for our specific target. 59 | 60 | > **Note** 61 | > Polyfills were initially implemented as `js-sdk:POLYFILL` modules, which have the advantage 62 | > that `esbuild` is able to use its `alias` feature to map them without any extra plugins. 63 | > 64 | > However, this required using `deno.json`'s import maps to resolve the modules in the Deno target. 65 | > At the time of writing, Deno does not support import maps for package dependencies, only for top-level 66 | > applications, which means we can't make use of those. Deno [notes they are working](https://hachyderm.io/@deno_land@fosstodon.org/111693831461332098) 67 | > on a feature for to support this use-case, though, so we'll want to track where they end up. 68 | 69 | 1. If the polyfill introduces dependencies on `deno.land`, add them to `types/deno/index.d.ts` to 70 | provide support for other languages. 71 | - These typings don't have to be exact -- they can be "best effort" if you have to write them yourself. 72 | 2. Modifying the esbuild `polyfills` added by `build_worker`, `build_worker_node`, 73 | `build_node_cjs`, `build_node_esm`, and `build_browser`. 74 | - Node overrides are set to `./src/polyfills/node-wasi.ts`. 75 | - Browser overrides are set to `./src/polyfills/browser-wasi.ts`. 76 | 77 | In this manner, differences between the platforms are hidden and the core of 78 | the library can be written in "mutually intelligble" TypeScript. 79 | 80 | One notable exception to this rule: Deno implements Node polyfills; for 81 | complicated imports, like `node:worker_threads`, we instead only polyfill the 82 | browser. The browser polyfill is split into `host:node:worker_threads.ts` and 83 | `worker-node-worker_threads.ts`: these polyfill just enough of the Node worker 84 | thread API over the top of builtin workers to make them adhere to the same 85 | interface. 86 | 87 | ### Testing 88 | 89 | Tests are co-located with source code, using the `*.test.ts` pattern. Tests 90 | are run in three forms: 91 | 92 | - Interpreted, via `deno test -A` 93 | - Compiled, via `node --test` 94 | - And via playwright, which polyfills `node:test` using `tape` and runs tests 95 | across firefox, webkit, and chromium. 96 | 97 | The `assert` API is polyfilled in browser using 98 | [`rollup-plugin-polyfill-node`](https://npm.im/rollup-plugin-polyfill-node). 99 | This polyfill doesn't track Node's APIs very closely, so it's best to stick to 100 | simple assertions (`assert.equal`.) 101 | 102 | ## The Extism runtime, shared memory, and worker threads 103 | 104 | This SDK defaults to running on background threads in contexts where that is 105 | feasible. Host functions require this library to share memory between the main 106 | and worker threads, however. The rules on transferring buffers are as follows: 107 | 108 | - ArrayBuffers may be transferred asynchronously between main and worker threads. Once 109 | transferred they may no longer be accessed on the sending thread. 110 | - SharedArrayBuffers may be sent _only_ from the main thread. (All browsers allow 111 | the creation of SharedArrayBuffers off of the main thread, but Chromium disallows 112 | _sending_ those SharedArrayBuffers to the main thread from the worker.) 113 | - Browser environments disallow using `TextDecoder` against typed arrays backed by 114 | SharedArrayBuffers. The Extism library handles this transparently by copying out 115 | of shared memory. 116 | 117 | These rules make navigating memory sharing fairly tricky compared to other SDK platforms. 118 | As a result, the JS SDK includes its own extism runtime, which: 119 | 120 | - Reserves 16 bits of address space for "page id" information, leaving 48 bits per "page" 121 | of allocated memory. 122 | - Creates sharedarraybuffers on the worker thread and shares them with the worker thread on 123 | `call()`. 124 | - Worker-originated pages are transferred up to the main thread and copied into sharedarraybuffers 125 | whenever the worker transfers control to the main thread (whether returning from a `call()` or 126 | calling a `hostfn`.) 127 | - When new pages are created during the execution of a `hostfn`, they will be 128 | _copied down_ to the worker thread using a 64KiB scratch space. 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Dylibso, Inc. 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Extism JS SDK 2 | 3 | This is a universal JavaScript SDK for Extism. It works in all the major JavaScript runtimes: 4 | 5 | * Browsers (Firefox, Chrome, WebKit) 6 | * Node 7 | * Deno 8 | * Bun 9 | * Cloudflare Workers 10 | * _interested in others? [Let us know!](https://github.com/extism/js-sdk/issues)_ 11 | 12 | Instead of using FFI and the libextism shared object, this library uses whatever Wasm runtime is already available with the JavaScript runtime. 13 | 14 | ## Installation 15 | 16 | Install via npm: 17 | 18 | ```shell 19 | $ npm install @extism/extism 20 | ``` 21 | 22 | > **Note**: Keep in mind we will possibly have breaking changes b/w rc versions until we hit 1.0. 23 | 24 | ## Compatibility 25 | 26 | - **Node.js**: `v18+` (with `--experimental-global-webcrypto`); `v20` with no additional flags 27 | - **Deno**: `v1.36+` 28 | - **Bun**: Tested on `v1.0.7`; Bun partially implements WASI. 29 | 30 | Browser tests are run using [playwright](https://playwright.dev)'s defaults. In 31 | browsers, background thread support requires `SharedArrayBuffer` and `Atomic` 32 | support. This is only available in 33 | [`crossOriginIsolated`](https://developer.mozilla.org/en-US/docs/Web/API/crossOriginIsolated) 34 | contexts. 35 | 36 | ## Reference Docs 37 | 38 | Reference docs can be found at [https://extism.github.io/js-sdk/](https://extism.github.io/js-sdk/). 39 | 40 | ## Getting Started 41 | 42 | This guide should walk you through some of the concepts in Extism and this JS library. 43 | 44 | First you should import `createPlugin` from Extism: 45 | ```js 46 | // CommonJS 47 | const createPlugin = require("@extism/extism") 48 | 49 | // ES Modules/Typescript 50 | import createPlugin from '@extism/extism'; 51 | 52 | // Deno 53 | import createPlugin from "jsr:@extism/extism"; 54 | ``` 55 | 56 | ## Creating A Plug-in 57 | 58 | The primary concept in Extism is the [plug-in](https://extism.org/docs/concepts/plug-in). You can think of a plug-in as a code module stored in a `.wasm` file. 59 | 60 | Plug-in code can come from a file on disk, object storage or any number of places. Since you may not have one handy let's load a demo plug-in from the web: 61 | 62 | ```js 63 | const plugin = await createPlugin( 64 | 'https://cdn.modsurfer.dylibso.com/api/v1/module/be716369b7332148771e3cd6376d688dfe7ee7dd503cbc43d2550d76cb45a01d.wasm', 65 | { useWasi: true } 66 | ); 67 | ``` 68 | 69 | > *Note*: Plug-ins can be loaded in a variety of ways. See the reference docs for [createPlugin](https://extism.github.io/js-sdk/functions/createPlugin.html) 70 | > and read about the [manifest](https://extism.org/docs/concepts/manifest/). 71 | 72 | ## Calling A Plug-in's Exports 73 | 74 | We're using a plug-in, `count_vowels`, which was compiled from Rust. 75 | `count_vowels` plug-in does one thing: it counts vowels in a string. As such, 76 | it exposes one "export" function: `count_vowels`. We can call exports using 77 | `Plugin.call`: 78 | 79 | ```js 80 | const input = "Hello World"; 81 | let out = await plugin.call("count_vowels", input); 82 | console.log(out.text()); 83 | 84 | // => {"count": 3, "total": 3, "vowels": "aeiouAEIOU"} 85 | ``` 86 | 87 | All plug-in exports have a simple interface of optional bytes in, and optional 88 | bytes out. This plug-in happens to take a string and return a JSON encoded 89 | string with a report of results. 90 | 91 | ### Plug-in State 92 | 93 | Plug-ins may be stateful or stateless. Plug-ins can maintain state between calls by 94 | the use of variables. Our `count_vowels` plug-in remembers the total number of 95 | vowels it's ever counted in the `total` key in the result. You can see this by 96 | making subsequent calls to the export: 97 | 98 | ```js 99 | let out = await plugin.call("count_vowels", "Hello, World!"); 100 | console.log(out.text()); 101 | // => {"count": 3, "total": 3, "vowels": "aeiouAEIOU"} 102 | 103 | out = await plugin.call("count_vowels", "Hello, World!"); 104 | console.log(out.json()); 105 | // => {"count": 3, "total": 6, "vowels": "aeiouAEIOU"} 106 | ``` 107 | 108 | These variables will persist until you call `await plugin.reset()`. Variables 109 | are not shared between plugin instances. 110 | 111 | ### Configuration 112 | 113 | Plug-ins may optionally take a configuration object. This is a static way to 114 | configure the plug-in. Our count-vowels plugin takes an optional configuration 115 | to change out which characters are considered vowels. Example: 116 | 117 | ```js 118 | const wasm = { 119 | url: 'https://github.com/extism/plugins/releases/latest/download/count_vowels.wasm' 120 | } 121 | 122 | let plugin = await createPlugin(wasm.url, { 123 | useWasi: true, 124 | }); 125 | 126 | let out = await plugin.call("count_vowels", "Yellow, World!"); 127 | console.log(out.text()); 128 | // => {"count": 3, "total": 3, "vowels": "aeiouAEIOU"} 129 | 130 | plugin = await createPlugin(wasm.url, { 131 | useWasi: true, 132 | config: { "vowels": "aeiouyAEIOUY" } 133 | }); 134 | 135 | out = await plugin.call("count_vowels", "Yellow, World!"); 136 | console.log(out.text()); 137 | // => {"count": 4, "total": 4, "vowels": "aeiouAEIOUY"} 138 | ``` 139 | 140 | ### Host Functions 141 | 142 | Let's extend our count-vowels example a little bit: Instead of storing the 143 | `total` in an ephemeral plug-in var, let's store it in a persistent key-value 144 | store! 145 | 146 | Wasm can't use our KV store on its own. This is where [Host 147 | Functions](https://extism.org/docs/concepts/host-functions) come in. 148 | 149 | [Host functions](https://extism.org/docs/concepts/host-functions) allow us to 150 | grant new capabilities to our plug-ins from our application. They are simply 151 | some JS functions you write which can be passed down and invoked from any 152 | language inside the plug-in. 153 | 154 | Let's load the manifest like usual but load up this `count_vowels_kvstore` 155 | plug-in: 156 | 157 | ```js 158 | const wasm = { 159 | url: "https://github.com/extism/plugins/releases/latest/download/count_vowels_kvstore.wasm" 160 | } 161 | ``` 162 | 163 | > *Note*: The source code for this is [here](https://github.com/extism/plugins/blob/main/count_vowels_kvstore/src/lib.rs) and is written in Rust, but it could be written in any of our PDK languages. 164 | 165 | Unlike our previous plug-in, this plug-in expects you to provide host functions that satisfy its import interface for a KV store. 166 | 167 | We want to expose two functions to our plugin, `kv_write(key: string, value: Uint8Array)` which writes a bytes value to a key and `kv_read(key: string): Uint8Array` which reads the bytes at the given `key`. 168 | ```js 169 | // pretend this is Redis or something :) 170 | let kvStore = new Map(); 171 | 172 | const options = { 173 | useWasi: true, 174 | functions: { 175 | "extism:host/user": { 176 | // NOTE: the first argument is always a CurrentPlugin 177 | kv_read(cp: CurrentPlugin, offs: bigint) { 178 | const key = cp.read(offs).text(); 179 | let value = kvStore.get(key) ?? new Uint8Array([0, 0, 0, 0]); 180 | console.log(`Read ${new DataView(value.buffer).getUint32(0, true)} from key=${key}`); 181 | return cp.store(value); 182 | }, 183 | kv_write(cp: CurrentPlugin, kOffs: bigint, vOffs: bigint) { 184 | const key = cp.read(kOffs).text(); 185 | 186 | // Value is a PluginOutput, which subclasses DataView. Along 187 | // with the `text()` and `json()` methods we've seen, we also 188 | // get DataView methods, such as `getUint32`. 189 | const value = cp.read(vOffs); 190 | console.log(`Writing value=${value.getUint32(0, true)} from key=${key}`); 191 | 192 | kvStore.set(key, value.bytes()); 193 | } 194 | } 195 | } 196 | }; 197 | ``` 198 | 199 | > *Note*: In order to write host functions you should get familiar with the 200 | > methods on the `CurrentPlugin` type. 201 | 202 | We need to pass these imports to the plug-in to create them. All imports of a 203 | plug-in must be satisfied for it to be initialized: 204 | 205 | ```js 206 | const plugin = await createPlugin(wasm.url, options); 207 | ``` 208 | 209 | Now we can invoke the event: 210 | 211 | ```js 212 | let out = await plugin.call("count_vowels", "Hello World!"); 213 | console.log(out.text()); 214 | // => Read from key=count-vowels" 215 | // => Writing value=3 from key=count-vowels" 216 | // => {"count": 3, "total": 3, "vowels": "aeiouAEIOU"} 217 | 218 | out = await plugin.call("count_vowels", "Hello World!"); 219 | console.log(out.text()); 220 | // => Read from key=count-vowels" 221 | // => Writing value=6 from key=count-vowels" 222 | // => {"count": 3, "total": 6, "vowels": "aeiouAEIOU"} 223 | ``` 224 | 225 | ## Run Examples: 226 | 227 | ``` 228 | npm run build 229 | 230 | node --experimental-wasi-unstable-preview1 ./examples/node.js wasm/config.wasm 231 | 232 | deno run -A ./examples/deno.ts ./wasm/config.wasm 233 | 234 | bun run ./examples/node.js wasm/config.wasm 235 | ``` 236 | -------------------------------------------------------------------------------- /benches/deno/main.ts: -------------------------------------------------------------------------------- 1 | import { createPlugin } from '../../src/mod.ts'; 2 | 3 | const buf = await Deno.readFile('../../wasm/consume.wasm'); 4 | const module = await WebAssembly.compile(buf); 5 | 6 | // Plugin creation benchmarks 7 | Deno.bench({ 8 | name: 'create consume (foreground; fs)', 9 | async fn(b) { 10 | b.start(); 11 | const plugin = await createPlugin('../../wasm/consume.wasm'); 12 | b.end(); 13 | await plugin.close(); 14 | } 15 | }); 16 | 17 | Deno.bench({ 18 | name: 'create consume (background; fs)', 19 | async fn(b) { 20 | b.start(); 21 | const plugin = await createPlugin('../../wasm/consume.wasm', { runInWorker: true }); 22 | b.end(); 23 | await plugin.close(); 24 | } 25 | }); 26 | 27 | Deno.bench({ 28 | name: 'create consume (foreground; buffer)', 29 | async fn(b) { 30 | b.start(); 31 | const plugin = await createPlugin({ wasm: [{ data: buf }] }); 32 | b.end(); 33 | await plugin.close(); 34 | } 35 | }); 36 | 37 | Deno.bench({ 38 | name: 'create consume (background; buffer)', 39 | async fn(b) { 40 | b.start(); 41 | const plugin = await createPlugin({ wasm: [{ data: buf }] }, { runInWorker: true }); 42 | b.end(); 43 | await plugin.close(); 44 | } 45 | }); 46 | 47 | Deno.bench({ 48 | name: 'create consume (foreground; WebAssembly.Module)', 49 | async fn(b) { 50 | b.start(); 51 | const plugin = await createPlugin({ wasm: [{ module }] }); 52 | b.end(); 53 | await plugin.close(); 54 | } 55 | }); 56 | 57 | Deno.bench({ 58 | name: 'create consume (background; WebAssembly.Module)', 59 | async fn(b) { 60 | b.start(); 61 | const plugin = await createPlugin({ wasm: [{ module }] }, { runInWorker: true }); 62 | b.end(); 63 | await plugin.close(); 64 | } 65 | }); 66 | 67 | const writeBuffer = new Uint8Array(1 << 30); 68 | const reflectBuf = await Deno.readFile('../../wasm/reflect.wasm'); 69 | const reflectModule = await WebAssembly.compile(reflectBuf); 70 | 71 | for (const [humanSize, size] of [['1KiB', 1024], ['1MiB', 1 << 20]] as [string, number][]) { 72 | Deno.bench({ 73 | name: `write consume ${humanSize} (foreground; WebAssembly.Module)`, 74 | group: 'consume', 75 | async fn(b) { 76 | const plugin = await createPlugin({ wasm: [{ module }] }, {}); 77 | b.start() 78 | await plugin.call('consume', writeBuffer.slice(0, size)); 79 | b.end() 80 | await plugin.close() 81 | }, 82 | }); 83 | 84 | Deno.bench({ 85 | name: `write consume ${humanSize} (background; WebAssembly.Module)`, 86 | group: 'consume', 87 | async fn(b) { 88 | const plugin = await createPlugin({ wasm: [{ module }] }, { runInWorker: true }); 89 | b.start() 90 | await plugin.call('consume', writeBuffer.slice(0, size)); 91 | b.end() 92 | await plugin.close() 93 | }, 94 | }); 95 | 96 | Deno.bench({ 97 | name: `write reflect ${humanSize} (foreground; WebAssembly.Module)`, 98 | group: 'reflect', 99 | async fn(b) { 100 | const plugin = await createPlugin({ wasm: [{ module: reflectModule }] }, { 101 | functions: { 102 | 'extism:host/user': { 103 | host_reflect(context, arg) { 104 | const buf = context.read(arg)!.bytes(); 105 | return context.store(buf); 106 | } 107 | } 108 | } 109 | }); 110 | b.start() 111 | await plugin.call('reflect', writeBuffer.slice(0, size)); 112 | b.end() 113 | await plugin.close() 114 | }, 115 | }); 116 | 117 | Deno.bench({ 118 | name: `write reflect ${humanSize} (background; WebAssembly.Module)`, 119 | group: 'reflect', 120 | async fn(b) { 121 | const plugin = await createPlugin({ wasm: [{ module: reflectModule }] }, { 122 | runInWorker: true, 123 | functions: { 124 | 'extism:host/user': { 125 | host_reflect(context, arg) { 126 | const buf = context.read(arg)!.bytes(); 127 | return context.store(buf); 128 | } 129 | } 130 | } 131 | }); 132 | b.start() 133 | await plugin.call('reflect', writeBuffer.slice(0, size)); 134 | b.end() 135 | await plugin.close() 136 | }, 137 | }); 138 | } 139 | -------------------------------------------------------------------------------- /benches/node/main.js: -------------------------------------------------------------------------------- 1 | import createPlugin from '@extism/extism' 2 | import { readFileSync, openSync } from 'node:fs' 3 | import { Bench } from 'tinybench' 4 | 5 | { 6 | const buf = readFileSync('../../wasm/consume.wasm') 7 | const module = await WebAssembly.compile(buf) 8 | 9 | let plugins = [] 10 | const startup = new Bench({ name: 'createPlugin', time: 100, async teardown() { for (const plugin of plugins) await plugin.close(); plugins.length = 0; } }) 11 | 12 | startup 13 | .add('create consume (foreground; fs)', async () => { 14 | const plugin = await createPlugin('../../wasm/consume.wasm') 15 | plugins.push(plugin) 16 | }) 17 | .add('create consume (background; fs)', async () => { 18 | const plugin = await createPlugin('../../wasm/consume.wasm', { runInWorker: true }) 19 | plugins.push(plugin) 20 | }) 21 | .add('create consume (foreground; buffer)', async () => { 22 | const plugin = await createPlugin({ wasm: [{ data: buf }] }) 23 | plugins.push(plugin) 24 | }) 25 | .add('create consume (background; buffer)', async () => { 26 | const plugin = await createPlugin({ wasm: [{ data: buf }] }, { runInWorker: true }) 27 | plugins.push(plugin) 28 | }) 29 | .add('create consume (foreground; WebAssembly.Module)', async () => { 30 | const plugin = await createPlugin({ wasm: [{ module }] }) 31 | plugins.push(plugin) 32 | }) 33 | .add('create consume (background; WebAssembly.Module)', async () => { 34 | const plugin = await createPlugin({ wasm: [{ module }] }, { runInWorker: true }) 35 | plugins.push(plugin) 36 | }) 37 | 38 | await startup.run() 39 | 40 | for (const plugin of plugins) { 41 | await plugin.close() 42 | } 43 | console.log(startup.name) 44 | console.table(startup.table()) 45 | } 46 | 47 | { 48 | const buf = readFileSync('../../wasm/consume.wasm') 49 | const module = await WebAssembly.compile(buf) 50 | 51 | const plugin = await createPlugin({ wasm: [{ module }] }) 52 | const backgroundPlugin = await createPlugin({ wasm: [{ module }] }, { runInWorker: true }) 53 | const startup = new Bench({ name: 'write', time: 100 }) 54 | 55 | const buffer = new Uint8Array(1 << 20) 56 | 57 | startup 58 | .add('write consume 1KiB (foreground; WebAssembly.Module)', async () => { 59 | await plugin.call('consume', buffer.slice(0, 1024)) 60 | }) 61 | .add('write consume 1KiB (background; WebAssembly.Module)', async () => { 62 | await backgroundPlugin.call('consume', buffer.slice(0, 1024)) 63 | }) 64 | .add('write consume 1MiB (foreground; WebAssembly.Module)', async () => { 65 | await plugin.call('consume', buffer.slice(0, 1 << 20)) 66 | }) 67 | .add('write consume 1MiB (background; WebAssembly.Module)', async () => { 68 | await backgroundPlugin.call('consume', buffer.slice(0, 1 << 20)) 69 | }) 70 | 71 | await startup.run() 72 | await plugin.close() 73 | await backgroundPlugin.close() 74 | console.log(startup.name) 75 | console.table(startup.table()) 76 | } 77 | 78 | { 79 | const buf = readFileSync('../../wasm/reflect.wasm') 80 | const module = await WebAssembly.compile(buf) 81 | 82 | const plugin = await createPlugin({ wasm: [{ module }] }, { 83 | functions: { 84 | 'extism:host/user': { 85 | host_reflect(context, arg) { 86 | const buf = context.read(arg).bytes() 87 | return context.store(buf) 88 | } 89 | } 90 | } 91 | }) 92 | const backgroundPlugin = await createPlugin({ wasm: [{ module }] }, { 93 | runInWorker: true, 94 | functions: { 95 | 'extism:host/user': { 96 | host_reflect(context, arg) { 97 | const buf = context.read(arg).bytes() 98 | return context.store(buf) 99 | } 100 | } 101 | } 102 | }) 103 | const startup = new Bench({ name: 'reflect', time: 100 }) 104 | 105 | const buffer = new Uint8Array(1 << 20) 106 | 107 | startup 108 | .add('write reflect 1KiB (foreground; WebAssembly.Module)', async () => { 109 | await plugin.call('reflect', buffer.slice(0, 1024)) 110 | }) 111 | .add('write reflect 1KiB (background; WebAssembly.Module)', async () => { 112 | await backgroundPlugin.call('reflect', buffer.slice(0, 1024)) 113 | }) 114 | .add('write reflect 1MiB (foreground; WebAssembly.Module)', async () => { 115 | await plugin.call('reflect', buffer.slice(0, 1 << 20)) 116 | }) 117 | .add('write reflect 1MiB (background; WebAssembly.Module)', async () => { 118 | await backgroundPlugin.call('reflect', buffer.slice(0, 1 << 20)) 119 | }) 120 | 121 | await startup.run() 122 | await plugin.close() 123 | await backgroundPlugin.close() 124 | console.log(startup.name) 125 | console.table(startup.table()) 126 | } 127 | -------------------------------------------------------------------------------- /benches/node/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "node", 9 | "version": "1.0.0", 10 | "license": "ISC", 11 | "dependencies": { 12 | "@extism/extism": "file:../../dist/extism-extism-0.0.0-replaced-by-ci.tgz", 13 | "tinybench": "^3.0.4" 14 | } 15 | }, 16 | "node_modules/@extism/extism": { 17 | "version": "0.0.0-replaced-by-ci", 18 | "resolved": "file:../../dist/extism-extism-0.0.0-replaced-by-ci.tgz", 19 | "integrity": "sha512-Sabzy2jWa7zjgTP+6Gx/Kd0KDBVpjc43nQ6jt+BR2sSI7JVHIAmtDjd4G5ZdIW+4Bvnc6algqAgd4SegGFH6Tg==", 20 | "license": "BSD-3-Clause" 21 | }, 22 | "node_modules/tinybench": { 23 | "version": "3.0.4", 24 | "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-3.0.4.tgz", 25 | "integrity": "sha512-JMCuHaSJh6i1/8RMgZiRhA2KY/SiwnCxxGmoRz7onx69vDlh9YkbBFoi37WOssH+EccktzXYacTUtmIfdSqFTw==", 26 | "engines": { 27 | "node": ">=18.0.0" 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /benches/node/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node", 3 | "version": "1.0.0", 4 | "description": "", 5 | "type": "module", 6 | "main": "index.js", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "@extism/extism": "file:../../dist/extism-extism-0.0.0-replaced-by-ci.tgz", 15 | "tinybench": "^3.0.4" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/deno.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env deno run -A 2 | import createPlugin from '../src/mod.ts'; 3 | 4 | const filename = Deno.args[0] || 'wasm/hello.wasm'; 5 | const funcname = Deno.args[1] || 'run_test'; 6 | const input = Deno.args[2] || 'this is a test'; 7 | 8 | const plugin = await createPlugin(filename, { 9 | useWasi: true, 10 | logLevel: 'trace', 11 | logger: console, 12 | config: { 13 | thing: 'testing', 14 | }, 15 | }); 16 | 17 | console.log('calling', { filename, funcname, input }); 18 | const res = await plugin.call(funcname, new TextEncoder().encode(input)); 19 | console.log(res); 20 | // const s = new TextDecoder().decode(res.buffer); 21 | // console.log(s); 22 | 23 | await plugin.close(); 24 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | WASM Plugin in Browser 7 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /examples/node.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node --no-warnings 2 | /* eslint-disable @typescript-eslint/no-var-requires */ 3 | const createPlugin = require('../dist/cjs').default; 4 | const { argv } = require('process'); 5 | 6 | async function main() { 7 | const filename = argv[2] || 'wasm/hello.wasm'; 8 | const funcname = argv[3] || 'run_test'; 9 | const input = argv[4] || 'this is a test'; 10 | 11 | const plugin = await createPlugin(filename, { 12 | useWasi: true, 13 | logger: console, 14 | config: { thing: 'testing' }, 15 | allowedHosts: ['*.typicode.com'], 16 | runInWorker: true, 17 | logLevel: 'trace', 18 | }); 19 | 20 | console.log('calling', { filename, funcname, input }); 21 | const res = await plugin.call(funcname, new TextEncoder().encode(input)); 22 | console.log(res); 23 | // const s = new TextDecoder().decode(res.buffer); 24 | // console.log(s); 25 | 26 | await plugin.close(); 27 | } 28 | 29 | main(); 30 | -------------------------------------------------------------------------------- /jsr.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extism/extism", 3 | "version": "0.0.0-replaced-by-ci", 4 | "exports": "./src/mod.ts", 5 | "publish": { 6 | "include": [ 7 | "./src/**/*.ts", 8 | "LICENSE", 9 | "README.md", 10 | "types", 11 | "tsconfig.json" 12 | ], 13 | "exclude": [ 14 | "./src/mod.test.ts" 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | export PATH := env_var("PATH") + ":" + justfile_directory() + "/node_modules/.bin" 2 | 3 | set export := true 4 | 5 | _help: 6 | @just --list 7 | 8 | prepare: 9 | #!/bin/bash 10 | set -eou pipefail 11 | 12 | if ! &>/dev/null which deno; then 13 | >&2 echo 'Deno not found. Please install it using the steps described here: https://docs.deno.com/runtime/manual/getting_started/installation' 14 | exit 1 15 | fi 16 | 17 | if ! &>/dev/null which node; then 18 | >&2 echo 'Node not found. Please install the appropriate LTS package from here: https://nodejs.org/' 19 | exit 1 20 | fi 21 | 22 | if ! &>/dev/null which jq; then 23 | >&2 echo 'jq not found. Please install jq (https://jqlang.github.io/jq/) using your favorite package manager.' 24 | exit 1 25 | fi 26 | 27 | if [ ! -e node_modules ]; then 28 | npm ci 29 | fi 30 | 31 | playwright install --with-deps 32 | 33 | _build out args='[]': prepare 34 | #!/bin/bash 35 | set -eou pipefail 36 | 37 | if [ -e dist/{{ out }} ]; then 38 | rm -rf dist/{{ out }} 39 | fi 40 | 41 | node < (acc, xs) => [...(acc[key] ?? []), ...(xs[key] ?? [])] 53 | const combValue = key => (acc, xs) => ({...(acc[key] ?? {}), ...(xs[key] ?? {})}) 54 | const lastValue = key => (acc, xs) => xs[key] ?? acc[key] 55 | 56 | const combine = { 57 | entryPoints: accValue('entryPoints'), 58 | external: accValue('external'), 59 | alias: combValue('alias'), 60 | polyfills: combValue('polyfills'), 61 | define: combValue('define'), 62 | sourcemap: lastValue('sourcemap'), 63 | } 64 | 65 | const resolved = args.reduce((acc, xs) => { 66 | for (var key in xs) { 67 | const combinator = combine[key] || lastValue(key); 68 | acc[key] = combinator(acc, xs); 69 | } 70 | return acc; 71 | }, {}) 72 | 73 | const { polyfills, ...config } = resolved; 74 | 75 | config.plugins = [{ 76 | name: 'resolve', 77 | setup (build) { 78 | const items = Object.keys(polyfills).map(xs => xs.replace(/\./g, '\\.').replace(/\//g, '\\/')); 79 | build.onResolve({ namespace: 'file', filter: /^\..*\.ts\$/g }, async args => { 80 | if (!args.path.startsWith('.')) { 81 | return { path: args.path, external: true } 82 | } 83 | 84 | const resolved = path.resolve(args.resolveDir, args.path) 85 | const replaced = resolved.replace(process.cwd(), '.').replaceAll(path.sep, path.posix.sep) 86 | 87 | if (!(replaced in polyfills)) { 88 | return { path: path.resolve(args.resolveDir, args.path) } 89 | } 90 | 91 | const result = polyfills[replaced] 92 | if (result[0] === '.') { 93 | return { path: path.resolve(result) } 94 | } 95 | 96 | return { path: result, external: true } 97 | }) 98 | } 99 | }]; 100 | 101 | 102 | if (config.platform === 'browser' && config.outdir.startsWith('dist/tests/')) { 103 | config.plugins = [].concat(config.plugins || [], [ 104 | require('esbuild-node-builtin').nodeBuiltin() 105 | ]); 106 | } 107 | 108 | build({ 109 | ...config 110 | }); 111 | EOF 112 | 113 | # bsd sed vs gnu sed: the former "-i" REQUIRES an argument, the latter 114 | # REQUIRES not having an argument 115 | find "dist/{{ out }}" -name '*.js' | if [ $(uname) == 'Darwin' ]; then 116 | xargs -I{} sed -i '' -e '/^((ex|im)port|} from)/s/.ts"/.js"/g' '{}' 117 | else 118 | xargs -I{} sed -i -e '/^((ex|im)port|} from)/s/.ts"/.js"/g' '{}' 119 | fi 120 | 121 | # build types (TODO: switch module target based on incoming args) 122 | tsc --emitDeclarationOnly --project ./tsconfig.json --declaration --outDir dist/{{ out }} 123 | 124 | build_worker out args='[]': 125 | #!/bin/bash 126 | config="$(<<<'{{ args }}' jq -cM ' 127 | [{ 128 | "sourcemap": false, 129 | "entryPoints": ["src/worker.ts"], 130 | "bundle": true, 131 | "minify": true, 132 | "format": "esm" 133 | }] + . 134 | ')" 135 | just _build {{ out }} "$config" 136 | 137 | if [ $(uname) == 'Darwin' ]; then 138 | flag="-b" 139 | else 140 | flag="-w" 141 | fi 142 | 143 | echo "export const WORKER_URL = new URL($( 144 | dist/{{ out }}/worker-url.ts 147 | 148 | build_worker_node out='worker/node' args='[]': 149 | #!/bin/bash 150 | config="$(<<<'{{ args }}' jq -cM ' 151 | [{ 152 | "platform": "node", 153 | "polyfills": { 154 | "./src/polyfills/deno-capabilities.ts": "./src/polyfills/node-capabilities.ts", 155 | "./src/polyfills/deno-minimatch.ts": "./src/polyfills/node-minimatch.ts", 156 | "./src/polyfills/node-fs.ts": "node:fs/promises", 157 | "./src/polyfills/deno-wasi.ts": "./src/polyfills/node-wasi.ts", 158 | } 159 | }] + . 160 | ')" 161 | just build_worker {{ out }} "$config" 162 | 163 | build_worker_browser out='worker/browser' args='[]': 164 | #!/bin/bash 165 | config="$(<<<'{{ args }}' jq -cM ' 166 | [{ 167 | "sourcemap": true, 168 | "format": "esm", 169 | "alias": { 170 | "node:worker_threads": "./src/polyfills/worker-node-worker_threads.ts" 171 | }, 172 | "polyfills": { 173 | "./src/polyfills/deno-capabilities.ts": "./src/polyfills/browser-capabilities.ts", 174 | "./src/polyfills/deno-minimatch.ts": "./src/polyfills/node-minimatch.ts", 175 | "./src/polyfills/node-fs.ts": "./src/polyfills/browser-fs.ts", 176 | "./src/polyfills/deno-wasi.ts": "./src/polyfills/browser-wasi.ts", 177 | } 178 | }] + . 179 | ')" 180 | just build_worker {{ out }} "$config" 181 | 182 | build_node_cjs out='cjs' args='[]': 183 | #!/bin/bash 184 | config="$(<<<'{{ args }}' jq -cM ' 185 | [{ 186 | "sourcemap": true, 187 | "entryPoints": ["src/mod.ts"], 188 | "platform": "node", 189 | "minify": false, 190 | "polyfills": { 191 | "./src/polyfills/deno-capabilities.ts": "./src/polyfills/node-capabilities.ts", 192 | "./src/polyfills/deno-minimatch.ts": "./src/polyfills/node-minimatch.ts", 193 | "./src/worker-url.ts": "./dist/worker/node/worker-url.ts", 194 | "./src/polyfills/node-fs.ts": "node:fs/promises", 195 | "./src/polyfills/deno-wasi.ts": "./src/polyfills/node-wasi.ts", 196 | }, 197 | "define": { 198 | "import.meta.url": "__filename" 199 | } 200 | }] + . 201 | ')" 202 | just _build {{ out }} "$config" 203 | echo '{"type":"commonjs"}' > dist/{{ out }}/package.json 204 | cat > dist/{{ out }}/index.js < dist/{{ out }}/index.d.ts < dist/{{ out }}/package.json 233 | 234 | build_bun out='bun' args='[]': 235 | #!/bin/bash 236 | config="$(<<<'{{ args }}' jq -cM ' 237 | [{ 238 | "sourcemap": true, 239 | "entryPoints": ["src/mod.ts", "src/worker.ts"], 240 | "platform": "node", 241 | "format": "esm", 242 | "minify": false, 243 | "polyfills": { 244 | "./src/worker-url.ts": "./src/polyfills/bun-worker-url.ts", 245 | "./src/polyfills/response-to-module.ts": "./src/polyfills/bun-response-to-module.ts", 246 | "./src/polyfills/deno-minimatch.ts": "./src/polyfills/node-minimatch.ts", 247 | "./src/polyfills/deno-capabilities.ts": "./src/polyfills/bun-capabilities.ts", 248 | "./src/polyfills/node-fs.ts": "node:fs/promises", 249 | "./src/polyfills/deno-wasi.ts": "./src/polyfills/node-wasi.ts", 250 | } 251 | }] + . 252 | ')" 253 | just _build {{ out }} "$config" 254 | echo '{"type":"module"}' > dist/{{ out }}/package.json 255 | 256 | build_browser out='browser' args='[]': 257 | #!/bin/bash 258 | config="$(<<<'{{ args }}' jq -cM ' 259 | [{ 260 | "sourcemap": true, 261 | "entryPoints": ["src/mod.ts"], 262 | "platform": "browser", 263 | "define": {"global": "globalThis"}, 264 | "format": "esm", 265 | "alias": { 266 | "node:worker_threads": "./src/polyfills/host-node-worker_threads.ts" 267 | }, 268 | "polyfills": { 269 | "./src/polyfills/deno-capabilities.ts": "./src/polyfills/browser-capabilities.ts", 270 | "./src/polyfills/deno-minimatch.ts": "./src/polyfills/node-minimatch.ts", 271 | "./src/polyfills/node-fs.ts": "./src/polyfills/browser-fs.ts", 272 | "./src/worker-url.ts": "./dist/worker/browser/worker-url.ts", 273 | "./src/polyfills/deno-wasi.ts": "./src/polyfills/browser-wasi.ts", 274 | } 275 | }] + . 276 | ')" 277 | just _build {{ out }} "$config" 278 | echo '{"type":"module"}' > dist/{{ out }}/package.json 279 | 280 | _build_node_tests: 281 | just build_node_cjs 'tests/cjs' '[{"minify": false, "entryPoints":["src/mod.test.ts"]}]' 282 | just build_node_esm 'tests/esm' '[{"minify": false, "entryPoints":["src/mod.test.ts"]}]' 283 | 284 | _build_bun_tests: 285 | just build_bun 'tests/bun' '[{"minify": false, "entryPoints":["src/mod.test.ts"], "alias": {"node:test": "tape"}}]' 286 | 287 | _build_browser_tests out='tests/browser' args='[]': 288 | #!/bin/bash 289 | config="$(<<<'{{ args }}' jq -cM ' 290 | [{ 291 | "entryPoints": ["src/mod.test.ts"], 292 | "alias": { 293 | "node:test": "tape" 294 | }, 295 | "minify": false 296 | }] + . 297 | ')" 298 | just build_browser {{ out }} "$config" 299 | echo '{"type":"module"}' > dist/{{ out }}/package.json 300 | echo '' > dist/{{ out }}/index.html 301 | 302 | build: prepare build_worker_node build_worker_browser build_browser build_node_esm build_node_cjs build_bun _build_browser_tests _build_node_tests _build_bun_tests 303 | npm pack --pack-destination dist/ 304 | 305 | _test filter='.*': 306 | #!/bin/bash 307 | set -eou pipefail 308 | just serve 8124 false & 309 | cleanup() { 310 | &>/dev/null curl http://localhost:8124/quit 311 | } 312 | trap cleanup EXIT 313 | trap cleanup ERR 314 | 315 | case "$(uname -s)" in 316 | [dD]arwin) 317 | os=macos 318 | ;; 319 | [lL]inux*) 320 | os=linux 321 | ;; 322 | *) 323 | os=windows 324 | ;; 325 | esac 326 | if [ "$os" = "windows" ]; then 327 | browsers=chromium 328 | else 329 | browsers=all 330 | fi 331 | 332 | sleep 0.1 333 | if [[ "deno" =~ {{ filter }} ]]; then deno test -A src/mod.test.ts; fi 334 | if [[ "node-cjs" =~ {{ filter }} ]]; then node --no-warnings --test --experimental-global-webcrypto dist/tests/cjs/*.test.js; fi 335 | if [[ "node-esm" =~ {{ filter }} ]]; then node --no-warnings --test --experimental-global-webcrypto dist/tests/esm/*.test.js; fi 336 | if [[ "bun" =~ {{ filter }} ]]; then if &>/dev/null which bun; then bun run dist/tests/bun/*.test.js; fi; fi 337 | if [[ "browsers" =~ {{ filter }} ]]; then playwright test --browser $browsers tests/playwright.test.js --trace retain-on-failure; fi 338 | 339 | test: build && _test test-artifacts 340 | 341 | bake filter='.*': 342 | while just _test '{{ filter }}'; do true; done 343 | 344 | test-artifacts: 345 | #!/bin/bash 346 | set -eou pipefail 347 | rm -rf tests/artifacts 348 | mkdir -p tests/artifacts 349 | cd tests/artifacts 350 | npm init --yes 351 | npm i ../../dist/extism*.tgz 352 | node --no-warnings < { 369 | console.error(err) 370 | process.exit(1) 371 | }) 372 | EOF 373 | 374 | cat >./index.js </dev/null which bun; then bun run index.js; fi 391 | 392 | lint *args: 393 | eslint src tests examples $args 394 | 395 | format: 396 | prettier --write src/*.ts src/**/*.ts examples/* 397 | 398 | docs: 399 | #!/bin/bash 400 | typedoc src/mod.ts 401 | 402 | serve-docs: docs 403 | python3 -m http.server 8000 -d docs/ 404 | 405 | watch-docs: prepare 406 | watchexec -r -w types -w src -w README.md just serve-docs 407 | 408 | bench-deno: prepare 409 | (cd benches/deno; deno bench -A --no-check main.ts) 410 | 411 | bench-node: build 412 | (cd benches/node; npm ci; node --no-warnings main.js) 413 | 414 | bench-bun: build 415 | (cd benches/node; npm ci; bun main.js) 416 | 417 | bench: bench-deno bench-node bench-bun 418 | 419 | serve port='8124' logs='true': 420 | #!/usr/bin/env node 421 | 422 | const http = require('http') 423 | const fs = require('fs/promises'); 424 | const path = require('path'); 425 | const server = http.createServer().listen({{ port }}, console.log); 426 | server.on('request', async (req, res) => { 427 | let statusCode = 200 428 | const headers = { 429 | 'Access-Control-Allow-Origin': '*', 430 | 'Cross-Origin-Embedder-Policy': 'require-corp', 431 | 'Cross-Origin-Opener-Policy': 'same-origin' 432 | } 433 | 434 | const url = new URL(req.url, 'http://localhost:{{port}}'); 435 | 436 | if (url.pathname === '/quit') { 437 | server.close() 438 | } 439 | 440 | let body = await fs.readFile(url.pathname.slice(1)).catch(err => { 441 | if (err.code === 'EISDIR') { 442 | url.pathname = path.join(url.pathname, 'index.html') 443 | return fs.readFile(url.pathname.slice(1)) 444 | } 445 | return null 446 | }).catch(() => null); 447 | 448 | if (!body) { 449 | headers['content-type'] = 'text/html' 450 | statusCode = 404 451 | body = 'not here sorry' 452 | } else switch (path.extname(url.pathname)) { 453 | case '.html': headers['content-type'] = 'text/html'; break 454 | case '.wasm': headers['content-type'] = 'application/wasm'; break 455 | case '.json': headers['content-type'] = 'application/json'; break 456 | case '.js': headers['content-type'] = 'text/javascript'; break 457 | } 458 | 459 | if ({{ logs }}) { 460 | console.log(statusCode, url.pathname, body.length) 461 | } 462 | 463 | headers['content-length'] = body.length 464 | res.writeHead(statusCode, headers); 465 | res.end(body); 466 | }); 467 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extism/extism", 3 | "version": "0.0.0-replaced-by-ci", 4 | "description": "Extism runtime for JavaScript", 5 | "scripts": { 6 | "build": "just build", 7 | "format": "just format", 8 | "test": "just test", 9 | "serve": "just serve", 10 | "lint": "just lint" 11 | }, 12 | "private": false, 13 | "publishConfig": { 14 | "access": "public" 15 | }, 16 | "files": [ 17 | "dist/browser/*", 18 | "dist/bun/*", 19 | "dist/cjs/*", 20 | "dist/esm/*" 21 | ], 22 | "module": "dist/esm/mod.js", 23 | "main": "dist/cjs/index.js", 24 | "typings": "./dist/cjs/mod.d.ts", 25 | "browser": "./dist/browser/mod.js", 26 | "exports": { 27 | "bun": "./dist/bun/mod.js", 28 | "node": { 29 | "import": { 30 | "types": "./dist/esm/mod.d.ts", 31 | "default": "./dist/esm/mod.js" 32 | }, 33 | "require": { 34 | "types": "./dist/cjs/mod.d.ts", 35 | "default": "./dist/cjs/index.js" 36 | } 37 | }, 38 | "default": { 39 | "types": "./dist/browser/mod.d.ts", 40 | "default": "./dist/browser/mod.js" 41 | } 42 | }, 43 | "author": "The Extism Authors ", 44 | "license": "BSD-3-Clause", 45 | "devDependencies": { 46 | "@bjorn3/browser_wasi_shim": "^0.2.17", 47 | "@playwright/test": "^1.49.1", 48 | "@types/node": "^20.8.7", 49 | "@typescript-eslint/eslint-plugin": "^6.8.0", 50 | "@typescript-eslint/parser": "^6.8.0", 51 | "esbuild": "^0.15.13", 52 | "esbuild-node-builtin": "^0.1.1", 53 | "eslint": "^8.51.0", 54 | "minimatch": "^9.0.3", 55 | "playwright": "^1.49.1", 56 | "prettier": "^2.7.1", 57 | "tape": "^5.7.1", 58 | "typedoc": "^0.25.3", 59 | "typedoc-github-wiki-theme": "^1.1.0", 60 | "typedoc-plugin-markdown": "^3.17.1", 61 | "typescript": "^5.2.2" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /playwright.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from '@playwright/test'; 2 | 3 | const config = defineConfig({ 4 | webServer: { 5 | command: 'echo', 6 | url: 'http://127.0.0.1:8124/dist/tests/browser/', 7 | reuseExistingServer: true, 8 | stdout: 'ignore', 9 | stderr: 'pipe', 10 | }, 11 | }) 12 | 13 | config.testDir = 'tests/'; 14 | export default config; 15 | -------------------------------------------------------------------------------- /src/background-plugin.ts: -------------------------------------------------------------------------------- 1 | /*eslint-disable no-empty*/ 2 | import { 3 | CallContext, 4 | ENV, 5 | EXPORT_STATE, 6 | GET_BLOCK, 7 | IMPORT_STATE, 8 | RESET, 9 | SET_HOST_CONTEXT, 10 | STORE, 11 | } from './call-context.ts'; 12 | import { type InternalConfig, PluginOutput, SAB_BASE_OFFSET, SharedArrayBufferSection } from './interfaces.ts'; 13 | import { WORKER_URL } from './worker-url.ts'; 14 | import { Worker } from 'node:worker_threads'; 15 | import { CAPABILITIES } from './polyfills/deno-capabilities.ts'; 16 | import { EXTISM_ENV } from './foreground-plugin.ts'; 17 | import { HttpContext } from './http-context.ts'; 18 | 19 | // Firefox has not yet implemented Atomics.waitAsync, but we can polyfill 20 | // it using a worker as a one-off. 21 | // 22 | // TODO: we should probably give _each_ background plugin its own waiter 23 | // script. 24 | const AtomicsWaitAsync = 25 | Atomics.waitAsync || 26 | (() => { 27 | const src = `onmessage = ev => { 28 | const [b, i, v] = ev.data 29 | const f = new Int32Array(b) 30 | postMessage(Atomics.wait(f, i, v)); 31 | }`; 32 | 33 | const blob = new (Blob as any)([src], { type: 'text/javascript' }); 34 | const url = URL.createObjectURL(blob); 35 | const w = new Worker(url, { execArgv: [] }); 36 | return (ia: any, index, value) => { 37 | const promise = new Promise((resolve) => { 38 | w.once('message', (data) => { 39 | resolve(data); 40 | }); 41 | }); 42 | w.postMessage([ia.buffer, index, value]); 43 | return { async: true, value: promise }; 44 | }; 45 | })(); 46 | 47 | class BackgroundPlugin { 48 | sharedData: SharedArrayBuffer; 49 | sharedDataView: DataView; 50 | hostFlag: Int32Array; 51 | opts: InternalConfig; 52 | worker: Worker; 53 | modules: WebAssembly.Module[]; 54 | names: string[]; 55 | 56 | #context: CallContext; 57 | #request: [(result: any) => void, (result: any) => void] | null = null; 58 | 59 | constructor( 60 | worker: Worker, 61 | sharedData: SharedArrayBuffer, 62 | names: string[], 63 | modules: WebAssembly.Module[], 64 | opts: InternalConfig, 65 | context: CallContext, 66 | ) { 67 | this.sharedData = sharedData; 68 | this.sharedDataView = new DataView(sharedData); 69 | this.hostFlag = new Int32Array(sharedData); 70 | this.opts = opts; 71 | this.names = names; 72 | this.modules = modules; 73 | this.worker = worker; 74 | this.#context = context; 75 | this.hostFlag[0] = SAB_BASE_OFFSET; 76 | 77 | this.worker.on('message', (ev) => this.#handleMessage(ev)); 78 | } 79 | 80 | async #handleTimeout() { 81 | // block new requests from coming in & the current request from settling 82 | const request = this.#request; 83 | this.#request = [() => {}, () => {}]; 84 | 85 | const timedOut = {}; 86 | const failed = {}; 87 | const result = await Promise.race( 88 | [ 89 | timeout(this.opts.timeoutMs, timedOut), 90 | Promise.all([terminateWorker(this.worker), createWorker(this.opts, this.names, this.modules, this.sharedData)]), 91 | ].filter(Boolean), 92 | ).catch(() => failed); 93 | this.#context[RESET](); 94 | 95 | // Oof. The Wasm module failed to even _restart_ in the time allotted. There's 96 | // not much we can do at this point. Release as much memory as we can while 97 | // squatting on `this.#request` so the plugin always looks "active". 98 | if (result === timedOut) { 99 | this.opts.logger.error( 100 | 'EXTISM: Plugin timed out while handling a timeout. Plugin will hang. This Wasm module may have a non-trivial `start` section.', 101 | ); 102 | this.worker = null as unknown as any; 103 | // TODO: expose some way to observe that the plugin is in a "poisoned" state. 104 | return; 105 | } 106 | 107 | // The worker failed to start up for some other reason. This is pretty unlikely to happen! 108 | if (result === failed) { 109 | this.opts.logger.error('EXTISM: Plugin failed to restart during a timeout. Plugin will hang.'); 110 | this.worker = null as unknown as any; 111 | return; 112 | } 113 | const [, worker] = result as any[]; 114 | this.worker = worker as Worker; 115 | 116 | if (request) { 117 | request.pop()!(new Error('EXTISM: call canceled due to timeout')); 118 | } 119 | this.#request = null; 120 | 121 | this.worker.on('message', (ev) => this.#handleMessage(ev)); 122 | } 123 | 124 | async reset(): Promise { 125 | if (this.isActive()) { 126 | return false; 127 | } 128 | 129 | await this.#invoke('reset'); 130 | 131 | this.#context[RESET](); 132 | return true; 133 | } 134 | 135 | isActive() { 136 | return Boolean(this.#request); 137 | } 138 | 139 | async #handleMessage(ev: any) { 140 | switch (ev?.type) { 141 | case 'invoke': 142 | return this.#handleInvoke(ev); 143 | case 'return': 144 | return this.#handleReturn(ev); 145 | case 'log': 146 | return this.#handleLog(ev); 147 | } 148 | } 149 | 150 | #handleLog(ev: any) { 151 | const fn = (this.opts.logger as any)[ev.level as string]; 152 | if (typeof fn !== 'function') { 153 | this.opts.logger?.error(`failed to find loglevel="${ev.level}" on logger: message=${ev.message}`); 154 | } else { 155 | fn.call(this.opts.logger, ev.message); 156 | } 157 | } 158 | 159 | #handleReturn(ev: any) { 160 | const responder = this.#request || null; 161 | if (responder === null) { 162 | // This is fatal, we should probably panic 163 | throw new Error(`received "return" call with no corresponding request`); 164 | } 165 | 166 | this.#request = null; 167 | 168 | const [resolve, reject] = responder; 169 | 170 | if (!Array.isArray(ev.results) || ev.results.length !== 2) { 171 | return reject(new Error(`received malformed "return"`) as any); 172 | } 173 | 174 | const [err, data] = ev.results; 175 | 176 | err ? reject(err) : resolve(data); 177 | } 178 | 179 | // host -> guest() invoke 180 | async #invoke(handler: string, ...args: any[]): Promise { 181 | if (this.#request) { 182 | throw new Error('plugin is not reentrant'); 183 | } 184 | let resolve, reject; 185 | const promise = new Promise((res, rej) => { 186 | resolve = res; 187 | reject = rej; 188 | }); 189 | 190 | this.#request = [resolve as any, reject as any]; 191 | 192 | if (!this.worker) { 193 | throw new Error('worker not initialized'); 194 | } 195 | 196 | const timedOut = {}; 197 | 198 | // Since this creates a new promise, we need to provide 199 | // an empty error handler. 200 | Promise.race([timeout(this.opts.timeoutMs, timedOut), promise].filter(Boolean)).then( 201 | async (v) => { 202 | if (v === timedOut) { 203 | await this.#handleTimeout(); 204 | } 205 | }, 206 | () => {}, 207 | ); 208 | 209 | this.worker.postMessage({ 210 | type: 'invoke', 211 | handler, 212 | args, 213 | }); 214 | 215 | return promise; 216 | } 217 | 218 | async functionExists(funcName: string): Promise { 219 | return await this.#invoke('functionExists', funcName); 220 | } 221 | 222 | // host -> guest invoke() 223 | async call(funcName: string, input?: string | Uint8Array, hostContext?: T): Promise { 224 | const index = this.#context[STORE](input); 225 | this.#context[SET_HOST_CONTEXT](hostContext); 226 | 227 | const [errorIdx, outputIdx] = await this.callBlock(funcName, index); 228 | 229 | const shouldThrow = errorIdx !== null; 230 | const idx = errorIdx ?? outputIdx; 231 | 232 | if (idx === null) { 233 | return null; 234 | } 235 | 236 | const block = this.#context[GET_BLOCK](idx); 237 | 238 | if (block === null) { 239 | return null; 240 | } 241 | 242 | const buf = new PluginOutput( 243 | CAPABILITIES.allowSharedBufferCodec ? block.buffer : new Uint8Array(block.buffer).slice().buffer, 244 | ); 245 | 246 | if (shouldThrow) { 247 | const msg = new TextDecoder().decode(buf); 248 | throw new Error(`Plugin-originated error: ${msg}`); 249 | } 250 | 251 | return buf; 252 | } 253 | 254 | async callBlock(funcName: string, input: number | null): Promise<[number | null, number | null]> { 255 | const exported = this.#context[EXPORT_STATE](); 256 | const { results, state } = await this.#invoke('call', funcName, input, exported); 257 | this.#context[IMPORT_STATE](state, true); 258 | 259 | const [err, data] = results; 260 | if (err) { 261 | throw err; 262 | } 263 | 264 | return data; 265 | } 266 | 267 | async getExports(): Promise { 268 | return await this.#invoke('getExports'); 269 | } 270 | 271 | async getImports(): Promise { 272 | return await this.#invoke('getImports'); 273 | } 274 | 275 | async getInstance(): Promise { 276 | throw new Error('todo'); 277 | } 278 | 279 | async close(): Promise { 280 | if (this.worker) { 281 | await terminateWorker(this.worker); 282 | this.worker = null as any; 283 | } 284 | } 285 | 286 | // guest -> host invoke() 287 | async #handleInvoke(ev: any) { 288 | const writer = new RingBufferWriter(this.sharedData); 289 | const namespace = this.opts.functions[ev.namespace]; 290 | const func = (namespace ?? {})[ev.func]; 291 | // XXX(chrisdickinson): this is cürsëd code. Add a setTimeout because some platforms 292 | // don't spin their event loops if the only pending item is a Promise generated by Atomics.waitAsync. 293 | // 294 | // - https://github.com/nodejs/node/pull/44409 295 | // - https://github.com/denoland/deno/issues/14786 296 | const timer = setInterval(() => {}, 0); 297 | try { 298 | if (!func) { 299 | throw Error(`Plugin error: host function "${ev.namespace}" "${ev.func}" does not exist`); 300 | } 301 | 302 | // Fill the shared array buffer with an expected garbage value to make debugging 303 | // errors more straightforward 304 | new Uint8Array(this.sharedData).subarray(8).fill(0xfe); 305 | 306 | this.#context[IMPORT_STATE](ev.state, true); 307 | 308 | const data = await func(this.#context, ...ev.args); 309 | 310 | const { blocks } = this.#context[EXPORT_STATE](); 311 | 312 | // Writes to the ring buffer MAY return a promise if the write would wrap. 313 | // Writes that fit within the ring buffer return void. 314 | let promise: any; 315 | for (const [buffer, destination] of blocks) { 316 | promise = writer.writeUint8(SharedArrayBufferSection.Block); 317 | if (promise) { 318 | await promise; 319 | } 320 | 321 | promise = writer.writeUint32(destination); 322 | if (promise) { 323 | await promise; 324 | } 325 | 326 | promise = writer.writeUint32(buffer?.byteLength || 0); 327 | if (promise) { 328 | await promise; 329 | } 330 | 331 | if (buffer) { 332 | promise = writer.write(buffer); 333 | if (promise) { 334 | await promise; 335 | } 336 | } 337 | } 338 | 339 | if (typeof data === 'bigint') { 340 | promise = writer.writeUint8(SharedArrayBufferSection.RetI64); 341 | if (promise) { 342 | await promise; 343 | } 344 | 345 | promise = writer.writeUint64(data); 346 | if (promise) { 347 | await promise; 348 | } 349 | } else if (typeof data === 'number') { 350 | promise = writer.writeUint8(SharedArrayBufferSection.RetF64); 351 | if (promise) { 352 | await promise; 353 | } 354 | 355 | promise = writer.writeFloat64(data); 356 | if (promise) { 357 | await promise; 358 | } 359 | } else { 360 | promise = writer.writeUint8(SharedArrayBufferSection.RetVoid); 361 | if (promise) { 362 | await promise; 363 | } 364 | } 365 | 366 | promise = writer.writeUint8(SharedArrayBufferSection.End); 367 | if (promise) { 368 | await promise; 369 | } 370 | await writer.flush(); 371 | } catch (err) { 372 | this.close(); 373 | const [, reject] = this.#request as any[]; 374 | this.#request = null; 375 | return reject(err); 376 | } finally { 377 | clearInterval(timer); 378 | } 379 | } 380 | } 381 | 382 | // Return control to the waiting promise. Anecdotally, this appears to help 383 | // with a race condition in Bun. 384 | const MAX_WAIT = 500; 385 | class RingBufferWriter { 386 | output: SharedArrayBuffer; 387 | scratch: ArrayBuffer; 388 | scratchView: DataView; 389 | outputOffset: number; 390 | flag: Int32Array; 391 | 392 | static SAB_IDX = 0; 393 | 394 | constructor(output: SharedArrayBuffer) { 395 | this.scratch = new ArrayBuffer(8); 396 | this.scratchView = new DataView(this.scratch); 397 | this.output = output; 398 | this.outputOffset = SAB_BASE_OFFSET; 399 | this.flag = new Int32Array(this.output); 400 | this.wait(0); 401 | } 402 | 403 | async wait(lastKnownValue: number) { 404 | // if the flag == SAB_BASE_OFFSET, that means "we have ownership", every other value means "the thread has ownership" 405 | let value = 0; 406 | do { 407 | value = Atomics.load(this.flag, 0); 408 | if (value === lastKnownValue) { 409 | const { value: result, async } = AtomicsWaitAsync(this.flag, 0, lastKnownValue, MAX_WAIT); 410 | if (async) { 411 | if ((await result) === 'timed-out') { 412 | continue; 413 | } 414 | } 415 | } 416 | } while (value === lastKnownValue); 417 | } 418 | 419 | signal() { 420 | const old = Atomics.load(this.flag, 0); 421 | while (Atomics.compareExchange(this.flag, 0, old, this.outputOffset) === old) {} 422 | Atomics.notify(this.flag, 0, 1); 423 | } 424 | 425 | async flush() { 426 | if (this.outputOffset === SAB_BASE_OFFSET) { 427 | // no need to flush -- we haven't written anything! 428 | return; 429 | } 430 | 431 | const workerId = this.outputOffset; 432 | this.signal(); 433 | this.outputOffset = SAB_BASE_OFFSET; 434 | await this.wait(workerId); 435 | } 436 | 437 | async spanningWrite(input: Uint8Array) { 438 | let inputOffset = 0; 439 | let toWrite = this.output.byteLength - this.outputOffset; 440 | let flushedWriteCount = 1 + Math.floor((input.byteLength - toWrite) / (this.output.byteLength - SAB_BASE_OFFSET)); 441 | const finalWrite = (input.byteLength - toWrite) % (this.output.byteLength - SAB_BASE_OFFSET); 442 | 443 | do { 444 | new Uint8Array(this.output).set(input.subarray(inputOffset, inputOffset + toWrite), this.outputOffset); 445 | 446 | // increment the offset so we know we've written _something_ (and can bypass the "did we not write anything" check in `flush()`) 447 | this.outputOffset += toWrite; 448 | inputOffset += toWrite; 449 | await this.flush(); 450 | 451 | // reset toWrite to the maximum available length. (So we may write 29 bytes the first time, but 4096 the next N times. 452 | toWrite = this.output.byteLength - SAB_BASE_OFFSET; 453 | --flushedWriteCount; 454 | } while (flushedWriteCount != 0); 455 | 456 | if (finalWrite) { 457 | this.write(input.subarray(inputOffset, inputOffset + finalWrite)); 458 | } 459 | } 460 | 461 | write(bytes: ArrayBufferLike): void | Promise { 462 | if (bytes.byteLength + this.outputOffset < this.output.byteLength) { 463 | new Uint8Array(this.output).set(new Uint8Array(bytes), this.outputOffset); 464 | this.outputOffset += bytes.byteLength; 465 | return; 466 | } 467 | 468 | return this.spanningWrite(new Uint8Array(bytes)); 469 | } 470 | 471 | writeUint8(value: number): void | Promise { 472 | this.scratchView.setUint8(0, value); 473 | return this.write(this.scratch.slice(0, 1)); 474 | } 475 | 476 | writeUint32(value: number): void | Promise { 477 | this.scratchView.setUint32(0, value, true); 478 | return this.write(this.scratch.slice(0, 4)); 479 | } 480 | 481 | writeUint64(value: bigint): void | Promise { 482 | this.scratchView.setBigUint64(0, value, true); 483 | return this.write(this.scratch.slice(0, 8)); 484 | } 485 | 486 | writeFloat64(value: number): void | Promise { 487 | this.scratchView.setFloat64(0, value, true); 488 | return this.write(this.scratch.slice(0, 8)); 489 | } 490 | } 491 | 492 | export async function createBackgroundPlugin( 493 | opts: InternalConfig, 494 | names: string[], 495 | modules: WebAssembly.Module[], 496 | ): Promise { 497 | const context = new CallContext(SharedArrayBuffer, opts.logger, opts.logLevel, opts.config, opts.memory); 498 | const httpContext = new HttpContext(opts.fetch, opts.allowedHosts, opts.memory, opts.allowHttpResponseHeaders); 499 | httpContext.contribute(opts.functions); 500 | 501 | // NB(chrisdickinson): In order for the host and guest to have the same "view" of the 502 | // variables, forward the guest's var_get/var_set methods up to the host CallContext. 503 | // If they're overridden, however, preserve the user-provided values. 504 | opts.functions[EXTISM_ENV] ??= {}; 505 | opts.functions[EXTISM_ENV].var_get ??= (_: CallContext, key: bigint) => { 506 | return context[ENV].var_get(key); 507 | }; 508 | opts.functions[EXTISM_ENV].var_set ??= (_: CallContext, key: bigint, val: bigint) => { 509 | return context[ENV].var_set(key, val); 510 | }; 511 | 512 | // NB(chrisdickinson): We *have* to create the SharedArrayBuffer in 513 | // the parent context because -- for whatever reason! -- chromium does 514 | // not allow the creation of shared buffers in worker contexts, but firefox 515 | // and webkit do. 516 | const sharedData = new (SharedArrayBuffer as any)(opts.sharedArrayBufferSize); 517 | new Uint8Array(sharedData).subarray(8).fill(0xfe); 518 | 519 | const timedOut = {}; 520 | 521 | // If we fail to initialize the worker (because Wasm hangs), we need access to 522 | // the partially-initialized worker so that we can terminate its thread. 523 | let earlyWorker: Worker; 524 | const onworker = (w: Worker) => { 525 | earlyWorker = w; 526 | }; 527 | 528 | const worker = await Promise.race( 529 | [timeout(opts.timeoutMs, timedOut), createWorker(opts, names, modules, sharedData, onworker)].filter(Boolean), 530 | ); 531 | 532 | if (worker === timedOut) { 533 | await terminateWorker(earlyWorker!); 534 | throw new Error('EXTISM: timed out while waiting for plugin to instantiate'); 535 | } 536 | return new BackgroundPlugin(worker as Worker, sharedData, names, modules, opts, context); 537 | } 538 | 539 | async function createWorker( 540 | opts: InternalConfig, 541 | names: string[], 542 | modules: WebAssembly.Module[], 543 | sharedData: SharedArrayBuffer, 544 | onworker: (_w: Worker) => void = (_w: Worker) => {}, 545 | ): Promise { 546 | const worker = new Worker(WORKER_URL, opts.nodeWorkerArgs); 547 | onworker(worker); 548 | 549 | await new Promise((resolve, reject) => { 550 | worker.on('message', function handler(ev) { 551 | if (ev?.type !== 'initialized') { 552 | reject(new Error(`received unexpected message (type=${ev?.type})`)); 553 | } 554 | 555 | worker.removeListener('message', handler); 556 | resolve(null); 557 | }); 558 | }); 559 | 560 | const onready = new Promise((resolve, reject) => { 561 | worker.on('message', function handler(ev) { 562 | if (ev?.type !== 'ready') { 563 | reject(new Error(`received unexpected message (type=${ev?.type})`)); 564 | } 565 | 566 | worker.removeListener('message', handler); 567 | resolve(null); 568 | }); 569 | }); 570 | 571 | const { fetch: _, logger: __, ...rest } = opts; 572 | const message = { 573 | ...rest, 574 | type: 'init', 575 | functions: Object.fromEntries(Object.entries(opts.functions || {}).map(([k, v]) => [k, Object.keys(v)])), 576 | names, 577 | modules, 578 | sharedData, 579 | }; 580 | 581 | worker.postMessage(message); 582 | await onready; 583 | 584 | return worker; 585 | } 586 | 587 | function timeout(ms: number | null, sentinel: any) { 588 | return ms === null ? null : new Promise((resolve) => setTimeout(() => resolve(sentinel), ms)); 589 | } 590 | 591 | async function terminateWorker(w: Worker) { 592 | if (typeof (globalThis as any).Bun !== 'undefined') { 593 | const timer = setTimeout(() => {}, 10); 594 | await w.terminate(); 595 | clearTimeout(timer); 596 | } else { 597 | await w.terminate(); 598 | } 599 | } 600 | -------------------------------------------------------------------------------- /src/call-context.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LogLevel, 3 | MemoryOptions, 4 | type PluginConfig, 5 | PluginOutput, 6 | LogLevelPriority, 7 | priorityToLogLevel, 8 | logLevelToPriority, 9 | } from './interfaces.ts'; 10 | import { CAPABILITIES } from './polyfills/deno-capabilities.ts'; 11 | 12 | export const BEGIN = Symbol('begin'); 13 | export const END = Symbol('end'); 14 | export const ENV = Symbol('env'); 15 | export const SET_HOST_CONTEXT = Symbol('set-host-context'); 16 | export const GET_BLOCK = Symbol('get-block'); 17 | export const IMPORT_STATE = Symbol('import-state'); 18 | export const EXPORT_STATE = Symbol('export-state'); 19 | export const STORE = Symbol('store-value'); 20 | export const RESET = Symbol('reset'); 21 | 22 | export class Block { 23 | buffer: ArrayBufferLike; 24 | view: DataView; 25 | local: boolean; 26 | 27 | get byteLength(): number { 28 | return this.buffer.byteLength; 29 | } 30 | 31 | constructor(arrayBuffer: ArrayBufferLike, local: boolean) { 32 | this.buffer = arrayBuffer; 33 | this.view = new DataView(this.buffer); 34 | this.local = local; 35 | } 36 | 37 | static indexToAddress(idx: bigint | number): bigint { 38 | return BigInt(idx) << 48n; 39 | } 40 | 41 | static addressToIndex(addr: bigint | number): number { 42 | return Number(BigInt(addr) >> 48n); 43 | } 44 | 45 | static maskAddress(addr: bigint | number): number { 46 | return Number(BigInt(addr) & ((1n << 48n) - 1n)); 47 | } 48 | } 49 | 50 | export type CallState = { 51 | blocks: [ArrayBufferLike | null, number][]; 52 | stack: [number | null, number | null, number | null][]; 53 | }; 54 | 55 | export class CallContext { 56 | #stack: [number | null, number | null, number | null][]; 57 | /** @hidden */ 58 | #blocks: (Block | null)[] = []; 59 | #logger: Console; 60 | #logLevel: LogLevelPriority; 61 | #decoder: TextDecoder; 62 | #encoder: TextEncoder; 63 | #arrayBufferType: { new (size: number): ArrayBufferLike }; 64 | #config: PluginConfig; 65 | #vars: Map = new Map(); 66 | #varsSize: number; 67 | #memoryOptions: MemoryOptions; 68 | #hostContext: any; 69 | 70 | /** @hidden */ 71 | constructor( 72 | type: { new (size: number): ArrayBufferLike }, 73 | logger: Console, 74 | logLevel: LogLevelPriority, 75 | config: PluginConfig, 76 | memoryOptions: MemoryOptions, 77 | ) { 78 | this.#arrayBufferType = type; 79 | this.#logger = logger; 80 | this.#logLevel = logLevel ?? 0x7fff_ffff; 81 | this.#decoder = new TextDecoder(); 82 | this.#encoder = new TextEncoder(); 83 | this.#memoryOptions = memoryOptions; 84 | 85 | this.#varsSize = 0; 86 | this.#stack = []; 87 | 88 | // reserve the null page. 89 | this.alloc(1); 90 | 91 | this.#config = config; 92 | } 93 | 94 | hostContext(): T { 95 | return this.#hostContext as T; 96 | } 97 | 98 | /** 99 | * Allocate a chunk of host memory visible to plugins via other extism host functions. 100 | * Returns the start address of the block. 101 | */ 102 | alloc(size: bigint | number): bigint { 103 | const block = new Block(new this.#arrayBufferType(Number(size)), true); 104 | const index = this.#blocks.length; 105 | this.#blocks.push(block); 106 | 107 | if (this.#memoryOptions.maxPages) { 108 | const pageSize = 64 * 1024; 109 | const totalBytes = this.#blocks.reduce((acc, block) => acc + (block?.buffer.byteLength ?? 0), 0); 110 | const totalPages = Math.ceil(totalBytes / pageSize); 111 | 112 | if (totalPages > this.#memoryOptions.maxPages) { 113 | this.#logger.error( 114 | `memory limit exceeded: ${totalPages} pages requested, ${this.#memoryOptions.maxPages} allowed`, 115 | ); 116 | return 0n; 117 | } 118 | } 119 | 120 | return Block.indexToAddress(index); 121 | } 122 | 123 | /** 124 | * Read a variable from extism memory by name. 125 | * 126 | * @returns {@link PluginOutput} 127 | */ 128 | getVariable(name: string): PluginOutput | null { 129 | if (!this.#vars.has(name)) { 130 | return null; 131 | } 132 | return new PluginOutput(this.#vars.get(name)!.buffer); 133 | } 134 | 135 | /** 136 | * Set a variable to a given string or byte array value. 137 | */ 138 | setVariable(name: string, value: string | Uint8Array) { 139 | const buffer = typeof value === 'string' ? this.#encoder.encode(value) : value; 140 | 141 | const variable = this.#vars.get(name); 142 | 143 | const newSize = this.#varsSize + buffer.byteLength - (variable?.byteLength || 0); 144 | if (newSize > (this.#memoryOptions?.maxVarBytes || Infinity)) { 145 | throw new Error( 146 | `var memory limit exceeded: ${newSize} bytes requested, ${this.#memoryOptions.maxVarBytes} allowed`, 147 | ); 148 | } 149 | this.#varsSize = newSize; 150 | this.#vars.set(name, buffer); 151 | } 152 | 153 | /** 154 | * Delete a variable if present. 155 | */ 156 | deleteVariable(name: string) { 157 | const variable = this.#vars.get(name); 158 | if (!variable) { 159 | return; 160 | } 161 | this.#vars.delete(name); 162 | this.#varsSize -= variable.byteLength; 163 | } 164 | 165 | /** 166 | * Given an address in extism memory, return a {@link PluginOutput} that represents 167 | * a view of that memory. Returns null if the address is invalid. 168 | * 169 | * @returns bigint 170 | */ 171 | read(addr: bigint | number): PluginOutput | null { 172 | const blockIdx = Block.addressToIndex(addr); 173 | const block = this.#blocks[blockIdx]; 174 | if (!block) { 175 | return null; 176 | } 177 | 178 | const buffer = 179 | !(block.buffer instanceof ArrayBuffer) && !CAPABILITIES.allowSharedBufferCodec 180 | ? new Uint8Array(block.buffer).slice().buffer 181 | : block.buffer; 182 | 183 | return new PluginOutput(buffer); 184 | } 185 | 186 | /** 187 | * Store a string or Uint8Array value in extism memory. 188 | * 189 | * @returns bigint 190 | */ 191 | store(input: string | Uint8Array): bigint { 192 | const idx = this[STORE](input); 193 | if (!idx) { 194 | throw new Error('failed to store output'); 195 | } 196 | return Block.indexToAddress(idx); 197 | } 198 | 199 | length(addr: bigint): bigint { 200 | const blockIdx = Block.addressToIndex(addr); 201 | const block = this.#blocks[blockIdx]; 202 | if (!block) { 203 | return 0n; 204 | } 205 | return BigInt(block.buffer.byteLength); 206 | } 207 | 208 | setError(err: string | Error | null = null) { 209 | const blockIdx = err ? this[STORE](err instanceof Error ? err.message : err) : 0; 210 | if (!blockIdx) { 211 | throw new Error('could not store error value'); 212 | } 213 | 214 | this.#stack[this.#stack.length - 1][2] = blockIdx; 215 | } 216 | 217 | get logLevel(): LogLevel { 218 | return priorityToLogLevel(this.#logLevel); 219 | } 220 | 221 | set logLevel(v: LogLevel) { 222 | this.#logLevel = logLevelToPriority(v); 223 | } 224 | 225 | /** @hidden */ 226 | [ENV]: Record = { 227 | alloc: (n: bigint): bigint => { 228 | return this.alloc(n); 229 | }, 230 | 231 | free: (addr: number | bigint): void => { 232 | this.#blocks[Block.addressToIndex(addr)] = null; 233 | }, 234 | 235 | load_u8: (addr: bigint): number => { 236 | const blockIdx = Block.addressToIndex(addr); 237 | const offset = Block.maskAddress(addr); 238 | const block = this.#blocks[blockIdx]; 239 | return block?.view.getUint8(Number(offset)) as number; 240 | }, 241 | 242 | load_u64: (addr: bigint): bigint => { 243 | const blockIdx = Block.addressToIndex(addr); 244 | const offset = Block.maskAddress(addr); 245 | const block = this.#blocks[blockIdx]; 246 | return block?.view.getBigUint64(Number(offset), true) as bigint; 247 | }, 248 | 249 | store_u8: (addr: bigint, n: number): void => { 250 | const blockIdx = Block.addressToIndex(addr); 251 | const offset = Block.maskAddress(addr); 252 | const block = this.#blocks[blockIdx]; 253 | block?.view.setUint8(Number(offset), Number(n)); 254 | }, 255 | 256 | store_u64: (addr: bigint, n: bigint): void => { 257 | const blockIdx = Block.addressToIndex(addr); 258 | const offset = Block.maskAddress(addr); 259 | const block = this.#blocks[blockIdx]; 260 | block?.view.setBigUint64(Number(offset), n, true); 261 | }, 262 | 263 | input_offset: (): bigint => { 264 | const blockIdx = this.#stack[this.#stack.length - 1][0]; 265 | return Block.indexToAddress(blockIdx || 0); 266 | }, 267 | 268 | input_length: (): bigint => { 269 | return BigInt(this.#input?.byteLength ?? 0); 270 | }, 271 | 272 | input_load_u8: (addr: bigint): number => { 273 | const offset = Block.maskAddress(addr); 274 | return this.#input?.view.getUint8(Number(offset)) as number; 275 | }, 276 | 277 | input_load_u64: (addr: bigint): bigint => { 278 | const offset = Block.maskAddress(addr); 279 | return this.#input?.view.getBigUint64(Number(offset), true) as bigint; 280 | }, 281 | 282 | output_set: (addr: bigint, length: bigint): void => { 283 | const blockIdx = Block.addressToIndex(addr); 284 | const block = this.#blocks[blockIdx]; 285 | if (!block) { 286 | throw new Error(`cannot assign to this block (addr=${addr.toString(16).padStart(16, '0')}; length=${length})`); 287 | } 288 | 289 | if (length > block.buffer.byteLength) { 290 | throw new Error('length longer than target block'); 291 | } 292 | 293 | this.#stack[this.#stack.length - 1][1] = blockIdx; 294 | }, 295 | 296 | error_set: (addr: bigint): void => { 297 | const blockIdx = Block.addressToIndex(addr); 298 | const block = this.#blocks[blockIdx]; 299 | if (!block) { 300 | throw new Error('cannot assign error to this block'); 301 | } 302 | 303 | this.#stack[this.#stack.length - 1][2] = blockIdx; 304 | }, 305 | 306 | error_get: (): bigint => { 307 | const error = this.#stack[this.#stack.length - 1][2]; 308 | if (error) { 309 | return Block.indexToAddress(error); 310 | } 311 | return 0n; 312 | }, 313 | 314 | config_get: (addr: bigint): bigint => { 315 | const item = this.read(addr); 316 | 317 | if (item === null) { 318 | return 0n; 319 | } 320 | 321 | try { 322 | const key = item.string(); 323 | if (key in this.#config) { 324 | return this.store(this.#config[key]); 325 | } 326 | } finally { 327 | this[ENV].free(addr); 328 | } 329 | 330 | return 0n; 331 | }, 332 | 333 | var_get: (addr: bigint): bigint => { 334 | const item = this.read(addr); 335 | 336 | if (item === null) { 337 | return 0n; 338 | } 339 | 340 | try { 341 | const key = item.string(); 342 | 343 | const result = this.getVariable(key); 344 | const stored = result ? this[STORE](result.bytes()) || 0 : 0; 345 | return Block.indexToAddress(stored); 346 | } finally { 347 | this[ENV].free(addr); 348 | } 349 | }, 350 | 351 | var_set: (addr: bigint, valueaddr: bigint): void => { 352 | const item = this.read(addr); 353 | 354 | if (item === null) { 355 | this.#logger.error(`attempted to set variable using invalid key address (addr="${addr.toString(16)}H")`); 356 | return; 357 | } 358 | 359 | const key = item.string(); 360 | 361 | if (valueaddr === 0n) { 362 | this.deleteVariable(key); 363 | return; 364 | } 365 | 366 | const valueBlock = this.#blocks[Block.addressToIndex(valueaddr)]; 367 | if (!valueBlock) { 368 | this.#logger.error( 369 | `attempted to set variable to invalid address (key="${key}"; addr="${valueaddr.toString(16)}H")`, 370 | ); 371 | return; 372 | } 373 | 374 | try { 375 | // Copy the variable value out of the block for TWO reasons: 376 | // 1. Variables outlive blocks -- blocks are reset after each invocation. 377 | // 2. If the block is backed by a SharedArrayBuffer, we can't read text out of it directly (in many browser contexts.) 378 | const copied = new Uint8Array(valueBlock.buffer.byteLength); 379 | copied.set(new Uint8Array(valueBlock.buffer), 0); 380 | this.setVariable(key, copied); 381 | } catch (err: any) { 382 | this.#logger.error(err.message); 383 | this.setError(err); 384 | return; 385 | } 386 | }, 387 | 388 | http_request: (_requestOffset: bigint, _bodyOffset: bigint): bigint => { 389 | this.#logger.error('http_request is not enabled'); 390 | return 0n; 391 | }, 392 | 393 | http_status_code: (): number => { 394 | this.#logger.error('http_status_code is not enabled'); 395 | return 0; 396 | }, 397 | 398 | http_headers: (): bigint => { 399 | this.#logger.error('http_headers is not enabled'); 400 | return 0n; 401 | }, 402 | 403 | length: (addr: bigint): bigint => { 404 | return this.length(addr); 405 | }, 406 | 407 | length_unsafe: (addr: bigint): bigint => { 408 | return this.length(addr); 409 | }, 410 | 411 | log_warn: this.#handleLog.bind(this, logLevelToPriority('warn'), 'warn'), 412 | log_info: this.#handleLog.bind(this, logLevelToPriority('info'), 'info'), 413 | log_debug: this.#handleLog.bind(this, logLevelToPriority('debug'), 'debug'), 414 | log_error: this.#handleLog.bind(this, logLevelToPriority('error'), 'error'), 415 | log_trace: this.#handleLog.bind(this, logLevelToPriority('trace'), 'trace'), 416 | 417 | get_log_level: (): number => { 418 | return isFinite(this.#logLevel) ? this.#logLevel : 0xffff_ffff; 419 | }, 420 | }; 421 | 422 | /** @hidden */ 423 | #handleLog(incomingLevel: LogLevelPriority, level: LogLevel, addr: bigint) { 424 | const blockIdx = Block.addressToIndex(addr); 425 | const block = this.#blocks[blockIdx]; 426 | if (!block) { 427 | this.#logger.error( 428 | `failed to log(${level}): bad block reference in addr 0x${addr.toString(16).padStart(64, '0')}`, 429 | ); 430 | return; 431 | } 432 | try { 433 | if (this.#logLevel <= incomingLevel) { 434 | const text = this.#decoder.decode(block.buffer); 435 | (this.#logger[level as keyof Console] as any)(text); 436 | } 437 | } finally { 438 | this.#blocks[blockIdx] = null; 439 | } 440 | } 441 | 442 | /** @hidden */ 443 | get #input(): Block | null { 444 | const idx = this.#stack[this.#stack.length - 1][0]; 445 | if (idx === null) { 446 | return null; 447 | } 448 | return this.#blocks[idx]; 449 | } 450 | 451 | /** @hidden */ 452 | [RESET]() { 453 | this.#hostContext = null; 454 | 455 | // preserve the null page. 456 | this.#blocks.length = 1; 457 | 458 | // ... but dump the stack items. 459 | this.#stack.length = 0; 460 | } 461 | 462 | /** @hidden */ 463 | [GET_BLOCK](index: number): Block { 464 | const block = this.#blocks[index]; 465 | if (!block) { 466 | throw new Error(`invalid block index: ${index}`); 467 | } 468 | return block; 469 | } 470 | 471 | /** @hidden */ 472 | [IMPORT_STATE](state: CallState, copy: boolean = false) { 473 | // eslint-disable-next-line prefer-const 474 | for (let [buf, idx] of state.blocks) { 475 | if (buf && copy) { 476 | const dst = new Uint8Array(new this.#arrayBufferType(Number(buf.byteLength))); 477 | dst.set(new Uint8Array(buf)); 478 | buf = dst.buffer; 479 | } 480 | this.#blocks[idx] = buf ? new Block(buf, false) : null; 481 | } 482 | this.#stack = state.stack; 483 | } 484 | 485 | /** @hidden */ 486 | [EXPORT_STATE](): CallState { 487 | return { 488 | stack: this.#stack.slice(), 489 | blocks: this.#blocks 490 | .map((block, idx) => { 491 | if (!block) { 492 | return [null, idx]; 493 | } 494 | 495 | if (block.local) { 496 | block.local = false; 497 | return [block.buffer, idx]; 498 | } 499 | return null; 500 | }) 501 | .filter(Boolean) as [ArrayBufferLike, number][], 502 | }; 503 | } 504 | 505 | /** @hidden */ 506 | [STORE](input?: string | Uint8Array): number | null { 507 | if (typeof input === 'string') { 508 | input = this.#encoder.encode(input); 509 | } 510 | 511 | if (!input) { 512 | return null; 513 | } 514 | 515 | if (input instanceof Uint8Array) { 516 | if ( 517 | input.buffer.constructor === this.#arrayBufferType && 518 | input.byteOffset === 0 && 519 | input.byteLength === input.buffer.byteLength 520 | ) { 521 | // no action necessary, wrap it up in a block 522 | const idx = this.#blocks.length; 523 | this.#blocks.push(new Block(input.buffer, true)); 524 | return idx; 525 | } 526 | const idx = Block.addressToIndex(this.alloc(input.length)); 527 | const block = this.#blocks[idx] as Block; 528 | const buf = new Uint8Array(block.buffer); 529 | buf.set(input, 0); 530 | return idx; 531 | } 532 | 533 | return input; 534 | } 535 | 536 | /** @hidden */ 537 | [SET_HOST_CONTEXT](hostContext: any) { 538 | this.#hostContext = hostContext; 539 | } 540 | 541 | /** @hidden */ 542 | [BEGIN](input: number | null) { 543 | this.#stack.push([input, null, null]); 544 | } 545 | 546 | /** @hidden */ 547 | [END](): [number | null, number | null] { 548 | this.#hostContext = null; 549 | const [, outputIdx, errorIdx] = this.#stack.pop() as (number | null)[]; 550 | const outputPosition = errorIdx === null ? 1 : 0; 551 | const idx = errorIdx ?? outputIdx; 552 | const result: [number | null, number | null] = [null, null]; 553 | 554 | if (idx === null) { 555 | return result; 556 | } 557 | 558 | const block = this.#blocks[idx]; 559 | 560 | if (block === null) { 561 | // TODO: this might be an error? we got an output idx but it referred to a freed (or non-existant) block 562 | return result; 563 | } 564 | 565 | result[outputPosition] = idx; 566 | 567 | return result; 568 | } 569 | } 570 | -------------------------------------------------------------------------------- /src/foreground-plugin.ts: -------------------------------------------------------------------------------- 1 | import { BEGIN, CallContext, END, ENV, GET_BLOCK, RESET, SET_HOST_CONTEXT, STORE } from './call-context.ts'; 2 | import { type InternalConfig, InternalWasi, PluginOutput } from './interfaces.ts'; 3 | import { CAPABILITIES } from './polyfills/deno-capabilities.ts'; 4 | import { loadWasi } from './polyfills/deno-wasi.ts'; 5 | import { HttpContext } from './http-context.ts'; 6 | 7 | export const EXTISM_ENV = 'extism:host/env'; 8 | 9 | type InstantiatedModule = [WebAssembly.Module, WebAssembly.Instance]; 10 | 11 | interface SuspendingCtor { 12 | new (fn: CallableFunction): any; 13 | } 14 | 15 | const AsyncFunction = (async () => {}).constructor; 16 | const Suspending: SuspendingCtor | undefined = (WebAssembly as any).Suspending; 17 | const promising: CallableFunction | undefined = (WebAssembly as any).promising; 18 | 19 | export class ForegroundPlugin { 20 | #context: CallContext; 21 | #instancePair: InstantiatedModule; 22 | #active: boolean = false; 23 | #wasi: InternalWasi[]; 24 | #opts: InternalConfig; 25 | #suspendsOnInvoke: boolean; 26 | 27 | constructor( 28 | opts: InternalConfig, 29 | context: CallContext, 30 | instancePair: InstantiatedModule, 31 | wasi: InternalWasi[], 32 | suspendsOnInvoke: boolean, 33 | ) { 34 | this.#context = context; 35 | this.#instancePair = instancePair; 36 | this.#wasi = wasi; 37 | this.#opts = opts; 38 | this.#suspendsOnInvoke = suspendsOnInvoke; 39 | } 40 | 41 | async reset(): Promise { 42 | if (this.isActive()) { 43 | return false; 44 | } 45 | 46 | this.#context[RESET](); 47 | return true; 48 | } 49 | 50 | isActive() { 51 | return this.#active; 52 | } 53 | 54 | async functionExists(funcName: string): Promise { 55 | return typeof this.#instancePair[1].exports[funcName] === 'function'; 56 | } 57 | 58 | async callBlock(funcName: string, input: number | null): Promise<[number | null, number | null]> { 59 | this.#active = true; 60 | const func: CallableFunction | undefined = this.#instancePair[1].exports[funcName] as CallableFunction; 61 | 62 | if (!func) { 63 | throw Error(`Plugin error: function "${funcName}" does not exist`); 64 | } 65 | 66 | if (typeof func !== 'function') { 67 | throw Error(`Plugin error: export "${funcName}" is not a function`); 68 | } 69 | 70 | this.#context[BEGIN](input ?? null); 71 | try { 72 | this.#suspendsOnInvoke ? await (promising as any)(func)() : func(); 73 | return this.#context[END](); 74 | } catch (err) { 75 | this.#context[END](); 76 | throw err; 77 | } finally { 78 | this.#active = false; 79 | } 80 | } 81 | 82 | async call(funcName: string, input?: string | Uint8Array, hostContext?: T): Promise { 83 | this.#context[RESET](); 84 | 85 | const inputIdx = this.#context[STORE](input); 86 | this.#context[SET_HOST_CONTEXT](hostContext); 87 | 88 | const [errorIdx, outputIdx] = await this.callBlock(funcName, inputIdx); 89 | const shouldThrow = errorIdx !== null; 90 | const idx = errorIdx ?? outputIdx; 91 | 92 | if (idx === null) { 93 | return null; 94 | } 95 | 96 | const block = this.#context[GET_BLOCK](idx); 97 | if (!block) { 98 | return null; 99 | } 100 | 101 | const output = new PluginOutput(block.buffer); 102 | if (shouldThrow) { 103 | throw new Error(`Plugin-originated error: ${output.string()}`); 104 | } 105 | return output; 106 | } 107 | 108 | async getExports(): Promise { 109 | return WebAssembly.Module.exports(this.#instancePair[0]) || []; 110 | } 111 | 112 | async getImports(): Promise { 113 | return WebAssembly.Module.imports(this.#instancePair[0]) || []; 114 | } 115 | 116 | async getInstance(): Promise { 117 | return this.#instancePair[1]; 118 | } 119 | 120 | async close(): Promise { 121 | await Promise.all(this.#wasi.map((xs) => xs.close())); 122 | this.#wasi.length = 0; 123 | } 124 | } 125 | 126 | export async function createForegroundPlugin( 127 | opts: InternalConfig, 128 | names: string[], 129 | modules: WebAssembly.Module[], 130 | context: CallContext = new CallContext(ArrayBuffer, opts.logger, opts.logLevel, opts.config, opts.memory), 131 | ): Promise { 132 | const imports: Record> = { 133 | [EXTISM_ENV]: context[ENV], 134 | env: {}, 135 | }; 136 | 137 | let suspendsOnInvoke = false; 138 | for (const namespace in opts.functions) { 139 | imports[namespace] = imports[namespace] || {}; 140 | for (const [name, func] of Object.entries(opts.functions[namespace])) { 141 | const isAsync = func.constructor === AsyncFunction; 142 | suspendsOnInvoke ||= isAsync; 143 | const wrapped = func.bind(null, context); 144 | imports[namespace][name] = isAsync ? new Suspending!(wrapped) : wrapped; 145 | } 146 | } 147 | 148 | if (suspendsOnInvoke && (!Suspending || !promising)) { 149 | throw new TypeError( 150 | 'This platform does not support async function imports on the main thread; consider using `runInWorker`.', 151 | ); 152 | } 153 | 154 | // find the "main" module and try to instantiate it. 155 | const mainIndex = names.indexOf('main'); 156 | if (mainIndex === -1) { 157 | throw new Error('Unreachable: manifests must have at least one "main" module. Enforced by "src/manifest.ts")'); 158 | } 159 | const seen: Map = new Map(); 160 | const wasiList: InternalWasi[] = []; 161 | 162 | const mutableFlags = { suspendsOnInvoke }; 163 | const instance = await instantiateModule( 164 | context, 165 | ['main'], 166 | modules[mainIndex], 167 | imports, 168 | opts, 169 | wasiList, 170 | names, 171 | modules, 172 | seen, 173 | mutableFlags, 174 | ); 175 | 176 | return new ForegroundPlugin(opts, context, [modules[mainIndex], instance], wasiList, mutableFlags.suspendsOnInvoke); 177 | } 178 | 179 | async function instantiateModule( 180 | context: CallContext, 181 | current: string[], 182 | module: WebAssembly.Module, 183 | imports: Record>, 184 | opts: InternalConfig, 185 | wasiList: InternalWasi[], 186 | names: string[], 187 | modules: WebAssembly.Module[], 188 | linked: Map, 189 | mutableFlags: { suspendsOnInvoke: boolean }, 190 | ) { 191 | linked.set(module, null); 192 | 193 | const instantiationImports: Record> = {}; 194 | const requested = WebAssembly.Module.imports(module); 195 | 196 | let wasi = null; 197 | for (const { kind, module, name } of requested) { 198 | const nameIdx = names.indexOf(module); 199 | 200 | if (nameIdx === -1) { 201 | if (module === 'wasi_snapshot_preview1' && wasi === null) { 202 | if (!CAPABILITIES.supportsWasiPreview1) { 203 | throw new Error('WASI is not supported on this platform'); 204 | } 205 | 206 | if (!opts.wasiEnabled) { 207 | throw new Error('WASI is not enabled; see the "useWasi" plugin option'); 208 | } 209 | 210 | if (wasi === null) { 211 | wasi = await loadWasi(opts.allowedPaths, opts.enableWasiOutput); 212 | wasiList.push(wasi); 213 | imports.wasi_snapshot_preview1 = await wasi.importObject(); 214 | } 215 | } 216 | 217 | // lookup from "imports" 218 | if (!Object.hasOwnProperty.call(imports, module)) { 219 | throw new Error( 220 | `from module "${current.join( 221 | '"/"', 222 | )}": cannot resolve import "${module}" "${name}": not provided by host imports nor linked manifest items`, 223 | ); 224 | } 225 | 226 | if (!Object.hasOwnProperty.call(imports[module], name)) { 227 | throw new Error( 228 | `from module "${current.join( 229 | '"/"', 230 | )}": cannot resolve import "${module}" "${name}" ("${module}" is a host module, but does not contain "${name}")`, 231 | ); 232 | } 233 | 234 | // XXX(chrisdickinson): This is a bit of a hack, admittedly. So what's going on here? 235 | // 236 | // JSPI is going on here. Let me explain: at the time of writing, the js-sdk supports 237 | // JSPI by detecting AsyncFunction use in the `functions` parameter. When we detect an 238 | // async function in imports we _must_ mark all exported Wasm functions as "promising" -- 239 | // that is, they might call a host function that suspends the stack. 240 | // 241 | // If we were to mark extism's http_request as async, we would _always_ set exports as 242 | // "promising". This adds unnecessary overhead for folks who aren't using `http_request`. 243 | // Instead, we detect if any of the manifest items *import* `http_request`. If they 244 | // haven't overridden the default CallContext implementation, we provide an HttpContext 245 | // on-demand. 246 | // 247 | // Unfortunately this duplicates a little bit of logic-- in particular, we have to bind 248 | // CallContext to each of the HttpContext contributions (See "REBIND" below.) 249 | // 250 | // Notably, if we're calling this from a background thread, skip all of the patching: 251 | // we want to dispatch to the main thread. 252 | if ( 253 | module === EXTISM_ENV && 254 | name === 'http_request' && 255 | promising && 256 | imports[module][name] === context[ENV].http_request && 257 | !opts.executingInWorker 258 | ) { 259 | const httpContext = new HttpContext(opts.fetch, opts.allowedHosts, opts.memory, opts.allowHttpResponseHeaders); 260 | 261 | mutableFlags.suspendsOnInvoke = true; 262 | 263 | const contributions = {} as any; 264 | httpContext.contribute(contributions); 265 | for (const [key, entry] of Object.entries(contributions[EXTISM_ENV] as { [k: string]: CallableFunction })) { 266 | // REBIND: 267 | imports[module][key] = (entry as any).bind(null, context); 268 | } 269 | imports[module][name] = new Suspending!(imports[module][name]); 270 | } 271 | 272 | switch (kind) { 273 | case `function`: { 274 | instantiationImports[module] ??= {}; 275 | instantiationImports[module][name] = imports[module][name] as CallableFunction; 276 | break; 277 | } 278 | default: 279 | throw new Error( 280 | `from module "${current.join( 281 | '"/"', 282 | )}": in import "${module}" "${name}", "${kind}"-typed host imports are not supported yet`, 283 | ); 284 | } 285 | } else { 286 | // lookup from "linked" 287 | const provider = modules[nameIdx]; 288 | const providerExports = WebAssembly.Module.exports(provider); 289 | 290 | const target = providerExports.find((xs) => { 291 | return xs.name === name && xs.kind === kind; 292 | }); 293 | 294 | if (!target) { 295 | throw new Error( 296 | `from module "${current.join('"/"')}": cannot import "${module}" "${name}"; no export matched request`, 297 | ); 298 | } 299 | 300 | // If the dependency provides "_start", treat it as a WASI Command module; instantiate it (and its subtree) directly. 301 | const instance = providerExports.find((xs) => xs.name === '_start') 302 | ? await instantiateModule( 303 | context, 304 | [...current, module], 305 | provider, 306 | imports, 307 | opts, 308 | wasiList, 309 | names, 310 | modules, 311 | new Map(), 312 | mutableFlags, 313 | ) 314 | : !linked.has(provider) 315 | ? (await instantiateModule( 316 | context, 317 | [...current, module], 318 | provider, 319 | imports, 320 | opts, 321 | wasiList, 322 | names, 323 | modules, 324 | linked, 325 | mutableFlags, 326 | ), 327 | linked.get(provider)) 328 | : linked.get(provider); 329 | 330 | if (!instance) { 331 | // circular import, either make a trampoline or bail 332 | if (kind === 'function') { 333 | instantiationImports[module] = {}; 334 | let cached: CallableFunction | null = null; 335 | instantiationImports[module][name] = (...args: (number | bigint)[]) => { 336 | if (cached) { 337 | return cached(...args); 338 | } 339 | const instance = linked.get(modules[nameIdx]); 340 | if (!instance) { 341 | throw new Error( 342 | `from module instance "${current.join('"/"')}": target module "${module}" was never instantiated`, 343 | ); 344 | } 345 | cached = instance.exports[name] as CallableFunction; 346 | return cached(...args); 347 | }; 348 | } else { 349 | throw new Error( 350 | `from module "${current.join( 351 | '"/"', 352 | )}": cannot import "${module}" "${name}"; circular imports of type="${kind}" are not supported`, 353 | ); 354 | } 355 | } else { 356 | // Add each requested import value piecemeal, since we have to validate that _all_ import requests are satisfied by this 357 | // module. 358 | instantiationImports[module] ??= {}; 359 | instantiationImports[module][name] = instance.exports[name] as WebAssembly.ExportValue; 360 | } 361 | } 362 | } 363 | 364 | const instance = await WebAssembly.instantiate(module, instantiationImports); 365 | 366 | const guestType = instance.exports.hs_init 367 | ? 'haskell' 368 | : instance.exports._initialize 369 | ? 'reactor' 370 | : instance.exports._start 371 | ? 'command' 372 | : 'none'; 373 | 374 | if (wasi) { 375 | await wasi?.initialize(instance); 376 | if (instance.exports.hs_init) { 377 | (instance.exports.hs_init as CallableFunction)(); 378 | } 379 | } else { 380 | switch (guestType) { 381 | case 'command': 382 | if (instance.exports._initialize) { 383 | (instance.exports._initialize as CallableFunction)(); 384 | } 385 | 386 | (instance.exports._start as CallableFunction)(); 387 | break; 388 | case 'reactor': 389 | (instance.exports._initialize as CallableFunction)(); 390 | break; 391 | case 'haskell': 392 | (instance.exports.hs_init as CallableFunction)(); 393 | break; 394 | } 395 | } 396 | 397 | linked.set(module, instance); 398 | return instance; 399 | } 400 | -------------------------------------------------------------------------------- /src/http-context.ts: -------------------------------------------------------------------------------- 1 | import { CallContext, ENV } from './call-context.ts'; 2 | import { MemoryOptions } from './interfaces.ts'; 3 | import { EXTISM_ENV } from './foreground-plugin.ts'; 4 | import { matches } from './polyfills/deno-minimatch.ts'; 5 | 6 | export class HttpContext { 7 | fetch: typeof fetch; 8 | lastStatusCode: number; 9 | lastHeaders: Record | null; 10 | allowedHosts: string[]; 11 | memoryOptions: MemoryOptions; 12 | 13 | constructor( 14 | _fetch: typeof fetch, 15 | allowedHosts: string[], 16 | memoryOptions: MemoryOptions, 17 | allowResponseHeaders: boolean, 18 | ) { 19 | this.fetch = _fetch; 20 | this.allowedHosts = allowedHosts; 21 | this.lastStatusCode = 0; 22 | this.memoryOptions = memoryOptions; 23 | this.lastHeaders = allowResponseHeaders ? {} : null; 24 | } 25 | 26 | contribute(functions: Record>) { 27 | functions[EXTISM_ENV] ??= {}; 28 | functions[EXTISM_ENV].http_request = (callContext: CallContext, reqaddr: bigint, bodyaddr: bigint) => 29 | this.makeRequest(callContext, reqaddr, bodyaddr); 30 | functions[EXTISM_ENV].http_status_code = () => this.lastStatusCode; 31 | functions[EXTISM_ENV].http_headers = (callContext: CallContext) => { 32 | if (this.lastHeaders === null) { 33 | return 0n; 34 | } 35 | return callContext.store(JSON.stringify(this.lastHeaders)); 36 | }; 37 | } 38 | 39 | async makeRequest(callContext: CallContext, reqaddr: bigint, bodyaddr: bigint) { 40 | if (this.lastHeaders !== null) { 41 | this.lastHeaders = {}; 42 | } 43 | this.lastStatusCode = 0; 44 | 45 | const req = callContext.read(reqaddr); 46 | if (req === null) { 47 | return 0n; 48 | } 49 | 50 | const { headers, header, url: rawUrl, method: m } = req.json(); 51 | const method = m?.toUpperCase() ?? 'GET'; 52 | const url = new URL(rawUrl); 53 | 54 | const isAllowed = this.allowedHosts.some((allowedHost) => { 55 | return allowedHost === url.hostname || matches(url.hostname, allowedHost); 56 | }); 57 | 58 | if (!isAllowed) { 59 | throw new Error(`Call error: HTTP request to "${url}" is not allowed (no allowedHosts match "${url.hostname}")`); 60 | } 61 | 62 | const body = bodyaddr === 0n || method === 'GET' || method === 'HEAD' ? null : callContext.read(bodyaddr)?.bytes(); 63 | const fetch = this.fetch; 64 | const response = await fetch(rawUrl, { 65 | headers: headers || header, 66 | method, 67 | ...(body ? { body: body.slice() } : {}), 68 | }); 69 | 70 | this.lastStatusCode = response.status; 71 | 72 | if (this.lastHeaders !== null) { 73 | this.lastHeaders = Object.fromEntries(response.headers); 74 | } 75 | 76 | try { 77 | const bytes = this.memoryOptions.maxHttpResponseBytes 78 | ? await readBodyUpTo(response, this.memoryOptions.maxHttpResponseBytes) 79 | : new Uint8Array(await response.arrayBuffer()); 80 | 81 | const result = callContext.store(bytes); 82 | 83 | return result; 84 | } catch (err) { 85 | if (err instanceof Error) { 86 | const ptr = callContext.store(new TextEncoder().encode(err.message)); 87 | callContext[ENV].log_error(ptr); 88 | return 0n; 89 | } 90 | return 0n; 91 | } 92 | } 93 | } 94 | 95 | async function readBodyUpTo(response: Response, maxBytes: number): Promise { 96 | const reader = response.body?.getReader(); 97 | if (!reader) { 98 | return new Uint8Array(0); 99 | } 100 | 101 | let receivedLength = 0; 102 | const chunks = []; 103 | 104 | while (receivedLength < maxBytes) { 105 | const { done, value } = await reader.read(); 106 | if (done) { 107 | break; 108 | } 109 | chunks.push(value); 110 | receivedLength += value.length; 111 | if (receivedLength >= maxBytes) { 112 | throw new Error(`Response body exceeded ${maxBytes} bytes`); 113 | } 114 | } 115 | 116 | const limitedResponseBody = new Uint8Array(receivedLength); 117 | let position = 0; 118 | for (const chunk of chunks) { 119 | limitedResponseBody.set(chunk, position); 120 | position += chunk.length; 121 | } 122 | 123 | return limitedResponseBody; 124 | } 125 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | import { CallContext } from './call-context.ts'; 2 | 3 | /** 4 | * {@link Plugin} Config 5 | */ 6 | export interface PluginConfigLike { 7 | [key: string]: string; 8 | } 9 | 10 | /** 11 | * `PluginOutput` is a view around some memory exposed by the plugin. Typically 12 | * returned by {@link Plugin#call | `plugin.call()`} or {@link CallContext#read 13 | * | `callContext.read()`}. It implements the read side of 14 | * [`DataView`](https://mdn.io/dataview) along with methods for reading string 15 | * and JSON data out of the backing buffer. 16 | */ 17 | export class PluginOutput extends DataView { 18 | static #decoder = new TextDecoder(); 19 | #bytes: Uint8Array | null = null; 20 | 21 | /** @hidden */ 22 | constructor(buffer: ArrayBufferLike) { 23 | super(buffer); 24 | } 25 | 26 | json(): any { 27 | return JSON.parse(this.string()); 28 | } 29 | 30 | arrayBuffer(): ArrayBufferLike { 31 | return this.buffer; 32 | } 33 | 34 | text(): string { 35 | return this.string(); 36 | } 37 | 38 | /** @hidden */ 39 | string(): string { 40 | return PluginOutput.#decoder.decode(this.buffer); 41 | } 42 | 43 | bytes(): Uint8Array { 44 | this.#bytes ??= new Uint8Array(this.buffer); 45 | return this.#bytes; 46 | } 47 | 48 | override setInt8(_byteOffset: number, _value: number): void { 49 | throw new Error('Cannot set values on output'); 50 | } 51 | 52 | override setInt16(_byteOffset: number, _value: number, _littleEndian?: boolean): void { 53 | throw new Error('Cannot set values on output'); 54 | } 55 | 56 | override setInt32(_byteOffset: number, _value: number, _littleEndian?: boolean): void { 57 | throw new Error('Cannot set values on output'); 58 | } 59 | 60 | override setUint8(_byteOffset: number, _value: number): void { 61 | throw new Error('Cannot set values on output'); 62 | } 63 | 64 | override setUint16(_byteOffset: number, _value: number, _littleEndian?: boolean): void { 65 | throw new Error('Cannot set values on output'); 66 | } 67 | 68 | override setUint32(_byteOffset: number, _value: number, _littleEndian?: boolean): void { 69 | throw new Error('Cannot set values on output'); 70 | } 71 | 72 | override setFloat32(_byteOffset: number, _value: number, _littleEndian?: boolean): void { 73 | throw new Error('Cannot set values on output'); 74 | } 75 | 76 | override setFloat64(_byteOffset: number, _value: number, _littleEndian?: boolean): void { 77 | throw new Error('Cannot set values on output'); 78 | } 79 | 80 | override setBigInt64(_byteOffset: number, _value: bigint, _littleEndian?: boolean): void { 81 | throw new Error('Cannot set values on output'); 82 | } 83 | 84 | override setBigUint64(_byteOffset: number, _value: bigint, _littleEndian?: boolean): void { 85 | throw new Error('Cannot set values on output'); 86 | } 87 | } 88 | 89 | export type PluginConfig = Record; 90 | 91 | export interface Plugin { 92 | /** 93 | * Check if a function exists in the WebAssembly module. 94 | * 95 | * @param {string} funcName The function's name. 96 | * @returns {Promise} true if the function exists, otherwise false 97 | */ 98 | functionExists(funcName: string): Promise; 99 | close(): Promise; 100 | 101 | /** 102 | * Call a specific function from the WebAssembly module with provided input. 103 | * 104 | * @param {string} funcName The name of the function to call 105 | * @param {Uint8Array | string} input The input to pass to the function 106 | * @param {T} hostContext Per-call context to make available to host functions 107 | * @returns {Promise} The result from the function call 108 | */ 109 | call(funcName: string, input?: string | number | Uint8Array, hostContext?: T): Promise; 110 | getExports(): Promise; 111 | getImports(): Promise; 112 | getInstance(): Promise; 113 | 114 | /** 115 | * Whether the plugin is currently processing a call. 116 | */ 117 | isActive(): boolean; 118 | 119 | /** 120 | * Reset Plugin memory. If called while the plugin is {@link Plugin#isActive|actively executing}, memory will not be reset. 121 | * 122 | * @returns {Promise} Whether or not the reset was successful. 123 | */ 124 | reset(): Promise; 125 | } 126 | 127 | /** 128 | * Arguments to be passed to `node:worker_threads.Worker` when `runInWorker: true`. 129 | */ 130 | export interface NodeWorkerArgs { 131 | name?: string; 132 | execArgv?: string[]; 133 | argv?: string[]; 134 | env?: Record; 135 | resourceLimits?: { 136 | maxOldGenerationSizeMb?: number; 137 | maxYoungGenerationSizeMb?: number; 138 | codeRangeSizeMb?: number; 139 | stackSizeMb?: number; 140 | }; 141 | [k: string]: any; 142 | } 143 | 144 | /** 145 | * Options for initializing an Extism plugin. 146 | */ 147 | export interface ExtismPluginOptions { 148 | /** 149 | * Whether or not to enable WASI preview 1. 150 | */ 151 | useWasi?: boolean; 152 | 153 | /** 154 | * Whether or not to run the Wasm module in a Worker thread. Requires 155 | * {@link Capabilities#hasWorkerCapability | `CAPABILITIES.hasWorkerCapability`} to 156 | * be true. Defaults to false. 157 | * 158 | * This feature is marked experimental as we work out [a bug](https://github.com/extism/js-sdk/issues/46). 159 | * 160 | * @experimental 161 | */ 162 | runInWorker?: boolean; 163 | 164 | /** 165 | * A logger implementation. Must provide `trace`, `info`, `debug`, `warn`, and `error` methods. 166 | */ 167 | logger?: Console; 168 | 169 | /** 170 | * The log level to use. 171 | */ 172 | logLevel?: LogLevel; 173 | 174 | /** 175 | * A map of namespaces to function names to host functions. 176 | * 177 | * ```js 178 | * const functions = { 179 | * 'my_great_namespace': { 180 | * 'my_func': (callContext: CallContext, input: bigint) => { 181 | * const output = callContext.read(input); 182 | * if (output !== null) { 183 | * console.log(output.string()); 184 | * } 185 | * } 186 | * } 187 | * } 188 | * ``` 189 | */ 190 | functions?: 191 | | { 192 | [key: string]: { 193 | [key: string]: (callContext: CallContext, ...args: any[]) => any; 194 | }; 195 | } 196 | | undefined; 197 | allowedPaths?: { [key: string]: string } | undefined; 198 | 199 | /** 200 | * A list of allowed hostnames. Wildcard subdomains are supported via `*`. 201 | * 202 | * Requires the plugin to run in a worker using `runInWorker: true`. 203 | * 204 | * @example 205 | * ```ts 206 | * await createPlugin('path/to/some/wasm', { 207 | * runInWorker: true, 208 | * allowedHosts: ['*.example.com', 'www.dylibso.com'] 209 | * }) 210 | * ``` 211 | */ 212 | allowedHosts?: string[] | undefined; 213 | 214 | memory?: MemoryOptions; 215 | 216 | timeoutMs?: number | null; 217 | 218 | /** 219 | * Whether WASI stdout should be forwarded to the host. 220 | * 221 | * Overrides the `EXTISM_ENABLE_WASI_OUTPUT` environment variable. 222 | */ 223 | enableWasiOutput?: boolean; 224 | config?: PluginConfigLike; 225 | fetch?: typeof fetch; 226 | sharedArrayBufferSize?: number; 227 | 228 | /** 229 | * Determines whether or not HTTP response headers should be exposed to plugins, 230 | * when set to `true`, `extism:host/env::http_headers` will return the response 231 | * headers for HTTP requests made using `extism:host/env::http_request` 232 | */ 233 | allowHttpResponseHeaders?: boolean; 234 | 235 | /** 236 | * Arguments to pass to the `node:worker_threads.Worker` instance when `runInWorker: true`. 237 | * 238 | * This is particularly useful for changing `process.execArgv`, which controls certain startup 239 | * behaviors in node (`--import`, `--require`, warnings.) 240 | * 241 | * If not set, defaults to removing the current `execArgv` and disabling node warnings. 242 | */ 243 | nodeWorkerArgs?: NodeWorkerArgs; 244 | } 245 | 246 | export type MemoryOptions = { 247 | /** 248 | * Maximum number of pages to allocate for the WebAssembly memory. Each page is 64KB. 249 | */ 250 | maxPages?: number | undefined; 251 | 252 | /** 253 | * Maximum number of bytes to read from an HTTP response. 254 | */ 255 | maxHttpResponseBytes?: number | undefined; 256 | 257 | /** 258 | * Maximum number of bytes to allocate for plugin Vars. 259 | */ 260 | maxVarBytes?: number | undefined; 261 | }; 262 | 263 | type CamelToSnakeCase = S extends `${infer T}${infer U}` 264 | ? `${T extends Capitalize ? '_' : ''}${Lowercase}${CamelToSnakeCase}` 265 | : S; 266 | 267 | type SnakeCase> = { 268 | [K in keyof T as CamelToSnakeCase]: T[K]; 269 | }; 270 | 271 | export interface NativeManifestOptions 272 | extends Pick {} 273 | /** 274 | * The subset of {@link ExtismPluginOptions} attributes available for configuration via 275 | * a {@link Manifest}. If an attribute is specified at both the {@link ExtismPluginOptions} and 276 | * `ManifestOptions` level, the plugin option will take precedence. 277 | */ 278 | export type ManifestOptions = NativeManifestOptions & SnakeCase; 279 | 280 | export interface InternalConfig extends Required { 281 | logger: Console; 282 | logLevel: LogLevelPriority; 283 | enableWasiOutput: boolean; 284 | functions: { [namespace: string]: { [func: string]: any } }; 285 | fetch: typeof fetch; 286 | wasiEnabled: boolean; 287 | sharedArrayBufferSize: number; 288 | allowHttpResponseHeaders: boolean; 289 | nodeWorkerArgs: NodeWorkerArgs; 290 | executingInWorker: boolean; 291 | } 292 | 293 | export interface InternalWasi { 294 | importObject(): Promise>; 295 | initialize(instance: WebAssembly.Instance): Promise; 296 | close(): Promise; 297 | } 298 | 299 | /** 300 | * Represents the raw bytes of a WASM file loaded into memory 301 | * 302 | * @category Manifests 303 | */ 304 | export interface ManifestWasmData { 305 | data: Uint8Array; 306 | } 307 | 308 | /** 309 | * Represents a url to a WASM module 310 | */ 311 | export interface ManifestWasmUrl { 312 | url: URL | string; 313 | } 314 | 315 | /** 316 | * Represents a path to a WASM module 317 | */ 318 | export interface ManifestWasmPath { 319 | path: string; 320 | } 321 | 322 | /** 323 | * Represents a WASM module as a response 324 | */ 325 | export interface ManifestWasmResponse { 326 | response: Response; 327 | } 328 | 329 | /** 330 | * Represents a WASM module as a response 331 | */ 332 | export interface ManifestWasmModule { 333 | module: WebAssembly.Module; 334 | } 335 | 336 | /** 337 | * The WASM to load as bytes, a path, a fetch `Response`, a `WebAssembly.Module`, or a url 338 | * 339 | * @property name The name of the Wasm module. Used when disambiguating {@link Plugin#call | `Plugin#call`} targets when the 340 | * plugin embeds multiple Wasm modules. 341 | * 342 | * @property hash The expected SHA-256 hash of the associated Wasm module data. {@link createPlugin} validates incoming Wasm against 343 | * provided hashes. If running on Node v18, `node` must be invoked using the `--experimental-global-webcrypto` flag. 344 | * 345 | * ⚠️ `module` cannot be used in conjunction with `hash`: the Web Platform does not currently provide a way to get source 346 | * bytes from a `WebAssembly.Module` in order to hash. 347 | */ 348 | export type ManifestWasm = ( 349 | | ManifestWasmUrl 350 | | ManifestWasmData 351 | | ManifestWasmPath 352 | | ManifestWasmResponse 353 | | ManifestWasmModule 354 | ) & { 355 | name?: string; 356 | hash?: string; 357 | }; 358 | 359 | /** 360 | * The manifest which describes the {@link Plugin} code and runtime constraints. This is passed to {@link createPlugin} 361 | * 362 | * ```js 363 | * let manifest = { 364 | * wasm: [{name: 'my-wasm', url: 'http://example.com/path/to/wasm'}], 365 | * config: { 366 | * 'greeting': 'hello' // these variables will be available via `extism_get_var` in plugins 367 | * } 368 | * } 369 | * ``` 370 | * 371 | * Every member of `.wasm` is expected to be an instance of {@link ManifestWasm}. 372 | * 373 | * @see [Extism](https://extism.org/) > [Concepts](https://extism.org/docs/category/concepts) > [Manifest](https://extism.org/docs/concepts/manifest) 374 | */ 375 | export interface Manifest extends ManifestOptions { 376 | wasm: Array; 377 | } 378 | 379 | /** 380 | * Any type that can be converted into an Extism {@link Manifest}. 381 | * - `object` instances that implement {@link Manifest} are validated. 382 | * - `ArrayBuffer` instances are converted into {@link Manifest}s with a single {@link ManifestWasmData} member. 383 | * - `URL` instances are fetched and their responses interpreted according to their `content-type` response header. `application/wasm` and `application/octet-stream` items 384 | * are treated as {@link ManifestWasmData} items; `application/json` and `text/json` are treated as JSON-encoded {@link Manifest}s. 385 | * - `string` instances that start with `http://`, `https://`, or `file://` are treated as URLs. 386 | * - `string` instances that start with `{` treated as JSON-encoded {@link Manifest}s. 387 | * - All other `string` instances are treated as {@link ManifestWasmPath}. 388 | * 389 | * ```js 390 | * let manifest = { 391 | * wasm: [{name: 'my-wasm', url: 'http://example.com/path/to/wasm'}], 392 | * config: { 393 | * 'greeting': 'hello' // these variables will be available via `extism_get_var` in plugins 394 | * } 395 | * } 396 | * 397 | * let manifest = '{"wasm": {"url": "https://example.com"}}' 398 | * let manifest = 'path/to/file.wasm' 399 | * let manifest = new ArrayBuffer() 400 | * ``` 401 | * 402 | * @throws [TypeError](https://mdn.io/TypeError) when `URL` parameters don't resolve to a known `content-type` 403 | * @throws [TypeError](https://mdn.io/TypeError) when the resulting {@link Manifest} does not contain a `wasm` member with valid {@link ManifestWasm} items. 404 | * 405 | * @see [Extism](https://extism.org/) > [Concepts](https://extism.org/docs/category/concepts) > [Manifest](https://extism.org/docs/concepts/manifest) 406 | */ 407 | export type ManifestLike = Manifest | Response | WebAssembly.Module | ArrayBuffer | string | URL; 408 | 409 | export interface Capabilities { 410 | /** 411 | * Whether or not the environment supports [JSPI](https://github.com/WebAssembly/js-promise-integration/blob/main/proposals/js-promise-integration/Overview.md). 412 | * 413 | * If supported, host functions may be asynchronous without running the plugin with `runInWorker: true`. 414 | * 415 | * - ✅ node 23+ 416 | * - ❌ deno 417 | * - ❌ bun 418 | * - ❌ firefox 419 | * - ❌ chrome 420 | * - ❌ webkit 421 | */ 422 | supportsJSPromiseInterface: boolean; 423 | 424 | /** 425 | * Whether or not the environment allows SharedArrayBuffers to be passed to `TextDecoder.decode` and `TextEncoder.encodeInto` directly 426 | * 427 | * - ✅ node 428 | * - ✅ deno 429 | * - ✅ bun 430 | * - ❌ firefox 431 | * - ❌ chrome 432 | * - ❌ webkit 433 | */ 434 | allowSharedBufferCodec: boolean; 435 | 436 | /** 437 | * Whether or not {@link ManifestWasm} items support the "path:" key. 438 | * 439 | * - ✅ node 440 | * - ✅ deno 441 | * - ✅ bun 442 | * - ❌ firefox 443 | * - ❌ chrome 444 | * - ❌ webkit 445 | */ 446 | manifestSupportsPaths: boolean; 447 | 448 | /** 449 | * Whether or not cross-origin checks are enforced for outgoing HTTP requests on this platform. 450 | * 451 | * - ❌ node 452 | * - ❌ deno 453 | * - ❌ bun 454 | * - ✅ firefox 455 | * - ✅ chrome 456 | * - ✅ webkit 457 | */ 458 | crossOriginChecksEnforced: boolean; 459 | 460 | /** 461 | * Whether or not the host environment has access to a filesystem. 462 | * 463 | * - ✅ node 464 | * - ✅ deno 465 | * - ✅ bun 466 | * - ❌ firefox 467 | * - ❌ chrome 468 | * - ❌ webkit 469 | */ 470 | fsAccess: boolean; 471 | 472 | /** 473 | * Whether or not the host environment supports moving Wasm plugin workloads to a worker. This requires 474 | * SharedArrayBuffer support, which requires `window.crossOriginIsolated` to be true in browsers. 475 | * 476 | * @see [`crossOriginalIsolated` on MDN](https://mdn.io/crossOriginIsolated) 477 | * 478 | * - ✅ node 479 | * - ✅ deno 480 | * - ✅ bun 481 | * - 🔒 firefox 482 | * - 🔒 chrome 483 | * - 🔒 webkit 484 | */ 485 | hasWorkerCapability: boolean; 486 | 487 | /** 488 | * Whether or not the host environment supports WASI preview 1. 489 | * 490 | * @see [`WASI`](https://wasi.dev/) and [`WASI Preview 1`](https://github.com/WebAssembly/WASI/blob/main/legacy/preview1/docs.md) 491 | * 492 | * - ✅ node (via [`node:wasi`](https://nodejs.org/api/wasi.html)) 493 | * - ✅ deno (via [`deno.land/std/wasi/snapshot_preview1`](https://deno.land/std@0.200.0/wasi/snapshot_preview1.ts)) 494 | * - ❌ bun 495 | * - ✅ firefox (via [`@bjorn3/browser_wasi_shim`](https://www.npmjs.com/package/@bjorn3/browser_wasi_shim)) 496 | * - ✅ chrome (via [`@bjorn3/browser_wasi_shim`](https://www.npmjs.com/package/@bjorn3/browser_wasi_shim)) 497 | * - ✅ webkit (via [`@bjorn3/browser_wasi_shim`](https://www.npmjs.com/package/@bjorn3/browser_wasi_shim)) 498 | */ 499 | supportsWasiPreview1: boolean; 500 | 501 | /** 502 | * Whether or not the host environment supports timeouts. 503 | * 504 | * - ✅ node 505 | * - ✅ deno 506 | * - ❌ bun (Exhibits strange behavior when await'ing `worker.terminate()`.) 507 | * - ✅ firefox 508 | * - ✅ chrome 509 | * - ✅ webkit 510 | */ 511 | supportsTimeouts: boolean; 512 | 513 | /** 514 | * Whether or not the `EXTISM_ENABLE_WASI_OUTPUT` environment variable has been set. 515 | * 516 | * This value is consulted whenever {@link ExtismPluginOptions#enableWasiOutput} is omitted. 517 | */ 518 | extismStdoutEnvVarSet: boolean; 519 | } 520 | 521 | export const SAB_BASE_OFFSET = 4; 522 | 523 | export enum SharedArrayBufferSection { 524 | End = 0xff, 525 | RetI64 = 1, 526 | RetF64 = 2, 527 | RetVoid = 3, 528 | Block = 4, 529 | } 530 | 531 | export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent'; 532 | 533 | export function logLevelToPriority(level: LogLevel): LogLevelPriority { 534 | switch (level) { 535 | case 'trace': 536 | return 0; 537 | case 'debug': 538 | return 1; 539 | case 'info': 540 | return 2; 541 | case 'warn': 542 | return 3; 543 | case 'error': 544 | return 4; 545 | case 'silent': 546 | return 0x7fffffff; 547 | default: 548 | throw new TypeError( 549 | `unrecognized log level "${level}"; expected one of "trace", "debug", "info", "warn", "error", "silent"`, 550 | ); 551 | } 552 | } 553 | 554 | export type LogLevelPriority = 0 | 1 | 2 | 3 | 4 | 0x7fffffff; 555 | 556 | export function priorityToLogLevel(level: LogLevelPriority): LogLevel { 557 | switch (level) { 558 | case 0: 559 | return 'trace'; 560 | case 1: 561 | return 'debug'; 562 | case 2: 563 | return 'info'; 564 | case 3: 565 | return 'warn'; 566 | case 4: 567 | return 'error'; 568 | case 0x7fffffff: 569 | return 'silent'; 570 | default: 571 | throw new TypeError( 572 | `unrecognized log level "${level}"; expected one of "trace", "debug", "info", "warn", "error", "silent"`, 573 | ); 574 | } 575 | } 576 | -------------------------------------------------------------------------------- /src/manifest.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Manifest, 3 | ManifestWasmUrl, 4 | ManifestWasmData, 5 | ManifestWasmPath, 6 | ManifestWasmResponse, 7 | ManifestWasmModule, 8 | ManifestLike, 9 | ManifestOptions, 10 | } from './interfaces.ts'; 11 | import { readFile } from './polyfills/node-fs.ts'; 12 | import { responseToModule } from './polyfills/response-to-module.ts'; 13 | 14 | async function _populateWasmField(candidate: ManifestLike, _fetch: typeof fetch): Promise { 15 | if (candidate instanceof ArrayBuffer) { 16 | return { wasm: [{ data: new Uint8Array(candidate as ArrayBuffer) }] }; 17 | } 18 | 19 | if (candidate instanceof WebAssembly.Module) { 20 | return { wasm: [{ module: candidate as WebAssembly.Module }] }; 21 | } 22 | 23 | if (typeof candidate === 'string') { 24 | if (candidate.search(/^\s*\{/g) === 0) { 25 | return parseManifestFromJson(candidate); 26 | } 27 | 28 | if (candidate.search(/^(https?|file):\/\//) !== 0) { 29 | return { wasm: [{ path: candidate }] }; 30 | } 31 | 32 | candidate = new URL(candidate); 33 | } 34 | 35 | if (candidate instanceof Response || candidate?.constructor?.name === 'Response') { 36 | const response: Response = candidate as Response; 37 | const contentType = response.headers.get('content-type') || 'application/octet-stream'; 38 | 39 | switch (contentType.split(';')[0]) { 40 | case 'application/octet-stream': 41 | case 'application/wasm': 42 | return { wasm: [{ response }] }; 43 | case 'application/json': 44 | case 'text/json': 45 | return _populateWasmField(parseManifestFromJson(await response.text()), _fetch); 46 | default: 47 | throw new TypeError( 48 | `While processing manifest URL "${response.url}"; expected content-type of "text/json", "application/json", "application/octet-stream", or "application/wasm"; got "${contentType}" after stripping off charset.`, 49 | ); 50 | } 51 | } 52 | 53 | if (candidate instanceof URL) { 54 | return _populateWasmField(await _fetch(candidate, { redirect: 'follow' }), _fetch); 55 | } 56 | 57 | if (!('wasm' in candidate)) { 58 | throw new TypeError('Expected "wasm" key in manifest'); 59 | } 60 | 61 | if (!Array.isArray(candidate.wasm)) { 62 | throw new TypeError('Expected "manifest.wasm" to be array'); 63 | } 64 | 65 | const badItemIdx = candidate.wasm.findIndex( 66 | (item) => 67 | !('data' in item) && !('url' in item) && !('path' in item) && !('module' in item) && !('response' in item), 68 | ); 69 | if (badItemIdx > -1) { 70 | throw new TypeError( 71 | `Expected every item in "manifest.wasm" to include either a "data", "url", or "path" key; got bad item at index ${badItemIdx}`, 72 | ); 73 | } 74 | 75 | return { ...(candidate as Manifest) }; 76 | } 77 | 78 | function parseManifestFromJson(json: string): Manifest { 79 | const parsed = JSON.parse(json); 80 | 81 | return { 82 | wasm: parsed.wasm, 83 | timeoutMs: parsed.timeoutMs ?? parsed.timeout_ms, 84 | allowedPaths: parsed.allowedPaths ?? parsed.allowed_paths, 85 | allowedHosts: parsed.allowedHosts ?? parsed.allowed_hosts, 86 | config: parsed.config, 87 | ...(parsed.memory 88 | ? { 89 | maxHttpResponseBytes: parsed.memory.maxHttpResponseBytes ?? parsed.memory.max_http_response_bytes, 90 | maxPages: parsed.memory.maxPages ?? parsed.memory.max_pages, 91 | maxVarBytes: parsed.memory.maxVarBytes ?? parsed.memory.max_var_bytes, 92 | } 93 | : {}), 94 | }; 95 | } 96 | 97 | async function intoManifest(candidate: ManifestLike, _fetch: typeof fetch = fetch): Promise { 98 | const manifest = (await _populateWasmField(candidate, _fetch)) as Manifest; 99 | manifest.config ??= {}; 100 | return manifest; 101 | } 102 | 103 | export async function toWasmModuleData( 104 | input: ManifestLike, 105 | _fetch: typeof fetch, 106 | ): Promise<[ManifestOptions, string[], WebAssembly.Module[]]> { 107 | const names: string[] = []; 108 | 109 | const manifest = await intoManifest(input, _fetch); 110 | const manifestOpts: ManifestOptions = { 111 | allowedPaths: manifest.allowedPaths, 112 | allowedHosts: manifest.allowedHosts, 113 | config: manifest.config, 114 | memory: manifest.memory, 115 | }; 116 | 117 | const manifestsWasm = await Promise.all( 118 | manifest.wasm.map(async (item, idx, all) => { 119 | let module: WebAssembly.Module; 120 | let buffer: ArrayBuffer | undefined; 121 | if ((item as ManifestWasmData).data) { 122 | const data = (item as ManifestWasmData).data; 123 | buffer = data.buffer ? data.buffer : data; 124 | module = await WebAssembly.compile(data); 125 | } else if ((item as ManifestWasmPath).path) { 126 | const path = (item as ManifestWasmPath).path; 127 | const data = await readFile(path); 128 | buffer = data.buffer as ArrayBuffer; 129 | module = await WebAssembly.compile(data); 130 | } else if ((item as ManifestWasmUrl).url) { 131 | const response = await _fetch((item as ManifestWasmUrl).url, { 132 | headers: { 133 | accept: 'application/wasm;q=0.9,application/octet-stream;q=0.8', 134 | }, 135 | }); 136 | const result = await responseToModule(response, Boolean(item.hash)); 137 | buffer = result.data; 138 | module = result.module; 139 | } else if ((item as ManifestWasmResponse).response) { 140 | const result = await responseToModule((item as ManifestWasmResponse).response, Boolean(item.hash)); 141 | buffer = result.data; 142 | module = result.module; 143 | } else if ((item as ManifestWasmModule).module) { 144 | (names[idx]) = item.name ?? String(idx); 145 | module = (item as ManifestWasmModule).module; 146 | } else { 147 | throw new Error( 148 | `Unrecognized wasm item at index ${idx}. Keys include: "${Object.keys(item).sort().join(',')}"`, 149 | ); 150 | } 151 | 152 | let potentialName = String(idx); 153 | if (item.hash) { 154 | if (!buffer) { 155 | throw new Error('Item specified a hash but WebAssembly.Module source data is unavailable for hashing'); 156 | } 157 | 158 | const hashBuffer = new Uint8Array(await crypto.subtle.digest('SHA-256', buffer)); 159 | const checkBuffer = new Uint8Array(32); 160 | let eq = true; 161 | for (let i = 0; i < 32; ++i) { 162 | checkBuffer[i] = parseInt(item.hash.slice(i << 1, (i << 1) + 2), 16); 163 | // do not exit early: we want to do a constant time comparison 164 | eq = eq && checkBuffer[i] === hashBuffer[i]; 165 | } 166 | const hashAsString = () => [...hashBuffer].map((xs) => xs.toString(16).padStart(2, '0')).join(''); 167 | 168 | if (!eq) { 169 | throw new Error(`Plugin error: hash mismatch. Expected: ${item.hash}. Actual: ${hashAsString()}`); 170 | } 171 | 172 | potentialName = hashAsString(); 173 | } 174 | 175 | (names[idx]) = item.name ?? (idx === all.length - 1 ? 'main' : potentialName); 176 | 177 | return module; 178 | }), 179 | ); 180 | 181 | if (!names.includes('main')) { 182 | throw new Error('manifest with multiple modules must designate one "main" module'); 183 | } 184 | 185 | return [manifestOpts, names, manifestsWasm]; 186 | } 187 | -------------------------------------------------------------------------------- /src/mod.ts: -------------------------------------------------------------------------------- 1 | import { CAPABILITIES } from './polyfills/deno-capabilities.ts'; 2 | 3 | import { 4 | logLevelToPriority, 5 | type ExtismPluginOptions, 6 | type InternalConfig, 7 | type ManifestLike, 8 | type Plugin, 9 | } from './interfaces.ts'; 10 | 11 | import { toWasmModuleData as _toWasmModuleData } from './manifest.ts'; 12 | 13 | import { createForegroundPlugin as _createForegroundPlugin } from './foreground-plugin.ts'; 14 | import { createBackgroundPlugin as _createBackgroundPlugin } from './background-plugin.ts'; 15 | 16 | export { CAPABILITIES } from './polyfills/deno-capabilities.ts'; 17 | 18 | export type { 19 | Capabilities, 20 | ExtismPluginOptions, 21 | LogLevel, 22 | Manifest, 23 | ManifestLike, 24 | ManifestWasm, 25 | ManifestWasmData, 26 | ManifestWasmModule, 27 | ManifestWasmPath, 28 | ManifestWasmResponse, 29 | ManifestWasmUrl, 30 | MemoryOptions, 31 | Plugin, 32 | PluginConfig, 33 | PluginConfigLike, 34 | PluginOutput, 35 | } from './interfaces.ts'; 36 | 37 | export type { CallContext, CallContext as CurrentPlugin } from './call-context.ts'; 38 | 39 | /** 40 | * Create a {@link Plugin} given a {@link ManifestLike} and {@link ExtismPluginOptions}. 41 | * 42 | * Plugins wrap Wasm modules, exposing rich access to exported functions. 43 | * 44 | * ```ts 45 | * const plugin = await createPlugin( 46 | * 'https://github.com/extism/plugins/releases/download/v0.3.0/count_vowels.wasm', 47 | * { useWasi: true } 48 | * ); 49 | * 50 | * try { 51 | * const result = await plugin.call('count_vowels', 'hello world'); 52 | * const parsed = result.json(); 53 | * 54 | * console.log(parsed); // { count: 3, total: 3, vowels: "aeiouAEIOU" } 55 | * } finally { 56 | * await plugin.close(); 57 | * } 58 | * ``` 59 | * 60 | * {@link Plugin | `Plugin`} can run on a background thread when the 61 | * environment supports it. You can see if the current environment supports 62 | * background plugins by checking the {@link Capabilities#hasWorkerCapability | 63 | * `hasWorkerCapability`} property of {@link CAPABILITIES}. 64 | * 65 | * @param manifest A {@link ManifestLike | `ManifestLike`}. May be a `string` 66 | * representing a URL, JSON, a path to a wasm file ({@link 67 | * Capabilities#manifestSupportsPaths | in environments} where paths are 68 | * supported); an [ArrayBuffer](https://mdn.io/ArrayBuffer); or a {@link 69 | * Manifest}. 70 | * 71 | * @param opts {@link ExtismPluginOptions | options} for controlling the behavior 72 | * of the plugin. 73 | * 74 | * @returns a promise for a {@link Plugin}. 75 | */ 76 | export async function createPlugin( 77 | manifest: ManifestLike | PromiseLike, 78 | opts: ExtismPluginOptions = {}, 79 | ): Promise { 80 | opts = { ...opts }; 81 | opts.useWasi ??= false; 82 | opts.enableWasiOutput ??= opts.useWasi ? CAPABILITIES.extismStdoutEnvVarSet : false; 83 | opts.functions = opts.functions || {}; 84 | 85 | // TODO(chrisdickinson): reset this to `CAPABILITIES.hasWorkerCapability` once we've fixed https://github.com/extism/js-sdk/issues/46. 86 | opts.runInWorker ??= false; 87 | 88 | opts.logger ??= console; 89 | opts.logLevel ??= 'silent'; 90 | opts.fetch ??= fetch; 91 | 92 | const [manifestOpts, names, moduleData] = await _toWasmModuleData( 93 | await Promise.resolve(manifest), 94 | opts.fetch ?? fetch, 95 | ); 96 | 97 | opts.allowedPaths = opts.allowedPaths || manifestOpts.allowedPaths || {}; 98 | opts.allowedHosts = opts.allowedHosts || manifestOpts.allowedHosts || []; 99 | opts.config = opts.config || manifestOpts.config || {}; 100 | opts.memory = opts.memory || manifestOpts.memory || {}; 101 | opts.timeoutMs = opts.timeoutMs || manifestOpts.timeoutMs || null; 102 | opts.nodeWorkerArgs = Object.assign( 103 | { 104 | name: 'extism plugin', 105 | execArgv: ['--disable-warning=ExperimentalWarning'], 106 | }, 107 | opts.nodeWorkerArgs || {}, 108 | ); 109 | 110 | if (opts.allowedHosts.length && !opts.runInWorker) { 111 | if (!(WebAssembly as any).Suspending) { 112 | throw new TypeError( 113 | '"allowedHosts" requires "runInWorker: true". HTTP functions are only available to plugins running in a worker.', 114 | ); 115 | } 116 | } 117 | 118 | if (opts.timeoutMs && !opts.runInWorker) { 119 | throw new TypeError( 120 | '"timeout" requires "runInWorker: true". Call timeouts are only available to plugins running in a worker.', 121 | ); 122 | } 123 | 124 | if (opts.runInWorker && !CAPABILITIES.hasWorkerCapability) { 125 | throw new Error( 126 | 'Cannot enable off-thread wasm; current context is not `crossOriginIsolated` (see https://mdn.io/crossOriginIsolated)', 127 | ); 128 | } 129 | 130 | for (const guest in opts.allowedPaths) { 131 | const host = opts.allowedPaths[guest]; 132 | 133 | if (host.startsWith('ro:')) { 134 | throw new Error(`Readonly dirs are not supported: ${host}`); 135 | } 136 | } 137 | 138 | const ic: InternalConfig = { 139 | executingInWorker: false, 140 | allowedHosts: opts.allowedHosts as [], 141 | allowedPaths: opts.allowedPaths, 142 | functions: opts.functions, 143 | fetch: opts.fetch || fetch, 144 | wasiEnabled: opts.useWasi, 145 | logger: opts.logger, 146 | logLevel: logLevelToPriority(opts.logLevel || 'silent'), 147 | config: opts.config, 148 | enableWasiOutput: opts.enableWasiOutput, 149 | sharedArrayBufferSize: Number(opts.sharedArrayBufferSize) || 1 << 16, 150 | timeoutMs: opts.timeoutMs, 151 | memory: opts.memory, 152 | allowHttpResponseHeaders: !!opts.allowHttpResponseHeaders, 153 | nodeWorkerArgs: opts.nodeWorkerArgs || {}, 154 | }; 155 | 156 | return (opts.runInWorker ? _createBackgroundPlugin : _createForegroundPlugin)(ic, names, moduleData); 157 | } 158 | 159 | export { createPlugin as newPlugin }; 160 | 161 | export default createPlugin; 162 | -------------------------------------------------------------------------------- /src/polyfills/browser-capabilities.ts: -------------------------------------------------------------------------------- 1 | import type { Capabilities } from '../interfaces.ts'; 2 | 3 | const WebAssembly = globalThis.WebAssembly || {}; 4 | 5 | export const CAPABILITIES: Capabilities = { 6 | supportsJSPromiseInterface: 7 | typeof (WebAssembly as any).Suspending === 'function' && typeof (WebAssembly as any).promising === 'function', 8 | 9 | // When false, shared buffers have to be copied to an array 10 | // buffer before passing to Text{En,De}coding() 11 | allowSharedBufferCodec: false, 12 | 13 | // Whether or not the manifest supports the "path:" key. 14 | manifestSupportsPaths: false, 15 | 16 | // Whether or not cross-origin checks are enforced on this platform. 17 | crossOriginChecksEnforced: true, 18 | 19 | fsAccess: false, 20 | 21 | hasWorkerCapability: 22 | typeof globalThis !== 'undefined' 23 | ? (globalThis as any).crossOriginIsolated && typeof SharedArrayBuffer !== 'undefined' 24 | : true, 25 | 26 | supportsWasiPreview1: true, 27 | 28 | supportsTimeouts: true, 29 | 30 | extismStdoutEnvVarSet: false, 31 | }; 32 | -------------------------------------------------------------------------------- /src/polyfills/browser-fs.ts: -------------------------------------------------------------------------------- 1 | export async function readFile(_path: string): Promise { 2 | throw new Error('readFile not supported in this environment'); 3 | } 4 | -------------------------------------------------------------------------------- /src/polyfills/browser-wasi.ts: -------------------------------------------------------------------------------- 1 | import { WASI, Fd, File, OpenFile, wasi } from '@bjorn3/browser_wasi_shim'; 2 | import { type InternalWasi } from '../mod.ts'; 3 | 4 | class Output extends Fd { 5 | #mode: string; 6 | 7 | constructor(mode: string) { 8 | super(); 9 | this.#mode = mode; 10 | } 11 | 12 | fd_write(view8: Uint8Array, iovs: [wasi.Iovec]): { ret: number; nwritten: number } { 13 | let nwritten = 0; 14 | const decoder = new TextDecoder(); 15 | const str = iovs.reduce((acc, iovec, idx, all) => { 16 | nwritten += iovec.buf_len; 17 | const buffer = view8.slice(iovec.buf, iovec.buf + iovec.buf_len); 18 | return acc + decoder.decode(buffer, { stream: idx !== all.length - 1 }); 19 | }, ''); 20 | 21 | (console[this.#mode] as any)(str); 22 | 23 | return { ret: 0, nwritten }; 24 | } 25 | } 26 | 27 | export async function loadWasi( 28 | _allowedPaths: { [from: string]: string }, 29 | enableWasiOutput: boolean, 30 | ): Promise { 31 | const args: Array = []; 32 | const envVars: Array = []; 33 | const fds: Fd[] = enableWasiOutput 34 | ? [ 35 | new Output('log'), // fd 0 is dup'd to stdout 36 | new Output('log'), 37 | new Output('error'), 38 | ] 39 | : [ 40 | new OpenFile(new File([])), // stdin 41 | new OpenFile(new File([])), // stdout 42 | new OpenFile(new File([])), // stderr 43 | ]; 44 | 45 | const context = new WASI(args, envVars, fds); 46 | 47 | return { 48 | async importObject() { 49 | return context.wasiImport; 50 | }, 51 | 52 | async close() { 53 | // noop 54 | }, 55 | 56 | async initialize(instance: WebAssembly.Instance) { 57 | const memory = instance.exports.memory as WebAssembly.Memory; 58 | 59 | if (!memory) { 60 | throw new Error('The module has to export a default memory.'); 61 | } 62 | 63 | if (instance.exports._initialize) { 64 | const init = instance.exports._initialize as CallableFunction; 65 | if (context.initialize) { 66 | context.initialize({ 67 | exports: { 68 | memory, 69 | _initialize: () => { 70 | init(); 71 | }, 72 | }, 73 | }); 74 | } else { 75 | init(); 76 | } 77 | } else { 78 | context.start({ 79 | exports: { 80 | memory, 81 | _start: () => {}, 82 | }, 83 | }); 84 | } 85 | }, 86 | }; 87 | } 88 | -------------------------------------------------------------------------------- /src/polyfills/bun-capabilities.ts: -------------------------------------------------------------------------------- 1 | import type { Capabilities } from '../interfaces.ts'; 2 | 3 | const WebAssembly = globalThis.WebAssembly || {}; 4 | 5 | export const CAPABILITIES: Capabilities = { 6 | supportsJSPromiseInterface: 7 | typeof (WebAssembly as any).Suspending === 'function' && typeof (WebAssembly as any).promising === 'function', 8 | 9 | // When false, shared buffers have to be copied to an array 10 | // buffer before passing to Text{En,De}coding() 11 | allowSharedBufferCodec: false, 12 | 13 | // Whether or not the manifest supports the "path:" key. 14 | manifestSupportsPaths: true, 15 | 16 | // Whether or not cross-origin checks are enforced on this platform. 17 | crossOriginChecksEnforced: false, 18 | 19 | fsAccess: true, 20 | 21 | hasWorkerCapability: true, 22 | 23 | // See https://github.com/oven-sh/bun/issues/1960 24 | supportsWasiPreview1: false, 25 | 26 | supportsTimeouts: false, 27 | 28 | extismStdoutEnvVarSet: Boolean(process.env.EXTISM_ENABLE_WASI_OUTPUT), 29 | }; 30 | -------------------------------------------------------------------------------- /src/polyfills/bun-response-to-module.ts: -------------------------------------------------------------------------------- 1 | // XXX(chrisdickinson): BUN NOTE: bun doesn't support `WebAssembly.compileStreaming` at the time of writing, nor 2 | // does cloning a response work [1]. 3 | // 4 | // [1]: https://github.com/oven-sh/bun/issues/6348 5 | export async function responseToModule( 6 | response: Response, 7 | _hasHash?: boolean, 8 | ): Promise<{ module: WebAssembly.Module; data?: ArrayBuffer }> { 9 | if (String(response.headers.get('Content-Type')).split(';')[0] === 'application/octet-stream') { 10 | const headers = new Headers(response.headers); 11 | headers.set('Content-Type', 'application/wasm'); 12 | 13 | response = new Response(response.body, { 14 | status: response.status, 15 | statusText: response.statusText, 16 | headers: headers, 17 | }); 18 | } 19 | const data = await response.arrayBuffer(); 20 | const module = await WebAssembly.compile(data); 21 | 22 | return { module, data }; 23 | } 24 | -------------------------------------------------------------------------------- /src/polyfills/bun-worker-url.ts: -------------------------------------------------------------------------------- 1 | export const WORKER_URL = new URL('./worker.js', import.meta.url); 2 | -------------------------------------------------------------------------------- /src/polyfills/deno-capabilities.ts: -------------------------------------------------------------------------------- 1 | import type { Capabilities } from '../interfaces.ts'; 2 | 3 | const WebAssembly = globalThis.WebAssembly || {}; 4 | 5 | const { Deno } = globalThis as unknown as { Deno: { env: Map } }; 6 | 7 | export const CAPABILITIES: Capabilities = { 8 | supportsJSPromiseInterface: 9 | typeof (WebAssembly as any).Suspending === 'function' && typeof (WebAssembly as any).promising === 'function', 10 | 11 | // When false, shared buffers have to be copied to an array 12 | // buffer before passing to Text{En,De}coding() 13 | allowSharedBufferCodec: true, 14 | 15 | // Whether or not the manifest supports the "path:" key. 16 | manifestSupportsPaths: true, 17 | 18 | // Whether or not cross-origin checks are enforced on this platform. 19 | crossOriginChecksEnforced: false, 20 | 21 | fsAccess: true, 22 | 23 | hasWorkerCapability: true, 24 | 25 | supportsWasiPreview1: false, 26 | 27 | supportsTimeouts: true, 28 | 29 | extismStdoutEnvVarSet: Boolean(Deno.env.get('EXTISM_ENABLE_WASI_OUTPUT')), 30 | }; 31 | -------------------------------------------------------------------------------- /src/polyfills/deno-minimatch.ts: -------------------------------------------------------------------------------- 1 | import { minimatch } from 'npm:minimatch@9.0.4'; 2 | 3 | export function matches(text: string, pattern: string): boolean { 4 | return minimatch(text, pattern); 5 | } 6 | -------------------------------------------------------------------------------- /src/polyfills/deno-wasi.ts: -------------------------------------------------------------------------------- 1 | import { type InternalWasi } from '../interfaces.ts'; 2 | 3 | export async function loadWasi( 4 | _allowedPaths: { [from: string]: string }, 5 | _enableWasiOutput: boolean, 6 | ): Promise { 7 | throw new TypeError('WASI is not supported on Deno.'); 8 | } 9 | -------------------------------------------------------------------------------- /src/polyfills/host-node-worker_threads.ts: -------------------------------------------------------------------------------- 1 | // This is a polyfill for the main thread in a browser context. 2 | // We're making the native Worker API look like node's worker_threads 3 | // implementation. 4 | export const parentPort = null; 5 | 6 | const HANDLER_MAP = new WeakMap(); 7 | 8 | export class Worker extends (global.Worker || Object) { 9 | constructor(url: string) { 10 | super(url, { type: 'module', credentials: 'omit', name: 'extism-worker', crossOriginIsolated: true } as any); 11 | } 12 | 13 | on(ev: string, action: any) { 14 | const handler = (ev: any) => action(ev.data); 15 | HANDLER_MAP.set(action, handler); 16 | this.addEventListener(ev, handler); 17 | } 18 | 19 | removeListener(ev: string, action: any) { 20 | const handler = HANDLER_MAP.get(action); 21 | if (handler) { 22 | this.removeEventListener(ev, handler); 23 | } 24 | } 25 | 26 | once(ev: string, action: any) { 27 | // eslint-disable-next-line @typescript-eslint/no-this-alias 28 | const self = this; 29 | this.addEventListener(ev, function handler(...args) { 30 | self.removeEventListener(ev, handler); 31 | action.call(self, ...args); 32 | }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/polyfills/node-capabilities.ts: -------------------------------------------------------------------------------- 1 | import type { Capabilities } from '../interfaces.ts'; 2 | 3 | const WebAssembly = globalThis.WebAssembly || {}; 4 | 5 | export const CAPABILITIES: Capabilities = { 6 | supportsJSPromiseInterface: 7 | typeof (WebAssembly as any).Suspending === 'function' && typeof (WebAssembly as any).promising === 'function', 8 | 9 | // When false, shared buffers have to be copied to an array 10 | // buffer before passing to Text{En,De}coding() 11 | allowSharedBufferCodec: false, 12 | 13 | // Whether or not the manifest supports the "path:" key. 14 | manifestSupportsPaths: true, 15 | 16 | // Whether or not cross-origin checks are enforced on this platform. 17 | crossOriginChecksEnforced: false, 18 | 19 | fsAccess: true, 20 | 21 | hasWorkerCapability: true, 22 | 23 | supportsWasiPreview1: true, 24 | 25 | supportsTimeouts: true, 26 | 27 | extismStdoutEnvVarSet: Boolean(process.env.EXTISM_ENABLE_WASI_OUTPUT), 28 | }; 29 | -------------------------------------------------------------------------------- /src/polyfills/node-fs.ts: -------------------------------------------------------------------------------- 1 | export { readFile } from 'node:fs/promises'; 2 | -------------------------------------------------------------------------------- /src/polyfills/node-minimatch.ts: -------------------------------------------------------------------------------- 1 | import { minimatch } from 'minimatch'; 2 | 3 | export function matches(text: string, pattern: string): boolean { 4 | return minimatch(text, pattern); 5 | } 6 | -------------------------------------------------------------------------------- /src/polyfills/node-wasi.ts: -------------------------------------------------------------------------------- 1 | import { WASI } from 'wasi'; 2 | import { type InternalWasi } from '../interfaces.ts'; 3 | import { devNull } from 'node:os'; 4 | import { open } from 'node:fs/promises'; 5 | import { closeSync } from 'node:fs'; 6 | 7 | async function createDevNullFDs() { 8 | const [stdin, stdout] = await Promise.all([open(devNull, 'r'), open(devNull, 'w')]); 9 | let needsClose = true; 10 | // TODO: make this check always run when bun fixes [1], so `fs.promises.open()` returns a `FileHandle` as expected. 11 | // [1]: https://github.com/oven-sh/bun/issues/5918 12 | let close = async () => { 13 | closeSync(stdin as any); 14 | closeSync(stdout as any); 15 | }; 16 | if (typeof stdin !== 'number') { 17 | const fr = new globalThis.FinalizationRegistry((held: number) => { 18 | try { 19 | if (needsClose) closeSync(held); 20 | } catch { 21 | // The fd may already be closed. 22 | } 23 | }); 24 | 25 | fr.register(stdin, stdin.fd); 26 | fr.register(stdout, stdout.fd); 27 | close = async () => { 28 | needsClose = false; 29 | await Promise.all([stdin.close(), stdout.close()]).catch(() => {}); 30 | }; 31 | } 32 | 33 | return { 34 | close, 35 | fds: [stdin.fd, stdout.fd, stdout.fd], 36 | }; 37 | } 38 | 39 | export async function loadWasi( 40 | allowedPaths: { [from: string]: string }, 41 | enableWasiOutput: boolean, 42 | ): Promise { 43 | const { 44 | close, 45 | fds: [stdin, stdout, stderr], 46 | } = enableWasiOutput ? { async close() {}, fds: [0, 1, 2] } : await createDevNullFDs(); 47 | 48 | const context = new WASI({ 49 | version: 'preview1', 50 | preopens: allowedPaths, 51 | stdin, 52 | stdout, 53 | stderr, 54 | } as any); 55 | 56 | return { 57 | async importObject() { 58 | return context.wasiImport; 59 | }, 60 | 61 | async close() { 62 | await close(); 63 | }, 64 | 65 | async initialize(instance: WebAssembly.Instance) { 66 | const memory = instance.exports.memory as WebAssembly.Memory; 67 | 68 | if (!memory) { 69 | throw new Error('The module has to export a default memory.'); 70 | } 71 | 72 | if (instance.exports._initialize) { 73 | const init = instance.exports._initialize as CallableFunction; 74 | if (context.initialize) { 75 | context.initialize({ 76 | exports: { 77 | memory, 78 | _initialize: () => { 79 | init(); 80 | }, 81 | }, 82 | }); 83 | } else { 84 | init(); 85 | } 86 | } else { 87 | context.start({ 88 | exports: { 89 | memory, 90 | _start: () => {}, 91 | }, 92 | }); 93 | } 94 | }, 95 | }; 96 | } 97 | -------------------------------------------------------------------------------- /src/polyfills/response-to-module.ts: -------------------------------------------------------------------------------- 1 | export async function responseToModule( 2 | response: Response, 3 | hasHash?: boolean, 4 | ): Promise<{ module: WebAssembly.Module; data?: ArrayBuffer }> { 5 | if (String(response.headers.get('Content-Type')).split(';')[0] === 'application/octet-stream') { 6 | const headers = new Headers(response.headers); 7 | headers.set('Content-Type', 'application/wasm'); 8 | 9 | response = new Response(response.body, { 10 | status: response.status, 11 | statusText: response.statusText, 12 | headers: headers, 13 | }); 14 | } 15 | 16 | // XXX(chrisdickinson): Note that we want to pass a `Response` to WebAssembly.compileStreaming if we 17 | // can to play nicely with V8's code caching [1]. At the same time, we need the original ArrayBuffer data 18 | // to verify any hashes. There's no way back to bytes from `WebAssembly.Module`, so we have to `.clone()` 19 | // the response to get the `ArrayBuffer` data if we need to check a hash. 20 | // 21 | // [1]: https://v8.dev/blog/wasm-code-caching#algorithm 22 | const data = hasHash ? await response.clone().arrayBuffer() : undefined; 23 | const module = await WebAssembly.compileStreaming(response); 24 | 25 | return { module, data }; 26 | } 27 | -------------------------------------------------------------------------------- /src/polyfills/worker-node-worker_threads.ts: -------------------------------------------------------------------------------- 1 | // This is a polyfill for the worker thread in a browser context. 2 | // We're exposing the worker thread's addEventListener/postMessage 3 | // functionality out on something that looks like Node's MessagePort. 4 | const _parentPort = null; 5 | 6 | export const parentPort = _parentPort || { 7 | on(ev: string, fn: unknown) { 8 | addEventListener(ev, (event: MessageEvent) => { 9 | fn(event.data); 10 | }); 11 | }, 12 | 13 | postMessage(data, txf = []) { 14 | self.postMessage(data, txf); 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /src/worker-url.ts: -------------------------------------------------------------------------------- 1 | // NB(chris): we can't do the obvious thing here (`new URL('./worker.js', import.meta.url)`.) 2 | // Why? This file is consumed by Deno, and in Deno JSR packages `import.meta.url` 3 | // resolves to an `http(s):` protocol. However, `http(s):` protocol URLs are not supported 4 | // by node:worker_threads. 5 | // 6 | // (And oof, in order to switch from node workers to web Workers, 7 | // we'd have to polyfill the web Worker api on top of node. It was easier to go the other way 8 | // around.) 9 | // 10 | // In Node, Bun, and browser environments, this entire file is *ignored*: the esbuild config 11 | // replaces it with a prebuilt base64'd inline javascript URL. See `build_worker_node` in 12 | // the `justfile`. 13 | const relativeUrl = (await (import.meta.resolve as any)('./worker.ts')) as string; 14 | export const WORKER_URL = `data:text/javascript;base64,${btoa(` 15 | export * from ${JSON.stringify(relativeUrl)}; 16 | `)}`; 17 | -------------------------------------------------------------------------------- /src/worker.ts: -------------------------------------------------------------------------------- 1 | import { parentPort } from 'node:worker_threads'; 2 | 3 | import { createForegroundPlugin as _createForegroundPlugin, ForegroundPlugin } from './foreground-plugin.ts'; 4 | import { CallContext, CallState, EXPORT_STATE, IMPORT_STATE } from './call-context.ts'; 5 | import { type InternalConfig, SAB_BASE_OFFSET, SharedArrayBufferSection } from './interfaces.ts'; 6 | 7 | class Reactor { 8 | hostFlag: Int32Array | null; 9 | sharedData: SharedArrayBuffer | null; 10 | sharedDataView: DataView | null; 11 | plugin?: ForegroundPlugin; 12 | port: Exclude; 13 | dynamicHandlers: Map Promise>; 14 | context?: CallContext; 15 | 16 | constructor(port: typeof parentPort) { 17 | if (!port) { 18 | throw new Error('This should be unreachable: this module should only be invoked as a web worker.'); 19 | } 20 | 21 | this.sharedData = null; 22 | this.sharedDataView = null; 23 | this.hostFlag = null; 24 | this.port = port; 25 | this.port.on('message', (ev: any) => this.handleMessage(ev)); 26 | this.port.postMessage({ type: 'initialized' }); 27 | 28 | this.dynamicHandlers = new Map(); 29 | this.dynamicHandlers.set('call', async (transfer: any[], name: string, input: number | null, state: CallState) => { 30 | if (!this.context) { 31 | throw new Error('invalid state: no context available to worker reactor'); 32 | } 33 | 34 | this.context[IMPORT_STATE](state); 35 | 36 | const results: any = await this.plugin?.callBlock(name, input).then( 37 | (indices) => [null, indices], 38 | (err) => [err, null], 39 | ); 40 | 41 | state = this.context[EXPORT_STATE](); 42 | for (const [block] of state.blocks) { 43 | if (block) { 44 | transfer.push(block); 45 | } 46 | } 47 | 48 | if (results[0]) { 49 | results[0] = { 50 | originalStack: results[0]?.stack, 51 | message: results[0]?.message, 52 | }; 53 | } 54 | 55 | return { results, state }; 56 | }); 57 | 58 | this.dynamicHandlers.set('reset', async (_txf) => { 59 | return this.plugin?.reset(); 60 | }); 61 | 62 | this.dynamicHandlers.set('getExports', async (_txf) => { 63 | return this.plugin?.getExports(); 64 | }); 65 | 66 | this.dynamicHandlers.set('getImports', async (_txf) => { 67 | return this.plugin?.getImports(); 68 | }); 69 | 70 | this.dynamicHandlers.set('functionExists', async (_txf, name) => { 71 | return this.plugin?.functionExists(name); 72 | }); 73 | } 74 | 75 | async handleMessage(ev: any) { 76 | switch (ev.type) { 77 | case 'init': 78 | return await this.handleInit(ev); 79 | case 'invoke': 80 | return await this.handleInvoke(ev); 81 | } 82 | } 83 | 84 | async handleInvoke(ev: { handler: string; args: any[] }) { 85 | const handler = this.dynamicHandlers.get(ev.handler); 86 | if (!handler) { 87 | return this.port.postMessage({ 88 | type: 'return', 89 | result: [`no handler registered for ${ev.handler}`, null], 90 | }); 91 | } 92 | 93 | const transfer: any[] = []; 94 | const results = await handler(transfer, ...(ev.args || [])).then( 95 | (ok) => [null, ok], 96 | (err) => [err, null], 97 | ); 98 | 99 | if (results[0]) { 100 | results[0] = { 101 | originalStack: results[0]?.stack, 102 | message: results[0]?.message, 103 | }; 104 | } 105 | 106 | return this.port.postMessage( 107 | { 108 | type: 'return', 109 | results, 110 | }, 111 | transfer, 112 | ); 113 | } 114 | 115 | async handleInit( 116 | ev: InternalConfig & { 117 | type: string; 118 | names: string[]; 119 | modules: WebAssembly.Module[]; 120 | sharedData: SharedArrayBuffer; 121 | functions: { [name: string]: string[] }; 122 | }, 123 | ) { 124 | this.sharedData = ev.sharedData; 125 | this.sharedDataView = new DataView(ev.sharedData); 126 | this.hostFlag = new Int32Array(this.sharedData); 127 | 128 | const functions = Object.fromEntries( 129 | Object.entries(ev.functions).map(([namespace, funcs]) => { 130 | return [ 131 | namespace, 132 | Object.fromEntries( 133 | funcs.map((funcName) => { 134 | return [ 135 | funcName, 136 | (context: CallContext, ...args: any[]) => this.callHost(context, namespace, funcName, args), 137 | ]; 138 | }), 139 | ), 140 | ]; 141 | }), 142 | ); 143 | 144 | const { type: _, modules, functions: __, ...opts } = ev; 145 | 146 | const logLevel = (level: string) => (message: string) => this.port.postMessage({ type: 'log', level, message }); 147 | 148 | // TODO: we're using non-blocking log functions here; to properly preserve behavior we 149 | // should invoke these and wait on the host to return. 150 | const logger = Object.fromEntries( 151 | ['info', 'debug', 'warn', 'error', 'trace'].map((lvl) => [lvl, logLevel(lvl)]), 152 | ) as unknown as Console; 153 | 154 | this.context = new CallContext(ArrayBuffer, logger, ev.logLevel, ev.config, ev.memory); 155 | 156 | // TODO: replace our internal fetch and logger 157 | this.plugin = await _createForegroundPlugin( 158 | { ...opts, functions, fetch, logger, executingInWorker: true } as InternalConfig, 159 | ev.names, 160 | modules, 161 | this.context, 162 | ); 163 | 164 | this.port.postMessage({ type: 'ready' }); 165 | } 166 | 167 | callHost(context: CallContext, namespace: string, func: string, args: (number | bigint)[]): number | bigint | void { 168 | if (!this.hostFlag) { 169 | throw new Error('attempted to call host before receiving shared array buffer'); 170 | } 171 | Atomics.store(this.hostFlag, 0, SAB_BASE_OFFSET); 172 | 173 | const state = context[EXPORT_STATE](); 174 | this.port.postMessage({ 175 | type: 'invoke', 176 | namespace, 177 | func, 178 | args, 179 | state, 180 | }); 181 | 182 | const reader = new RingBufferReader(this.sharedData as SharedArrayBuffer); 183 | const blocks: [ArrayBufferLike | null, number][] = []; 184 | let retval: any; 185 | 186 | do { 187 | const sectionType = reader.readUint8(); 188 | switch (sectionType) { 189 | // end 190 | case SharedArrayBufferSection.End: 191 | state.blocks = blocks; 192 | context[IMPORT_STATE](state); 193 | reader.close(); 194 | return retval; 195 | 196 | case SharedArrayBufferSection.RetI64: 197 | retval = reader.readUint64(); 198 | break; 199 | 200 | case SharedArrayBufferSection.RetF64: 201 | retval = reader.readFloat64(); 202 | break; 203 | 204 | case SharedArrayBufferSection.RetVoid: 205 | retval = undefined; 206 | break; 207 | 208 | case SharedArrayBufferSection.Block: 209 | { 210 | const index = reader.readUint32(); 211 | const len = reader.readUint32(); 212 | if (!len) { 213 | blocks.push([null, index]); 214 | } else { 215 | const output = new Uint8Array(len); 216 | reader.read(output); 217 | blocks.push([output.buffer, index]); 218 | } 219 | } 220 | break; 221 | 222 | // a common invalid state: 223 | // case 0: 224 | // console.log({retval, input: reader.input, reader }) 225 | default: 226 | throw new Error( 227 | `invalid section type="${sectionType}" at position ${reader.position}; please open an issue (https://github.com/extism/js-sdk/issues/new?title=shared+array+buffer+bad+section+type+${sectionType}&labels=bug)`, 228 | ); 229 | } 230 | } while (1); 231 | } 232 | } 233 | 234 | new Reactor(parentPort); 235 | 236 | // This controls how frequently we "release" control from the Atomic; anecdotally 237 | // this appears to help with stalled wait() on Bun. 238 | const MAX_WAIT = 500; 239 | 240 | class RingBufferReader { 241 | input: SharedArrayBuffer; 242 | flag: Int32Array; 243 | inputOffset: number; 244 | scratch: ArrayBuffer; 245 | scratchView: DataView; 246 | position: number; 247 | #available: number; 248 | 249 | static SAB_IDX = 0; 250 | 251 | constructor(input: SharedArrayBuffer) { 252 | this.input = input; 253 | this.inputOffset = SAB_BASE_OFFSET; 254 | this.flag = new Int32Array(this.input); 255 | this.scratch = new ArrayBuffer(8); 256 | this.scratchView = new DataView(this.scratch); 257 | this.position = 0; 258 | this.#available = 0; 259 | this.wait(); 260 | } 261 | 262 | close() { 263 | this.signal(); 264 | Atomics.store(this.flag, 0, SAB_BASE_OFFSET); 265 | } 266 | 267 | wait() { 268 | let value = SAB_BASE_OFFSET; 269 | do { 270 | value = Atomics.load(this.flag, 0); 271 | if (value === SAB_BASE_OFFSET) { 272 | const result = Atomics.wait(this.flag, 0, SAB_BASE_OFFSET, MAX_WAIT); 273 | if (result === 'timed-out') { 274 | continue; 275 | } 276 | } 277 | } while (value <= SAB_BASE_OFFSET); 278 | 279 | this.#available = Atomics.load(this.flag, 0); 280 | 281 | this.inputOffset = SAB_BASE_OFFSET; 282 | } 283 | 284 | get available() { 285 | return this.#available - this.inputOffset; 286 | } 287 | 288 | signal() { 289 | Atomics.store(this.flag, 0, SAB_BASE_OFFSET); 290 | Atomics.notify(this.flag, 0, 1); 291 | } 292 | 293 | pull() { 294 | this.signal(); 295 | this.wait(); 296 | } 297 | 298 | read(output: Uint8Array) { 299 | this.position += output.byteLength; 300 | if (output.byteLength < this.available) { 301 | output.set(new Uint8Array(this.input).subarray(this.inputOffset, this.inputOffset + output.byteLength)); 302 | this.inputOffset += output.byteLength; 303 | return; 304 | } 305 | 306 | let outputOffset = 0; 307 | let extent = this.available; 308 | // read ::= [outputoffset, inputoffset, extent] 309 | // firstread = [this.outputOffset, 0, this.available - this.outputOffset] 310 | do { 311 | output.set(new Uint8Array(this.input).subarray(this.inputOffset, this.inputOffset + extent), outputOffset); 312 | outputOffset += extent; 313 | this.inputOffset += extent; 314 | if (outputOffset === output.byteLength) { 315 | break; 316 | } 317 | 318 | if (this.available < 0) { 319 | break; 320 | } 321 | 322 | this.pull(); 323 | extent = Math.min(Math.max(this.available, 0), output.byteLength - outputOffset); 324 | } while (outputOffset !== output.byteLength); 325 | } 326 | 327 | readUint8(): number { 328 | this.read(new Uint8Array(this.scratch).subarray(0, 1)); 329 | return this.scratchView.getUint8(0); 330 | } 331 | 332 | readUint32(): number { 333 | this.read(new Uint8Array(this.scratch).subarray(0, 4)); 334 | return this.scratchView.getUint32(0, true); 335 | } 336 | 337 | readUint64(): bigint { 338 | this.read(new Uint8Array(this.scratch)); 339 | return this.scratchView.getBigUint64(0, true); 340 | } 341 | 342 | readFloat64(): number { 343 | this.read(new Uint8Array(this.scratch)); 344 | return this.scratchView.getFloat64(0, true); 345 | } 346 | } 347 | -------------------------------------------------------------------------------- /tests/data/test.txt: -------------------------------------------------------------------------------- 1 | hello world! -------------------------------------------------------------------------------- /tests/playwright.test.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test('tape succeeds', async ({ page }) => { 4 | const finished = new Promise(resolve => { 5 | page.on('console', msg => { 6 | if (/^# (not )?ok/.test(msg.text())) { 7 | resolve() 8 | } 9 | }) 10 | }) 11 | 12 | page.on('console', msg => console.log('>', msg.text())) 13 | page.on('pageerror', err => { 14 | console.error(err); 15 | expect(err).toBeNull(); 16 | }); 17 | 18 | page.on('console', msg => { 19 | expect(msg.text()).not.toMatch(/^not ok/) 20 | }); 21 | await page.goto('http://localhost:8124/dist/tests/browser/'); 22 | await finished 23 | }); 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "module": "esnext", 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "allowImportingTsExtensions": true, 8 | "declaration": true, 9 | "strict": true, 10 | "skipLibCheck": true, 11 | "allowJs": true, 12 | "typeRoots": ["./types", "node_modules/@types"] 13 | }, 14 | "exclude": ["node_modules", "dist"], 15 | "files": ["./src/mod.ts"] 16 | } 17 | -------------------------------------------------------------------------------- /types/deno/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'npm:minimatch@9.0.4' { 2 | export * as minimatch from 'minimatch'; 3 | } 4 | 5 | declare module 'jsr:@std/path@0.223.0/relative' { 6 | export function relative(base: string, relative: string): string; 7 | } 8 | 9 | declare module 'jsr:@std/path@0.223.0/resolve' { 10 | export function resolve(base: string, relative: string): string; 11 | } 12 | 13 | declare namespace Deno { 14 | interface DirEntry { 15 | name: string 16 | isFile: boolean 17 | isDirectory: boolean 18 | isSymlink: boolean 19 | } 20 | } 21 | 22 | declare const Deno: any 23 | -------------------------------------------------------------------------------- /wasm/02-var-reflected.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/extism/js-sdk/bec25c60d0ad32ce06d3934563fd8560f486f323/wasm/02-var-reflected.wasm -------------------------------------------------------------------------------- /wasm/alloc.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/extism/js-sdk/bec25c60d0ad32ce06d3934563fd8560f486f323/wasm/alloc.wasm -------------------------------------------------------------------------------- /wasm/circular-lhs.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/extism/js-sdk/bec25c60d0ad32ce06d3934563fd8560f486f323/wasm/circular-lhs.wasm -------------------------------------------------------------------------------- /wasm/circular-rhs.wasm: -------------------------------------------------------------------------------- 1 | asm`lhsmul_twomemoryadd_one 2 |  Aj namealphain -------------------------------------------------------------------------------- /wasm/circular.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/extism/js-sdk/bec25c60d0ad32ce06d3934563fd8560f486f323/wasm/circular.wasm -------------------------------------------------------------------------------- /wasm/code-functions.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/extism/js-sdk/bec25c60d0ad32ce06d3934563fd8560f486f323/wasm/code-functions.wasm -------------------------------------------------------------------------------- /wasm/code.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/extism/js-sdk/bec25c60d0ad32ce06d3934563fd8560f486f323/wasm/code.wasm -------------------------------------------------------------------------------- /wasm/config.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/extism/js-sdk/bec25c60d0ad32ce06d3934563fd8560f486f323/wasm/config.wasm -------------------------------------------------------------------------------- /wasm/consume.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/extism/js-sdk/bec25c60d0ad32ce06d3934563fd8560f486f323/wasm/consume.wasm -------------------------------------------------------------------------------- /wasm/corpus/00-circular-lhs.wat: -------------------------------------------------------------------------------- 1 | ;; source for wasm/circular-lhs.wasm 2 | (module 3 | (import "rhs" "add_one" (func $alpha (param i32) (result i32))) 4 | (func (export "mul_two") (param $in i32) (result i32) 5 | (i32.gt_u (local.get $in) (i32.const 100)) 6 | (if (result i32) 7 | (then 8 | (local.get $in) 9 | ) 10 | (else 11 | (call $alpha (i32.mul (local.get $in) (i32.const 2))) 12 | ) 13 | ) 14 | ) 15 | ) 16 | -------------------------------------------------------------------------------- /wasm/corpus/01-circular-rhs.wat: -------------------------------------------------------------------------------- 1 | ;; source for wasm/circular-rhs.wasm 2 | (module 3 | (import "lhs" "mul_two" (func $alpha (param i32) (result i32))) 4 | (memory (export "memory") 0) 5 | (func (export "add_one") (param $in i32) (result i32) 6 | (call $alpha (i32.add (local.get $in) (i32.const 1))) 7 | ) 8 | ) 9 | 10 | -------------------------------------------------------------------------------- /wasm/corpus/02-var-reflected.wat: -------------------------------------------------------------------------------- 1 | (module 2 | (import "extism:host/env" "store_u64" (func $store_u64 (param i64 i64))) 3 | (import "extism:host/env" "store_u8" (func $store_u8 (param i64 i32))) 4 | (import "extism:host/env" "alloc" (func $alloc (param i64) (result i64))) 5 | (import "extism:host/env" "var_set" (func $var_set (param i64 i64))) 6 | (import "extism:host/env" "var_get" (func $var_get (param i64) (result i64))) 7 | (import "user" "test" (func $test (param i64))) 8 | 9 | (memory $mem (export "memory") 1) 10 | (data (memory $mem) (offset i32.const 0) "hi there") 11 | (func (export "test") (result i32) 12 | (local $var_offset i64) 13 | (local.set $var_offset (call $alloc (i64.const 8))) 14 | (call $store_u64 (local.get $var_offset) (i64.load (i32.const 0))) 15 | 16 | (call $var_set (local.get $var_offset) (local.get $var_offset)) 17 | 18 | (call $test (call $var_get (local.get $var_offset))) 19 | (i32.const 0) 20 | ) 21 | ) 22 | -------------------------------------------------------------------------------- /wasm/corpus/circular.wat: -------------------------------------------------------------------------------- 1 | (module 2 | (import "lhs" "mul_two" (func $mul_two (param i32) (result i32))) 3 | (import "extism:host/env" "store_u64" (func $store_u64 (param i64 i64))) 4 | (import "extism:host/env" "alloc" (func $alloc (param i64) (result i64))) 5 | (import "extism:host/env" "output_set" (func $output_set (param i64 i64))) 6 | (memory (import "rhs" "memory") 0) 7 | 8 | (func (export "encalculate") (result i32) 9 | (local $output i64) 10 | (local.set $output (call $alloc (i64.const 8))) 11 | 12 | (call $store_u64 (local.get $output) (i64.extend_i32_u (call $mul_two (i32.const 1)))) 13 | (call $output_set (local.get $output) (i64.const 8)) 14 | i32.const 0 15 | ) 16 | ) 17 | -------------------------------------------------------------------------------- /wasm/corpus/fs-link.wat: -------------------------------------------------------------------------------- 1 | (module 2 | ;;(import "side" "memory" (memory $memory 0)) 3 | 4 | (import "side" "run_test" (func $run_test (result i32))) 5 | (import "extism:host/env" "store_u64" (func $store_u64 (param i64 i64))) 6 | (import "extism:host/env" "alloc" (func $alloc (param i64) (result i64))) 7 | (import "extism:host/env" "output_set" (func $output_set (param i64 i64))) 8 | (memory (export "memory") 0) 9 | (func (export "run_test") (result i32) 10 | (call $run_test) 11 | ) 12 | ) 13 | -------------------------------------------------------------------------------- /wasm/corpus/loop-forever-init.wat: -------------------------------------------------------------------------------- 1 | (module 2 | (func $loop (export "loop") 3 | (loop $loop (br $loop)) 4 | ) 5 | (start $loop) 6 | ) 7 | -------------------------------------------------------------------------------- /wasm/corpus/loop-forever.wat: -------------------------------------------------------------------------------- 1 | (module 2 | (func (export "loop") 3 | (loop $loop (br $loop)) 4 | ) 5 | ) 6 | -------------------------------------------------------------------------------- /wasm/exit.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/extism/js-sdk/bec25c60d0ad32ce06d3934563fd8560f486f323/wasm/exit.wasm -------------------------------------------------------------------------------- /wasm/fail.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/extism/js-sdk/bec25c60d0ad32ce06d3934563fd8560f486f323/wasm/fail.wasm -------------------------------------------------------------------------------- /wasm/fs-link.wasm: -------------------------------------------------------------------------------- 1 | asm``~~`~~bsiderun_testextism:host/env store_u64extism:host/envallocextism:host/env 2 | output_setmemoryrun_test 3 |  0name)run_test store_u64alloc 4 | output_set -------------------------------------------------------------------------------- /wasm/fs.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/extism/js-sdk/bec25c60d0ad32ce06d3934563fd8560f486f323/wasm/fs.wasm -------------------------------------------------------------------------------- /wasm/hello.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/extism/js-sdk/bec25c60d0ad32ce06d3934563fd8560f486f323/wasm/hello.wasm -------------------------------------------------------------------------------- /wasm/hello_haskell.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/extism/js-sdk/bec25c60d0ad32ce06d3934563fd8560f486f323/wasm/hello_haskell.wasm -------------------------------------------------------------------------------- /wasm/http.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/extism/js-sdk/bec25c60d0ad32ce06d3934563fd8560f486f323/wasm/http.wasm -------------------------------------------------------------------------------- /wasm/http_headers.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/extism/js-sdk/bec25c60d0ad32ce06d3934563fd8560f486f323/wasm/http_headers.wasm -------------------------------------------------------------------------------- /wasm/input_offset.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/extism/js-sdk/bec25c60d0ad32ce06d3934563fd8560f486f323/wasm/input_offset.wasm -------------------------------------------------------------------------------- /wasm/log.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/extism/js-sdk/bec25c60d0ad32ce06d3934563fd8560f486f323/wasm/log.wasm -------------------------------------------------------------------------------- /wasm/loop-forever-init.wasm: -------------------------------------------------------------------------------- 1 | asm`loop 2 | @ nameloop loop -------------------------------------------------------------------------------- /wasm/loop-forever.wasm: -------------------------------------------------------------------------------- 1 | asm`loop 2 | @ name loop -------------------------------------------------------------------------------- /wasm/memory.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/extism/js-sdk/bec25c60d0ad32ce06d3934563fd8560f486f323/wasm/memory.wasm -------------------------------------------------------------------------------- /wasm/reflect.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/extism/js-sdk/bec25c60d0ad32ce06d3934563fd8560f486f323/wasm/reflect.wasm -------------------------------------------------------------------------------- /wasm/sleep.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/extism/js-sdk/bec25c60d0ad32ce06d3934563fd8560f486f323/wasm/sleep.wasm -------------------------------------------------------------------------------- /wasm/upper.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/extism/js-sdk/bec25c60d0ad32ce06d3934563fd8560f486f323/wasm/upper.wasm -------------------------------------------------------------------------------- /wasm/var.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/extism/js-sdk/bec25c60d0ad32ce06d3934563fd8560f486f323/wasm/var.wasm -------------------------------------------------------------------------------- /wasm/wasistdout.wasm: -------------------------------------------------------------------------------- 1 | asm ``#wasi_snapshot_preview1fd_writememory say_hello 2 | AA6AA 6AAAA A hello world 3 | namefd_writemain --------------------------------------------------------------------------------