diff --git a/docs/00-status.md b/docs/00-status.md index c5d28c0..963cb40 100644 --- a/docs/00-status.md +++ b/docs/00-status.md @@ -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 ` 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. diff --git a/docs/examples/web-app/main.wo b/docs/examples/web-app/main.wo index b0d349a..082f950 100644 --- a/docs/examples/web-app/main.wo +++ b/docs/examples/web-app/main.wo @@ -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); } diff --git a/docs/examples/writeonce-framework/README.md b/docs/examples/writeonce-framework/README.md index 526e97c..a8f014c 100644 --- a/docs/examples/writeonce-framework/README.md +++ b/docs/examples/writeonce-framework/README.md @@ -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 diff --git a/docs/examples/writeonce-framework/app.wo b/docs/examples/writeonce-framework/app.wo index 72106c5..2abb0cc 100644 --- a/docs/examples/writeonce-framework/app.wo +++ b/docs/examples/writeonce-framework/app.wo @@ -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 = {}; - if route_match(r.pattern, req.path, params) { + let params: map = {}; + 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(); } diff --git a/docs/examples/writeonce-framework/http/serve.wo b/docs/examples/writeonce-framework/http/serve.wo index a900805..267d93b 100644 --- a/docs/examples/writeonce-framework/http/serve.wo +++ b/docs/examples/writeonce-framework/http/serve.wo @@ -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); diff --git a/docs/examples/writeonce-framework/http/types.wo b/docs/examples/writeonce-framework/http/types.wo index 985c7ef..cb8afe0 100644 --- a/docs/examples/writeonce-framework/http/types.wo +++ b/docs/examples/writeonce-framework/http/types.wo @@ -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 = {}; + 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 = {}; 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; +} diff --git a/docs/examples/writeonce-framework/router/router.wo b/docs/examples/writeonce-framework/router/router.wo index 1faa45d..3f6be22 100644 --- a/docs/examples/writeonce-framework/router/router.wo +++ b/docs/examples/writeonce-framework/router/router.wo @@ -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 diff --git a/docs/stories/language-runtime-database/00-story.md b/docs/stories/language-runtime-database/00-story.md index ced94c5..a4a1df9 100644 --- a/docs/stories/language-runtime-database/00-story.md +++ b/docs/stories/language-runtime-database/00-story.md @@ -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. diff --git a/docs/stories/language-runtime-database/16-web-framework.md b/docs/stories/language-runtime-database/16-web-framework.md index 4e1d400..2c41ecc 100644 --- a/docs/stories/language-runtime-database/16-web-framework.md +++ b/docs/stories/language-runtime-database/16-web-framework.md @@ -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 diff --git a/docs/stories/language-runtime-database/17-library-projects-internal.md b/docs/stories/language-runtime-database/17-library-projects-internal.md index f15d55f..84bbef7 100644 --- a/docs/stories/language-runtime-database/17-library-projects-internal.md +++ b/docs/stories/language-runtime-database/17-library-projects-internal.md @@ -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 diff --git a/scripts/web-app-accept.sh b/scripts/web-app-accept.sh index 259599d..6fc1221 100755 --- a/scripts/web-app-accept.sh +++ b/scripts/web-app-accept.sh @@ -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