-
Notifications
You must be signed in to change notification settings - Fork 0
rhai
Rhai is a specific embedded scripting language (not a compilation target). You
write .rhai files — plain text files in Rhai syntax — and Conduit runs them
for every request that passes through the middleware. No build step, no
compiler, no toolchain required.
Requires
cargo build --features rhai
When to use Rhai vs WASM:
| Rhai | WASM | |
|---|---|---|
| Language | Rhai only | Rust, C, Go, AssemblyScript, … |
| Build step | None — edit and reload | Compile to .wasm
|
| Read request headers | ✅ request.header("Name")
|
✅ |
| Write request headers (to upstream) | ❌ ¹ | ✅ conduit_set_request_header
|
| Read client IP | ❌ ² | ✅ conduit_get_client_ip
|
Plugin config access |
✅ | ✅ |
| Response phase | ✅ phase: "response"
|
✅ on_response export |
| Best for | Guards, path checks, allow/deny decisions | Header mutation, high performance, complex logic |
¹ Rhai can set headers on the abort response (
response.header()), but cannot add headers to the request forwarded to the upstream. Use WASM orrequestTransform.setHeadersin config for that.² Client IP is not exposed to Rhai scripts. Use
ipFilterin config or a WASM plugin for IP-based decisions.
Use Rhai when you want to inspect headers and allow/deny requests without a build step. Switch to WASM when you need to add or modify forwarded request headers, or access the client IP.
- Configuration
- Script API
- Examples
- Response phase
- Execution model
- Error handling
- Rhai language reference
Rhai scripts are plain text files with a .rhai extension. Create them with
any text editor — no compiler needed.
# conduit.yaml
middleware:
- type: script
path: ./scripts/my-check.rhai # path relative to working directory
# Multiple scripts run in order — first false wins
- type: script
path: ./scripts/rate-check.rhai
# Optional config object available inside the script as `config` variable
- type: script
path: ./scripts/auth.rhai
config:
allowed_token: "secret-123"
max_body_kb: 512// conduit.json
{
"middleware": [
{ "type": "script", "path": "./scripts/my-check.rhai" },
{
"type": "script",
"path": "./scripts/auth.rhai",
"config": { "allowed_token": "secret-123", "max_body_kb": 512 }
}
]
}Scripts are loaded from the path relative to the working directory where
conduitis started. Paths starting with/are absolute.
Every script receives two pre-populated variables: request and response.
Read-only view of the incoming HTTP request.
| Property / Method | Type | Description |
|---|---|---|
request.path |
String |
Request path, e.g. "/api/users"
|
request.method |
String |
HTTP method, e.g. "GET", "POST"
|
request.query |
String |
Raw query string, e.g. "page=1&size=10" (empty when absent) |
request.header("Name") |
String |
Header value — case-insensitive; empty string when absent |
Used to build the response when aborting the pipeline.
| Property / Method | Type | Description |
|---|---|---|
response.status |
int |
HTTP status code to send (default: 200) |
response.body |
String |
Response body text (default: "") |
response.header("Name", "Value") |
— | Append a response header |
| Return | Effect |
|---|---|
true (or any truthy value) |
Pipeline continues — request forwarded to upstream |
| (no explicit return) | Treated as true — pipeline continues |
false |
Pipeline stops — response is sent to the client |
Check that an Authorization header is present. Returns 401 with a
WWW-Authenticate challenge when it is missing.
// auth-check.rhai
let token = request.header("Authorization");
if token == "" {
response.status = 401;
response.body = "Unauthorized";
response.header("WWW-Authenticate", "Bearer realm=\"api\"");
return false;
}
true
Check a specific bearer token:
// bearer-token.rhai
let auth = request.header("Authorization");
if auth != "Bearer my-secret-token" {
response.status = 403;
response.body = "Forbidden";
return false;
}
true
Deny all requests to /admin from this middleware point:
// block-admin.rhai
if request.path.starts_with("/admin") {
response.status = 404; // pretend it doesn't exist
return false;
}
true
Allow only GET and HEAD:
// read-only.rhai
let allowed = ["GET", "HEAD"];
if !allowed.contains(request.method) {
response.status = 405;
response.body = "Method Not Allowed";
response.header("Allow", "GET, HEAD");
return false;
}
true
Block or restrict access based on query string values:
// debug-guard.rhai — block ?debug=1 in production
if request.query.contains("debug=1") {
response.status = 403;
response.body = "Debug mode is disabled";
return false;
}
true
// api-version-check.rhai — require ?version=2
if !request.query.contains("version=2") {
response.status = 400;
response.body = "Missing required ?version=2 parameter";
return false;
}
true
Use a redirect response to route legacy URLs to new ones:
// legacy-redirect.rhai
// Redirect /legacy/* → /api/*
if request.path.starts_with("/legacy/") {
// Strip "/legacy/" prefix (8 chars) and build new URL
let new_path = "/api/" + request.path.sub_string(8);
response.status = 301;
response.header("Location", new_path);
return false; // send the redirect response
}
true
// version-redirect.rhai
// Redirect /v1/* → /v2/*
if request.path.starts_with("/v1/") {
response.status = 308; // Permanent Redirect, preserves method
response.header("Location", "/v2/" + request.path.sub_string(4));
return false;
}
true
To rewrite the path transparently (without the client seeing a redirect), use
proxy.*.rewriterules in the config — Rhai cannot modify the forwarded path directly.
// json-errors.rhai
let key = request.header("X-API-Key");
if key == "" {
response.status = 401;
response.header("Content-Type", "application/json");
response.body = `{"error":"missing API key","status":401}`;
return false;
}
true
Scripts have access to Rhai's standard library. Use print to write a line
to the process stdout (captured by journald/Docker logs, not the access log):
// debug-log.rhai
print(`[debug] ${request.method} ${request.path}`);
true
logging: jsonand OTLP tracing. Use
Set a config object in the middleware entry — it becomes a config variable
in the script scope. Fields map directly to Rhai values (strings, numbers,
booleans, arrays, nested objects).
# conduit.yaml
middleware:
- type: script
path: ./scripts/check-key.rhai
config:
valid_key: "$MY_SECRET_KEY"
blocked_paths: ["/internal", "/debug"]
max_body_kb: 512// conduit.json
{
"middleware": [
{
"type": "script",
"path": "./scripts/check-key.rhai",
"config": {
"valid_key": "$MY_SECRET_KEY",
"blocked_paths": ["/internal", "/debug"],
"max_body_kb": 512
}
}
]
}// check-key.rhai — access config fields directly
// String field
let key = request.header("X-API-Key");
if key != config.valid_key {
response.status = 401;
return false;
}
// Array field
if config.blocked_paths.contains(request.path) {
response.status = 403;
return false;
}
true
Config types:
| YAML / JSON value | Rhai type |
|---|---|
"string" |
String |
42 |
i64 |
3.14 |
f64 |
true / false
|
bool |
[1, 2, 3] |
Array |
{ key: val } |
Map — access as config.key
|
absent (no config:) |
() (unit) — check with config == ()
|
Scripts can run after the upstream response is received by setting
phase: "response" in the middleware entry. The same .rhai file path — no
separate file needed.
# conduit.yaml
middleware:
# Request phase (default) — runs before upstream
- type: script
path: ./scripts/auth-check.rhai
# Response phase — runs after upstream responds
- type: script
path: ./scripts/add-response-headers.rhai
phase: "response"// conduit.json
{
"middleware": [
{ "type": "script", "path": "./scripts/auth-check.rhai" },
{
"type": "script",
"path": "./scripts/add-response-headers.rhai",
"phase": "response"
}
]
}In a response-phase script, the available variables differ from request phase:
| Variable | Properties / Methods | Description |
|---|---|---|
upstream |
.status |
HTTP status returned by upstream (e.g. 200, 404) |
.header("Name") |
Read an upstream response header (empty if absent) | |
response |
.set_header("Name", "Value") |
Add/overwrite a header on the client response |
.remove_header("Name") |
Remove a header from the client response | |
config |
same as request phase | Per-script config object (or () if not set) |
requestis not available in response-phase scripts.
responsemethods differ between phases:
- Request phase:
response.header("Name", "Value")— appends a header to the abort response (only used whenreturn false)- Response phase:
response.set_header("Name", "Value")— modifies the upstream response forwarded to the client
Return value is ignored — response scripts always continue.
// add-response-headers.rhai — add security header to all responses
response.set_header("X-Served-By", "conduit");
response.remove_header("X-Powered-By");
// log-errors.rhai — log when upstream returns 5xx
if upstream.status >= 500 {
print(`upstream error: ${upstream.status} for path`);
// Add debug header for internal use
response.set_header("X-Upstream-Error", upstream.status.to_string());
}
// hide-server.rhai — remove server identity headers on all responses
response.remove_header("Server");
response.remove_header("X-Powered-By");
response.remove_header("Via");
// cors-on-error.rhai — ensure CORS header is present even on 4xx/5xx
// (some upstreams strip CORS headers on errors)
let origin = "https://app.example.com";
if upstream.header("Access-Control-Allow-Origin") == "" {
response.set_header("Access-Control-Allow-Origin", origin);
}
- Scripts are compiled once (on first use) and the AST is cached for
the lifetime of the process. Hot-reload (
conduit reload) clears the cache so the next request picks up the new file. - Scripts execute synchronously in the request-handling thread.
- Each request gets its own
requestandresponsescope — there is no shared mutable state between requests. - The Rhai engine runs in safe mode — file I/O, network, and system calls are not available.
Resource limits (hard limits enforced by the engine):
| Limit | Value |
|---|---|
| Max operations per script execution | 500,000 |
| Max string size | 1 MiB (1,048,576 bytes) |
| Max array / map size | 65,536 elements |
A script that exceeds any limit terminates with a runtime error and fails open (request passes through). Loops in middleware scripts should be short or bounded.
Conduit uses fail-open: if a script fails to compile or throws a runtime
error, the error is logged as a warning and the request passes through as if
the script returned true.
This means a broken script will never take down your server, but it also means errors can silently bypass auth checks. Monitor your logs.
WARN conduit::filter::script: Rhai compile error: Variable not found: undefined_var
WARN conduit::filter::script: Rhai runtime error: Division by zero
Set RUST_LOG=conduit=debug to see the full error context.
Rhai is a simple, Rust-like scripting language. Below are the patterns most useful when writing request middleware.
let path = request.path; // "/api/users"
let method = request.method; // "GET"
let query = request.query; // "page=1&size=10"
let auth = request.header("Authorization"); // "" if absent
// String interpolation
let msg = `${method} ${path} rejected`;
// String tests
path.starts_with("/api")
path.ends_with(".json")
path.contains("admin")
auth.len() > 0
auth == ""
// Case conversion
method.to_lower() == "get"
path.to_upper()
// Extract substring — sub_string(start) or sub_string(start, length)
let tail = path.sub_string(5); // skip first 5 chars
let seg = path.sub_string(1, 3); // chars 1..4
// Allow → return true (or just fall through)
// Deny → set response, return false
if auth == "" {
response.status = 401;
response.body = "Unauthorized";
return false;
}
// One-liner guard
if !path.starts_with("/api") { return true; } // not our concern
let blocked = ["/admin", "/internal", "/debug"];
if blocked.contains(path) {
response.status = 403;
return false;
}
let methods = ["GET", "POST"];
if !methods.contains(method) {
response.status = 405;
response.header("Allow", "GET, POST");
return false;
}
// config is set in conduit.yaml under middleware[].config
// Absent config → config == ()
if config == () {
// No config provided — use fallback
return true;
}
if config.allowed_key != request.header("X-API-Key") {
response.status = 403;
return false;
}
// Numeric config
if config.max_path_len < path.len() {
response.status = 414;
return false;
}
// Array config
if config.blocked_paths.contains(path) {
response.status = 403;
return false;
}
// Add response header (only effective when returning false)
response.header("X-Reason", "blocked");
response.header("Content-Type", "application/json");
// Redirect
response.status = 302;
response.header("Location", "/new-path");
return false;
if x > 10 { ... } else if x > 5 { ... } else { ... }
// Switch-like
let result = switch method {
"GET" => "read",
"POST" => "write",
_ => "unknown",
};
// Loops (rarely needed in middleware)
for item in array { ... }
while condition { ... }
Full language documentation: rhai.rs/book