Brainstorm outcome (forks locked with the developer): - TLS: proxy-terminated (nginx/caddy gives browsers TLS+ALPN+h2; the framework speaks HTTP/1.1 behind it) — zero TLS in the toolchain, no doctrine fight; homegrown TLS refused outright. - Dependencies: a real mini package manager — wo.toml [deps] with exact-rev git deps, wo.lock, .wo-deps cache, `use <dep>` as a module root; fetch by shelling to the git binary (no network code in woc); flat-only v1. - HTTP/2: v1 is HTTP/1.1 keep-alive; h2c is the parked successor behind iterations 8/9f/11 (multiplexing needs a scheduler to pay off); the bytes/buffer type rides with it, not v1. - Handler model: no function values by doctrine, so Handler/Middleware are structural interfaces (ICALL dispatch, WO-E205-checked); middleware returns ?Resp and rides the shipped ?T narrowing. - Incubation: framework at docs/examples/writeonce-framework/, consuming storefront at docs/examples/web-app/ importing it THROUGH [deps] — the sample exercises fetch -> lock -> build -> serve -> durable-restart. - Iteration 10 relationship: service blocks later LOWER ONTO this library. Files: specs/2026-08-18-web-framework-design.md (A deps normative, B framework normative, C h2c parked); stories 15-deps-package-manager.md + 16-web-framework.md; roadmap + board rows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
184 lines
9.4 KiB
Markdown
184 lines
9.4 KiB
Markdown
# Web framework + dependency system — design spec
|
|
|
|
**Date:** 2026-08-18
|
|
**Status:** approved design, pre-implementation
|
|
**Scope:** (A) a minimal git-backed dependency system (`wo.toml [deps]` +
|
|
`wo.lock`), (B) a web framework written in writeonce as an importable `.wo`
|
|
library, HTTP/1.1 behind a TLS-terminating reverse proxy, and (C) the parked
|
|
HTTP/2 path. Three sub-projects; A and B are the fundable ones, C is a
|
|
recorded successor.
|
|
**Relates to:** iteration 10 (`service` blocks — this framework becomes their
|
|
lowering target, not a rival), iterations 8/9f/11 (the concurrency work h2c
|
|
waits for), `docs/plan/discarded.md` (FFI reject row — load-bearing here).
|
|
|
|
## Decisions locked during brainstorming
|
|
|
|
| Question | Decision |
|
|
| --- | --- |
|
|
| TLS | **Proxy-terminated** (nginx/caddy). Browsers get TLS+ALPN+h2 from the proxy; the framework speaks HTTP/1.1 (later h2c) behind it. Zero TLS in the language or runtime. Direct-serving TLS is a separate future iteration and would be judged against the FFI/libc-only doctrine then, not now. Homegrown TLS is refused outright: a decade of side-channel and certificate-validation subtleties makes it a security liability, not a milestone. |
|
|
| Dependencies | **`wo.toml [deps]` + git fetch.** A real (mini) package manager: exact-rev git dependencies, a lockfile, a per-project cache. No registry, no semver solving. |
|
|
| HTTP/2 | **v1 is HTTP/1.1 keep-alive.** h2's payoff is multiplexing, which a single blocking thread cannot exploit; h2c lands as its own iteration after shards (8) / io_uring (9f) / fibers (11). Behind the proxy, browsers see h2 from day one regardless. |
|
|
| Handler model | **Structural interfaces, not closures.** The language has no function values by doctrine; a route handler is a class satisfying a `Handler` interface, dispatched by ICALL — which works on today's runtime and is checked by WO-E205. |
|
|
| Incubation | Framework is born at `docs/examples/writeonce-framework/`; the consuming app at `docs/examples/web-app/` imports it **through the `[deps]` mechanism** (a local git URL), so the whole import chain is exercised by the sample. Extraction to `github.com/shoneyj/<name>` later is a `git subtree split`, not a redesign. |
|
|
|
|
Rejected: TLS in the runtime via rustls/OpenSSL (breaks libc-only for a
|
|
benefit the proxy already provides; revisit only if direct serving becomes a
|
|
requirement); nghttp2 binding (same doctrine cost, and the scheduler cannot
|
|
use multiplexing yet); vendor-directory imports (the user chose the real
|
|
dependency mechanism); native h2 in v1 (HPACK synchronization and
|
|
flow-control deadlocks are where the time goes — the user's own estimate).
|
|
|
|
## A. Dependency system (`wo.toml [deps]`, `wo.lock`)
|
|
|
|
**Manifest.** `wo.toml` gains one section:
|
|
|
|
```
|
|
[deps]
|
|
niceframework = { git = "https://github.com/shoneyj/niceframework", rev = "v0.1.0" }
|
|
```
|
|
|
|
`rev` is mandatory and exact (a tag or SHA). No version ranges, no registry,
|
|
no resolution algorithm — those are future work a corpus of real deps must
|
|
justify first.
|
|
|
|
**Fetch.** `woc` shells out to the `git` binary (`git clone --depth 1`,
|
|
`git checkout <rev>`) — no network code inside the compiler, OCaml-stdlib
|
|
doctrine intact; `git` joins `cc` in the "external tools the toolchain may
|
|
invoke" set. Dependencies land in `.wo-deps/<name>/` beside `wo.toml`
|
|
(gitignored). A missing `git` binary or unreachable remote is a plain
|
|
diagnostic, never a hang without a message.
|
|
|
|
**Lockfile.** `wo.lock` records `name -> resolved SHA` for every fetch.
|
|
Present lockfile wins over the manifest's rev label (a moved tag is detected
|
|
and reported, not silently followed). `woc --update-deps` refreshes the lock;
|
|
plain builds never touch the network when `.wo-deps` already satisfies the
|
|
lock.
|
|
|
|
**Resolution.** A dependency is an ordinary writeonce project (its own
|
|
`wo.toml` with `name`). `use <depname>` resolves the dep's root directory as
|
|
a module root, exactly like a project-internal module; `use <depname>/sub`
|
|
reaches its subdirectories. Collisions between a dep name and a local module
|
|
diagnose (the existing WO-E collision family).
|
|
|
|
**Transitive deps are refused in v1.** A fetched dep whose own `wo.toml`
|
|
carries `[deps]` is a diagnostic naming the dep — flat-only keeps the
|
|
resolver small and the failure honest. Recorded as the successor iteration's
|
|
first fork.
|
|
|
|
**Entry-point rule.** A dep's `fn main` (if any) is ignored — only the
|
|
consuming app owns the entry. `pub` visibility applies across the dep
|
|
boundary exactly as across modules.
|
|
|
|
## B. The framework (v1, HTTP/1.1, library-only)
|
|
|
|
**Repo shape.** `docs/examples/writeonce-framework/` is a complete writeonce
|
|
project (`wo.toml name = "niceframework"` — final name decided at extraction)
|
|
whose modules are the framework:
|
|
|
|
- `http/` — request parsing, response serialization, keep-alive loop.
|
|
Extracted from the proven log-watcher `mcp` pattern (601k requests soaked,
|
|
fd-clean, SIGTERM-clean).
|
|
- `router/` — method+path table with `:param` captures.
|
|
- `app.wo` — the assembly: register routes/middleware, `serve(port)`.
|
|
|
|
**Types (records).**
|
|
|
|
- `Req`: `method: Text`, `path: Text`, `params: map<Text, Text>` (the
|
|
`:param` captures), `query: map<Text, Text>`, `headers: map<Text, Text>`,
|
|
`body: Text`.
|
|
- `Resp`: `status: Int`, `headers: map<Text, Text>`, `body: Text`, plus
|
|
builder helpers (`ok_json`, `ok_text`, `not_found`, `redirect`, …).
|
|
|
|
**Handlers.** One structural interface:
|
|
|
|
```
|
|
interface Handler {
|
|
fn handle(req: Req) -> Resp
|
|
}
|
|
```
|
|
|
|
A route is any class satisfying it (state lives in the class's own fields —
|
|
the closure substitute). Registration passes the handler *value*:
|
|
`app.get("/products/:id", ProductShow { ... })`. Dispatch is ICALL;
|
|
WO-E205 makes a non-conforming handler a compile error.
|
|
|
|
**Middleware.** Its own one-method interface (distinct from `Handler`,
|
|
because the signatures differ):
|
|
|
|
```
|
|
interface Middleware {
|
|
fn before(req: Req) -> ?Resp
|
|
}
|
|
```
|
|
|
|
The app holds a `multi` of middleware run in order before routing (auth,
|
|
logging, content-type defaults). Returning a `Resp` short-circuits; nil means
|
|
"continue" — `?Resp` narrowing is exactly what the 2026-08-18 `?T`
|
|
enforcement shipped, used as a design tool.
|
|
|
|
**Server core.** `net.listen/accept` blocking loop, HTTP/1.1 with keep-alive
|
|
and `Content-Length` bodies (no chunked encoding in v1 — disclosed), fd
|
|
closed on every exit path, `env.stopping()` honored. **Single-threaded,
|
|
blocking, one request at a time** — stated in the framework README, with the
|
|
proxy config (nginx upstream keepalive) as the deployment story. Concurrency
|
|
arrives via iterations 8/11 underneath the same library surface.
|
|
|
|
**Database.** Nothing to build: handlers use `@table` + the query surface
|
|
directly. This is the differentiator — a durable, compiler-checked data layer
|
|
with no ORM and no separate database process, in the same binary.
|
|
|
|
**The consuming app.** `docs/examples/web-app/` — a small storefront:
|
|
`Product`/`Order` as `@table` classes, list/show/create routes, one auth
|
|
middleware, JSON responses. Its `wo.toml [deps]` points at the framework by
|
|
git URL (`file://` in CI, the GitHub URL after extraction). Its acceptance
|
|
script is the whole feature's gate: fetch deps, build, serve, curl the
|
|
routes, restart-persistence check, SIGTERM.
|
|
|
|
## C. HTTP/2 (h2c) — parked successor
|
|
|
|
After iterations 8 (shards) / 9f (io_uring) / 11 (fibers): h2c framing +
|
|
HPACK, either natively (needs a bytes/buffer type with cheap slicing — that
|
|
type rides with this iteration, not v1) or via nghttp2-in-runtime (a doctrine
|
|
decision to re-argue then, with the TweetNaCl precedent and the libc-only
|
|
rule both on the table). Behind the proxy, h2c's win is backend multiplexing;
|
|
browsers already had h2 since v1. No TLS obligation even here.
|
|
|
|
## Error handling
|
|
|
|
Dep failures (missing git, bad rev, dirty cache, transitive deps) are
|
|
compiler diagnostics with the WO-E1xx driver family. Framework runtime
|
|
failures follow house rules: malformed requests get a 400 and a closed
|
|
connection, handler traps are caught at the serve loop (`try`) and answered
|
|
with a 500 — a bad request must never kill the server; the soak asserts
|
|
resident + fd flatness exactly like log-watcher's.
|
|
|
|
## Testing
|
|
|
|
- Dep system: fixture projects under `tests/` — fetch-and-build from a local
|
|
git repo, lockfile drift detection, transitive-dep refusal, collision
|
|
diagnostic. Network-free (local `file://` remotes).
|
|
- Framework: unit-ish `.wo` fixtures for the router and parser; the web-app
|
|
acceptance script (curl matrix incl. keep-alive reuse, 404/400/500 paths,
|
|
restart persistence, stop) is the gate; `LW_SOAK`-style opt-in soak.
|
|
- The whole chain (deps fetch -> build -> serve) runs in `just web-app`.
|
|
|
|
## Success criteria
|
|
|
|
1. `docs/examples/web-app` builds by fetching `writeonce-framework` through
|
|
`[deps]` + `wo.lock` with no path references, and `woc` never touches the
|
|
network when the lock is satisfied.
|
|
2. The storefront serves list/show/create with `@table` persistence across a
|
|
restart, behind nginx with browser-visible h2 (proxy-terminated), while
|
|
the backend speaks HTTP/1.1.
|
|
3. A non-conforming handler class is WO-E205 at compile time; a trapping
|
|
handler answers 500 and the server survives (soak-proven).
|
|
4. Framework extraction to a standalone repo requires changing only the
|
|
app's `[deps]` URL.
|
|
|
|
## Out of scope (v1)
|
|
|
|
TLS anywhere in the toolchain; HTTP/2 and the bytes/buffer type (parked to
|
|
C); chunked transfer encoding; WebSockets/SSE (needs the push story);
|
|
transitive dependencies, version ranges, registries; templates/SSR (the
|
|
removed UI track is not resurrected here — JSON APIs first); multipart
|
|
uploads.
|