# `3pm serve` cheatsheet

pm2's `serve`, as a bundled module: a directory (or one HTML page) served
over HTTP by a 3pm-managed app. Nothing to install — `3pm serve` works
out of the box (`3pm module list` shows it). Full contract of modules:
[modules.md](modules.md).

## Invocations

| Command | What happens |
|---|---|
| `3pm serve` | Serves `./` on 8080 (or the next free port above it), prints the links and a QR code, returns |
| `3pm serve ./dist` | Same, for a directory |
| `3pm serve ./dist 9000` | Explicit port; busy → error, nothing started |
| `3pm serve ./dist 0` | Kernel-chosen port (shown in the links) |
| `3pm serve page.html` | Serves the page's directory with the page as homepage, opens it in the browser, **stays attached: Ctrl-C stops the server** |
| `3pm serve page.html --detach` | Same, but returns and leaves the server running |
| `3pm serve ./dist --attach` | Directory with the attached behavior (Ctrl-C stops it) |
| `3pm serve ./dist` (again) | Restarts the existing site with the new options (same name or same port) |
| `3pm --json serve ./dist 0` | `{"affected":[ProcessRecord…]}` on stdout, no links, no QR |

The app is named `static-page-server-<port>` (`--name` overrides) and is
**online only once the socket is bound** (`wait_ready`).

## Flags

| Flag | Default | Effect |
|---|---|---|
| `[PATH]` | `.` | Directory, or a single `.html`/`.htm` file |
| `[PORT]` / `--port N` | 8080 or next free | `0` = kernel-chosen |
| `--host ADDR` | `0.0.0.0` | Bind address; a loopback host prints the local link only |
| `--spa` | off | Unknown paths → homepage (pm2's two-step fallback: same basename at the root, then the homepage) |
| `--ftp` | off | Directory listing (`index.html` wins, dirs first, dotfiles hidden, `/dir` → `/dir/`) |
| `--homepage FILE` | `/index.html` | Page served for `/` (not allowed with a file argument: the file is the homepage) |
| `--basic-auth-username U --basic-auth-password P` | none | HTTP Basic auth on every request (401 + `WWW-Authenticate` otherwise) |
| `--name NAME` | `static-page-server-<port>` | App name |
| `--namespace NS` | none | App namespace |
| `--attach` / `--detach` | file: attach, directory: detach | Hold the terminal until Ctrl-C (deletes the app) / return at once |
| `--no-qr` | — | Links only, no QR code (a single page never prints one) |
| `--json` | — | Machine output (also `THREEPM_JSON=1`, set by `3pm --json serve`) |

## What you get on the wire

| Situation | Response |
|---|---|
| File | `200`, `Content-Type` from the extension (`text/*; charset=utf-8`), `Content-Length`, `Access-Control-Allow-Origin: *`, body streamed |
| `HEAD` | Same headers, empty body |
| Other methods | `405`, `Allow: GET, HEAD` |
| Missing | `404` — the site's own `404.html` when it exists |
| `..` anywhere in the path | `403` |
| Symlink resolving outside the served directory (file, directory, or anything reached through it) | `403`; never listed by `--ftp`; skipped when it would only be a fallback (`404.html`, SPA homepage). A symlink staying inside the directory is served normally. Enforced at open time: every file is resolved component by component from a descriptor of the served directory (`openat` + `O_NOFOLLOW`, links read with `readlinkat`), so a link swapped between the check and the read is still refused |
| Malformed percent-escape (`/%`, `/%e0%a4`), NUL byte | `400` (GHSA-9prm-75wm-xpg6: never a crash) |
| Directory without slash in `--ftp` mode | `301` to `…/` |

## Managing a served site

```console
$ 3pm ls                                  # under "Modules", below the apps started by hand
$ 3pm stop static-page-server-8080        # name it: `stop|restart|delete all` skip module apps
$ 3pm logs static-page-server-8080        # "Exposing <dir> directory on <host>:<port>" at start
$ 3pm delete static-page-server-8080
$ 3pm uninstall serve                     # removes every served site with the module
```

A served site survives `3pm update` like any app; for a reboot, `3pm
save` then `3pm startup`, as for any app. Either way the module's
binary must still exist (a site of the bundled module is dropped from
`resurrect` once the bundle is gone).

## pm2 ecosystem files

Files written for pm2 keep working with the module binary as the script
and `PM2_SERVE_*` env (`THREEPM_SERVE_*` wins when both are set):

```toml
[[apps]]
name = "legacy-site"
script = "/usr/lib/3pm/modules/serve/bin/3pm-serve"   # or ~/.3pm/bin/modules/serve/bin/3pm-serve
args = ["__run"]
cwd = "/srv/www"
wait_ready = true

[apps.env]
PM2_SERVE_PATH = "/srv/www"
PM2_SERVE_PORT = "8080"
PM2_SERVE_SPA = "true"
```

| Variable | Default | Also accepted |
|---|---|---|
| `THREEPM_SERVE_PATH` | `.` | `PM2_SERVE_PATH` |
| `THREEPM_SERVE_PORT` | `8080` | `PM2_SERVE_PORT` |
| `THREEPM_SERVE_HOST` | `0.0.0.0` | `PM2_SERVE_HOST` |
| `THREEPM_SERVE_SPA` / `_FTP` | `false` | `PM2_SERVE_SPA` / `PM2_SERVE_FTP` (`true`, `1`, `yes`, `on`) |
| `THREEPM_SERVE_HOMEPAGE` | `/index.html` | `PM2_SERVE_HOMEPAGE` |
| `THREEPM_SERVE_BASIC_AUTH_USERNAME` / `_PASSWORD` | none (both or neither) | `PM2_SERVE_BASIC_AUTH_*` |

Not carried over from pm2: `--monitor` (the PM2.io browser snippet).

## Rules of thumb

- Sharing with a phone: bind on every interface (the default) and scan
  the QR code of the `Network:` link; `--host 127.0.0.1` keeps it local.
- Looking at one generated report: `3pm serve report.html`, Ctrl-C when
  done — nothing is left behind.
- Long-lived site on a server: `3pm serve /srv/www 8080 --name www`,
  then `3pm save` and `3pm startup` so it comes back after a reboot.
