diff --git a/docs/examples/web-app/README.md b/docs/examples/web-app/README.md new file mode 100644 index 0000000..950b91e --- /dev/null +++ b/docs/examples/web-app/README.md @@ -0,0 +1,41 @@ +# web-app — the storefront sample + +A small store: `Product`/`Order` as `@table` classes, JSON routes, one auth +middleware — built on [`writeonce-framework`](../writeonce-framework/), which +it imports **through `[deps]`** (iteration 15). This app is iteration 16's +acceptance workload: `just web-app` runs the whole chain — fetch → lock → +build → serve → curl matrix → restart persistence → SIGTERM. + +## Routes + +| Route | What | +| --- | --- | +| `GET /products` | list (JSON array) | +| `GET /products/:id` | one product or 404 | +| `POST /products` | create from a JSON body (`name`, `price`, `stock`); 400 on malformed JSON; 409 on a duplicate name (`@unique`) | +| `POST /orders` | create (`product`, `qty`); FK checked | +| `DELETE /products/:id` | 409 while orders reference it (FK restrict), 200 after | + +Every request needs `authorization: Bearer ` (the auth middleware); +the token comes from the `WA_TOKEN` env var. + +## Run + + woc . # fetches deps, builds target/web-app + WA_TOKEN=secret WO_DATA=./data ./target/web-app 8080 + +## TLS / HTTP2 + +None here, deliberately: deploy behind nginx/caddy — the proxy terminates +TLS+ALPN and speaks h2 to browsers while this backend serves HTTP/1.1 +keep-alive. Sketch: + + server { + listen 443 ssl; + http2 on; + location / { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Connection ""; + } + } diff --git a/docs/examples/web-app/wo.toml b/docs/examples/web-app/wo.toml new file mode 100644 index 0000000..d959e21 --- /dev/null +++ b/docs/examples/web-app/wo.toml @@ -0,0 +1,17 @@ +name = "web-app" +version = "0.1.0" +description = "Storefront sample: consumes writeonce-framework through [deps]; @table persistence; iteration 16's acceptance workload" + +[runtime] +wo = ">= 0.1" + +# No [build] runtime pinned (portable): an installed woc self-locates wovm; +# the in-repo gate passes WO_RUNTIME (log-watcher's precedent). + +# The framework is a real dependency, never a relative path — extraction of +# the framework to its own repository changes only this URL. The acceptance +# gate (scripts/web-app-accept.sh) substitutes a run-time file:// remote +# built from docs/examples/writeonce-framework, so CI never needs the +# network and this repo never carries .wo-deps/wo.lock artifacts. +[deps] +writeonce-framework = { git = "https://github.com/shoneyj/writeonce-framework", rev = "v0.1.0" } diff --git a/docs/examples/writeonce-framework/README.md b/docs/examples/writeonce-framework/README.md new file mode 100644 index 0000000..d5d54ab --- /dev/null +++ b/docs/examples/writeonce-framework/README.md @@ -0,0 +1,43 @@ +# writeonce-framework + +A web framework **written in writeonce**, consumed as a `[deps]` dependency +(iteration 15). Spec: `docs/superpowers/specs/2026-08-18-web-framework-design.md` §B. + +```toml +[deps] +writeonce-framework = { git = "https://github.com/shoneyj/writeonce-framework", rev = "v0.1.0" } +``` + +## What it is + +- **HTTP/1.1 keep-alive** server core (`http/`): request parsing + (`Content-Length` bodies), response serialization, a blocking serve loop + that answers 400 to malformed requests, 500 to trapping handlers (and + survives), closes every fd, and honors SIGTERM. +- **Router** (`router/`): method + path table with `:param` captures into + `req.params`; first match wins; no match is the framework's 404. +- **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 + non-conforming handler is a compile error (WO-E205). Middleware is its own + interface (`fn before(req: Req) -> ?Resp`; nil = continue, a `Resp` + short-circuits). +- **Data layer for free**: handlers use `@table` + the query surface + directly — durable, compiler-checked persistence in the same binary. No + ORM, no database server. + +## Honest limits (v1, all deliberate) + +- **Single-threaded, blocking** — one request at a time. Concurrency arrives + underneath this same surface with the shard/fiber iterations (8/11). +- **TLS: none, anywhere.** Deploy behind nginx/caddy; the proxy terminates + TLS+ALPN and gives browsers HTTP/2 while this backend speaks HTTP/1.1 + keep-alive. See the web-app sample's README for the nginx sketch. +- `Content-Length` bodies only (no chunked encoding), no WebSockets/SSE, + JSON-first (no templates). + +## The consuming sample + +`docs/examples/web-app` — a small storefront importing this framework +through `[deps]`. Its acceptance (`just web-app`) exercises the whole chain: +fetch → lock → build → serve → durable restart. diff --git a/docs/examples/writeonce-framework/http/types.wo b/docs/examples/writeonce-framework/http/types.wo new file mode 100644 index 0000000..985c7ef --- /dev/null +++ b/docs/examples/writeonce-framework/http/types.wo @@ -0,0 +1,74 @@ +-- http/types.wo — the request/response shapes every layer shares, plus the +-- response builders. Records only; parsing lives in http/parse.wo, the +-- serve loop in http/serve.wo, dispatch in router/ and app.wo. + +pub typedef Req = { + method: Text, -- uppercased: GET, POST, ... + path: Text, -- decoded path, query stripped + params: map, -- :param captures, filled by the router + query: map, -- decoded query-string pairs + headers: map, -- names lowercased on read + body: Text -- exactly Content-Length bytes ("" if none) +} + +pub typedef Resp = { + status: Int, + headers: map, + body: Text +} + +-- ---- builders: every route answers through one of these ---------------- + +pub fn ok_text(body: Text) -> Resp { + let h: map = {}; + h["content-type"] = "text/plain; charset=utf-8"; + return Resp { status: 200, headers: h, body: body }; +} + +pub fn ok_json(body: Text) -> Resp { + let h: map = {}; + h["content-type"] = "application/json"; + return Resp { status: 200, headers: h, body: body }; +} + +pub fn created_json(body: Text) -> Resp { + let h: map = {}; + h["content-type"] = "application/json"; + return Resp { status: 201, headers: h, body: body }; +} + +pub fn not_found() -> Resp { + let h: map = {}; + h["content-type"] = "application/json"; + return Resp { status: 404, headers: h, body: "{\"error\":\"not found\"}" }; +} + +pub fn bad_request(msg: Text) -> Resp { + let h: map = {}; + h["content-type"] = "application/json"; + return Resp { status: 400, headers: h, body: "{\"error\":\"${msg}\"}" }; +} + +pub fn unauthorized() -> Resp { + let h: map = {}; + h["content-type"] = "application/json"; + return Resp { status: 401, headers: h, body: "{\"error\":\"unauthorized\"}" }; +} + +pub fn conflict(msg: Text) -> Resp { + let h: map = {}; + h["content-type"] = "application/json"; + return Resp { status: 409, headers: h, body: "{\"error\":\"${msg}\"}" }; +} + +pub fn server_error() -> Resp { + let h: map = {}; + h["content-type"] = "application/json"; + return Resp { status: 500, headers: h, body: "{\"error\":\"internal error\"}" }; +} + +pub fn redirect(location: Text) -> Resp { + let h: map = {}; + h["location"] = location; + return Resp { status: 302, headers: h, body: "" }; +} diff --git a/docs/examples/writeonce-framework/wo.toml b/docs/examples/writeonce-framework/wo.toml new file mode 100644 index 0000000..c96f94d --- /dev/null +++ b/docs/examples/writeonce-framework/wo.toml @@ -0,0 +1,10 @@ +name = "writeonce-framework" +version = "0.1.0" +description = "A web framework written in writeonce: HTTP/1.1 keep-alive server core, router with :param captures, Handler/Middleware structural interfaces (iteration 16)" + +[runtime] +wo = ">= 0.1" + +# A LIBRARY project: no `fn main` here — the consuming app owns the entry. +# Apps import this repo through `wo.toml [deps]` (iteration 15) and +# `use writeonce-framework` / `use writeonce-framework/http` / `.../router`.