JavaScript Scripting
Chute supports JavaScript scripting for advanced request/response modification, custom rule matching, DNS resolution, and scheduled tasks. Scripts run on JavaScriptCore on Apple platforms and on QuickJS on Android, and follow the Surge-compatible script API.
Scripts are defined in the [Script] section of the configuration file.
Configuration
[Script]
MyScript = type=http-request, script-path=/path/to/script.js, pattern=^https?://example\.com, requires-body=true, max-size=65536, timeout=10, argument=myArg
CronJob = type=cron, script-path=/path/to/cron.js, cron-expression=*/30 * * * *
Script Parameters
| Parameter | Required | Default | Description |
|---|---|---|---|
type |
No | http-request |
Script trigger type (see below) |
script-path |
Yes | — | Local file path or HTTP(S) URL to the JS script |
pattern |
No | (match all) | URL regex pattern to filter when the script triggers. It is matched anywhere in the request's full URL — https://… once decrypted, http://… for plain HTTP — and never against the path alone, so a pattern such as ^/api does not match |
requires-body |
No | auto-detect | Force script to receive full request/response body |
max-size |
No | 131072 (128KB) | Max body size in bytes for body-accessing scripts. If the collected body exceeds this size, the script is skipped for that request (the body is not truncated). On HTTP/1.x — plain HTTP and decrypted HTTP/1.1 — Chute buffers at most 131072 bytes for scripts whatever this says, so there it can only lower the limit; a decrypted HTTP/2 message is handed over whole and checked against max-size alone |
timeout |
No | 5.0 seconds | Per-execution timeout, counted from the start: loading the source (downloading it, for a remote script), running the code and waiting for timers and requests all share it. Values above 30 seconds are clamped to 30 |
argument |
No | — | Custom string argument available as $argument in JS |
full-header-mode |
No | false | When true, $request.headers and $response.headers are arrays of {field, value}, one per header line (Surge's format), instead of objects. Applies to http-request, http-request-before-send and http-response scripts; $done() accepts both forms either way. See Headers with more than one line |
debug |
No | false | Reserved; currently has no effect |
cron-expression |
No | — | Cron schedule expression (for cron type only); cronexp= and Shadowrocket's cronexpr= are accepted as the same key |
event-name |
No | (every event) | The event an event script listens for — network-changed, engine-started or profile-reloaded; with no name the script runs on all three (see Event Script) |
wake-system |
No | false | Reserved; parsed but currently has no effect |
enable |
No | true | Enable or disable this script |
script-update-interval |
No | 0 | Remote script update polling (see Execution Model) |
binary-body-mode |
No | — | Accepted for Surge compatibility (also spelled binary-mode) and kept when the profile is saved, but it changes nothing: the raw bytes are always in .bodyBytes |
engine |
No | — | Accepted and kept, but ignored: every script runs on the platform's own engine |
Script Types
| Type String | Enum | Description |
|---|---|---|
http-request |
HTTP Request | Intercept and modify HTTP requests before upstream |
http-response |
HTTP Response | Intercept and modify HTTP responses before client |
http-request-before-send |
HTTP Request Before Send | Modify the request just before it is sent upstream |
rule |
Rule | Custom rule matching logic |
dns |
DNS | Custom DNS resolution |
cron |
Cron | Scheduled/timed scripts |
event |
Event | System event handlers (e.g., network-changed) |
generic |
Generic | Accepted for Surge compatibility; it is not attached to requests, rules, DNS or events, so Chute never runs it on its own. Run it with Run in the script list of Chute iOS or Chute Android, or with POST /api/scripts/run on the HTTP Control API |
An unknown type= is reported in the log and handled as http-request.
Body auto-detection: for
http-requestandhttp-responsescripts, a source that contains$request.bodyor$response.bodygets the body automatically, up tomax-size. Userequires-body=trueto force this behavior. A script that only reads.bodyBytesor.rawBodyis not detected, andhttp-request-before-sendscripts never are: those needrequires-body=true.
JavaScript API Reference
Scripts run in a sandboxed JavaScript environment — JavaScriptCore on Apple platforms, QuickJS on Android — with the following global objects available.
$request (Read-only)
Available in: http-request, http-response, http-request-before-send, rule, dns
| Property | Type | Description |
|---|---|---|
.url |
String | Full request URL; the port is written unless it is the scheme's default, and an IPv6 host is in brackets. A request target that is already an absolute URL is used as written, one that does not start with / is given one, and with no host known the value is the path alone. In a rule script it is the destination host, in a dns script the domain being queried |
.method |
String | HTTP method (GET, POST, etc.) or QUERY for DNS |
.headers |
Object or Array | Request headers, each name to its value; the values of a header with several lines are joined into one string (see Headers with more than one line). An array of {field, value} with full-header-mode=true. Names are looked up whatever their case (see Header names) |
.body |
String or null | Request body (UTF-8 decoded) |
.bodyBytes |
Bytes or null | Raw request body bytes (see the notice below) |
.hostname |
String | Target hostname |
.destPort |
Number | Destination port |
.processPath |
String | Requesting process path (macOS; on Android, the APK path of the app, for TCP connections the VPN captures) |
.userAgent |
String | User-Agent header value |
.sourceIP |
String | Source IP address |
.listenPort |
Number | Proxy listen port |
.requestId |
String | Unique request ID |
.dnsResult |
String | Resolved IP address |
.srcPort |
Number | Source port |
.protocol |
String | Detected protocol: http, https, tcp, dns |
$response (Read-only)
Available in: http-response
| Property | Type | Description |
|---|---|---|
.status |
Number | HTTP status code |
.headers |
Object or Array | Response headers, each name to its value; the values of a header with several lines are joined into one string (see Headers with more than one line). An array of {field, value} with full-header-mode=true. Names are looked up whatever their case (see Header names) |
.body |
String or null | Response body (UTF-8 decoded) |
.bodyBytes |
Bytes or null | Raw response body bytes (see the notice below) |
.rawBody |
Bytes or null | Alias for .bodyBytes |
.body,.bodyBytesand.rawBodycarry the same body: chunked framing is removed and a gzip or deflateContent-Encodingundone before the script sees it..bodyBytesand.rawBodyhold a byte object. Pass it to a call that takes bytes —$utils.ungzip(), whose result is a byte object too, orbodyin$done(), which takes it as the raw body. What the object is depends on the engine: an opaque wrapper with no.lengthand no indexing on Apple platforms, aUint8Arrayon Android, so a script meant for every platform should not rely on.lengthor indexing. To read the content, use.body, which is the same bytes decoded as UTF-8.
$done(value) — Completion Handler
Must be called exactly once at the end of the script to signal completion. A script that returns without having called it is completed as a passthrough immediately, unless a setTimeout timer or a $httpClient request is still pending. timeout= runs from the moment the execution starts: loading the source, running the code and that wait all count against it.
$done({}) // Passthrough — no modifications
$done() // Abort the connection
$done({matched: true}) // Rule match result (rule scripts only)
$done({address: "1.2.3.4"}) // DNS result (dns scripts only)
In an http-request, http-request-before-send or http-response script, $done() with no argument — or with a value that is not an object — aborts the connection: Chute sends nothing in place of the request or the response, and an aborted request never reaches the server. What is already on its way to the client is written first, then the connection closes at once. On HTTP/2 only that request's stream is reset (RST_STREAM with the error code CANCEL) and nothing is sent in its place; the connection and the other requests on it carry on.
Headers and url from a script are checked before they are used. A header is dropped, with a warning in the log, when:
- its name or value contains a line break (CR or LF) or a NUL character:
[JS] Dropped header <name>: a header name or value cannot contain a line break; - its name is not a valid HTTP field name (RFC 9110 §5.1). A valid name has one or more letters, digits, or any of
!#$%&'*+-.^_`|~, so an empty name, or one that contains a space, a colon or a non-ASCII letter, is dropped:[JS] Dropped header <name>: not a valid header name.
The rest of the result still applies. The same checks cover headers given in the array form and the headers of a response a request script returns. A url that contains a line break, a NUL or a space is ignored, also with a warning. The path and query of a url are sent as written: percent-escapes such as %20 stay escaped.
HTTP Request script return values:
$done({
url: "https://new.example.com/path", // Rewrite URL
headers: {"X-Custom": "value"}, // Modify headers
body: "new request body", // Modify body
response: { // Return a synthetic response (skip upstream)
status: 200,
headers: {"Content-Type": "text/html"},
body: "<html>Blocked</html>"
}
})
When response is provided, the request is short-circuited: Chute returns the synthetic response directly to the client, and the request is not sent to the upstream server. This holds wherever Chute handles the request, HTTP/2 included, and whether the script runs on the header, on the whole body or as http-request-before-send. On HTTP/1.x the status line carries the code's standard reason phrase (HTTP/1.1 404 Not Found) and the connection closes once the response is out; on HTTP/2 only that request's stream is answered. status is a number from 200 to 599, or a string that holds one once surrounding spaces are trimmed ("404"); any other value, or none, gives 200. Content-Length is computed from body, whatever headers says. This is useful for blocking, mocking APIs, or returning cached content.
Answers Chute generates itself — Map Local, a script's
response, and the answers and error page of URL Rewrite and the REJECT policies — follow HTTP: the answer to a HEAD request is the header alone, with the Content-Length a GET would get, and an answer with status 204, 205 or 304 carries neither a body nor a Content-Length.
HTTP Response script return values:
$done({
status: 200, // Modify status code
headers: {"X-Custom": "value"}, // Modify response headers
body: "new response body", // Modify response body
url: "https://other.example.com" // Redirect (302 by default)
})
body replaces the response's body and nothing else: the status line and the headers stay the server's, with the returned status and headers merged in, and the server's own body is dropped. When the script ran on the header alone, Body Rewrite rules and the http-response scripts that take the body still run afterwards, on the new body. A response to HEAD, or one whose status is 1xx, 204, 205 or 304, has no body, so a returned body is ignored there, while status and headers still apply.
status is a number from 200 to 599, or a string that holds one once surrounding spaces are trimmed ("301"). Any other value, true and false included, is ignored, and the response keeps its status. A status that changes the code also gives the status line the new code's standard reason phrase. A status of 204, 205 or 304 sends the response without a body — neither the server's nor a returned one — and without a length header; on HTTP/1.x the connection closes once the header is out.
When url is provided, Chute answers with a redirect to it instead of the original response. Its status is the returned status — 301, 307 or 308, say — or 302 when there is none or it is ignored; the returned headers are merged in as usual, and Location is url. Without a body the redirect has none, and the server's body is never sent; on HTTP/2 the server's trailers are not sent either. url must be returned together with at least one of status / headers / body; a result containing only url is treated as passthrough.
DNS script return values:
$done({address: "1.2.3.4"}) // Single IP
$done({addresses: ["1.2.3.4", "5.6.7.8"]}) // Multiple IPs
$done({address: "10.0.0.1", ttl: 300}) // With custom TTL (seconds, default 60)
$done({server: "1.1.1.1"}) // Resolve through this server
$done({servers: ["tls://dns.google", "8.8.8.8#disable-ipv6"]})
server/serverschoose the resolver instead of answering: each entry is an ordinary DNS server entry — an address,tls://,https://, with the usual#options — and they are asked together, first answer wins. If none of the entries can be used at all, the lookup fails rather than falling through to the configured pool.
Headers with more than one line
A header can appear on several lines: a response that sets two cookies carries two Set-Cookie lines. http-request, http-request-before-send and http-response scripts can read every line and write several.
Reading
By default, $request.headers and $response.headers are objects that map each header name to one string. When a header has several lines, their values are joined in order: Cookie values with "; ", and every other header's values (Set-Cookie included) with ", ".
$response.headers["Set-Cookie"] // "a=1; Path=/, b=2; Path=/"
$request.headers["Cookie"] // "a=1; b=2"
A cookie's Expires date contains a comma, so a joined Set-Cookie string cannot be split back into cookies reliably. To read each line on its own, add full-header-mode=true to the script line. $request.headers and $response.headers are then arrays with one {field, value} object per line, the format Surge uses. Lines with the same name keep their order.
// full-header-mode=true
const cookies = $response.headers
.filter(h => h.field.toLowerCase() === "set-cookie")
.map(h => h.value) // ["a=1; Path=/", "b=2; Path=/"]
The same holds over HTTP/2: when a client splits its cookie into several cookie fields, the script reads them as one Cookie joined with "; ", and each set-cookie is a line of its own.
Writing
headers in $done() changes only the headers it names; every other header stays as it is. The same rules apply to headers inside a response that a request script answers with.
| Value | Effect |
|---|---|
| a string | The header gets exactly one line with this value; all its existing lines are replaced |
| an array of strings | One line per value, in order, in place of the header's lines. {"Set-Cookie": ["a=1", "b=2"]} sends two Set-Cookie lines |
[] |
The header is removed |
anything else (a number, null, an object, an array that holds a non-string) |
Ignored: the header stays as it is |
A joined string that a script returns exactly as it read it keeps the header's original lines. So const h = $response.headers; h["X-A"] = "1"; $done({headers: h}) changes only X-A, and two Set-Cookie lines still go out as two lines. Any other string replaces all of the header's lines with one line. To add a cookie, write the full list as an array: read the existing lines in full-header mode, then return {"Set-Cookie": [...existing, "c=3"]}.
A request carries one Cookie line: an array given for Cookie in the headers of an http-request or http-request-before-send script is joined with "; ", so {"Cookie": ["a=1", "b=2"]} sends Cookie: a=1; b=2.
$done() also accepts headers as an array of {field, value} objects, with or without full-header-mode:
$done({headers: [{field: "Set-Cookie", value: "a=1"},
{field: "Set-Cookie", value: "b=2"},
{field: "X-A", value: "1"}]})
Entries are grouped by name, ignoring case, and each group replaces that header's lines in the order given. Headers the array does not name stay as they are, so leaving a header out does not remove it; to remove one, use the object form with []. An entry without a string field and a string value is ignored.
- Header names are case-insensitive:
set-cookieandSet-Cookieare the same header, so give each name only once. A name that is not a valid header name is dropped, as described under$done. - A value that contains a line break or a NUL is dropped, as described under
$done. In an array only that value is dropped; if every value is, the header stays as it is. - On HTTP/2 each line becomes a field of its own, with a lowercase name. Headers that only apply to one connection, such as
ConnectionandKeep-Alive, are not sent.
Header names in $request.headers and $response.headers
$request.headers and $response.headers find a header whatever case its name is written in: $response.headers['ETag'], $response.headers['etag'] and $response.headers['Etag'] all read the same header. This also applies to in ('etag' in $response.headers), to assignment (headers['content-type'] = 'text/html' changes the Content-Type already in the object instead of adding a second name for it) and to delete.
When you list the names (Object.keys(), for…in, JSON.stringify()), each header appears once, spelled the way Chute stores it — every word capitalized, however the client or the server wrote it: Content-Type, Etag, X-Api-Key, Www-Authenticate.
Only the object you read from $request.headers or $response.headers matches names this way. A copy you make yourself, for example with Object.assign({}, $request.headers) or {...$response.headers}, is a plain object; in a copy, use the spelling shown above. With full-header-mode=true, headers is an array of {field, value} objects, field spelled the same way, and this lookup does not apply; compare field.toLowerCase() instead.
$httpClient — Async HTTP Client
Make HTTP requests from within scripts. All requests are cancelled on $done() or timeout.
$httpClient.get(url, function(error, response, data) {
if (error) {
console.log("Request failed: " + error)
} else {
console.log("Status: " + response.status)
console.log("Response: " + data)
}
})
$httpClient.post(url, {headers: {...}, body: "...", timeout: 5}, callback)
$httpClient.put(url, options, callback)
$httpClient.del(url, options, callback)
$httpClient.head(url, options, callback)
$httpClient.options(url, options, callback)
$httpClient.patch(url, options, callback)
An options object may also carry policy, the name of a policy the profile defines: the request is then dialled through that policy instead of the default route. A name the profile does not define is logged as a warning, and the request takes the default route. Surge's inline policy-descriptor form is not supported: it is logged as a warning, and the request takes the default route as well — define the policy in the profile and pass its name.
Callback signature: callback(error, response, data)
error: Error string ornullresponse:{status: Number, headers: Object}ornull.headersfinds a header whatever case its name is written in, and lists the names the way$request.headersdoes — every word capitalized, as inContent-Type,Etag,X-Api-Key— with or withoutpolicydata: the response body decoded as a UTF-8 string; a body that is not valid UTF-8 arrives as a byte object like.bodyBytes, not asnull.nullonly when there is no body
$httpClientis available inhttp-request,http-response,http-request-before-send,cron, andeventscripts. It is not available inruleanddnsscripts.A script may have at most 8 requests outstanding, and all scripts together 16. A request over either limit is not sent and its callback never runs; the log says so once per run. A response body larger than 4 MB fails the request.
$persistentStore — Key-Value Storage
Persistent key-value storage that survives script and process restarts; every script reads and writes the same keys. On Android it is kept in Chute's private storage and left out of device backups.
$persistentStore.write(data, key) // Store a value
$persistentStore.read(key) // Retrieve a value
$persistentStore.remove(key) // Remove a value
$notification — Local Notifications
Post local system notifications. Chute Apple TV shows none.
$notification.post("Title", "Subtitle", "Notification body text")
An optional fourth argument carries Surge's options: url (the link to open when the notification is tapped), action (open-url is implied by url, and it is the only action the apps act on — others, such as Surge's clipboard, are carried but nothing handles them) and auto-dismiss (seconds). They are attached to the notification; other keys are ignored with a warning. url is acted on by Chute iOS, Chute Mac and Chute Android, which open it when the notification is clicked. auto-dismiss is acted on by Chute Android only, which removes the notification after that many seconds; the Apple apps leave it in place.
$notification.post("Title", "", "Tap to open", {url: "https://example.com", "auto-dismiss": 5})
Script notifications — like those of rules with notification-text — follow the app's notification switch: Allow Notification on Chute iOS, Show report event notifications on Chute Mac, the system's notification permission for Chute on Chute Android. Turn it off and nothing is shown. Chute Android posts them in a notification channel of their own, Script Notifications, which can be turned off separately in the system's notification settings. See Notification Reporting.
$network — Network Info
Read-only network state information.
$network.dns // Array of DNS server IPs
$network.wifi // {ssid: "WiFiName", bssid: null}
$network.v4 // {primaryAddress, primaryRouter}
$network.v6 // {primaryAddress, primaryRouter}
$network.primaryRouter // IPv4 default gateway, or null
$network.cellularData // {radio: "LTE" | "5G" | ..., carrier: null}
ssidandbssidare populated on iOS, where the tunnel may read the Wi-Fi identity, and on Android when Chute holds the permission Android requires to read the network name (see Getting Started on Android); on macOS and tvOSssidis empty andbssidisnull.primaryAddressskips the tunnel's own address and link-local ones, so it is the address the device reaches the network with.carrieris alwaysnull— iOS 16 removed the carrier name.
$environment — Runtime Info
$environment.system // "iOS", "macOS" or "Android"
$environment.appVersion // KLNEKit SDK version string
$environment.surgeVersion // The engine's own version, under the name Surge scripts read
$environment.language // Preferred language as a BCP 47 tag, e.g. "en-US"
$environment.deviceModel // "iPhone", "Mac" or "AppleTV"; on Android the device's model, e.g. "Pixel 8"
$utils — Utilities
$utils.geoip("1.2.3.4") // Country code (e.g., "US")
$utils.ipasn("1.2.3.4") // ASN number (e.g., "13335")
$utils.ipaso("1.2.3.4") // Organization of the ASN (e.g., "CLOUDFLARENET")
$utils.ungzip(data) // Decompress gzip data
$klne — Proxy Control API
Control the proxy runtime from scripts. $klne is available in every script type.
$klne.getPolicyGroups() // Get all policy groups
$klne.selectGroupDetails() // The same groups, in Surge's shape
$klne.selectPolicy("Group", "Proxy") // Switch policy for a group
$klne.getActiveConnections() // List active connections
$klne.closeConnection("id") // Close a connection by the id getActiveConnections() gave
$klne.flushDNS() // Purge DNS cache
$klne.startURLTest("Group") // Trigger URL test for a group
$klne.reloadConfiguration() // Reload all configuration
$klne.setOutboundMode("rule") // Set mode: "global"/"proxy", "direct", "rule"
$klne.setHTTPCaptureEnabled(true) // Enable/disable MitM
$klne.setRewriteEnabled(true) // Enable/disable the rewrite family
getPolicyGroups() returns the selectable policy groups:
{
count: 1, // Number — number of groups
policyGroups: [{
name: "MainGroup", // String — group name
type: 0, // Number — 0 select, 1 url-test, 2 fallback, 3 ssid, 5 load-balance
policyNames: ["A", "B"], // Array of String — the group's actual members, subscription-supplied nodes included
selectedIndex: 0, // Number — index of the active policy in policyNames (absent when unresolved)
selectedPolicy: "A" // String — name of the active policy (absent when unresolved)
}]
}
selectGroupDetails() returns the same groups in Surge's shape — policyGroups maps each group name to its member names, decisions maps each group name to the member now selected:
{
policyGroups: {MainGroup: ["A", "B"]},
decisions: {MainGroup: "A"}
}
A group whose selection is unresolved is left out of decisions.
getActiveConnections() returns an Array describing the current connections:
[{
id: 1042, // Number — the connection number, the one closeConnection takes
host: "example.com", // String — destination host (absent when unknown)
port: 443 // Number — destination port
}]
The remaining methods take plain arguments and return nothing:
selectPolicy(group, policy)— makespolicy(a name from the group'spolicyNames) the active policy ofgroup. An unknown group or policy name is ignored with a warning in the log.closeConnection(id)— closes the connection with that id, the numbergetActiveConnections()reports. An id that is not open is ignored with a warning.flushDNS()— purges the DNS cache.startURLTest(group)— starts an asynchronous latency test for aurl-test,fallbackorload-balancegroup; other group types and unknown names are ignored with a warning.reloadConfiguration()— re-fetches the profile's#!MANAGED-CONFIGsource now, instead of waiting for the update interval, and applies it if it differs. Re-applying the configuration the engine already holds would change nothing, so a profile with no managed source logs a warning and nothing else.setOutboundMode(mode)—"global"and"proxy"both proxy all traffic,"direct"sends all traffic directly, and any other value selects rule mode.setHTTPCaptureEnabled(enabled)— enables or disables HTTPS decryption (MitM) at runtime; takes a boolean.setRewriteEnabled(enabled)— enables or disables the whole rewrite family — URL Rewrite, Header Rewrite, Body Rewrite and Mock Response — at runtime; takes a boolean.
$surge is accepted for Surge scripts, with the calls whose meaning is identical: $surge.setSelectGroupPolicy(group, policy) (same as selectPolicy), $surge.selectGroupDetails() (same as selectGroupDetails), $surge.setOutboundMode(mode), $surge.setHTTPCaptureEnabled(enabled), $surge.setRewriteEnabled(enabled) (the whole rewrite family — URL Rewrite, Header Rewrite, Body Rewrite and Mock Response) and $surge.retestGroup(name). The rest of Surge's $surge has no equivalent here and reads as undefined, so a script can test for it.
$httpAPI — Control API Bridge
Call the engine's own HTTP Control API from a script. $httpAPI is available in every script type.
$httpAPI("GET", "/api/status", null, function(result) {
console.log(result.statusCode) // Number — HTTP status
console.log(result.body.data) // Object — the parsed JSON body
})
$httpAPI("/api/status") // One argument: a GET of that path
$httpAPI("DELETE", "/api/dns/cache") // Method and path
var result = $httpAPI("GET", "/api/status") // The same object is also returned
The call is synchronous — the request is routed inside the engine, and the {statusCode, body} object is both passed to the callback and returned. path must start with /. A string body is sent as written; any other value is encoded as JSON. Because the request never crosses the listener, no token is involved and the API does not have to be enabled. POST /api/scripts/run is refused with 409 and would_reenter — it would need the engine the calling script is holding — and a route that does not answer within 12 seconds gives 504.
$script — Script Metadata
$script.name // Script name from config
$script.type // Script type string
$script.startTime // Monotonic timestamp (seconds since system boot reference, not Epoch)
$script.sessionID // Distinct per execution, for tying one run's own state together
Per-Execution Globals
The following variables are injected per script execution and are specific to certain script types.
$argument — Script Argument
The string value from the argument= parameter in the script configuration, or null when the script has none. Available in: http-request, http-response, http-request-before-send, rule, dns, cron, event.
console.log("Argument: " + $argument)
$domain — DNS Domain (DNS Script Only)
The domain name being queried. Available only in dns scripts.
var domain = $domain // e.g. "example.com"
$cronexp — Cron Expression (Cron Script Only)
The cron schedule expression from the script configuration. Available only in cron scripts.
console.log("Schedule: " + $cronexp) // e.g. "*/30 * * * *"
$event — Event Info (Event Script Only)
Information about the triggering event. Chute fires network-changed, engine-started and profile-reloaded — see Event Script.
console.log("Event: " + $event.name) // "network-changed"
$event.name is the event that actually fired. A script with event-name= only ever runs for that one event, so the name is always the one it declared; a script without event-name= runs for all three, and $event.name is how it tells them apart.
console — Logging
console.log("Debug message") // Verbose log
console.warn("Warning message") // Warning log
console.error("Error message") // Warning log tagged as a JavaScript error
Each message is cut to 256 characters, and credentials in it — an Authorization or Cookie header, a password= or token= value and the like — are logged as <redacted>. console.log writes at the verbose level, so it only shows when loglevel is verbose; console.warn and console.error show at the default warning level.
setTimeout(fn, seconds) — Timer
Schedule a function to run after a delay.
setTimeout(function() {
console.log("Delayed execution")
}, 2.5) // 2.5 seconds
$scriptImport(subScriptPath) — Sub-script Loader
Load and evaluate another JavaScript file. Only local file paths are supported; http(s):// and file:// URLs are rejected. ($script is reserved for the script metadata object.)
$scriptImport("/path/to/helper.js")
// Pass data between scripts using $persistentStore
Script Type Details
The three HTTP script types see a plain HTTP request only when it reaches Chute through its HTTP proxy, and an HTTPS request only when its host is decrypted — add the host to
hostnamein HTTPS Decryption.
HTTP Request Script
Executed when request headers are received. Can modify URL, headers, and body before the request is forwarded.
[Script]
ModifyHeaders = type=http-request, script-path=modify.js, pattern=^https://api\.example\.com
HTTP Response Script
Executed when response headers are received. Can modify status, headers, and body before returning to the client.
[Script]
ModifyResponse = type=http-response, script-path=response.js, pattern=^https://api\.example\.com
A script that takes the body runs once all of it has arrived. On HTTP/1.x, when the server closes the connection first, a body with neither Content-Length nor chunked encoding is complete — the close is its end — and the script runs as usual. A Content-Length or chunked body that the close cut short is not handed to the script: it goes to the client exactly as it arrived, and the connection is then closed. A Body Rewrite rule that matches the same message is applied first, and the script sees the rewritten body.
HTTP Request Before Send Script
Executed just before the request is sent upstream. When its body is held — this script has requires-body=true, or a Body Rewrite rule or an http-request script takes the body — the script runs once all of it has arrived. A request with no body, or with one larger than max-size, is not held, and a body that grows past max-size while it is held goes on as it came: either way the script runs as the header goes out, with an empty body, and a script with requires-body=true is skipped. Useful for modifying POST/PUT request bodies. It runs last: after any Body Rewrite rule and any http-request script that takes the body, and it sees the body they produced. A body the script returns replaces the request's own, which is dropped, and Content-Length is set to match it.
[Script]
BeforeSend = type=http-request-before-send, script-path=before-send.js, pattern=^https://api\.example\.com, requires-body=true
Rule Script
Custom rule matching. The script must call $done({matched: true}) or $done({matched: false}).
[Rule]
SCRIPT,MyRuleScript,DIRECT
[Script]
MyRuleScript = type=rule, script-path=rule.js
DNS Script
Custom DNS resolution. Receives $domain and returns resolved address(es).
// dns.js
var domain = $domain
if (domain === "internal.example.com") {
$done({address: "10.0.0.1", ttl: 300})
} else {
$done({}) // Passthrough to normal DNS resolution
}
A [Host] entry <domain> = script:<name> sends the lookups of matching domains to the named DNS script — see Local DNS Mapping.
Cron Script
Scheduled execution using cron expressions. Minimum interval is 60 seconds.
[Script]
HalfHourlyTask = type=cron, script-path=task.js, cron-expression=*/30 * * * *
The expression must be exactly five fields separated by single spaces. Anything else — a double space, a tab, four fields, six fields — disables the script silently: it is never scheduled, and nothing is written to the log.
Of the five fields only the minute one is honored: */N runs every N minutes. Any other minute field fires every 60 seconds, and the script must check the current time itself to decide whether to act.
Event Script
Triggered by system events. Three events are fired:
| Event | When it fires |
|---|---|
network-changed |
The Wi-Fi or cellular network changed |
engine-started |
The engine finished starting — policies, rules and listeners are live |
profile-reloaded |
A configuration reload finished, on the reloaded profile's own scripts |
[Script]
NetChange = type=event, script-path=network-changed.js
The $event object is available:
$event.name // "network-changed", "engine-started" or "profile-reloaded"
Surge names the event with event-name=, and Chute reads it too: event-name=engine-started runs the script on that event only. A script that names no event runs on all three and reads $event.name to tell which one fired. A script bound to any other event — Surge also fires events such as notification — never runs in Chute, and the log says so when the configuration loads.
Practical Examples
Redirect Mobile Devices
An http-request script that redirects mobile users based on User-Agent:
[Script]
MobileRedirect = type=http-request, script-path=mobile-redirect.js, pattern=^https://example\.com
// mobile-redirect.js
var ua = $request.headers["User-Agent"] || ""
if (/Mobile|Android|iPhone/.test(ua)) {
$done({
response: {
status: 302,
headers: {"Location": "https://m.example.com" + $request.url.replace(/.*example\.com/, "")},
body: ""
}
})
} else {
$done({})
}
Block Content in API Responses
An http-response script that removes ads and sponsored content from a JSON API response:
[Script]
RemoveAds = type=http-response, script-path=remove-ads.js, pattern=^https://api\.example\.com/feed, requires-body=true
// remove-ads.js
var body = JSON.parse($response.body)
if (body.ads) {
delete body.ads
}
if (body.recommendations) {
body.recommendations = body.recommendations.filter(function(r) {
return !r.sponsored
})
}
$done({body: JSON.stringify(body)})
Modify Request Body Before Sending
An http-request-before-send script that sanitizes a POST payload:
[Script]
SanitizePayload = type=http-request-before-send, script-path=sanitize.js, pattern=^https://api\.example\.com/submit, requires-body=true
// sanitize.js
var body = JSON.parse($request.body)
body.clientSecret = "[REDACTED]"
body.timestamp = Math.floor(Date.now() / 1000)
$done({body: JSON.stringify(body)})
Custom Rule: Time-Based Routing
A rule script that selects a different proxy depending on the time of day:
[Rule]
SCRIPT,TimeBasedRule,ProxyA
[Script]
TimeBasedRule = type=rule, script-path=time-rule.js
// time-rule.js
var hour = new Date().getHours()
if (hour >= 9 && hour < 18) {
$done({matched: false}) // Fall through to next rule during work hours
} else {
$done({matched: true}) // Use ProxyA during off hours
}
Custom DNS for Internal Domains
A dns script that resolves internal hostnames to local IPs:
[Script]
InternalDNS = type=dns, script-path=internal-dns.js
// internal-dns.js
var internalHosts = {
"gitlab.local": "10.0.0.10",
"registry.local": "10.0.0.11",
"monitor.local": "10.0.0.12"
}
if (internalHosts[$domain]) {
$done({address: internalHosts[$domain], ttl: 3600})
} else {
$done({}) // Passthrough to normal DNS
}
Auto-Switch Policy on Network Change
An event script that probes connectivity after a network change and switches the policy group accordingly:
[Script]
NetSwitch = type=event, script-path=network-switch.js, timeout=15
// network-switch.js
$httpClient.head("https://www.google.com/generate_204", {timeout: 5},
function(error, response, data) {
if (error) {
// Probe failed on the new network — switch to the backup group
$klne.selectPolicy("MainGroup", "BackupProxy")
console.log("Probe failed after " + $event.name)
} else {
$klne.selectPolicy("MainGroup", "MainProxy")
}
$done({})
})
Notice:
$networkis visible to every script, but itswifi.ssidis only populated on iOS and Android (see the notice in $network). On macOS and tvOS the name is empty, so an event or cron script that has to work everywhere probes with$httpClientas above rather than reading the SSID.
Periodic Health Check
A cron script that checks proxy health every 30 minutes:
[Script]
HealthCheck = type=cron, script-path=health-check.js, timeout=15, cron-expression=*/30 * * * *
// health-check.js
$httpClient.head("https://www.google.com/generate_204", {timeout: 10},
function(error, response, data) {
if (error || response.status !== 204) {
console.error("Health check failed: " + (error || "status " + response.status))
$notification.post("Chute Alert", "Health Check", "Cannot reach Google")
} else {
console.log("Health check OK")
}
$done({})
}
)
Call
$done({})inside the callback — completing the script cancels all pending$httpClientrequests, so a synchronous$done()at the end of the script would cancel the health check before its response arrives. For the same reason a request's owntimeouthas to be shorter than the script's: when the script'stimeout(5 seconds by default) runs out first, the request is cancelled and its callback never runs. Both examples above settimeout=15on the[Script]line.
Enrich API Responses with External Data
An http-response script that enriches user data by calling a secondary API:
[Script]
EnrichUsers = type=http-response, script-path=enrich.js, pattern=^https://api\.example\.com/users, requires-body=true, timeout=20
// enrich.js — at most 8 requests in flight, the per-script limit
var users = JSON.parse($response.body)
var next = 0
var pending = 0
function launch() {
while (pending < 8 && next < users.length) {
fetchAvatar(next++)
}
if (pending === 0) {
$done({body: JSON.stringify(users)})
}
}
function fetchAvatar(index) {
pending++
$httpClient.get("https://internal-api.example.com/avatar/" + users[index].id,
function(error, resp, data) {
if (!error && resp.status === 200) {
users[index].avatar = JSON.parse(data).url
}
pending--
launch()
}
)
}
launch()
Execution Model
- All scripts run on a dedicated serial queue for thread safety.
- Each script execution has a per-script timeout, counted from the start of the execution: loading the source (a remote script is downloaded within it), running the code and waiting for timers and requests all share it. If
$done()is not called within the timeout, the script is treated as passthrough. A script that returns without$done()while nosetTimeouttimer or$httpClientrequest is pending is treated as passthrough at once. - A pool of 3 pre-warmed contexts is kept; a context is discarded after each execution and replaced with a fresh one, so globals never leak between runs.
- Chute notes the process's memory use when the script engine starts. Once it has grown by more than 10 MB on iOS and tvOS, or 512 MB on macOS, every script is skipped and its message passes through; on Android the measure is the Java heap in use, with a 512 MB margin. The margin is for the whole process, not one script, and the starting point is not taken again while Chute runs.
- At most 16 script executions may be in flight at once on iOS, tvOS and Android (64 on macOS), with at most 4 MB of script source between them (8 MB on macOS). An execution over either limit is skipped and its message passes through; the log says
execution admission is full. - A script's source may be at most 1 MB on macOS and 512 KB on iOS, tvOS and Android. How many scripts a configuration may declare is not limited; what is capped is how many script sources may be loading at the same time — 32 on macOS, 8 on iOS, tvOS and Android. Over the cap the log says
source load queue is fulland that one execution runs with no source, which means it passes through. - Remote scripts (HTTP/HTTPS paths) are fetched on every execution. A positive
script-update-interval, or exactly-1, additionally makes Chute poll the URL with a conditional HEAD request every 10 minutes. The value is only a switch, not a period: the poll is every 10 minutes whatever the number says.0(the default) and any other negative value leave the polling off.
Module Script Integration
Scripts can also be defined in Module files (.sgmodule) under the [Script] section.