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:

HookTriggerSignature
onRequestHeadersAfter request headers received(ctx, flow) => flow | undefined
onRequestRequest body ready, before forward(body, flow) => flow | undefined
onResponseHeadersAfter response headers received(ctx, flow) => flow | undefined
onResponseResponse body ready(body, flow) => flow | undefined
onWebSocketMessageWebSocket frame received(ctx, flow, message) => message | "DROP" | undefined
onWebSocketStartWebSocket handshake complete(ctx, flow) => void
onWebSocketEndWebSocket normal close(ctx, flow, code, reason) => void
onWebSocketErrorWebSocket protocol/IO error(ctx, flow, error) => void
onConnectClient TCP connection established(ctx, conn) => {drop:true} | undefined
onDisconnectConnection closed(ctx, conn, stats) => void
onErrorAny 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));
}
ControlDescription
Disabled by defaultWithout --script-fetch-allow, relay.fetch returns script fetch disabled
AllowlistComma-separated hostnames; * allows any host. Set it before the script is loaded
Anti-recursionlocalhost / 127.0.0.1 / ::1 on the proxy's own listen port is rejected
Concurrency and timeoutHard-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:

MetricDescription
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_totalfetch attempts (no labels)
relay_core_script_fetch_rejected_totalRejected fetches
relay_core_script_env_access_totalenv lookups (name and value are not labels)