Skip to main content

Services

A service is a set of scripts the daemon keeps running for you. You describe it once, as commands or as a file, and from then on the daemon owns it: it survives your terminal closing, it restarts by a policy you pick, it can come up on boot, and it serves the scripts' screens and logs on HTTP and WebSocket routes. yeet run --detach keeps one script alive past your terminal; an enabled service keeps a whole set alive past a reboot.

yeet service new pulse                                     # a service, empty
yeet service unit add pulse/app -I ./pulse.js # a script in it
yeet service unit add pulse/web -W http://127.0.0.1:9100 # a web server in it
yeet service mount pulse/web -L /log -t app -p console # a route from the one to the other
yeet service start pulse # bring it up
yeet service tree # see all of it

Run yeet service --help or yeet service <command> --help for the full option list. The help text is kept current with the binary and is the source of truth when it disagrees with this page.

What a service adds to yeet run​

yeet run is one script, one isolate, one terminal. A service is where the daemon, not your shell, is the thing holding the scripts, and that buys five things a run cannot:

  • Several scripts as one thing. A service groups any number of isolates and web servers under one name, and start, stop, restart, export, and remove act on all of them together. Isolates in the same service share a scope: a SharedWorker opened by one unit is the same worker for every other unit of that service, which is how a long-lived collector and a per-request scrape script share one registry.
  • A life of its own. run --detach outlives your terminal; an enabled service also outlives a reboot, and a restart policy brings a unit back when it goes down.
  • Routes instead of redirects. run -p serves one script's tty or console on one address. A web-server unit serves many routes on one port: several isolates, each portal, a static directory, an upstream, each with its own tenancy and cap.
  • Isolates on demand. A lazy unit is a script the daemon runs only when a route asks, and per-connection tenancy runs a fresh copy per client, so a script can be a request handler rather than a process.
  • A file. export writes the whole service as TOML or JSON and import builds it again, on this host or another.

The pieces​

A service has three kinds of parts, and yeet service tree draws them as a tree:

● noteworthy_shoulder (pulse) running restart=no (disabled)
├─app isolate id=3705
├─web web-server http://127.0.0.1:9100
│ ├─GET / => /home/you/pulse/public
│ ├─GET /log console@app
│ ├─GET /snapshot console@snapshot per-connection one line per request
│ └─GET /tty app
└─snapshot isolate --host docs (lazy)
  • The service is the root line. Every service has an id the daemon mints, two words joined by an underscore, and optionally an alias you choose, shown in parentheses. Every command that takes a service accepts either. The line also shows the service's status, its restart policy, and (disabled) when it will not start on boot.

  • Units are the children, and they are the things the daemon starts and stops. A unit is a description, not a process: the service remembers it while stopped, and start turns it into something running. There are two kinds, and a unit is one or the other for life.

    • An isolate unit is one script and how to run it: its yeet.args, its heap budget, and whether it is eager (launched with the service) or lazy (launched by a route on first demand). When it runs, it is an ordinary isolate with an id, visible in yeet ps and reachable with yeet attach -c. One unit can stand behind several running isolates at once, when a per-connection route spawns a copy per client.
    • A web-server unit is one listening address, http:// or ws:// on a literal IPv4 address and port, and the routes mounted on it. It runs nothing by itself; it is how clients reach the isolate units beside it.

    A unit is named SERVICE/NAME, such as pulse/app, and units in one service must have distinct names whatever their kind.

  • Routes hang off a web server. A route answers one path with one method, and serves one of three things: an isolate unit's portal (its tty or its console, the same two channels yeet run -p redirects) under a tenancy; the files beneath a directory; or a WebSocket upstream, a ws:// or wss:// URL elsewhere that the client's WebSocket is forwarded to, so a service can put a listener that is not yeet behind one of its paths.

What a client gets from a portal route is what yeet run -p would serve: a tty over WebSocket speaks the tty protocol, a console over WebSocket is one text frame per message, and a console over plain HTTP GET is a chunked text/plain body, one message per line, that stays open until the isolate ends or the client leaves. A static route serves the files below its path: mounted at /assets, it answers /assets, /assets/, and /assets/index.html with the directory's index.html, /assets/nope and a .. climb with a 404.

A route matches its path and everything below it, case-sensitively: a route at /log answers /log, /log/, and /log/extra, and /LOG is a 404. A service can have several web servers, each on its own address, and any route can target any isolate unit of the service. A web server bound to 127.0.0.1 answers this host only; bind 0.0.0.0 to answer other machines.

Build one, step by step​

Every output on this page was captured from a real run; only ids, paths, and timestamps differ from what you will get. The service is built from three files in /home/you/pulse:

// pulse.js — long-lived: draws on the tty and logs a JSON line every second.
let n = 0;
setInterval(() => {
n++;
if (typeof tty !== "undefined") {
tty.clear();
tty.write(style.bold(`pulse ${n}`) + "\n");
}
console.log(JSON.stringify({ tick: n, at: Date.now() }));
}, 1000);
// snapshot.js — answers one request with one line, then exits.
// The work runs from a timer: a script that has finished before the
// request's console is attached has nothing left to stream.
setTimeout(() => {
console.log(JSON.stringify({ host: yeet.args.host, at: Date.now() }));
}, 100);
<!-- public/index.html -->
<!doctype html>
<title>pulse</title>
<h1>pulse</h1>

Create the service​

$ yeet service new pulse

Created service pulse

● noteworthy_shoulder (pulse) stopped restart=no (disabled)

The daemon minted the id noteworthy_shoulder and bound the alias pulse to it. The service is stopped, has the default restart policy no, and is disabled, so it will not start on boot. It has no units yet. Without an alias you get the id alone, and -q prints just the id for scripts to capture:

$ yeet service new -q
marvelous_power

A new, disabled service does not show in yeet service list until you enable it. -a includes disabled services and marks them:

$ yeet service list -a

ID ALIAS STATUS RESTART UNITS
noteworthy_shoulder pulse stopped no (disabled)

Add an isolate​

$ yeet service unit add pulse/app --isolate ./pulse.js

Added unit pulse/app

app isolate

$ yeet service tree pulse

● noteworthy_shoulder (pulse) stopped restart=no (disabled)
└─app isolate

The unit is app, and it is an isolate unit because --isolate (-I) named a script. The path is canonicalized when you add it, so the unit remembers /home/you/pulse/pulse.js however you typed it, and a script that does not exist is refused on the spot. The script's content is captured at this moment too; see when a script is read. Like yeet run, the source can also be an http(s) URL or a git repo such as gh:yeet-src/heatsink, which is cloned when you add the unit and stored by that name.

Add a web server​

$ yeet service unit add pulse/web --web-server http://127.0.0.1:9100

Added unit pulse/web

web web-server http://127.0.0.1:9100

$ yeet service tree pulse

● noteworthy_shoulder (pulse) stopped restart=no (disabled)
├─app isolate
└─web web-server http://127.0.0.1:9100

--web-server (-W) makes a web-server unit, bound to that address once the service starts. The rules are those of yeet run -p: a literal IPv4 address and a port. A hostname is refused with Redirect targets require a literal IP address, and leaving the port off is refused because port 80 is privileged. http:// and ws:// both serve plain GET requests and WebSocket upgrades on the same port; the scheme states what the server is mostly for.

Mount routes​

$ yeet service mount pulse/web -L /log -t app -p console

Mounted route /log on pulse/web

GET /log console@app

$ yeet service mount pulse/web -L /tty -t app

Mounted route /tty on pulse/web

GET /tty app

$ yeet service mount pulse/web -L / -S ./public

Mounted route / on pulse/web

GET / => /home/you/pulse/public

$ yeet service tree pulse

● noteworthy_shoulder (pulse) stopped restart=no (disabled)
├─app isolate
└─web web-server http://127.0.0.1:9100
├─GET / => /home/you/pulse/public
├─GET /log console@app
└─GET /tty app

Each route reads as METHOD /path and then what it serves. console@app is the console portal of unit app; a bare app is its tty, the default portal. => dir is a static directory. A route's target must be an isolate unit of the same service, and the web server must be a web-server unit: mount pulse/app … is refused with Unit 'app' is not a web server, and an unknown target with Route targets unknown unit. Mounting a path that is already mounted replaces the route.

Add a lazy unit for one-shot requests​

$ yeet service unit add pulse/snapshot -I ./snapshot.js --lazy -- --host docs

Added unit pulse/snapshot

snapshot isolate --host docs (lazy)

$ yeet service mount pulse/web -L /snapshot -t snapshot -p console -T per-connection -d "one line per request"

Mounted route /snapshot on pulse/web

GET /snapshot console@snapshot per-connection one line per request

$ yeet service tree pulse

● noteworthy_shoulder (pulse) stopped restart=no (disabled)
├─app isolate
├─web web-server http://127.0.0.1:9100
│ ├─GET / => /home/you/pulse/public
│ ├─GET /log console@app
│ ├─GET /snapshot console@snapshot per-connection one line per request
│ └─GET /tty app
└─snapshot isolate --host docs (lazy)

Everything after -- is the unit's yeet.args, shown on its line, exactly as yeet run takes them. --lazy means the service does not start this unit; a route does, on first demand. -T per-connection gives every request its own fresh isolate, which runs snapshot.js once and streams its console until the script exits. -d is free text shown at the end of the route's line.

Start it​

$ yeet service start pulse
✓ Starting pulse

$ yeet service tree pulse

● noteworthy_shoulder (pulse) running restart=no (disabled)
├─app isolate id=3705
├─web web-server http://127.0.0.1:9100
│ ├─GET / => /home/you/pulse/public
│ ├─GET /log console@app
│ ├─GET /snapshot console@snapshot per-connection one line per request
│ └─GET /tty app
└─snapshot isolate --host docs (lazy)

Three things changed: the service is running, the eager unit app got an isolate id, and the web server is listening. Had the port been taken, by anything, the start would have failed with Unit web failed to start: Failed to bind web server on 127.0.0.1:9100: Address already in use and nothing would be running. The lazy unit snapshot has no id because nothing has asked for it. The isolate is an ordinary one as far as yeet ps is concerned, named after the script as you gave it, and yeet attach -c 3705 tails its console the same as for a yeet run isolate:

$ yeet ps

ID STATUS UPTIME EVAL HEAP REDIRECTS NAME
3705 running 91ms 4ms 276.2 KiB - ./pulse.js

The NAME column is the script as the unit was given it: ./pulse.js for a unit added by hand, the absolute path for one that came from a file. A shared worker that the service's isolates opened is an isolate too, listed as worker:<service id>:<path>. start on a running service is not an error; it reports ✓ Starting and changes nothing.

Connect to it​

The static route serves the page:

$ curl -s http://127.0.0.1:9100/
<!doctype html>
<title>pulse</title>
<h1>pulse</h1>

The console route over plain HTTP is a chunked text/plain stream of the running isolate's log, from the moment you connect:

$ curl -si --max-time 2.5 http://127.0.0.1:9100/log
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
x-content-type-options: nosniff
cache-control: no-store
x-accel-buffering: no
transfer-encoding: chunked
date: Mon, 28 Sep 2026 22:12:34 GMT

{"tick":1,"at":1790633555654}
{"tick":2,"at":1790633556654}

The same route over WebSocket is one text frame per message:

$ timeout 2.5 websocat -t -n ws://127.0.0.1:9100/log </dev/null
{"tick":7,"at":1790633561655}
{"tick":8,"at":1790633562654}

The per-connection route runs snapshot.js afresh for each request and ends the response when the script exits. Two requests, two isolates, two different timestamps:

$ curl -s http://127.0.0.1:9100/snapshot
{"host":"docs","at":1790633557447}
$ curl -s http://127.0.0.1:9100/snapshot
{"host":"docs","at":1790633557601}

The tty route is the screen, and it is WebSocket only. A plain GET is told to upgrade; a WebSocket client receives the PTY's bytes and can send keystrokes and a resize frame back, as described under what a client receives:

$ curl -si http://127.0.0.1:9100/tty
HTTP/1.1 426 Upgrade Required
upgrade: websocket
connection: Upgrade
content-length: 0

$ timeout 3 websocat --binary -n ws://127.0.0.1:9100/tty </dev/null | cat -v
^[[2J^[[H^[[1mpulse 4^[[0m^M
{"tick":4,"at":1790633558654}^M
^[[2J^[[H^[[1mpulse 5^[[0m^M
{"tick":5,"at":1790633559654}^M

A path that is not mounted is a 404, and a mounted path with the wrong method is a 405 that names the allowed one:

$ curl -si http://127.0.0.1:9100/nothing
HTTP/1.1 404 Not Found

$ curl -si -X POST http://127.0.0.1:9100/log
HTTP/1.1 405 Method Not Allowed
allow: GET

Read it back​

get prints a service's settable properties, and unit get a unit's, in sections like systemctl status. The flags are the ones set takes, without values: one flag prints its value bare, for scripts, and several print key value rows.

$ yeet service get pulse
Service noteworthy_shoulder (pulse) running

Attributes
alias pulse
restart no
enabled false

$ yeet service get pulse -R
no

$ yeet service get pulse -a -e
alias pulse
enabled false

$ yeet service unit get pulse/snapshot
Service noteworthy_shoulder (pulse) running
Unit snapshot isolate (lazy)

Attributes
isolate /home/you/pulse/snapshot.js
lazy true
args --host docs

$ yeet service unit get pulse/web
Service noteworthy_shoulder (pulse) running
Unit web web-server http://127.0.0.1:9100

Attributes
web-server http://127.0.0.1:9100/

Routes
GET / => /home/you/pulse/public
GET /log console@app
GET /snapshot console@snapshot per-connection one line per request
GET /tty app

tree takes the same scoping. yeet service tree pulse web prints the web server and its routes alone, and yeet service tree pulse snapshot the one unit line.

Change it​

Routes and service properties change while the service runs. A mount takes effect immediately:

$ yeet service mount pulse/web -L /each -t app -p console -T per-connection -c 4

Mounted route /each on pulse/web

GET /each console@app per-connection max=4

$ curl -s --max-time 1.5 http://127.0.0.1:9100/each
{"tick":1,"at":1790633568038}

$ yeet service set pulse -R always

Updated service pulse

● noteworthy_shoulder (pulse) running restart=always (disabled)

Units do not. Adding, removing, or changing a unit needs the service down:

$ yeet service unit set pulse/snapshot -H 512MiB

│ Error: Daemon Error
│
│ Daemon returned error: Service must be down to change its units

$ yeet service stop pulse
✓ Stopping pulse

$ yeet service unit set pulse/snapshot -H 512MiB

Updated unit pulse/snapshot

snapshot isolate --host docs (lazy)

stop takes every isolate of the service down and closes its web servers, so a client gets a refused connection rather than an HTTP error until the next start. The tree keeps the units, minus their ids:

$ yeet service tree pulse

● noteworthy_shoulder (pulse) stopped restart=always (disabled)
├─app isolate
├─web web-server http://127.0.0.1:9100
│ ├─GET / => /home/you/pulse/public
│ ├─GET /log console@app
│ ├─GET /snapshot console@snapshot per-connection one line per request
│ └─GET /tty app
└─snapshot isolate --host docs (lazy)

Enable it​

enable marks the service to start on boot, and drops the (disabled) from its line. It prints nothing. --now also starts it, so enable --now is the usual one command for "run this from now on":

$ yeet service enable pulse --now
✓ Starting pulse

$ yeet service tree pulse

● noteworthy_shoulder (pulse) running restart=no
├─app isolate id=3716
…

Once enabled, the service appears in a plain yeet service list. disable is the reverse, and disable --now also stops it. set -e and set -D change the same flag without starting or stopping anything.

Write it down​

export prints the whole service as a file, TOML by default:

$ yeet service export pulse
alias = "pulse"
restart = "no"
enabled = false

[units.app]
isolate = "/home/you/pulse/pulse.js"

[units.web]
web-server = "http://127.0.0.1:9100/"

[units.web.routes."/"]
static = "/home/you/pulse/public"

[units.web.routes."/log"]
target = "app"
portal = "console"

[units.web.routes."/snapshot"]
target = "snapshot"
portal = "console"
tenancy = "per-connection"
description = "one line per request"

[units.web.routes."/tty"]
target = "app"

[units.snapshot]
isolate = "/home/you/pulse/snapshot.js"
lazy = true
args = ["--host", "docs"]

-o pulse.toml writes it to a file instead, -j writes JSON, and an output name ending in .json does too. Defaults are left out: a unit that is eager, on the default heap, with no args, is just its isolate line.

Tear it down and bring it back​

A running service refuses to be removed unless you say -f, which stops it first:

$ yeet service remove pulse

Invalid state: `pulse` is running. Stop it first, or pass -f

$ yeet service remove pulse -f

Removed service pulse

import rebuilds a service from the file export wrote, in one request, so it either exists whole or not at all. --now enables and starts it in the same breath:

$ yeet service import pulse.toml --now

Created service pulse

✓ Starting large_period

● large_period (pulse) running restart=no
├─app isolate id=3719
├─web web-server http://127.0.0.1:9100
│ ├─GET / => /home/you/pulse/public
│ ├─GET /log console@app
│ ├─GET /snapshot console@snapshot per-connection one line per request
│ └─GET /tty app
└─snapshot isolate --host docs (lazy)

The id is new; only the alias carries over. Importing a file whose alias is taken is refused with Service name 'pulse' is taken, and -a imports it under another alias instead, which is how you run two copies of one description.

Tenancy: what a connection gets​

The -T of mount decides how connections to a route map onto the target unit. Three clients on three routes of the same app unit:

TenancyWhat a connection getsA second connection at the same time
shared (default)The unit's one running isolate. Every client sees the same stream from the moment it connects.Also served, seeing the same thing.
per-connectionA fresh isolate of the unit's script, started for this connection and gone when it ends. Its output starts from the script's beginning.Its own fresh isolate.
exclusiveThe unit's one running isolate, for this client alone.Refused with 409 Conflict until the first leaves.

Two shared readers see the same ticks:

$ curl -s --max-time 2.2 http://127.0.0.1:9100/log &
$ curl -s --max-time 2.2 http://127.0.0.1:9100/log
{"tick":6,"at":1790633650472}
{"tick":7,"at":1790633651472}
{"tick":6,"at":1790633650472}
{"tick":7,"at":1790633651472}

Two per-connection readers each get a script that just started:

$ curl -s --max-time 1.5 http://127.0.0.1:9100/each &
$ curl -s --max-time 1.5 http://127.0.0.1:9100/each
{"tick":1,"at":1790633653212}
{"tick":1,"at":1790633653212}

The second exclusive reader is turned away:

$ curl -si --max-time 2.5 http://127.0.0.1:9100/one &
$ curl -si http://127.0.0.1:9100/one
HTTP/1.1 409 Conflict

-c caps concurrent connections on any route; a connection past the cap gets the same 409 Conflict. The tree shows live per-connection isolates beneath their route and counts them on the unit:

$ yeet service tree pulse

● snappy_sandwich (pulse) running restart=no (disabled)
├─app isolate id=3738 (2 connections)
├─web web-server http://127.0.0.1:9100
│ ├─GET /each console@app per-connection max=4 (2 connections)
│ │ ├─app isolate id=3739
│ │ └─app isolate id=3740
…

Those replicas are in yeet ps too, under the unit's script name, and leave when their client does.

The same holds for a tty route. per-connection on a tty gives every WebSocket its own isolate, each drawing its own screen from the script's first frame, which is how one service serves a TUI or a yeetkit page to any number of browsers at once:

$ yeet service mount cov/web -L /tty -t app -T per-connection
$ yeet service tree cov

● guilty_grocery (cov) running restart=no (disabled)
├─app isolate id=4044 (2 connections) (lazy)
└─web web-server http://127.0.0.1:9180
├─GET /log console@app
└─GET /tty app per-connection (2 connections)
├─app isolate id=4045
└─app isolate id=4046

One-shot scripts​

A per-connection route on a script that logs and exits is a complete request handler: the response body is the console, and it ends when the script does. This is how the Prometheus exporter pattern works. The script has to still be running when the request's console is attached, which happens right after the isolate launches. A script whose whole body runs synchronously, console.log("hi") and nothing else, has exited by then, the daemon logs died during its launch, and the client gets 502 Bad Gateway. Doing the work from a timer or a promise callback is enough, as snapshot.js above does, and as main().then(console.log) does.

Lazy units​

An isolate unit is eager by default: start launches it with the service and it runs until the service stops. --lazy (-Z) hands that to the routes:

  • A shared or exclusive route on a lazy unit launches the unit's isolate on the first connection, and the isolate then stays up. It is not stopped when the last client leaves; the tree shows its id from then on.
  • A per-connection route never needs the unit's own isolate at all. Each connection gets its own, so a lazy unit behind only per-connection routes is a template that never runs on its own. This is the right setting for a one-shot script: eager would run it once at start, for nothing, and its exit would count against the restart policy.

--eager (-E) is the default and exists so unit set -E can undo --lazy. The two flags are refused together.

Restart policy​

-R on new, import, and set picks what the daemon does when a unit goes down. The choices are no, on-failure, on-failure:<max>, and always; anything else is refused by the parser. A crash.js that logs, then throws from a timer half a second later, shows each:

// crash.js
console.log("starting");
setTimeout(() => { throw new Error("boom"); }, 500);
PolicyWhen a unit that came up later exits, however it exitsWhen a unit dies while launching
no (default)The service is exited. Nothing is restarted.start fails and the service stays stopped.
on-failure:<max>The service is exited. Nothing is restarted.The start is retried max times, then fails and the service is failed.
on-failureThe service is exited. Nothing is restarted.The start is retried until the unit comes up; the service is restarting meanwhile.
alwaysThe unit is respawned, with a growing delay between attempts, forever. The service shows restarting between attempts.As on-failure without a max.

"Dies while launching" means the module could not be compiled or evaluated: a syntax error, or a throw at the top level of the script. A web server whose port is taken counts too. An uncaught error in a callback later on is an exit, not a launch failure, and only always brings the unit back from it.

Under no, the crash ends the service:

$ yeet service start flaky
✓ Starting flaky

$ yeet service tree flaky

● negligible_raw (flaky) exited restart=no (disabled)
└─crash isolate

Under always, the daemon keeps respawning it, and the tree shows the attempt count:

$ yeet service set flaky -R always
$ yeet service start flaky
✓ Starting flaky

$ yeet service tree flaky

● negligible_raw (flaky) running restart=always (disabled)
└─crash isolate id=3725

$ yeet service tree flaky

● negligible_raw (flaky) restarting restart=always (disabled) (attempt 6 of ∞): crash failed
└─crash isolate

stop ends the loop. A unit that cannot launch at all fails the start command itself, and under on-failure:2 the tree records the retries and the reason:

$ yeet service start flaky
✗ Starting flaky
Daemon returned error: Unit `broken` failed to start: Died during launch: Module evaluate failed: Error: boom at top
at /home/you/pulse/broken.js:2:7

$ yeet service tree flaky

● negligible_raw (flaky) failed restart=on-failure:2 (disabled) after 2 retries: broken: Died during launch: Module evaluate failed: Error: boom at top at /home/you/pulse/broken.js:2:7
├─crash isolate
└─broken isolate

The service statuses you will see, on the tree's root line and in list, are stopped, running, exited (a unit ended and the policy let it), restarting (between attempts under always), and failed (a start that gave up).

A shared route on a lazy unit whose script exits counts the same way: under always the exit is a failure and the service restarts, so a one-shot script belongs behind per-connection.

When a script is read​

A unit's script is read once, when the unit is added, and that copy is what every launch runs. Editing the file afterwards changes nothing: not the next per-connection replica, not restart, not stop and start, not a unit set. A unit that logged version one kept logging it through all four after its file said version two. Only adding the unit again reads the file again, and the shortest way to do that for a whole service is the export, remove, import round trip:

$ yeet service export pulse -o pulse.toml
$ yeet service remove pulse -f
$ yeet service import pulse.toml --now

The exported file carries absolute paths, so the import re-reads the scripts where they are. For one unit, unmount its routes, unit remove it, unit add it, and mount again. While you are still editing a script, yeet run --watch is the tool; a service is for a script that is done.

The service file​

export writes it and import and new -f read it. TOML or JSON, told apart by the text itself or forced with -j:

alias = "pulse"                   # optional; the id is always minted
restart = "always" # no | on-failure | on-failure:<max> | always
enabled = true # start on boot

[units.app] # an isolate unit named app
isolate = "./pulse.js" # path, http(s) URL, or git repo, as unit add -I takes it
lazy = false # optional, default false
heap-limit = "512MiB" # optional, default 384MiB
args = ["--host", "docs"] # optional, yeet.args

[units.web] # a web-server unit named web
web-server = "http://127.0.0.1:9100"

[units.web.routes."/log"] # a route, keyed by its path
target = "app" # isolate unit in the same service
portal = "console" # tty (default) | console
tenancy = "per-connection" # shared (default) | per-connection | exclusive
method = "GET" # optional, default GET; what mount -X sets
cap = 8 # optional, no cap by default
description = "the log" # optional, free text

[units.web.routes."/"]
static = "./public" # a directory, instead of target

[units.web.routes."/legacy"]
upstream = "ws://10.0.0.5:9000" # a ws:// or wss:// URL, instead of target

A relative isolate or static path resolves against the file's directory, and against the working directory when the file comes from stdin. export writes absolute paths, so an exported file imports from anywhere on the same host; write relative ones by hand for a file that lives next to its scripts. The whole service lands in one request, so a file with a script that does not exist creates nothing:

$ yeet service import sub/rel.toml            # isolate = "./pulse.js", and sub/pulse.js does not exist

│ Error: Command Failed
│
│ Failed to canonicalize local script sub/./pulse.js: No such file or directory (os error 2)

$ cat sub/rel.toml | yeet service import - # resolves against the working directory instead

Created service rel

JSON, and a service written by a script​

The file can be JSON with the same keys, and import tells the two apart by looking at the text: a JSON file needs no flag, whatever its name. -j forces the JSON reader, for a case the text does not settle, and it refuses TOML with is not a service file. export -j writes the JSON form, so the two round-trip:

$ yeet service export pulse -j | yeet service import - -a pulse-2

Since a description is just JSON on stdin, a script can write one. yeet run in a pipe prints console.log lines to stdout and nothing else, so a script that logs a description, shaped by its yeet.args, is a service generator:

// conf.js — prints a service description, shaped by yeet.args.
const env = yeet.args.env ?? "dev";
const port = env === "prod" ? 9100 : 9190;
console.log(JSON.stringify({
alias: `pulse-${env}`,
restart: env === "prod" ? "always" : "no",
units: {
app: { isolate: "./pulse.js", args: ["--env", env] },
web: { "web-server": `http://127.0.0.1:${port}`, routes: { "/log": { target: "app", portal: "console" } } },
},
}, null, 2));
$ yeet run conf.js --env prod | yeet service import -

Created service pulse-prod

● grouchy_chart (pulse-prod) stopped restart=always (disabled)
├─app isolate --env prod
└─web web-server http://127.0.0.1:9100
└─GET /log console@app

$ yeet run conf.js --env dev | yeet service import - -j

Created service pulse-dev

● nautical_remove (pulse-dev) stopped restart=no (disabled)
├─app isolate --env dev
└─web web-server http://127.0.0.1:9190
└─GET /log console@app

The relative ./pulse.js resolves against the directory you ran the pipeline in, as any stdin import does. The same works for new -f -, and --now on the import starts what the script described.

Command reference​

Every command below takes a service by id or alias, and a unit as SERVICE/NAME. Where a command takes [SERVICE/NAME] as a positional, -u (or -W for mount and unmount) gives the same thing as a flag instead.

yeet service list​

Lists services as rows: id, alias (- when none), status, restart policy, unit names. By default only enabled services are shown. -a / --all adds the disabled ones, marked (disabled) at the end of their row. ls is an alias for list.

yeet service tree [SERVICE] [UNIT]​

Draws every service with its units and routes. With SERVICE, one service. With SERVICE UNIT, one unit: an isolate's line, or a web server with its routes. Running isolates show id=, per-connection replicas are drawn beneath their route, and the root line carries the status, the restart policy, (disabled), and, when the service is restarting or failed, the attempt count and the reason.

yeet service new [ALIAS]​

Creates an empty, stopped, disabled service. Takes -R for the restart policy, or a whole description with -f <FILE> (- for stdin) plus -j to force JSON, in which case it is import. -q prints only the minted id and suppresses progress banners. -y auto-accepts consent prompts and -% caps archive inflation while the file's scripts are fetched, as yeet run takes them. An alias that another service holds is refused with Service name '…' is taken.

yeet service import <FILE>​

Creates a service from a file, as new -f does. -a <ALIAS> overrides the file's alias, and -R overrides its restart policy. -n / --now enables the service and brings it up once it exists. -j, -q, -y, and -% as for new.

yeet service export <SERVICE>​

Prints the service as TOML. -o <FILE> writes a file instead (-o - prints), -j writes JSON, and so does an -o name ending in .json.

yeet service remove <SERVICE>​

Removes a stopped service and everything in it. A running one is refused unless -f / --force, which stops it first.

yeet service enable <SERVICE> / disable​

Sets whether the service starts on boot. -n / --now also starts it (for enable) or stops it (for disable). Without --now, the running state does not change. enable alone prints nothing.

yeet service start / stop / restart <SERVICE>​

start launches every eager isolate unit and binds every web server, and reports ✓ Starting, or ✗ Starting with the daemon's reason when a unit cannot launch. On a running service it is a no-op. stop takes every isolate of the service down, lazy ones and per-connection replicas included, and closes the web servers. restart is a stop and a start, and is how a unit change takes effect. It does not pick up an edited script; see when a script is read.

yeet service get <SERVICE> / set​

set changes a service's properties: -R <POLICY>, -a <ALIAS> to name or rename it, -A / --no-alias to drop the alias so only the id answers, -e to enable, -D to disable. Properties left out are left alone, and set works on a running service. get reads the same properties: no flags prints them in sections, one flag prints its value bare, several print rows.

yeet service unit add <SERVICE/NAME> <-I SOURCE | -W URL> [-- ARGS...]​

Adds a unit. Exactly one of the two kinds:

  • -I / --isolate <SOURCE> runs a script. -Z / --lazy or -E / --eager picks when it starts; -H / --heap-limit <SIZE> sets its old-generation heap budget (512MiB, 1GiB, 2GB; default 384MiB); everything after -- is its yeet.args. -q suppresses the clone banner for a git source, -y auto-accepts consent prompts, -% caps archive inflation.
  • -W / --web-server <URL> binds an http:// or ws:// address.

A name already used in the service is refused with Unit '…' is taken. The service must be down.

yeet service unit set <SERVICE/NAME> [-- ARGS...]​

Changes a unit. For an isolate: -Z / -E, -H <SIZE> or -N / --no-heap-limit to return to the default, -- ARGS to replace the arguments or -A / --no-args to clear them. For a web server: -W <URL> moves it. A property left out is left alone; isolate flags on a web server (Unit 'web' is not an isolate) and -W on an isolate (Unit '…' is not a web server) are refused, as is a call with no flags (Nothing to set). The service must be down.

yeet service unit get <SERVICE/NAME>​

Reads a unit's properties, as get does for a service. The flags are -I (the script), -Z (lazy), -H (heap), -A (args), and -W (the web server's URL). With no flags, a web server's routes are printed last.

yeet service unit remove <SERVICE/NAME>​

Removes the unit. The service must be down. An isolate that a route still targets is refused with Unit '…' is the target of route …: unmount it first; a web server goes together with its routes.

yeet service mount <SERVICE/UNIT> -L <ROUTE> …​

Mounts a route on a web server, and can be done while the service runs. -L / --location is the path, given a leading slash if it lacks one. Then one of three things to serve:

  • -t / --target <UNIT>: an isolate unit of the same service. -p / --portal picks tty (default) or console; -T / --tenancy picks shared (default), per-connection, or exclusive.
  • -S / --static <DIR>: the files beneath a directory, over plain HTTP. The directory must exist.
  • -U / --upstream <URL>: forward the path to a ws:// or wss:// URL. http:// is refused.

And for any of them: -X / --request <METHOD>, the HTTP method the route answers, GET by default and spelled in upper case; a request with another method gets a 405 naming the allowed one. -c / --cap <N>, a ceiling on concurrent connections; -d / --description <TEXT>, shown at the end of the route's line. A path already mounted is replaced.

yeet service unmount <SERVICE/UNIT> -L <ROUTE>​

Removes the route at that path. A path that is not mounted is not an error.

Refused up front​

What you triedWhat you get
An alias another service holds, on new, import, or set -aService name '…' is taken
A unit name already in the serviceUnit '…' is taken
unit add, unit set, or unit remove on a running serviceService must be down to change its units
remove on a running serviceInvalid state: … is running. Stop it first, or pass -f
A script path that does not exist, in unit add -I or a fileFailed to canonicalize local script …: No such file or directory
A web-server URL with a hostnameRedirect targets require a literal IP address, got localhost
A web-server URL without a portBinding http://127.0.0.1/ requires a privileged port
-I together with -W, on unit add'--isolate <SOURCE>' cannot be used with '--web-server <URL>'
-Z together with -E'--lazy' cannot be used with '--eager'
mount on an isolate unitUnit '…' is not a web server
mount -t naming a unit that does not existRoute targets unknown unit '…'
mount -t naming a web-server unitRoute targets unit '…', which is not an isolate
start when a web server's port is takenUnit web failed to start: Failed to bind web server on …: Address already in use
unit remove on an isolate a route targetsUnit '…' is the target of route …: unmount it first
mount -t together with -S'--target <TARGET>' cannot be used with '--static <DIR>'
mount -S on something that is not a directoryA static route serves a directory, and … is not one
mount -U with an http:// URLAn upstream is a ws:// or wss:// URL, not http://
mount with none of -t, -S, -Uthe following required arguments were not provided: --target
A -T, -p, or -R value outside the listinvalid value '…' from the parser
A lower-case method, -X getA method is spelled as on the wire: GET
Any command naming a service that does not existNo service …