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(notflow.request) - sharedState: use
get(key)/set(key, value)/delete(key), not plain object properties - Rule variables: scripts run before rules in
onRequestHeaders; readflow.rule_variablesinonRequestor later hooks - Mock / direct responses:
{ action: "respond" }is not supported in 0.5.1 — use ruleMockResponse(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
--minifyfor 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