writeonce/docs/stories/porch/08-static-and-lifecycle.md
shoney.arickathil 01df75245f docs(porch): give the framework its own story track, iterations 1-8
- docs/stories/porch/ — a TRACK folder, not a status folder: status still
  lives only in frontmatter. Adds `track: porch` so a query over
  docs/stories/ can tell a porch 3 from a language 3
- 00-story.md carries the sequence, the dependency graph, and a table of
  what the track explicitly does NOT own (binding -> 29, cache -> 18,
  proxy -> 38, metrics -> 30, TLS/templates -> doctrine)
- eight iterations, each with phases, per-phase tasks, Given/When/Then
  criteria, out-of-scope and the forks a spec must settle:
  1 store-backed middleware (limiter + idempotency — needs nothing new,
    first on purpose so the store pattern is proven cheaply)
  2 randomness + cookies (phase A is language-track: a CSPRNG builtin;
    `Resp.headers` being a map cannot emit two Set-Cookie lines)
  3 sessions   4 CSRF   5 routing/response ergonomics (independent)
  6 streaming core (the seam 7 and 8 wait on; chunked-request refusal
    must survive)   7 SSE + compression   8 static + lifecycle hooks
- language iteration 39 -> status: hold, retitled superseded, with a row
  mapping each of its goals to the porch iteration that took it. Kept, not
  deleted: the Fiber study cites it and its randomness argument is what
  this track is built on
- board gains a porch section; board-views gains porch and both-track
  Dataview queries; porch README and the Fiber study §7 point at the track
- no code blocks in any story (plans carry concept and actions in words);
  linkcheck 0 broken / 0 anchors

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 20:17:33 +02:00

7.7 KiB

track iteration status
porch 8 refine

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. The static half needs iteration 6; the rest does not.

The clean-up iteration. 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 rather than normalised, max_bytes is a hard ceiling, and the site's /dl downloads run through it. What it cannot do is serve a file it cannot hold in memory, resume a partial download, or let a browser cache correctly. Fiber's static ships ByteRange, MaxAge, CacheDuration, IndexNames, Browse and Download; the range support is what makes video and large downloads work at all.
  • Cache headers that let a client skip the request. etag_for/with_etag give conditional GETs; 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" as a known gap. Fiber has eleven hook families; porch needs a small handful — on-listen, on-shutdown, and on-route-registered — and 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 is a handful of lines and their absence is felt immediately by anyone starting a new app.

Phases

Phase A — ranges and cache headers

  • Range request parsing (single range first; multi-range is a multipart response and 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, If-Modified-Since handling, and Cache-Control with a configurable max-age.
  • Serve the body through iteration 6's writer so file size stops bounding what can be served.
  • 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 (index.html and friends) and an optional directory listing, defaulting off — a listing that is on by default is an information leak the first time someone points it at the wrong directory.
  • Download/Attachment disposition, reusing the helper iteration 5 added.
  • Re-verify the traversal refusal against every new path (index resolution and listing both construct paths, which is exactly where traversal creeps back in).
  • Verify: index resolution works; listing is off unless asked for; traversal is still refused on all new paths.

Phase C — lifecycle hooks

  • Decide the minimal set (fork 1) and the interface — hooks are classes, like everything else here.
  • Wire the shutdown hook into the existing env.stopping() path so an app can flush and close before the process exits, and guarantee it runs 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.

Phase D — the small middleware set

  • Healthcheck (liveness and readiness are different questions — say which), 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.

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, so these features have a real consumer rather than a synthetic one.
  • 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 matching the file's mtime, 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 — the existing guarantee, re-proven against the new code paths.
  • 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 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, which is what the workload needs.
  • 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. Assets are read from disk per request; a cache with invalidation is a different feature and language iteration 18 owns caching.
  • pprof, expvar, metrics endpoints. Language iteration 30 (no story file yet). A healthcheck is not observability — it is one bit.
  • 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 here.

Info

Forks the spec must settle:

  1. Which hooks, exactly? Fiber has eleven families and porch needs the fewest that are load-bearing. On-shutdown is clearly one — an app with open resources has nowhere to close them today. On-listen is convenient for a startup banner. On-route-registered is only useful for introspection, which iteration 5 may already cover. Fewer is better; each hook is a contract forever.
  2. Liveness or readiness for healthcheck? They answer different questions and conflating them is why deployments flap: liveness says "do not kill me", readiness says "do not send me traffic yet". A framework shipping one endpoint called /health should say which it is, and probably ships both.
  3. Is directory listing available at all? Off-by-default is not the same as present-but-off. Shipping it at all means it will eventually be switched on somewhere it should not be. Leaning: ship it, off, with the doc saying plainly what it exposes — the alternative is every app hand-rolling a worse one.