- 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>
142 lines
7.2 KiB
Markdown
142 lines
7.2 KiB
Markdown
---
|
||
track: porch
|
||
iteration: "5"
|
||
status: refine
|
||
---
|
||
|
||
# porch 5 — routing and response ergonomics: the parity that is merely missing
|
||
|
||
> Part of [Story — `porch`, the writeonce web framework](00-story.md).
|
||
> Source: [the Fiber parity study](../../plan/exploration/fiber/00-fiber-parity.md) §5.
|
||
>
|
||
> Independent of iterations 2–4 and of the streaming seam — startable at any
|
||
> time, and a reasonable slice to interleave when the risky work needs a break.
|
||
> Nothing here is hard; all of it is felt.
|
||
|
||
## Goals
|
||
|
||
- **The rest of the method helpers.** `App` has `get`/`post`/`put`/`delete_`.
|
||
A `Route { method: "PATCH" }` literal already works, so this is registration
|
||
ergonomics rather than capability — but writing the literal by hand for
|
||
`PATCH` while `get` exists is the kind of asymmetry that makes a framework
|
||
feel unfinished. Add `patch`, `options`, `head`, and an `all`.
|
||
- **Named routes and reverse routing.** Fiber has `Name()` and `GetRouteURL()`.
|
||
porch has neither, so every link in the site is a hand-written string that no
|
||
compiler checks — and the site is exactly the app where a renamed path breaks
|
||
a page silently.
|
||
- **A per-route body limit.** `BODY_MAX = 1048576` is one compile-time constant
|
||
for the whole server. An upload route and a JSON route want different numbers,
|
||
and the JSON route wants a much smaller one than the upload route can live
|
||
with.
|
||
- **Request ids.** `req.ctx` already exists to carry one; there is no generator
|
||
and no middleware. With iteration 2's builtin available this is a few lines,
|
||
and it is the difference between logs you can correlate and logs you cannot.
|
||
- **The response helpers written by hand today.** `Location`, `Vary`,
|
||
`Attachment`/`Download`, and a content-negotiated `format` dispatch on top of
|
||
the existing `accepts()`. Also q-value *ranking*, which the ledger has carried
|
||
as a known 🔶 since the negotiation slice landed.
|
||
|
||
## Phases
|
||
|
||
### Phase A — method helpers and route introspection
|
||
|
||
- The missing registration helpers, including `all`, and decide whether `head`
|
||
auto-registers alongside `get` (Fiber has `DisableHeadAutoRegister`, which
|
||
tells you the default is auto and that people want it off).
|
||
- Route introspection — list the table — because it is nearly free once routes
|
||
are already a `multi Route`, and it is what makes a startup banner or a
|
||
route-dump flag possible.
|
||
- Verify: each method dispatches; `405` still carries a correct `Allow` built
|
||
from the real table; existing routes unchanged.
|
||
|
||
### Phase B — named routes and URL building
|
||
|
||
- A name on `Route`, a lookup, and a builder that fills `:param` captures.
|
||
- Decide the failure mode for a missing or extra parameter. A silently wrong URL
|
||
is worse than a trap, and this is a compile-time-checkable shape only once
|
||
language iteration 29's `@derive` exists — so for now it is a runtime check
|
||
and should say so.
|
||
- Migrate the site's internal links onto it, which is the proof it is usable.
|
||
- Verify: every site link resolves through the builder; a wrong parameter set is
|
||
refused loudly.
|
||
|
||
### Phase C — per-route body limits and request ids
|
||
|
||
- Move the limit from a module constant to route-level configuration with the
|
||
current value as the default, so no existing app changes behaviour.
|
||
- A request-id middleware writing into `req.ctx`, and settle whether an inbound
|
||
header is trusted (fork 2).
|
||
- Thread the id into the logging middleware's output, since a request id nothing
|
||
logs is decoration.
|
||
- Verify: an oversized body is refused per-route; the id appears in logs and is
|
||
stable across a request's lifetime.
|
||
|
||
### Phase D — response helpers and negotiation ranking
|
||
|
||
- `Location`, `Vary`, `Attachment`/`Download`, and a `format`-style dispatch
|
||
choosing a builder from `accepts()`.
|
||
- Rank q-values properly instead of stripping them, retiring the ledger's 🔶.
|
||
- Verify: `Vary` accumulates rather than overwrites (which the iteration-2
|
||
repeated-header work makes possible); negotiation picks the highest-q match,
|
||
not the first.
|
||
|
||
### Phase E — the gate and the ledger
|
||
|
||
- Both serving gates, the ledger rows, the board entry.
|
||
- Verify: `just web-app`, `just site`, `just linkcheck` green.
|
||
|
||
## Acceptance Criteria
|
||
|
||
- **Given** a route registered with each new helper, **when** the matching
|
||
method arrives, **then** it dispatches; **and** an unmatched method still
|
||
yields `405` with an `Allow` listing exactly the registered methods.
|
||
- **Given** `head` auto-registration, **when** a `HEAD` request hits a `GET`
|
||
route, **then** the response is headers-only with the `Content-Length` a `GET`
|
||
would have sent — the behaviour `serialize()` already implements, now
|
||
reachable by registration.
|
||
- **Given** a named route with `:param` captures, **when** a URL is built with
|
||
the right parameters, **then** it matches that route's pattern exactly;
|
||
**and** a wrong or missing parameter is refused rather than producing a
|
||
plausible-looking wrong URL.
|
||
- **Given** two routes with different body limits, **when** a body exceeding the
|
||
smaller arrives at each, **then** it is refused at the small route and
|
||
accepted at the large one.
|
||
- **Given** no per-route limit, **when** a request arrives, **then** the
|
||
previous global limit applies unchanged.
|
||
- **Given** a request-id middleware, **when** a request is handled, **then** the
|
||
same id appears in every log line for that request and in the response header.
|
||
- **Given** an `Accept` header with q-values out of order, **when** negotiation
|
||
runs, **then** the highest-q acceptable type wins — not the first listed.
|
||
- **Given** two `Vary` contributions from different middleware, **when** the
|
||
response leaves, **then** both appear.
|
||
|
||
## Out Of Scope
|
||
|
||
- **A radix-tree router.** Path matching is a linear scan and the ledger marks
|
||
it 🔶 pending a *measurement*. Language iteration 22 built the benchmark
|
||
harness but pointed it at the database. Until someone benches the router, this
|
||
is an optimisation without evidence.
|
||
- **Case-insensitive or strict-slash routing.** Fiber exposes both as config.
|
||
porch is case-sensitive and lenient; changing that is a behaviour change for
|
||
existing apps and wants its own decision.
|
||
- **Compile-time-checked URL building.** The typed version needs language
|
||
iteration [29](../language-runtime-database/29-compile-time-metaprogramming.md).
|
||
Runtime-checked now, upgraded later.
|
||
- **Streaming responses, `SendFile`, byte ranges** — iterations
|
||
[6](06-streaming-core.md) and [8](08-static-and-lifecycle.md).
|
||
- **Typed binding of params into a class** — language iteration 29 again.
|
||
|
||
## Info
|
||
|
||
Forks the spec must settle:
|
||
|
||
1. **Does `head` auto-register?** Fiber's default is yes with an opt-out. Auto is
|
||
friendlier; explicit is more predictable and never surprises someone
|
||
debugging why a route they did not register is answering.
|
||
2. **Is an inbound request-id header trusted?** Behind the mandated proxy,
|
||
trusting it is what makes tracing work across hops. On an open port it lets a
|
||
client forge correlation ids and poison logs. `client_ip` and `net.peer`
|
||
already exist for exactly this trust decision — reuse that conclusion.
|
||
3. **Where does a route's body limit live?** A field on `Route` is the obvious
|
||
home but widens a record that the conformance corpus pins the ownership shape
|
||
of. Check that fixture before choosing.
|