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 ### ▸ porch — the web framework track
New 2026-08-26, from [the Fiber v3.5.0 parity study](../plan/exploration/fiber/00-fiber-parity.md). 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` Supersedes language iteration 39, now a pointer. **The whole track (2–8) is
(brainstormed 2026-09-06, forks locked, validated against `ready`** (brainstormed 2026-09-06, forks locked, validated against
`.dev/reference/fiber`); 8 remains `refine`.** Ordered by dependency; the `.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 first slice is deliberately the cheapest so the store pattern and gate shape are
proven before the runtime and `Resp` are touched. 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 🔶) | | 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) | | 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 | | 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 track: porch
iteration: "8" iteration: "8"
status: pending status: pending
readiness: refine readiness: ready
--- ---
# porch 8 — static files, lifecycle hooks, and the small middleware everyone ships # porch 8 — static files, lifecycle hooks, and the small middleware everyone ships
> Part of [Story — `porch`, the writeonce web framework](00-story.md). > 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. > 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. > 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 > The clean-up iteration, and the last in the track. Individually every item is
> most of what makes a framework feel finished rather than adequate. > small; together they are most of what makes a framework feel finished rather
> than adequate.
## Goals ## Goals
- **Static files that can serve something large.** `StaticFiles` exists and is - **Static files that can serve something large.** `StaticFiles` exists and is
already careful — traversal is refused rather than normalised, `max_bytes` is a already careful — traversal is refused, `max_bytes` is a hard ceiling, and the
hard ceiling, and the site's `/dl` downloads run through it. What it cannot do site's `/dl` downloads run through it — but it reads the whole file with
is serve a file it cannot hold in memory, resume a partial download, or let a `fs.read_all` and so cannot serve a file it cannot hold in memory, resume a
browser cache correctly. Fiber's static ships `ByteRange`, `MaxAge`, partial download, or let a browser cache correctly. Byte ranges are what make
`CacheDuration`, `IndexNames`, `Browse` and `Download`; the range support is video and large downloads work at all.
what makes video and large downloads work at all.
- **Cache headers that let a client skip the request.** `etag_for`/`with_etag` - **Cache headers that let a client skip the request.** `etag_for`/`with_etag`
give conditional GETs; `Cache-Control`, `Last-Modified` and `If-Modified-Since` give conditional GETs already; `Cache-Control`, `Last-Modified` and
are the other half, and `fs.stat` already returns the mtime they need. `If-Modified-Since` are the other half, and `fs.stat` already returns the mtime
- **Lifecycle hooks.** The ledger records "no user teardown hooks yet" as a known they need.
gap. Fiber has eleven hook families; porch needs a small handful — on-listen, - **Lifecycle hooks.** The ledger records "no user teardown hooks yet". porch
on-shutdown, and on-route-registered — and the shutdown one is the one that needs a small handful; the shutdown one is the one that matters, because an app
matters, because an app with its own resources currently has nowhere to close with its own resources currently has nowhere to close them.
them.
- **The small middleware every framework ships**: healthcheck, favicon, - **The small middleware every framework ships**: healthcheck, favicon,
redirect, rewrite, and a `skip` combinator. Each is a handful of lines and redirect, rewrite, and a `skip` combinator — each a handful of lines whose
their absence is felt immediately by anyone starting a new app. 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 ## 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 - Add `time.utc(ms) -> TimeParts` (decision 4): a module builtin at the next free
response and can wait), `206 Partial Content`, `Content-Range`, and `wob.h` id (re-check — the runtime-v2 track has been consuming them),
`Accept-Ranges`. An unsatisfiable range is `416`, not a truncated `200`. `gmtime_r`-based, mirroring `time.local`'s implementation. Document it in the
- `Last-Modified` from `fs.stat`'s mtime, `If-Modified-Since` handling, and builtin-surface contract in the same change.
`Cache-Control` with a configurable max-age. - `Range` request parsing (single range first; multi-range can wait),
- Serve the body through iteration 6's writer so file size stops bounding what `206 Partial Content`, `Content-Range`, and `Accept-Ranges`. An unsatisfiable
can be served. 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 - 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. larger than the arena serves; a conditional request returns 304 with no body.
### Phase B — directory behaviour ### Phase B — directory behaviour
- Index-file resolution (`index.html` and friends) and an optional directory - Index-file resolution and an optional directory listing defaulting off
listing, defaulting **off** — a listing that is on by default is an (decision 3).
information leak the first time someone points it at the wrong directory. - `Download`/`Attachment` disposition, reusing the helper iteration 5 added.
- `Download`/`Attachment` disposition, reusing the helper iteration - Re-verify the traversal refusal against every new path.
[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).
- Verify: index resolution works; listing is off unless asked for; traversal is - Verify: index resolution works; listing is off unless asked for; traversal is
still refused on all new paths. still refused on all new paths.
### Phase C — lifecycle hooks ### Phase C — lifecycle hooks
- Decide the minimal set (fork 1) and the interface — hooks are classes, like - The three hook interfaces (decision 1). Wire the shutdown hook into the
everything else here. existing `env.stopping()` path so an app can flush and close before the process
- Wire the shutdown hook into the existing `env.stopping()` path so an app can exits, guaranteed to run exactly once even on a trapping path.
flush and close before the process exits, and guarantee it runs exactly once - Verify: the shutdown hook fires on SIGTERM before the listener closes, once; a
even on a trapping path. trapping hook does not prevent shutdown; on-listen fires after bind; the
- Verify: the shutdown hook fires on SIGTERM before the listener closes, once; on-route-registered hook fires per registration.
a trapping hook does not prevent shutdown.
### Phase D — the small middleware set ### Phase D — the small middleware set
- Healthcheck (liveness and readiness are different questions — say which), - Healthcheck (both endpoints, decision 2), favicon, redirect (permanent and
favicon, redirect (permanent and temporary), rewrite (internal, no round temporary), rewrite (internal, no round trip), and `skip` wrapping another
trip), and `skip` wrapping another middleware with a predicate. middleware with a predicate.
- Verify: each behaves; `skip` composes with the existing chain in registration - 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 ### Phase E — the gate and the ledger
- Both serving gates. The site is the natural subject: it already serves - Both serving gates. The site is the natural subject: it already serves
`/favicon.svg`, `/health`, and `/dl` downloads through `StaticFiles`, so these `/favicon.svg`, `/health`, and `/dl` downloads through `StaticFiles`.
features have a real consumer rather than a synthetic one.
- Close out the porch track's ledger rows and record what the whole track - Close out the porch track's ledger rows and record what the whole track
actually landed versus what the Fiber study predicted. actually landed versus what the Fiber study predicted.
- Verify: `just web-app`, `just site`, `just linkcheck` green. - Verify: `just web-app`, `just site`, `just linkcheck` green.
@ -97,58 +134,53 @@ readiness: refine
is `416`, never a truncated `200`. is `416`, never a truncated `200`.
- **Given** a file larger than the heap, **when** it is requested, **then** it - **Given** a file larger than the heap, **when** it is requested, **then** it
serves completely and peak memory does not track file size. serves completely and peak memory does not track file size.
- **Given** `If-Modified-Since` matching the file's mtime, **when** the request - **Given** `If-Modified-Since` equal to the file's formatted `Last-Modified`,
arrives, **then** the response is `304` with no body. **when** the request arrives, **then** the response is `304` with no body.
- **Given** a directory with an index file, **when** the directory is requested, - **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 **then** the index is served; **and** with no index and listing disabled, the
response is `404`, not a listing. response is `404`, not a listing.
- **Given** a traversal attempt through the index-resolution and listing paths, - **Given** a traversal attempt through the index-resolution and listing paths,
**when** it is served, **then** it is refused — the existing guarantee, re-proven **when** it is served, **then** it is refused.
against the new code paths.
- **Given** a registered shutdown hook, **when** the process receives SIGTERM, - **Given** a registered shutdown hook, **when** the process receives SIGTERM,
**then** the hook runs exactly once before the listener closes, and in-flight **then** the hook runs exactly once before the listener closes, and in-flight
requests still complete. requests still complete.
- **Given** a hook that traps, **when** shutdown runs, **then** shutdown still - **Given** a hook that traps, **when** shutdown runs, **then** shutdown still
completes. completes.
- **Given** `skip` wrapping a middleware with a predicate, **when** the - **Given** `/livez` and `/readyz`, **when** the app is up but not ready,
predicate matches, **then** the wrapped middleware does not run and the chain **then** `/livez` is 200 and `/readyz` is 503.
continues in order. - **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 ## Out Of Scope
- **Multi-range requests.** A multipart byte-range response is a separate - **Multi-range requests.** A multipart byte-range response is a separate format;
format; single ranges cover downloads and media seeking, which is what the single ranges cover downloads and media seeking.
workload needs.
- **Precompressed asset serving** (`file.gz` beside `file`). Composes with - **Precompressed asset serving** (`file.gz` beside `file`). Composes with
iteration [7](07-sse-and-compression.md); worth doing once, later, when both iteration [7](07-sse-and-compression.md); worth doing once, later, when both
exist. exist.
- **A file-watching or hot-reload story.** Assets are read from disk per - **A file-watching or hot-reload story.** Caching with invalidation is language
request; a cache with invalidation is a different feature and language iteration [18](../language-runtime-database/18-memory-db-features.md).
iteration [18](../language-runtime-database/18-memory-db-features.md) owns - **`pprof`, `expvar`, metrics endpoints.** Language iteration 30. A healthcheck
caching. is one bit, not observability.
- **`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; - **Fiber's fork, mount and prefork hooks.** The shard runtime owns placement;
there is no worker-pool to hook. there is no worker-pool to hook.
- **`SendFile` with kernel `sendfile(2)`.** No such builtin exists and no - **`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 ## 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 With this iteration ready, the whole porch track (2–8) is brainstormed and
fewest that are load-bearing. On-shutdown is clearly one — an app with open locked. The three language touches the track needs are now explicit and small:
resources has nowhere to close them today. On-listen is convenient for a `random_bytes` (2), `deflate`/`crc32` (7), and `time.utc` (8) — each a builtin
startup banner. On-route-registered is only useful for introspection, which with a named consumer, none of them a primitive shipped as decoration.
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.