Skip to content

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.

[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=10
add-token = type=http-request, pattern=^https:\/\/api\.example\.com\/, script-path=https://example.com/scripts/token.js, argument=token=abc&debug=1
[Script]
http-response ^https:\/\/api\.example\.com\/v1\/feed script-path=https://example.com/scripts/feed.js, requires-body=true, timeout=10, tag=strip-ads

Script rules can also be written in your local rules using the Surge syntax.

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.

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.

// http-response, requires-body=1
let body = JSON.parse($response.body);
if (body.data) {
delete body.data.ads;
}
$done({ body: JSON.stringify(body) });
// http-request
const 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:

  • status may be a number or a status line such as "HTTP/1.1 302 Found" (the first three-digit number is used). statusCode is accepted as an alias.
  • headers replaces 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 untouched Set-Cookie headers aren’t merged.
  • body may be a string (sent as UTF-8), an ArrayBuffer or typed array (sent as is), or another object (sent as its JSON). Changing the body only works for scripts with requires-body.
  • Changing url only 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 method has no effect.
  • If the body changed, it is sent uncompressed with a recalculated length.
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.

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.

  • console.log, console.info, console.debug (written to the engine log at info level), console.warn, console.error (warning level).
  • setTimeout, setInterval, clearTimeout, clearInterval.
  • TextEncoder and TextDecoder (UTF-8 only).
  • atob and btoa (Latin-1 strings, as in browsers).
  • URL and URLSearchParams (a WHATWG-style subset, including searchParams, origin, relative URLs and URL.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.

$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.

  • 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.

  • 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.