Scripts
MeowX runs JavaScript in Surge’s http-request and http-response style, with a Surge-compatible API. Scripts run on decrypted HTTPS hosts and on plain HTTP, after header rewrites, URL rewrites, Map Local and body rewrites.
Declaring scripts
Section titled “Declaring scripts”Surge [Script]
Section titled “Surge [Script]”[Script]strip-ads = type=http-response, pattern=^https:\/\/api\.example\.com\/v1\/feed, script-path=https://example.com/scripts/feed.js, requires-body=1, max-size=262144, timeout=10add-token = type=http-request, pattern=^https:\/\/api\.example\.com\/, script-path=https://example.com/scripts/token.js, argument=token=abc&debug=1Loon [Script]
Section titled “Loon [Script]”[Script]http-response ^https:\/\/api\.example\.com\/v1\/feed script-path=https://example.com/scripts/feed.js, requires-body=true, timeout=10, tag=strip-adsScript rules can also be written in your local rules using the Surge syntax.
Parameters
Section titled “Parameters”| Parameter | Meaning |
|---|---|
type |
http-request or http-response (Surge). In Loon the type comes first on the line. |
pattern |
URL regex the request must match (Surge). In Loon the regex follows the type. Matched against the original URL. |
script-path |
https:// URL of the script. The app downloads and caches it; the tunnel never downloads scripts. Required. |
requires-body |
1 / true: the body is passed to the script as $request.body / $response.body. Otherwise the script only sees the headers and the body is streamed through unchanged. |
binary-body-mode |
1 / true: the body is given to the script as a Uint8Array instead of a string. |
max-size |
Maximum body size in bytes. If the (decompressed) body is larger, this script is skipped. The platform limit (512 KB on iOS, 10 MB on macOS) always applies. |
timeout |
Seconds; default 10, maximum 60. Counted from when the script starts running, not while it waits for its turn. |
argument |
Text available as $argument. |
tag (Loon) |
Script name, if the line has no name. |
The script name (Surge’s rule name, Loon’s tag, or otherwise the script’s file name) is used for $script.name, in log messages, and as the default $persistentStore key.
Every script matching a request runs, in order (local rules first, then modules from the top of the list down). Bodies are decompressed before they reach a script; see Body rewrite for buffering limits and when bodies can’t be buffered.
Unsupported script types
Section titled “Unsupported script types”Only http-request and http-response are supported. cron, event, generic, dns, rule and other types are listed as unsupported in the module’s details. Loon’s new-syntax script(…) action is also unsupported.
Writing a script
Section titled “Writing a script”// http-response, requires-body=1let body = JSON.parse($response.body);if (body.data) { delete body.data.ads;}$done({ body: JSON.stringify(body) });// http-requestconst headers = $request.headers;headers['X-Debug'] = '1';$done({ headers });A script finishes when it calls $done(…). If it never calls $done, it finishes once nothing is pending (no timers and no $httpClient requests in flight), and the message passes through unchanged. Each run gets a fresh JavaScript context; nothing is shared between runs except $persistentStore.
Request scripts:
| Call | Effect |
|---|---|
$done() or $done({}) |
Pass through unchanged. |
$done({ url, headers, body }) |
Change the request. Omitted fields stay as they were. |
$done({ response: { status, headers, body } }) |
Don’t contact the server; answer with this response (status defaults to 200). |
$done({ abort: true }) |
Close the connection. |
Response scripts:
| Call | Effect |
|---|---|
$done() or $done({}) |
Pass through unchanged. |
$done({ status, headers, body }) |
Change the response. Omitted fields stay as they were. |
$done({ abort: true }) |
Close the connection. |
Details:
statusmay be a number or a status line such as"HTTP/1.1 302 Found"(the first three-digit number is used).statusCodeis accepted as an alias.headersreplaces the whole header list. Key order becomes header order. Give an array to send several fields with the same name. A header whose value you didn’t change keeps its original separate lines, so untouchedSet-Cookieheaders aren’t merged.bodymay be a string (sent as UTF-8), anArrayBufferor typed array (sent as is), or another object (sent as its JSON). Changing the body only works for scripts withrequires-body.- Changing
urlonly works within the same host: the scheme, host and port must stay the same. A change to a different host is ignored and diagnostics records “Cross-host rewrite not supported”. - Changing
methodhas no effect. - If the body changed, it is sent uncompressed with a recalculated length.
API reference
Section titled “API reference”Request and response
Section titled “Request and response”| Global | Contents |
|---|---|
$request |
url, method, headers (object), body (only with requires-body; string, or Uint8Array in binary mode), id. In response scripts, $request describes the request as it was sent upstream, without a body. |
$response |
status, statusCode, headers (object), body (only with requires-body). Response scripts only. |
Headers are given as an object. Fields with the same name (compared case-insensitively) are combined into one entry, joined with ", ", using the spelling of the first occurrence.
Surge API
Section titled “Surge API”| API | Behavior in MeowX |
|---|---|
$httpClient.get/post/put/delete/head/options/patch(options, callback) |
options is a URL string or { url, headers, body, timeout, 'binary-mode' }. timeout is in seconds (default 5); values above 300 are treated as milliseconds. body is ignored for GET and HEAD. The callback receives (error, response, data), with response having status, statusCode and headers; data is a string, or a Uint8Array with 'binary-mode': true. Only http and https URLs. See Where $httpClient traffic goes. |
$persistentStore.read(key) / .write(value, key) |
Strings that survive across runs and reconnects. Without a key, the script name is used. write returns false if a value is larger than 256 KB or the store would exceed 2 MB; writing null deletes the key. The store is shared by all scripts and isn’t cleared when a module is deleted. |
$notification.post(title, subtitle, body, options) |
Written to the engine log. The tunnel can’t show system notifications. |
$argument |
The rule’s argument text, or undefined if there is none. |
$script |
name, type ("http-request" / "http-response"), startTime. |
$environment |
surge-version ("5.20.0"), surge-build, system ("iOS" or "macOS"), language, meowx-version. |
$utils.ungzip(data) |
Decompresses gzip data from an ArrayBuffer or typed array and returns a Uint8Array, or null. Output is capped at 8 MB on iOS and 64 MB on macOS. |
$utils.geoip, $utils.ipasn, $utils.ipaso |
Present but always return an empty string. |
There is no $loon or $task object, so common cross-platform helper libraries (such as Env.js) detect MeowX as Surge.
JavaScript environment
Section titled “JavaScript environment”console.log,console.info,console.debug(written to the engine log at info level),console.warn,console.error(warning level).setTimeout,setInterval,clearTimeout,clearInterval.TextEncoderandTextDecoder(UTF-8 only).atobandbtoa(Latin-1 strings, as in browsers).URLandURLSearchParams(a WHATWG-style subset, includingsearchParams,origin, relative URLs andURL.canParse).- Standard JavaScript (JSON, typed arrays, promises and so on) from the system’s JavaScriptCore.
Script output and errors appear in the engine log (Logs, source Engine). Info-level messages, including console.log and notifications, only appear while logging is turned on; warnings, errors and timeouts are always recorded.
Where $httpClient traffic goes
Section titled “Where $httpClient traffic goes”$httpClient requests are sent with the system’s networking stack directly from the process running the script:
- iPhone, iPad, and Mac in TUN mode: the requests come from the tunnel extension itself. Its own traffic doesn’t enter the tunnel, so they go directly, not through your proxy rules or proxy servers.
- Mac in system proxy mode: the requests follow the system proxy, so they pass through MeowX’s local proxy and your rules.
Surge’s policy option is not supported. No cookies are stored between requests, and scripts have no access to MeowX’s account credentials or settings.
Timeouts, stuck scripts and concurrency
Section titled “Timeouts, stuck scripts and concurrency”- Concurrency: one script runs at a time on iOS and two on macOS. Others wait their turn (waiting doesn’t count toward the timeout).
- Errors: an uncaught exception lets the message pass through unchanged and is logged.
- Timeout: when a script reaches its
timeout, the message passes through unchanged (“script timed out, passed through” in the log). - Stuck scripts: JavaScript can’t be interrupted while it runs synchronous code (an endless loop, for example). If a script is still executing when it times out, MeowX abandons that thread and won’t run that script again until you reconnect (scripts are recognized by their content, so the same file in another module is also skipped). An abandoned thread keeps using a CPU core until the script returns on its own.
- Safety stop: at most one stuck script thread is tolerated on iOS and four on macOS. When that limit is reached, all scripts are skipped (messages pass through) until a stuck script finishes or you reconnect.
Each reconnect starts with a fresh script engine.
Limits on script files
Section titled “Limits on script files”- Only
https://script addresses are accepted. Each script may be at most 2 MB on iOS or 8 MB on macOS. - All scripts and remote jq files together may be at most 4 MB on iOS or 32 MB on macOS.
- Scripts that aren’t downloaded yet show Script not downloaded in the module’s details and don’t run.
- A remote update that adds a script, or changes a script’s address or parameters, always waits for your confirmation. If the file at the same address changes on the server, the update check can’t see it.
