脚本开发指引

本指南涵盖使用 RelayCore 脚本引擎的常用模式、最佳实践和调试技巧。

快速开始

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

通过 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 备忘

  • Flow 结构:HTTP 请求/响应在 flow.layer.data.request / flow.layer.data.response(不是 flow.request
  • sharedState:使用 get(key) / set(key, value) / delete(key),不是普通对象属性
  • 规则变量:脚本在 onRequestHeaders 阶段先于规则执行;读取 flow.rule_variables 请在 onRequest 或更晚钩子
  • Mock / 直接响应:0.5.1 脚本尚不支持 { action: "respond" },请用规则 MockResponse(见规则指南
  • 沙箱限制:无 URL 全局对象,解析路径请用字符串 helper(见 §2)

常用模式

1. 认证注入

为所有到内部 API 的请求自动注入认证头:

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;  // 返回修改后的 flow
};

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

2. Mock 响应

0.5.1 中 Mock 请优先使用规则引擎 MockResponse(见规则指南 §2)。脚本侧若需匹配路径,沙箱内没有 URL 对象:

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,配合 MockResponse / Drop 规则
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. 请求重写

修改请求目标,将 API 调用重定向到不同环境:

globalThis.onRequestHeaders = (ctx, flow) => {
  // 将 prod API 调用重定向到 staging
  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"
    );
    // 更新 Host header
    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. 响应脱敏

自动移除敏感字段如手机号、证件号:

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

  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;
}

function isJson(s) {
  try { JSON.parse(s); return true; } catch { return false; }
}

5. 限速与熔断

通过 sharedState 实现简单的请求计数和限速:

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) {
    console.warn(`Rate limit exceeded for ${ip} (${entry.count}/${RATE_LIMIT})`);
    flow.tags.push("rate-limited");
    // 返回 429 请用规则 RateLimit action(见规则指南 §8)
  }
  return flow;
};

6. 跨钩子上下文传递

使用 sharedState 在请求和响应之间传递数据:

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, auth=${meta.authType})`);
  }

  sharedState.delete(flow.id);
};

7. 连接 IP 黑名单

在 TCP 连接建立时直接拒绝:

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. 外部鉴权(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:返回 401 / 阻断请配合 MockResponse 或 Drop 规则
    }
  } catch (e) {
    console.error("Auth service unreachable:", e);
    // fail-open: 允许通过
  }
};

调试技巧

日志级别

// 开发时用 console.debug,生产不可见
console.debug("Flow id:", flow.id);          // RUST_LOG=relay_core_script=debug
console.log("Request handled");              // 默认可见
console.error("This should not happen!");    // 永远可见

onError 全局捕获

globalThis.onError = (ctx, flow, error, stage) => {
  // 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 指标

# 查看脚本执行指标
curl http://127.0.0.1:8082/api/v1/metrics/prometheus | grep script_
# 输出:script_hook_duration_us{hook="onRequest"}
#       script_hook_invocations_total{hook="onRequest"} 12345

性能注意事项

  • 钩子执行阻塞代理线程——单个钩子 > 10ms 会显著影响吞吐
  • 避免同步大文件处理——对 > 1MB body 进行 JSON 解析会导致 OOM
  • sharedState 及时清理——不清理会导致内存增长(旧条目 > 10k 时发 warning)
  • relay.fetch 有并发上限——默认最大 8 个并发请求,超出会排队等待
  • 引擎池独立 V8 isolate——不同引擎的 sharedState 互不可见(同 flow 保证路同引擎)
  • Bundle 模式打包——用 esbuild 的 --minify 可以减少脚本加载时间

测试脚本

无需启动完整代理即可在单元测试中验证脚本逻辑:

# Rust 侧已有完整的脚本集成测试
# 见 relay-core-script/tests/integration_test.rs
cargo test --package relay-core-script

# 覆盖:全部 11 钩子、relay.* 工具、relay.fetch、
#       env 白名单、sharedState、onError、
#       matched_rules、rule_variables