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

10 KiB
Raw Blame History

track iteration status readiness
porch 8 pending ready

porch 8 — static files, lifecycle hooks, and the small middleware everyone ships

Part of Story — porch, the writeonce web framework. Source: the Fiber parity study §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 and iteration 5'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; worth doing once, later, when both exist.
  • A file-watching or hot-reload story. Caching with invalidation is language iteration 18.
  • 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.