forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
26.0 KiB
1# Topcoat 0.10 API cheat-sheet (git forge web layer)
2
3`REG` = `/home/tahinli/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f`. Sub-crates: `REG/topcoat-<name>-0.10.0/`. **UNVERIFIED** = not confirmed in source. Snippets checked against signatures and docs, not compiled.
4
5Key files: facade `REG/topcoat-0.10.0/src/{lib,serve}.rs`; router `REG/topcoat-router-0.10.0/src/{router,builder,request,response,error,body,body_limit,compression,origin,proxy,listener}.rs`, `.../src/content/{json,form}.rs`; cookies `REG/topcoat-cookie-0.10.0/src/{lib,prefix,store}.rs`; context `REG/topcoat-core-0.10.0/src/context/app_context.rs`; views `REG/topcoat-view-0.10.0/src/`, `REG/topcoat-view-macro-0.10.0/docs/view.md`; guides `REG/topcoat-0.10.0/docs/*.md`, `REG/topcoat-router-0.10.0/docs/*.md`.
6
7---
8
9## 1. Feature flags
10
11Default: `asset, compression, cookie, font, icon, router, runtime, serve, session, view, discover`.
12
13| Feature | Enables |
14|---|---|
15| `router` | `topcoat::router` (routes, pages, layouts, layers, `Router`); pulls `view` |
16| `serve` | `topcoat::start/serve/serve_until`. Without it use `Router::handle` only |
17| `discover` | Link-time collection: `module_router!()`, `.discover()` |
18| `view` | `view!`, `#[component]`, `class!`, `suspense`, `error_boundary` |
19| `cookie` | `topcoat::cookie`. Still call `.cookies()` on the builder |
20| `asset` | `asset!`, `AssetBundle` |
21| `compression` | Response compression (on by default, see 7e) |
22| `session` | `topcoat::session` (**UNVERIFIED**, not covered) |
23| `fs` | `public_dir`, `serve_dir` (not default) |
24| `multipart` | `content::Multipart` (not default) |
25| `websocket`, `sse`, `sitemap`, `tower`, `tailwind`, `datastar`, `htmx`, `alpine-ajax`, `mail`, `ui`, `font-fontsource`, `icon-iconify`, `anyhow`, `full` | Optional extras (not default) |
26Minimal deps: `topcoat = "0.10.0"`, `tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "io-util"] }`. For streaming (4b) also `http-body = "1"`, `http-body-util = "0.1"`, `tokio-util = { version = "0.7", features = ["io"] }`, `futures-util = "0.3"`.
27
28---
29
30## 2. Routes
31
32### 2a. Module routing (recommended)
33
34```rust
35// src/app.rs (module maps to "/")
36use topcoat::router::{Router, module_router};
37pub fn router() -> Router { module_router!().discover().build() }
38```
39
40- `module_router!()` returns a `RouterBuilder`. `.discover()` (trait `topcoat::router::RouterBuilderDiscoverExt`) also collects explicit-path items.
41- `app::about` -> `/about`; `app::blog_posts` -> `/blog-posts`. Handler names do not affect the URL.
42- Without a path string, `#[page]`, `#[route(GET)]`, `#[layout]`, `#[layer]` take the module path. `#[route(GET "./health")]` appends to it. Source: `REG/topcoat-router-0.10.0/docs/module.md`.
43
44### 2b. Explicit paths
45
46```rust
47use topcoat::{Result, router::{Router, page, route}, view::{View, view}};
48#[page("/")]
49async fn home() -> Result<impl View> { Ok(view! { <h1>"Home"</h1> }) }
50#[route(GET "/api/health")]
51async fn health() -> Result<&'static str> { Ok("ok") }
52pub fn router() -> Router { Router::builder().page(home).route(health).build() }
53```
54
55Builder methods: `.page(f)`, `.route(f)`, `.layout(f)`, `.layer(f)`, `.discover()`, `.build()`. Source: `REG/topcoat-router-0.10.0/src/builder.rs`.
56
57### 2c. Attributes
58
59```rust
60#[route(GET "/x")] // one method
61#[route([GET, POST] "/x")] // several
62#[route(* "/x")] // any method; specific-method routes win over `*`
63#[page] // GET, module path
64#[page("/x")] // GET, explicit
65#[page(POST "/x")] // POST page
66```
67
68Route handlers return `Result<T>` where `T: AsyncIntoResponse`. Pages return `Result<impl View>`. Parameters: optional `cx: &Cx` and at most one body extractor, in either order. Overlapping methods at the same path fail at `.build()`.
69
70### 2d. Path params: `/{owner}/{repo}/info/refs`
71
72```rust
73use topcoat::{Result, context::Cx, router::{path_param, route}, view::{View, view}};
74path_param!(owner); // unparsed: path_param::<Owner>(cx) -> &str (percent-decoded)
75path_param!(repo);
76path_param!(id: u64); // parsed with FromStr: path_param::<Id>(cx) -> Result<&u64, _>
77path_param!(id2: u64, error = not_found); // error = not_found|bad_request|unauthorized|forbidden|redirect("/x")
78path_param!(*rest); // catch-all: "{*rest}", path_param::<Rest>(cx) -> CatchAllSegments
79
80#[route(GET "/{owner}/{repo}/info/refs")]
81async fn info_refs(cx: &Cx) -> Result<&'static str> {
82 let owner: &str = path_param::<Owner>(cx);
83 let repo: &str = path_param::<Repo>(cx);
84 let _ = (owner, repo);
85 Ok("ok")
86}
87```
88
89- `path_param!(name)` generates `Name` (Pascal-cased). Name the parameter in the path as `{name}`. Reading a parameter the route did not capture panics.
90- Module form: `module_param!(post_id: u64, error = bad_request);` in `src/app/posts/post_id.rs` serves `/posts/{post_id}` (the module adds one segment). `module_param!(*path)` for catch-all.
91- Source: `REG/topcoat-router-macro-0.10.0/docs/path_param.md`, `.../docs/module_param.md`.
92
93### 2e. Query params
94
95```rust
96use topcoat::{Result, context::Cx, router::{page, query_params}, view::{View, view}};
97#[query_params(error = bad_request)]
98struct InfoRefsQuery { service: Option<String> }
99
100#[page]
101async fn page(cx: &Cx) -> Result<impl View> {
102 let q = query_params::<InfoRefsQuery>(cx)?; // &InfoRefsQuery, memoized
103 Ok(view! { <p>(q.service.as_deref().unwrap_or(""))</p> })
104}
105```
106
107Derives `serde::Deserialize`. Works from any handler with `cx`. Source: `REG/topcoat-router-macro-0.10.0/docs/query_params.md`.
108
109### 2f. Registration summary
110
111- `module_router!()` (module-derived), `Router::builder()` (manual), `.discover()` (all explicit-path items), `.public_dir("./public")` / `.serve_dir("/p/{*f}", "./dir")` (feature `fs`, static files).
112
113---
114
115## 3. Request access
116
117Import helpers from `topcoat::router::request`. All take `cx: &Cx`.
118
119| Need | Call | Returns |
120|---|---|---|
121| method | `method(cx)` | `&http::Method` |
122| uri / query | `uri(cx)` | `&http::Uri` (`uri(cx).query()`) |
123| headers | `headers(cx)` | `&HeaderMap` |
124| content type | `content_type(cx)` | `Option<&str>` |
125| all parts | `parts(cx)` | `&http::request::Parts` |
126| client IP | `client_ip(cx)` | `Option<IpAddr>` (proxy-aware) |
127| peer addr | `remote_addr(cx)` | `Option<SocketAddr>` (None on Unix sockets) |
128| pre-rewrite | `original_uri(cx)`, `original_headers(cx)`, ... | same types |
129
130`client_ip` returns the direct peer unless `.trusted_proxies(TrustedProxies::new()...)` is set on the builder. `ForwardedHeader` variant names: **UNVERIFIED** (`REG/topcoat-router-0.10.0/src/proxy.rs`).
131
132Body extractors (one per handler):
133
134| Type | Behavior |
135|---|---|
136| `Body` (`topcoat::router::Body`) | Streaming. Not limited. `body.into_data_stream()` yields `Result<Bytes, BoxError>` |
137| `Bytes` (`topcoat::router::request::Bytes`) | Buffered, body limit applies (413) |
138| `String` | Buffered UTF-8, body limit applies |
139| `Json<T>` (`topcoat::router::content::Json`) | Requires `Content-Type: application/json` (400 otherwise); malformed = 400 |
140| `Form<T>` (`topcoat::router::content::Form`) | `x-www-form-urlencoded`. On GET/HEAD reads the query string |
141| `RawForm` | Raw form bytes |
142| `Multipart` (feature `multipart`) | **UNVERIFIED** field API (`REG/topcoat-router-0.10.0/docs/content/multipart.md`) |
143| `Option<T>` | Wraps any extractor to accept an absent body |
144
145```rust
146use topcoat::{Result, context::Cx, router::{Body, route, content::Form}};
147
148#[route(POST "/{owner}/{repo}/git-receive-pack")]
149async fn receive(cx: &Cx, body: Body) -> Result<&'static str> { let _ = (cx, body); Ok("") }
150
151#[derive(serde::Deserialize)] struct NewRepo { name: String }
152#[route(POST "/repos")]
153async fn create(Form(input): Form<NewRepo>) -> Result<&'static str> { let _ = input.name; Ok("created") }
154```
155
156Custom extractor: `impl FromRequest { async fn from_request(cx: &Cx, body: Body) -> Result<Self> }`. Delegate buffering to `Bytes` or `to_bytes(body, body_limit(cx))`.
157
158---
159
160## 4. Responses and errors
161
162### 4a. Return types
163
164- `&'static str`, `String`, `Vec<u8>`, `Bytes`, `()` -> 200 with body. `StatusCode` -> empty body. `Body` -> as-is. `Json<T>` -> JSON. `Response` (`http::Response<Body>`) -> full control.
165- Tuples: a leading `StatusCode`, `Parts`, or `Response<()>` sets the status; middle items are `IntoResponseParts` (`HeaderMap`, `[(K, V); N]`, `Extensions`, `Option<T>`); the last item is the body. Source: `REG/topcoat-router-0.10.0/src/response.rs`.
166
167```rust
168use topcoat::{Result, router::{Body, HeaderMap, StatusCode, header, route}};
169#[route(GET "/{owner}/{repo}/info/refs")]
170async fn info_refs() -> Result<(StatusCode, HeaderMap, Body)> {
171 let mut h = HeaderMap::new();
172 h.insert(header::CONTENT_TYPE, "application/x-git-upload-pack-advertisement".parse().unwrap());
173 Ok((StatusCode::OK, h, Body::from("001e# service=git-upload-pack\n0000")))
174}
175// or: Ok((StatusCode::OK, [(header::CONTENT_TYPE, "text/plain")], Body::from("ok")))
176```
177
178### 4b. Streaming from a child process
179
180Pattern from `REG/topcoat-router-0.10.0/src/route/directory.rs` (`fn stream`): `Body::new(StreamBody::new(stream_of_frames))`.
181
182```rust
183use std::process::Stdio;
184use futures_util::TryStreamExt;
185use http_body::Frame;
186use http_body_util::StreamBody;
187use tokio::process::Command;
188use tokio_util::io::ReaderStream;
189use topcoat::{Result, router::{Body, HeaderMap, StatusCode}};
190
191async fn stream_child(mut cmd: Command) -> Result<(StatusCode, HeaderMap, Body)> {
192 let mut child = cmd.stdout(Stdio::piped()).spawn()?; // io::Error -> 500
193 let stdout = child.stdout.take().expect("piped");
194 let chunks = ReaderStream::new(stdout).map_ok(Frame::<bytes::Bytes>::data);
195 Ok((StatusCode::OK, HeaderMap::new(), Body::new(StreamBody::new(chunks))))
196}
197```
198
199- `bytes` is re-exported as `topcoat::router::request::Bytes`.
200- Caveat: `child` drops when this function returns. With `kill_on_drop(true)` the process would die immediately, so either omit it or move the `Child` into the stream (a struct owning `child` and the stream). Without `kill_on_drop`, the child is not killed on client disconnect; it exits on SIGPIPE. **UNVERIFIED** that the router does anything here.
201- Request body to child stdin: `tokio_util::io::StreamReader::new(body.into_data_stream().map_err(std::io::Error::other))` then `tokio::io::copy`.
202
203### 4c. Errors -> status codes
204
205`topcoat::Result<T>` = `Result<T, topcoat_core::error::Error>`. Any `std::error::Error` converts with `?`. Non-router errors become **500** (body `internal server error`). Handler panics also become 500 (`REG/topcoat-router-0.10.0/src/router.rs`, `handle`).
206
207| Constructor (`topcoat::router::error`) | Status | Ext on `Option`/`Result` |
208|---|---|---|
209| `not_found()` | 404 | `ok_or_not_found()` |
210| `bad_request("msg")` | 400 | `ok_or_bad_request(..)` |
211| `unauthorized()` | 401 | `ok_or_unauthorized()` |
212| `forbidden()` | 403 | `ok_or_forbidden()` |
213| `method_not_allowed(methods)` | 405 (+ `Allow`) | - |
214| `content_too_large()` | 413 | - |
215| `too_many_requests(secs)` | 429 | - |
216| `service_unavailable(secs)` | 503 | - |
217| `redirect(uri)` | 307 | `ok_or_redirect(uri)` |
218| `redirect_permanent(uri)` | 308 | `ok_or_redirect_permanent(uri)` |
219| `see_other(uri)` | 303 (`SeeOther`, usable as `Ok(..)`) | - |
220| `internal_server_error(e)` | 500 | - |
221
222```rust
223use topcoat::{Result, context::Cx, router::{error::{RouterErrorExt, forbidden, see_other, SeeOther}, route}};
224#[route(POST "/repos")]
225async fn create(cx: &Cx) -> Result<SeeOther> { let _ = cx; Ok(see_other("/repos")) }
226fn lookup() -> Result<&'static str> { Ok(None::<&str>.ok_or_not_found()?) } // 404
227fn gate() -> Result<()> { Err(forbidden().into()) } // 403
228```
229
230Error boundary: a layout can wrap `slot` in `error_boundary(fallback: |error| ..., (slot))` and `error.downcast_ref::<NotFoundError>()` for a branded 404 (`REG/topcoat-0.10.0/docs/router.md`).
231
232Headers that must appear on error responses: `topcoat::router::response::response_headers(cx).append(name, value)` in a layer (`REG/topcoat-router-0.10.0/src/response/headers.rs`).
233
234---
235
236## 5. Views, components, layouts, escaping, forms
237
238```rust
239use topcoat::{Result, router::{Slot, layout}, view::{View, component, view, class}};
240
241#[component]
242async fn badge(label: &str, ok: bool) -> Result<impl View> {
243 Ok(view! { <span class=(class!("badge", "badge-ok" if ok))>(label)</span> })
244}
245
246#[layout] // or #[layout("/settings")]
247async fn root(slot: Slot<'_>) -> Result<impl View> {
248 Ok(view! {
249 <!DOCTYPE html>
250 <html>
251 <body>
252 <h1>"Repo"</h1>
253 badge(label: "New", ok: true) // component call, named args
254 (slot) // inner page
255 </body>
256 </html>
257 })
258}
259
260#[component]
261async fn list(items: Vec<String>) -> Result<impl View> {
262 Ok(view! {
263 <ul>
264 for item in items.iter() { <li>(item)</li> }
265 </ul>
266 })
267}
268```
269
270- Syntax: HTML tags with real names; void tags without closing; text in quotes; Rust expressions in parentheses (child and attribute position); `if`, `for`, `match`, `let` in markup; dynamic names `<(tag) (attr)="x">`; attribute names may contain `-`, `:`, `.`. Source: `REG/topcoat-view-macro-0.10.0/docs/view.md`, `.../docs/component.md`.
271- Components take typed params; `Slot` for layouts; `cx: &Cx` optional, any order.
272- **Escaping: yes, auto-escaped.** Text and `(expr)` interpolations escape `<`, `&`, etc., and attribute values are escaped too (tests in `REG/topcoat-view-0.10.0/src/buffer/handle.rs`, `.../src/html/attribute.rs`). Opt-out is `push_*_unescaped` and an `Unescaped<T>` wrapper (`REG/topcoat-view-0.10.0/src/string.rs`); facade path **UNVERIFIED**. Do not wrap commit messages, file names, or diffs in it.
273- Links and forms: `href!(handler_fn)` builds URLs from the handler's registered path; `href!(mod::handler, mod::Param(1))` passes params. `<form method="post" action=(href!(publish))>` from source docs. HTML forms only send GET/POST. Source: `REG/topcoat-router-0.10.0/docs/href.md`.
274- Active nav: `href.is_current(cx)` plus `class!`.
275- A `#[page]` sets status or headers with a `StatusCode` or `HeaderMap` element inside `view!` (per `REG/topcoat-0.10.0/docs/router.md`; not run). For git endpoints use `#[route]` returning `Response` or a tuple.
276
277---
278
279## 6. Cookies
280
281```rust
282use topcoat::{cookie::RouterBuilderCookieExt, router::{Router, RouterBuilderDiscoverExt, module_router}};
283pub fn router() -> Router { module_router!().discover().cookies().build() } // required
284
285use topcoat::{Result, context::Cx, cookie::{Cookie, Cookies, cookies}, router::route};
286#[route(POST "/theme")]
287async fn set_theme(cx: &Cx) -> Result<&'static str> {
288 let jar = cookies(cx); // &CookieJar; `Cookies` trait in scope
289 let _current = jar.get("theme"); // includes pending writes
290 jar.add(Cookie::build(("theme", "dark")).path("/").http_only(true).secure(true).build());
291 jar.remove(Cookie::build(("theme", "")).path("/")); // same Path/Domain as when set
292 Ok("ok")
293}
294```
295
296- `add`/`remove` accept `Into<Cookie>`, so `jar.add(("k", "v"))` works.
297- Attributes: `Path`, `Domain`, `HttpOnly`, `Secure`, `SameSite`, `MaxAge`, set via builder or `cookie!("s" = "v"; Path = "/"; Secure; HttpOnly; SameSite = Lax)` (`topcoat::cookie::cookie`).
298- Defaults/overrides on the jar: `cookies(cx).default_secure(true).default_http_only(true).default_same_site(SameSite::Lax).default_path("/")`. The `override_*` form forces a value. Wrap this in one helper `fn cookies(cx) -> impl Cookies`.
299- `__Host-`: works. `jar.override_prefix_host()` stores `__Host-name`, forces `Secure`, `Path=/`, no `Domain`; reads strip the prefix. `override_prefix_secure()` gives `__Secure-`. Browsers reject `Secure` cookies over plain `http://` except on localhost (**UNVERIFIED** for your deployment).
300- Signed/private: `signed_cookies(cx)`, `private_cookies(cx)` need a `Key` in app context: `.app_context(Key::generate())`. Calling them without a key panics. Persist the key across restarts. `cookies(cx).private(&key)` takes it explicitly.
301- Typed store: `cookie_store::<T, _>(private_cookies(cx), "cart").parse_or_default().update(|t| ..).commit()?`. Nothing is written until `commit()`.
302- Timing: writes must happen before the handler returns. After the jar is sealed (e.g. inside a streaming body) writes **panic**. Reads still work.
303- Source: `REG/topcoat-0.10.0/docs/cookie.md`, `REG/topcoat-cookie-0.10.0/src/{lib,prefix,store}.rs`.
304
305---
306
307## 7. Layers, body limit, timeouts, compression, origin
308
309### 7a. Layers
310
311```rust
312use topcoat::{Result, context::Cx, router::{Body, Next, layer, response::Response}};
313#[layer("/api")] // no path = wraps every request
314async fn timing(cx: &Cx, body: Body, next: Next<'_>) -> Result<Response> {
315 next.run(cx, body).await
316}
317// register: Router::builder().layer(timing)
318```
319
320- Matching is by path prefix; outer = least specific. Same-path layers from `module_router!` are rejected; use explicit `.layer()` for ordering. A path-less layer also sees 404/405 as `Err` from `next.run`. Per-request values: `let cx = cx.with(value)`.
321- Source: `REG/topcoat-router-0.10.0/docs/router.md`, `.../docs/layer.md`.
322
323### 7b. Tower (feature `tower`)
324
325`TowerLayer::new(mw).at("/api")`, `TowerRoute::any("/legacy/{*rest}", svc)`. Example: `TowerLayer::new(tower::timeout::TimeoutLayer::new(Duration::from_secs(5))).at("/api")`. Source: `REG/topcoat-router-0.10.0/docs/tower.md`.
326
327### 7c. Body limit
328
329- Default **2 MiB** (`REG/topcoat-router-0.10.0/src/body_limit.rs`), enforced by `Bytes`, `String`, `Json`, `Form`. Over it: **413**. `Body` is not limited.
330- Override per path:
331
332```rust
333use topcoat::router::{BodyLimit, module_router};
334let router = module_router!()
335 .layer(BodyLimit::max(64 << 20).at("/upload"))
336 .layer(BodyLimit::disable().at("/{owner}/{repo}/git-receive-pack")) // `{param}` in .at(): UNVERIFIED
337 .build();
338```
339
340- `body_limit(cx)` returns the effective limit for custom extractors.
341
342### 7d. Timeouts
343
344- **No request, header, or idle timeout** found in router or serve code (grepped `REG/topcoat-router-0.10.0/src` and `REG/topcoat-0.10.0/src/serve.rs`).
345- Only timeout: graceful shutdown, `RouterService::new(router).shutdown_timeout(d)`, default **30 s**. Request timeout, if wanted: TowerLayer with `TimeoutLayer` (7b).
346
347### 7e. Compression (on by default; matters for git)
348
349- Negotiated from `Accept-Encoding` after all layers (`REG/topcoat-router-0.10.0/src/compression.rs`).
350- Streaming bodies of unknown size are always compressed. Excluded types: gRPC, images, SSE. `application/x-git-*-result` is not excluded, so pack responses would be compressed.
351- Fix (applied): `Router::builder().compression(Compression::off())` on the public router, plus `crates/forge-web/src/compression.rs` (`ResponseCompression`, a tower layer). Topcoat has no per-route or content-type switch, so the policy is in that layer. See architecture.md 5.6.
352
353### 7f. Origin policy (CSRF guard, default on)
354
355- Before layers run: state-changing cross-origin browser requests and cross-origin WebSocket handshakes get **403**. Requests with no Origin or Fetch-Metadata headers pass, as do same-origin and direct navigations.
356- Configure: `.origin_policy(OriginPolicy::new().trust_origins([...]))` (`REG/topcoat-router-0.10.0/src/origin.rs`).
357- Git clients: expected to pass (no browser headers). **UNVERIFIED** per client.
358
359---
360
361## 8. Shared application state (answer to the critical question)
362
363Shared state uses **app context**: values keyed by Rust type, registered once on the builder. Not an extension, and not a field on `Cx`.
364
365```rust
366// src/state.rs
367pub struct AppState { pub repos_root: std::path::PathBuf, pub db: std::sync::Arc<Db> }
368
369// router setup
370pub fn router(state: AppState) -> topcoat::router::Router {
371 topcoat::router::module_router!().discover().app_context(state).build()
372}
373
374// any handler/layout/component with cx
375use topcoat::context::{Cx, app_context, try_app_context};
376pub fn state(cx: &Cx) -> &AppState { app_context::<AppState>(cx) } // panics if missing
377pub fn maybe_state(cx: &Cx) -> Option<&AppState> { try_app_context(cx) }
378```
379
380- Bounds: `T: Any + Send + Sync + 'static`. Duplicate type panics at registration.
381- Use `Arc` inside for mutable resources (pools, locks).
382- Per-request values: `request_context::<T>(cx)` / `try_request_context`, added by a layer with `cx.with(value)`.
383- Background work: `let cx = cx.clone()` and move it into `tokio::spawn`. A cloned `Cx` still reads app context, but cannot set response headers.
384- Source: `REG/topcoat-0.10.0/docs/context.md`, `REG/topcoat-router-0.10.0/src/builder.rs` (`app_context`), `REG/topcoat-core-0.10.0/src/context/app_context.rs`.
385
386---
387
388## 9. Serving
389
390```rust
391topcoat::start(service: impl Into<RouterService>) -> io::Result<()> // HOST (127.0.0.1) / PORT (3000)
392topcoat::serve(listener: impl Listener, service) -> io::Result<()> // Ctrl+C / SIGTERM
393topcoat::serve_until(listener, service, signal: impl Future<Output = ()>) -> io::Result<()>
394```
395
396Source: `REG/topcoat-0.10.0/src/serve.rs`. `Listener` is implemented for `tokio::net::TcpListener` and `tokio::net::UnixListener` (`REG/topcoat-router-0.10.0/src/listener.rs`). `Router` converts to `RouterService` via `From`.
397
398Two routers (public TCP, internal Unix socket) with one shared shutdown:
399
400```rust
401use tokio::net::{TcpListener, UnixListener};
402use topcoat::router::RouterService;
403
404#[tokio::main]
405async fn main() -> std::io::Result<()> {
406 let _ = std::fs::remove_file("/run/forge-internal.sock"); // stale socket is not removed for you
407 let tcp = TcpListener::bind("0.0.0.0:8080").await?;
408 let unix = UnixListener::bind("/run/forge-internal.sock")?;
409 let (stop, _) = tokio::sync::watch::channel(());
410 let (mut a, mut b) = (stop.subscribe(), stop.subscribe());
411 tokio::try_join!(
412 topcoat::serve_until(tcp, RouterService::new(public::router(state())), async move { let _ = a.changed().await; }),
413 topcoat::serve_until(unix, RouterService::new(internal::router(state())), async move { let _ = b.changed().await; }),
414 async { tokio::signal::ctrl_c().await?; stop.send_replace(()); Ok::<(), std::io::Error>(()) },
415 )?;
416 Ok(())
417}
418```
419
420- `serve` and `start` each install their own signal handlers, so use `serve_until` for several listeners. Unix listener: `remote_addr` and `client_ip` return `None`. Placeholders (`public::router`, `state()`) are not compiled.
421
422---
423
424## 10. Assets and CSS
425
426```rust
427use topcoat::{Result, asset::{Asset, asset}, router::{Slot, layout}, view::{View, view}};
428const APP_CSS: Asset = asset!("assets/app.css"); // relative to CARGO_MANIFEST_DIR; "./x" is relative to the source file
429#[layout]
430async fn root(slot: Slot<'_>) -> Result<impl View> {
431 Ok(view! {
432 <!DOCTYPE html>
433 <html><head><link rel="stylesheet" href=(APP_CSS)></head><body>(slot)</body></html>
434 })
435}
436```
437
438- Renders as `/_topcoat/assets/app-<hash>.css` (cache-safe). Build the bundle with `topcoat asset bundle` (or `topcoat dev`); output is `<target>/<profile>/assets`.
439- Load before `.build()`: `module_router!().discover().assets(AssetBundle::load().unwrap()).build()`; use `AssetBundle::load_dir("dist/assets")` for a custom directory. Rendering a missing asset panics.
440- Tailwind (feature `tailwind`): `build.rs` calls `topcoat::tailwind::BuildConfig::new().render().unwrap()`, then `tailwind::stylesheet!()` (`REG/topcoat-0.10.0/docs/tailwind.md`). Fixed-URL files: feature `fs`, `public_dir("./public")`.
441
442---
443
444## 11. Testing without a socket
445
446```rust
447use topcoat::router::{Body, Router, request::Request, to_bytes};
448
449#[tokio::test]
450async fn info_refs_ok() {
451 let router: Router = my_app::router(test_state());
452 let req = Request::builder().method("GET")
453 .uri("/alice/repo/info/refs?service=git-upload-pack")
454 .body(Body::empty()).unwrap();
455 let res = router.handle(req).await; // http::Response<Body>
456 assert_eq!(res.status(), 200);
457 let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap();
458 assert!(!bytes.is_empty());
459}
460```
461
462- `Router::handle(&self, Request) -> Response`. Panics become 500. Needs no `serve` feature.
463- `handle_with(request, values)` injects request-context values. `to_bytes` is exported from `topcoat::router`.
464
465---
466
467## 12. Differences from 0.9
468
469- `module_param!` is new in 0.10 (CHANGELOG: "Added: module_param! macro", PR #460). Grep found no `module_param` in `REG/topcoat-router-macro-0.9.0/src`.
470- 0.10 changelog recommends the module router as the default example.
471- Same names exist in 0.9 (existence checked only): `serve`, `serve_until`, `start`, `Router::handle`, `app_context`, `request_context`, `cookies`, `signed_cookies`, `cookie_store`.
472- Not compared: `Cookies` trait, `Compression`, `OriginPolicy`, `TrustedProxies`, `session`. Use 0.10 source. Other 0.10 changelog items (CLI, runtime) are not relevant here.
473
474---
475
476## 13. Forge notes
477
4781. Smart HTTP routes: `GET /{o}/{r}/info/refs?service=..` (query struct), `POST /{o}/{r}/git-upload-pack`, `POST /{o}/{r}/git-receive-pack`. Use explicit `#[route]` paths with `path_param!`.
4792. Pack bodies: take `Body` (streaming). The 2 MiB default would reject pushes otherwise. Use `BodyLimit::disable()` or a large `max` on receive-pack.
4803. Compression off on pack routes (7e). Serve pack output via `Body` from child stdout (4b). No request timeout by default (7d).
4814. Repos root, config, and DB pool go in one `AppState` (section 8).
4825. Web sessions: `private_cookies` with a persisted `Key`, `override_prefix_host()`, `HttpOnly`, `SameSite=Lax`.
4836. Escape all user text (commit messages, paths). Never `Unescaped` for user data.
4847. Internal API on a Unix socket via `serve_until` (section 9). `client_ip` is None there.
485
486---
487
488## 14. UNVERIFIED
489
490- Facade path for `Unescaped<T>`.
491- Per-route compression disable (only `Compression::off()` on the builder is confirmed).
492- `BodyLimit::at` accepting `{param}` segments.
493- `ForwardedHeader` / `TrustedProxies` variant names.
494- Multipart field API. `session` API.
495- Whether the router kills child processes on client disconnect (not tested).
496- Whether a 401 from `unauthorized()` sends `WWW-Authenticate`.
497- Exact Origin/Fetch-Metadata headers sent by git clients.
498- `HeaderMap` inside `view!` for `#[page]` headers (documented, not run).
499- Sections 8, 9, 11 snippets: derived from signatures, not compiled or run.