Skip to main content

Rewrite

Rewrite modifies HTTP requests or responses when specified conditions match. It can also replace a URL, return a redirect, reject a request, or generate mock data.

This page describes the new syntax supported since Loon 3.5.1 (978).

Scope

Rewrite applies only to HTTP traffic and HTTPS traffic decrypted through MitM. It runs before rule matching.

Visual builder

Use the Rewrite Builder to combine conditions and actions, then copy the generated configuration.

Quick start

Each Rewrite entry is written on one line:

<phase> if <condition> then <action> [| <action> ...]

Set a request header:

request if ${url} ~= /^https:\/\/api\.example\.com/ then request.header.set("X-Loon", "true")

Modify a JSON response:

response if ${url} ~= /^https:\/\/api\.example\.com\/profile$/ && ${response.status} == 200 then response.json.replace("data.vip", true)

Join multiple actions with |. They run from left to right:

request if ${url} ~= /^https:\/\/api\.example\.com/ then request.header.set("X-Loon", "true") | request.header.del("Cookie")

Phases

PhaseTimingAvailable data
requestBefore the request is sentURL, request method, request headers
responseAfter response headers are receivedRequest data, response status, response headers

Request and response actions normally need separate entries:

request if ${url} ~= /^https:\/\/example\.com/ then request.header.set("X-Test", "request")
response if ${url} ~= /^https:\/\/example\.com/ then response.header.set("X-Test", "response")

A regular Rewrite entry cannot mix request actions with response actions.

response.body.mock(...) is a special case. Its configured phase is still response, but Loon generates the response before sending the request upstream. See Mock response body for its restrictions.

Conditions

Comparison operators

OperatorDescription
==Compare the complete value for equality
~=Search for a regular-expression match

Match a request method:

request if ${request.method} == "POST" then request.header.set("X-Method", "POST")

Match a response header:

response if ${response.header['Content-Type']} ~= /^application\/json(?:;|$)/i then response.header.set("X-JSON", "true")

~= searches for a matching substring by default. Use ^ and $ when you need to match the complete value.

Logical operators

OperatorDescription
&&AND
||OR
()Change or preserve condition grouping
request if ${request.method} == "POST" && (${request.header['X-Region']} == "CN" || ${request.header['X-Region']} == "HK") then request.header.set("X-Matched", "true")

Precedence:

comparison operators > && > ||

Use parentheses to make grouping explicit when combining && and ||. An explicit group with two or more direct conditions is preserved. Redundant parentheses around a single condition are omitted.

Variables

General dynamic values use ${...}:

SourceExample
Loon built-in variable${url}
Plugin argument${region}
Condition regex capture${item.1}

Built-in variables

VariableTyperequestresponse
${url}String
${request.method}String
${request.header['name']}String or null
${response.status}Number
${response.header['name']}String or null

Header name lookup is case-insensitive:

${request.header['content-type']}
${request.header['Content-Type']}

Both expressions refer to the same header. The request phase cannot reference response variables that do not exist yet.

The current version does not support reading a request or response body in an if condition, including ${request.body}, ${response.body}, and JSON key paths.

Plugin arguments

Continue to declare plugin arguments in [Argument]:

[Argument]
enabled = switch,true,tag=Enabled
price = input,9.99,type=number,tag=Price
region = select,"CN","US","JP",tag=Region
level = select,1,2,3,type=number,tag=Level

Reference the argument name directly:

[Rewrite]
response if ${enabled} == true && ${level} == 2 && ${request.header['X-Region']} == ${region} then response.json.replace("data.price", ${price})
ControlSupported typesDefault type
inputString, NumberString
selectString, NumberString
switchBooleanBoolean

Use type=number when an input or select should return a number:

price = input,9.99,type=number
level = select,1,2,3,type=number

Older plugins without type keep their existing behavior: input and select are parsed as String, while switch is parsed as Boolean. An argument is typed data only. It is not reparsed into a condition or action, and variable expansion does not run a second time.

A local Rewrite has no [Argument] source, so the local editor can use only built-in variables and regex captures declared by the current Rewrite entry.

Condition regex captures

Add as <name> after a regex condition to save its match:

request if ${url} ~= /^https:\/\/api\.shop\.com\/item\/(\d+)/ as item then request.header.set("X-Item-ID", "${item.1}")
VariableValue
${item.0}Complete match
${item.1}First capture group
${item.2}Second capture group

Restrictions:

  1. as can be used only with a ~= regex condition.
  2. Capture names must be unique within one Rewrite entry.
  3. A capture name cannot duplicate a plugin argument name.
  4. A capture index cannot exceed the regex capture-group count.
  5. A capture referenced by an action must exist on every successful expression path. It cannot be in an optional || branch.

Valid:

request if (${request.method} == "GET" || ${request.method} == "POST") && ${url} ~= /item\/(\d+)/ as item then request.header.set("X-Item", "${item.1}")

Invalid:

request if ${url} ~= /item\/(\d+)/ as item || ${request.header['X-Debug']} == "true" then request.header.set("X-Item", "${item.1}")

If the regex matches but a referenced optional capture group has no value, the current action fails and is skipped. Later actions continue to run.

Condition captures and action captures

Condition regexes and action-owned regexes use separate capture syntax:

SourceDeclarationReferenceScope
Regex in if~= /.../ as item${item.0}, ${item.1}Actions in the current Rewrite
Regex in Header/Body ReplaceAction Regex argument$0, $1Replacement argument of that action

For example:

request if ${url} ~= /^https:\/\/old\.example\.com(\/.*)$/ as item then url.replace("https://new.example.com${item.1}")
request if ${url} ~= /^https:\/\/example\.com/ then request.body.replace(/price=(\d+)/, "amount=$1")

${item.1} in the first entry comes from the if condition. $1 in the second comes from the regex owned by request.body.replace. $n is not a general Rewrite variable, cannot cross action boundaries, and is invalid in url.replace("$1").

Values and strings

Literals

TypeExample
String"hello world"
Number200, 9.99
Booleantrue, false
Nullnull
Regex/^https:\/\/example\.com/i

Fixed strings require double quotes. These values have different types:

9.99      # Number
"9.99" # String

Regular expressions

Format:

/pattern/flags

Supported flags:

FlagDescription
iCase-insensitive
mMultiline mode
sLet . match line breaks

${...} is not expanded inside a regex literal. To provide the complete regex through a plugin argument, place the variable directly on the right side of ~=:

request if ${url} ~= ${urlPattern} then request.header.set("X-Matched", "true")

Double-quoted strings

Double-quoted strings support ${...} templates:

request.header.set("X-Info", "price=${price}, region=${region}")

Supported escapes:

SyntaxResult
\"Double quote
\\Backslash
\nLine break
\rCarriage return
\tTab
\${Literal ${

Use single quotes around a header name in a variable expression:

request.header.set("X-Origin", "UA=${request.header['User-Agent']}")

Raw strings

Use backticks for fixed JSON, HTML, or other text containing many quotes:

response if ${url} ~= /^https:\/\/api\.example\.com/ then response.body.mock("json", `{"code":0,"message":"ok"}`, 200)

A raw string:

  • Does not process backslash escapes.
  • Does not expand ${...}.
  • Treats commas, equals signs, parentheses, and double quotes as content.
  • Uses two consecutive backticks for one literal backtick.

Use a regular double-quoted string when variables are required:

response if ${url} ~= /^https:\/\/api\.example\.com\/item\/(\d+)/ as item then response.body.mock("json", "{\"item\":\"${item.1}\"}", 200)

The new syntax does not split an entry on spaces, so \x20 is no longer needed to represent a space.

Actions

All actions use positional arguments:

action(value, value)

Arguments must follow the order in the method declaration. Argument names are not allowed:

# Valid
redirect(302, "https://example.com")

# Invalid
redirect(status=302, location="https://example.com")

Optional arguments can be omitted only from the right. In the declarations below, [...] marks optional trailing arguments and is not part of the configuration syntax.

Action reference

url.replace(String)
redirect(Number, String)
reject(Number[, String])
reject_img(Number)
reject_dict(Number)
reject_array(Number)
reject_video(Number)

request.header.add(String, String)
request.header.set(String, String)
request.header.del(String)
request.header.replace(String, Regex, RegexReplacement)

response.header.add(String, String)
response.header.set(String, String)
response.header.del(String)
response.header.replace(String, Regex, RegexReplacement)

request.body.replace(Regex, RegexReplacement)
response.body.replace(Regex, RegexReplacement)

request.json.add(String, Any)
request.json.delete(String)
request.json.replace(String, Any)
request.json.jq(String)
request.json.jq_file(String)

response.json.add(String, Any)
response.json.delete(String)
response.json.replace(String, Any)
response.json.jq(String)
response.json.jq_file(String)

request.body.mock(String, String[, Boolean])
request.body.mock_file(String, String[, Boolean])

response.body.mock(String, String[, Number[, Boolean]])
response.body.mock_file(String, String[, Number[, Boolean]])

A RegexReplacement is still written as a string. $0 through $n refer to matches from the regex owned by the same action.

Batch array arguments

Header changes, body regex replacement, and JSON changes can contain multiple parameter groups in one action:

request if ${url} ~= /api/ then request.header.set(["X-A", "X-B"], ["1", "2"])
response if ${url} ~= /api/ then response.body.replace([/false/, /disabled/], ["true", "enabled"])
response if ${url} ~= /api/ then response.json.add(["data.a", "data.b"], [1, true])

Actions that support batch arguments:

  • request.header.add/set/del/replace
  • response.header.add/set/del/replace
  • request.body.replace and response.body.replace
  • request.json.add/delete/replace
  • response.json.add/delete/replace

The existing scalar form remains valid. Array arguments follow these rules:

  1. Every argument of the same action must be an array; scalar and array arguments cannot be mixed.
  2. All argument arrays must have the same length. Elements with the same index are paired and run in order.
  3. Arrays cannot be empty or nested.
  4. Every element must still match the String, Regex, RegexReplacement, or Any type required at that position.
  5. Every JSON key path is validated separately.

For example:

request.header.del(["Cookie", "Referer"])
request.header.replace(["X-A", "X-B"], [/old-a/, /old-b/i], ["new-a", "new-b"])
response.json.delete(["data.ads", "data.tracking"])

Batch syntax has the same execution result as listing the corresponding actions in the same order, but it remains stored as one batch instruction.

URL changes

url.replace

url.replace uses the single mandatory URL regex in if as its replacement range. The action does not repeat that regex:

request if ${url} ~= /^https:\/\/old\.example\.com(\/.*)$/ as urlMatch then url.replace("https://new.example.com${urlMatch.1}")

Only the matched range is replaced. Unmatched URL content is preserved.

Restrictions:

  1. if must contain exactly one ${url} ~= /.../ condition that every successful path passes through.
  2. The URL regex cannot be in an optional || branch.
  3. Use as and ${name.n} when capture groups are needed.
  4. $n is not allowed in the url.replace argument.

redirect

request if ${url} ~= /^http:\/\/example\.com/ then redirect(302, "https://api.example.com")

Arguments:

PositionTypeDescription
1NumberStatus code; currently 302 or 307
2StringReplacement for the URL regex match

redirect follows the same mandatory URL regex rules as url.replace and preserves unmatched URL content.

For this input:

http://example.com/item/123?region=CN

The example produces:

https://api.example.com/item/123?region=CN

Reject

ActionResponse
reject(status)Specified status, empty body
reject(status, body)Specified status and UTF-8 text
reject_img(status)1×1 GIF
reject_dict(status)JSON object {}
reject_array(status)JSON array []
reject_video(status)Blank video

The status must be an integer from 100 through 599.

request if ${url} ~= /^https:\/\/example\.com\/ads/ then reject_dict(200)
request if ${url} ~= /^https:\/\/example\.com\/blocked/ then reject(451, "Unavailable for legal reasons")

reject(200, "{}") returns plain text. Use reject_dict(200) or reject_array(200) when a JSON Content-Type is required.

Headers

Request headers:

request if ${url} ~= /^https:\/\/example\.com/ then request.header.add("X-Loon", "true")
request if ${url} ~= /^https:\/\/example\.com/ then request.header.set("User-Agent", "Loon")
request if ${url} ~= /^https:\/\/example\.com/ then request.header.del("Cookie")
request if ${url} ~= /^https:\/\/example\.com/ then request.header.replace("User-Agent", /iPhone OS (\d+)/, "iPhone OS $1")

Response headers:

response if ${url} ~= /^https:\/\/example\.com/ then response.header.add("X-Loon", "true")
response if ${url} ~= /^https:\/\/example\.com/ then response.header.set("Cache-Control", "no-cache")
response if ${url} ~= /^https:\/\/example\.com/ then response.header.del("Set-Cookie")
response if ${url} ~= /^https:\/\/example\.com/ then response.header.replace("Content-Type", /^(.+); charset=.+$/i, "$1")
ActionArgument order
*.header.addHeader name, header value
*.header.setHeader name, header value
*.header.delHeader name
*.header.replaceHeader name, regex, replacement

The replacement in header.replace supports $0 and $1 through $n from the action's regex, as well as general ${...} variables.

Body regex replacement

request if ${url} ~= /^https:\/\/example\.com/ then request.body.replace(/"price":\s*([0-9.]+)/, "\"originalPrice\":$1")
response if ${url} ~= /^https:\/\/example\.com/ then response.body.replace(/"enabled":\s*(false)/, "\"enabled\":$1")

Argument order:

Regex, RegexReplacement

In the replacement, $0 is the complete match and $1 through $n are capture groups from the current action's regex. General ${...} variables are also supported.

JSON

Request JSON:

request if ${url} ~= /^https:\/\/example\.com/ then request.json.add("data.price", 9.99)
request if ${url} ~= /^https:\/\/example\.com/ then request.json.delete("data.ads")
request if ${url} ~= /^https:\/\/example\.com/ then request.json.replace("data.price", ${price})
request if ${url} ~= /^https:\/\/example\.com/ then request.json.jq(".data.ads = []")
request if ${url} ~= /^https:\/\/example\.com/ then request.json.jq_file("request-filter.jq")

Response JSON:

response if ${url} ~= /^https:\/\/example\.com/ then response.json.add("data.price", 9.99)
response if ${url} ~= /^https:\/\/example\.com/ then response.json.delete("data.ads")
response if ${url} ~= /^https:\/\/example\.com/ then response.json.replace("data.price", ${price})
response if ${url} ~= /^https:\/\/example\.com/ then response.json.jq(".data.ads = []")
response if ${url} ~= /^https:\/\/example\.com/ then response.json.jq_file("response-filter.jq")
ActionArgument order
*.json.addKey path, value
*.json.deleteKey path
*.json.replaceKey path, value
*.json.jqInline JQ
*.json.jq_filePlugin resource file

JSON actions apply only when the body is valid JSON. Key paths use dot notation and [n] for an array index:

data.apps[0].appName

A JSON value can be a String, Number, Boolean, null, or variable.

Mock request body

Inline body:

request if ${url} ~= /^https:\/\/example\.com/ then request.body.mock("json", `{"price":9.99}`)

Plugin resource file:

request if ${url} ~= /^https:\/\/example\.com/ then request.body.mock_file("json", "request_body.json")

Base64 data:

request if ${url} ~= /^https:\/\/example\.com/ then request.body.mock("png", "iVBORw0KGgo...", true)
MethodArgument order
request.body.mockContent type, inline body, optional Base64
request.body.mock_fileContent type, resource file, optional Base64

Base64 defaults to false.

Mock response body

Inline body:

response if ${url} ~= /^https:\/\/example\.com/ then response.body.mock("json", `{"code":0}`, 200)

Plugin resource file:

response if ${url} ~= /^https:\/\/example\.com/ then response.body.mock_file("json", "response_body.json", 200)

Base64 data:

response if ${url} ~= /^https:\/\/example\.com/ then response.body.mock("png", "iVBORw0KGgo...", 200, true)
MethodArgument order
response.body.mockContent type, inline body, optional status, optional Base64
response.body.mock_fileContent type, resource file, optional status, optional Base64

The status defaults to 200, and Base64 defaults to false. To provide Base64, the status argument must be present first.

A Rewrite containing a response mock has these restrictions:

  1. It can contain only one response.body.mock or response.body.mock_file.
  2. Apart from the mock, it can contain only response.header.* actions.
  3. Its if condition and mock arguments cannot reference ${response.status} or ${response.header['name']}, because no response exists yet.
  4. Loon creates the mock response first, then runs all header actions in configuration order.
  5. Loon removes Transfer-Encoding and recalculates Content-Length from the final body.

Supported content types:

json, text, css, html, javascript, plain,
png, gif, jpeg, tiff, svg, mp4, form-data

Use the corresponding *_file method for medium or large data.

Execution rules

Multiple Rewrite entries

All matching Rewrite entries in the same phase run in configuration order:

request if ${url} ~= /^https:\/\/example\.com/ then request.header.set("X-Value", "first")
request if ${url} ~= /\/api\// then request.header.set("X-Value", "second")

A request to https://example.com/api/user ends with:

X-Value: second

Source priority is local configuration > plugins. Entries from the same source run from top to bottom.

Multiple actions

Actions in one Rewrite entry run from left to right in | order.

If an action fails at runtime:

  1. Earlier completed changes are kept.
  2. The failing action is skipped and an error is recorded.
  3. Later actions continue to run.

An invalid argument count, argument type, or regular expression is a configuration error. Loon rejects the complete Rewrite entry when loading it instead of handling the issue at runtime.

Old and new syntax

Legacy and new syntax can be used together:

[Rewrite]
^https://example\.com header-add X-Order old
request if ${url} ~= /^https:\/\/example\.com/ then request.header.set("X-Order", "new")

After parsing, both forms enter the same execution sequence and run in configuration order. Syntax type does not change priority.

Legacy syntax is accepted only as input compatibility. Generated, saved, and fully displayed Rewrite configuration uses the new syntax.

Common mappings:

Legacy actionNew action
headerurl.replace(...)
302, 307redirect(...)
reject, reject-200reject(...)
reject-imgreject_img(...)
reject-dictreject_dict(...)
reject-arrayreject_array(...)
reject-videoreject_video(...)
header-add, header-replace, header-delrequest.header.*
response-header-*response.header.*
request-body-replace-regexrequest.body.replace(...)
response-body-replace-regexresponse.body.replace(...)
request-body-json-*request.json.*
response-body-json-*response.json.*
mock-request-bodyrequest.body.mock(...)
mock-response-bodyresponse.body.mock(...)

In legacy header, 302, and 307 entries, $n in the replacement refers to the leading URL regex. During conversion, Loon assigns a capture name and converts $n to ${name.n}. $n belonging to the regex in a Header/Body Replace action remains action-local.

Multiple operations of the same type on one legacy line are merged into batch array arguments during conversion. A single parameter group remains in scalar form.

The development-only http-request, http-response, and named-argument forms were never released and are not compatibility syntax.

Complete example

[Argument]
enabled = switch,true,tag=Enabled
price = input,9.99,type=number,tag=Price
region = select,"CN","US","JP",tag=Region

[Rewrite]
request if ${enabled} == true && ${request.method} == "GET" && ${url} ~= /^https:\/\/api\.shop\.com\/item\/(\d+)/ as item && ${request.header['X-Region']} == ${region} then request.header.set("X-Item-ID", "${item.1}") | request.header.set("X-Region", "${region}")

response if ${enabled} == true && ${url} ~= /^https:\/\/api\.shop\.com\/item\/(\d+)/ as item && ${response.status} == 200 then response.json.replace("data.price", ${price}) | response.header.set("X-Rewritten-Item", "${item.1}")

Configuration validation

When loading configuration, Loon validates:

  • Plugin arguments and capture names are valid, defined, and conflict-free.
  • Capture indexes do not exceed regex capture-group counts.
  • A referenced capture exists on every successful expression path.
  • Variables and actions are available in the selected phase.
  • Action argument counts, order, and types are correct.
  • Batch arrays are non-empty, have matching lengths, and are not mixed with scalar arguments.
  • URL replacement and redirect actions have one mandatory URL regex.
  • Regular expressions compile successfully.
  • Response mock actions follow their action-combination and variable restrictions.

Errors include a line number and reason:

Rewrite line 18: undefined argument ${price2}
Rewrite line 21: regex item has 2 capture groups; ${item.3} is invalid
Rewrite line 25: the request phase cannot reference ${response.status}

Developer notes

  • The parser must recognize string, raw-string, regex, and variable boundaries. It must not split a line directly on spaces or commas.
  • if, then, &&, ||, |, ,, parentheses, and array boundaries [] have syntax meaning only at the outermost level.
  • Plugin arguments must enter the syntax tree as typed data. Do not substitute their text and parse the line again.
  • Variables expand once. ${...} inside an argument value does not trigger a second expansion.
  • Positional argument order for a released action remains stable. New optional arguments can be appended only at the end.