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:
parent
4d3e4261e1
commit
b932e0cb87
2 changed files with 120 additions and 86 deletions
|
|
@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue