Scripting API Reference
RelayCore embeds a Deno/V8 runtime for intercepting and modifying traffic via globalThis hook functions. The scripting engine supports both JavaScript and TypeScript.
Wire semantics (0.13.1+): returning a flow from onResponseHeaders / onRequestHeaders means “mutate headers and continue streaming the upstream body”, not “replace the whole response with an empty body”. Async hooks pump the event loop; body reads are budgeted (~1 MiB) and timed out so a hung hook cannot stall the engine. Prefer sync header hooks; rewrite bodies from the body hooks by writing flow.layer.data.*.body.
Lifecycle Hooks
Scripts register hooks by defining named functions on globalThis. RelayCore 1.0 provides 11 hooks covering HTTP, WebSocket, and connection lifecycles:
| Hook | Trigger | Signature |
|---|---|---|
onRequestHeaders | After request headers received | (ctx, flow) => flow | undefined |
onRequest | Request body ready, before forward | (body, flow) => flow | undefined |
onResponseHeaders | After response headers received | (ctx, flow) => flow | undefined |
onResponse | Response body ready | (body, flow) => flow | undefined |
onWebSocketMessage | WebSocket frame received | (ctx, flow, message) => message | "DROP" | undefined |
onWebSocketStart | WebSocket handshake complete | (ctx, flow) => void |
onWebSocketEnd | WebSocket normal close | (ctx, flow, code, reason) => void |
onWebSocketError | WebSocket protocol/IO error | (ctx, flow, error) => void |
onConnect | Client TCP connection established | (ctx, conn) => {drop:true} | undefined |
onDisconnect | Connection closed | (ctx, conn, stats) => void |
onError | Any hook throws an error | (ctx, flow, error, stage) => void |
onWebSocketMessage must return the message, the string DROP, or nothing. Returning the flow is the header-hook shape and fails that call. A WebSocket flow's layer type is WebSocket, not Http. A throw tags only that flow with script-error; the next flow is unaffected, and loading another script replaces the hook.
Flow Object
The flow parameter contains the complete state of the current flow:
// Flow structure (script-side view)
{
id: string, // UUID
start_time: string, // ISO 8601
tags: string[],
network: {
client_ip: string,
server_ip: string,
protocol: "TCP" | "UDP",
tls: boolean,
sni: string | null,
},
// Rule bridge (RelayCore exclusive)
matched_rules: string[], // Rule IDs that matched
rule_variables: Record, // Variables set by SetVariable action
// Application-layer protocol data
layer: {
type: "Http" | "WebSocket",
data: {
// When type = "Http":
request: {
method: string,
url: string, // Full URL
version: string, // "HTTP/1.1" | "HTTP/2.0"
headers: [string, string][],
body: {
encoding: string,
size: number,
content: string, // encoding is utf-8 or base64
} | null,
cookies: [string, string][],
},
response: {
status: number,
status_text: string,
version: string,
headers: [string, string][],
body: { encoding, size, content } | null,
cookies: [string, string][],
} | null,
}
} Built-in Utilities relay.*
The global relay object provides commonly needed utility functions:
relay.uuid()
const id = relay.uuid(); // "550e8400-e29b-41d4-a716-446655440000" relay.hash(algorithm, data)
const sha = relay.hash("sha256", "hello");
// Supported: sha1, sha256, sha512, md5
// Returns hex string relay.base64.encode / relay.base64.decode
const b64 = relay.base64.encode("hello world"); // "aGVsbG8gd29ybGQ="
const raw = relay.base64.decode(b64); // Uint8Array relay.json.parseSafe / relay.json.stringifyPretty
const obj = relay.json.parseSafe('{"a":1}'); // {a:1} or undefined
const txt = relay.json.stringifyPretty(obj); // 2-space indent relay.env(name)
Reads whitelisted environment variables. Requires --script-env-allow CLI flag:
// CLI: relay-core run --script-env-allow=BACKEND_HOST
const host = relay.env("BACKEND_HOST"); // "api.example.com"
const miss = relay.env("SECRET"); // undefined (not whitelisted) relay.fetch(url)
Issues one HTTP GET. Disabled until --script-fetch-allow is set. There is no method, headers, or body argument. The result is { ok, status, body } or { ok: false, error }.
// CLI: relay-core run --script-fetch-allow=auth.internal
const resp = relay.fetch("http://auth.internal/verify");
if (!resp.ok) {
throw new Error(resp.error || ("HTTP " + resp.status));
} | Control | Description |
|---|---|
| Disabled by default | Without --script-fetch-allow, relay.fetch returns script fetch disabled |
| Allowlist | Comma-separated hostnames; * allows any host. Set it before the script is loaded |
| Anti-recursion | localhost / 127.0.0.1 / ::1 on the proxy's own listen port is rejected |
| Concurrency and timeout | Hard-coded: 8 in flight, 5s per request. No CLI flags for either |
RelayBody
The first argument of a body hook is a RelayBody. There is no relay.readBody. When the body is over budget, truncated is true and text() / json() throw.
globalThis.onResponse = async (body, flow) => {
const text = await body.text();
flow.layer.data.response.body = { encoding: "utf-8", size: text.length, content: text };
return flow;
}; Global State sharedState
globalThis.sharedState is a key-value store shared across all hooks within the same engine instance:
// Record the start time once request headers arrive
globalThis.onRequestHeaders = (ctx, flow) => {
sharedState.set(flow.id, { start: Date.now() });
return flow;
};
// Read in onResponse
globalThis.onResponse = (body, flow) => {
const meta = sharedState.get(flow.id);
if (meta) {
console.log(`Flow ${flow.id} took ${Date.now() - meta.start}ms`);
sharedState.delete(flow.id);
}
}; Graded Logging console.*
Five log levels map to Rust tracing:
console.log("Request received"); // tracing::info! — visible at runtime
console.info("Status OK"); // tracing::info!
console.warn("Rate limit hit"); // tracing::warn! — needs attention
console.error("Auth failed"); // tracing::error! — always visible
console.debug("Var value: " + x); // tracing::debug! — only with RUST_LOG=debug Connection Hooks
// Connection filtering — return {drop:true} to reject
globalThis.onConnect = (ctx, conn) => {
// conn: { id, client_addr, server_addr, tls_sni }
if (String(conn.client_addr).startsWith("192.168.1.")) {
return { drop: true };
}
};
// Connection statistics
globalThis.onDisconnect = (ctx, conn, stats) => {
// stats: { duration_ms, bytes_sent, bytes_received, flows_count }
console.log(`Connection ${conn.id}: ${stats.flows_count} flows`);
}; WebSocket Lifecycle
globalThis.onWebSocketStart = (ctx, flow) => {
console.log(`WS opened: ${flow.layer.data.request.url}`);
};
globalThis.onWebSocketEnd = (ctx, flow, code, reason) => {
console.log(`WS closed: ${code} ${reason}`);
};
globalThis.onWebSocketError = (ctx, flow, error) => {
console.error(`WS error: ${error}`);
}; Rule Bridge
RelayCore exclusive — scripts can observe rule engine execution results:
globalThis.onResponse = (body, flow) => {
// Read matched rules
if (flow.matched_rules.includes("rule-block-tracking")) {
flow.layer.data.response.headers.push(["X-Blocked-By", "RelayCore"]);
}
// Read variables set by rule engine SetVariable action
const count = flow.rule_variables["rate_limit_count"];
if (count && parseInt(count) > 100) {
console.warn(`Rate limit exceeded: ${count}`);
}
}; Script Management & Deployment
CLI Commands
# Load a script via HTTP API
curl -X POST http://127.0.0.1:8082/api/v1/script \
-H "Content-Type: application/json" \
-d '{"script": "globalThis.onRequestHeaders = (ctx, flow) => { console.log(flow.layer.data.request.url); }"}'
# MCP tool set_script takes the script source, not a file path
# Bundle mode: use npm packages
relay-core scripts init my-project # Scaffold
cd my-project && npm install jwt-decode # Install deps
relay-core scripts build # esbuild bundle
relay-core scripts dev # build + hot reload + watch Bundle Mode & npm Ecosystem
RelayCore's V8 isolate does not include an npm resolver. Bundle scripts with dependencies via esbuild:
// src/main.ts — import npm packages as usual
import jwtDecode from "jwt-decode";
import { XMLParser } from "fast-xml-parser";
globalThis.onRequestHeaders = (ctx, flow) => {
const auth = flow.layer.data.request.headers.find(h => h[0] === "authorization");
if (auth) {
const payload = jwtDecode(auth[1]);
console.log("JWT subject:", payload.sub);
}
};
// relay-core scripts build → dist/bundle.js (single IIFE file)
// Load bundle.js directly Script environment
# Allow env vars for relay.env() at startup
relay-core run --script-env-allow API_TOKEN,DEBUG Observability
Script execution metrics are automatically reported to Prometheus:
| Metric | Description |
|---|---|
relay_core_script_hook_duration_us{hook} | Per-hook execution duration |
relay_core_script_hook_invocations_total{hook} | Cumulative hook invocations |
relay_core_script_hook_errors_total{hook} | Cumulative hook errors |
relay_core_script_fetch_total | fetch attempts (no labels) |
relay_core_script_fetch_rejected_total | Rejected fetches |
relay_core_script_env_access_total | env lookups (name and value are not labels) |