writeonce/docs/stories/porch/08-static-and-lifecycle.md
shoney.arickathil b932e0cb87 docs(porch-static): brainstorm story 8 (static + lifecycle) to ready
- whole porch track (2-8) now brainstormed and locked (all ready)
- four decisions: three hooks (on-listen/on-shutdown/on-route-registered);
  healthcheck ships BOTH /livez + /readyz; directory listing off-by-default,
  documented; Last-Modified via a new small time.utc(ms)->TimeParts builtin
- language enhancement: YES, one small builtin -- time.utc, a gmtime sibling
  of time.local (time.local is local-tz, time.iso is UTC-but-ISO); IMS by
  string-equality, no date parser. The track's third + smallest language touch
- byte ranges/large files via fs.read_at + iteration 6 writer; not lang-41-exposed
- track language bill now explicit: random_bytes (2), deflate+crc32 (7),
  time.utc (8) -- each a builtin with a named consumer, none decoration
- validated against .dev/reference/fiber. Board: whole track marked ready

(cherry picked from commit 9801fceade799e25718606177f09e4306a579e98)
2026-09-15 01:15:31 +02:00

186 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
track: porch
iteration: "8"
status: pending
readiness: ready
---
# porch 8 — static files, lifecycle hooks, and the small middleware everyone ships
> Part of [Story — `porch`, the writeonce web framework](00-story.md).
> Source: [the Fiber parity study](../../plan/exploration/fiber/00-fiber-parity.md) §3, §5,
> re-checked 2026-09-06 against `.dev/reference/fiber` (v3, `3ca9a9d`) and the
> existing `http/files.wo`.
> The static half needs [iteration 6](06-streaming-core.md) and iteration
> [5](05-routing-response-ergonomics.md)'s `Download` helper; the `Last-Modified`
> half needs **one small language-track builtin** (`time.utc`, decision 4). The
> rest is pure `.wo`. Not exposed to the lang-41 hang.
>
> The clean-up iteration, and the last in the track. Individually every item is
> small; together they are most of what makes a framework feel finished rather
> than adequate.
## Goals
- **Static files that can serve something large.** `StaticFiles` exists and is
already careful — traversal is refused, `max_bytes` is a hard ceiling, and the
site's `/dl` downloads run through it — but it reads the whole file with
`fs.read_all` and so cannot serve a file it cannot hold in memory, resume a
partial download, or let a browser cache correctly. Byte ranges are what make
video and large downloads work at all.
- **Cache headers that let a client skip the request.** `etag_for`/`with_etag`
give conditional GETs already; `Cache-Control`, `Last-Modified` and
`If-Modified-Since` are the other half, and `fs.stat` already returns the mtime
they need.
- **Lifecycle hooks.** The ledger records "no user teardown hooks yet". porch
needs a small handful; the shutdown one is the one that matters, because an app
with its own resources currently has nowhere to close them.
- **The small middleware every framework ships**: healthcheck, favicon,
redirect, rewrite, and a `skip` combinator — each a handful of lines whose
absence is felt immediately.
## Decisions locked (brainstorm 2026-09-06)
1. **Three lifecycle hooks: on-listen, on-shutdown, on-route-registered.** Hooks
are classes, like everything else. on-shutdown is the essential one, wired
into `env.stopping()`, and runs exactly once even on a trapping path.
on-listen runs app code after the port binds (post-bind warmup, a readiness
signal). on-route-registered fires at registration time for plugins and
logging — it composes with iteration 5's route introspection rather than
duplicating it (a fire-at-registration hook versus a query of the table, two
different timings).
2. **The healthcheck ships both liveness and readiness as distinct endpoints.**
`/livez` is a static 200 (the process is up, do not kill me); `/readyz`
consults an app-supplied readiness predicate (DB reachable, warmup done) and
answers 503 until ready (do not send traffic yet). Conflating them is why
deployments flap; shipping one endpoint that is silently only one of the two
is the trap.
3. **Directory listing ships, off by default, documented.** Index resolution is
always on (a directory resolves to `index.html` and friends). A listing is
available behind a config flag defaulting off, with the doc stating plainly
what it exposes — the alternative is every app hand-rolling a worse, leakier
one. Traversal refusal is re-proven against both the index-resolution and
listing paths, which is exactly where traversal creeps back in.
4. **`Last-Modified` uses a new small `time.utc(ms) -> TimeParts` builtin.**
`time.local` is localtime (wrong zone for an HTTP-date) and `time.iso` is UTC
but ISO format (wrong shape). `time.utc` is the gmtime sibling of `time.local`
— the same `TimeParts` record (including `dow`) but in UTC — a tiny,
broadly-reusable builtin that fills a genuine gap (any GMT timestamp or log
line wants it). HTTP-date formatting is then pure `.wo` (static day/month name
tables plus the record fields). `If-Modified-Since` is handled by
string-equality against the formatted `Last-Modified` — the echo-back flow
clients actually use — so no HTTP-date *parser* is needed. This is the track's
third and smallest language touch, after iteration 2's `random_bytes` and
iteration 7's `deflate`/`crc32`.
## Phases
### Phase A — ranges, cache headers, and the `time.utc` builtin
- Add `time.utc(ms) -> TimeParts` (decision 4): a module builtin at the next free
`wob.h` id (re-check — the runtime-v2 track has been consuming them),
`gmtime_r`-based, mirroring `time.local`'s implementation. Document it in the
builtin-surface contract in the same change.
- `Range` request parsing (single range first; multi-range can wait),
`206 Partial Content`, `Content-Range`, and `Accept-Ranges`. An unsatisfiable
range is `416`, not a truncated `200`.
- `Last-Modified` from `fs.stat`'s mtime formatted with `time.utc`,
`If-Modified-Since` by string-equality, and `Cache-Control` with a configurable
max-age.
- Serve the body through iteration 6's writer with `fs.read_at`, so file size
stops bounding what can be served (retiring the `fs.read_all` whole-file read).
- Verify: a ranged request returns exactly the requested bytes; a file much
larger than the arena serves; a conditional request returns 304 with no body.
### Phase B — directory behaviour
- Index-file resolution and an optional directory listing defaulting off
(decision 3).
- `Download`/`Attachment` disposition, reusing the helper iteration 5 added.
- Re-verify the traversal refusal against every new path.
- Verify: index resolution works; listing is off unless asked for; traversal is
still refused on all new paths.
### Phase C — lifecycle hooks
- The three hook interfaces (decision 1). Wire the shutdown hook into the
existing `env.stopping()` path so an app can flush and close before the process
exits, guaranteed to run exactly once even on a trapping path.
- Verify: the shutdown hook fires on SIGTERM before the listener closes, once; a
trapping hook does not prevent shutdown; on-listen fires after bind; the
on-route-registered hook fires per registration.
### Phase D — the small middleware set
- Healthcheck (both endpoints, decision 2), favicon, redirect (permanent and
temporary), rewrite (internal, no round trip), and `skip` wrapping another
middleware with a predicate.
- Verify: each behaves; `skip` composes with the existing chain in registration
order; `/livez` and `/readyz` answer independently.
### Phase E — the gate and the ledger
- Both serving gates. The site is the natural subject: it already serves
`/favicon.svg`, `/health`, and `/dl` downloads through `StaticFiles`.
- Close out the porch track's ledger rows and record what the whole track
actually landed versus what the Fiber study predicted.
- Verify: `just web-app`, `just site`, `just linkcheck` green.
## Acceptance Criteria
- **Given** a `Range: bytes=a-b` request, **when** it is served, **then** the
response is `206` with exactly those bytes and a correct `Content-Range`.
- **Given** an unsatisfiable range, **when** it is served, **then** the response
is `416`, never a truncated `200`.
- **Given** a file larger than the heap, **when** it is requested, **then** it
serves completely and peak memory does not track file size.
- **Given** `If-Modified-Since` equal to the file's formatted `Last-Modified`,
**when** the request arrives, **then** the response is `304` with no body.
- **Given** a directory with an index file, **when** the directory is requested,
**then** the index is served; **and** with no index and listing disabled, the
response is `404`, not a listing.
- **Given** a traversal attempt through the index-resolution and listing paths,
**when** it is served, **then** it is refused.
- **Given** a registered shutdown hook, **when** the process receives SIGTERM,
**then** the hook runs exactly once before the listener closes, and in-flight
requests still complete.
- **Given** a hook that traps, **when** shutdown runs, **then** shutdown still
completes.
- **Given** `/livez` and `/readyz`, **when** the app is up but not ready,
**then** `/livez` is 200 and `/readyz` is 503.
- **Given** `skip` wrapping a middleware with a predicate, **when** the predicate
matches, **then** the wrapped middleware does not run and the chain continues
in order.
## Out Of Scope
- **Multi-range requests.** A multipart byte-range response is a separate format;
single ranges cover downloads and media seeking.
- **Precompressed asset serving** (`file.gz` beside `file`). Composes with
iteration [7](07-sse-and-compression.md); worth doing once, later, when both
exist.
- **A file-watching or hot-reload story.** Caching with invalidation is language
iteration [18](../language-runtime-database/18-memory-db-features.md).
- **`pprof`, `expvar`, metrics endpoints.** Language iteration 30. A healthcheck
is one bit, not observability.
- **Fiber's fork, mount and prefork hooks.** The shard runtime owns placement;
there is no worker-pool to hook.
- **`SendFile` with kernel `sendfile(2)`.** No such builtin exists and no
iteration owns adding one; the streaming writer is the portable answer.
- **An HTTP-date *parser*.** `If-Modified-Since` is matched by string-equality
against the formatted `Last-Modified` (decision 4); parsing arbitrary
HTTP-date shapes is not needed for the echo-back flow.
## Info
The one language dependency is small: `time.utc`, a `gmtime_r` sibling of the
existing `time.local`. Everything else is pure `.wo` — `fs.read_at` (id 44)
plus iteration 6's writer for ranges and large files, `fs.stat` (id 42) for
mtime, `env.stopping()` (id 50) for the shutdown hook, and the small middleware.
None of it touches actors or the per-key pool, so, like the rest of the track
since iteration 1, it is not exposed to the lang-41 hang.
With this iteration ready, the whole porch track (2–8) is brainstormed and
locked. The three language touches the track needs are now explicit and small:
`random_bytes` (2), `deflate`/`crc32` (7), and `time.utc` (8) — each a builtin
with a named consumer, none of them a primitive shipped as decoration.