Skip to content

Writing WASM Plugins

WebAssembly (WASM) plugins offer a powerful alternative to native Go plugins, providing cross-platform compatibility and sandboxed execution. Unlike native .so plugins, WASM plugins:

  • Run anywhere - Single .wasm binary works on any OS/architecture
  • No version matching - No need to match Go versions or dependency versions
  • Sandboxed execution - WASM provides memory-safe, isolated execution
  • Multi-language support - Write plugins in TypeScript, Go, Rust, or any WASM-compatible language

All WASM plugins must export these functions:

ExportSignatureDescription
malloc(size: u32) -> u32Allocate memory for host to write data
free(ptr: u32) or (ptr: u32, size: u32)Free allocated memory (Rust requires size for dealloc)
get_name() -> u64Returns packed ptr+len of plugin name
init(config_ptr, config_len: u32) -> i32Initialize with config (0 = success)
http_pre_hook(input_ptr, input_len: u32) -> u64HTTP transport pre-hook (request interception)
http_post_hook(input_ptr, input_len: u32) -> u64HTTP transport post-hook (non-streaming response interception)
http_stream_chunk_hook(input_ptr, input_len: u32) -> u64HTTP streaming chunk hook (per-chunk interception for streaming responses)
pre_hook(input_ptr, input_len: u32) -> u64Pre-request hook
post_hook(input_ptr, input_len: u32) -> u64Post-response hook
cleanup() -> i32Cleanup resources (0 = success)

Functions returning data use a packed u64 format:

  • Upper 32 bits: pointer to data in WASM memory
  • Lower 32 bits: length of data

All complex data is exchanged as JSON strings. The host allocates memory using malloc, writes JSON data, and passes pointers to the plugin functions.

Choose your preferred language:

Install Node.js (v18+) for AssemblyScript compilation:

macOS:

Terminal window
brew install node

Linux (Ubuntu/Debian):

Terminal window
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
my-wasm-plugin/
├── assembly/
│ ├── index.ts # Plugin implementation
│ ├── memory.ts # Memory management utilities
│ ├── types.ts # Type definitions
│ └── tsconfig.json # AssemblyScript config
├── package.json
└── Makefile
Terminal window
mkdir my-wasm-plugin && cd my-wasm-plugin
npm init -y
npm install --save-dev assemblyscript json-as
npx asinit .

Create assembly/index.ts:

import { JSON } from 'json-as'
// Memory management (simplified)
let heap: ArrayBuffer = new ArrayBuffer(65536)
let heapOffset: u32 = 0
export function malloc(size: u32): u32 {
const ptr = heapOffset
heapOffset += size
return ptr
}
export function free(ptr: u32): void {
// Simple allocator - no-op for free
}
function readString(ptr: u32, len: u32): string {
const bytes = new Uint8Array(len)
for (let i: u32 = 0; i < len; i++) {
bytes[i] = load<u8>(ptr + i)
}
return String.UTF8.decode(bytes.buffer)
}
function writeString(str: string): u64 {
const encoded = String.UTF8.encode(str)
const bytes = Uint8Array.wrap(encoded)
const ptr = malloc(bytes.length)
for (let i = 0; i < bytes.length; i++) {
store<u8>(ptr + i, bytes[i])
}
// Pack pointer (upper 32 bits) and length (lower 32 bits)
return (u64(ptr) << 32) | u64(bytes.length)
}
// Plugin configuration
let pluginConfig: string = ''
export function get_name(): u64 {
return writeString('my-typescript-wasm-plugin')
}
export function init(configPtr: u32, configLen: u32): i32 {
pluginConfig = readString(configPtr, configLen)
return 0 // Success
}
export function http_pre_hook(inputPtr: u32, inputLen: u32): u64 {
const input = readString(inputPtr, inputLen)
// Parse and modify as needed
// For pass-through, return the input with has_response: false
const output = '{"context":{},"request":null,"response":null,"has_response":false,"error":""}'
return writeString(output)
}
export function http_post_hook(inputPtr: u32, inputLen: u32): u64 {
const input = readString(inputPtr, inputLen)
// Parse input which includes both request and response
// For pass-through, just return context and empty error
const output = '{"context":{},"error":""}'
return writeString(output)
}
// Input structure for http_stream_chunk_hook
@json
class StreamChunkInput {
context: JSON.Obj = new JSON.Obj()
request: JSON.Raw = new JSON.Raw('null')
chunk: JSON.Raw = new JSON.Raw('null') // DeepIntShieldStreamChunk as JSON (see below)
}
// Output structure for http_stream_chunk_hook
@json
class StreamChunkOutput {
context: JSON.Obj = new JSON.Obj()
chunk: JSON.Raw = new JSON.Raw('null') // DeepIntShieldStreamChunk as JSON, or null to skip
has_chunk: bool = false
skip: bool = false
error: string = ''
}
// DeepIntShieldStreamChunk is one of: DeepIntShieldChatResponse, DeepIntShieldTextCompletionResponse,
// DeepIntShieldResponsesStreamResponse, DeepIntShieldSpeechStreamResponse, DeepIntShieldTranscriptionStreamResponse,
// DeepIntShieldImageGenerationStreamResponse, or DeepIntShieldError.
// For chat completions, the chunk JSON looks like:
// {
// "id": "chatcmpl-xxx",
// "object": "chat.completion.chunk",
// "created": 1234567890,
// "model": "gpt-4",
// "choices": [{"index": 0, "delta": {"content": "Hello"}, "finish_reason": null}],
// ...
// }
export function http_stream_chunk_hook(inputPtr: u32, inputLen: u32): u64 {
const inputJson = readString(inputPtr, inputLen)
const input = JSON.parse<StreamChunkInput>(inputJson)
// For pass-through, return chunk unchanged with skip: false
// To skip a chunk, set skip: true and chunk: null
const output = new StreamChunkOutput()
output.context = input.context
output.chunk = input.chunk
output.has_chunk = true
output.skip = false
output.error = ''
return writeString(JSON.stringify(output))
}
export function pre_hook(inputPtr: u32, inputLen: u32): u64 {
const input = readString(inputPtr, inputLen)
// Parse and modify as needed
// For pass-through, return with has_short_circuit: false
const output = '{"context":{},"request":null,"short_circuit":null,"has_short_circuit":false,"error":""}'
return writeString(output)
}
export function post_hook(inputPtr: u32, inputLen: u32): u64 {
const input = readString(inputPtr, inputLen)
// Parse and modify as needed
// For pass-through, return with has_error matching input
const output = '{"context":{},"response":null,"error":null,"has_error":false,"hook_error":""}'
return writeString(output)
}
export function cleanup(): i32 {
pluginConfig = ''
return 0 // Success
}

Add to package.json:

{
"scripts": {
"build": "asc assembly/index.ts -o build/plugin.wasm --runtime stub --optimize"
}
}

Build:

Terminal window
npm run build

Output: build/plugin.wasm

Input:

{
"context": {
"request_id": "abc-123"
},
"request": {
"method": "POST",
"path": "/v1/chat/completions",
"headers": { "content-type": "application/json" },
"query": {},
"body": "<base64-encoded>"
}
}

Output:

{
"context": { "request_id": "abc-123", "custom_key": "value" },
"request": { ... },
"response": null,
"has_response": false,
"error": ""
}

To short-circuit with a response:

{
"context": { ... },
"request": null,
"response": {
"status_code": 200,
"headers": { "Content-Type": "application/json" },
"body": "<base64-encoded>"
},
"has_response": true,
"error": ""
}

Called after the response is received from the LLM provider. Receives both the original request and the response.

Input:

{
"context": {
"request_id": "abc-123",
"custom_key": "value"
},
"request": {
"method": "POST",
"path": "/v1/chat/completions",
"headers": { "content-type": "application/json" },
"query": {},
"body": "<base64-encoded>"
},
"response": {
"status_code": 200,
"headers": { "content-type": "application/json" },
"body": "<base64-encoded>"
}
}

Output:

{
"context": { "request_id": "abc-123", "custom_key": "value", "post_processed": true },
"error": ""
}

Called for each chunk during streaming responses, BEFORE the chunk is written to the client. This hook allows plugins to modify or filter streaming chunks in real-time.

Input:

{
"context": {
"request_id": "abc-123",
"custom_key": "value"
},
"request": {
"method": "POST",
"path": "/v1/chat/completions",
"headers": { "content-type": "application/json" },
"query": {},
"body": "<base64-encoded>"
},
"chunk": {
"id": "chatcmpl-xxx",
"object": "chat.completion.chunk",
"created": 1234567890,
"model": "gpt-4",
"choices": [{"index": 0, "delta": {"content": "Hello"}, "finish_reason": null}]
}
}

The chunk field contains a DeepIntShieldStreamChunk struct serialized as JSON. It will contain the data from whichever response type is active:

  • Chat completion streaming: {"id":"...","object":"chat.completion.chunk","choices":[...],"model":"..."}
  • Text completion streaming: {"id":"...","choices":[...]}
  • Responses API streaming: {"type":"...","item":...}
  • Speech/Transcription/Image streaming: respective response fields
  • Error: {"error":{"type":"...","message":"..."}}

It does NOT include SSE framing (no data: prefix or \n\n suffix).

Output (pass through unchanged):

{
"context": { "request_id": "abc-123", "custom_key": "value" },
"chunk": {
"id": "chatcmpl-xxx",
"object": "chat.completion.chunk",
"created": 1234567890,
"model": "gpt-4",
"choices": [{"index": 0, "delta": {"content": "Hello"}, "finish_reason": null}]
},
"has_chunk": true,
"skip": false,
"error": ""
}

Output (skip/filter chunk):

{
"context": { "request_id": "abc-123" },
"chunk": null,
"has_chunk": false,
"skip": true,
"error": ""
}

Output (modify chunk):

{
"context": { "request_id": "abc-123" },
"chunk": {
"id": "chatcmpl-xxx",
"object": "chat.completion.chunk",
"created": 1234567890,
"model": "gpt-4",
"choices": [{"index": 0, "delta": {"content": "Modified!"}, "finish_reason": null}]
},
"has_chunk": true,
"skip": false,
"error": ""
}

Input:

{
"context": { "request_id": "abc-123" },
"request": {
"provider": "openai",
"model": "gpt-4",
"input": [{ "role": "user", "content": "Hello" }],
"params": { "temperature": 0.7 }
}
}

Output:

{
"context": { "request_id": "abc-123", "plugin_processed": true },
"request": { ... },
"short_circuit": null,
"has_short_circuit": false,
"error": ""
}

To short-circuit with a response:

{
"context": { ... },
"request": null,
"short_circuit": {
"response": {
"chat_response": {
"id": "mock-123",
"model": "gpt-4",
"choices": [{ "index": 0, "message": { "role": "assistant", "content": "Mock response" } }]
}
}
},
"has_short_circuit": true,
"error": ""
}

Input:

{
"context": { "request_id": "abc-123" },
"response": {
"chat_response": {
"id": "chatcmpl-123",
"model": "gpt-4",
"choices": [{ "index": 0, "message": { "role": "assistant", "content": "Hello!" } }],
"usage": { "prompt_tokens": 5, "completion_tokens": 10, "total_tokens": 15 }
}
},
"error": {},
"has_error": false
}

Output:

{
"context": { "request_id": "abc-123", "post_processed": true },
"response": { ... },
"error": {},
"has_error": false,
"hook_error": ""
}

Configure your WASM plugin in DeepIntShield’s config.json:

{
"plugins": [
{
"path": "/path/to/plugin.wasm",
"name": "my-wasm-plugin",
"enabled": true,
"config": {
"custom_option": "value"
}
}
]
}

You can also load plugins from URLs:

{
"plugins": [
{
"path": "https://example.com/plugins/my-plugin.wasm",
"name": "my-wasm-plugin",
"enabled": true
}
]
}

WASM plugins have some trade-offs compared to native Go plugins:

AspectNative (.so)WASM
PerformanceFastest (in-process)JSON serialization overhead
Cross-platformBuild per platformSingle binary everywhere
Version matchingExact Go/package match requiredNo version requirements
MemoryShared process memoryLinear memory (limited)
LanguagesGo onlyTypeScript, Go, Rust, etc.
DebuggingFull Go toolingLimited debugging support
SecurityFull process accessSandboxed execution

Complete hello-world examples are available in the DeepIntShield repository:

Error: failed to instantiate WASM module

Solution: Ensure all required exports are present. Use a WASM inspection tool:

Terminal window
# List exports
wasm-objdump -x plugin.wasm | grep -A 20 "Export"

Error: out of memory or invalid memory access

Solution:

  • Increase heap size in your allocator
  • Ensure you’re freeing memory after use
  • Check for memory leaks in long-running plugins

Error: failed to parse input JSON

Solution:

  • Validate your JSON structures match expected schemas
  • Handle optional/nullable fields properly
  • Add error logging to identify malformed data

Error: package not supported by TinyGo

Solution: TinyGo doesn’t support all Go standard library packages. Avoid:

  • reflect (limited support)
  • net/http (use raw JSON instead)
  • Complex generics

Error: cannot find -lc

Solution: For wasm32-unknown-unknown target, don’t link to libc. Ensure your Cargo.toml doesn’t require native dependencies.