diff --git a/docs/stories/00-status.md b/docs/stories/00-status.md index 836ec06..cd601ed 100644 --- a/docs/stories/00-status.md +++ b/docs/stories/00-status.md @@ -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 | --- diff --git a/docs/stories/porch/08-static-and-lifecycle.md b/docs/stories/porch/08-static-and-lifecycle.md index 92e95c2..f809be8 100644 --- a/docs/stories/porch/08-static-and-lifecycle.md +++ b/docs/stories/porch/08-static-and-lifecycle.md @@ -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.