- done/ (11): 1, 2, 3, 4, 6, 7, 7b, 9, 9b, 15, 16 — landed iterations (9/9b remainders live in the post-12 drain list, not in the files) - refine/ (8): 9c, 9d, 9e, 9f, 9g, 11, 13, 14 — everything marked "no spec yet / brainstorm before planning" - root keeps: 00-story (index), 05 (partial, plan 8 open), 8/10/12 (specs or plans exist), 17 (parked, spec+plan approved), 18 (next) - every cross-reference re-pathed and VERIFIED resolving: board, specs, plans, employee-list README, story table, intra-story links (moved files' relative links deepened one level; done/7b's 9e pointer now crosses to refine/) - pre-existing dead link noted, not touched: refine/11-fibers.md points at docs/plan/exploration/fibers/00-fibers.md which does not exist (predates the move) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
196 lines
10 KiB
Markdown
196 lines
10 KiB
Markdown
# Iteration 16 — the web framework + web-app: implementation plan
|
|
|
|
> **Status: COMPLETE (2026-08-19)** — all tasks landed on branch
|
|
> `web-framework`; `just web-app` 14/0. Deviations from the plan as written,
|
|
> recorded honestly: (1) registration is `app.add(Route { method, pattern,
|
|
> h })` — the get/post/put helper fns were dropped because the ctor-literal-
|
|
> plus-`take` shape is the ownership pattern the corpus pins
|
|
> (`run/container-owned-move`); a helper taking an interface-typed parameter
|
|
> is unproven ground, deferred; (2) show/delete key on `:name` (the @unique
|
|
> index probe, the employee-proven pattern), not `:id` — row-id lookup is not
|
|
> in the query surface; (3) the `[deps]` key is `framework` (hyphens are not
|
|
> identifier characters in `use` paths); (4) connection policy became
|
|
> pipelined-keep-alive/close-when-idle after a probe showed an idle parked
|
|
> connection starves accept on a single-threaded server; (5) two compiler
|
|
> gaps surfaced and were fixed en route: owned-values-in-containers now MOVE,
|
|
> and a dep's internal `use` paths resolve dep-relatively; (6) Dispatcher
|
|
> takes `mut req` so :param captures land on the borrowed request — the
|
|
> borrow checker correctly refused rebuilding a Req from borrowed maps.
|
|
|
|
> **For agentic workers:** use superpowers:executing-plans (inline) or
|
|
> subagent-driven-development. Steps are checkboxes. Per repo rule, this plan
|
|
> carries **actions in words + verification commands, no code blocks** — the
|
|
> normative design is the spec, which travels with this plan.
|
|
|
|
**Goal:** `docs/examples/writeonce-framework/` — a web framework written in
|
|
writeonce (HTTP/1.1 keep-alive server core, router with `:param` captures,
|
|
`Handler`/`Middleware` structural interfaces, `Req`/`Resp` records) — and
|
|
`docs/examples/web-app/`, a storefront that consumes it **through
|
|
`[deps]`** (iteration 15) and persists through `@table`. The app, not the
|
|
framework, owns the entry, the data model, and the routes.
|
|
|
|
**Architecture:** pure `.wo` — no compiler or runtime changes are expected
|
|
(the two enabling probes already pass: interface-typed fields dispatch via
|
|
ICALL, and the route-table pattern is pinned ASan-clean by
|
|
`tests/corpus/run/container-owned-move`). The server core is the proven
|
|
log-watcher `mcp` pattern (blocking `net` loop, fd-clean, stop-clean),
|
|
generalized. TLS/h2 live at the reverse proxy; the backend speaks HTTP/1.1
|
|
keep-alive with `Content-Length` bodies only.
|
|
|
|
**Spec:** [`../specs/2026-08-18-web-framework-design.md`](../specs/2026-08-18-web-framework-design.md)
|
|
section B (normative; §C's h2c stays parked). Story:
|
|
[`16-web-framework.md`](../../stories/language-runtime-database/done/16-web-framework.md).
|
|
|
|
## Global Constraints
|
|
|
|
- Framework and app are SEPARATE projects: the framework never references the
|
|
app; the app reaches the framework only via `use` over a `[deps]` fetch —
|
|
never a relative path. Extraction to its own repo must be a URL change.
|
|
- Single-threaded blocking serving, stated in the framework README with the
|
|
nginx upstream-keepalive deployment story; no chunked encoding, no
|
|
WebSockets, JSON-first (all disclosed).
|
|
- A handler trap answers 500 and the server survives; a malformed request
|
|
answers 400 and closes; every accept path closes its fd; `env.stopping()`
|
|
honored — the same discipline log-watcher's soak enforces.
|
|
- The committed `web-app/wo.toml` carries the framework's FUTURE GitHub URL
|
|
as documentation; the acceptance gate substitutes a run-time `file://`
|
|
remote (a temp git repo built from the framework directory) into a temp
|
|
copy of the app, so the repo never contains `.wo-deps`/`wo.lock` artifacts
|
|
and CI never touches the network.
|
|
- Gates: `just web-app` (the new acceptance), plus the standing
|
|
`just woc-test` / `just oop-e2e` / sample gates stay green untouched.
|
|
|
|
---
|
|
|
|
## Task 1 — framework skeleton: `Req`/`Resp`, builders, project shape
|
|
|
|
**Files:** create `docs/examples/writeonce-framework/{wo.toml,README.md,
|
|
http/types.wo}`.
|
|
|
|
- [x] Project manifest (`name = "writeonce-framework"`); README states what
|
|
it is, the single-thread/proxy deployment story, and the disclosed limits
|
|
up front.
|
|
- [x] `Req` record: method, path, params (`:param` captures), query, headers
|
|
(all `map<Text, Text>`), body Text. `Resp` record: status, headers, body.
|
|
Builder free fns: ok_text, ok_json, not_found, bad_request, server_error,
|
|
redirect — each returns a fully-formed `Resp` (content-type set).
|
|
- [x] Verify: the framework directory typechecks standalone (`woc` on it —
|
|
no `fn main`, so check-only) with zero diagnostics. Commit.
|
|
|
|
## Task 2 — HTTP/1.1: parse, serialize, keep-alive serve loop
|
|
|
|
**Files:** create `docs/examples/writeonce-framework/http/{parse.wo,serve.wo}`.
|
|
|
|
- [x] Request parsing from a `net` connection: request line (method,
|
|
target — split path from query string, decode `%`-escapes in both), header
|
|
lines to the blank line, then exactly `Content-Length` bytes of body
|
|
(missing length = empty body; a non-integer length or an oversized one is
|
|
a 400). Header names lowercase on read so lookups are predictable.
|
|
Anything malformed: respond 400, close, continue serving.
|
|
- [x] Response serialization: status line with reason text, headers,
|
|
`Content-Length` always computed from the body, `Connection: keep-alive`
|
|
unless the request asked to close.
|
|
- [x] The serve loop: `net.listen`, accept, then per connection read
|
|
requests until EOF/close/stop — the keep-alive inner loop; the dispatch
|
|
callback boundary is a structural interface the router provides (Task 3),
|
|
wrapped in `try` so a trapping handler answers 500 and the loop lives.
|
|
Close the connection fd on every exit path and the listener on stop.
|
|
- [x] Verify with a throwaway `.wo` main beside the framework (not
|
|
committed): serve one echo handler, curl matrix — GET with query,
|
|
keep-alive reuse (two requests, one connection), 400 on garbage, SIGTERM
|
|
stops cleanly. Commit.
|
|
|
|
## Task 3 — router, `Handler`/`Middleware`, the `App` assembly
|
|
|
|
**Files:** create `docs/examples/writeonce-framework/{router/router.wo,app.wo}`.
|
|
|
|
- [x] `Handler` interface (`handle(req) -> Resp`) and `Middleware` interface
|
|
(`before(req) -> ?Resp`, nil = continue) — the spec's shapes verbatim.
|
|
- [x] Route table: `App` holds `multi` of route records (method, the pattern
|
|
split into segments, the handler value). Matching walks segments; a
|
|
`:name` segment captures into `req.params`. First match wins; no match is
|
|
the framework's 404. Registration helpers: get/post/put/delete_ plus a
|
|
generic route(method, pattern, handler).
|
|
- [x] `App.serve(port)`: run middleware in order (a `Resp` short-circuits —
|
|
`?Resp` narrowing), then route, then the matched handler, all inside the
|
|
Task-2 loop's try boundary.
|
|
- [x] Verify with the throwaway main: two routes incl. `/things/:id`
|
|
echoing the capture, a header-checking middleware that short-circuits 401,
|
|
404 for unknown paths, 500 for a deliberately trapping handler with the
|
|
server surviving. Commit.
|
|
|
|
## Task 4 — the web-app storefront
|
|
|
|
**Files:** create `docs/examples/web-app/{wo.toml,README.md,types.wo,main.wo}`
|
|
(+ a module justfile mirroring the other samples).
|
|
|
|
- [x] `wo.toml`: `[deps] writeonce-framework = { git = <future GitHub URL>,
|
|
rev = "v0.1.0" }` (documentation value; the gate substitutes a `file://`
|
|
remote), `[build]` runtime unpinned (portable, per the log-watcher
|
|
precedent).
|
|
- [x] Data model: `@table Product` (name @unique, price, stock) and
|
|
`@table Order` (`ref Product`, qty) — small, honest, exercising `@unique`
|
|
and FK restrict through web routes.
|
|
- [x] Routes: list products (query + json encode), show by `:id`, create
|
|
product (json decode body — the checked decode's nil path is a 400),
|
|
create order (FK), delete product (FK restrict surfaces as a 409-style
|
|
error body, caught via try). One auth middleware (a shared-token header,
|
|
401 otherwise) registered before the routes.
|
|
- [x] `fn main`: build the App, register middleware + routes, `serve(port)`
|
|
with the port from args. README documents the curl matrix and the nginx
|
|
h2-in-front config sketch.
|
|
- [x] Verify by hand end to end once (fetch via a local file:// remote,
|
|
serve, curl, restart, drop). Commit.
|
|
|
|
## Task 5 — the acceptance gate
|
|
|
|
**Files:** create `scripts/web-app-accept.sh`; modify `justfile`
|
|
(`web-app` recipe).
|
|
|
|
- [x] The gate builds the whole chain at run time: git-init a temp remote
|
|
from `docs/examples/writeonce-framework/` (tag `v0.1.0`), copy
|
|
`docs/examples/web-app/` to a temp dir, substitute the `file://` URL into
|
|
its manifest, then: fetch+build (lock written); serve on a scratch port
|
|
with `WO_DATA` set; curl matrix — 401 without the token, list empty,
|
|
create product, list shows it, show by id, unknown path 404, malformed
|
|
json body 400, create order, delete-restricted product answers the error
|
|
body while the server keeps serving; keep-alive reuse; kill -TERM stops
|
|
cleanly; restart and the product list still answers (WAL persistence);
|
|
fd/resident flatness via an opt-in `WA_SOAK` mirroring log-watcher's.
|
|
- [x] Wire `just web-app`; run it plus the standing gates
|
|
(`just woc-test`, `just oop-e2e`, `just deps-accept`, `just log-watcher`,
|
|
`just employee`) — all green.
|
|
- [x] Commit.
|
|
|
|
## Task 6 — docs closeout
|
|
|
|
**Files:** modify `docs/00-status.md` (iteration 16 → done with what
|
|
landed), story `16-web-framework.md` (landing note), `README.md` (one
|
|
paragraph + pointer under the samples list), `docs/08-project-structure.md`
|
|
(the two new sample entries).
|
|
|
|
- [x] Apply; `just web-app` still green; commit.
|
|
|
|
## Success criteria (spec §Success criteria, restated)
|
|
|
|
1. web-app builds purely through `[deps]` + `wo.lock`; no path references;
|
|
offline once locked.
|
|
2. The storefront serves with `@table` persistence across restart; nginx in
|
|
front gives browsers h2 while the backend speaks HTTP/1.1 (documented,
|
|
demonstrated manually).
|
|
3. A non-conforming handler is WO-E205 at compile time; a trapping handler
|
|
answers 500 and the server survives (gate-proven).
|
|
4. Extraction = changing the app's `[deps]` URL only.
|
|
|
|
## Self-review notes
|
|
|
|
- Spec §B coverage: types/builders → T1; parse/serialize/loop → T2;
|
|
router/interfaces/App → T3; storefront + data model → T4; the gate → T5;
|
|
docs → T6. §C (h2c) deliberately absent. No placeholders; interface names
|
|
and record shapes match the spec exactly.
|
|
- Two risks retired BEFORE this plan: interface-typed fields dispatch
|
|
(probe passed) and the route-table double-free (fixed + pinned by
|
|
`run/container-owned-move`).
|
|
- Open risk, disclosed: `%`-escape decoding and header-case handling are
|
|
easy to get subtly wrong — T2's verify step includes them explicitly, and
|
|
the gate's curl matrix covers a query with an encoded space.
|