feat(framework): v1 polish — helpers, 405+Allow, HEAD, Logging, set_header

- App.get/post/put/delete_(pattern, take h: Handler) — the take-interface
  shape probe-proven release + ASan before landing; retires plan-16
  deviation 1; delete_ because delete is the query keyword
- dispatch matches path-first: wrong method on a known path answers 405
  with Allow in registration order; unknown path stays 404
- HEAD routed as GET, body suppressed, Content-Length names the body a
  GET would carry (serialize gains head_only)
- Logging middleware (request line to stderr) ships in router/
- set_header(mut r, name, value) — the builder escape hatch
- web-app registers through the helpers (dogfood); README documents all
- gate grows 14 -> 16: 405+Allow, HEAD-vs-GET content-length equality
- all gates green: web-app 16/0, woc-test 540/0, oop-e2e 89/0,
  deps-accept 8/0, log-watcher 7/0, employee 8/0
- board/story: iteration 17 parked (spec+plan ready on library-internal),
  16 carries the v1-polish landing, order list updated

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
shoney.arickathil 2026-08-20 03:04:57 +02:00
parent b2da9b3d6d
commit 3d33b7bf58
11 changed files with 184 additions and 64 deletions

View file

@ -19,41 +19,25 @@ Statuses: ✅ **done** · 🔄 **in progress** · ⬜ **pending** · ⏸ **hold*
## ▶ NEXT PLAN
**The web framework becomes a first-class library — iteration 17.** The goal
shifted 2026-08-20: the previous NEXT PLAN ("make log-watcher executable —
nothing else") was met in full 2026-08-15 (milestone record below), and the
driving workload is now `docs/examples/writeonce-framework` consumed by
`docs/examples/web-app` through `[deps]`. Iteration 17's forks are settled
(decisions + framework/compiler/VM/GC impact in
[the iteration](stories/language-runtime-database/17-library-projects-internal.md));
what remains, in order:
**Framework v1 — a polished micro-framework (routing, middleware,
`Req`/`Resp`), nothing MVC-scale.** Directive 2026-08-20: iteration 17
(library kind + `internal/`) is **parked** — spec + plan approved and ready
on branch `library-internal` — and the framework itself is the work. The
v1-polish slice landed the same day (branch `framework-v1`): registration
helpers `get/post/put/delete_` (the take-Handler shape, probe-proven),
405 + `Allow` on wrong-method hits, HEAD served as GET with the body
suppressed, a `Logging` middleware, `set_header`; `just web-app` grew to
16/0. En route it exposed and fixed a real emitter bug: a Text-typed
single-segment interpolation of a place (`"${r.method}"`, `r` a loop
borrow) crossed `let`/assignment boundaries uncopied — aliased the field,
crashed the release build; `copy_place_text` now sees through `Interp`
exactly as `drop_fresh_text` does, pinned by
`tests/corpus/run/interp-borrowed-field`.
1. Merge the `web-framework` branch to master (iterations 15–17 docs + code,
local only). Why first: everything iteration 17 edits exists ONLY on that
branch — the framework and web-app sources, the `[deps]` resolution code
in `compiler/bin/main.ml` (17's `internal/` rule lands in that exact
code), and the `just web-app` regression gate. Starting 17 unmerged means
stacking a branch on an unreviewed branch; merged, 17 is a small clean
diff off master.
2. Spec + plan from the settled decisions (prose only, per convention).
3. `kind = "library"` in `wo.toml` (default `"program"`): `woc <dir>` on a
library runs the FULL pipeline as a check — parse, typecheck, borrow
check, GC inference — with no entry required; an explicit build still
works when a `main` exists (dual lib+bin).
4. The `internal/` rule at the `[deps]` boundary: a consumer `use` of a dep
path containing the segment `internal` is a new WO-E1xx at the `use`,
naming the dependency; inside the dep it stays legal.
5. Framework reorg: request-parser and serve-loop plumbing move under
`internal/`; the public surface (`Handler`, `Middleware`, `App`,
`Req`/`Resp`, builders) does not move.
6. Gate: `just web-app` stays 14/0, plus new checks — library check without
an entry succeeds, a consumer `internal` import is refused, and the
iteration-16 `--emit` verification workaround is deleted.
The VM and GC are untouched by design — visibility is compile-time name
resolution, libraries compile whole-program into the consumer's image, and
GC inference stays whole-program (app usage may promote dep classes; that is
intended).
Next per the implementation order (17 parked): finish the half-done
database branches — 9c (ipc-attach: manifest + binding) and 9d
(keypair-auth: manifest) — both need their forks brainstormed before
planning.
---
@ -167,8 +151,8 @@ that sequences its tasks. Read one, approve, then the next starts.
| 13 | [Compile-time metaprogramming](stories/language-runtime-database/13-compile-time-metaprogramming.md) | ⬜ needs a spec first |
| 14 | [skillhost host workload](stories/language-runtime-database/14-skillhost-host-workload.md) | ⬜ gaps recorded (branch query-grammar found skillhost needs no new query grammar); each gap a candidate iteration |
| 15 | [deps: `wo.toml [deps]`](stories/language-runtime-database/15-deps-package-manager.md) | ✅ **landed 2026-08-18** (branch web-framework): [deps] inline tables, git-binary fetch, wo.lock pinning, offline-when-locked, --update-deps, WO-E106/E107; `just deps-accept` 8/0 |
| 16 | [web framework](stories/language-runtime-database/16-web-framework.md) | ✅ **landed 2026-08-19** — writeonce-framework (HTTP/1.1 + router + Handler/Middleware) consumed by web-app through [deps]; `just web-app` 14/0; h2c parked (§C) behind 8/9f/11 |
| 17 | [library projects + `internal/`](stories/language-runtime-database/17-library-projects-internal.md) | ⬜ **forks settled 2026-08-20, awaiting spec/plan**: kind = "library" manifest key; Go internal/ rule, dep-boundary-only; lib+bin dual; VM/GC untouched by design (impact analysis in the iteration) |
| 16 | [web framework](stories/language-runtime-database/16-web-framework.md) | ✅ **landed 2026-08-19** — writeonce-framework (HTTP/1.1 + router + Handler/Middleware) consumed by web-app through [deps]; h2c parked (§C) behind 8/9f/11. **v1 polish landed 2026-08-20** (branch framework-v1): get/post/put/delete_ helpers, 405+Allow, HEAD, Logging middleware, set_header; `just web-app` 16/0; fixed the interp-borrowed-field emitter crash en route |
| 17 | [library projects + `internal/`](stories/language-runtime-database/17-library-projects-internal.md) | ⏸ **PARKED 2026-08-20** (developer directive; framework v1 first) — forks settled, spec + plan approved and ready on branch `library-internal`: kind = "library" key; Go internal/ rule, dep-boundary-only; lib+bin dual; VM/GC untouched by design |
---
@ -176,10 +160,11 @@ that sequences its tasks. Read one, approve, then the next starts.
| Track | Item | Where |
| -------- | --------------------------------------------------------------------------- | ---------------------------------------------------------- |
| Language | Iteration 17 — web framework as first-class library: spec/plan from the settled forks, then `kind = "library"` + `internal/` + framework reorg | [iteration 17](stories/language-runtime-database/17-library-projects-internal.md) |
| Language | nothing active — the framework v1-polish slice landed 2026-08-20 (branch framework-v1, awaiting merge); next per the order: brainstorm 9c/9d's forks | [order](#implementation-order-re-sequenced-2026-08-20--framework-goal) |
Off-goal work is parked; the goal shifted 2026-08-20 from log-watcher (met)
to the web framework as a first-class library.
Off-goal work is parked; the goal (2026-08-20) is the web framework as a
polished micro-framework v1 — iteration 17 (library kind + `internal/`) is
parked with its spec + plan ready on branch `library-internal`.
### Landed 2026-08-14 — the compile-and-run milestone
@ -353,9 +338,10 @@ parked behind 8/9f/11; the post-12 parked list stays parked by the
2026-08-08 scope directive. (Iteration 7 dropped from this list — landed
2026-08-15.)
1. **17** — THE goal slice: `kind = "library"` + `internal/` + framework
reorg; forks settled, compiler-driver-only, no runtime deps. Merge the
`web-framework` branch first.
1. ~~**17**~~ — **PARKED 2026-08-20** (developer directive: framework v1
first); spec + plan approved, ready on branch `library-internal` for
whenever it unparks. The framework v1-polish slice took its place and
landed the same day (branch framework-v1, `just web-app` 16/0).
2. **9c then 9d** — finish the half-done branches (ipc-attach: manifest +
binding; keypair-auth: manifest) before they rot; 9d folds into 9c's
plan; both must precede 10.

View file

@ -108,10 +108,10 @@ fn main(args: multi Text) -> Int {
let app = App { middleware: [], routes: [] };
app.use_mw(Mw { m: Auth { token: token } });
app.add(Route { method: "GET", pattern: "/products", h: ListProducts { pad: 0 } });
app.add(Route { method: "GET", pattern: "/products/:name", h: ShowProduct { pad: 0 } });
app.add(Route { method: "POST", pattern: "/products", h: CreateProduct { pad: 0 } });
app.add(Route { method: "POST", pattern: "/orders", h: CreateOrder { pad: 0 } });
app.add(Route { method: "DELETE", pattern: "/products/:name", h: DeleteProduct { pad: 0 } });
app.get("/products", ListProducts { pad: 0 });
app.get("/products/:name", ShowProduct { pad: 0 });
app.post("/products", CreateProduct { pad: 0 });
app.post("/orders", CreateOrder { pad: 0 });
app.delete_("/products/:name", DeleteProduct { pad: 0 });
return app.serve("127.0.0.1", port);
}

View file

@ -20,7 +20,16 @@ writeonce-framework = { git = "https://github.com/shoneyj/writeonce-framework",
other client, so closing is the correct shape until shards/fibers (8/11).
A proxy in front simply reconnects.
- **Router** (`router/`): method + path table with `:param` captures into
`req.params`; first match wins; no match is the framework's 404.
`req.params`; first match wins; a known path with the wrong method is
**405 with the `Allow` header** (registration order); no matching path is
the framework's 404. **HEAD is served free**: routed as GET, body
suppressed, `Content-Length` still names the body a GET would carry.
- **Registration helpers**: `app.get/post/put/delete_(pattern, handler)`
push the route for you (`delete_` because `delete` is the query
keyword); `app.add(Route { ... })` stays for anything else. A
request-line `Logging` middleware ships in `router/`, and
`set_header(resp, name, value)` is the escape hatch for headers the
builders don't set.
- **Handlers without closures**: the language has no function values by
doctrine, so a route handler is a class satisfying the `Handler` interface
(`fn handle(req: Req) -> Resp`), dispatched structurally — a

View file

@ -25,22 +25,44 @@ pub class App {
push(self.routes, r);
}
-- Registration helpers — the ctor-literal-into-take shape, per method.
fn get(pattern: Text, take h: Handler) {
push(self.routes, Route { method: "GET", pattern: pattern, h: h });
}
fn post(pattern: Text, take h: Handler) {
push(self.routes, Route { method: "POST", pattern: pattern, h: h });
}
fn put(pattern: Text, take h: Handler) {
push(self.routes, Route { method: "PUT", pattern: pattern, h: h });
}
fn delete_(pattern: Text, take h: Handler) {
push(self.routes, Route { method: "DELETE", pattern: pattern, h: h });
}
fn dispatch(mut req: Req) -> Resp {
for mw in self.middleware {
let short = mw.m.before(req);
if short != nil { return short; }
}
-- Path-first matching so a wrong-method hit on a known path answers
-- 405 with the Allow header (registration order) instead of a 404.
let allow = "";
for r in self.routes {
if r.method == req.method {
let params: map<Text, Text> = {};
if route_match(r.pattern, req.path, params) {
let params: map<Text, Text> = {};
if route_match(r.pattern, req.path, params) {
if r.method == req.method {
-- captures land on the borrowed request itself (mut) — a failed
-- match above never touched it, since captures collect locally
for k, v in params { req.params[k] = v; }
return r.h.handle(req);
}
if allow == "" { allow = "${r.method}"; } else { allow = "${allow}, ${r.method}"; }
}
}
if allow != "" { return method_not_allowed(allow); }
return not_found();
}

View file

@ -20,13 +20,16 @@ fn status_text(code: Int) -> Text {
case 400: return "Bad Request";
case 401: return "Unauthorized";
case 404: return "Not Found";
case 405: return "Method Not Allowed";
case 409: return "Conflict";
case 500: return "Internal Server Error";
default: return "Status";
}
}
pub fn serialize(resp: Resp, keep: Bool) -> Text {
-- head_only: HEAD answers — full status line + headers (Content-Length of
-- the body a GET would have sent) with the body itself suppressed.
pub fn serialize(resp: Resp, keep: Bool, head_only: Bool) -> Text {
let head = "HTTP/1.1 ${resp.status} ${status_text(resp.status)}\r\n";
for k, v in resp.headers {
head = head .. "${k}: ${v}\r\n";
@ -37,6 +40,7 @@ pub fn serialize(resp: Resp, keep: Bool) -> Text {
head = head .. "Connection: close\r\n";
}
head = head .. "Content-Length: ${len(resp.body)}\r\n\r\n";
if head_only { return head; }
return head .. resp.body;
}
@ -54,13 +58,18 @@ pub fn serve(host: Text, port: Int, d: Dispatcher) -> Int {
if p == nil { alive = false; continue; }
if p.closed { alive = false; continue; }
if p.ok == false {
try net.write(c, serialize(bad_request("malformed request"), false)) catch (e) {}
try net.write(c, serialize(bad_request("malformed request"), false, false)) catch (e) {}
alive = false;
continue;
}
let r = p.req;
if r == nil { alive = false; continue; }
carry = p.rest;
-- HEAD is GET with the body suppressed: route and dispatch as GET,
-- serialize with head_only so Content-Length still names the body a
-- GET would have carried (RFC 9110 §9.3.2).
let is_head = r.method == "HEAD";
if is_head { r.method = "GET"; }
-- Connection policy for a single-threaded server: serve PIPELINED
-- requests on one connection (bytes already buffered), but close when
-- the client would idle — a parked keep-alive connection would block
@ -72,7 +81,7 @@ pub fn serve(host: Text, port: Int, d: Dispatcher) -> Int {
if to_lower(conn) == "close" { keep = false; }
}
let resp = try d.dispatch(r) catch (e) server_error();
try net.write(c, serialize(resp, keep)) catch (e) { alive = false; }
try net.write(c, serialize(resp, keep, is_head)) catch (e) { alive = false; }
if keep == false { alive = false; }
}
net.close(c);

View file

@ -61,6 +61,13 @@ pub fn conflict(msg: Text) -> Resp {
return Resp { status: 409, headers: h, body: "{\"error\":\"${msg}\"}" };
}
pub fn method_not_allowed(allow: Text) -> Resp {
let h: map<Text, Text> = {};
h["content-type"] = "application/json";
h["allow"] = allow;
return Resp { status: 405, headers: h, body: "{\"error\":\"method not allowed\"}" };
}
pub fn server_error() -> Resp {
let h: map<Text, Text> = {};
h["content-type"] = "application/json";
@ -72,3 +79,9 @@ pub fn redirect(location: Text) -> Resp {
h["location"] = location;
return Resp { status: 302, headers: h, body: "" };
}
-- Set (or overwrite) one header on a built response — the escape hatch for
-- anything the builders don't cover: cache-control, etag, custom headers.
pub fn set_header(mut r: Resp, name: Text, value: Text) {
r.headers[name] = value;
}

View file

@ -30,6 +30,17 @@ pub class Mw {
m: Middleware
}
-- Request-line logging, the one middleware every framework ships: method +
-- path to stderr, never short-circuits. `pad` is the record-class ctor
-- convention (every stateless handler carries one Int field).
pub class Logging {
pad: Int
fn before(req: Req) -> ?Resp {
print_err("${req.method} ${req.path}");
return nil;
}
}
-- Does `pattern` match `path`? Fills `params` with :name captures.
-- Segments split on '/', empties dropped (so "/a//b" == "/a/b" and the
-- root "/" is the empty segment list). First mismatch wins; a :segment

View file

@ -90,11 +90,15 @@ list, and a pointer to the plan document that already sequences its tasks.
Iterations 3 → 4 → 5 → 6 → 7 are the committed line; nothing off that
line lands before iteration 7's acceptance. Then 7b, then 8–12 (with 9b after the database engine).
- **Goal shifted 2026-08-20: log-watcher (iteration 7, landed 2026-08-15)
to the web framework as a first-class library.** Implementation order for
everything still pending: 17 → 9c/9d → 9e → 8 → 9f → 11 (+ h2c unparks) →
10 → 12 → 14/9g → 13 + parked drain. Rationale and forcing dependencies
live in [`docs/00-status.md`](../../00-status.md) under "Implementation
order".
to the web framework.** Same day, refined by directive: the framework
stays a polished MICRO-framework (routing, middleware, `Req`/`Resp`) —
nothing MVC-scale — and iteration 17 (library kind + `internal/`) is
**parked** with spec + plan ready on branch `library-internal`. The
v1-polish slice landed 2026-08-20 (`just web-app` 16/0). Implementation
order for everything still pending: 9c/9d → 9e → 8 → 9f → 11 (+ h2c
unparks) → 10 → 12 → 14/9g → 13 + parked drain. Rationale and forcing
dependencies live in [`docs/00-status.md`](../../00-status.md) under
"Implementation order".
- **Iteration 7b (inserted 2026-08-11)** sits after the critical path
deliberately: it delays nothing on the log-watcher line, and it must precede
iteration 8 because the collector should be settled before shards multiply.

View file

@ -13,6 +13,17 @@
> stored in containers now MOVE (`run/container-owned-move`), and a dep's
> internal `use` paths resolve dep-relatively.
>
> **v1 polish LANDED 2026-08-20** (branch `framework-v1`, developer
> directive: ship routing/middleware/req-resp as a polished micro-framework,
> nothing MVC-scale): registration helpers `get/post/put/delete_` (the
> take-Handler shape, probe-proven — deviation 1 of the 16 plan retired),
> 405 + `Allow` on wrong-method path hits, HEAD served as GET with the body
> suppressed (RFC 9110 §9.3.2), a request-line `Logging` middleware,
> `set_header`; `just web-app` 16/0. It exposed and fixed a third compiler
> gap: a Text single-segment interpolation of a place crossed
> `let`/assignment boundaries uncopied — `copy_place_text` now sees through
> `Interp` (`run/interp-borrowed-field`).
>
> The framework is written IN writeonce and imported
> like any dependency (iteration 15 is the prerequisite). TLS terminates at a
> reverse proxy — browsers get TLS+ALPN+h2 from nginx/caddy while the

View file

@ -7,8 +7,13 @@
> as an iteration, do not implement yet).
>
> **Refined 2026-08-20: all four forks SETTLED** (developer decisions, no code
> changed). See "Settled decisions" and "Impact analysis" below. Next step is
> the spec/plan; implementation stays parked until asked.
> changed). See "Settled decisions" and "Impact analysis" below.
>
> **⏸ PARKED 2026-08-20** (developer directive: framework v1 work first).
> The spec and plan were written and approved before parking; both sit ready
> on branch `library-internal`
> (`docs/superpowers/specs/2026-08-20-library-kind-internal-design.md`,
> `docs/superpowers/plans/2026-08-20-library-kind-internal.md`).
## Why this iteration exists

View file

@ -96,7 +96,57 @@ expect "unknown product is 404" "$(hit GET /products/none)" 404
expect "create order (FK)" "$(hit POST /orders '{"product":"mug","qty":2}')" 201
expect "delete restricted by FK (409), server survives" "$(hit DELETE /products/mug)" 409 "reference"
# ---- 11. pipelined keep-alive: two requests, one connection ----
# ---- 11. 405 on a known path with the wrong method (Allow header) ----
ma="$(timeout 5 python3 - "$PORT" <<'PYEOF'
import socket, sys
port = int(sys.argv[1])
s = socket.create_connection(("127.0.0.1", port), timeout=3)
s.sendall(b"PUT /products HTTP/1.1\r\nhost: a\r\nauthorization: Bearer s3cr3t\r\nconnection: close\r\ncontent-length: 0\r\n\r\n")
d = b""
while True:
got = s.recv(4000)
if not got: break
d += got
head = d.split(b"\r\n\r\n")[0].decode()
status = head.splitlines()[0].split(" ")[1]
allow = [l.split(":", 1)[1].strip() for l in head.splitlines() if l.lower().startswith("allow")]
print(status + "|" + (allow[0] if allow else ""))
PYEOF
)"
[[ "$ma" == "405|GET, POST" ]] && ok "405 + Allow on wrong method" \
|| bad "405" "got $ma (want 405|GET, POST)"
# ---- 12. HEAD: GET's headers, no body ----
hd="$(timeout 5 python3 - "$PORT" <<'PYEOF'
import socket, sys
port = int(sys.argv[1])
def raw(req):
s = socket.create_connection(("127.0.0.1", port), timeout=3)
s.sendall(req)
d = b""
while True:
got = s.recv(4000)
if not got: break
d += got
s.close()
return d
tail = b" /products HTTP/1.1\r\nhost: a\r\nauthorization: Bearer s3cr3t\r\nconnection: close\r\ncontent-length: 0\r\n\r\n"
g = raw(b"GET" + tail)
h = raw(b"HEAD" + tail)
ghead, _, gbody = g.partition(b"\r\n\r\n")
hhead, _, hbody = h.partition(b"\r\n\r\n")
def cl(head):
return [l.split(b":")[1].strip() for l in head.splitlines() if l.lower().startswith(b"content-length")][0]
status = hhead.decode().splitlines()[0].split(" ")[1]
print(f"{status}|{(cl(h[:len(hhead)]) == cl(g[:len(ghead)]))}|{len(hbody)}|{len(gbody)}")
PYEOF
)"
IFS='|' read -r hs same hb gb <<<"$hd"
[[ "$hs" == "200" && "$same" == "True" && "$hb" == "0" && "$gb" != "0" ]] \
&& ok "HEAD answers GET's Content-Length with no body" \
|| bad "HEAD" "status=$hs same-length=$same head-body=$hb get-body=$gb"
# ---- 13. pipelined keep-alive: two requests, one connection ----
n="$(timeout 5 python3 - "$PORT" <<'PYEOF'
import socket, sys
port = int(sys.argv[1])
@ -117,14 +167,14 @@ PYEOF
[[ "$n" == "2" ]] && ok "pipelined keep-alive (2 responses, 1 connection)" \
|| bad "pipelining" "expected 2 responses, got $n"
# ---- 12. SIGTERM stops it ----
# ---- 14. SIGTERM stops it ----
kill -TERM "$SRV"
stopped=1
for _ in $(seq 1 30); do kill -0 "$SRV" 2>/dev/null || { stopped=0; break; }; sleep 0.1; done
[[ $stopped -eq 0 ]] && ok "SIGTERM stops the server" || bad "stop" "still running"
SRV=""
# ---- 13. restart persistence (WAL replay) ----
# ---- 15. restart persistence (WAL replay) ----
WA_TOKEN=s3cr3t WO_DATA="$DATA" "$W/app/target/web-app" "$PORT" >>"$W/srv.out" 2>&1 &
SRV=$!
sleep 0.5