Profile manual
Scripts
[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 withrequires-bodyitsbody. 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'sscript-echo-responsescripts do,$done({status, headers, body})— or end the request,$done({abort: true}). - An answer script is given
$requestand$response:status,headers, and withrequires-bodyitsbody. 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 withoutrequires-bodyis read past.- Request scripts run after the rewrites and before the rules (The order of processing).
- A body longer than a script's
max-sizegoes on as it came, unscripted, a request's or an answer's. A chunked request body, or one that waits for100 Continue, is not given to them. - What a script says about where a message ends is not the engine's to
say: a
Content-LengthorTransfer-Encodingit 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), a204or304has 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 is0or7. - 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-pathURL is downloaded by the engine when it starts — through the rules, as any other connection, though not through theirSCRIPTrules, which may be its own — kept, and fetched again once its copy is older thanscript-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-pathfile 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
$persistentStorekeeps. - A run ends at its
$done, at itstimeout(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.ungzipunpacks — 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
ruleanddnsscripts, 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-requestandhttp-responsescripts have a thread and a heap of 32 MiB of their own as well, which nocron,eventorgenericscript 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,$httpAPIand$surge.retestGroupcounted 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-descriptorin$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.