Skip to main content
Beta Feature - Enterprise OnlyWASM plugins are currently in beta and only available in Bifrost Enterprise builds. Contact your account team for access.

Overview

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

Plugin Interface

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)

Return Value Format

Functions returning data use a packed u64 format:
  • Upper 32 bits: pointer to data in WASM memory
  • Lower 32 bits: length of data

Data Exchange

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

Getting Started

Choose your preferred language:

Prerequisites

Install Node.js (v18+) for AssemblyScript compilation:macOS:
brew install node
Linux (Ubuntu/Debian):
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs

Project Structure

my-wasm-plugin/
├── assembly/
│   ├── index.ts       # Plugin implementation
│   ├── memory.ts      # Memory management utilities
│   ├── types.ts       # Type definitions
│   └── tsconfig.json  # AssemblyScript config
├── package.json
└── Makefile

Step 1: Initialize Project

mkdir my-wasm-plugin && cd my-wasm-plugin
npm init -y
npm install --save-dev assemblyscript json-as
npx asinit .

Step 2: Implement the Plugin

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') // BifrostStreamChunk 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') // BifrostStreamChunk as JSON, or null to skip
  has_chunk: bool = false
  skip: bool = false
  error: string = ''
}

// BifrostStreamChunk is one of: BifrostChatResponse, BifrostTextCompletionResponse,
// BifrostResponsesStreamResponse, BifrostSpeechStreamResponse, BifrostTranscriptionStreamResponse,
// BifrostImageGenerationStreamResponse, or BifrostError.
// 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
}

Step 3: Build

Add to package.json:
{
  "scripts": {
    "build": "asc assembly/index.ts -o build/plugin.wasm --runtime stub --optimize"
  }
}
Build:
npm run build
Output: build/plugin.wasm

Hook Input/Output Structures

http_pre_hook

Header and Query Parameter Handling: Headers and query parameters in request.headers and request.query preserve the original casing sent by the client. When looking up headers/query params, you should perform case-insensitive comparisons in your WASM plugin code to handle various casing (e.g., Content-Type, content-type, CONTENT-TYPE).For Go native plugins, use the built-in CaseInsensitiveHeaderLookup() and CaseInsensitiveQueryLookup() helper methods.
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": ""
}

http_post_hook

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": ""
}
The http_post_hook is called in reverse order of http_pre_hook. Context values set in http_pre_hook are available in http_post_hook.
http_post_hook is NOT called for streaming responses. Use http_stream_chunk_hook instead.

http_stream_chunk_hook

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 BifrostStreamChunk 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).
Go Native vs WASM Plugins: In Go native plugins (.so), you work directly with *schemas.BifrostStreamChunk typed structs. In WASM plugins, this struct is serialized to JSON for crossing the WASM boundary. The underlying data structure is the same.
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": ""
}
The http_stream_chunk_hook is called in reverse order of http_pre_hook, same as other post-hooks.

pre_hook

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": ""
}

post_hook

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": ""
}

Configuration

Configure your WASM plugin in Bifrost’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
    }
  ]
}

Limitations vs Native Plugins

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

Source Code Reference

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

Troubleshooting

Module fails to load

Error: failed to instantiate WASM module Solution: Ensure all required exports are present. Use a WASM inspection tool:
# List exports
wasm-objdump -x plugin.wasm | grep -A 20 "Export"

Memory allocation errors

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

JSON parsing errors

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

Build errors (TinyGo)

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

Build errors (Rust)

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

Need Help?