writeonce/docs/plan/exploration/fiber/00-fiber-parity.md
shoney.arickathil 01df75245f docs(porch): give the framework its own story track, iterations 1-8
- 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>
2026-08-26 20:17:33 +02:00

13 KiB
Raw Blame History

Fiber parity study — what a mainstream Go web framework ships that porch does not

Reference: 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: 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
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 — 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–8, which unparked them
  • typed binding (§4) — iteration 29
  • TTL cache middleware — iteration 18
  • proxy — iteration 38
  • pprof/expvar/metrics — iteration 30
  • everything in §6 — closed by doctrine