Script
The new Script syntax available in Loon 3.5.1 (983) and later provides one configuration format for Request, Response, Cron, Network Changed, and Generic scripts. It also adds compound conditions to HTTP scripts.
The JavaScript objects and methods available inside a script are unchanged. See the Script API.
The main [Script] section, [Remote Script] resources, and plugin [Script] sections use the same syntax, defaults, and validation rules. Plugin scripts can additionally reference parameters from the current plugin's [Argument] section.
Use the Script Configuration Editor to generate new syntax, or the Script Syntax Converter to migrate legacy configuration.
Quick start
Every script type uses the same script(...) action:
HTTP <request|response> if <condition> then script(<path> [, <argument>]) [with <options>]
Cron cron <cron-expression> then script(<path> [, <argument>]) [with <options>]
Network Changed network-changed then script(<path> [, <argument>]) [with <options>]
Generic generic then script(<path> [, <argument>]) [with <options>]
A complete HTTP example:
request if ${url} ~= /^https:\/\/api\.example\.com/i && ${request.method} == "POST" then script("request.js", "source=profile") with tag="Request Script", timeout=20, requires_body=true
Each entry has three parts:
| Part | Purpose | Example |
|---|---|---|
| Trigger / Condition | Determines when the entry triggers or matches | request if ..., cron "..." |
| Script action | Specifies the script path and $argument | script("request.js", "debug=true") |
with | Configures properties of the entry | with tag="Request", timeout=20 |
Script types
Request Script
Matches and runs before the request is sent:
request if ${url} ~= /\/api\// && ${request.method} == "POST" then script("request.js") with requires_body=true
- When
requires_bodyis omitted orfalse, the script runs at the Request Header stage. - With
requires_body=true, it waits for the complete Request Body. - At most one Request Script is selected for a request: the first complete match in final configuration order.
Response Script
Matches against request data and the original response:
response if ${url} ~= /\/api\// && ${response.status} == 200 && ${response.header['Content-Type']} ~= /application\/json/i then script("response.js") with requires_body=true
- Every Response Script must contain a mandatory URL guard. See Response URL guard.
- When
requires_bodyis omitted orfalse, the script runs at the Response Header stage. - With
requires_body=true, it waits for the complete Response Body. - At most one Response Script is selected, while preserving original configuration order.
Cron Script
Runs on a Cron schedule:
cron "0 8 * * *" then script("cron.js") with tag="Daily Task", timeout=300
Five- and six-field expressions are supported:
* * * * * minute hour day month weekday
* * * * * * second minute hour day month weekday
A plugin can supply a dynamic Cron expression through a String parameter:
cron ${cron} then script("cron.js", {${region}}) with enable=${enabled}, tag="Plugin Cron"
Due Cron scripts continue to use the existing scheduler and concurrency behavior. They do not use HTTP's first-match rule.
Network Changed Script
Runs when Loon detects a network change:
network-changed then script("network.js") with tag="Network Changed", timeout=30
All enabled Network Changed scripts run for the same network-change event.
Generic Script
Defines a script that can be run manually from the app or an existing entry point:
generic then script("switch-node.js", "region=CN") with tag="Switch Node", img_url="arrow.triangle.swap.system", timeout=30
Each Generic Script is an independent action and does not run automatically.
The script(...) action
Method declaration
script(String[, String|RawString|PluginObject])
The first argument is the script path. The optional second argument becomes $argument inside the script.
script("request.js")
script("request.js", "hello")
script("request.js", {${region}, ${level}})
An entry can contain only one script(...) action. Rewrite-style | action pipelines are not supported.
Script path
The path must be a non-empty fixed string. It may identify a local file, relative path, or remote URL:
script("local.js")
script("folder/local.js")
script("https://example.com/script.js")
Variables and templates are not allowed in the path:
# Invalid
script(${scriptPath})
script("${region}.js")
Local/remote detection, downloading, caching, and path lookup continue to use the existing behavior.
$argument
The form of the second argument directly determines the type of $argument in JavaScript.
| Configuration | $argument type |
|---|---|
| Second argument omitted | null |
| String or Raw String | String |
| Plugin object argument | Object |
No argument
request if ${url} ~= /api/ then script("request.js")
console.log($argument); // null
String argument
generic then script("tool.js", "region=CN&level=2")
The script receives the original String. Loon does not automatically parse JSON, query strings, or another application-specific format:
console.log(typeof $argument); // string
Use a Raw String for text containing many quotes or line breaks:
generic then script("tool.js", `{"region":"CN","level":2}`)
const params = JSON.parse($argument);
Plugin object argument
A plugin script can select multiple [Argument] parameters and receive an Object as $argument:
[Argument]
region = select,"CN","US",tag=Region
level = select,1,2,3,type=number,tag=Level
enabled = switch,true,tag=Enabled
[Script]
generic then script("plugin.js", {${region}, ${level}, ${enabled}})
The script receives:
$argument = {
region: "CN",
level: 2,
enabled: true
};
The object argument is not a general JavaScript object literal. Its braces can contain only ${name} references declared by the current plugin:
# Valid
script("plugin.js", {${region}, ${level}})
# Invalid
script("plugin.js", {})
script("plugin.js", {"CN", 2})
script("plugin.js", {${url}})
script("plugin.js", {${region}, ${region}})
Rules:
- The object cannot be empty, and a variable cannot occur more than once.
- Every variable must be declared in the current plugin's
[Argument]section. - Object keys come from parameter names; values preserve the String, Number, or Boolean plugin parameter type.
- An
inputor aselectwithout a default option may have no effective value. When it is used only in the object, its field value isnull. - Local scripts and ordinary Remote Script resources have no plugin parameter scope and cannot use object arguments.
- String and plugin object forms are mutually exclusive. A third argument is not supported.
The with clause
The with clause configures the Script entry itself:
with <name>=<value> [, <name>=<value> ...]
request if ${url} ~= /api/ then script("request.js") with enable=true, tag="API Script", img_url="api.system", timeout=20, debug=true, requires_body=true, binary_body_mode=false
Supported fields
| Field | Type | Default | Applies to |
|---|---|---|---|
enable | Boolean / plugin Boolean | true | All scripts |
tag | String | Derived from the script path | All scripts |
img_url | String | None | All scripts |
timeout | Number / plugin Number or numeric String | 20 for Request / Response; 300 for other types | All scripts |
debug | Boolean / plugin Boolean | false | All scripts |
requires_body | Boolean | false | Request / Response |
binary_body_mode | Boolean | false | Request / Response |
requires_body controls whether Loon waits for the complete body. binary_body_mode controls only the existing binary representation of an available body and does not enable requires_body automatically.
Cron, Network Changed, and Generic scripts have no HTTP body and cannot use requires_body or binary_body_mode.
In a plugin, enable, timeout, and debug can dynamically reference [Argument] parameters:
[Argument]
script_timeout = input,"20",tag=Timeout
script_debug = switch,false,true,tag=Debug logging
[Script]
generic then script("tool.js") with timeout=${script_timeout}, debug=${script_debug}
Field rules
- Omit the complete
withclause when it has no fields. - Field names are case-sensitive and use lowercase snake_case.
- A field cannot be repeated. An unknown field makes the entry invalid.
enable,debug,requires_body, andbinary_body_modeuse Boolean values. Dynamicenableanddebugmust reference plugin Boolean / switch parameters.timeoutmust be a finite Number greater than0. It may also reference a plugin Number or a String that strictly parses as a finite positive number. A numeric String is converted only when evaluatingtimeout; its type in$argumentis unchanged.tagandimg_urlmust be String values.- In a plugin,
enable,timeout, anddebugmay reference parameters of the required type, such as${enabled},${script_timeout}, and${script_debug}. tag,img_url,requires_body, andbinary_body_modedo not accept variables or string templates.
Missing dynamic option values
The following rules apply only when a parameter is declared in the current plugin's [Argument] section and has a valid type for the field, but has neither a user value nor a declared default. An undeclared parameter or a parameter with an invalid type still makes the current script invalid; it does not use a fallback.
| Dynamic field | Result when no effective value exists |
|---|---|
enable=${name} | Use true |
timeout=${name} | Use 20 for Request / Response; use 300 for Cron / Network Changed / Generic |
debug=${name} | Use false |
A plugin parameter used in a condition or dynamic Cron expression must have an effective value; otherwise, the current script is invalid. If the same parameter is used by a condition or Cron expression and a dynamic option, it is treated as required and cannot use the option fallback. A missing parameter used only in the plugin object $argument is still passed as null.
Each dynamic option fallback emits a Warn containing the plugin source, Script name, option, parameter name, and final default value. A missing condition or Cron parameter skips only the current invalid script and does not affect later valid scripts in the same plugin. Legacy and new Script syntax use the same binding and fallback rules.
HTTP conditions
HTTP Script conditions use the same expression syntax as the new Rewrite syntax, except that named captures are not supported.
Comparison and logical operators
| Operator | Meaning |
|---|---|
== | Exact equality with matching operand types |
~= | Regular-expression search |
&& | Logical AND |
|| | Logical OR |
() | Explicit grouping |
Precedence is:
comparison > && > ||
request if ${request.method} == "POST" && (${request.header['X-Region']} == "CN" || ${request.header['X-Region']} == "HK") then script("request.js")
Logical expressions use short-circuit evaluation. Parentheses are recommended when && and || appear together.
Available variables
| Variable | Type | Request | Response |
|---|---|---|---|
${url} | String | ✓ | ✓ |
${request.method} | String | ✓ | ✓ |
${request.header['name']} | String or null | ✓ | ✓ |
${response.status} | Number | — | ✓ |
${response.header['name']} | String or null | — | ✓ |
${pluginParameter} | String, Number, Boolean | Plugin | Plugin |
Header lookup is case-insensitive. A Request Script cannot reference response data that does not yet exist.
A plugin parameter must have the type required by its expression position. For example, a String URL pattern can be used with ~=, while a Boolean parameter can be used as enable=${enabled}.
Header value types
A Request or Response header is a String when present and null when absent. A present header with no value is the empty string "", not null.
# The header is absent
request if ${request.header['X-Optional']} == null then script("missing-request.js")
# The header is present with an empty value
response if ${url} ~= /\/api\// && ${response.header['X-Optional']} == "" then script("empty-response.js")
For ==, choose the value type according to where the comparison value comes from:
| Value type | Syntax | Use case |
|---|---|---|
| String | "CN" | Compare exactly with a fixed header value |
| Null | null | Check whether the header is absent |
| Variable | ${region} | Use an entire String value supplied by a plugin variable |
| Template | "Bearer ${token}" | Combine fixed text with one or more plugin variables |
| Raw String | `literal ${region}` | Compare literal text without escapes or variable expansion |
With ~=, the right-hand value must be a Regex. Use it for headers such as Content-Type or User-Agent that follow a pattern or may contain parameters:
response if ${url} ~= /\/api\// && ${response.header['Content-Type']} ~= /^application\/json(?:;|$)/i then script("json.js")
Headers cannot be compared directly with Number or Boolean values. A plugin variable used in a header comparison must be a String.
Raw Syntax lets you enter the complete right-hand expression directly, such as ${region}, "CN", or `CN`. It is an advanced editor input mode, not a separate configuration value type; the generated expression must still follow the syntax above.
Regular expressions
Regular expressions use /pattern/flags:
request if ${url} ~= /^https:\/\/api\.example\.com/i then script("request.js")
Supported flags:
i case-insensitive
m multiline
s dot matches newline
~= performs a search. Use explicit ^ and $ anchors when the complete value must match.
A Script URL regex only determines whether an entry matches. It does not support Rewrite's as named captures and does not create capture variables such as ${name.1}.
This is because the JavaScript code can read Request/Response data such as $request.url, $request.headers, $response.status, and $response.headers, then run another regular expression and access its capture groups directly. When captured URL content is needed, perform the match in JavaScript:
const match = $request.url.match(/\/item\/(\d+)/);
const itemId = match?.[1];
Response URL guard
Every Response Script must contain a URL guard, and that guard must be necessary for the complete condition to be true.
Valid:
response if ${url} ~= /\/api\// && ${response.status} == 200 then script("success.js")
response if (${url} ~= /\/v1\// || ${url} ~= /\/v2\//) && (${response.status} == 200 || ${response.header['X-Cache']} == "HIT") then script("api.js")
Invalid:
# No URL guard
response if ${response.status} == 500 then script("error.js")
# URL is not necessary for the expression to be true
response if ${url} ~= /\/api\// || ${response.status} == 500 then script("error.js")
At the request stage, Loon filters candidates using the URL guard and known request data. When Response Headers arrive, it evaluates the complete condition against the original Response Status/Headers. Changes made by Response Rewrite do not alter Script condition results retroactively.
Conditions not currently supported
- Request or Response Body content conditions.
- Regex
asnamed captures and capture variables; run the regular expression again in JavaScript when captured content is needed. !=,!~, logical NOT, ordering comparisons, or set operators.
Matching and execution
First HTTP match
First-match behavior applies only to Request and Response scripts:
request if ${url} ~= /api/ && ${request.method} == "POST" then script("post.js")
request if ${url} ~= /api/ then script("fallback.js")
- POST
/apirunspost.js. - GET
/apirunsfallback.js. - Request and Response each select at most one entry and do not consume each other's match position.
Even when Response Scripts go through request-stage candidate filtering, Loon selects the first entry whose final condition is true in original configuration order. If an earlier entry is waiting for response data, a later entry already known to be true cannot be selected early.
Rewrite and Script
The execution order and existing disable relationship between Rewrite and Script remain unchanged:
- A terminal response produced at the Request stage disables both Request and Response scripts.
- A matching Request Body Rewrite or Request Body Mock disables the Request Script.
- A matching Response Body Rewrite disables the Response Script.
- A Rewrite that only modifies Request Headers does not gain new disabling behavior.
- A Script condition cannot bypass a disabling decision already made by Rewrite.
Request conditions and Response candidate filtering read the URL, method, and headers after Request Rewrite processing. Final Response conditions read the original Response Status/Headers.
Local, remote, and plugin sources
All three sources use the same parser and execution model:
| Capability | Local | Ordinary remote | Plugin |
|---|---|---|---|
| New syntax | ✓ | ✓ | ✓ |
String $argument | ✓ | ✓ | ✓ |
Plugin object $argument | — | — | ✓ |
| Plugin parameter in a condition | — | — | ✓ |
Dynamic enable | — | — | ✓ |
Dynamic timeout | — | — | ✓ |
Dynamic debug | — | — | ✓ |
| Dynamic Cron | — | — | ✓ |
A Remote Script parsed as part of plugin content can use plugin parameter features when it has that plugin's parameter scope.
Plugin parameters are type-checked and bound during configuration loading. Binding prefers a saved user value and uses the declared [Argument] default only when no saved value exists. Runtime code uses an immutable parameter snapshot; changing a parameter publishes a new snapshot through configuration reload.
Legacy and new syntax
Legacy syntax remains accepted for input compatibility, but newly created or edited entries are saved in the new form. Loading legacy configuration alone does not rewrite the file.
HTTP
Legacy:
http-request ^https?:\/\/api\.example\.com script-path=request.js, requires-body=true, argument="hello", timeout=20, tag=Request
New:
request if ${url} ~= /^https?:\/\/api\.example\.com/i then script("request.js", "hello") with tag="Request", timeout=20, requires_body=true
Plugin arguments
# Legacy
http-response ^https?:\/\/api\.example\.com script-path=response.js, argument={region,level}, enabled={enabled}
# New
response if ${url} ~= /^https?:\/\/api\.example\.com/i then script("response.js", {${region}, ${level}}) with enable=${enabled}
Cron
# Legacy
cron "0 8 * * *" script-path=cron.js, argument="daily", timeout=300, tag=Daily
# New
cron "0 8 * * *" then script("cron.js", "daily") with tag="Daily", timeout=300
Network Changed and Generic
# Legacy
network-changed script-path=network.js, argument={region}, tag=Network
generic script-path=tool.js, argument="manual", img-url=tool.system, tag=Tool
# New
network-changed then script("network.js", {${region}}) with tag="Network"
generic then script("tool.js", "manual") with tag="Tool", img_url="tool.system"
Legacy HTTP URL regular expressions are case-insensitive. Conversion preserves this behavior with the i flag. Legacy requires-body, binary-body-mode, and img-url become requires_body, binary_body_mode, and img_url.
Complete example
[Argument]
enabled = switch,true,tag=Enabled
region = select,"CN","US","JP",tag=Region
level = select,1,2,3,type=number,tag=Level
cron = input,"0 8 * * *",tag=Schedule
[Script]
request if ${enabled} == true && ${url} ~= /\/order/ && ${request.method} == "POST" then script("order.js", {${region}, ${level}}) with enable=${enabled}, tag="Order", timeout=20, requires_body=true
response if ${url} ~= /\/account/ && ${request.header['Accept']} ~= /application\/json/i && ${response.status} == 200 && ${response.header['Content-Type']} ~= /application\/json/i then script("response.js", {${region}}) with enable=${enabled}, tag="Account Response", timeout=20, requires_body=true, binary_body_mode=true
cron ${cron} then script("cron.js", {${region}, ${level}}) with enable=${enabled}, tag="Scheduled Task", timeout=300
network-changed then script("network.js", {${region}}) with enable=${enabled}, tag="Network Changed", timeout=30
generic then script("tool.js", `{"action":"switch"}`) with enable=${enabled}, tag="Switch Tool", img_url="arrow.triangle.swap.system", timeout=30