Scripting Guide

This guide covers common patterns, best practices, and debugging techniques for RelayCore scripts.

Quick Start

// script.js — minimal script
globalThis.onRequest = (ctx, flow) => {
  console.log(`${flow.layer.data.request.method} ${flow.layer.data.request.url}`);
};

Load 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); }"}'

0.5.x API Notes

  • Flow shape: HTTP request/response live at flow.layer.data.request / flow.layer.data.response (not flow.request)
  • sharedState: use get(key) / set(key, value) / delete(key), not plain object properties
  • Rule variables: scripts run before rules in onRequestHeaders; read flow.rule_variables in onRequest or later hooks
  • Mock / direct responses: { action: "respond" } is not supported in 0.5.1 — use rule MockResponse (see Rule Guide)
  • Sandbox: no global URL — parse paths with a string helper (see §2)

Common Patterns

1. Auth Injection

globalThis.onRequestHeaders = (ctx, flow) => {
  if (flow.layer.data.request.url.includes("/api/")) {
    const existing = flow.layer.data.request.headers.find(h => h[0] === "authorization");
    if (!existing) {
      flow.layer.data.request.headers.push(["authorization", "Bearer ${getToken()}"]);
    }
  }
  return flow;
};

function getToken() {
  return relay.env("API_TOKEN") || "dev-token";
}

2. Mock Responses

In 0.5.1, prefer rule-engine MockResponse for mocks (see Rule Guide §2). The sandbox has no global URL object:

function pathname(url) {
  const start = url.indexOf("://");
  const slash = start >= 0 ? url.indexOf("/", start + 3) : url.indexOf("/");
  return (slash >= 0 ? url.slice(slash) : "/").split("?")[0];
}

// Tag paths that a MockResponse / Drop rule should handle
globalThis.onRequestHeaders = (ctx, flow) => {
  const path = pathname(flow.layer.data.request.url);
  if (path === "/api/users" || path === "/api/health") {
    flow.tags.push("mock-candidate");
  }
  return flow;
};

3. Request Rewriting

globalThis.onRequestHeaders = (ctx, flow) => {
  if (flow.layer.data.request.url.includes("api.example.com")) {
    flow.layer.data.request.url = flow.layer.data.request.url.replace(
      "api.example.com", "staging.example.com"
    );
    flow.layer.data.request.headers = flow.layer.data.request.headers.filter(
      h => h[0].toLowerCase() !== "host"
    );
    flow.layer.data.request.headers.push(["host", "staging.example.com"]);
  }
  return flow;
};

4. Response Sanitization

globalThis.onResponse = (ctx, flow) => {
  if (flow.layer.type !== "Http" || !flow.layer.data.response?.body) return;
  const raw = atob(flow.layer.data.response.body.content);

  const data = relay.json.parseSafe(raw);
  if (!data) return;

  const sanitized = redactPersonalInfo(data);
  const newBody = relay.json.stringifyPretty(sanitized);
  flow.layer.data.response.body.content = btoa(newBody);
  flow.layer.data.response.body.size = newBody.length;
  return flow;
};

function redactPersonalInfo(obj) {
  if (typeof obj !== "object" || obj === null) return obj;
  if (Array.isArray(obj)) return obj.map(redactPersonalInfo);
  const result = {};
  for (const [k, v] of Object.entries(obj)) {
    if (/phone|mobile|id_card|ssn/i.test(k)) {
      result[k] = "***REDACTED***";
    } else {
      result[k] = redactPersonalInfo(v);
    }
  }
  return result;
}

5. Rate Limiting

const RATE_LIMIT = 100;
const WINDOW_MS = 60_000;

globalThis.onRequest = (ctx, flow) => {
  const ip = flow.network.client_ip;
  const now = Date.now();
  const bucket = sharedState.get("_rateLimit") || {};
  const entry = bucket[ip] || { count: 0, reset: now + WINDOW_MS };

  if (now > entry.reset) {
    entry.count = 0;
    entry.reset = now + WINDOW_MS;
  }

  entry.count++;
  bucket[ip] = entry;
  sharedState.set("_rateLimit", bucket);

  if (entry.count > RATE_LIMIT) {
    flow.tags.push("rate-limited");
    // Return 429 via rule RateLimit action (Rule Guide §8)
  }
  return flow;
};

6. Cross-Hook Context

globalThis.onRequest = (ctx, flow) => {
  sharedState.set(flow.id, {
    startTime: Date.now(),
    authType: flow.layer.data.request.headers.find(h => h[0].toLowerCase() === "authorization")
      ? "bearer" : "none",
  });
  return flow;
};

globalThis.onResponse = (ctx, flow) => {
  const meta = sharedState.get(flow.id);
  if (!meta) return;

  const duration = Date.now() - meta.startTime;
  if (duration > 5000) {
    console.warn(`Slow request: ${flow.layer.data.request.url} (${duration}ms)`);
  }
  sharedState.delete(flow.id);
};

7. IP Blacklist

const BLOCKED_NETS = ["10.0.0.", "192.168.254."];

globalThis.onConnect = (ctx, conn) => {
  for (const net of BLOCKED_NETS) {
    if (conn.client_ip.startsWith(net)) {
      console.warn(`Blocked connection from ${conn.client_ip}`);
      return { drop: true };
    }
  }
};

8. External Auth (relay.fetch)

globalThis.onRequest = async (ctx, flow) => {
  const auth = flow.layer.data.request.headers.find(h => h[0] === "authorization");
  if (!auth) return;

  try {
    const resp = await relay.fetch("http://auth.internal/verify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ token: auth[1] }),
      timeout_ms: 3000,
    });

    if (resp.status !== 200) {
      flow.tags.push("auth-failed");
      // 0.5.1: block / 401 via MockResponse or Drop rules
    }
  } catch (e) {
    console.error("Auth service unreachable:", e);
  }
};

Debugging

Log Levels

console.debug("Flow id:", flow.id);          // needs RUST_LOG=relay_core_script=debug
console.log("Request handled");              // visible by default
console.error("This should not happen!");    // always visible

Global Error Handler

globalThis.onError = (ctx, flow, error, stage) => {
  console.error(`[${stage}] ${error}`);
  const errors = sharedState.get("_errors") || [];
  errors.push({
    flow_id: flow?.id, stage,
    message: String(error),
    time: new Date().toISOString(),
  });
  sharedState.set("_errors", errors);
};

Prometheus Metrics

curl http://127.0.0.1:8082/api/v1/metrics/prometheus | grep script_

Performance Notes

  • Hook execution blocks the proxy thread — keep hooks under 10ms
  • Avoid synchronous large body processing — JSON parsing >1MB bodies risks OOM
  • Clean up sharedState — stale entries trigger warnings at 10k keys
  • relay.fetch has concurrency limits — max 8 concurrent requests by default
  • Per-isolate sharedState — different engine isolates have independent state (same flow = same engine guaranteed)
  • Minify bundles — use esbuild --minify for faster script load

Testing Scripts

# Full integration test suite for scripting
cargo test --package relay-core-script

# Covers: all 11 hooks, relay.* utilities, relay.fetch,
#         env whitelist, sharedState, onError,
#         matched_rules, rule_variables