# 20 — Resident preview server (`--serve`)

*(2.1.0)* One long-lived JVM that answers HTTP requests for `--derivative-preview` renders. Built
for web2print previews, where the question is "what does this template look like with these
values" many times a minute for the same handful of templates, and a 3 s cold one-shot per
keystroke is the wrong tool.

The per-request code **is** `--derivative-preview`'s: both the one-shot and the server call the
same steps of `derivatives/PreviewRun` (load package, pick dataset, choose engine, render page
sink, encode PNG) in the same order with the same error precedence. The server only keeps the
first two steps' results in memory between requests. A PNG from `POST /preview` is byte-identical
to the one-shot's for the same package, dataset, values and ppi (gated at release, see
`FEATURES_2.0.md`).

## Invocation

```
java -jar java-epic-cli.jar --serve=[HOST:]PORT [--cache=N] [--serve-allow-remote]
                            [--threads=N] [--fonts-dir=DIR] [--strict-fonts]
```

| flag | meaning |
|---|---|
| `--serve=[HOST:]PORT` | Required. `HOST` defaults to `127.0.0.1`. |
| `--serve-allow-remote` | Permit a non-loopback `HOST`. Without it any other address is refused (exit 1) because the server has **no authentication**. |
| `--cache=N` | Templates kept in memory, least recently used evicted first. Default 16. A large Illustrator master costs about 30 MB of heap per cached template; size `-Xmx` as `cache × 30 MB + 512 MB` and watch `/health`. |
| `--threads=N` | Worker threads, default all cores (the global flag, stripped by Main). One request renders on one thread; `N` requests run at once. A preview does not get faster with more threads, so `N` is a concurrency knob, not a speed knob. |
| `--fonts-dir=DIR` | The global font directory (manual §3), applied to every request. |
| `--strict-fonts` | Strict fonts (manual §19.3), applied to every request. |

**Font policy is server-wide (2.1.2).** Every request runs with the server's `--fonts-dir` and
`--strict-fonts`, exactly as a one-shot `--derivative-preview` given the same flags would: same
PNG, same error text. Through 2.1.1 each request ran in a fresh job that dropped both flags, so
a server started with `--fonts-dir=/empty` still resolved a compile-engine font named without a
file path from `./fonts` or `/datahandler/fonts` and answered 200 where the one-shot failed.
Confirm the server's policy with `GET /health` (`strict_fonts`, `fonts_dir`).

**Per-request font policy (2.14.0).** A `/preview` body may carry `"fonts_dir"` (absolute path)
and/or `"strict_fonts"` (boolean). These replace the server's flags for that request only. The
answer is what a one-shot `--derivative-preview --fonts-dir=D [--strict-fonts]` would give: same
PNG, same error text. Templates and their engines are cached, and fonts PDFBox resolved for one
request stay resolved in the cached document, so one shared template switched per request could
answer a strict request from a lax lookup. The policy is therefore part of the cache key: a
request naming its own policy is served from a template built and resolved under that policy,
and never from the server-wide one. Requests that name nothing, or that restate the server's
own flags (directories compared canonically), share the server-wide template exactly as before.
Each policy is a separate cache entry and counts against `--cache`. `/stats` lists the entry with
its `fonts_dir` and `strict_fonts`, and `/evict` of a package drops every policy's entry.
Measured on the compile fixture: three tenant directories (the right face, a different face under
the same name, an empty one) interleaved on one warm server each matched its one-shot PNG. A
strict request for a non-embedded font refused (400) on every repeat, and the default requests
between them stayed identical.

Where a request's fonts come from, by engine:

- **envelope** (Illustrator master) and **livetext** (lab master without a request.json): the
  package's own `Fonts/` folder only, scanned when the template is built. Nothing carries from one
  template to another. The font directory is reached only for a font the master PDF uses without
  embedding: substituted from system fonts when lax, refused under `--strict-fonts`.
- **compile** (lab master with a request.json): each `design.fonts[]` ref. A ref with `file` uses
  that path (relative paths resolve against the server's working directory); a ref with only
  `font` resolves in the font directory, never in the package's `Fonts/`. For tenant isolation
  either give every ref an absolute `file` inside the package, or start the server with
  `--fonts-dir` set to the directory that holds that tenant's fonts.

A font the policy refuses fails the request (400 when strict fonts recorded the refusal, else
500 with the engine's reason), never a preview drawn with a substitute.

On a successful bind exactly one JSON line goes to stdout and the process stays up:

```json
{"status":"ok","listening":"127.0.0.1:8765","version":"Epic CLI 2.1.2+<sha>","threads":8,"cache":16,
 "strict_fonts":false,"fonts_dir":null}
```

A wrapper should wait for that line (or poll `GET /health`) before sending work. A usage error or
a bind failure prints `{"status":"error","reason":"..."}` and exits 1. `POST /shutdown` (or a
signal) ends the process with exit 0; the scratch directory under the temp root is removed.

Nothing else is ever printed on stdout; request failures are answered on the socket, never
logged, and never stop the server.

## Endpoints

Every response carries an `X-Epic-Status: ok|error` header. Bodies are JSON unless stated.
Header names are documented in their canonical spelling; the JDK server sends them as
`X-epic-status`, `Content-type` and so on. HTTP header names are case-insensitive, so match
them case-insensitively (every mainstream client library already does).

### `GET /health`

```json
{"status":"ok","version":"Epic CLI 2.1.0+<sha>","templates_cached":3,"cache_capacity":16,
 "uptime_s":4120,"heap_used_mb":412,"heap_max_mb":2048,"rss_mb":1210,
 "threads":8,"in_flight":1,"requests":9876,"errors":12,"strict_fonts":false,"fonts_dir":null}
```

`strict_fonts` and `fonts_dir` are the startup font policy every request runs under unless it
names its own (2.1.2; per-request since 2.14.0; `fonts_dir` null = the default chain).

`heap_used_mb` / `heap_max_mb` are the JVM heap; `rss_mb` is the process resident set from
`/proc/self/status` (null where the OS does not expose it). A worker that wants to recycle the
process on memory growth has what it needs here. Note that `heap_used_mb` includes garbage not
yet collected; a steady climb across many requests with `templates_cached` flat is the signal,
not a single high reading.

### `POST /preview`

Request body:

```json
{"package":"/abs/path/dir-or-zip",      // required: the --derivatives batch package
 "dataset":"HA_PREVIEW",                // optional: ha:out of the base dataset; default the sidecar's first
 "values":{"NAME":"VALUE", ...},        // optional: overrides, exactly like --set=NAME=VALUE
 "ppi":72,                              // optional: 0 < ppi <= 2400, default 72
 "recolor":"OLD=NEW:c,m,y,k;...",       // optional (2.1.1): the --recolor spec string, verbatim
 "strict_variables":true,               // optional (2.13.0): --strict-variables; boolean only
 "fonts_dir":"/abs/tenant/fonts",       // optional (2.14.0): this request's --fonts-dir; absolute only
 "strict_fonts":true,                   // optional (2.14.0): this request's --strict-fonts; boolean only
 "soft_proof":"/abs/press.icc",         // optional (2.25.0): --soft-proof=FILE.icc|intent, verbatim
 "soft_proof_intent":"relcol",          // optional (2.25.0): --soft-proof-intent
 "soft_proof_paper":true,               // optional (2.25.0): --soft-proof-paper; boolean or "on"/"off"
 "format":"png"}                        // optional: "png" (default) or "json"
```

**`strict_variables`** is the one-shot's `--strict-variables` (chapter 12). A value that does not
print is a `500` whose body carries `skipped_variables` and `"fault_class":"input"`, and no PNG.
A clean strict request answers exactly like a plain one, adding `"skipped_variables":[]` in the
`json` format. The flag applies to that request only; the cached template is shared, and a
default request after a strict one is byte-identical to the one-shot (measured). Any non-boolean
value is a `400`.

**`recolor`** carries exactly the string `--derivative-preview --recolor=` takes (chapter 12:
`OLD=NEW:c,m,y,k` clauses joined with `;`, CMYK in 0–100 percent) and runs the one-shot's own
recolor code: the same name resolution and the same page-colorspace swap `--swap-spot-colors`
performs (forms and image colorspaces included), applied to the in-memory working page just
before it is rendered. Nothing is saved, so a recolor preview costs about what a plain one does
(BR1X007 warm: ~0.55 s against ~0.47 s). The PNG is byte-identical to what 2.0.0 rendered from
the saved, recolored working derivative (gated on the envelope and live-text engines). A spec
that does not parse is a `400` with the parser's own message; a spec that parses but names a
separation the working derivative does not carry is a `500` with the one-shot's reason
(`--recolor source separation "X" is not in the working derivative; it carries [...]`), exactly
as the one-shot exits 2. `colors_used` is the recolored page's separation list, as in the
one-shot. Since 2.1.1 the preview never generates the production derivative, so a spec only the
production file would reject no longer fails a preview; `--derivatives --recolor` still checks
both files.

`request` is **not served** (400; ship the request.json inside the package). Everything else the
one-shot accepts (`background` must be `transparent`) behaves the same.

**`format: "png"`** → `200`, `Content-Type: image/png`, the RGBA PNG (always transparent page,
never matted — chapter 12), and headers:

| header | value |
|---|---|
| `X-Epic-Status` | `ok` |
| `X-Epic-Colors` | comma-joined `colors_used` |
| `X-Epic-Warnings` | count of `warnings` |
| `X-Epic-Usage` | the `--usage` sidecar document for this request as compact JSON (below) |

Header values are kept to printable ASCII: any other character is written as a JSON `\uXXXX`
escape. The `json` format carries the same strings verbatim.

**`format: "json"`** → `200` with the `--derivative-preview` result object (chapter 12), with
`png_base64` (the PNG bytes) in place of the `png` path and a `usage` object added:

```json
{"status":"ok","master":"BR1X007","flavor":"ai","engine":"envelope","ha_out":"HA_PREVIEW",
 "values":{"State":"TEXAS","City":"Austin"},"ppi":72.0,"background":"transparent",
 "width_px":553,"height_px":934,"colors_used":["PANTONE 123 C"],"warnings":[],
 "png_base64":"iVBORw0KGgo...","usage":{...}}
```

**Errors** → `400` for the usage class (what the one-shot exits 1 for: unknown field, bad ppi,
missing package, unknown dataset, unparseable `recolor`) and `500` for processing faults (its
exit 2: unreadable package, engine failure, `recolor` naming a missing separation), body `{"status":"error","reason":"...","usage":{...}}` with
the one-shot's reason strings. The `usage` object is present on every `/preview` response that
got as far as parsing a request body.

### Usage parity

The `usage` object (json) / `X-Epic-Usage` header (png) is the `--usage` sidecar document of
chapter 19 (schema 1), produced by the same `Usage` class from an equivalent command line
(`--derivative-preview=<package> [--dataset=..] [--set=..]... [--ppi=..]`): `mode`, `argv`,
`status`, `exit_code`, `error`, `work` (input, pages, trim, bytes in/out, files out), `fonts`,
`resources`, `warnings`. Two differences from a one-shot's sidecar, by design:

- `resources.cpu_ms` is the **request thread's** CPU time, not the process's, so concurrent
  requests do not bill each other. `peak_heap_mb` is process-wide (calibration only, never billed).
- `work.bytes_out` / `files_out` / `pages_out` count the PNG the response carries (one file, one
  page, its byte length) even though it was never written to disk.

Counters are per request (one `Usage` instance per request); nothing is process-global.

### `POST /run` (2.12.0)

One whole one-shot job, run inside the resident JVM so it skips the ~3 s JVM start. Built for
the inspect lane (`--preflight` on intake, `--render` thumbnails, `--separate`, `--ink-stack`,
`--list-text`) and for previews that need per-request fonts.

```json
{"argv":["--preflight","file.pdf"],   // required: the one-shot's arguments, paths relative to cwd
 "cwd":"/abs/request/dir",            // required: an existing absolute directory
 "fonts_dir":"fonts",                 // optional: adds --fonts-dir=fonts
 "strict_fonts":true,                 // optional: adds --strict-fonts
 "threads":1,                         // optional: adds --threads=1
 "usage":"usage.json"}                // optional, default "usage.json": adds --usage=...; null = no sidecar
```

**Contract:** the response is what `java -jar java-epic-cli.jar [--usage=U] [--strict-fonts]
[--fonts-dir=F] [--threads=N] ARGV` run in `cwd` would have produced: the same stdout bytes,
stderr bytes and exit code, the same `--usage` sidecar written into `cwd`, the same output
files. The globals are prepended in that order (JarRunner's order) and only when `argv` does not
already carry the flag, so a caller can also pass a complete command line in `argv` and set
`usage` to null. `argv` in the response is the command line that ran.

It is the same run, not a re-implementation: `Main.execute` on a fresh job, with the process
working directory switched to `cwd` for the duration (libc `chdir` plus the two places the JDK
caches the start directory) and `System.out` / `System.err` captured in the JVM's own stream
encodings. Relative paths, the `./fonts` default chain, paths echoed in messages and in the
sidecar's `argv` / `work.input` all behave as in `cwd`. Every job gets fresh font, colour,
unit and usage state; nothing from one `/run` is visible to the next.

```json
{"status":"ok","exit_code":0,"stdout":"{...the mode's JSON...}\n","stderr":"",
 "argv":["--usage=usage.json","--strict-fonts","--fonts-dir=fonts","--preflight","file.pdf"],
 "cwd":"/abs/request/dir","wall_ms":214}
```

`stdout` and `stderr` are strings, never merged. `X-Epic-Exit` carries the exit code.

| status | meaning |
|---|---|
| `200` | the job ran; `exit_code` is whatever the one-shot would have exited with |
| `400` | malformed body, unknown field, `cwd` not an absolute directory, or a mode `/run` does not serve |
| `501` | this JVM cannot switch directories (not started with `java -jar`, or not Linux); the reason says which |
| `503` | busy: another `/run` or a preview is in flight |
| `500` | the job threw past the exit funnel (a one-shot would have printed a trace and exited 1); body carries `exit_code`, `stdout`, `stderr` (with the trace) |

**Every non-200 means "run it one-shot".** A job that threw may have written partial output
files; a one-shot rerun overwrites them. No sidecar is written for a `500`.

**Modes served:** `--preflight`, `--thin-line`, `--list-spot-colors`, `--list-process-colors`,
`--render`, `--separate`, `--ink-coverage`, `--ink-stack` / `--gang-check`, `--list-text`,
`--bind-variables`, `--derivative-preview`, `--list-slots`, `--pack`, `--quantize`,
`--match-colors`, `--remove-color`, the prepress transforms (`--artboard`, `--bleed`, `--crop`,
`--marks`, `--pages`, `--delete-pages`, `--merge`, `--step`, `--overprint`, `--spot-to-process`,
`--convert`, `--edge`, `--trap`, `--expand-strokes`, `--outline-text`, `--keyline`, `--fit`,
`--scale`, `--align`, `--offset`, `--rotate-art`, `--distort`), `--simplify-curves`,
`--extract-objects`, `--help`, `--version`. The mode is the first flag that is not a global, the
way the sidecar names it. **Refused anywhere in argv:** `--serve`, `--spool`, `--aws`, `--mysql`,
and any argument containing `://`. Every other mode (`--compile`, `--derivatives`,
`--imposition`, `--vectorize`, `--backers`, ...) is a `400`: run it one-shot.

**Exclusive:** the working directory and the standard streams belong to the whole process, so a
`/run` holds the server alone. It never waits: if anything is in flight it is answered `503` at
once. Previews that arrive during a `/run` wait for it to finish, then run. Send one `/run` at a
time per server, and run inspect jobs and previews on separate `--serve` processes when preview
latency matters.

**Per-request fonts:** `fonts_dir` / `strict_fonts` apply to that job only (a fresh job with no
template cache), so this is also the warm route for a `--derivative-preview` that needs the
request's own font library: the template is built per call (no cache), but the JVM is warm.

**Operations:** each `/run` creates its job temp directory under `java.io.tmpdir`, exactly as a
one-shot does (and, like the one-shot, does not always remove it). Watch `rss_mb` on `/health` and
recycle the process on your budget. A worker that kills a `/run` on a wall-clock timeout must
restart the server: the job cannot be interrupted in place.

### `POST /evict`

`{"package":"/abs/path"}` drops that template; `{}` (or an empty body) drops all. Reply
`{"status":"ok","evicted":N,"templates_cached":M}`. An entry still rendering a request is retired
immediately and its scratch directory deleted when that request finishes.

### `GET /stats`

```json
{"status":"ok","uptime_s":4120,"requests":9876,"errors":12,"in_flight":1,
 "templates":[{"package":"/srv/masters/BR1X007","master":"BR1X007","flavor":"ai","engine":"envelope",
   "hits":2210,"errors":0,"last_ms":412,"avg_ms":438,"load_ms":2650,"loaded_at":1761160000000,"in_use":0}]}
```

`last_ms` / `avg_ms` are whole-request wall times on the server side (render + PNG encode).

### `POST /shutdown`

Accepted from loopback peers only (`403` otherwise). Replies `{"status":"ok","shutting_down":true}`,
stops accepting connections, lets in-flight requests finish (1 s grace on the listener, up to
30 s on the workers), deletes the scratch tree and exits 0.

## Cache semantics

**Key:** the package's canonical path + a size/mtime fingerprint. For a zip the fingerprint is
its size and mtime; for a directory it is the name, size and mtime of every regular file at its
root and in its `Fonts` folder. A request whose path is cached but whose fingerprint differs
evicts the stale entry and rebuilds — so a file **rewritten at the same path is detected** on the
next request, and **content-hash-named paths behave as content keys** (a new content hash is a new
path, an unchanged one never rebuilds). Two different paths to the same bytes are two entries.

**Entry:** the extracted package (zips are unpacked into the server's own scratch tree, one
subdirectory per entry), the parsed sidecar, and the engine `--derivative-preview` would have
chosen for it (compile when a request.json is packaged, livetext for a lab master without one,
envelope for an Illustrator master). Building one is the one-shot's cold cost minus the JVM
(2–3 s for a large Illustrator master, mostly its private-data payload and font loading).

**Eviction:** least recently used when the cache is over `--cache`; explicit via `/evict`; stale
fingerprint on lookup. Eviction deletes the entry's scratch directory, deferred until any request
using it has finished.

**Concurrency:** the envelope engine is immutable after construction and serves overlapping
requests on one instance. The compile and livetext engines are not (they bind values into their
request model / extract fonts by name into the shared scratch), so requests on one such template
are serialised; requests on different templates always run in parallel. A cold template is built
once even when several requests ask for it at the same moment (the others wait on it).

## Security and operations

- **No authentication, no TLS.** Bind to loopback (the default) and let the web2print service be
  the only client. `--serve-allow-remote` exists for a container network where the port is not
  reachable from outside; it is not a public endpoint.
- **One process per host port.** Several `--serve` instances are fine on different ports; they do
  not share a cache.
- **The service owns timeouts.** The server has none: a request takes as long as the render takes
  (a 2400 ppi preview of a large page is many seconds and a lot of heap). Put a client-side
  timeout in front and size `ppi` for the use.
- **Recycle on memory.** Cached engines are long-lived heap; the G1 behaviour described in the
  rebuild notes (humongous garbage piling up between pauses) applies. Watch `rss_mb` and
  `heap_used_mb` on `/health`, and restart the process when it crosses the budget — a restart
  costs the cold load of the templates in use, nothing else.
- **Temp root.** Scratch lives under the normal temp directory (`java.io.tmpdir`, `epic_XXXXXX/serve/`)
  and is deleted on graceful shutdown. A killed process leaves it behind, as the one-shot would.

## Examples

```bash
# start, wait for the ready line
java -Xmx2g -jar java-epic-cli.jar --serve=8765 --cache=32 --threads=4 &

curl -s localhost:8765/health

# PNG with headers
curl -s -X POST localhost:8765/preview -H 'Content-Type: application/json' \
     -d '{"package":"/srv/masters/BR1X007","values":{"City":"Austin","State":"TEXAS"},"ppi":96}' \
     -o preview.png -D -

# JSON with the PNG inline
curl -s -X POST localhost:8765/preview -H 'Content-Type: application/json' \
     -d '{"package":"/srv/masters/BR1X007.zip","dataset":"HA_PREVIEW","format":"json"}' | jq .usage

curl -s -X POST localhost:8765/evict -d '{"package":"/srv/masters/BR1X007"}'
curl -s localhost:8765/stats
curl -s -X POST localhost:8765/shutdown
```

## Measured (dev VM, BR1X007 Illustrator master, 72 ppi, 553×934 RGBA)

See `FEATURES_2.0.md` → "2.1.0" for the latency table recorded at release (one-shot vs resident,
warm p50/p95, threads=1 and default, and four concurrent clients).
