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)
This commit is contained in:
shoney.arickathil 2026-09-06 17:53:37 +02:00
parent 4d3e4261e1
commit b932e0cb87
2 changed files with 120 additions and 86 deletions

View file

@ -1195,9 +1195,11 @@ the language arc as v1 history.
### ▸ porch — the web framework track
New 2026-08-26, from [the Fiber v3.5.0 parity study](../plan/exploration/fiber/00-fiber-parity.md).
Supersedes language iteration 39, now a pointer. **Stories 2–7 are `ready`
(brainstormed 2026-09-06, forks locked, validated against
`.dev/reference/fiber`); 8 remains `refine`.** Ordered by dependency; the
Supersedes language iteration 39, now a pointer. **The whole track (2–8) is
`ready`** (brainstormed 2026-09-06, forks locked, validated against
`.dev/reference/fiber`). The three language touches the track needs are now
explicit and small, each a builtin with a named consumer: `random_bytes` (2),
`deflate`/`crc32` (7), `time.utc` (8). Ordered by dependency; the
first slice is deliberately the cheapest so the store pattern and gate shape are
proven before the runtime and `Resp` are touched.
@ -1210,7 +1212,7 @@ proven before the runtime and `Resp` are touched.
| 5 | [Routing + response ergonomics](porch/05-routing-response-ergonomics.md) | ✅ **`ready` 2026-09-06** — **independent, any time; NO upstream dependency (not even iteration 2)**. Five decisions: `head` auto-registers with an opt-out (+ `patch`/`options`/`all`); request ids mirror the limiter's trust model with a **non-crypto** source (so no CSPRNG dependency); per-route `body_limit` is a **second check after routing** (global `BODY_MAX` stays the pre-routing ceiling, over-limit = 413); adding `name`/`body_limit` to `Route` is corpus-free; `Vary` accumulates by comma-join. Plus named routes + runtime-checked URL building, q-value ranking (retires the 🔶) |
| 6 | [Streaming core](porch/06-streaming-core.md) | ✅ **`ready` 2026-09-06** — riskiest/highest-leverage; **re-scoped to outbound only**. Three decisions: separate `StreamHandler`/`BodyProducer` parallel path (the `Resp` path untouched → existing responses byte-identical); streaming routes **opt out** of the after-chain, framework **refuses at registration** to combine with header-mutating middleware (loud, never silent), handlers stamp headers via a `security_headers()` helper; chunked **REQUEST** bodies **split into their own future iteration** (parse.wo refusal stays). No language enhancement; rides the fiber loop, not the actor pool (not lang-41-exposed) |
| 7 | [SSE + compression](porch/07-sse-and-compression.md) | ✅ **`ready` 2026-09-06** — after 6 (+ 5 for q-ranking/comma-join Vary; NOT 2). Five decisions: refuse an incoherent heartbeat/`idle_ms` pair at construction; **codec = two C builtins `deflate`+`crc32`** (perf over pure-`.wo`; hand-rolled, no zlib dep; gzip framing in `.wo`) — the track's **second language dependency** after iteration 2; ETag over uncompressed bytes + `Vary`; `Last-Event-ID` explicitly unsupported (not silently ignored); Vary via comma-join. Codec is pure compute — not lang-41-exposed |
| 8 | [Static files + lifecycle](porch/08-static-and-lifecycle.md) | ⬜ static half after 6. Byte ranges, `Last-Modified`/`Cache-Control`, index resolution, listing off-by-default, shutdown hooks (the ledger's "no user teardown hooks yet"), plus healthcheck/favicon/redirect/rewrite/skip |
| 8 | [Static files + lifecycle](porch/08-static-and-lifecycle.md) | ✅ **`ready` 2026-09-06** — static half after 6 (+ 5's Download helper). Four decisions: three hooks (on-listen/on-shutdown/on-route-registered); healthcheck ships **both** `/livez`+`/readyz`; directory listing **off by default**, documented; **`Last-Modified` needs a small `time.utc(ms)->TimeParts` builtin** (gmtime sibling of time.local — the track's third, smallest language touch; time.local is local-tz, time.iso is UTC-but-ISO), IMS by string-equality (no parser). Byte ranges via `fs.read_at`+iteration 6 writer. Not lang-41-exposed |
---

View file

@ -2,89 +2,126 @@
track: porch
iteration: "8"
status: pending
readiness: refine
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.
> The static half needs [iteration 6](06-streaming-core.md); the rest does not.
> 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. Individually every item is small; together they are
> most of what makes a framework feel finished rather than adequate.
> 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 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.
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; `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.
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 is a handful of lines and
their absence is felt immediately by anyone starting a new app.
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 and cache headers
### Phase A — ranges, cache headers, and the `time.utc` builtin
- `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.
- 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 (`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](05-routing-response-ergonomics.md) 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).
- 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
- 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.
- 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 (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.
- 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.
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`, so these
features have a real consumer rather than a synthetic one.
`/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.
@ -97,58 +134,53 @@ readiness: refine
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** `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 — the existing guarantee, re-proven
against the new code 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** `skip` wrapping a middleware with a predicate, **when** the
predicate matches, **then** the wrapped middleware does not run and the chain
continues in order.
- **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, which is what the
workload needs.
- **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.** Assets are read from disk per
request; a cache with invalidation is a different feature and language
iteration [18](../language-runtime-database/18-memory-db-features.md) owns
caching.
- **`pprof`, `expvar`, metrics endpoints.** Language iteration 30 (no story file
yet). A healthcheck is not observability — it is one bit.
- **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 here.
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
Forks the spec must settle:
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.
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](05-routing-response-ergonomics.md) 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.
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.