Kynetra FX

Documentation

Polyglot WASM Handlers

@kynetra/fx-wasm lets you plug any WebAssembly module — Rust, Go, C++, or any other language that compiles to WASM — directly into a Kynetra FX route or middleware pipeline. The guest module communicates with the host over a lightweight JSON protocol, keeping the ABI simple enough to implement in any language without FFI bindings.

Overview#

Traditional polyglot setups require a separate microservice per language, adding a network hop and operational overhead. WASM handlers collapse that to zero: the guest module runs in the same process as the framework, sandboxed by the WASM runtime. The host serializes the incoming HTTP request as JSON, calls the guest's handle export, and deserializes the JSON response back into a Response object.

  • wasmHandler(source) — wraps a guest object as a route handler. The framework calls source.handle(requestJson) and returns the result as an HTTP response.
  • wasmMiddleware(source) — wraps a guest object as pipeline middleware. The guest can either return a response to short-circuit the pipeline, or return { "next": true } to let the next handler run.

Both functions accept the same source shape: any object (or factory function that returns an object) that has a handle(requestJson: string): string | Promise<string> method. A real wasm-pack module, a Rust-compiled WASM instance, and a plain TypeScript object all satisfy this contract — which makes unit testing trivial.

Tip

Use a plain TypeScript object to prototype the logic and verify the JSON protocol before writing the equivalent in Rust or Go. The host cannot tell the difference.

The guest contract#

The entire ABI is two JSON shapes. The host serializes the incoming request into a WasmRequest string and passes it to handle(). The guest deserializes it, does its work, and returns a WasmResponse string. Both shapes use only primitive types so they map cleanly to every target language.

WasmRequest

NameTypeDescription
methodstringHTTP method in upper-case: "GET", "POST", "PATCH", etc.
urlstringFull request URL including scheme, host, path, and query string.
pathstringURL pathname only, without query string. Equivalent to new URL(url).pathname.
queryRecord<string, string>Parsed query parameters as a flat key/value map. Multi-value keys take the last value.
headersRecord<string, string>Request headers as a flat key/value map with lower-cased header names.
bodystring | nullRequest body as a UTF-8 string, or null for methods that carry no body (GET, HEAD, DELETE).

WasmResponse

NameTypeDescription
statusnumber (optional)HTTP status code to return. Defaults to 200 when omitted.
headersRecord<string, string> (optional)Response headers to set. Merged with any headers the framework adds.
bodystring (optional)Response body as a UTF-8 string. Set content-type in headers to match the body format.
nextboolean (optional)Middleware only. When true the response fields are ignored and the pipeline continues to the next handler.

Note

The next field is only meaningful when using wasmMiddleware(). Returning { "next": true } from a wasmHandler() guest is treated as an empty 200 response.

wasmHandler — route handler#

Use wasmHandler() to register a guest module as the sole handler for a route. The example below uses a plain TypeScript object — the same interface a real WASM module implements via its exported handle function.

src/handlers/echo.ts
import { createFX } from '@kynetra/fx'
import { wasmHandler, wasmMiddleware } from '@kynetra/fx-wasm'
 
const app = createFX()
 
// Fake guest — same interface a real WASM module implements
const echoGuest = {
handle(requestJson: string): string {
const req = JSON.parse(requestJson)
return JSON.stringify({
status: 200,
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ echo: req.path, method: req.method }),
})
},
}
 
app.get('/echo', wasmHandler(echoGuest))

wasmHandler() calls source.handle() with the serialized WasmRequest and passes the parsed WasmResponse straight to ctx.body(). If the guest throws or returns malformed JSON, the framework responds with 502 FX_WASM_ERROR rather than crashing the process.

wasmMiddleware — pipeline middleware#

Use wasmMiddleware() when the guest should run as part of the middleware pipeline — for example to perform authentication, rate limiting, or request validation in a language other than TypeScript. The guest signals whether to continue the pipeline by returning { "next": true }.

src/middleware/auth-check.ts
const authCheckGuest = {
handle(requestJson: string): string {
const req = JSON.parse(requestJson)
const auth = req.headers['authorization'] ?? ''
if (auth.startsWith('Bearer ')) {
// Let the pipeline continue
return JSON.stringify({ next: true })
}
return JSON.stringify({
status: 401,
body: JSON.stringify({ error: { code: 'FX_UNAUTHENTICATED', message: 'Missing token' } }),
})
},
}
 
app.use(wasmMiddleware(authCheckGuest))
app.get('/protected', (ctx) => ctx.json({ secret: 42 }))

When the guest returns any response without next: true, the framework stops the pipeline and sends that response to the client. Downstream handlers — including the route handler — are not called.

Tip

Middleware guests that pass through (next: true) can still set response headers by returning { "next": true, "headers": { "x-guest-id": "rust-v1" } }. These headers are merged into the context before next() is called.

Error handling#

The WASM bridge catches all guest-side failures and normalises them into a structured error response so a misbehaving module cannot crash the worker process or leak an unformatted stack trace to the client.

  • 502 FX_WASM_ERROR — the guest threw an exception, returned a non-string value, or returned a string that could not be parsed as valid JSON. The original error is logged at warn level with a wasmError field.
  • 502 FX_WASM_TIMEOUT — the guest's handle() promise did not resolve within the configured timeout (default: 5 000 ms). Set { timeoutMs: number } in the options to adjust.
src/handlers/guarded.ts
// Guest that deliberately errors — framework responds 502 FX_WASM_ERROR
const faultyGuest = {
handle(_requestJson: string): string {
throw new Error('something went wrong inside the guest')
},
}
 
app.get('/risky', wasmHandler(faultyGuest))
// GET /risky → 502 { error: { code: 'FX_WASM_ERROR', message: '...' } }

Warning

The guest receives the full request body as a string. Avoid passing untrusted binary data directly — encode it as base64 before serializing into the JSON envelope and document the encoding convention in your guest module.

Using a real wasm-pack module#

When your Rust module is compiled with wasm-pack, the generated JavaScript bindings expose the exported function as a named export. Import the init function and the handler export, call init() once at startup, then wrap the export in a guest object.

src/handlers/rust-handler.ts
import init, { handle } from './pkg/my_module'
import { wasmHandler } from '@kynetra/fx-wasm'
 
// Call init() once — it compiles the WASM binary and sets up memory
await init()
 
const wasmGuest = { handle }
 
app.get('/wasm', wasmHandler(wasmGuest))

Rust guest example

The following Rust snippet shows the minimal implementation of the guest contract using the serde_json crate. Compile with wasm-pack build --target web.

src/lib.rs
use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;
 
#[derive(Deserialize)]
struct WasmRequest {
method: String,
path: String,
}
 
#[derive(Serialize)]
struct WasmResponse {
status: u16,
body: String,
}
 
#[wasm_bindgen]
pub fn handle(request_json: &str) -> String {
let req: WasmRequest = serde_json::from_str(request_json).unwrap();
let resp = WasmResponse {
status: 200,
body: format!(r#"{{"path":"{}","method":"{}"}}"#, req.path, req.method),
};
serde_json::to_string(&resp).unwrap()
}

Note

wasm-pack generates a pkg/ directory containing the compiled .wasm binary and JavaScript bindings. Import from ./pkg/my_module (or the package name you set in Cargo.toml). The init() call fetches and compiles the binary — do this once at module load time, not per-request.

Factory sources#

Instead of a plain object, wasmHandler() and wasmMiddleware() also accept a factory function. The factory is called once at registration time and the returned guest object is reused for every request. This is useful when the guest needs async initialisation (such as fetching a WASM binary from R2 or KV).

src/handlers/factory.ts
import { wasmHandler } from '@kynetra/fx-wasm'
 
async function buildGuest() {
const binary = await fetch('https://assets.example.com/module.wasm').then((r) => r.arrayBuffer())
const { instance } = await WebAssembly.instantiate(binary, {})
const handle = instance.exports.handle as (s: string) => string
return { handle }
}
 
// Factory receives the FX context at registration time
app.get('/dynamic', wasmHandler(await buildGuest()))