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.

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(ctx, flow, body) => action
onResponseHeadersAfter response headers received(ctx, flow) => flow | undefined
onResponseResponse body ready(ctx, flow, body) => action
onWebSocketMessageWebSocket frame received(ctx, flow, message) => message | 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

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,  // base64 encoded
        } | 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-cli 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, options?)

Make controlled outbound HTTP sub-requests. Disabled by default; requires --enable-script-fetch.

const resp = await relay.fetch("http://auth.internal/verify", {
  method: "POST",
  headers: { "Authorization": flow.layer.data.request.headers.find(h => h[0]==="authorization")?.[1] },
  body: JSON.stringify({ flow_id: flow.id }),
  timeout_ms: 2000,
});
// resp: { status, headers, body }
if (resp.status !== 200) {
  throw new Error("Auth failed");
}
ControlDescription
Disabled by defaultMust pass --enable-script-fetch
Allowlist--script-fetch-allow=host1,host2
Anti-recursionFetching own proxy port is rejected
Concurrency cap--script-fetch-max-concurrency=8
Timeout capHard limit 30s
AuditEvery fetch enters the Audit Trail

relay.readBody(flow)

const body = await relay.readBody(flow);
// For streaming bodies, returns accumulated content

Global State sharedState

globalThis.sharedState is a key-value store shared across all hooks within the same engine instance:

// Store in onRequest
globalThis.onRequest = (ctx, flow) => {
  sharedState.set(flow.id, { start: Date.now() });
  return flow;
};

// Read in onResponse
globalThis.onResponse = (ctx, 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_ip, client_port }
  if (conn.client_ip.startsWith("192.168.1.")) {
    return { drop: true };
  }
};

// Connection statistics
globalThis.onDisconnect = (ctx, conn, stats) => {
  // stats: { duration_ms, 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 = (ctx, 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.onRequest = (ctx, flow) => { console.log(flow.layer.data.request.url); }"}'

# MCP tool (via AI Agent)
mcp: relay-core set_script --script script.js

# 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.onRequest = (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-cli run --script-env-allow API_TOKEN,DEBUG

Observability

Script execution metrics are automatically reported to Prometheus:

MetricDescription
script_hook_duration_us{hook}Per-hook execution duration (histogram)
script_hook_invocations_total{hook}Cumulative hook invocations
script_hook_errors_total{hook}Cumulative hook errors
script_fetch_total{target,status}fetch call count
script_env_access_total{key}env access count (values not recorded)