- 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>
207 lines
13 KiB
Markdown
207 lines
13 KiB
Markdown
# Fiber parity study — what a mainstream Go web framework ships that `porch` does not
|
||
|
||
Reference: [gofiber/fiber](https://github.com/gofiber/fiber) **v3.5.0**, read
|
||
2026-08-26 from a shallow clone at `.dev/reference/fiber` (gitignored — re-clone
|
||
with `git clone --depth 1 https://github.com/gofiber/fiber .dev/reference/fiber`).
|
||
Read against `docs/examples/porch` as it stands the same day.
|
||
|
||
Why Fiber and not Express or Axum: it is the closest structural analogue in the
|
||
reference set. Compiled language, no runtime, one binary, an explicit
|
||
`Ctx`-per-request, and middleware as an ordered chain — the same shape
|
||
`porch` already has. Where it differs, the difference is a feature
|
||
decision rather than a paradigm gap, which is what makes the comparison useful.
|
||
Express would have contributed mostly "you have no closures".
|
||
|
||
What was actually read: `app.go` (routing methods, the 53-field `Config`),
|
||
`ctx.go` / `req.go` / `res.go` (the request and response surface), `bind.go`
|
||
(binding), `hooks.go` (lifecycle), and the `Config` struct of every one of the
|
||
**32** packages under `middleware/`.
|
||
|
||
**Headline: `porch` is further along than its size suggests.** Of
|
||
Fiber's 32 middleware packages, 9 already have a working `porch`
|
||
counterpart (CORS, basic auth, key/bearer auth, helmet-style security headers,
|
||
ETag, static files, logger, host authorization, recover-as-500). The gaps are
|
||
real but they are mostly *breadth*, and they cluster around four things:
|
||
**cookies** (absent entirely, and half the list sits on top of them),
|
||
**streaming** (absent, and SSE/compression/chunked all sit on that),
|
||
**binding** (blocked by doctrine until `@derive`), and **one missing runtime
|
||
primitive nobody had noticed**.
|
||
|
||
---
|
||
|
||
## 0. The blocker the existing ledger gets wrong
|
||
|
||
`docs/examples/porch/README.md`'s crypto row currently reads:
|
||
|
||
> Unlocks (signed cookies, CSRF, session integrity, webhook verification, JWT
|
||
> HS256) | ⬜ **UNBLOCKED** (the primitives exist since iteration 34)
|
||
|
||
**That is not true for three of the five.** SHA-256 and HMAC-SHA256 let you
|
||
*authenticate* a token. They do not let you *mint* one, because
|
||
**writeonce has no source of randomness at all** — `grep -inE
|
||
'random|rand|urandom|getrandom'` over `compiler/src/types.ml`,
|
||
`runtime/src/wob.h`, `sysio.c` and `crypto.c` returns nothing. There is no
|
||
`getrandom(2)`, no `/dev/urandom` read (`fs.read_at` could reach it, but `fs`
|
||
has no way to open a character device meaningfully and the result would be a
|
||
`Text` of raw bytes with no API contract), and no CSPRNG builtin.
|
||
|
||
Fiber's session store defaults its `KeyGenerator` to a UUID; its CSRF
|
||
middleware mints a token per request. Both are unguessability requirements, not
|
||
integrity requirements. An HMAC over a *predictable* session id is not a
|
||
session — it is a signed guess.
|
||
|
||
So the honest dependency order is: **a random-bytes builtin comes before
|
||
sessions and CSRF, not after them.** Signed cookies over an
|
||
application-supplied value and webhook verification (where the secret comes
|
||
from config and the nonce comes from the *sender*) genuinely are unblocked; JWT
|
||
HS256 is unblocked for verification and blocked for issuing anything with a
|
||
random `jti`.
|
||
|
||
This is the study's most valuable single finding and it is the reason iteration
|
||
39 leads with the primitive rather than the middleware.
|
||
|
||
---
|
||
|
||
## 1. Cookies — absent, and foundational
|
||
|
||
`porch` has **no cookie support in either direction**. `Req` has
|
||
`headers: map<Text, Text>` and nothing parses `Cookie:`; `Resp` has
|
||
`headers: map<Text, Text>` and there is no `Set-Cookie` builder — and because
|
||
`Resp.headers` is a *map*, it structurally cannot carry the two `Set-Cookie`
|
||
lines a login-plus-flash response needs. That map is a real design constraint
|
||
this iteration has to confront, not a missing function.
|
||
|
||
Fiber, for comparison: `Req.Cookies(key)`, `Res.Cookie(*Cookie)` with
|
||
`ClearCookie`, and a `Cookie` struct carrying Path, Domain, MaxAge, Expires,
|
||
Secure, HTTPOnly, SameSite, Partitioned and SessionOnly.
|
||
|
||
| Piece | Fiber | porch |
|
||
| --- | --- | --- |
|
||
| read request cookies | `Req.Cookies(key)` | — |
|
||
| set a response cookie | `Res.Cookie(&Cookie{...})` | — |
|
||
| clear | `Res.ClearCookie(key...)` | — |
|
||
| attributes | Path/Domain/MaxAge/Expires/Secure/HTTPOnly/SameSite/Partitioned | — |
|
||
| multiple `Set-Cookie` per response | native (header list) | **impossible** — `Resp.headers` is `map<Text,Text>` |
|
||
| signed / encrypted | `encryptcookie` middleware | — (HMAC exists; see §0 for the minting problem) |
|
||
|
||
Everything in §2 depends on this section landing first.
|
||
|
||
## 2. Session, CSRF, rate limiting, idempotency — the store-backed chain
|
||
|
||
All four are one shape in Fiber: a middleware plus a `Storage` interface. All
|
||
four are pure-`.wo` work in writeonce *once cookies and randomness exist*, and
|
||
writeonce has an unusual advantage here — `@table` gives a **durable,
|
||
WAL-backed, crash-recoverable** store for free, where Fiber ships an in-memory
|
||
default and makes you bolt on Redis for anything real.
|
||
|
||
| Middleware | Fiber's config knobs (the shape to translate) | writeonce status |
|
||
| --- | --- | --- |
|
||
| `session` | Storage, KeyGenerator, IdleTimeout, AbsoluteTimeout, CookieDomain/Path/SameSite/Secure/HTTPOnly/SessionOnly, Extractor | absent; needs §0 + §1 |
|
||
| `csrf` | Storage, Session, KeyGenerator, TrustedOrigins, SingleUseToken, CookieName + the cookie attrs, IdleTimeout, Extractor | absent; needs §0 + §1 |
|
||
| `limiter` | Storage, Max, Expiration, KeyGenerator, LimitReached, SkipFailed/SkipSuccessful, DisableHeaders | absent; needs only a store + `time.ticks` — **unblocked today** |
|
||
| `idempotency` | Storage, Lock, KeyHeader + validator, Lifetime, KeepResponseHeaders | absent; store + `time.ticks` — **unblocked today** |
|
||
|
||
`limiter` and `idempotency` are the two cheapest real wins in this whole
|
||
document: no new primitive, no cookie, just a `@table` and a clock that already
|
||
exists.
|
||
|
||
## 3. Streaming — absent, and three features sit on it
|
||
|
||
`internal/serve.wo` builds a whole response as one `Text` and writes it with a
|
||
single `net.write`; `serialize()` always emits `Content-Length`. There is no
|
||
flush, no chunked framing, no way to write a response incrementally.
|
||
`internal/parse.wo:153-157` **explicitly refuses** chunked request bodies, with
|
||
a correct note that silently treating a chunked request as body-less is request
|
||
smuggling — that refusal is good engineering and should stay until chunked is
|
||
implemented properly.
|
||
|
||
Blocked on this one seam:
|
||
|
||
- **SSE** (Fiber: `middleware/sse` with Retry, HeartbeatInterval, OnClose) —
|
||
the natural fit for writeonce's actor model, since a room actor already has
|
||
the fan-out shape. Wants iteration 24's chat work beside it.
|
||
- **Compression** (Fiber: `middleware/compress`, Level) — gzip/deflate/brotli.
|
||
Iteration 36 landed the bitwise operators, so a pure-`.wo` DEFLATE is now
|
||
*expressible*; whether it should be `.wo` or a C builtin is a genuine fork,
|
||
and the honest answer probably depends on whether anything else ever wants
|
||
zlib.
|
||
- **`SendFile` / `SendStream` / byte ranges** — Fiber's static ships ByteRange,
|
||
Browse, MaxAge, CacheDuration, IndexNames, Download. `http/files.wo` serves
|
||
whole small files only. Range requests are what make video and large
|
||
downloads work.
|
||
|
||
The framework README already lists "lazy body streaming + backpressure ·
|
||
streaming responses · explicit commit point" as ⏸ unblocked-by-the-arc. This
|
||
study's contribution is naming what *else* falls out of it.
|
||
|
||
## 4. Binding — blocked by doctrine, and that is fine
|
||
|
||
Fiber's `Bind` is 16 methods: `Body`, `JSON`, `XML`, `CBOR`, `MsgPack`, `Form`,
|
||
`Query`, `URI`, `Header`, `Cookie`, `RespHeader`, `All`, `Custom`, plus
|
||
validator hooks. It fills a struct by reflecting over tags.
|
||
|
||
writeonce has `json.decode(t) as T` for JSON bodies and **nothing** for query,
|
||
path params, form or header — those hand you `map<Text, Text>` and you assign
|
||
field by field. Principle 13 forbids reflection, so this cannot be closed the
|
||
way Fiber closes it.
|
||
|
||
The right owner is **[iteration 29, `@derive`](../../../stories/language-runtime-database/29-compile-time-metaprogramming.md)**:
|
||
compile-time generation from the class table gives typed binding with no
|
||
runtime reflection. This is a genuine parity gap with a real answer that is
|
||
already on the roadmap, so iteration 39 records it and does not attempt it.
|
||
|
||
Same verdict, same reason, for the codec spread: Fiber ships XML, CBOR and
|
||
MsgPack encoders/decoders in `Config`. writeonce ships JSON. That is the small
|
||
stdlib doing its job, not a defect.
|
||
|
||
## 5. Routing and response ergonomics — mostly sugar, cheap to close
|
||
|
||
| Gap | Fiber | porch |
|
||
| --- | --- | --- |
|
||
| method helpers | Get/Post/Put/Delete/Patch/Head/Options/Trace/Connect/All/Add | `get`/`post`/`put`/`delete_` only — a `Route { method: "PATCH" }` literal works, so this is registration sugar, but its absence is felt |
|
||
| route names + URL building | `Name()`, `GetRouteURL()` | — (no named routes, no reverse routing) |
|
||
| route introspection | `GetRoutes()`, `Stack()`, `HandlersCount()` | — |
|
||
| mount / sub-app | `Use(prefix, subApp)` | `Group { prefix }` covers the common case ✅ |
|
||
| case sensitivity / strict slash | `CaseSensitive`, `StrictRouting` | — (always case-sensitive, always lenient) |
|
||
| per-route body limit | `Config.BodyLimit` | `const BODY_MAX = 1048576` in `internal/parse.wo` — one compile-time number for the whole server |
|
||
| per-handler timeout | `middleware/timeout` (Timeout, OnTimeout) | conn-level `read_ms`/`idle_ms` only; bounding a *handler* needs cancellation, which the README already parks |
|
||
| `Location`, `Vary`, `Links`, `Append`, `Attachment`/`Download` | ✅ each a `Res` method | — (`set_header` by hand) |
|
||
| `Format`/`AutoFormat` content negotiation on the way out | ✅ | `accepts()` exists; no format dispatch helper |
|
||
| q-value **ranking** | ✅ | 🔶 already in the ledger: q-values stripped, not ranked |
|
||
| request id | `middleware/requestid` (Header, Generator) | `req.ctx` bag exists to carry it; no generator — and see §0 |
|
||
| healthcheck / favicon / redirect / rewrite / skip | five small middleware | — (each a handful of lines) |
|
||
| `earlydata`, `paginate`, `responsetime`, `envvar`, `expvar`, `pprof` | ✅ | — (`expvar`/`pprof` belong to iteration 30, which has no story file) |
|
||
| lifecycle hooks | 11 hook families (OnRoute, OnListen, OnPreShutdown, OnPostShutdown, …) | — README already notes "no user teardown hooks yet" 🔶 |
|
||
| `proxy` middleware | ✅ | **impossible today** — no `net.connect`; owned by [iteration 38](../../../stories/language-runtime-database/38-content-platform-capabilities.md) |
|
||
| `recover` with stack trace | `EnableStackTrace`, `StackTraceHandler` | trap → 500 and the server lives ✅; no backtrace primitive exists |
|
||
|
||
## 6. Deliberate divergences — listed so nobody re-opens them
|
||
|
||
Not gaps. Each was decided and the reasoning is on file.
|
||
|
||
| Fiber feature | writeonce position |
|
||
| --- | --- |
|
||
| `Views` / `Render` / `ReloadViews` / `PassLocalsToViews` — a runtime template engine | **Rejected.** Markup is a compile-time literal (iteration 37's raw text literal + `writeonce-view`'s `Component`) or it does not exist. A per-request file read is the already-rejected engine — see `docs/plan/discarded.md`. |
|
||
| TLS config, `SetTLSHandler`, HTTP/2 | **Proxy-terminated, forever** (principle: TLS is not the app's job). h2c stays parked behind iteration 23. |
|
||
| `adaptor` (net/http interop) | No FFI, no foreign handler ecosystem to adapt to. |
|
||
| `Concurrency`, `ReadBufferSize`, prefork/`OnFork` | The shard-actor runtime owns placement; there is no worker-pool knob to expose. |
|
||
| Closures as handlers | Handlers are classes satisfying `Handler` (`router/router.wo`'s own note: "no function values in this language, by doctrine — a handler is a CLASS, its fields are the closure substitute"). |
|
||
| `SharedState` / `Locals` as an untyped bag | `req.ctx` is `map<Text,Text>` on purpose; typed per-request state is a `@table` row or a field on the handler class. |
|
||
|
||
## 7. What this study feeds
|
||
|
||
The **[`porch` track](../../../stories/porch/00-story.md)** — eight iterations
|
||
numbered from 1, which superseded language iteration 39 on the day this study
|
||
was written. §0–§2 and the cheap half of §5 became porch 1–5, in dependency
|
||
order because §0 gates §2 and §1 gates most of it; §3's streaming seam and what
|
||
falls out of it became porch 6–8, so nothing in this study is now unscheduled
|
||
except what the table below hands to someone else.
|
||
|
||
Explicitly *not* iteration 39's, with owners:
|
||
|
||
- streaming, SSE, compression, byte ranges (§3) — [porch 6](../../../stories/porch/06-streaming-core.md)–[8](../../../stories/porch/08-static-and-lifecycle.md), which unparked them
|
||
- typed binding (§4) — [iteration 29](../../../stories/language-runtime-database/29-compile-time-metaprogramming.md)
|
||
- TTL cache middleware — [iteration 18](../../../stories/language-runtime-database/18-memory-db-features.md)
|
||
- `proxy` — [iteration 38](../../../stories/language-runtime-database/38-content-platform-capabilities.md)
|
||
- `pprof`/`expvar`/metrics — iteration 30
|
||
- everything in §6 — closed by doctrine
|