- rename the two libraries: writeonce-framework -> writeonce-serve
(`use serve`), wo-html -> writeonce-view (`use view`). Names say the
ROLE now; every sample, script, gate and live doc follows
- stories/specs/plans keep the old names: they are dated records, and
both library READMEs carry a "renamed 2026-08-25" note
- serve/http/files.wo: StaticFiles { dir, max_bytes } — traversal
refused not normalised, extension content types, attachment
disposition for archives. Lifted out of the shop, which had said in
a comment that it belonged in the framework
- shop drops its private copy and mounts the framework's
- site: /dl/*path over $WO_DIST (default ./dist), 16 MiB ceiling
- /install gains supported systems — Linux x86-64, glibc >= 2.38,
not musl — read off `file` and the binaries' GLIBC_ symbol
versions, not off a wish list; plus GitHub release as primary,
/dl as mirror, and the sha256 verify step
- site-accept: 17 -> 21 checks (supported systems, gzip download with
a binary-safe probe, checksum, /dl traversal 404)
Verified on 192.168.0.165: the real 960,820-byte tarball downloads
as application/gzip and its sha256 matches the published digest.
Gates: oop-accept MET, site 21/0, web-app 46/0, fibers 10/0,
db-actor 8/0; shop rebuilt and its /assets served by the framework.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
264 lines
9.4 KiB
Text
264 lines
9.4 KiB
Text
-- web-app — the storefront: writeonce-serve (via [deps]) + @table
|
|
-- persistence. Every handler is a class satisfying Handler; the auth gate is
|
|
-- a Middleware; the data layer is the language's own database — no ORM, no
|
|
-- separate process, one binary.
|
|
use env
|
|
use json
|
|
use net
|
|
use time
|
|
use serve
|
|
use serve/http
|
|
use serve/router
|
|
|
|
-- decode target for POST /products, encode shape for every product answer
|
|
typedef ProductView = { name: Text, price: Float, stock: Int }
|
|
typedef NewOrder = { product: Text, qty: Int }
|
|
|
|
fn view_json(name: Text, price: Float, stock: Int) -> Text {
|
|
return json.encode(ProductView { name: name, price: price, stock: stock });
|
|
}
|
|
|
|
class ListProducts {
|
|
fn handle(req: Req) -> Resp {
|
|
let body = "[";
|
|
let first = true;
|
|
for p in from x in Product order by x.name select x {
|
|
if first == false { body = body .. ","; }
|
|
first = false;
|
|
body = body .. view_json(p.name, p.price, p.stock);
|
|
}
|
|
return ok_json(body .. "]");
|
|
}
|
|
}
|
|
|
|
class ShowProduct {
|
|
fn handle(req: Req) -> Resp {
|
|
let name = req.params["name"];
|
|
if name == nil { return bad_request("no name"); }
|
|
let hits = from p in Product where p.name == name take 1 select p;
|
|
if len(hits) == 0 { return not_found(); }
|
|
let p = hits[0];
|
|
return ok_json(view_json(p.name, p.price, p.stock));
|
|
}
|
|
}
|
|
|
|
-- Accepts THREE bodies: multipart/form-data (curl -F), a form post
|
|
-- (application/x-www-form-urlencoded), and JSON — same insert either way.
|
|
fn create_product(name: Text, price: Float, stock: Int) -> Resp {
|
|
let made = try insert Product { name: name, price: price, stock: stock }
|
|
catch (e) nil;
|
|
if made == nil { return conflict("product name already exists"); }
|
|
return created_json(view_json(name, price, stock));
|
|
}
|
|
|
|
class CreateProduct {
|
|
fn handle(req: Req) -> Resp {
|
|
if media_type(req) == "multipart/form-data" {
|
|
let ps = multipart_parts(req);
|
|
if ps == nil { return bad_request("unreadable multipart body"); }
|
|
let name = part_named(ps, "name");
|
|
if name == nil { return bad_request("multipart needs name, price, stock"); }
|
|
let pstr = part_named(ps, "price");
|
|
if pstr == nil { return bad_request("multipart needs name, price, stock"); }
|
|
let sstr = part_named(ps, "stock");
|
|
if sstr == nil { return bad_request("multipart needs name, price, stock"); }
|
|
-- parse_float answers NaN for unparseable input rather than a ?Float:
|
|
-- "not a number" is already a Float value, and NaN != NaN is the test
|
|
let price = parse_float(pstr);
|
|
if price != price { return bad_request("price must be a number"); }
|
|
let stock = parse_int(sstr);
|
|
if stock == nil { return bad_request("stock must be a number"); }
|
|
return create_product(name, price, stock);
|
|
}
|
|
if media_type(req) == "application/x-www-form-urlencoded" {
|
|
let f = form_values(req);
|
|
if f == nil { return bad_request("unreadable form body"); }
|
|
let name = f["name"];
|
|
if name == nil { return bad_request("form needs name, price, stock"); }
|
|
let ps = f["price"];
|
|
if ps == nil { return bad_request("form needs name, price, stock"); }
|
|
let ss = f["stock"];
|
|
if ss == nil { return bad_request("form needs name, price, stock"); }
|
|
let price = parse_float(ps);
|
|
if price != price { return bad_request("price must be a number"); }
|
|
let stock = parse_int(ss);
|
|
if stock == nil { return bad_request("stock must be a number"); }
|
|
return create_product(name, price, stock);
|
|
}
|
|
let v = json.decode(req.body) as ProductView;
|
|
if v == nil { return bad_request("body must be {name, price, stock}"); }
|
|
return create_product(v.name, v.price, v.stock);
|
|
}
|
|
}
|
|
|
|
class CreateOrder {
|
|
fn handle(req: Req) -> Resp {
|
|
let v = json.decode(req.body) as NewOrder;
|
|
if v == nil { return bad_request("body must be {product, qty}"); }
|
|
if v.qty < 1 { return bad_request("qty must be positive"); }
|
|
let hits = from p in Product where p.name == v.product take 1 select p;
|
|
if len(hits) == 0 { return not_found(); }
|
|
insert Order { product: hits[0], qty: v.qty };
|
|
return created_json("{\"ok\":true}");
|
|
}
|
|
}
|
|
|
|
class DeleteProduct {
|
|
fn handle(req: Req) -> Resp {
|
|
let name = req.params["name"];
|
|
if name == nil { return bad_request("no name"); }
|
|
let hits = from p in Product where p.name == name take 1 select p;
|
|
if len(hits) == 0 { return not_found(); }
|
|
let gone = try delete hits[0] catch (e) nil;
|
|
if gone == nil { return conflict("orders still reference this product"); }
|
|
return ok_json("{\"deleted\":true}");
|
|
}
|
|
}
|
|
|
|
-- ---- iteration 35, the serving slice: fiber-per-connection ------------
|
|
-- The app owns the accept loop and spawns ONE ConnWorker actor per
|
|
-- accepted connection (spawn takes a class literal, so this ten-line
|
|
-- pattern lives app-side by doctrine). Each worker builds its OWN App —
|
|
-- route tables are small, and per-shard placement means no shared state
|
|
-- crosses heaps — then runs the framework's keep-alive loop with
|
|
-- deadlines. Concurrency: a slow request no longer blocks the next one;
|
|
-- a stalled client is evicted at the read deadline; an idle keep-alive
|
|
-- connection parks (blocking nobody) until the idle deadline.
|
|
|
|
class Conn {
|
|
fd: net.Conn
|
|
}
|
|
|
|
class ConnWorker {
|
|
token: Text
|
|
read_ms: Int
|
|
idle_ms: Int
|
|
fn receive(msg: Conn) {
|
|
let app = build_app(self.token);
|
|
app.handle_conn(msg.fd, self.read_ms, self.idle_ms);
|
|
}
|
|
}
|
|
|
|
-- a deliberately slow route: the concurrency proof's workload
|
|
class Slow {
|
|
fn handle(req: Req) -> Resp {
|
|
time.sleep(400);
|
|
return ok_text("slow done");
|
|
}
|
|
}
|
|
|
|
-- ---- framework v1 slice 2: the storefront exercises the new surface ----
|
|
|
|
-- wildcard capture: GET /files/*path echoes the rest
|
|
class EchoPath {
|
|
fn handle(req: Req) -> Resp {
|
|
let p = req.params["path"];
|
|
if p == nil { return ok_text("path="); }
|
|
return ok_text("path=${p}");
|
|
}
|
|
}
|
|
|
|
-- group middleware writes the request-scoped ctx bag; the handler reads it
|
|
class StampCtx {
|
|
fn before(mut req: Req) -> ?Resp {
|
|
req.ctx["via"] = "api-group";
|
|
return nil;
|
|
}
|
|
}
|
|
|
|
class ApiPing {
|
|
fn handle(req: Req) -> Resp {
|
|
let via = req.ctx["via"];
|
|
if via == nil { return ok_text("pong via="); }
|
|
return ok_text("pong via=${via}");
|
|
}
|
|
}
|
|
|
|
-- ETag + conditional: same body = same tag; If-None-Match collapses to 304
|
|
class EtagProbe {
|
|
fn handle(req: Req) -> Resp {
|
|
return with_etag(req, ok_json("{\"v\":1}"));
|
|
}
|
|
}
|
|
|
|
-- response-side negotiation: JSON or nothing
|
|
class NegoProbe {
|
|
fn handle(req: Req) -> Resp {
|
|
if accepts(req, "application/json") == false {
|
|
let h: map<Text, Text> = {};
|
|
h["content-type"] = "application/json";
|
|
return Resp { status: 406, headers: h, body: "{\"error\":\"json only\"}" };
|
|
}
|
|
return ok_json("{\"ok\":true}");
|
|
}
|
|
}
|
|
|
|
fn main(args: multi Text) -> Int {
|
|
if len(args) < 1 {
|
|
print_err("usage: web-app <port> (WA_TOKEN and WO_DATA must be set)");
|
|
return 2;
|
|
}
|
|
let port = parse_int(args[0]);
|
|
if port == nil {
|
|
print_err("web-app: <port> must be a number");
|
|
return 2;
|
|
}
|
|
let token = env.get("WA_TOKEN");
|
|
if token == nil {
|
|
print_err("web-app: WA_TOKEN is required (the auth middleware's bearer token)");
|
|
return 2;
|
|
}
|
|
|
|
-- iteration 35: the app-owned accept loop. accept_dl's tick keeps the
|
|
-- stop flag honored within 250ms; each accepted fd moves into its own
|
|
-- ConnWorker actor (placed round-robin across shards — DB access from
|
|
-- any shard rides the transparent DB actor).
|
|
let read_ms = 5000;
|
|
let idle_ms = 5000;
|
|
let ie = env.get("WA_IDLE_MS");
|
|
if ie != nil {
|
|
let iv = parse_int(ie);
|
|
if iv != nil { idle_ms = iv; read_ms = iv; }
|
|
}
|
|
let srv = net.listen("127.0.0.1", port);
|
|
print("listening on 127.0.0.1:${port}");
|
|
while true {
|
|
if env.stopping() { net.close(srv); return 0; }
|
|
let c = net.accept_dl(srv, 250);
|
|
if c != nil {
|
|
let w: actor Conn = spawn ConnWorker { token: "${token}", read_ms: read_ms, idle_ms: idle_ms };
|
|
send(w, Conn { fd: c });
|
|
}
|
|
}
|
|
}
|
|
|
|
-- Every worker's own App: registration is idempotent code, and per-worker
|
|
-- construction is what lets connections live on ANY shard without shared
|
|
-- state (route tables are small; measured before optimized, per doctrine).
|
|
fn build_app(token: Text) -> App {
|
|
let app = App { middleware: [], routes: [] };
|
|
-- v1 slice 2: host gate first (421 before anything runs), CORS preflight
|
|
-- next, then the framework's Bearer mechanism (constant-time compare,
|
|
-- principal attached to req.principal for handlers that want "who")
|
|
app.use_mw(Mw { m: HostAllow { host: "a" } });
|
|
app.use_mw(Mw { m: Cors { allow_origin: "*" } });
|
|
app.use_mw(Mw { m: BearerAuth { token: "${token}", principal: "api" } });
|
|
-- the response half: security headers + the CORS origin stamp on every
|
|
-- response that leaves dispatch (404/405/401 included)
|
|
app.use_after(Aw { a: SecurityHeaders {} });
|
|
app.use_after(Aw { a: Cors { allow_origin: "*" } });
|
|
app.get("/products", ListProducts {});
|
|
app.get("/products/:name", ShowProduct {});
|
|
app.post("/products", CreateProduct {});
|
|
app.post("/orders", CreateOrder {});
|
|
app.delete_("/products/:name", DeleteProduct {});
|
|
app.get("/files/*path", EchoPath {});
|
|
app.get("/etag-probe", EtagProbe {});
|
|
app.get("/nego", NegoProbe {});
|
|
app.get("/slow", Slow {});
|
|
let g = Group { prefix: "/api" };
|
|
g.use_mw(Mw { m: StampCtx {} });
|
|
g.get("/ping", ApiPing {});
|
|
app.mount(g);
|
|
return app;
|
|
}
|