Pipe Manual

Profile manual

Scripts

On this page
  1. The line
  2. Script types
  3. Where a script comes from
  4. How a run goes
  5. The API
  6. [Panel]

[Script] holds scripts: JavaScript that Pipe runs when something happens — a request or an answer passes, a rule asks, a name is looked up, an event comes, a time comes — or when it is asked to. [Panel] shows what scripts say in the app.

[Script]
fix-api = type=http-response, pattern=^https://api\.example\.com/, requires-body=true, script-path=https://example.com/fix-api.js
office-hours = type=rule, script-path=https://example.com/office-hours.js
geo-answer = type=dns, script-path=https://example.com/geo-answer.js
on-network = type=event, event-name=network-changed, script-path=https://example.com/network.js
nightly = type=cron, cronexp="0 2 * * *", script-path=https://example.com/nightly.js, timeout=30
my-ip = type=generic, script-path=https://example.com/my-ip.js

[Panel]
Address = title="Address", content="Not known yet", style=info, script-name=my-ip, update-interval=600

Scripts run in QuickJS, a JavaScript engine inside Pipe: each run in a context of its own, with no file system, under a memory cap and a time limit. A script that fails is as if it had said nothing.

The line#

name = parameter=value, …: the name, then the parameters. Without a type, the script is generic.

[Script]
greeting = type=cron, cronexp="0 8 * * *", script-path=https://example.com/a.js, argument=hello

Two scripts with one name, a line of another form, a type Pipe does not know, and a script without a script-path are errors.

Parameter For Values Default What it does
type all http-request, http-response, rule, dns, event, cron, generic generic When the script runs.
script-path all a file or a URL required The script (below).
script-update-interval a URL seconds 86400 How often the script is fetched again.
timeout all seconds 5 How long a run may take: at most 13 for rule and dns scripts, which a connection or a lookup waits for, just under an hour (3598) for http-request and http-response, a day for the rest.
argument all text none What the script finds in $argument.
pattern http-request, http-response a regular expression required The URLs the script sees.
requires-body http-request, http-response true, false false The script is given the body too.
max-size http-request, http-response bytes 1048576 The largest body the script is given; -1 lifts it. However large it says, no body over 32 MiB is given (4 MiB on iOS).
binary-body-mode http-request, http-response true, false false The body is given as bytes, a Uint8Array, rather than text.
full-header-mode http-request, http-response true, false false Headers are given as a list of {field, value}, one per line of the head, rather than as one value per name.
cronexp cron a cron expression required When the script runs.
event-name event network-changed, notification, engine-started, profile-reloaded required What the script hears.
engine all jsc, webview — Every script runs in Pipe's engine; webview is noted (Web APIs).
debug all true, false false true is noted: a script in a file is read when the profile is loaded, debugging or not.
wake-system cron true, false false true is noted: not supported; a cron script runs while the tunnel is up.
ability all — — Read past.

A parameter not in the table is noted, and read past. A value Pipe cannot read is warned about.

Script types#

http-request and http-response#

[Script]
add-token = type=http-request, pattern=^https://api\.example\.com/, script-path=https://example.com/add-token.js
fix-feed = type=http-response, pattern=^https://api\.example\.com/feed, requires-body=true, max-size=-1, script-path=https://example.com/fix-feed.js

These see the exchanges the HTTP engine carries whose URL their pattern finds — the URL, or the same with the host its Host names or the name its connection was made to, as for [URL Rewrite]. For each request and each answer, the first script of the profile that matches runs, and no other.

  • A request script is given $request: url, method, headers (its names found in any case; a name that comes twice has its values joined by , , Set-Cookie's by new lines), id, and with requires-body its body. It may change the method, the path and query of the URL (not its host), the headers and the body: $done({url, method, headers, body}). A URL of another scheme, host or port leaves the request as it came — every change of that run with it — since its connection is to this server. It may answer in the server's place, $done({response: {status, headers, body}}) — or, as Quantumult X's script-echo-response scripts do, $done({status, headers, body}) — or end the request, $done({abort: true}).
  • An answer script is given $request and $response: status, headers, and with requires-body its body. It may change the status, the headers and the body: $done({status, headers, body}), or end it, $done({abort: true}).
  • $done({}) changes nothing. A body given back by a script without requires-body is read past.
  • Request scripts run after the rewrites and before the rules (The order of processing).
  • A body longer than a script's max-size goes on as it came, unscripted, a request's or an answer's. A chunked request body, or one that waits for 100 Continue, is not given to them.
  • What a script says about where a message ends is not the engine's to say: a Content-Length or Transfer-Encoding it gives is read past, and an answer is framed by the engine — a status below 200 is no answer it can make (the server's goes), a 204 or 304 has no body after it, whatever the script gave one.

rule#

[Rule]
SCRIPT,office-hours,Work,requires-resolve

A rule script decides SCRIPT rules, when the rules reach them. It is asked once per connection, however many rules name it, and is given $request: hostname, destPort, protocol (TCP, UDP, HTTP, HTTPS, and Pipe's ICMP for a ping), sourceIP, sourcePort, listenPort, processPath where the platform tells it, url and userAgent for a request the HTTP engine carries, and — with requires-resolve on the rule — dnsResult, the name's v4Addresses and v6Addresses. The rule matches when the script says $done({matched: true}).

dns#

[Host]
*.dynamic.example = script:geo-answer

A DNS script answers for the names [Host] gives it. It is given $domain, and answers with addresses — $done({address: "1.2.3.4"}), $done({addresses: […], ttl: 600}) — or with servers to ask, $done({servers: ["8.8.8.8"]}). An answer is kept for its ttl, in seconds; one without is asked again each time. A script that says nothing, or fails, leaves the name to be looked up as if [Host] did not have it.

event#

An event script runs when its event comes, and is given $event: name, and data.

event-name Comes when
engine-started Pipe has started.
network-changed The network the device is on has changed.
profile-reloaded The profile changed and went into the running engine. The new profile's scripts hear it.
notification A notification was posted; data says what it was. A script this event starts cannot post notifications itself.

An event script without event-name, or with another event, never runs, and is warned about.

cron#

nightly = type=cron, cronexp="0 2 * * *", script-path=https://example.com/nightly.js
quarter = type=cron, cronexp="0 */15 * * * *", script-path=https://example.com/quarter.js

A cron script runs on its schedule while the tunnel is up, by the device's local time. It is given $cronexp.

  • The expression has five fields — minute, hour, day of the month, month, day of the week — or six, seconds first.
  • A field is *, a value, a range (1-5), any of these with a step (*/10, 0-30/5, 5/10: from 5 on), or a list of them separated by commas. Months and days may be named (jan, sun); Sunday is 0 or 7.
  • When the day of the month and the day of the week are both restricted — neither written *, a step alone (*/2) is not — either one matching is enough.
  • The clocks changing is taken as cron takes it: a time the clocks skip does not come that day, and one they repeat does not fire a job fixed to it twice.
  • An expression Pipe cannot read is an error.

generic#

A generic script runs when it is asked to: from the app's list of scripts, which can run cron and event scripts by hand too, or for a panel. It is given $trigger, and $input where the caller gives one.

Where a script comes from#

  • A script-path URL is downloaded by the engine when it starts — through the rules, as any other connection, though not through their SCRIPT rules, which may be its own — kept, and fetched again once its copy is older than script-update-interval, while the copy goes on being used.
  • One download of a script is under way at a time, whoever asks for it. A download that failed is tried again after half a minute, then twice as long each time, up to an hour; until then a script with no copy answers nothing to traffic — a request, a rule, a name — while a run by hand, on a schedule or for an event waits for it.
  • A script-path file is read when the profile is compiled, by its path from the profile's folder. A file that is not there leaves its script out, with a warning. On iPhone, the app keeps no files beside a profile yet (Files and URLs): give scripts there by URL.

How a run goes#

  • Each run has a context of its own: what one run leaves for the next is what $persistentStore keeps.
  • A run ends at its $done, at its timeout (5 seconds unless the line says), at an exception nothing caught — which is as if it had said nothing — or as soon as nothing is left that could call $done: no timer, no request waiting. What it asked for and never got is called off when it ends: a request in flight goes no further.
  • Scripts are run in non-strict mode, as most are written.
  • Runs share a JavaScript heap of 12 MiB on iOS, or 32 MiB elsewhere, which grows past that while Pipe's memory has room for it — up to 32 MiB on iOS (enough to run a backend the size of Sub-Store's), 512 MiB elsewhere — and gives what it grew by back when the runs that took it are done. A script that would take the heap past what there is room for just then runs out of memory, as if it had thrown. Whatever a script is given as bytes — a body, a response, what $utils.ungzip unpacks — is made in that heap too. Up to 16 run at once on iOS, 64 elsewhere; more wait their turn. A new run is refused when 256 are already waiting or running.
  • Runs share a thread, too: while one waits — on the network, a timer — the others run, but one that computes holds up the rest until it is done or out of time. So rule and dns scripts, which traffic waits on, run on a thread of their own, with a heap of their own that does not grow: on iOS 4 MiB of the 12 (8 of them at once), elsewhere 32 MiB. No other script holds them up. On CLI hosts, http-request and http-response scripts have a thread and a heap of 32 MiB of their own as well, which no cron, event or generic script holds up; on them, where the 12 MiB do not split three ways, they share the thread of those.
  • A run may hold 20 HTTP requests at once, and make 256 in all ($httpClient, $httpAPI and $surge.retestGroup counted together); 64 timers may wait at once, 4096 may be set in all (cleared ones too), and a timer is a day at most. A run may post 8 notifications, write 512 lines to the log and 64 to the logbook, and write 1024 values to $persistentStore.
  • Should Pipe's own side of the scripts fail, the runs under way end — each says why — the log says so, and the scripts start afresh for the runs that come next; should it fail a fourth time, those scripts stop running until the profile is loaded again, and the log says that too.
  • What a script logs goes to Pipe's log, a line at most 8,192 characters long.

The API#

$done#

$done(value) ends the run with its result: what each type takes is under Script types. A body given as a Uint8Array is taken as bytes. Only the first call counts.

$httpClient#

$httpClient.get({ url: "https://example.com/ip", headers: { Accept: "application/json" } }, (error, response, data) => {
  if (error) return $done({});
  $done({ title: "Address", content: JSON.parse(data).ip });
});

get, post, put, delete, head, options and patch send a request — given a URL, or an object — and call back with an error, or with the response (status, headers) and its data.

Option What it does
url An http:// or https:// URL. Required.
headers The request's headers. Without them, User-Agent: pipe/<version> and Accept: */*.
body Text, bytes, or an object, which is sent as JSON with Content-Type: application/json.
timeout Seconds; 5 unless it says.
policy The policy the request goes through. Without it, it goes where the rules send it.
insecure true: the server's certificate is not checked.
auto-redirect false: redirects are not followed.
auto-cookie Read past: Pipe keeps no cookies between requests; a script sends the Cookie it means to.
binary-mode true: the data comes back as bytes.
full-header-mode true: the response's headers come line by line.

A request that gives policy-descriptor fails, rather than going anywhere else. A response's body is given to the script up to 12 MiB on iOS, 32 MiB elsewhere; a larger one is an error.

A script's requests go where the rules send them, as Pipe's own do: servers on the local network, and the device itself, included. With insecure, a request is made to whatever answers for its host.

$httpAPI#

$httpAPI(method, path, body, callback) calls the allowed parts of Pipe's HTTP API from inside, without an http-api line:

Operation Routes
Read policies and groups GET /v1/policies, /v1/policies/detail, /v1/policy_groups, /v1/policy_groups/select, /v1/policy_groups/test_results
Read or choose mode and global policy GET or POST /v1/outbound, /v1/outbound/global
Choose a group's member POST /v1/policy_groups/select
Read connections and DNS GET /v1/requests/recent, /v1/requests/active, /v1/dns
Read or refresh rule lists GET /v1/external_resources; POST /v1/external_resources/update with only the list's opaque key
Reload the app's own prepared profile POST /v1/profiles/reload, with no body, null, or {}

What a script reads it can send on, so it is told less than the app. A connection comes with where it went (https://example.com/…) but not the path and query of its URL, the process that made it, its notes or the rule as written. A rule list comes as its origin, or a file's name. A reload a script asks for does not run profile-reloaded scripts.

The corresponding native reads (/mode, /selectors, /groups, /connections, /dns, /dns/history, /dns/cache, /dns/fakeip, /rulesets) and selections (POST /mode, /select) are also available. POST /rulesets/update takes only an opaque id from the list. A script cannot refresh every list at once, supply configuration, or name a file to reload. Other methods, routes and extra mutation fields are refused.

Rule-list diagnostics keep line numbers and fixed reasons, without quoting the input line. Connection lists omit processPath and pid. Import and module reviews say reads your connections and DNS whenever scripts can run, including scripts fetched later and Script-section includes; the review does not rely on scanning downloaded JavaScript for API calls. Module arguments are expanded for that disclosure even when the module's requirements are not met or another part of it does not compile.

$persistentStore#

$persistentStore.write(value, key) keeps text under a key, and $persistentStore.read(key) gives it back; write(null, key) forgets it. Without a key, the scripts with the same script-path share one value. Values are kept in Pipe's data folder, 4 MiB each at most and 64 MiB in all, under at most 4096 keys; a write past any of these fails, and says so by returning false. A value crosses from the store to the script, and back, through memory of Pipe's own: a read or a write it has no room for throws, saying so.

Quantumult X's API#

Scripts written for Quantumult X find its names for the same things:

Global Is
$task.fetch(request) A request, as $httpClient's: url, method, headers, body or bodyBytes, timeout in milliseconds, opts.policy, opts.redirection. It gives a promise of {statusCode, headers, body, bodyBytes}, or one rejected with {error}.
$prefs.valueForKey(key), setValueForKey(value, key), removeValueForKey(key) $persistentStore's read and write. removeAllValues() does nothing, and gives false.
$notify(title, subtitle, message, options) $notification.post.

A status given as a status line — "HTTP/1.1 200 OK" — is its number.

$notification#

$notification.post(title, subtitle, body, options) posts a notification on the device. Of its options, url (or open-url) is opened when the notification is tapped and sound plays the default sound; the others (action, media-url, auto-dismiss, …) are read past.

$utils#

Function Gives
$utils.geoip(address) The address's country code, from the profile's country database, or "".
$utils.ipasn(address) Its autonomous system's number, or null.
$utils.ipaso(address) Its autonomous system's organization, or "".
$utils.ungzip(bytes) The bytes, gzip undone, up to 8 MiB on iOS, 32 MiB elsewhere; or null.

$surge#

The object that controls Pipe, by the name scripts are written for.

Function What it does
setSelectGroupPolicy(group, policy) Picks a member of a select group. Gives whether it was done.
selectGroupDetails() Gives the groups and what each has picked.
setOutboundMode(mode) Switches the mode: rule, global-proxy (or global), or direct. Gives whether it was done.
retestGroup(group, callback) Tests a group's members again, and calls back when done.
logbook(text) Writes a line to the logbook, which the app shows.
setHTTPCaptureEnabled, setRewriteEnabled, setEnhancedModeEnabled, setCellularModeEnabled Change nothing, and give false.

What a script is told#

Global Holds
$environment system (iOS), language, device-model, pipe-version, and surge-version: the version of the script API, 5.23.0 (6.10.0 on a CLI host), as scripts written for it check it.
$script name, type, startTime, sessionID, binaryBodyMode.
$network The network: wifi (ssid, bssid), v4 (primaryAddress, primaryInterface, primaryRouter), v6 (primaryAddress, primaryInterface), dns; on cellular, cellular-data (carrier, radio).
$argument The line's argument.
$trigger What started a run asked for: auto-interval or button for a panel, http-api for one the HTTP API evaluates.
$input, $intent What the caller gave a run asked for.
$request, $response, $domain, $event, $cronexp What the script's type is given (Script types).

console.log, info, debug, warn and error write to Pipe's log. setTimeout and clearTimeout keep time.

Web APIs#

Scripts written for a browser's engine find the Web APIs they are most often written with: fetch — through the rules, or the policy it names, as $httpClient goes — with Headers, Response and AbortController; TextEncoder and TextDecoder (UTF-8); URL and URLSearchParams; atob and btoa; crypto.getRandomValues and crypto.randomUUID; setInterval and queueMicrotask. There is no DOM, and no crypto.subtle. A line with engine=webview gets a note that says so.

Not supported yet#

  • policy-descriptor in $httpClient: a request that gives one fails.
  • wake-system: a cron script runs while the tunnel is up.

[Panel]#

A panel shows text in the app, on the proxies page (Profile › All Proxies), where policies are picked: the line's own text, or what a generic script says.

[Panel]
Welcome = title="Pipe", content="Line one\nLine two", style=good
Address = title="Address", content="Not known yet", icon=network, icon-color=#4A52E6, script-name=my-ip, update-interval=600
Parameter Values What it does
title text The panel's title.
content text Its text; \n starts a new line.
style good, info, alert, error How it is shown.
icon an SF Symbol's name An icon, in place of the style's.
icon-color #RRGGBB The icon's colour.
script-name a generic script The script that fills the panel in.
update-interval seconds How often the script runs again while the panel is shown.

A panel that names a script shows what the script passes to $done — title, content, style, icon, icon-color — once it has run. The script runs when the panel is shown with Pipe on, again every update-interval seconds while it stays shown, and when its button is tapped. It is given $trigger — auto-interval or button — and $input: {purpose: "panel", position: "policy-selection", panelName}.

A panel naming a script that is not a generic script of the profile is warned about, and shows its own text. A parameter not in the table is noted, and read past.