diff --git a/Cargo.lock b/Cargo.lock index b5691f7..387f04f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -520,9 +520,9 @@ dependencies = [ [[package]] name = "compio" -version = "0.17.0" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8a7cc183295c36483f1c9616f43c4ac1a9030ce6d9321d6cebb4c4bb21164c4" +checksum = "9b84ee96a86948d04388f3a0b8c36b9f0a6b40b3528ac0d65737e53632fb37fe" dependencies = [ "compio-buf", "compio-driver", @@ -539,9 +539,9 @@ dependencies = [ [[package]] name = "compio-buf" -version = "0.7.2" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ebb4036bf394915196c09362e4fd5581ee8bf0f3302ab598bff9d646aea2061" +checksum = "a00d719dbd8c602ab0d25d219cbc6b517008858de7a8d6c51b4dc95aefff4dce" dependencies = [ "arrayvec", "bytes", @@ -550,9 +550,9 @@ dependencies = [ [[package]] name = "compio-driver" -version = "0.10.0" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff5c12800e82a01d12046ccc29b014e1cbbb2fbe38c52534e0d40d4fc58881d5" +checksum = "74d42d98dc890ee4db00c1e68a723391711aab6d67085880d716b72830f7c715" dependencies = [ "cfg-if", "cfg_aliases", @@ -566,17 +566,21 @@ dependencies = [ "libc", "once_cell", "paste", + "pin-project-lite", "polling", "slab", - "socket2 0.6.1", + "smallvec", + "socket2 0.6.3", + "synchrony", + "thin-cell", "windows-sys 0.61.2", ] [[package]] name = "compio-fs" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c568022f90c2e2e8ea7ff4c4e8fde500753b5b9b6b6d870e25b5e656f9ea2892" +checksum = "65ee36e1acf2cec4835efe9a986c012b2462c5ef53580e4ee84ae6d5a3d8e3b3" dependencies = [ "cfg-if", "cfg_aliases", @@ -586,19 +590,21 @@ dependencies = [ "compio-runtime", "libc", "os_pipe", + "pin-project-lite", "widestring", "windows-sys 0.61.2", ] [[package]] name = "compio-io" -version = "0.8.4" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1e64c6d723589492a4f5041394301e9903466a606f6d9bcc11e406f9f07e9ec" +checksum = "637522f28a64fd5f7dcceaa4ddef13fa8d8020025e8c993f7a069e237835580e" dependencies = [ "compio-buf", "futures-util", "paste", + "synchrony", ] [[package]] @@ -624,9 +630,9 @@ dependencies = [ [[package]] name = "compio-net" -version = "0.10.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bffab78b8a876111ca76450912ca6a5a164b0dd93973e342c5f438a6f478c735" +checksum = "becd7d40522c885113752a3640cba9f9d347f205b646bb3f8ff3967173a228f2" dependencies = [ "cfg-if", "compio-buf", @@ -636,16 +642,16 @@ dependencies = [ "either", "libc", "once_cell", - "socket2 0.6.1", + "socket2 0.6.3", "widestring", "windows-sys 0.61.2", ] [[package]] name = "compio-quic" -version = "0.6.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e101b05fe8608ce6fb2882ac331e211f2b0318449ae27c576c7456b4f1ec4e" +checksum = "ad9efdad81b920108b9de57148e1b9d73dc408b6d06a59ee64836dde651cf026" dependencies = [ "cfg_aliases", "compio-buf", @@ -659,15 +665,16 @@ dependencies = [ "quinn-proto", "rustc-hash", "rustls", + "synchrony", "thiserror 2.0.17", "windows-sys 0.61.2", ] [[package]] name = "compio-runtime" -version = "0.10.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fd890a129a8086af857bbe18401689c130aa6ccfc7f3c029a7800f7256af3e" +checksum = "d6c1c71f011bdd9c8f30e97d877b606505ee6d241c7782cfaed172f66acbd9cd" dependencies = [ "async-task", "cfg-if", @@ -682,15 +689,15 @@ dependencies = [ "pin-project-lite", "scoped-tls", "slab", - "socket2 0.6.1", + "socket2 0.6.3", "windows-sys 0.61.2", ] [[package]] name = "compio-tls" -version = "0.8.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84cd9ca48815f384f1a30400848beebcd8c7ead2f57bfe28ebc5560babea88ec" +checksum = "3a7056da226af42cda4c83b00a021cce3e1ee5f4cffc8a0ff8801381e618cf1c" dependencies = [ "compio-buf", "compio-io", @@ -701,9 +708,9 @@ dependencies = [ [[package]] name = "compio-ws" -version = "0.2.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf7281a15e8f638697415f9838030e41a92c8a8954ddccfc46556a413c16dd9a" +checksum = "99d45f47c6e64babcaa6b8df1dffced56012e60e58401255e679f428ddbe9fb6" dependencies = [ "compio-buf", "compio-io", @@ -859,9 +866,9 @@ dependencies = [ [[package]] name = "cyper-core" -version = "0.7.1" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4b86aa741e422dab7f730aa1ec5ab6bc26569e577fe2b8fe0ebf6d779b2325" +checksum = "f606aa5ddfee60d1cd86e350a1f7bf45ad5c4dc060d80edf84eef38a9a6b2efc" dependencies = [ "compio", "futures-util", @@ -1084,6 +1091,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "envy" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f47e0157f2cb54f5ae1bd371b30a2ae4311e1c028f575cd4e81de7353215965" +dependencies = [ + "serde", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1176,9 +1192,9 @@ dependencies = [ [[package]] name = "flume" -version = "0.11.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" dependencies = [ "futures-core", "futures-sink", @@ -1355,6 +1371,21 @@ dependencies = [ "slab", ] +[[package]] +name = "generator" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + [[package]] name = "generic-array" version = "0.14.9" @@ -1659,7 +1690,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.1", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -1959,9 +1990,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.180" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libloading" @@ -2026,12 +2057,35 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "pin-utils", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matchit" version = "0.9.1" @@ -2078,9 +2132,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", @@ -2658,7 +2712,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.1", + "socket2 0.6.3", "thiserror 2.0.17", "tokio", "tracing", @@ -2697,7 +2751,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.1", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] @@ -3364,12 +3418,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3510,6 +3564,16 @@ dependencies = [ "futures-core", ] +[[package]] +name = "synchrony" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c174d82fd56da8214ec095cfe4568e59e5ccb49d060e70c2f98e3ba352b23e45" +dependencies = [ + "futures-util", + "loom", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -3534,11 +3598,9 @@ dependencies = [ "brotli", "bytes", "compio", - "compio-buf", - "compio-io", - "compio-ws", "cookie", "cyper-core", + "envy", "flate2", "futures-util", "h3", @@ -3681,6 +3743,7 @@ dependencies = [ "serde_json", "sha1", "smallvec", + "subtle", "tako-core", "tako-extractors", "tokio", @@ -3772,9 +3835,6 @@ dependencies = [ "base64", "bytes", "compio", - "compio-buf", - "compio-io", - "compio-ws", "futures-util", "h3", "h3-quinn", @@ -3816,6 +3876,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thin-cell" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4164c6c316ba9733b0ab021e7f9852c788a4b991b49c25820f1be48e1d41345b" + [[package]] name = "thiserror" version = "1.0.69" @@ -3943,9 +4009,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.49.0" +version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ "bytes", "libc", @@ -3953,16 +4019,16 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.1", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -3992,14 +4058,14 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.27.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "489a59b6730eda1b0171fcfda8b121f4bee2b35cba8645ca35c5f7ba3eb736c1" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", "tokio", - "tungstenite 0.27.0", + "tungstenite 0.29.0", ] [[package]] @@ -4173,10 +4239,14 @@ version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex-automata", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log", ] @@ -4189,9 +4259,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.27.0" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", @@ -4199,6 +4269,8 @@ dependencies = [ "httparse", "log", "rand 0.9.2", + "rustls", + "rustls-pki-types", "sha1", "thiserror 2.0.17", "utf-8", @@ -4206,9 +4278,9 @@ dependencies = [ [[package]] name = "tungstenite" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", @@ -4216,11 +4288,8 @@ dependencies = [ "httparse", "log", "rand 0.9.2", - "rustls", - "rustls-pki-types", "sha1", "thiserror 2.0.17", - "utf-8", ] [[package]] @@ -4550,6 +4619,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.45.0" diff --git a/Cargo.toml b/Cargo.toml index dfeab7e..0bbed34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,6 +74,7 @@ async-trait = "0.1.88" base64 = "0.22.1" bytes = "1.10.1" cookie = { version = "0.18.1", features = ["private", "signed"] } +envy = "0.4" futures-util = "0.3.31" http = "1.3.1" http-body = "1.0.1" @@ -94,9 +95,9 @@ serde_json = "1.0.140" serde_urlencoded = "0.7" sha1 = "0.10.6" smallvec = "1" -tokio = { version = "1.48.0", features = ["full"] } +tokio = { version = "1.52.1", features = ["full"] } tokio-stream = "0.1.17" -tokio-tungstenite = "0.27.0" +tokio-tungstenite = "0.29.0" tokio-util = { version = "0.7.15", features = ["compat"] } tracing = "0.1.41" url = "2.5.4" @@ -105,11 +106,8 @@ urlencoding = "2.1.3" # Optional / feature-gated ahash = { version = "0.8.12", features = ["serde"] } brotli = "8.0.1" -compio = { version = "0.17.0", features = ["macros", "rustls"] } -compio-buf = "0.7.1" -compio-io = "0.8.4" -compio-ws = "0.2.0" -cyper-core = "0.7.0" +compio = { version = "0.18.0", features = ["macros", "rustls", "time", "fs", "net"] } +cyper-core = "0.8.0" flate2 = "1.1.2" h3 = "0.0.8" h3-quinn = "0.0.10" @@ -125,6 +123,7 @@ rustls-pemfile = "2.2.0" send_wrapper = "0.6" simd-json = "0.15.1" sonic-rs = "0.5.6" +subtle = "2.6.1" tikv-jemallocator = "0.6.0" tokio-rustls = "0.26.2" uuid = { version = "1.17.0", features = ["v4"] } diff --git a/README.md b/README.md index fdea1bc..cf17e5d 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,25 @@ [![Crates.io](https://img.shields.io/crates/v/tako-rs?style=flat-square)](https://crates.io/crates/tako-rs) ![License](https://img.shields.io/crates/l/tako-rs?style=flat-square) +> **โš ๏ธ Tako 2.0 is in progress on `main`.** The default branch carries breaking +> changes (new `Server::builder`, `Router::with_state`, `nest`/`scope`, `405 + +> Allow`, RFC 7807 `problem+json`, runtime-agnostic `ServerHandle`, โ€ฆ) that are +> **not yet released**. For production, install the published 1.x line from +> crates.io: +> +> ```toml +> [dependencies] +> tako-rs = "1" +> ``` +> +> Track 2.0 work in [`V2_ROADMAP.md`](./V2_ROADMAP.md). Expect API churn until +> the 2.0 alpha is tagged. + # ๐Ÿ™ Tako โ€” Multi-Transport Rust Framework for Modern Network Services > **Tako** (*"octopus"* in Japanese) is a pragmatic, ergonomic and extensible Rust framework for services that go beyond plain HTTP. > Build one cohesive application across HTTP/1.1, HTTP/2, HTTP/3, WebSocket, SSE, gRPC, TCP, UDP, Unix sockets, and WebTransport with a single routing, middleware, and observability model. -> **Blog posts:** -> - [Tako: A Lightweight Async Web Framework on Tokio and Hyper](https://rust-dd.com/post/tako-a-lightweight-async-web-framework-on-tokio-and-hyper) -> - [Tako v.0.5.0 road to v.1.0.0](https://rust-dd.com/post/tako-v-0-5-0-road-to-v-1-0-0) -> - [Tako v0.5.0 โ†’ v0.7.1-2: from "nice router" to "mini platform"](https://rust-dd.com/post/tako-v0-5-0-to-v0-7-1-2-from-nice-router-to-mini-platform) - ## Why Tako Tako is built for teams that want fewer moving parts in production: diff --git a/V2_ROADMAP.md b/V2_ROADMAP.md new file mode 100644 index 0000000..518cef4 --- /dev/null +++ b/V2_ROADMAP.md @@ -0,0 +1,460 @@ +# Tako v2 Roadmap + +A consolidated audit of the Tako workspace (~20k LOC, 9 crates, 38 examples, current `1.1.2`) and the concrete plan to take it to a credible 2.0. + +The document is split into: + +1. **Security patches** โ€” must ship as a `1.x` release before v2 work, independent of the redesign. +2. **Core API redesign** โ€” the breaking changes that justify a major bump. +3. **Server / transport** โ€” production-readiness gaps and the new builder API. +4. **Plugins / middleware** โ€” pluggable backends, missing primitives, fixes to existing plugins. +5. **Extractors / streams** โ€” spec compliance, parity with axum, finishing half-done modules. +6. **Project hygiene** โ€” tests, CI, docs, dependencies. +7. **Phased timeline.** + +File and line references throughout point to the `feat/thread-per-code` branch as it stands today. + +--- + +## ~~1. Security patches (ship as `1.2.0` before v2)~~ โ€” Shipped in 1.2.0 + +> **Status:** All items in this section were addressed in the `1.2.0` security release. Retained here as a historical record of the audit. + +### ~~1.1 Three independent insecure ID generators~~ + +| Location | What | +|---|---| +| ~~`tako-plugins/src/middleware/session.rs:168`~~ | ~~`generate_session_id` โ€” deterministic LCG seeded from `SystemTime::now().nanos`. UUID-shaped, but **predictable**. Enables session-fixation.~~ | +| ~~`tako-plugins/src/middleware/csrf.rs:80`~~ | ~~`generate_csrf_token` โ€” same LCG. Defeats the entire point of a CSRF token.~~ | +| ~~`tako-plugins/src/middleware/request_id.rs:54`~~ | ~~`generate_request_id` โ€” same LCG. Trace IDs leak collisions.~~ | + +~~**Fix:** replace all three with `uuid::Uuid::new_v4()` (already in workspace deps) or `getrandom::getrandom`.~~ + +### ~~1.2 Timing-oracle string compares in auth middleware~~ + +- ~~`api_key_auth.rs` and `bearer_auth.rs` compare credentials with `==`. Use `subtle::ConstantTimeEq`.~~ + +### ~~1.3 CORS credentials/wildcard footgun~~ + +- ~~`tako-plugins/src/plugins/cors.rs:300` reflects `*` when the configured origin set is empty, while `Access-Control-Allow-Credentials: true` is permitted alongside it. Browsers reject this combination, but the framework should refuse the configuration at build time.~~ +- ~~`Access-Control-Allow-Headers: *` written literally at `cors.rs:339` regardless of `allow_credentials`.~~ + +### ~~1.4 HTTP/2 RST flood (CVE-2023-44487 class)~~ + +- ~~`tako-server/src/server_tls.rs:227` builds the H2 server with defaults: no `max_concurrent_streams`, no `max_header_list_size`, no `max_send_buf_size`. Add explicit caps and expose them in the public API.~~ + +### ~~1.5 HTTP/3 buffer-the-whole-body and 0-RTT replay~~ + +- ~~`tako-server/src/server_h3.rs:289` collects the request body into a `Vec` before dispatch. Streaming uploads over H3 are impossible.~~ +- ~~`server_h3.rs:114` sets `max_early_data_size = u32::MAX` without any replay-protection wiring on the request path. Either remove this line or build the early-data extractor with explicit guidance.~~ +- ~~`server_h3.rs:328` only handles `frame.data_ref()`; trailers are silently dropped.~~ + +### ~~1.6 PEM key formats~~ + +- ~~`load_key` is duplicated in `server_tls.rs:318`, `server_h3.rs:348`, `server_tls_compio.rs:296` and only accepts `pkcs8_private_keys`. RSA / SEC1 / EC keys silently fail to load. Accept all three formats and consolidate the function (see ยง 3).~~ + +### ~~1.7 Metrics cardinality~~ + +- ~~`tako-plugins/src/plugins/metrics.rs:230,252` use the raw URI path as a label. `/users/:id` produces a new series per ID. Switch to the matched route template (requires `MatchedPath`, see ยง 5).~~ +- ~~`remote_addr` label on the connections counter is also unbounded.~~ + +### ~~1.8 Other~~ + +- ~~`idempotency.rs:91` defaults TTL to 30s while the docstring at `:69` advertises 24h. Pick one.~~ +- ~~`idempotency.rs:380-383` ignores the configured `inflight_wait_timeout_ms` on the compio path.~~ +- ~~`body_limit.rs:163-172` and `upload_progress.rs:182-229` both call `body.collect()`, defeating streaming. Replace with a `Limited` adapter.~~ +- ~~`compression.rs` does not write `Vary: Accept-Encoding` and does not parse `q=0`.~~ + +--- + +## 2. Core API โ€” Router, Handler, Macros (v2 breaking) โ€” Done in dev branch (unreleased) + +> **Status:** every subsection here is implemented on the dev branch and covered by tests; nothing is shipped to crates.io yet. The historical text is preserved with the original problem statement; each subsection is annotated with what landed. + +### ~~2.1 Per-router typed state~~ โ€” Done (runtime TypeMap variant) + +~~`Router::state(value)` writes into `GLOBAL_STATE` (`tako-core/src/state.rs:44, 70`). One value per `TypeId` *per process* โ€” two `String` configs are impossible without newtype wrappers.~~ + +~~**v2 design:**~~ + +~~```rust~~ +~~let router = Router::::new()~~ + ~~.with_state(AppState { db, cfg })~~ + ~~.get("/users/{id}", users_handler)~~ + ~~.post("/users", create_user);~~ +~~```~~ + +~~`Router` is generic over the state type; `with_state` binds it; handlers receive `State` via `FromRequestParts`. Demote the global registry to an opt-in `TypeMap` helper for advanced cases.~~ + +**Implemented as:** runtime per-router TypeMap (`tako_core::router_state::RouterState`), not a compile-time `Router` generic. `Router::with_state(value)` populates the instance-local store; `State` extractor reads from the request-scoped `Arc` first and falls back to `GLOBAL_STATE`. Two routers in the same process can hold distinct `T` values without newtype wrappers. Hot path is `AtomicBool::Acquire` fast-checked โ€” zero overhead when `with_state` was never called. The compile-time generic was deferred (would touch every consumer crate); the API ergonomics goal is met. + +### ~~2.2 `nest`, `scope`, route groups~~ โ€” Done + +~~Currently only `Router::merge` (`router.rs:871`), which mutates the shared `Arc` (`:884`). Merging the same source twice double-stacks middleware. Replace with:~~ + +~~```rust~~ +~~let api = Router::new()~~ + ~~.nest("/v1", v1_router)~~ + ~~.nest("/v2", v2_router)~~ + ~~.scope("/admin", |r| r.layer(admin_auth).get("/", dashboard));~~ +~~```~~ + +`Router::nest(prefix, child)` builds new `Arc` instances via `Route::cloned_with_path` so re-nesting can never double-stack the child's middleware. `Router::scope(prefix, |r| ...)` carries a `pending_prefix` field consumed by every `route() / get() / post() / โ€ฆ` call inside the closure. Tests cover both happy paths and the regression for the merge double-stack bug. + +### ~~2.3 `Result` handler returns~~ โ€” Done + +~~Today only `Responder` is supported. Typed errors must hand-implement it. Introduce `IntoResponse` (alias of `Responder` is fine) and accept `Result` from handlers natively.~~ + +~~Add missing `Responder` impls: `Bytes`, `Vec`, `Cow`, `serde_json::Value`, `(StatusCode, HeaderMap, Body)`, `Json` shorthand.~~ + +`IntoResponse` is a re-export of `Responder` (no new trait subtype). The blanket `Result` impl uses a `ResponderError` marker so it doesn't conflict with the existing `anyhow::Result` impl. New `Responder` impls: `Bytes`, `Vec`, `Cow<'static, str>`, `serde_json::Value`, `(StatusCode, HeaderMap, TakoBody)`, `(StatusCode, HeaderMap)`, `HeaderMap`, `StatusCode`. `Json` already implemented `Responder` previously. + +### ~~2.4 405 with `Allow` header~~ โ€” Done + +~~`router.rs:489-519` returns 404 for the wrong method on a matching path. v2 should return 405 with the `Allow` header populated. Expose method introspection on the matcher.~~ + +`Router::collect_allowed_methods(path)` walks the `MethodMap` (cold path only; iterates the 9 standard methods). On method mismatch the response is `405 Method Not Allowed` with a comma-separated `Allow` header. Hot path unchanged. One existing test was updated from `404` โ†’ `405 + Allow`. + +### ~~2.5 RFC 7807 `problem+json` default error responder~~ โ€” Done + +~~The `error_handler` hook only fires on 5xx (`router.rs:527`). Extend to 4xx and ship a default `application/problem+json` formatter.~~ + +New `tako::problem` module: `Problem` struct with `Responder` impl that emits `application/problem+json`, plus `default_problem_responder` helper. `Router::client_error_handler(handler)` sister to `error_handler`, fires only on 4xx. `Router::use_problem_json()` convenience installs `default_problem_responder` for both 4xx and 5xx. + +### ~~2.6 Method shorthands on `Router`~~ โ€” Done + +~~Today only the macro emits typed routes. Add:~~ + +~~```rust~~ +~~router.get(path, h);~~ +~~router.post(path, h);~~ +~~router.delete(path, h);~~ +~~router.put(path, h);~~ +~~router.patch(path, h);~~ +~~```~~ + +`Router::get / post / put / delete / patch / head / options` shorthand methods, each `#[inline]`-forwarded to `Router::route`. Pure additive. + +### ~~2.7 Drop dead code on `Route`~~ โ€” Done + +~~`Route::h09 / h10 / h11 / h2` (`route.rs:209-227`) take `&mut self`, but `Router::route` only hands back `Arc`. The methods are unreachable, and `enforce_protocol_guard` is dead. Replace with `route.version(http::Version)` using interior mutability.~~ + +`Route::http_protocol` is now `OnceLock` (mirrors the existing `timeout` / `simd_json_mode` pattern). New fluent `Route::version(http::Version) -> &Self`; `h09 / h10 / h11 / h2` are `&self` shorthands. The copy-paste `#[doc(alias = "tsr")]` on `h2` is removed. + +### ~~2.8 Macro cleanup~~ โ€” Done + +~~`#[tako::route]` always emits a `*Params` struct, even for static paths (`tako-macros/src/lib.rs:209-243`). The struct name is guessed from the function name (`PascalCase + "Params"`) โ€” rename-unsafe. Path syntax `{id: u64}` diverges from matchit/axum `{id}`.~~ + +~~**v2 macro:**~~ +- ~~emit `*Params` only when path placeholders exist.~~ +- ~~accept plain `{id}` and read the type from the handler signature.~~ +- ~~align with `matchit` capture syntax.~~ + +`parse_path` accepts both `{id: u64}` (typed โ†’ `*Params` field) and `{id}` (untyped โ†’ matchit pass-through, no field). The `*Params` struct is only emitted when at least one typed placeholder exists. Backward-compat: `name = "..."` on a static path emits a unit-marker struct so `Name::METHOD / Name::PATH` constants stay reachable. + +### 2.9 Other + +- ~~`Config::from_env` (`config.rs:37`) collects `HashMap` and serializes it through `serde_json::Value`, so non-string fields fail. Replace with `envy` or hand-rolled per-field parsing.~~ โ€” **Done** via `envy`. Added `Config::from_env_prefixed("MYAPP_")` helper for multiple configs in one process. +- `tako-core-local` (the `!Send` router) is missing plugins, signals, OpenAPI, timeout, fallback, TSR, error_handler, and `mount_all`. Either reach parity, or document the trade-off explicitly and label it as a niche tool. โ€” **Pending.** No work done; documenting the gap is still cheaper than parity. +- ~~`Route::h2` has `#[doc(alias = "tsr")]` (`route.rs:224`) โ€” copy-paste bug.~~ โ€” **Done** as part of 2.7. +- ~~`mount_all` is `linkme`-driven (`router.rs:239`) with unspecified ordering across crates and no per-prefix mount. v2: explicit `mount_all_into("/api", &mut router)`.~~ โ€” **Done.** New `Router::mount_all_into(prefix)` consumes the same `TAKO_ROUTES` slice but prefixes every registration. Cross-crate ordering remains the linker's choice (documented). +- ~~`Router::merge` and `Route::middleware` rebuild the middleware Vec on every push (`router.rs:631-633`, `route.rs:129-131`) โ€” racy under concurrent registration.~~ โ€” **Done.** `Router::middleware` and `Route::middleware` switched to `ArcSwap::rcu` so concurrent pushers never lose entries (CAS retry). Hot path (`load_full` in dispatch) unchanged. + +--- + +## 3. Server / transport (v2 breaking) + +### 3.1 Replace the seven `serve_*` functions with a builder + +```rust +let server = tako::Server::builder() + .listener(TcpListener::bind(addr).await?) + .http(HttpConfig::default() + .header_read_timeout(Duration::from_secs(30)) + .keep_alive_timeout(Duration::from_secs(60)) + .max_concurrent_streams(100) + .max_frame_size(16 * 1024) + .max_body_size(8 * 1024 * 1024)) + .tls(TlsConfig::Pem { cert, key }) // or ::Resolver(Arc) + .h3(H3Config::default()) + .limits(Limits::default() + .max_connections(50_000) + .drain_timeout(Duration::from_secs(60))) + .mode(Mode::PerCore { workers: num_cpus::get(), pin_cpus: true }) + .build(); + +let handle = server.spawn(router); +handle.shutdown(Duration::from_secs(30)).await?; +``` + +This subsumes `serve`, `serve_tls`, `serve_h3`, `serve_tcp`, `serve_udp`, `serve_unix`, `serve_proxy_protocol`, all `*_with_shutdown` variants, and the separate `tako-server-pt` crate. + +### 3.2 Production-readiness gaps to close + +- **`max_connections` semaphore on every transport.** `server.rs:122`, `server_tls.rs:165`, `server_unix.rs:218`, `proxy_protocol.rs:366` all unconditionally `JoinSet::spawn` per accept. +- **HTTP timeouts.** `server.rs:167-170` and `server_tls.rs:245-247` set only `keep_alive(true)` and `pipeline_flush(true)`. Wire `header_read_timeout`, `keep_alive_timeout`, H2 `keep_alive_interval`, `max_concurrent_streams`, `max_frame_size`, `initial_stream_window_size`. +- **Tunable drain timeout.** Hardcoded 30s in seven files (`server.rs:47`, `server_tls.rs:68`, `server_h3.rs:72`, `server_unix.rs:55`, `proxy_protocol.rs:62`, `server_tcp.rs:132`, `server_compio.rs:25`). +- **`Box::leak(Router)`** (`server.rs:82`, `tako-server-pt/src/lib.rs:114, 318`) makes hot-reload impossible. Switch to `Arc` with RCU-style swap. +- **Compio drain race.** `server_compio.rs:163-165` and `server_tls_compio.rs:233-235, 260-262` use `Notify::notify_one` only when `inflight == 1`. A connection finishing between the load and the await waits the full 30s. Use a `WaitGroup` or `notify_waiters` after every decrement. +- **`tako-server-pt::worker_main`** is an infinite `loop { accept }` with no `select!` against shutdown (`lib.rs:132-194`); workers leak on shutdown. +- **PROXY-protocol no read deadline** (`proxy_protocol.rs:368`). Apply `ProxyConfig::read_timeout` before parsing. +- **Listener accept errors are fatal.** `server.rs:118` propagates `?`, `server_h3.rs:158` exits the listen loop on `None` from `endpoint.accept()`. Add EMFILE backoff and supervised restart. + +### 3.3 Extract a `tako-tls` crate + +`load_certs` and `load_key` are duplicated in `server_tls.rs:318-362`, `server_h3.rs:348-367`, `server_tls_compio.rs:296-315`, and `tako-streams/src/webtransport.rs:170-194` reaches across crates into `tako_server::server_h3::load_certs`. Move to a shared `tako-tls` crate exposing: + +```rust +pub enum TlsConfig { + Pem { cert: Vec, key: Vec }, + Der { cert: Vec>, key: PrivateKeyDer<'static> }, + Resolver(Arc), + Acme { directory_url: String, contact: Vec, cache_dir: PathBuf }, +} +``` + +Support PKCS#8, RSA, SEC1, EC. Add SNI multi-cert resolver. Wire mTLS via `WebPkiClientVerifier`. Add hot reload (file-watcher or signal-driven). + +### 3.4 Protocol-completeness items + +- **HTTP/3:** stream the request body, support trailers, support graceful GOAWAY (currently `endpoint.close(0u32.into(), ...)` is hard-close at `server_h3.rs:204`), expose qlog, retry-token, datagrams, congestion-control selection, max bidi/uni streams. +- **h2c (cleartext H2)** for L7-proxy deployments. +- **80โ†’443 auto-redirect helper.** +- **socket activation** (`LISTEN_FDS`). +- **abstract Unix sockets** (`@`-prefixed). +- **vsock** for VM-host bridges. +- **PROXY v2 TLV parsing** (`proxy_protocol.rs:225-309`): AWS VPC endpoint ID (0xEA), TLS info (0x20), authority (0x02), CRC32C. Strip inbound `X-Forwarded-For` before injecting source. Handle `AF_UNIX` family (0x3) โ€” currently silently lands in `_ => UNSPEC` at `:301-308`. +- **Unify `ConnInfo` extension.** `server.rs:139` and `server_tls.rs:196` insert `SocketAddr`; `server_unix.rs:222` inserts `UnixPeerAddr`; H3 inserts something else again. Define one struct: + +```rust +pub struct ConnInfo { + pub peer: PeerAddr, // IP, Unix, vsock, ... + pub local: PeerAddr, + pub transport: Transport, // Tcp, Tls, H3, Unix, ... + pub alpn: Option>, + pub sni: Option, + pub tls_version: Option, + pub proxy_header: Option, +} +``` + +### 3.5 Observability inside the server crate + +The four `signals` emissions (`server.rs:122-191`, `server_tls.rs:165-265`, `server_h3.rs:161-194, 273-318`) are copy-pasted. Move emission into a single per-request middleware so transport files don't duplicate the boilerplate. Wire W3C `traceparent` propagation. + +--- + +## 4. Plugins / middleware (v2) + +### 4.1 Pluggable backends + +Today every store is `scc::HashMap`. Define traits and ship `Memory*` + feature-gated `Redis*` (and optionally `Postgres*`) implementations: + +```rust +trait SessionStore: Send + Sync + 'static { ... } +trait RateLimitStore: Send + Sync + 'static { ... } +trait IdempotencyStore: Send + Sync + 'static { ... } +trait JwksProvider: Send + Sync + 'static { ... } +trait CsrfTokenStore: Send + Sync + 'static { ... } +``` + +### 4.2 Existing plugin fixes + +| Plugin | Fix | +|---|---| +| `session` | Rotate on privilege change; split idle vs absolute timeout; rolling cookie refresh on every request (currently set only when `is_new`, `session.rs:267-292`); revoke-all helper. | +| `rate_limiter` | Per-route / per-user / per-IP composite key; emit `RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset` and `Retry-After` (draft-ietf-httpapi-ratelimit-headers); GCRA option. Currently per-IP only with fallback `0.0.0.0` collapsing all unknown clients into one bucket (`rate_limiter.rs:406-410`). | +| `idempotency` | Reconcile docstring vs default TTL; respect `inflight_wait_timeout_ms` on compio (`idempotency.rs:380-383`); cap stored response size. | +| `jwt_auth` | JWKS rotation, asymmetric keys, configurable `iss` / `aud` / `kid` / leeway, revocation list, optional remote introspection. | +| `csrf` | Bind token to session; origin/referer fallback; relax `SameSite` to a configurable choice. | +| `compression` | Write `Vary: Accept-Encoding`; parse `q=0`; cap inbound decompression (compression-bomb defense); content-type allow-list as configurable enum, not substring match. | +| `cors` | Refuse `Allow-Credentials: true` with reflective wildcard at config build time; regex/suffix origin matching; PNA support. | +| `metrics` | Use `MatchedPath` for the route label; switch to histograms; configurable bucket schedule; drop `remote_addr` label. | +| `body_limit` | Stream-aware limit, no full `body.collect()`. | +| `upload_progress` | Stream-aware, no full buffering; abandonment cleanup on disconnect. | +| `security_headers` | CSP nonce/hash support; COOP / COEP / CORP; Permissions-Policy; remove `X-XSS-Protection: 0`. | +| `request_id` | W3C `traceparent` parsing and emission. | + +### 4.3 Missing middleware to add for v2 + +- `timeout` โ€” per-request deadline. +- `traceparent` propagation (W3C trace context). +- `access_log` โ€” structured access log separate from metrics. +- `problem+json` error responder. +- `circuit_breaker` and outbound `retry` for the client. +- `ip_filter` โ€” allow/deny + CIDR. +- `healthcheck` โ€” readiness/liveness + drain semantics. +- `etag` / conditional GET helper. +- `tenant` โ€” `X-Tenant-ID` extraction with scoped state. +- `hmac_signature` โ€” Stripe/AWS-style request signing. +- `json_schema` โ€” request/response validator. + +--- + +## 5. Extractors / streams (v2) + +### 5.1 Extractors + +- **Finish or remove `zero_copy_extractors`.** `tako-extractors/src/zero_copy_extractors.rs` is three lines (`pub mod` declarations) and the README advertises a `zero-copy-extractors` feature flag. Either build it out or delete the feature. +- **axum parity:** `TypedHeader`, `Extension`, `MatchedPath`, `OriginalUri`, `Host`, `Scheme`, `ConnectInfo`, `ContentLengthLimit`. +- `Path`: support nested types, tuples, `Vec`, `Option`. +- `Query`: repeated keys / arrays / CSV. +- `Multipart`: per-part max size, content-type allow-list, disk-spill threshold, max parts. +- `JwtClaims`: today only base64-decodes (`jwt.rs`). Either rename to `JwtClaimsUnverified` to make the trust model explicit, or perform verification in the extractor with a `JwksProvider` from state. +- Cookies: key-id metadata for rotation; encryption rotation across `cookie_private` / `cookie_signed`. +- Validation integration with `validator` or `garde` as an opt-in feature. + +### 5.2 Streams + +**SSE (`tako-streams/src/sse.rs`)** is currently spec-partial: +- supports only the `data:` field. Add `event:`, `id:`, `retry:` fields and a builder API. +- support `Last-Event-ID` replay (caller-provided closure). +- emit periodic comment frames (`:keepalive\n\n`) for proxy keep-alive. +- send `X-Accel-Buffering: no` to defeat nginx buffering by default. + +**WebSocket (`tako-streams/src/ws.rs`)** is currently a thin upgrade helper: +- echo `Sec-WebSocket-Protocol` (subprotocol negotiation). +- ping/pong with configurable interval and timeout. +- `permessage-deflate` extension. +- `max_frame_size` and `max_message_size` config. +- Origin allowlist. +- upgrade timeout โ€” `ws.rs:164` spawns `tokio::spawn` waiting on `on_upgrade.await` with no deadline; if the client never upgrades the task leaks. +- target Autobahn green. + +**File stream (`file_stream.rs`)** has range support but lacks: +- `multipart/byteranges`. +- ETag, `If-Modified-Since`, `If-None-Match`. +- zero-copy `sendfile` path on Linux. + +**Static (`tako-streams/src/static.rs`)** has a single fallback file but lacks: +- precompressed file preference (`*.br`, `*.gz` next to the original). +- SPA fallback as a rewrite (current single fallback is not the same). +- explicit canonicalize + prefix check for path traversal. +- index resolution priority list. + +**WebTransport (`webtransport.rs:170`)** reaches across crates and **does not perform the CONNECT handshake** โ€” what is exposed today is raw QUIC, which is not WebTransport per the W3C draft. Implement the CONNECT extended handshake or downgrade the docs. + +### 5.3 gRPC + +The current implementation (`tako-core/src/grpc.rs`) is unary only. Add: +- client streaming, server streaming, bidirectional streaming. +- `grpc.reflection.v1` server reflection. +- `grpc.health.v1` health service. +- gRPC-Web bridge. +- `grpc-timeout` deadline propagation into request extensions. +- gRPC-specific interceptor / middleware story (current HTTP middleware semantics don't fit cleanly). + +### 5.4 GraphQL + +- persisted queries. +- complexity / depth / cost limits. +- dataloader integration documented. + +### 5.5 OpenAPI + +`utoipa` and `vespera` coexist (`tako-core/src/openapi/{utoipa,vespera}.rs`). Pick one as primary and demote the other to opt-in, or build a thin discovery layer over both. Today both are exposed through feature flags with overlapping responsibilities. + +### 5.6 Core platform โ€” queue, signals, client + +**Queue (`tako-core/src/queue.rs`)** is in-memory only. DLQ is in-memory too; restart loses jobs. v2 minimum: + +```rust +trait QueueBackend: Send + Sync + 'static { + async fn push(&self, queue: &str, payload: &[u8], opts: PushOptions) -> Result; + async fn reserve(&self, queue: &str) -> Result>; + async fn complete(&self, id: JobId) -> Result<()>; + async fn fail(&self, id: JobId, retry_at: Option) -> Result<()>; + async fn dead_letter(&self, id: JobId) -> Result<()>; +} +``` + +with `MemoryBackend`, `RedisBackend`, optionally `PostgresBackend` (LISTEN/NOTIFY). Add idempotent dedup keys, cron scheduling, observability hooks. + +**Signals (`tako-core/src/signals.rs`)** are process-local and lossy (broadcast drop-on-slow-consumer). v2: filtered subscriptions, optional cluster-scope (Redis pub/sub), and a consistent naming scheme โ€” current ids mix `request.started`, `request.completed`, `route.request.started`. + +**Client (`tako-core/src/client.rs`)** is HTTP/1.1 only with one TCP connection per `TakoClient`. No pool, no retry, no timeout, no cancellation, no tracing propagation. For v2 either rebuild on `hyper-util` legacy client with full pool/H2/H3/timeout/retry semantics, or re-export `reqwest` behind the `client` feature and keep the trivial helper as a learning example. + +--- + +## 6. Project hygiene + +### 6.1 Tests + +- **0 unit tests inside any `src/` file** across all crates. +- All tests live in `tako-rs/tests/` โ€” 9 integration files, ~125 tests: + - `middleware.rs` (31), `router.rs` (19), `extractors.rs` (17), `queue.rs` (13), `udp_tcp_progress.rs` (12), `typed_routes.rs` (11), `responder.rs` (10), `sse_redirect_config.rs` (10), `mount_all.rs` (2). +- No property tests, no fuzz, no Miri runs. +- No criterion benches (only the wrk-driven `examples/bench-*`). + +**v2 target:** +- 70% line coverage on `tako-core`, `tako-extractors`, `tako-plugins`. +- Fuzz harnesses on every parser: PROXY v1 and v2, multipart, JSON, URL-encoded form, JWT, cookies. +- Miri pass on `tako-core` and `tako-extractors`. +- Autobahn WebSocket suite green. +- Criterion benches for the hot path with regression gating. + +### 6.2 CI + +The current `.github/workflows/ci.yml` runs only: + +```yaml +- cargo build --release +- cargo build --release --all-features +- cargo build --release --examples +``` + +There is **no `cargo test`, no clippy, no fmt-check, no doctest, no MSRV, no Miri, no sanitizer, no coverage, no platform matrix**. Additionally, `cargo build --examples` from the workspace root effectively builds nothing because all 38 example crates are in the workspace `exclude:` list โ€” example breakage is not detected. + +**v2 minimum CI:** + +```yaml +matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + toolchain: [stable, "1.87.0", beta] +steps: + - cargo fmt --all -- --check + - cargo clippy --all-features --workspace -- -D warnings + - cargo test --all-features --workspace + - cargo doc --no-deps --all-features # with -D rustdoc::broken_intra_doc_links + - cargo +nightly miri test -p tako-core -p tako-extractors + - cargo deny check + - cargo llvm-cov --workspace --all-features --lcov --output-path lcov.info + - examples build job that iterates over examples/*/Cargo.toml and builds each + - criterion benchmark gate (on PRs touching tako-core / tako-server) +``` + +### 6.3 Documentation + +- No mdbook, no migration guide, no API stability statement. +- Rustdoc is uneven across crates; many extractor and middleware modules have generous docstrings while server transport files are sparse. +- `lib.rs` of `tako-rs` is the public re-export crate but the navigation through feature flags is hard. + +**v2 docs deliverables:** +- `MIGRATION_1_TO_2.md` covering every breaking change in ยงยง 2-5. +- mdbook with: getting-started, transports overview, routing, state, middleware, extractors, streams, queue, signals, observability, deployment. +- API stability policy (which re-exports are stable, what semver guarantees we make on the global `signals` ids, etc.). + +### 6.4 Dependencies + +- `sonic-rs` **and** `simd-json` are both pulled in (`Cargo.toml:126, 127`). Pick one. `simd-json` is more portable; `sonic-rs` is faster on x86_64 with AVX2. +- `webpki-roots` (frozen snapshot) is fine for hermetic builds; consider `rustls-native-certs` as a feature for users who want the system trust store. +- `send_wrapper` is used to satisfy hyper's `Send` bound on compio H2 timers (`server_tls_compio.rs:380-405`). Document this as a hard invariant: the `Send` claim is per-runtime, not global. +- `linkme` powers `mount_all` with unspecified ordering; either accept it and document, or replace with explicit registration. + +--- + +## 7. Phased roadmap + +| Phase | Scope | Estimated effort (one engineer) | +|---|---|---| +| ~~**`1.2.0` security release**~~ | ~~ยง 1 in full. Blog post documenting the audit.~~ โ€” **Shipped.** | ~~1 week~~ | +| **v2 alpha โ€” core** | ยง 2: `Router`, `IntoResponse`, `Result<_, E>`, `nest`/`scope`, 405+`Allow`, RFC 7807, macro cleanup, `mount_all` redesign, `tako-core-local` parity decision. | 3-4 weeks | +| **v2 alpha โ€” server** | ยง 3: `Server::builder`, `tako-tls` crate, `Arc` (drop `Box::leak`), `Limits` + `HttpConfig`, unified `ConnInfo`, `tako-server-pt` merge, h2c, H3 streaming body, PROXY v2 TLV, mTLS hooks. | 3-4 weeks | +| **v2 alpha โ€” plugins** | ยง 4: backend traits, `RedisStore`, `timeout`, `traceparent`, `problem+json`, `healthcheck`, `ip_filter`, `etag`, fixes to existing plugins. | 2-3 weeks | +| **v2 alpha โ€” streams + extractors** | ยง 5: SSE spec compliance, WS subprotocol/ping/permessage-deflate + Autobahn, `TypedHeader`/`Extension`/`MatchedPath`, validator integration, finish or delete `zero_copy_extractors`. | 2-3 weeks | +| **v2 beta โ€” hygiene** | ยง 6: tests to 70%, fuzz harnesses, Miri, full CI matrix, mdbook, migration guide, example fleet rebuild. | 2 weeks | +| **v2.0 release** | Ship. | โ€” | + +Total: **~12-16 weeks for one engineer**, **~6-8 weeks for two**. + +~~The `1.2.0` security release should ship **before** any v2 work begins, both because the bugs are real and because a public audit blog post is an effective lead-in to a v2 announcement.~~ โ€” **Done; v2 work can begin.** diff --git a/examples/bench-pt/Cargo.lock b/examples/bench-pt/Cargo.lock index e96d8bd..64851f0 100644 --- a/examples/bench-pt/Cargo.lock +++ b/examples/bench-pt/Cargo.lock @@ -937,6 +937,26 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linkme" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83272d46373fb8decca684579ac3e7c8f3d71d4cc3aa693df8759e260ae41cf" +dependencies = [ + "linkme-impl", +] + +[[package]] +name = "linkme-impl" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32d59e20403c7d08fe62b4376edfe5c7fb2ef1e6b1465379686d0f21c8df444b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1579,6 +1599,7 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", + "linkme", "matchit", "mime", "mime_guess", @@ -1631,6 +1652,15 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "tako-macros" +version = "1.1.2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tako-plugins" version = "1.1.2" @@ -1666,8 +1696,10 @@ dependencies = [ name = "tako-rs" version = "1.1.2" dependencies = [ + "linkme", "tako-core", "tako-extractors", + "tako-macros", "tako-plugins", "tako-server", "tako-server-pt", diff --git a/examples/bench-pt/src/main.rs b/examples/bench-pt/src/main.rs index 7be10bf..102d1a7 100644 --- a/examples/bench-pt/src/main.rs +++ b/examples/bench-pt/src/main.rs @@ -67,6 +67,7 @@ fn main() { workers, pin_to_core: false, backlog: 1024, + ..Default::default() }; tako::serve_per_thread(&addr, build_router(), cfg).expect("serve_per_thread"); } @@ -75,6 +76,7 @@ fn main() { workers, pin_to_core: false, backlog: 1024, + ..Default::default() }; tako::serve_per_thread_compio(&addr, build_router(), cfg) .expect("serve_per_thread_compio"); diff --git a/examples/hello-world-compio/Cargo.lock b/examples/hello-world-compio/Cargo.lock index 4765ae2..2006e56 100644 --- a/examples/hello-world-compio/Cargo.lock +++ b/examples/hello-world-compio/Cargo.lock @@ -37,6 +37,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -148,9 +157,9 @@ dependencies = [ [[package]] name = "compio" -version = "0.17.0" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8a7cc183295c36483f1c9616f43c4ac1a9030ce6d9321d6cebb4c4bb21164c4" +checksum = "9b84ee96a86948d04388f3a0b8c36b9f0a6b40b3528ac0d65737e53632fb37fe" dependencies = [ "compio-buf", "compio-driver", @@ -167,9 +176,9 @@ dependencies = [ [[package]] name = "compio-buf" -version = "0.7.2" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ebb4036bf394915196c09362e4fd5581ee8bf0f3302ab598bff9d646aea2061" +checksum = "a00d719dbd8c602ab0d25d219cbc6b517008858de7a8d6c51b4dc95aefff4dce" dependencies = [ "arrayvec", "bytes", @@ -178,9 +187,9 @@ dependencies = [ [[package]] name = "compio-driver" -version = "0.10.0" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff5c12800e82a01d12046ccc29b014e1cbbb2fbe38c52534e0d40d4fc58881d5" +checksum = "74d42d98dc890ee4db00c1e68a723391711aab6d67085880d716b72830f7c715" dependencies = [ "cfg-if", "cfg_aliases", @@ -194,17 +203,21 @@ dependencies = [ "libc", "once_cell", "paste", + "pin-project-lite", "polling", "slab", + "smallvec", "socket2", + "synchrony", + "thin-cell", "windows-sys 0.61.2", ] [[package]] name = "compio-fs" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c568022f90c2e2e8ea7ff4c4e8fde500753b5b9b6b6d870e25b5e656f9ea2892" +checksum = "65ee36e1acf2cec4835efe9a986c012b2462c5ef53580e4ee84ae6d5a3d8e3b3" dependencies = [ "cfg-if", "cfg_aliases", @@ -214,19 +227,21 @@ dependencies = [ "compio-runtime", "libc", "os_pipe", + "pin-project-lite", "widestring", "windows-sys 0.61.2", ] [[package]] name = "compio-io" -version = "0.8.4" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1e64c6d723589492a4f5041394301e9903466a606f6d9bcc11e406f9f07e9ec" +checksum = "637522f28a64fd5f7dcceaa4ddef13fa8d8020025e8c993f7a069e237835580e" dependencies = [ "compio-buf", "futures-util", "paste", + "synchrony", ] [[package]] @@ -252,9 +267,9 @@ dependencies = [ [[package]] name = "compio-net" -version = "0.10.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bffab78b8a876111ca76450912ca6a5a164b0dd93973e342c5f438a6f478c735" +checksum = "becd7d40522c885113752a3640cba9f9d347f205b646bb3f8ff3967173a228f2" dependencies = [ "cfg-if", "compio-buf", @@ -271,9 +286,9 @@ dependencies = [ [[package]] name = "compio-quic" -version = "0.6.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e101b05fe8608ce6fb2882ac331e211f2b0318449ae27c576c7456b4f1ec4e" +checksum = "ad9efdad81b920108b9de57148e1b9d73dc408b6d06a59ee64836dde651cf026" dependencies = [ "cfg_aliases", "compio-buf", @@ -287,15 +302,16 @@ dependencies = [ "quinn-proto", "rustc-hash", "rustls", + "synchrony", "thiserror", "windows-sys 0.61.2", ] [[package]] name = "compio-runtime" -version = "0.10.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fd890a129a8086af857bbe18401689c130aa6ccfc7f3c029a7800f7256af3e" +checksum = "d6c1c71f011bdd9c8f30e97d877b606505ee6d241c7782cfaed172f66acbd9cd" dependencies = [ "async-task", "cfg-if", @@ -316,9 +332,9 @@ dependencies = [ [[package]] name = "compio-tls" -version = "0.8.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84cd9ca48815f384f1a30400848beebcd8c7ead2f57bfe28ebc5560babea88ec" +checksum = "3a7056da226af42cda4c83b00a021cce3e1ee5f4cffc8a0ff8801381e618cf1c" dependencies = [ "compio-buf", "compio-io", @@ -329,9 +345,9 @@ dependencies = [ [[package]] name = "compio-ws" -version = "0.2.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf7281a15e8f638697415f9838030e41a92c8a8954ddccfc46556a413c16dd9a" +checksum = "99d45f47c6e64babcaa6b8df1dffced56012e60e58401255e679f428ddbe9fb6" dependencies = [ "compio-buf", "compio-io", @@ -423,9 +439,9 @@ dependencies = [ [[package]] name = "cyper-core" -version = "0.7.1" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4b86aa741e422dab7f730aa1ec5ab6bc26569e577fe2b8fe0ebf6d779b2325" +checksum = "f606aa5ddfee60d1cd86e350a1f7bf45ad5c4dc060d80edf84eef38a9a6b2efc" dependencies = [ "compio", "futures-util", @@ -500,9 +516,9 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flume" -version = "0.11.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" dependencies = [ "futures-core", "futures-sink", @@ -515,6 +531,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -595,6 +617,21 @@ dependencies = [ "slab", ] +[[package]] +name = "generator" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + [[package]] name = "generic-array" version = "0.14.9" @@ -627,11 +664,24 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + [[package]] name = "ghash" version = "0.5.1" @@ -661,12 +711,27 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hello-world" version = "0.1.0" @@ -854,6 +919,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -882,7 +953,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -932,12 +1005,44 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "linkme" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83272d46373fb8decca684579ac3e7c8f3d71d4cc3aa693df8759e260ae41cf" +dependencies = [ + "linkme-impl", +] + +[[package]] +name = "linkme-impl" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32d59e20403c7d08fe62b4376edfe5c7fb2ef1e6b1465379686d0f21c8df444b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -965,12 +1070,35 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "pin-utils", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matchit" version = "0.9.1" @@ -1001,15 +1129,24 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", "windows-sys 0.61.2", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -1145,6 +1282,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -1198,6 +1345,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -1266,6 +1419,23 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "ring" version = "0.17.14" @@ -1380,6 +1550,12 @@ version = "4.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b21a75f5913ab130e4b369fb8693be25f29b983e2ecad4279df9bfa5dd8aaf3e" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "send_wrapper" version = "0.6.0" @@ -1476,6 +1652,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1546,6 +1731,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synchrony" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c174d82fd56da8214ec095cfe4568e59e5ccb49d060e70c2f98e3ba352b23e45" +dependencies = [ + "futures-util", + "loom", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -1575,6 +1770,7 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", + "linkme", "matchit", "mime", "mime_guess", @@ -1628,6 +1824,15 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "tako-macros" +version = "1.1.2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tako-plugins" version = "1.1.2" @@ -1651,6 +1856,7 @@ dependencies = [ "serde_json", "sha1", "smallvec", + "subtle", "tako-core", "tako-extractors", "tokio", @@ -1658,14 +1864,17 @@ dependencies = [ "tracing", "url", "urlencoding", + "uuid", ] [[package]] name = "tako-rs" version = "1.1.2" dependencies = [ + "linkme", "tako-core", "tako-extractors", + "tako-macros", "tako-plugins", "tako-server", "tako-streams", @@ -1725,6 +1934,12 @@ dependencies = [ "url", ] +[[package]] +name = "thin-cell" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4164c6c316ba9733b0ab021e7f9852c788a4b991b49c25820f1be48e1d41345b" + [[package]] name = "thiserror" version = "2.0.18" @@ -1745,6 +1960,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.47" @@ -1803,9 +2027,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.50.0" +version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ "bytes", "libc", @@ -1820,9 +2044,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -1842,14 +2066,14 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.27.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "489a59b6730eda1b0171fcfda8b121f4bee2b35cba8645ca35c5f7ba3eb736c1" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", "tokio", - "tungstenite 0.27.0", + "tungstenite 0.29.0", ] [[package]] @@ -1925,6 +2149,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -1935,9 +2189,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.27.0" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", @@ -1945,6 +2199,8 @@ dependencies = [ "httparse", "log", "rand 0.9.2", + "rustls", + "rustls-pki-types", "sha1", "thiserror", "utf-8", @@ -1952,9 +2208,9 @@ dependencies = [ [[package]] name = "tungstenite" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", @@ -1962,11 +2218,8 @@ dependencies = [ "httparse", "log", "rand 0.9.2", - "rustls", - "rustls-pki-types", "sha1", "thiserror", - "utf-8", ] [[package]] @@ -1987,6 +2240,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "universal-hash" version = "0.5.1" @@ -2033,6 +2292,23 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version_check" version = "0.9.5" @@ -2063,6 +2339,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.114" @@ -2108,6 +2393,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-time" version = "1.1.0" @@ -2152,6 +2471,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -2248,6 +2576,88 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" diff --git a/examples/hello-world-compio/Cargo.toml b/examples/hello-world-compio/Cargo.toml index b8d65e8..add7dff 100644 --- a/examples/hello-world-compio/Cargo.toml +++ b/examples/hello-world-compio/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" [dependencies] anyhow = "1.0.98" -compio = { version = "0.17.0", features = ["macros"] } +compio = { version = "0.18.0", features = ["macros", "net"] } tako-rs = { path = "../../tako-rs", features = ["compio"] } [workspace] diff --git a/examples/streams-compio/Cargo.lock b/examples/streams-compio/Cargo.lock index 968963c..4098581 100644 --- a/examples/streams-compio/Cargo.lock +++ b/examples/streams-compio/Cargo.lock @@ -37,6 +37,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -148,9 +157,9 @@ dependencies = [ [[package]] name = "compio" -version = "0.17.0" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8a7cc183295c36483f1c9616f43c4ac1a9030ce6d9321d6cebb4c4bb21164c4" +checksum = "9b84ee96a86948d04388f3a0b8c36b9f0a6b40b3528ac0d65737e53632fb37fe" dependencies = [ "compio-buf", "compio-driver", @@ -167,9 +176,9 @@ dependencies = [ [[package]] name = "compio-buf" -version = "0.7.2" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ebb4036bf394915196c09362e4fd5581ee8bf0f3302ab598bff9d646aea2061" +checksum = "a00d719dbd8c602ab0d25d219cbc6b517008858de7a8d6c51b4dc95aefff4dce" dependencies = [ "arrayvec", "bytes", @@ -178,9 +187,9 @@ dependencies = [ [[package]] name = "compio-driver" -version = "0.10.0" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff5c12800e82a01d12046ccc29b014e1cbbb2fbe38c52534e0d40d4fc58881d5" +checksum = "74d42d98dc890ee4db00c1e68a723391711aab6d67085880d716b72830f7c715" dependencies = [ "cfg-if", "cfg_aliases", @@ -194,17 +203,21 @@ dependencies = [ "libc", "once_cell", "paste", + "pin-project-lite", "polling", "slab", + "smallvec", "socket2", + "synchrony", + "thin-cell", "windows-sys 0.61.2", ] [[package]] name = "compio-fs" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c568022f90c2e2e8ea7ff4c4e8fde500753b5b9b6b6d870e25b5e656f9ea2892" +checksum = "65ee36e1acf2cec4835efe9a986c012b2462c5ef53580e4ee84ae6d5a3d8e3b3" dependencies = [ "cfg-if", "cfg_aliases", @@ -214,19 +227,21 @@ dependencies = [ "compio-runtime", "libc", "os_pipe", + "pin-project-lite", "widestring", "windows-sys 0.61.2", ] [[package]] name = "compio-io" -version = "0.8.4" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1e64c6d723589492a4f5041394301e9903466a606f6d9bcc11e406f9f07e9ec" +checksum = "637522f28a64fd5f7dcceaa4ddef13fa8d8020025e8c993f7a069e237835580e" dependencies = [ "compio-buf", "futures-util", "paste", + "synchrony", ] [[package]] @@ -252,9 +267,9 @@ dependencies = [ [[package]] name = "compio-net" -version = "0.10.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bffab78b8a876111ca76450912ca6a5a164b0dd93973e342c5f438a6f478c735" +checksum = "becd7d40522c885113752a3640cba9f9d347f205b646bb3f8ff3967173a228f2" dependencies = [ "cfg-if", "compio-buf", @@ -271,9 +286,9 @@ dependencies = [ [[package]] name = "compio-quic" -version = "0.6.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e101b05fe8608ce6fb2882ac331e211f2b0318449ae27c576c7456b4f1ec4e" +checksum = "ad9efdad81b920108b9de57148e1b9d73dc408b6d06a59ee64836dde651cf026" dependencies = [ "cfg_aliases", "compio-buf", @@ -287,15 +302,16 @@ dependencies = [ "quinn-proto", "rustc-hash", "rustls", + "synchrony", "thiserror", "windows-sys 0.61.2", ] [[package]] name = "compio-runtime" -version = "0.10.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fd890a129a8086af857bbe18401689c130aa6ccfc7f3c029a7800f7256af3e" +checksum = "d6c1c71f011bdd9c8f30e97d877b606505ee6d241c7782cfaed172f66acbd9cd" dependencies = [ "async-task", "cfg-if", @@ -316,9 +332,9 @@ dependencies = [ [[package]] name = "compio-tls" -version = "0.8.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84cd9ca48815f384f1a30400848beebcd8c7ead2f57bfe28ebc5560babea88ec" +checksum = "3a7056da226af42cda4c83b00a021cce3e1ee5f4cffc8a0ff8801381e618cf1c" dependencies = [ "compio-buf", "compio-io", @@ -329,9 +345,9 @@ dependencies = [ [[package]] name = "compio-ws" -version = "0.2.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf7281a15e8f638697415f9838030e41a92c8a8954ddccfc46556a413c16dd9a" +checksum = "99d45f47c6e64babcaa6b8df1dffced56012e60e58401255e679f428ddbe9fb6" dependencies = [ "compio-buf", "compio-io", @@ -423,9 +439,9 @@ dependencies = [ [[package]] name = "cyper-core" -version = "0.7.1" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4b86aa741e422dab7f730aa1ec5ab6bc26569e577fe2b8fe0ebf6d779b2325" +checksum = "f606aa5ddfee60d1cd86e350a1f7bf45ad5c4dc060d80edf84eef38a9a6b2efc" dependencies = [ "compio", "futures-util", @@ -500,9 +516,9 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flume" -version = "0.11.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" dependencies = [ "futures-core", "futures-sink", @@ -515,6 +531,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -595,6 +617,21 @@ dependencies = [ "slab", ] +[[package]] +name = "generator" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + [[package]] name = "generic-array" version = "0.14.9" @@ -627,11 +664,24 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + [[package]] name = "ghash" version = "0.5.1" @@ -661,12 +711,27 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.2" @@ -845,6 +910,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -873,7 +944,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -923,12 +996,44 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "linkme" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83272d46373fb8decca684579ac3e7c8f3d71d4cc3aa693df8759e260ae41cf" +dependencies = [ + "linkme-impl", +] + +[[package]] +name = "linkme-impl" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32d59e20403c7d08fe62b4376edfe5c7fb2ef1e6b1465379686d0f21c8df444b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -956,12 +1061,35 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "pin-utils", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matchit" version = "0.9.1" @@ -992,15 +1120,24 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", "windows-sys 0.61.2", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -1136,6 +1273,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -1189,6 +1336,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -1257,6 +1410,23 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "ring" version = "0.17.14" @@ -1371,6 +1541,12 @@ version = "4.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b21a75f5913ab130e4b369fb8693be25f29b983e2ecad4279df9bfa5dd8aaf3e" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "send_wrapper" version = "0.6.0" @@ -1467,6 +1643,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1550,6 +1735,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synchrony" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c174d82fd56da8214ec095cfe4568e59e5ccb49d060e70c2f98e3ba352b23e45" +dependencies = [ + "futures-util", + "loom", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -1579,6 +1774,7 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", + "linkme", "matchit", "mime", "mime_guess", @@ -1632,6 +1828,15 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "tako-macros" +version = "1.1.2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tako-plugins" version = "1.1.2" @@ -1655,6 +1860,7 @@ dependencies = [ "serde_json", "sha1", "smallvec", + "subtle", "tako-core", "tako-extractors", "tokio", @@ -1662,14 +1868,17 @@ dependencies = [ "tracing", "url", "urlencoding", + "uuid", ] [[package]] name = "tako-rs" version = "1.1.2" dependencies = [ + "linkme", "tako-core", "tako-extractors", + "tako-macros", "tako-plugins", "tako-server", "tako-streams", @@ -1729,6 +1938,12 @@ dependencies = [ "url", ] +[[package]] +name = "thin-cell" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4164c6c316ba9733b0ab021e7f9852c788a4b991b49c25820f1be48e1d41345b" + [[package]] name = "thiserror" version = "2.0.18" @@ -1749,6 +1964,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.47" @@ -1807,9 +2031,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.50.0" +version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ "bytes", "libc", @@ -1824,9 +2048,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -1846,14 +2070,14 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.27.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "489a59b6730eda1b0171fcfda8b121f4bee2b35cba8645ca35c5f7ba3eb736c1" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", "tokio", - "tungstenite 0.27.0", + "tungstenite 0.29.0", ] [[package]] @@ -1929,6 +2153,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -1939,9 +2193,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.27.0" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", @@ -1949,6 +2203,8 @@ dependencies = [ "httparse", "log", "rand 0.9.2", + "rustls", + "rustls-pki-types", "sha1", "thiserror", "utf-8", @@ -1956,9 +2212,9 @@ dependencies = [ [[package]] name = "tungstenite" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", @@ -1966,11 +2222,8 @@ dependencies = [ "httparse", "log", "rand 0.9.2", - "rustls", - "rustls-pki-types", "sha1", "thiserror", - "utf-8", ] [[package]] @@ -1991,6 +2244,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "universal-hash" version = "0.5.1" @@ -2037,6 +2296,23 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version_check" version = "0.9.5" @@ -2067,6 +2343,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.114" @@ -2112,6 +2397,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-time" version = "1.1.0" @@ -2156,6 +2475,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -2252,6 +2580,88 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" diff --git a/examples/streams-compio/Cargo.toml b/examples/streams-compio/Cargo.toml index 0bb6121..39f8e1d 100644 --- a/examples/streams-compio/Cargo.toml +++ b/examples/streams-compio/Cargo.toml @@ -6,7 +6,7 @@ edition = "2024" [dependencies] anyhow = "1.0.98" bytes = "1" -compio = { version = "0.17.0", features = ["macros"] } +compio = { version = "0.18.0", features = ["macros", "net"] } futures-util = "0.3" http = "1" http-body = "1" diff --git a/examples/tls-compio/Cargo.lock b/examples/tls-compio/Cargo.lock index 910bf6b..5584863 100644 --- a/examples/tls-compio/Cargo.lock +++ b/examples/tls-compio/Cargo.lock @@ -37,6 +37,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -181,9 +190,9 @@ dependencies = [ [[package]] name = "compio" -version = "0.17.0" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8a7cc183295c36483f1c9616f43c4ac1a9030ce6d9321d6cebb4c4bb21164c4" +checksum = "9b84ee96a86948d04388f3a0b8c36b9f0a6b40b3528ac0d65737e53632fb37fe" dependencies = [ "compio-buf", "compio-driver", @@ -200,9 +209,9 @@ dependencies = [ [[package]] name = "compio-buf" -version = "0.7.2" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ebb4036bf394915196c09362e4fd5581ee8bf0f3302ab598bff9d646aea2061" +checksum = "a00d719dbd8c602ab0d25d219cbc6b517008858de7a8d6c51b4dc95aefff4dce" dependencies = [ "arrayvec", "bytes", @@ -211,9 +220,9 @@ dependencies = [ [[package]] name = "compio-driver" -version = "0.10.0" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff5c12800e82a01d12046ccc29b014e1cbbb2fbe38c52534e0d40d4fc58881d5" +checksum = "74d42d98dc890ee4db00c1e68a723391711aab6d67085880d716b72830f7c715" dependencies = [ "cfg-if", "cfg_aliases", @@ -227,17 +236,21 @@ dependencies = [ "libc", "once_cell", "paste", + "pin-project-lite", "polling", "slab", + "smallvec", "socket2", + "synchrony", + "thin-cell", "windows-sys 0.61.2", ] [[package]] name = "compio-fs" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c568022f90c2e2e8ea7ff4c4e8fde500753b5b9b6b6d870e25b5e656f9ea2892" +checksum = "65ee36e1acf2cec4835efe9a986c012b2462c5ef53580e4ee84ae6d5a3d8e3b3" dependencies = [ "cfg-if", "cfg_aliases", @@ -247,19 +260,21 @@ dependencies = [ "compio-runtime", "libc", "os_pipe", + "pin-project-lite", "widestring", "windows-sys 0.61.2", ] [[package]] name = "compio-io" -version = "0.8.4" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1e64c6d723589492a4f5041394301e9903466a606f6d9bcc11e406f9f07e9ec" +checksum = "637522f28a64fd5f7dcceaa4ddef13fa8d8020025e8c993f7a069e237835580e" dependencies = [ "compio-buf", "futures-util", "paste", + "synchrony", ] [[package]] @@ -285,9 +300,9 @@ dependencies = [ [[package]] name = "compio-net" -version = "0.10.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bffab78b8a876111ca76450912ca6a5a164b0dd93973e342c5f438a6f478c735" +checksum = "becd7d40522c885113752a3640cba9f9d347f205b646bb3f8ff3967173a228f2" dependencies = [ "cfg-if", "compio-buf", @@ -304,9 +319,9 @@ dependencies = [ [[package]] name = "compio-quic" -version = "0.6.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e101b05fe8608ce6fb2882ac331e211f2b0318449ae27c576c7456b4f1ec4e" +checksum = "ad9efdad81b920108b9de57148e1b9d73dc408b6d06a59ee64836dde651cf026" dependencies = [ "cfg_aliases", "compio-buf", @@ -320,15 +335,16 @@ dependencies = [ "quinn-proto", "rustc-hash", "rustls", + "synchrony", "thiserror", "windows-sys 0.61.2", ] [[package]] name = "compio-runtime" -version = "0.10.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fd890a129a8086af857bbe18401689c130aa6ccfc7f3c029a7800f7256af3e" +checksum = "d6c1c71f011bdd9c8f30e97d877b606505ee6d241c7782cfaed172f66acbd9cd" dependencies = [ "async-task", "cfg-if", @@ -349,9 +365,9 @@ dependencies = [ [[package]] name = "compio-tls" -version = "0.8.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84cd9ca48815f384f1a30400848beebcd8c7ead2f57bfe28ebc5560babea88ec" +checksum = "3a7056da226af42cda4c83b00a021cce3e1ee5f4cffc8a0ff8801381e618cf1c" dependencies = [ "compio-buf", "compio-io", @@ -362,9 +378,9 @@ dependencies = [ [[package]] name = "compio-ws" -version = "0.2.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf7281a15e8f638697415f9838030e41a92c8a8954ddccfc46556a413c16dd9a" +checksum = "99d45f47c6e64babcaa6b8df1dffced56012e60e58401255e679f428ddbe9fb6" dependencies = [ "compio-buf", "compio-io", @@ -456,9 +472,9 @@ dependencies = [ [[package]] name = "cyper-core" -version = "0.7.1" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4b86aa741e422dab7f730aa1ec5ab6bc26569e577fe2b8fe0ebf6d779b2325" +checksum = "f606aa5ddfee60d1cd86e350a1f7bf45ad5c4dc060d80edf84eef38a9a6b2efc" dependencies = [ "compio", "futures-util", @@ -539,9 +555,9 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flume" -version = "0.11.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" dependencies = [ "futures-core", "futures-sink", @@ -554,6 +570,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -640,6 +662,21 @@ dependencies = [ "slab", ] +[[package]] +name = "generator" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + [[package]] name = "generic-array" version = "0.14.9" @@ -672,11 +709,24 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + [[package]] name = "ghash" version = "0.5.1" @@ -706,12 +756,27 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.2" @@ -890,6 +955,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -918,7 +989,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -978,12 +1051,44 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "linkme" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83272d46373fb8decca684579ac3e7c8f3d71d4cc3aa693df8759e260ae41cf" +dependencies = [ + "linkme-impl", +] + +[[package]] +name = "linkme-impl" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32d59e20403c7d08fe62b4376edfe5c7fb2ef1e6b1465379686d0f21c8df444b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1011,12 +1116,35 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "pin-utils", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matchit" version = "0.9.1" @@ -1047,15 +1175,24 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", "windows-sys 0.61.2", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -1191,6 +1328,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -1244,6 +1391,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -1312,6 +1465,23 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "ring" version = "0.17.14" @@ -1437,6 +1607,12 @@ version = "4.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b21a75f5913ab130e4b369fb8693be25f29b983e2ecad4279df9bfa5dd8aaf3e" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "send_wrapper" version = "0.6.0" @@ -1533,6 +1709,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1603,6 +1788,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synchrony" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c174d82fd56da8214ec095cfe4568e59e5ccb49d060e70c2f98e3ba352b23e45" +dependencies = [ + "futures-util", + "loom", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -1632,6 +1827,7 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", + "linkme", "matchit", "mime", "mime_guess", @@ -1688,6 +1884,15 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "tako-macros" +version = "1.1.2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tako-plugins" version = "1.1.2" @@ -1710,6 +1915,7 @@ dependencies = [ "serde_json", "sha1", "smallvec", + "subtle", "tako-core", "tako-extractors", "tokio", @@ -1717,14 +1923,17 @@ dependencies = [ "tracing", "url", "urlencoding", + "uuid", ] [[package]] name = "tako-rs" version = "1.1.2" dependencies = [ + "linkme", "tako-core", "tako-extractors", + "tako-macros", "tako-plugins", "tako-server", "tako-streams", @@ -1785,6 +1994,12 @@ dependencies = [ "url", ] +[[package]] +name = "thin-cell" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4164c6c316ba9733b0ab021e7f9852c788a4b991b49c25820f1be48e1d41345b" + [[package]] name = "thiserror" version = "2.0.18" @@ -1805,6 +2020,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.47" @@ -1873,9 +2097,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.50.0" +version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ "bytes", "libc", @@ -1890,9 +2114,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -1922,14 +2146,14 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.27.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "489a59b6730eda1b0171fcfda8b121f4bee2b35cba8645ca35c5f7ba3eb736c1" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", "tokio", - "tungstenite 0.27.0", + "tungstenite 0.29.0", ] [[package]] @@ -2005,6 +2229,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -2015,9 +2269,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.27.0" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", @@ -2025,6 +2279,8 @@ dependencies = [ "httparse", "log", "rand 0.9.2", + "rustls", + "rustls-pki-types", "sha1", "thiserror", "utf-8", @@ -2032,9 +2288,9 @@ dependencies = [ [[package]] name = "tungstenite" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", @@ -2042,11 +2298,8 @@ dependencies = [ "httparse", "log", "rand 0.9.2", - "rustls", - "rustls-pki-types", "sha1", "thiserror", - "utf-8", ] [[package]] @@ -2067,6 +2320,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "universal-hash" version = "0.5.1" @@ -2113,6 +2372,23 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version_check" version = "0.9.5" @@ -2143,6 +2419,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.114" @@ -2188,6 +2473,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-time" version = "1.1.0" @@ -2232,6 +2551,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -2328,6 +2656,88 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" diff --git a/examples/tls-compio/Cargo.toml b/examples/tls-compio/Cargo.toml index 9d1fd19..d194dda 100644 --- a/examples/tls-compio/Cargo.toml +++ b/examples/tls-compio/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" [dependencies] anyhow = "1.0.98" -compio = { version = "0.17.0", features = ["macros"] } +compio = { version = "0.18.0", features = ["macros", "net"] } http = "1" tako-rs = { path = "../../tako-rs", features = ["compio-tls"] } diff --git a/examples/websocket-compio/Cargo.lock b/examples/websocket-compio/Cargo.lock index 10fd185..f8213dd 100644 --- a/examples/websocket-compio/Cargo.lock +++ b/examples/websocket-compio/Cargo.lock @@ -37,6 +37,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -148,9 +157,9 @@ dependencies = [ [[package]] name = "compio" -version = "0.17.0" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8a7cc183295c36483f1c9616f43c4ac1a9030ce6d9321d6cebb4c4bb21164c4" +checksum = "9b84ee96a86948d04388f3a0b8c36b9f0a6b40b3528ac0d65737e53632fb37fe" dependencies = [ "compio-buf", "compio-driver", @@ -167,9 +176,9 @@ dependencies = [ [[package]] name = "compio-buf" -version = "0.7.2" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ebb4036bf394915196c09362e4fd5581ee8bf0f3302ab598bff9d646aea2061" +checksum = "a00d719dbd8c602ab0d25d219cbc6b517008858de7a8d6c51b4dc95aefff4dce" dependencies = [ "arrayvec", "bytes", @@ -178,9 +187,9 @@ dependencies = [ [[package]] name = "compio-driver" -version = "0.10.0" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff5c12800e82a01d12046ccc29b014e1cbbb2fbe38c52534e0d40d4fc58881d5" +checksum = "74d42d98dc890ee4db00c1e68a723391711aab6d67085880d716b72830f7c715" dependencies = [ "cfg-if", "cfg_aliases", @@ -194,17 +203,21 @@ dependencies = [ "libc", "once_cell", "paste", + "pin-project-lite", "polling", "slab", + "smallvec", "socket2", + "synchrony", + "thin-cell", "windows-sys 0.61.2", ] [[package]] name = "compio-fs" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c568022f90c2e2e8ea7ff4c4e8fde500753b5b9b6b6d870e25b5e656f9ea2892" +checksum = "65ee36e1acf2cec4835efe9a986c012b2462c5ef53580e4ee84ae6d5a3d8e3b3" dependencies = [ "cfg-if", "cfg_aliases", @@ -214,19 +227,21 @@ dependencies = [ "compio-runtime", "libc", "os_pipe", + "pin-project-lite", "widestring", "windows-sys 0.61.2", ] [[package]] name = "compio-io" -version = "0.8.4" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1e64c6d723589492a4f5041394301e9903466a606f6d9bcc11e406f9f07e9ec" +checksum = "637522f28a64fd5f7dcceaa4ddef13fa8d8020025e8c993f7a069e237835580e" dependencies = [ "compio-buf", "futures-util", "paste", + "synchrony", ] [[package]] @@ -252,9 +267,9 @@ dependencies = [ [[package]] name = "compio-net" -version = "0.10.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bffab78b8a876111ca76450912ca6a5a164b0dd93973e342c5f438a6f478c735" +checksum = "becd7d40522c885113752a3640cba9f9d347f205b646bb3f8ff3967173a228f2" dependencies = [ "cfg-if", "compio-buf", @@ -271,9 +286,9 @@ dependencies = [ [[package]] name = "compio-quic" -version = "0.6.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e101b05fe8608ce6fb2882ac331e211f2b0318449ae27c576c7456b4f1ec4e" +checksum = "ad9efdad81b920108b9de57148e1b9d73dc408b6d06a59ee64836dde651cf026" dependencies = [ "cfg_aliases", "compio-buf", @@ -287,15 +302,16 @@ dependencies = [ "quinn-proto", "rustc-hash", "rustls", + "synchrony", "thiserror", "windows-sys 0.61.2", ] [[package]] name = "compio-runtime" -version = "0.10.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fd890a129a8086af857bbe18401689c130aa6ccfc7f3c029a7800f7256af3e" +checksum = "d6c1c71f011bdd9c8f30e97d877b606505ee6d241c7782cfaed172f66acbd9cd" dependencies = [ "async-task", "cfg-if", @@ -316,9 +332,9 @@ dependencies = [ [[package]] name = "compio-tls" -version = "0.8.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84cd9ca48815f384f1a30400848beebcd8c7ead2f57bfe28ebc5560babea88ec" +checksum = "3a7056da226af42cda4c83b00a021cce3e1ee5f4cffc8a0ff8801381e618cf1c" dependencies = [ "compio-buf", "compio-io", @@ -329,9 +345,9 @@ dependencies = [ [[package]] name = "compio-ws" -version = "0.2.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf7281a15e8f638697415f9838030e41a92c8a8954ddccfc46556a413c16dd9a" +checksum = "99d45f47c6e64babcaa6b8df1dffced56012e60e58401255e679f428ddbe9fb6" dependencies = [ "compio-buf", "compio-io", @@ -423,9 +439,9 @@ dependencies = [ [[package]] name = "cyper-core" -version = "0.7.1" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4b86aa741e422dab7f730aa1ec5ab6bc26569e577fe2b8fe0ebf6d779b2325" +checksum = "f606aa5ddfee60d1cd86e350a1f7bf45ad5c4dc060d80edf84eef38a9a6b2efc" dependencies = [ "compio", "futures-util", @@ -500,9 +516,9 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flume" -version = "0.11.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" dependencies = [ "futures-core", "futures-sink", @@ -515,6 +531,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -595,6 +617,21 @@ dependencies = [ "slab", ] +[[package]] +name = "generator" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + [[package]] name = "generic-array" version = "0.14.9" @@ -627,11 +664,24 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + [[package]] name = "ghash" version = "0.5.1" @@ -661,12 +711,27 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.2" @@ -845,6 +910,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -873,7 +944,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -923,12 +996,44 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "linkme" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83272d46373fb8decca684579ac3e7c8f3d71d4cc3aa693df8759e260ae41cf" +dependencies = [ + "linkme-impl", +] + +[[package]] +name = "linkme-impl" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32d59e20403c7d08fe62b4376edfe5c7fb2ef1e6b1465379686d0f21c8df444b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -956,12 +1061,35 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "pin-utils", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matchit" version = "0.9.1" @@ -992,15 +1120,24 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", "windows-sys 0.61.2", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -1136,6 +1273,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -1189,6 +1336,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -1257,6 +1410,23 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "ring" version = "0.17.14" @@ -1371,6 +1541,12 @@ version = "4.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b21a75f5913ab130e4b369fb8693be25f29b983e2ecad4279df9bfa5dd8aaf3e" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "send_wrapper" version = "0.6.0" @@ -1467,6 +1643,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1537,6 +1722,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synchrony" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c174d82fd56da8214ec095cfe4568e59e5ccb49d060e70c2f98e3ba352b23e45" +dependencies = [ + "futures-util", + "loom", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -1558,9 +1753,6 @@ dependencies = [ "base64", "bytes", "compio", - "compio-buf", - "compio-io", - "compio-ws", "cookie", "cyper-core", "futures-util", @@ -1569,6 +1761,7 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", + "linkme", "matchit", "mime", "mime_guess", @@ -1622,6 +1815,15 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "tako-macros" +version = "1.1.2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tako-plugins" version = "1.1.2" @@ -1644,6 +1846,7 @@ dependencies = [ "serde_json", "sha1", "smallvec", + "subtle", "tako-core", "tako-extractors", "tokio", @@ -1651,14 +1854,17 @@ dependencies = [ "tracing", "url", "urlencoding", + "uuid", ] [[package]] name = "tako-rs" version = "1.1.2" dependencies = [ + "linkme", "tako-core", "tako-extractors", + "tako-macros", "tako-plugins", "tako-server", "tako-streams", @@ -1695,9 +1901,6 @@ dependencies = [ "base64", "bytes", "compio", - "compio-buf", - "compio-io", - "compio-ws", "futures-util", "http", "http-body", @@ -1721,6 +1924,12 @@ dependencies = [ "url", ] +[[package]] +name = "thin-cell" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4164c6c316ba9733b0ab021e7f9852c788a4b991b49c25820f1be48e1d41345b" + [[package]] name = "thiserror" version = "2.0.18" @@ -1741,6 +1950,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.47" @@ -1799,9 +2017,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.50.0" +version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ "bytes", "libc", @@ -1816,9 +2034,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -1838,14 +2056,14 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.27.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "489a59b6730eda1b0171fcfda8b121f4bee2b35cba8645ca35c5f7ba3eb736c1" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", "tokio", - "tungstenite 0.27.0", + "tungstenite 0.29.0", ] [[package]] @@ -1921,6 +2139,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -1931,9 +2179,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.27.0" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", @@ -1941,6 +2189,8 @@ dependencies = [ "httparse", "log", "rand 0.9.2", + "rustls", + "rustls-pki-types", "sha1", "thiserror", "utf-8", @@ -1948,9 +2198,9 @@ dependencies = [ [[package]] name = "tungstenite" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", @@ -1958,11 +2208,8 @@ dependencies = [ "httparse", "log", "rand 0.9.2", - "rustls", - "rustls-pki-types", "sha1", "thiserror", - "utf-8", ] [[package]] @@ -1983,6 +2230,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "universal-hash" version = "0.5.1" @@ -2029,6 +2282,23 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version_check" version = "0.9.5" @@ -2059,6 +2329,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.114" @@ -2104,6 +2383,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-time" version = "1.1.0" @@ -2119,7 +2432,6 @@ name = "websocket-compio" version = "0.1.0" dependencies = [ "compio", - "compio-ws", "tako-rs", "tracing", ] @@ -2158,6 +2470,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -2254,6 +2575,88 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" diff --git a/examples/websocket-compio/Cargo.toml b/examples/websocket-compio/Cargo.toml index 03715c5..bd0861c 100644 --- a/examples/websocket-compio/Cargo.toml +++ b/examples/websocket-compio/Cargo.toml @@ -4,8 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -compio = { version = "0.17.0", features = ["macros", "time"] } -compio-ws = "0.2.0" +compio = { version = "0.18.0", features = ["macros", "time", "io", "ws"] } tako-rs = { path = "../../tako-rs", features = ["compio-ws"] } tracing = "0.1.41" diff --git a/examples/websocket-compio/src/main.rs b/examples/websocket-compio/src/main.rs index 211ae5a..18b484c 100644 --- a/examples/websocket-compio/src/main.rs +++ b/examples/websocket-compio/src/main.rs @@ -4,7 +4,7 @@ use std::time::Duration; -use compio_ws::tungstenite::Message; +use compio::ws::tungstenite::Message; use tako::Method; use tako::responder::Responder; use tako::types::Request; diff --git a/tako-core/Cargo.toml b/tako-core/Cargo.toml index ab98fcc..5559a8d 100644 --- a/tako-core/Cargo.toml +++ b/tako-core/Cargo.toml @@ -19,6 +19,7 @@ async-trait.workspace = true base64.workspace = true bytes.workspace = true cookie.workspace = true +envy.workspace = true futures-util.workspace = true http.workspace = true http-body.workspace = true @@ -54,9 +55,6 @@ ahash = { workspace = true, optional = true } async-graphql = { version = "7.0.17", optional = true } brotli = { workspace = true, optional = true } compio = { workspace = true, optional = true } -compio-buf = { workspace = true, optional = true } -compio-io = { workspace = true, optional = true } -compio-ws = { workspace = true, optional = true } cyper-core = { workspace = true, optional = true } flate2 = { workspace = true, optional = true } h3 = { workspace = true, optional = true } @@ -92,7 +90,7 @@ jwt-simple = { version = "0.12.12", optional = true } async-graphql = ["dep:async-graphql"] compio = ["dep:compio", "dep:cyper-core", "dep:send_wrapper"] compio-tls = ["compio", "tls", "dep:rustls", "dep:rustls-pemfile"] -compio-ws = ["compio", "dep:compio-ws", "dep:compio-io", "dep:compio-buf"] +compio-ws = ["compio", "compio/io", "compio/ws"] metrics-prometheus = ["dep:prometheus", "plugins", "signals"] metrics-opentelemetry = ["dep:opentelemetry", "dep:opentelemetry_sdk", "dep:opentelemetry-otlp", "plugins", "signals"] graphiql = ["dep:async-graphql", "async-graphql/graphiql"] diff --git a/tako-core/src/config.rs b/tako-core/src/config.rs index edda33c..427fb27 100644 --- a/tako-core/src/config.rs +++ b/tako-core/src/config.rs @@ -33,16 +33,24 @@ pub struct Config(pub T); impl Config { /// Loads configuration from environment variables. /// - /// Field names are converted to uppercase with underscores (e.g., `database_url` -> `DATABASE_URL`). + /// Field names are matched against environment variable names case-insensitively + /// (`database_url` โ†” `DATABASE_URL`). Non-string fields (`u16`, `bool`, โ€ฆ) are + /// parsed via the `envy` crate's per-field deserializers, so a typed + /// `port: u16` reads `PORT=8080` natively without relying on JSON number + /// coercion (which the previous serde_json-roundtrip implementation got wrong). pub fn from_env() -> Result { - // Collect all env vars into a map - let vars: std::collections::HashMap = std::env::vars().collect(); - - // Serialize the map to JSON, then deserialize into T - let value = serde_json::to_value(&vars).map_err(|e| ConfigError(e.to_string()))?; - let config: T = - serde_json::from_value(value).map_err(|e| ConfigError(e.to_string()))?; + let config: T = envy::from_env::().map_err(|e| ConfigError(e.to_string()))?; + Ok(Config(config)) + } + /// Loads configuration from environment variables that share a common prefix. + /// + /// Useful when several configs coexist in the process โ€” set + /// `MYAPP_DATABASE_URL`, `MYAPP_PORT`, โ€ฆ and call `Config::from_env_prefixed("MYAPP_")`. + pub fn from_env_prefixed(prefix: &str) -> Result { + let config: T = envy::prefixed(prefix) + .from_env::() + .map_err(|e| ConfigError(e.to_string()))?; Ok(Config(config)) } diff --git a/tako-core/src/conn_info.rs b/tako-core/src/conn_info.rs new file mode 100644 index 0000000..f194602 --- /dev/null +++ b/tako-core/src/conn_info.rs @@ -0,0 +1,139 @@ +//! Unified connection-info extension shared by every Tako transport. +//! +//! Every `serve_*` implementation inserts a [`ConnInfo`] into the request's +//! extensions before dispatch, so handlers and middleware can read peer / TLS +//! metadata without branching on transport-specific extension types +//! (`SocketAddr`, `UnixPeerAddr`, `ProxyHeader`, โ€ฆ). +//! +//! Existing extension inserts (`SocketAddr`, `UnixPeerAddr`, `ProxyHeader`) +//! remain in place for backward compatibility โ€” the new struct is additive. + +use std::net::SocketAddr; +use std::path::PathBuf; + +/// Network identity of a peer or local endpoint. +#[derive(Debug, Clone)] +pub enum PeerAddr { + /// IPv4 / IPv6 socket address. + Ip(SocketAddr), + /// Unix domain socket path (None for unnamed client sockets). + Unix(Option), + /// Reserved for vsock / abstract Unix / future transports. + Other(String), +} + +impl PeerAddr { + /// Convenience: the `SocketAddr` if this is an [`PeerAddr::Ip`], else `None`. + #[inline] + pub fn as_socket(&self) -> Option<&SocketAddr> { + match self { + PeerAddr::Ip(addr) => Some(addr), + _ => None, + } + } +} + +impl From for PeerAddr { + fn from(value: SocketAddr) -> Self { + PeerAddr::Ip(value) + } +} + +/// Transport that produced the request. Lets handlers branch on plain HTTP/1 +/// vs. TLS / HTTP/2 / HTTP/3 / Unix without keeping a parallel registry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Transport { + /// Plain HTTP/1.1 over TCP. + Http1, + /// HTTP/2 (cleartext or via TLS). + Http2, + /// HTTP/3 over QUIC. + Http3, + /// HTTP/1.1 over Unix domain socket. + Unix, + /// Raw TCP โ€” no HTTP wrapping (custom protocols only). + Tcp, +} + +/// TLS-specific connection metadata. Populated when the connection terminated +/// TLS at the server (TCP+TLS or HTTP/3); `None` for cleartext transports. +#[derive(Debug, Clone, Default)] +pub struct TlsInfo { + /// Negotiated ALPN protocol (e.g. `b"h2"`, `b"http/1.1"`, `b"h3"`). + pub alpn: Option>, + /// SNI hostname presented by the client. + pub sni: Option, + /// TLS protocol version label (e.g. `"TLSv1.3"`). + pub version: Option<&'static str>, +} + +/// Unified per-connection metadata, inserted into request extensions by every +/// transport before the router sees the request. +#[derive(Debug, Clone)] +pub struct ConnInfo { + /// Remote (client) endpoint. + pub peer: PeerAddr, + /// Local endpoint, if known. + pub local: Option, + /// Transport identifier. + pub transport: Transport, + /// TLS metadata if the connection was terminated as TLS at this server. + pub tls: Option, +} + +impl ConnInfo { + /// Helper for plain TCP HTTP/1 servers. + #[inline] + pub fn tcp(peer: SocketAddr) -> Self { + Self { + peer: PeerAddr::Ip(peer), + local: None, + transport: Transport::Http1, + tls: None, + } + } + + /// Helper for HTTP/2 over TLS connections. + #[inline] + pub fn h2_tls(peer: SocketAddr, tls: TlsInfo) -> Self { + Self { + peer: PeerAddr::Ip(peer), + local: None, + transport: Transport::Http2, + tls: Some(tls), + } + } + + /// Helper for plain HTTP/1 over TLS connections. + #[inline] + pub fn h1_tls(peer: SocketAddr, tls: TlsInfo) -> Self { + Self { + peer: PeerAddr::Ip(peer), + local: None, + transport: Transport::Http1, + tls: Some(tls), + } + } + + /// Helper for HTTP/3 connections. + #[inline] + pub fn h3(peer: SocketAddr, tls: TlsInfo) -> Self { + Self { + peer: PeerAddr::Ip(peer), + local: None, + transport: Transport::Http3, + tls: Some(tls), + } + } + + /// Helper for Unix domain socket connections. + #[inline] + pub fn unix(path: Option) -> Self { + Self { + peer: PeerAddr::Unix(path), + local: None, + transport: Transport::Unix, + tls: None, + } + } +} diff --git a/tako-core/src/lib.rs b/tako-core/src/lib.rs index cea622a..fd5bae2 100644 --- a/tako-core/src/lib.rs +++ b/tako-core/src/lib.rs @@ -42,6 +42,17 @@ pub mod plugins; /// Response generation utilities and traits. pub mod responder; +/// RFC 7807 / RFC 9457 `application/problem+json` error responses. +pub mod problem; + +/// Unified per-connection metadata extension shared by every transport. +pub mod conn_info; + +/// Shared TLS certificate / key PEM loading helpers. +#[cfg(any(feature = "tls", feature = "http3", feature = "client"))] +#[cfg_attr(docsrs, doc(cfg(any(feature = "tls", feature = "http3", feature = "client"))))] +pub mod tls; + /// Redirection utilities for handling HTTP redirects. pub mod redirect; @@ -57,6 +68,9 @@ pub mod queue; /// Application state management and dependency injection. pub mod state; +/// Per-router typed state container (instance-scoped, complements `state`). +pub mod router_state; + #[cfg(feature = "signals")] /// In-process signal arbiter for custom events. pub mod signals; diff --git a/tako-core/src/problem.rs b/tako-core/src/problem.rs new file mode 100644 index 0000000..581211d --- /dev/null +++ b/tako-core/src/problem.rs @@ -0,0 +1,128 @@ +//! RFC 7807 / RFC 9457 `application/problem+json` error responses. +//! +//! Provides a typed [`Problem`] struct, a [`Responder`] implementation that +//! emits `application/problem+json`, and a helper that builds a default +//! problem response from a status code. Hook the helper into +//! [`Router::error_handler`](crate::router::Router::error_handler) / +//! [`Router::client_error_handler`](crate::router::Router::client_error_handler) +//! to upgrade plain text 4xx/5xx responses into structured problem documents. + +use std::collections::BTreeMap; + +use http::StatusCode; +use http::header::HeaderValue; +use serde::Deserialize; +use serde::Serialize; + +use crate::body::TakoBody; +use crate::responder::Responder; +use crate::types::Response; + +/// Media type for RFC 7807 problem details. +pub const PROBLEM_JSON: &str = "application/problem+json"; + +/// RFC 7807 / RFC 9457 problem details document. +/// +/// All fields except `status` are optional. `extensions` carries any extra +/// implementation-specific members and is flattened into the JSON output. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Problem { + /// URI reference identifying the problem type. Defaults to `"about:blank"`. + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub r#type: Option, + /// Short, human-readable summary of the problem. + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + /// HTTP status code generated by the origin server. + pub status: u16, + /// Human-readable explanation specific to this occurrence of the problem. + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, + /// URI reference identifying the specific occurrence. + #[serde(skip_serializing_if = "Option::is_none")] + pub instance: Option, + /// Extension members merged into the top-level object. + #[serde(flatten)] + pub extensions: BTreeMap, +} + +impl Problem { + /// Builds a problem document from a status code, defaulting `title` to the + /// canonical reason phrase. + pub fn from_status(status: StatusCode) -> Self { + Self { + r#type: None, + title: status.canonical_reason().map(str::to_string), + status: status.as_u16(), + detail: None, + instance: None, + extensions: BTreeMap::new(), + } + } + + /// Sets the `detail` field. + pub fn with_detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } + + /// Sets the `type` URI. + pub fn with_type(mut self, type_uri: impl Into) -> Self { + self.r#type = Some(type_uri.into()); + self + } + + /// Sets the `instance` URI. + pub fn with_instance(mut self, instance: impl Into) -> Self { + self.instance = Some(instance.into()); + self + } + + /// Adds an extension member. + pub fn with_extension( + mut self, + key: impl Into, + value: impl Into, + ) -> Self { + self.extensions.insert(key.into(), value.into()); + self + } +} + +impl Responder for Problem { + fn into_response(self) -> Response { + let status = StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + let body = serde_json::to_vec(&self).unwrap_or_else(|_| b"{}".to_vec()); + let mut res = Response::new(TakoBody::from(body)); + *res.status_mut() = status; + res.headers_mut().insert( + http::header::CONTENT_TYPE, + HeaderValue::from_static(PROBLEM_JSON), + ); + res + } +} + +/// Default problem-formatter to be installed into +/// [`Router::error_handler`](crate::router::Router::error_handler) and/or +/// [`Router::client_error_handler`](crate::router::Router::client_error_handler). +/// +/// Behavior: if the incoming response already carries a JSON or `problem+json` +/// `Content-Type` header it is returned unchanged (handlers that produce their +/// own structured error stay authoritative). Otherwise the body is replaced +/// with a problem document built from the status code and any inline reason. +pub fn default_problem_responder(response: Response) -> Response { + let status = response.status(); + + if let Some(ct) = response.headers().get(http::header::CONTENT_TYPE) { + if let Ok(s) = ct.to_str() { + let s_lower = s.to_ascii_lowercase(); + if s_lower.contains("json") { + return response; + } + } + } + + let problem = Problem::from_status(status); + problem.into_response() +} diff --git a/tako-core/src/redirect.rs b/tako-core/src/redirect.rs index 2d4b507..9caa604 100644 --- a/tako-core/src/redirect.rs +++ b/tako-core/src/redirect.rs @@ -114,3 +114,41 @@ pub fn permanent_moved(location: impl Into) -> Redirect { pub fn permanent(location: impl Into) -> Redirect { Redirect::permanent(location) } + +/// Builds a router whose fallback redirects every request to the `https://` +/// equivalent on the same host, suitable for binding to port 80 alongside the +/// real TLS listener on `https_port`. +/// +/// # Examples +/// +/// ```rust,no_run +/// use tako::redirect::http_to_https_router; +/// +/// // serve(http80_listener, http_to_https_router(443)).await; +/// ``` +pub fn http_to_https_router(https_port: u16) -> crate::router::Router { + let mut router = crate::router::Router::new(); + router.fallback(move |req: crate::types::Request| { + let port = https_port; + async move { + let host_header = req + .headers() + .get(http::header::HOST) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let host = host_header.split(':').next().unwrap_or("").trim(); + let path_and_query = req + .uri() + .path_and_query() + .map(|pq| pq.as_str()) + .unwrap_or("/"); + let location = if port == 443 { + format!("https://{host}{path_and_query}") + } else { + format!("https://{host}:{port}{path_and_query}") + }; + Redirect::permanent(location) + } + }); + router +} diff --git a/tako-core/src/responder.rs b/tako-core/src/responder.rs index 9f0cbe0..541eecf 100644 --- a/tako-core/src/responder.rs +++ b/tako-core/src/responder.rs @@ -21,9 +21,11 @@ //! let response = ().into_response(); //! ``` +use std::borrow::Cow; use std::convert::Infallible; use bytes::Bytes; +use http::HeaderMap; use http::StatusCode; use http::header::HeaderName; use http::header::HeaderValue; @@ -74,6 +76,12 @@ pub trait Responder { fn into_response(self) -> Response; } +/// Alias for [`Responder`] matching the axum-style naming. +/// +/// Both names refer to the same trait; pick whichever reads better in context. +/// Existing code using `Responder` continues to compile unchanged. +pub use Responder as IntoResponse; + impl Responder for Response { fn into_response(self) -> Response { self @@ -141,6 +149,87 @@ impl Responder for (StatusCode, Vec) { } } +impl Responder for Bytes { + fn into_response(self) -> Response { + Response::new(TakoBody::full(Full::from(self))) + } +} + +impl Responder for Vec { + fn into_response(self) -> Response { + Response::new(TakoBody::full(Full::from(Bytes::from(self)))) + } +} + +impl Responder for Cow<'static, str> { + fn into_response(self) -> Response { + match self { + Cow::Borrowed(s) => Response::new(TakoBody::full(Full::from(Bytes::from_static(s.as_bytes())))), + Cow::Owned(s) => Response::new(TakoBody::full(Full::from(Bytes::from(s)))), + } + } +} + +impl Responder for serde_json::Value { + fn into_response(self) -> Response { + match serde_json::to_vec(&self) { + Ok(buf) => { + let mut res = Response::new(TakoBody::full(Full::from(Bytes::from(buf)))); + res.headers_mut().insert( + http::header::CONTENT_TYPE, + HeaderValue::from_static(mime::APPLICATION_JSON.as_ref()), + ); + res + } + Err(err) => { + let mut res = Response::new(TakoBody::from(err.to_string())); + *res.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; + res.headers_mut().insert( + http::header::CONTENT_TYPE, + HeaderValue::from_static(mime::TEXT_PLAIN_UTF_8.as_ref()), + ); + res + } + } + } +} + +impl Responder for (StatusCode, HeaderMap, TakoBody) { + fn into_response(self) -> Response { + let (status, headers, body) = self; + let mut res = Response::new(body); + *res.status_mut() = status; + *res.headers_mut() = headers; + res + } +} + +impl Responder for (StatusCode, HeaderMap) { + fn into_response(self) -> Response { + let (status, headers) = self; + let mut res = Response::new(TakoBody::empty()); + *res.status_mut() = status; + *res.headers_mut() = headers; + res + } +} + +impl Responder for HeaderMap { + fn into_response(self) -> Response { + let mut res = Response::new(TakoBody::empty()); + *res.headers_mut() = self; + res + } +} + +impl Responder for StatusCode { + fn into_response(self) -> Response { + let mut res = Response::new(TakoBody::empty()); + *res.status_mut() = self; + res + } +} + pub struct StaticHeaders(pub [(HeaderName, &'static str); N]); impl Responder for (StatusCode, StaticHeaders) { @@ -177,3 +266,30 @@ where } } } + +/// Native `Result` support for handler returns where both arms implement +/// [`Responder`]. The `Ok` value renders normally; the `Err` value is rendered +/// via its own [`Responder`] impl so error types stay typed instead of being +/// forced through a single panic-or-string path. +impl Responder for Result +where + T: Responder, + E: ResponderError, +{ + fn into_response(self) -> Response { + match self { + Ok(ok) => ok.into_response(), + Err(err) => err.into_response(), + } + } +} + +/// Marker trait that opts a type into being used as the `Err` arm of a +/// handler-returned `Result<_, E>`. +/// +/// Implement [`Responder`] on your error type, then add `impl ResponderError for MyErr {}` +/// to make it usable as `Result<_, MyErr>`. The marker prevents the blanket +/// `Result<_, E>` impl from colliding with [`Responder for anyhow::Result`] +/// โ€” `anyhow::Error` does not implement `ResponderError`, so the dedicated +/// `anyhow::Result` impl above keeps applying to `anyhow`-flavoured handlers. +pub trait ResponderError: Responder {} diff --git a/tako-core/src/route.rs b/tako-core/src/route.rs index 9646ac9..4070ba3 100644 --- a/tako-core/src/route.rs +++ b/tako-core/src/route.rs @@ -75,8 +75,8 @@ pub struct Route { /// Flag to ensure route plugins are initialized only once. #[cfg(feature = "plugins")] plugins_initialized: AtomicBool, - /// HTTP protocol version - http_protocol: Option, + /// HTTP protocol version guard (set once via [`Route::version`] / `h09`/`h10`/`h11`/`h2`). + http_protocol: OnceLock, /// Route-level signal arbiter. #[cfg(feature = "signals")] pub(crate) signals: SignalArbiter, @@ -103,7 +103,7 @@ impl Route { plugins: RwLock::new(Vec::new()), #[cfg(feature = "plugins")] plugins_initialized: AtomicBool::new(false), - http_protocol: None, + http_protocol: OnceLock::new(), #[cfg(feature = "signals")] signals: SignalArbiter::new(), #[cfg(any(feature = "utoipa", feature = "vespera"))] @@ -126,9 +126,14 @@ impl Route { Box::pin(async move { fut.await.into_response() }) }); - let mut middlewares = self.middlewares.load().iter().cloned().collect::>(); - middlewares.push(mw); - self.middlewares.store(Arc::new(middlewares)); + // RCU-style append: ArcSwap retries the closure on CAS conflict, so + // concurrent route-level middleware pushes cannot lose entries. + self.middlewares.rcu(move |current| { + let mut next = Vec::with_capacity(current.len() + 1); + next.extend(current.iter().cloned()); + next.push(mw.clone()); + Arc::new(next) + }); self.has_middleware.store(true, Ordering::Release); self } @@ -205,30 +210,40 @@ impl Route { } } - /// HTTP/0.9 guard - pub fn h09(&mut self) { - self.http_protocol = Some(http::Version::HTTP_09); + /// Restricts this route to a specific HTTP protocol version. + /// + /// Requests whose `version()` does not match are answered with + /// `505 HTTP Version Not Supported`. Set once at registration; later calls + /// are no-ops (lock-free reads in the hot path). + pub fn version(&self, version: http::Version) -> &Self { + let _ = self.http_protocol.set(version); + self } - /// HTTP/1.0 guard - pub fn h10(&mut self) { - self.http_protocol = Some(http::Version::HTTP_10); + /// HTTP/0.9 guard. Shorthand for [`Route::version`] with [`http::Version::HTTP_09`]. + pub fn h09(&self) -> &Self { + self.version(http::Version::HTTP_09) } - /// HTTP/1.1 guard - pub fn h11(&mut self) { - self.http_protocol = Some(http::Version::HTTP_11); + /// HTTP/1.0 guard. Shorthand for [`Route::version`] with [`http::Version::HTTP_10`]. + pub fn h10(&self) -> &Self { + self.version(http::Version::HTTP_10) } - /// HTTP/2 guard - #[doc(alias = "tsr")] - pub fn h2(&mut self) { - self.http_protocol = Some(http::Version::HTTP_2); + /// HTTP/1.1 guard. Shorthand for [`Route::version`] with [`http::Version::HTTP_11`]. + pub fn h11(&self) -> &Self { + self.version(http::Version::HTTP_11) + } + + /// HTTP/2 guard. Shorthand for [`Route::version`] with [`http::Version::HTTP_2`]. + pub fn h2(&self) -> &Self { + self.version(http::Version::HTTP_2) } /// Returns the configured protocol guard, if any. + #[inline] pub(crate) fn protocol_guard(&self) -> Option { - self.http_protocol + self.http_protocol.get().copied() } #[cfg(feature = "signals")] @@ -497,4 +512,53 @@ impl Route { pub(crate) fn get_simd_json_mode(&self) -> Option { self.simd_json_mode.get().copied() } + + /// Builds a new `Arc` with the same handler / middlewares / config + /// but a different path. Used by [`crate::router::Router::nest`] to register + /// a child router's routes under a prefix without mutating the originals. + /// + /// Route-level plugins are *not* carried over โ€” `TakoPlugin` is not `Clone`, + /// and the cloned route is treated as already-initialized so the empty + /// plugin list is never set up. Plugin-bearing routes should be registered + /// directly on the parent router after `nest`. + pub(crate) fn cloned_with_path(&self, new_path: String) -> Arc { + let cloned = Self { + path: new_path, + method: self.method.clone(), + handler: self.handler.clone(), + middlewares: ArcSwap::new(self.middlewares.load_full()), + has_middleware: AtomicBool::new(self.has_middleware.load(Ordering::Acquire)), + tsr: self.tsr, + #[cfg(feature = "plugins")] + plugins: RwLock::new(Vec::new()), + #[cfg(feature = "plugins")] + plugins_initialized: AtomicBool::new(true), + http_protocol: { + let lock = OnceLock::new(); + if let Some(v) = self.http_protocol.get() { + let _ = lock.set(*v); + } + lock + }, + #[cfg(feature = "signals")] + signals: SignalArbiter::new(), + #[cfg(any(feature = "utoipa", feature = "vespera"))] + openapi: RwLock::new(self.openapi.read().clone()), + timeout: { + let lock = OnceLock::new(); + if let Some(v) = self.timeout.get() { + let _ = lock.set(*v); + } + lock + }, + simd_json_mode: { + let lock = OnceLock::new(); + if let Some(v) = self.simd_json_mode.get() { + let _ = lock.set(*v); + } + lock + }, + }; + Arc::new(cloned) + } } diff --git a/tako-core/src/router.rs b/tako-core/src/router.rs index 5caeaac..410a872 100644 --- a/tako-core/src/router.rs +++ b/tako-core/src/router.rs @@ -49,6 +49,7 @@ use crate::middleware::Next; use crate::plugins::TakoPlugin; use crate::responder::Responder; use crate::route::Route; +use crate::router_state::RouterState; #[cfg(feature = "signals")] use crate::signals::Signal; #[cfg(feature = "signals")] @@ -97,6 +98,10 @@ pub struct Router { inner: MethodMap>>, /// An easy-to-iterate index of the same routes so we can access the `Arc` values. routes: MethodMap>>, + /// Optional path prefix prepended to every `route()` call while it is set. + /// Used by [`Router::mount_all_into`] and [`Router::scope`] (see v2 roadmap). + /// Only consulted at registration time โ€” zero cost on the dispatch hot path. + pending_prefix: Option, /// Global middleware chain applied to all routes. pub(crate) middlewares: ArcSwap>, /// Fast check: true when global middleware is registered (avoids ArcSwap load on hot path). @@ -118,6 +123,15 @@ pub struct Router { timeout_fallback: Option, /// Global error handler for 5xx responses. error_handler: Option, + /// Global error handler for 4xx responses (opt-in; runs after dispatch). + client_error_handler: Option, + /// Per-router typed state populated via [`Router::with_state`]. + /// `Arc` is shared with every dispatched request via the request extension + /// so the `State` extractor can read instance-local values. + router_state: Arc, + /// Fast-path flag: when `false`, dispatch skips the per-request Arc clone + + /// extension insert that wires `router_state` into requests. + has_router_state: AtomicBool, } impl Default for Router { @@ -134,6 +148,7 @@ impl Router { let router = Self { inner: MethodMap::new(), routes: MethodMap::new(), + pending_prefix: None, middlewares: ArcSwap::new(Arc::default()), has_global_middleware: AtomicBool::new(false), fallback: None, @@ -146,6 +161,9 @@ impl Router { timeout: None, timeout_fallback: None, error_handler: None, + client_error_handler: None, + router_state: Arc::new(RouterState::new()), + has_router_state: AtomicBool::new(false), }; #[cfg(feature = "signals")] @@ -191,8 +209,9 @@ impl Router { where H: Handler + Clone + 'static, { + let final_path = self.apply_pending_prefix(path); let route = Arc::new(Route::new( - path.to_string(), + final_path.clone(), method.clone(), BoxHandler::new::(handler), None, @@ -201,7 +220,7 @@ impl Router { if let Err(err) = self .inner .get_or_default_mut(&method) - .insert(path.to_string(), route.clone()) + .insert(final_path, route.clone()) { panic!("Failed to register route: {err}"); } @@ -214,6 +233,94 @@ impl Router { route } + /// Returns `path` with the active `pending_prefix` (if any) prepended. + /// Cold path; only runs at registration time. + fn apply_pending_prefix(&self, path: &str) -> String { + match &self.pending_prefix { + None => path.to_string(), + Some(prefix) => { + let prefix = prefix.trim_end_matches('/'); + if path.is_empty() || path == "/" { + if prefix.is_empty() { "/".to_string() } else { prefix.to_string() } + } else if path.starts_with('/') { + let mut s = String::with_capacity(prefix.len() + path.len()); + s.push_str(prefix); + s.push_str(path); + s + } else { + let mut s = String::with_capacity(prefix.len() + 1 + path.len()); + s.push_str(prefix); + s.push('/'); + s.push_str(path); + s + } + } + } + } + + /// Registers a `GET` route. Shorthand for [`Router::route`] with [`Method::GET`]. + #[inline] + pub fn get(&mut self, path: &str, handler: H) -> Arc + where + H: Handler + Clone + 'static, + { + self.route(Method::GET, path, handler) + } + + /// Registers a `POST` route. Shorthand for [`Router::route`] with [`Method::POST`]. + #[inline] + pub fn post(&mut self, path: &str, handler: H) -> Arc + where + H: Handler + Clone + 'static, + { + self.route(Method::POST, path, handler) + } + + /// Registers a `PUT` route. Shorthand for [`Router::route`] with [`Method::PUT`]. + #[inline] + pub fn put(&mut self, path: &str, handler: H) -> Arc + where + H: Handler + Clone + 'static, + { + self.route(Method::PUT, path, handler) + } + + /// Registers a `DELETE` route. Shorthand for [`Router::route`] with [`Method::DELETE`]. + #[inline] + pub fn delete(&mut self, path: &str, handler: H) -> Arc + where + H: Handler + Clone + 'static, + { + self.route(Method::DELETE, path, handler) + } + + /// Registers a `PATCH` route. Shorthand for [`Router::route`] with [`Method::PATCH`]. + #[inline] + pub fn patch(&mut self, path: &str, handler: H) -> Arc + where + H: Handler + Clone + 'static, + { + self.route(Method::PATCH, path, handler) + } + + /// Registers a `HEAD` route. Shorthand for [`Router::route`] with [`Method::HEAD`]. + #[inline] + pub fn head(&mut self, path: &str, handler: H) -> Arc + where + H: Handler + Clone + 'static, + { + self.route(Method::HEAD, path, handler) + } + + /// Registers an `OPTIONS` route. Shorthand for [`Router::route`] with [`Method::OPTIONS`]. + #[inline] + pub fn options(&mut self, path: &str, handler: H) -> Arc + where + H: Handler + Clone + 'static, + { + self.route(Method::OPTIONS, path, handler) + } + /// Registers every route declared via the `#[tako::route]` / `#[tako::get]` /// (and friends) attribute macros into this router. /// @@ -243,6 +350,144 @@ impl Router { self } + /// Like [`Router::mount_all`] but registers every macro-declared route under + /// the given path prefix. The prefix is normalized (trailing `/` stripped), + /// then prepended to each registered path. Useful when you want, e.g., all + /// `#[get("/users")]` declarations to live under `/api`. + /// + /// Ordering across crates remains the linker's choice (see + /// [`Router::mount_all`] for details). + /// + /// # Examples + /// + /// ```ignore + /// let mut router = Router::new(); + /// router.mount_all_into("/api"); // /users โ†’ /api/users, /health โ†’ /api/health + /// ``` + pub fn mount_all_into(&mut self, prefix: &str) -> &mut Self { + let saved = self.pending_prefix.take(); + self.pending_prefix = Some(prefix.to_string()); + for register in TAKO_ROUTES { + register(self); + } + self.pending_prefix = saved; + self + } + + /// Registers a group of routes under a shared path prefix. + /// + /// The closure receives `self` with the prefix active, so any `route()` / + /// `get()` / `post()` etc. calls inside register the routes with the prefix + /// prepended. Prefixes nest: a `scope("/v1", |r| r.scope("/users", โ€ฆ))` + /// produces routes under `/v1/users`. Cold path; no dispatch impact. + /// + /// # Examples + /// + /// ```rust + /// use tako::router::Router; + /// use tako::responder::Responder; + /// + /// async fn list_users() -> impl Responder { "users" } + /// async fn create_user() -> impl Responder { "created" } + /// + /// let mut router = Router::new(); + /// router.scope("/api/v1", |r| { + /// r.get("/users", list_users); + /// r.post("/users", create_user); + /// }); + /// ``` + pub fn scope(&mut self, prefix: &str, build: F) -> &mut Self + where + F: FnOnce(&mut Router), + { + let saved = self.pending_prefix.take(); + let new_prefix = match &saved { + Some(parent) => { + let parent = parent.trim_end_matches('/'); + if prefix.starts_with('/') { + format!("{parent}{prefix}") + } else { + format!("{parent}/{prefix}") + } + } + None => prefix.to_string(), + }; + self.pending_prefix = Some(new_prefix); + build(self); + self.pending_prefix = saved; + self + } + + /// Mounts every route from a child router under the given path prefix. + /// + /// Unlike [`Router::merge`], `nest` builds **new** `Arc` instances for + /// each child route via `Route::cloned_with_path` โ€” so re-nesting the same + /// child cannot double-stack its global middleware onto the same shared + /// `Arc`. The child router's global middleware chain is prepended to + /// each newly-registered route's middleware chain (so child globals run + /// before child-route middleware at dispatch time). + /// + /// Caveats: + /// - Route-level plugins on the child are **not** carried over. + /// - The child's fallback / error handlers are **not** inherited. + /// + /// # Examples + /// + /// ```rust + /// use tako::router::Router; + /// use tako::responder::Responder; + /// + /// async fn list_users() -> impl Responder { "users" } + /// + /// let mut api = Router::new(); + /// api.get("/users", list_users); + /// + /// let mut root = Router::new(); + /// root.nest("/api/v1", api); // /users โ†’ /api/v1/users + /// ``` + pub fn nest(&mut self, prefix: &str, child: Router) -> &mut Self { + let upstream_globals = child.middlewares.load_full(); + + for (method, weak_vec) in child.routes.iter() { + for weak in weak_vec { + let Some(child_route) = weak.upgrade() else { + continue; + }; + + let combined = combine_prefix_path(prefix, &child_route.path); + let new_path = self.apply_pending_prefix(&combined); + + let new_route = child_route.cloned_with_path(new_path.clone()); + + if !upstream_globals.is_empty() { + let existing = new_route.middlewares.load_full(); + let mut merged = Vec::with_capacity(upstream_globals.len() + existing.len()); + merged.extend(upstream_globals.iter().cloned()); + merged.extend(existing.iter().cloned()); + new_route.has_middleware.store(true, Ordering::Release); + new_route.middlewares.store(Arc::new(merged)); + } + + if let Err(err) = self + .inner + .get_or_default_mut(&method) + .insert(new_path, new_route.clone()) + { + panic!("Failed to nest route: {err}"); + } + self + .routes + .get_or_default_mut(&method) + .push(Arc::downgrade(&new_route)); + } + } + + #[cfg(feature = "signals")] + self.signals.merge_from(&child.signals); + + self + } + /// Registers a route with trailing slash redirection enabled. /// /// When TSR is enabled, requests to paths with or without trailing slashes @@ -275,8 +520,9 @@ impl Router { panic!("Cannot route with TSR for root path"); } + let final_path = self.apply_pending_prefix(path); let route = Arc::new(Route::new( - path.to_string(), + final_path.clone(), method.clone(), BoxHandler::new::(handler), Some(true), @@ -285,7 +531,7 @@ impl Router { if let Err(err) = self .inner .get_or_default_mut(&method) - .insert(path.to_string(), route.clone()) + .insert(final_path, route.clone()) { panic!("Failed to register route: {err}"); } @@ -369,6 +615,29 @@ impl Router { /// Dispatches an incoming request to the appropriate route handler. #[inline] pub async fn dispatch(&self, mut req: Request) -> Response { + // Per-router state: only inject when at least one `with_state` was called. + // The atomic load is monomorphic and cheap; the Arc clone (atomic incref) + // only happens for routers that actually use instance-local state. + if self.has_router_state.load(Ordering::Acquire) { + req.extensions_mut().insert(Arc::clone(&self.router_state)); + } + + // App-level request signal โ€” emitted here so every transport gets it for + // free without duplicating the boilerplate. The cost is a single string + // formatting pair per request and is gated to the `signals` feature. + #[cfg(feature = "signals")] + let (req_method_str, req_path_str) = + (req.method().to_string(), req.uri().path().to_string()); + #[cfg(feature = "signals")] + { + SignalArbiter::emit_app( + Signal::with_capacity(ids::REQUEST_STARTED, 2) + .meta("method", req_method_str.clone()) + .meta("path", req_path_str.clone()), + ) + .await; + } + // Phase 1: Route lookup using a borrowed path โ€” no String allocation on the // hot path. The block scope ensures all borrows on `req` are released before // we need to mutate it. @@ -427,12 +696,14 @@ impl Router { { let method_str = req.method().to_string(); let path_str = req.uri().path().to_string(); + let route_template = route.path.clone(); route_signals .emit( - Signal::with_capacity(ids::ROUTE_REQUEST_STARTED, 2) + Signal::with_capacity(ids::ROUTE_REQUEST_STARTED, 3) .meta("method", method_str.clone()) - .meta("path", path_str.clone()), + .meta("path", path_str.clone()) + .meta("route", route_template.clone()), ) .await; @@ -450,9 +721,10 @@ impl Router { route_signals .emit( - Signal::with_capacity(ids::ROUTE_REQUEST_COMPLETED, 3) + Signal::with_capacity(ids::ROUTE_REQUEST_COMPLETED, 4) .meta("method", method_str) .meta("path", path_str) + .meta("route", route_template) .meta("status", response.status().as_u16().to_string()), ) .await; @@ -475,7 +747,7 @@ impl Router { } } } else { - // Cold path: no direct match โ€” try TSR redirect / fallback. + // Cold path: no direct match โ€” try TSR redirect / 405 / fallback. // String allocation is acceptable here. let tsr_path = { let p = req.uri().path(); @@ -501,33 +773,72 @@ impl Router { self .run_with_global_middlewares_for_endpoint(req, BoxHandler::new::<_, (Request,)>(handler)) .await - } else if let Some(handler) = &self.fallback { - self - .run_with_global_middlewares_for_endpoint(req, handler.clone()) - .await } else { - let handler = |_req: Request| async { - http::Response::builder() - .status(StatusCode::NOT_FOUND) - .body(TakoBody::empty()) - .expect("valid 404 response") - }; + // Method-mismatch detection: if the same path is registered for any + // *other* method, RFC 9110 mandates 405 with an `Allow` header rather + // than 404. This is the cold path; iterating the 9 standard methods + // is cheap. + let allowed = self.collect_allowed_methods(req.uri().path()); + if !allowed.is_empty() { + let allow_value = join_methods(&allowed); + let handler = move |_req: Request| async move { + http::Response::builder() + .status(StatusCode::METHOD_NOT_ALLOWED) + .header(http::header::ALLOW, allow_value.clone()) + .body(TakoBody::empty()) + .expect("valid 405 response") + }; + self + .run_with_global_middlewares_for_endpoint(req, BoxHandler::new::<_, (Request,)>(handler)) + .await + } else if let Some(handler) = &self.fallback { + self + .run_with_global_middlewares_for_endpoint(req, handler.clone()) + .await + } else { + let handler = |_req: Request| async { + http::Response::builder() + .status(StatusCode::NOT_FOUND) + .body(TakoBody::empty()) + .expect("valid 404 response") + }; - self - .run_with_global_middlewares_for_endpoint(req, BoxHandler::new::<_, (Request,)>(handler)) - .await + self + .run_with_global_middlewares_for_endpoint(req, BoxHandler::new::<_, (Request,)>(handler)) + .await + } } }; - self.maybe_apply_error_handler(response) + let response = self.maybe_apply_error_handler(response); + + #[cfg(feature = "signals")] + { + SignalArbiter::emit_app( + Signal::with_capacity(ids::REQUEST_COMPLETED, 3) + .meta("method", req_method_str) + .meta("path", req_path_str) + .meta("status", response.status().as_u16().to_string()), + ) + .await; + } + + response } - /// Applies the global error handler if one is set and the response is a server error. + /// Applies the appropriate error handler if one is set: + /// - 5xx โ†’ [`Router::error_handler`] + /// - 4xx โ†’ [`Router::client_error_handler`] fn maybe_apply_error_handler(&self, response: Response) -> Response { - if response.status().is_server_error() { + let status = response.status(); + if status.is_server_error() { if let Some(handler) = &self.error_handler { return handler(response); } + } else if status.is_client_error() { + if let Some(handler) = &self.client_error_handler { + return handler(response); + } } response } @@ -559,6 +870,44 @@ impl Router { set_state(value); } + /// Inserts a value into this router's instance-local typed state. + /// + /// Unlike [`Router::state`] (which writes the process-global registry and + /// therefore allows only one value per `T` per process), `with_state` is + /// per-router โ€” multiple routers can hold distinct `T`s without collisions. + /// + /// The [`crate::extractors::state::State`] extractor reads the per-router + /// store first and falls back to the global store if no per-router value + /// exists, so existing code that uses `set_state` / `Router::state` + /// continues to work unchanged. + /// + /// Hot-path cost is one `Arc` clone per request *only when* at least one + /// `with_state` call has happened on this router; an `AtomicBool::Acquire` + /// fast-path skips it for routers that don't use instance-local state. + /// + /// # Examples + /// + /// ```rust + /// use tako::router::Router; + /// + /// #[derive(Clone)] + /// struct Db; + /// + /// let mut router = Router::new(); + /// router.with_state(Db); + /// ``` + pub fn with_state(&mut self, value: T) -> &mut Self { + self.router_state.insert(value); + self.has_router_state.store(true, Ordering::Release); + self + } + + /// Returns the per-router typed state (shared `Arc`). + #[inline] + pub fn router_state(&self) -> &Arc { + &self.router_state + } + #[cfg(feature = "signals")] /// Returns a reference to the signal arbiter. pub fn signals(&self) -> &SignalArbiter { @@ -628,9 +977,15 @@ impl Router { Box::pin(async move { fut.await.into_response() }) }); - let mut middlewares = self.middlewares.load().iter().cloned().collect::>(); - middlewares.push(mw); - self.middlewares.store(Arc::new(middlewares)); + // RCU-style append: rebuild the Vec atomically against concurrent pushers. + // ArcSwap retries the closure on CAS conflict, so concurrent middleware + // registrations cannot lose entries. + self.middlewares.rcu(move |current| { + let mut next = Vec::with_capacity(current.len() + 1); + next.extend(current.iter().cloned()); + next.push(mw.clone()); + Arc::new(next) + }); self.has_global_middleware.store(true, Ordering::Release); self } @@ -770,6 +1125,29 @@ impl Router { self } + /// Sets a global error handler for 4xx responses. + /// + /// Mirrors [`Router::error_handler`] but fires for client errors. Useful for + /// converting bare 404 / 405 / 422 responses into structured error documents + /// (e.g. via [`crate::problem::default_problem_responder`]). + pub fn client_error_handler( + &mut self, + handler: impl Fn(Response) -> Response + Send + Sync + 'static, + ) -> &mut Self { + self.client_error_handler = Some(Arc::new(handler)); + self + } + + /// Convenience: install [`crate::problem::default_problem_responder`] for + /// both 4xx and 5xx so unhandled errors always render as + /// `application/problem+json`. + pub fn use_problem_json(&mut self) -> &mut Self { + let h: ErrorHandler = Arc::new(crate::problem::default_problem_responder); + self.error_handler = Some(h.clone()); + self.client_error_handler = Some(h); + self + } + /// Registers a plugin with the router. /// /// Plugins extend the router's functionality by providing additional features @@ -900,6 +1278,20 @@ impl Router { self.signals.merge_from(&other.signals); } + /// Returns every method that has a route matching the given path. + /// + /// Used by the 405 / `Allow` cold-path branch in [`Router::dispatch`]; not on + /// the fast path. Iterates all standard methods (O(9)) plus any custom ones. + fn collect_allowed_methods(&self, path: &str) -> SmallVec<[Method; 4]> { + let mut allowed = SmallVec::<[Method; 4]>::new(); + for (method, m) in self.inner.iter() { + if m.at(path).is_ok() { + allowed.push(method); + } + } + allowed + } + /// Ensures the request HTTP version satisfies the route's configured protocol guard. /// Returns `Some(Response)` with 505 HTTP Version Not Supported when the request /// doesn't match the guard, otherwise returns `None` to continue dispatch. @@ -957,6 +1349,41 @@ impl Router { } } +/// Joins a path prefix and a child path, normalising the boundary slash. +fn combine_prefix_path(prefix: &str, path: &str) -> String { + if prefix.is_empty() || prefix == "/" { + return path.to_string(); + } + let prefix = prefix.trim_end_matches('/'); + if path.is_empty() || path == "/" { + return prefix.to_string(); + } + if path.starts_with('/') { + let mut out = String::with_capacity(prefix.len() + path.len()); + out.push_str(prefix); + out.push_str(path); + out + } else { + let mut out = String::with_capacity(prefix.len() + 1 + path.len()); + out.push_str(prefix); + out.push('/'); + out.push_str(path); + out + } +} + +/// Joins a slice of HTTP methods into a comma-separated `Allow`-header value. +fn join_methods(methods: &[Method]) -> String { + let mut out = String::with_capacity(methods.len() * 8); + for (i, m) in methods.iter().enumerate() { + if i > 0 { + out.push_str(", "); + } + out.push_str(m.as_str()); + } + out +} + /// Distributed slice of route registration thunks. /// /// Each `#[tako::route]` / `#[tako::get]` / etc. attribute contributes a diff --git a/tako-core/src/router_state.rs b/tako-core/src/router_state.rs new file mode 100644 index 0000000..d3680bd --- /dev/null +++ b/tako-core/src/router_state.rs @@ -0,0 +1,64 @@ +//! Per-router typed state container. +//! +//! Each [`crate::router::Router`] owns one [`RouterState`] (an `Arc<โ€ฆ>` +//! internally). Values inserted via [`crate::router::Router::with_state`] live +//! on the router instance โ€” multiple `Router`s in the same process can hold +//! distinct state values for the same `T`, which the historical process-wide +//! [`crate::state::set_state`] cannot do. +//! +//! [`crate::extractors::state::State`] reads from the request-scoped +//! `Arc` first (inserted by [`crate::router::Router::dispatch`]) +//! and falls back to [`crate::state::get_state`] if the per-router slot is +//! empty. Existing code that uses the global store keeps working unchanged. + +use std::any::Any; +use std::any::TypeId; +use std::sync::Arc; + +use scc::HashMap as SccHashMap; + +/// Type-keyed bag of values, lock-free for both reads and writes. +#[derive(Default)] +pub struct RouterState { + inner: SccHashMap>, +} + +impl std::fmt::Debug for RouterState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RouterState").finish_non_exhaustive() + } +} + +impl RouterState { + /// Construct an empty state container. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Insert (or replace) the value associated with `T`. + pub fn insert(&self, value: T) { + let _ = self + .inner + .insert_sync(TypeId::of::(), Arc::new(value)); + } + + /// Retrieve the value associated with `T`, if any. + pub fn get(&self) -> Option> { + self + .inner + .get_sync(&TypeId::of::()) + .map(|v| v.clone()) + .and_then(|v| v.downcast::().ok()) + } + + /// `true` when at least one value has been inserted. + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } + + /// Number of distinct types currently stored. + pub fn len(&self) -> usize { + self.inner.len() + } +} diff --git a/tako-core/src/signals.rs b/tako-core/src/signals.rs index 0a295f6..3192faf 100644 --- a/tako-core/src/signals.rs +++ b/tako-core/src/signals.rs @@ -612,3 +612,49 @@ impl SignalArbiter { ids } } + +/// Connection-lifecycle signal helpers used by every transport. +/// +/// `Router::dispatch` already emits the per-request `REQUEST_STARTED` / +/// `REQUEST_COMPLETED` signals automatically; these helpers cover the +/// connection-level events (`SERVER_STARTED`, `CONNECTION_OPENED`, +/// `CONNECTION_CLOSED`) that have no natural per-request hook. They keep the +/// emit boilerplate out of every transport file. +pub mod transport { + use super::Signal; + use super::SignalArbiter; + use super::ids; + + /// Emits the `server.started` signal with `addr` / `transport` / `tls` meta. + pub async fn emit_server_started(addr: &str, transport: &str, tls: bool) { + SignalArbiter::emit_app( + Signal::with_capacity(ids::SERVER_STARTED, 3) + .meta("addr", addr) + .meta("transport", transport) + .meta("tls", if tls { "true" } else { "false" }), + ) + .await; + } + + /// Emits the `connection.opened` signal with `remote_addr` / `tls` / optional `protocol`. + pub async fn emit_connection_opened(remote_addr: &str, tls: bool, protocol: Option<&str>) { + let mut sig = Signal::with_capacity(ids::CONNECTION_OPENED, 3) + .meta("remote_addr", remote_addr) + .meta("tls", if tls { "true" } else { "false" }); + if let Some(p) = protocol { + sig = sig.meta("protocol", p); + } + SignalArbiter::emit_app(sig).await; + } + + /// Emits the `connection.closed` signal with `remote_addr` / `tls` / optional `protocol`. + pub async fn emit_connection_closed(remote_addr: &str, tls: bool, protocol: Option<&str>) { + let mut sig = Signal::with_capacity(ids::CONNECTION_CLOSED, 3) + .meta("remote_addr", remote_addr) + .meta("tls", if tls { "true" } else { "false" }); + if let Some(p) = protocol { + sig = sig.meta("protocol", p); + } + SignalArbiter::emit_app(sig).await; + } +} diff --git a/tako-core/src/tls.rs b/tako-core/src/tls.rs new file mode 100644 index 0000000..61a769b --- /dev/null +++ b/tako-core/src/tls.rs @@ -0,0 +1,42 @@ +//! Shared TLS PEM loading helpers used by every TLS-capable Tako transport. +//! +//! Previously each `serve_tls*` / `serve_h3*` implementation carried its own +//! copy of `load_certs` / `load_key`, and `tako_streams::webtransport` reached +//! across crates into `tako_server::server_h3`. This module hosts the single +//! authoritative implementation. Both functions accept PKCS#8, PKCS#1 (RSA), +//! and SEC1 (EC) PEM blocks. + +use std::fs::File; +use std::io::BufReader; + +use rustls::pki_types::CertificateDer; +use rustls::pki_types::PrivateKeyDer; +use rustls_pemfile::certs; +use rustls_pemfile::private_key; + +/// Loads X.509 certificates from a PEM file. +pub fn load_certs(path: &str) -> anyhow::Result>> { + let mut rd = BufReader::new( + File::open(path).map_err(|e| anyhow::anyhow!("failed to open cert file '{}': {}", path, e))?, + ); + certs(&mut rd) + .collect::, _>>() + .map_err(|e| anyhow::anyhow!("failed to parse certs from '{}': {}", path, e)) +} + +/// Loads the first PEM-encoded private key from a file. +/// +/// Accepts PKCS#8, PKCS#1 (RSA), and SEC1 (EC) PEM blocks. +pub fn load_key(path: &str) -> anyhow::Result> { + let mut rd = BufReader::new( + File::open(path).map_err(|e| anyhow::anyhow!("failed to open key file '{}': {}", path, e))?, + ); + private_key(&mut rd) + .map_err(|e| anyhow::anyhow!("bad private key in '{}': {}", path, e))? + .ok_or_else(|| { + anyhow::anyhow!( + "no PEM private key (PKCS#8, PKCS#1 or SEC1) found in '{}'", + path + ) + }) +} diff --git a/tako-core/src/tracing.rs b/tako-core/src/tracing.rs index 7dfbccb..dabaa0f 100644 --- a/tako-core/src/tracing.rs +++ b/tako-core/src/tracing.rs @@ -18,15 +18,24 @@ pub fn set_tracing_level(level_filter: LevelFilter) { } /// Initializes the global tracing subscriber with formatted output. +/// +/// Idempotent: calling more than once (e.g. when several `serve_*` entry +/// points run in the same process) is a no-op after the first install. This +/// avoids the `SetGlobalDefaultError` panic the previous unconditional +/// `init()` produced under the `Server::builder` integration tests. pub fn init_tracing() { - tracing_subscriber::registry() - .with( - tracing_subscriber::fmt::layer() - .with_span_events(FmtSpan::CLOSE) - .with_file(true) - .with_line_number(true) - .with_level(true) - .with_filter(unsafe { TRACING_LEVEL }), - ) - .init(); + use std::sync::Once; + static INIT: Once = Once::new(); + INIT.call_once(|| { + let _ = tracing_subscriber::registry() + .with( + tracing_subscriber::fmt::layer() + .with_span_events(FmtSpan::CLOSE) + .with_file(true) + .with_line_number(true) + .with_level(true) + .with_filter(unsafe { TRACING_LEVEL }), + ) + .try_init(); + }); } diff --git a/tako-extractors/src/state.rs b/tako-extractors/src/state.rs index 18bc84e..e21f4df 100644 --- a/tako-extractors/src/state.rs +++ b/tako-extractors/src/state.rs @@ -25,6 +25,7 @@ use http::request::Parts; use tako_core::extractors::FromRequest; use tako_core::extractors::FromRequestParts; use tako_core::responder::Responder; +use tako_core::router_state::RouterState; use tako_core::state::get_state; use tako_core::types::Request; @@ -50,6 +51,17 @@ impl Responder for MissingState { } } +/// Reads `T` from the per-router typed state first (when the router was set +/// up via `Router::with_state`), falling back to the process-global registry. +fn lookup(extensions: &http::Extensions) -> Option> { + if let Some(rs) = extensions.get::>() { + if let Some(arc) = rs.get::() { + return Some(arc); + } + } + get_state::() +} + impl<'a, T> FromRequest<'a> for State where T: Send + Sync + 'static, @@ -57,9 +69,9 @@ where type Error = MissingState; fn from_request( - _req: &'a mut Request, + req: &'a mut Request, ) -> impl core::future::Future> + Send + 'a { - futures_util::future::ready(match get_state::() { + futures_util::future::ready(match lookup::(req.extensions()) { Some(arc) => Ok(Self(arc)), None => Err(MissingState), }) @@ -73,9 +85,9 @@ where type Error = MissingState; fn from_request_parts( - _parts: &'a mut Parts, + parts: &'a mut Parts, ) -> impl core::future::Future> + Send + 'a { - futures_util::future::ready(match get_state::() { + futures_util::future::ready(match lookup::(&parts.extensions) { Some(arc) => Ok(Self(arc)), None => Err(MissingState), }) diff --git a/tako-macros/src/lib.rs b/tako-macros/src/lib.rs index ff7df8b..75871fe 100644 --- a/tako-macros/src/lib.rs +++ b/tako-macros/src/lib.rs @@ -109,12 +109,16 @@ struct PathParam { ty: Type, } -/// Parses `"/users/{id: u64}/posts/{post_id: u64}"` into the matchit-friendly -/// stripped path `"/users/{id}/posts/{post_id}"` plus a list of `(name, type)` -/// pairs. +/// Parses path placeholders. Two syntaxes are accepted: +/// - typed: `{id: u64}` โ€” emits a field on the generated `*Params` struct +/// - untyped: `{id}` โ€” matchit/axum-style; passes through untouched and does +/// not contribute to the `*Params` struct +/// +/// Returns the matchit-friendly stripped path (every placeholder reduced to +/// `{name}`) plus the list of typed `(name, type)` pairs only. fn parse_path(path: &str, span: Span) -> syn::Result<(String, Vec)> { let mut stripped = String::with_capacity(path.len()); - let mut params = Vec::new(); + let mut typed = Vec::new(); let bytes = path.as_bytes(); let mut i = 0; while i < bytes.len() { @@ -128,31 +132,40 @@ fn parse_path(path: &str, span: Span) -> syn::Result<(String, Vec)> { .find(|&j| bytes[j] == b'}') .ok_or_else(|| syn::Error::new(span, "unclosed '{' in path"))?; let inner = &path[i + 1..close]; - let (name_str, ty_str) = inner.split_once(':').ok_or_else(|| { - syn::Error::new( - span, - format!("placeholder '{{{inner}}}' must be 'name: Type'"), - ) - })?; - let name: Ident = parse_str(name_str.trim()).map_err(|e| { - syn::Error::new( - span, - format!("invalid placeholder name '{}': {e}", name_str.trim()), - ) - })?; - let ty: Type = parse_str(ty_str.trim()).map_err(|e| { - syn::Error::new( - span, - format!("invalid placeholder type '{}': {e}", ty_str.trim()), - ) - })?; - stripped.push('{'); - stripped.push_str(&name.to_string()); - stripped.push('}'); - params.push(PathParam { name, ty }); + match inner.split_once(':') { + Some((name_str, ty_str)) => { + let name: Ident = parse_str(name_str.trim()).map_err(|e| { + syn::Error::new( + span, + format!("invalid placeholder name '{}': {e}", name_str.trim()), + ) + })?; + let ty: Type = parse_str(ty_str.trim()).map_err(|e| { + syn::Error::new( + span, + format!("invalid placeholder type '{}': {e}", ty_str.trim()), + ) + })?; + stripped.push('{'); + stripped.push_str(&name.to_string()); + stripped.push('}'); + typed.push(PathParam { name, ty }); + } + None => { + let name: Ident = parse_str(inner.trim()).map_err(|e| { + syn::Error::new( + span, + format!("invalid placeholder name '{}': {e}", inner.trim()), + ) + })?; + stripped.push('{'); + stripped.push_str(&name.to_string()); + stripped.push('}'); + } + } i = close + 1; } - Ok((stripped, params)) + Ok((stripped, typed)) } /// snake_case โ†’ PascalCase. `get_user` โ†’ `GetUser`. ASCII only, which is @@ -175,6 +188,10 @@ fn pascal_case(s: &str) -> String { /// Shared expansion: given a method ident, a path literal, an optional struct /// name override, and the handler fn, produce the generated tokens. +/// +/// Only emits the `*Params` struct when the path contains at least one typed +/// placeholder (`{id: u64}`). Pure-static or untyped-only paths skip the +/// struct entirely and just register the route. fn expand_route( method: Ident, path: LitStr, @@ -189,6 +206,49 @@ fn expand_route( }; let fn_name = &func.sig.ident; + let registrar_ident = format_ident!( + "__TAKO_REGISTER_{}", + fn_name.to_string().to_uppercase(), + span = fn_name.span() + ); + + // No typed placeholders. + if params.is_empty() { + // Explicit `name = "..."` keeps emitting a unit marker struct so callers + // can still reference `Name::METHOD` / `Name::PATH`. Without an override + // we skip the struct entirely. + if let Some(struct_name) = name_override { + let expanded: TokenStream2 = quote! { + pub struct #struct_name; + + impl #struct_name { + pub const METHOD: ::tako::Method = ::tako::Method::#method; + pub const PATH: &'static str = #stripped; + } + + #[::tako::__private::linkme::distributed_slice(::tako::router::TAKO_ROUTES)] + #[linkme(crate = ::tako::__private::linkme)] + static #registrar_ident: fn(&mut ::tako::router::Router) = |__router| { + __router.route(#struct_name::METHOD, #struct_name::PATH, #fn_name); + }; + + #func + }; + return expanded.into(); + } + + let expanded: TokenStream2 = quote! { + #[::tako::__private::linkme::distributed_slice(::tako::router::TAKO_ROUTES)] + #[linkme(crate = ::tako::__private::linkme)] + static #registrar_ident: fn(&mut ::tako::router::Router) = |__router| { + __router.route(::tako::Method::#method, #stripped, #fn_name); + }; + + #func + }; + return expanded.into(); + } + let struct_name = name_override.unwrap_or_else(|| { format_ident!( "{}Params", @@ -196,11 +256,6 @@ fn expand_route( span = fn_name.span() ) }); - let registrar_ident = format_ident!( - "__TAKO_REGISTER_{}", - fn_name.to_string().to_uppercase(), - span = fn_name.span() - ); let field_idents: Vec<&Ident> = params.iter().map(|p| &p.name).collect(); let field_names_str: Vec = params.iter().map(|p| p.name.to_string()).collect(); diff --git a/tako-plugins/Cargo.toml b/tako-plugins/Cargo.toml index 2ac2fcf..54f8035 100644 --- a/tako-plugins/Cargo.toml +++ b/tako-plugins/Cargo.toml @@ -35,11 +35,13 @@ serde.workspace = true serde_json.workspace = true sha1.workspace = true smallvec.workspace = true +subtle.workspace = true tokio.workspace = true tokio-stream.workspace = true tracing.workspace = true url.workspace = true urlencoding.workspace = true +uuid.workspace = true # Optional / feature-gated ahash = { workspace = true, optional = true } @@ -51,7 +53,6 @@ prometheus = { workspace = true, optional = true } opentelemetry = { workspace = true, optional = true } opentelemetry_sdk = { workspace = true, optional = true } opentelemetry-otlp = { workspace = true, optional = true } -uuid = { workspace = true, optional = true } zstd = { workspace = true, optional = true } [target.'cfg(target_os = "freebsd")'.dependencies] @@ -63,7 +64,7 @@ jwt-simple = { version = "0.12.12", optional = true } [features] default = [] plugins = ["dep:brotli", "dep:flate2", "tako-core/plugins"] -multipart = ["dep:multer", "dep:uuid", "tako-extractors/multipart"] +multipart = ["dep:multer", "tako-extractors/multipart"] metrics-prometheus = ["dep:prometheus", "plugins", "signals"] metrics-opentelemetry = ["dep:opentelemetry", "dep:opentelemetry_sdk", "dep:opentelemetry-otlp", "plugins", "signals"] signals = ["tako-core/signals"] diff --git a/tako-plugins/src/middleware/api_key_auth.rs b/tako-plugins/src/middleware/api_key_auth.rs index f2edcff..a5e0c86 100644 --- a/tako-plugins/src/middleware/api_key_auth.rs +++ b/tako-plugins/src/middleware/api_key_auth.rs @@ -32,7 +32,6 @@ //! ``` use std::borrow::Cow; -use std::collections::HashSet; use std::future::Future; use std::pin::Pin; use std::sync::Arc; @@ -40,15 +39,29 @@ use std::sync::Arc; use http::HeaderValue; use http::StatusCode; use http::header; +use subtle::Choice; +use subtle::ConstantTimeEq; use tako_core::body::TakoBody; use tako_core::middleware::IntoMiddleware; use tako_core::middleware::Next; use tako_core::responder::Responder; -use tako_core::types::BuildHasher; use tako_core::types::Request; use tako_core::types::Response; +/// Constant-time match against a list of candidate keys. +/// +/// Iterates the full list every call; per-byte comparison uses `subtle::ConstantTimeEq` +/// so equal-length matches do not leak via wall-clock. Length mismatches still return +/// faster than equal-length compares โ€” clients learn key length but not contents. +fn constant_time_contains(input: &[u8], candidates: &[Vec]) -> bool { + let mut found = Choice::from(0u8); + for candidate in candidates { + found |= input.ct_eq(candidate.as_slice()); + } + bool::from(found) +} + /// Location where the API key should be extracted from. #[derive(Clone)] pub enum ApiKeyLocation { @@ -90,8 +103,8 @@ impl Default for ApiKeyLocation { /// }); /// ``` pub struct ApiKeyAuth { - /// Static API key set for quick validation. - keys: Option>, + /// Static API keys (raw bytes, scanned in constant time). + keys: Option>>, /// Custom verification function for dynamic key validation. verify: Option bool + Send + Sync + 'static>>, /// Location to extract the API key from. @@ -103,10 +116,9 @@ impl ApiKeyAuth { /// /// By default, the key is extracted from the `X-API-Key` header. pub fn new(key: impl Into) -> Self { - let mut set: HashSet = HashSet::with_hasher(BuildHasher::default()); - set.insert(key.into()); + let key: String = key.into(); Self { - keys: Some(set), + keys: Some(vec![key.into_bytes()]), verify: None, location: ApiKeyLocation::default(), } @@ -119,7 +131,12 @@ impl ApiKeyAuth { I::Item: Into, { Self { - keys: Some(keys.into_iter().map(Into::into).collect()), + keys: Some( + keys + .into_iter() + .map(|k| Into::::into(k).into_bytes()) + .collect(), + ), verify: None, location: ApiKeyLocation::default(), } @@ -145,7 +162,12 @@ impl ApiKeyAuth { F: Fn(&str) -> bool + Send + Sync + 'static, { Self { - keys: Some(keys.into_iter().map(Into::into).collect()), + keys: Some( + keys + .into_iter() + .map(|k| Into::::into(k).into_bytes()) + .collect(), + ), verify: Some(Arc::new(f)), location: ApiKeyLocation::default(), } @@ -248,9 +270,9 @@ impl IntoMiddleware for ApiKeyAuth { } }; - // Validate against static keys + // Validate against static keys (constant-time scan) if let Some(set) = &keys { - if set.contains(api_key.as_ref()) { + if constant_time_contains(api_key.as_bytes(), set) { return next.run(req).await.into_response(); } } diff --git a/tako-plugins/src/middleware/bearer_auth.rs b/tako-plugins/src/middleware/bearer_auth.rs index 64eb757..0340b7f 100644 --- a/tako-plugins/src/middleware/bearer_auth.rs +++ b/tako-plugins/src/middleware/bearer_auth.rs @@ -36,7 +36,6 @@ //! }); //! ``` -use std::collections::HashSet; use std::future::Future; use std::pin::Pin; use std::sync::Arc; @@ -44,15 +43,24 @@ use std::sync::Arc; use http::HeaderValue; use http::StatusCode; use http::header; - +use subtle::Choice; +use subtle::ConstantTimeEq; use tako_core::body::TakoBody; use tako_core::middleware::IntoMiddleware; use tako_core::middleware::Next; use tako_core::responder::Responder; -use tako_core::types::BuildHasher; use tako_core::types::Request; use tako_core::types::Response; +/// Constant-time match against a list of candidate tokens. See `api_key_auth` for rationale. +fn constant_time_contains(input: &[u8], candidates: &[Vec]) -> bool { + let mut found = Choice::from(0u8); + for candidate in candidates { + found |= input.ct_eq(candidate.as_slice()); + } + bool::from(found) +} + /// Bearer token authentication middleware configuration. /// /// `BearerAuth` provides flexible configuration for Bearer token authentication using either @@ -98,8 +106,8 @@ use tako_core::types::Response; /// }); /// ``` pub struct BearerAuth { - /// Static token set for quick validation. - tokens: Option>, + /// Static tokens (raw bytes, scanned in constant time). + tokens: Option>>, /// Custom verification function for dynamic token validation. verify: Option bool + Send + Sync + 'static>>, } @@ -109,10 +117,9 @@ pub struct BearerAuth { impl BearerAuth { /// Creates authentication middleware with a single static token. pub fn static_token(token: impl Into) -> Self { - let mut set: HashSet = HashSet::with_hasher(BuildHasher::default()); - set.insert(token.into()); + let token: String = token.into(); Self { - tokens: Some(set), + tokens: Some(vec![token.into_bytes()]), verify: None, } } @@ -124,7 +131,12 @@ impl BearerAuth { I::Item: Into, { Self { - tokens: Some(tokens.into_iter().map(Into::into).collect()), + tokens: Some( + tokens + .into_iter() + .map(|t| Into::::into(t).into_bytes()) + .collect(), + ), verify: None, } } @@ -148,7 +160,12 @@ impl BearerAuth { F: Fn(&str) -> bool + Clone + Send + Sync + 'static, { Self { - tokens: Some(tokens.into_iter().map(Into::into).collect()), + tokens: Some( + tokens + .into_iter() + .map(|t| Into::::into(t).into_bytes()) + .collect(), + ), verify: Some(Box::new(f)), } } @@ -192,9 +209,9 @@ impl IntoMiddleware for BearerAuth { .into_response(); } Some(t) => { - // Check static token set first + // Check static tokens (constant-time scan) if let Some(set) = &tokens - && set.contains(t) + && constant_time_contains(t.as_bytes(), set) { return next.run(req).await.into_response(); } diff --git a/tako-plugins/src/middleware/body_limit.rs b/tako-plugins/src/middleware/body_limit.rs index a21c7ce..b807432 100644 --- a/tako-plugins/src/middleware/body_limit.rs +++ b/tako-plugins/src/middleware/body_limit.rs @@ -40,10 +40,9 @@ use std::future::Future; use std::pin::Pin; use std::sync::Arc; -use bytes::Bytes; use http::StatusCode; use http::header::CONTENT_LENGTH; -use http_body_util::BodyExt; +use http_body_util::Limited; use tako_core::body::TakoBody; use tako_core::middleware::IntoMiddleware; @@ -158,19 +157,13 @@ where return (StatusCode::PAYLOAD_TOO_LARGE, "Body exceeds allowed size").into_response(); } - // Runtime body size enforcement: collect body and check actual size. - // This catches chunked/streaming bodies that bypass Content-Length checks. + // Stream-aware enforcement: wrap the body in `Limited` so reads past `limit` + // produce a `LengthLimitError` instead of buffering the entire body up-front. + // Handlers that consume the body see a normal body error and can return 413 + // via `LengthLimitError` downcasting. let (parts, body) = req.into_parts(); - let collected = match body.collect().await { - Ok(c) => c.to_bytes(), - Err(_) => { - return (StatusCode::BAD_REQUEST, "Failed to read request body").into_response(); - } - }; - if collected.len() > limit { - return (StatusCode::PAYLOAD_TOO_LARGE, "Body exceeds allowed size").into_response(); - } - let req = http::Request::from_parts(parts, TakoBody::from(Bytes::from(collected))); + let limited = Limited::new(body, limit); + let req = http::Request::from_parts(parts, TakoBody::new(limited)); next.run(req).await.into_response() }) } diff --git a/tako-plugins/src/middleware/csrf.rs b/tako-plugins/src/middleware/csrf.rs index 27bd736..36957d5 100644 --- a/tako-plugins/src/middleware/csrf.rs +++ b/tako-plugins/src/middleware/csrf.rs @@ -78,14 +78,8 @@ impl Csrf { } fn generate_csrf_token() -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - let a = now.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); - let b = a.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); - format!("{:016x}{:016x}", a & 0xFFFFFFFFFFFFFFFF, b & 0xFFFFFFFFFFFFFFFF) + // Cryptographically secure random CSRF token (UUID v4 backed by getrandom). + uuid::Uuid::new_v4().simple().to_string() } fn is_unsafe_method(method: &Method) -> bool { diff --git a/tako-plugins/src/middleware/request_id.rs b/tako-plugins/src/middleware/request_id.rs index f6eb0fc..d9082c4 100644 --- a/tako-plugins/src/middleware/request_id.rs +++ b/tako-plugins/src/middleware/request_id.rs @@ -51,23 +51,7 @@ impl RequestId { pub fn new() -> Self { Self { header: HeaderName::from_static("x-request-id"), - generator: Arc::new(|| { - // Simple UUID v4 generation without dependency - use std::time::{SystemTime, UNIX_EPOCH}; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default(); - let seed = now.as_nanos(); - // Simple pseudo-random ID (good enough for request correlation) - format!( - "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}", - (seed & 0xFFFFFFFF) as u32, - ((seed >> 32) & 0xFFFF) as u16, - ((seed >> 48) & 0x0FFF) as u16, - (0x8000 | ((seed >> 60) & 0x3FFF)) as u16, - (seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407) & 0xFFFFFFFFFFFF) as u64, - ) - }), + generator: Arc::new(|| uuid::Uuid::new_v4().to_string()), } } diff --git a/tako-plugins/src/middleware/session.rs b/tako-plugins/src/middleware/session.rs index 5d76759..731a5de 100644 --- a/tako-plugins/src/middleware/session.rs +++ b/tako-plugins/src/middleware/session.rs @@ -166,14 +166,8 @@ impl SessionMiddleware { } fn generate_session_id() -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - let a = now.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); - let b = a.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); - format!("{:032x}{:032x}", a, b) + // Cryptographically secure random session ID (UUID v4 backed by getrandom). + uuid::Uuid::new_v4().simple().to_string() } fn extract_cookie_value<'a>(req: &'a Request, cookie_name: &str) -> Option<&'a str> { diff --git a/tako-plugins/src/middleware/upload_progress.rs b/tako-plugins/src/middleware/upload_progress.rs index cf54be0..e91ebb4 100644 --- a/tako-plugins/src/middleware/upload_progress.rs +++ b/tako-plugins/src/middleware/upload_progress.rs @@ -26,9 +26,20 @@ use std::future::Future; use std::pin::Pin; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; +use std::task::Context; +use std::task::Poll; +use bytes::Bytes; +use http_body::Body; +use http_body::Frame; +use http_body::SizeHint; +use parking_lot::Mutex; +use pin_project_lite::pin_project; + +use tako_core::body::TakoBody; use tako_core::middleware::IntoMiddleware; use tako_core::middleware::Next; +use tako_core::types::BoxError; use tako_core::types::Request; use tako_core::types::Response; @@ -147,6 +158,86 @@ impl UploadProgress { } } +pin_project! { + /// Body wrapper that tracks bytes read frame-by-frame without buffering. + /// + /// Increments the shared counter as each data frame flows through and fires + /// the optional callback when the configured byte interval is exceeded. Errors + /// and end-of-stream are forwarded transparently. + struct ProgressBody { + #[pin] + inner: B, + bytes_read: Arc, + total_bytes: Option, + last_notified_at: u64, + min_interval: u64, + callback: Option>, + final_notified: Arc>, + } +} + +impl Body for ProgressBody +where + B: Body, + B::Error: Into, +{ + type Data = Bytes; + type Error = BoxError; + + fn poll_frame( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + let this = self.project(); + match this.inner.poll_frame(cx) { + Poll::Ready(Some(Ok(frame))) => { + if let Some(data) = frame.data_ref() { + let added = data.len() as u64; + let total = this.bytes_read.fetch_add(added, Ordering::Relaxed) + added; + if let Some(cb) = this.callback.as_ref() + && (*this.min_interval == 0 || total - *this.last_notified_at >= *this.min_interval) + { + *this.last_notified_at = total; + cb(ProgressState { + bytes_read: total, + total_bytes: *this.total_bytes, + }); + } + } + Poll::Ready(Some(Ok(frame))) + } + Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e.into()))), + Poll::Ready(None) => { + // Fire a final callback exactly once when the body ends, so callers see + // the closing total even if the last interval did not trigger a notify. + if let Some(cb) = this.callback.as_ref() { + let mut guard = this.final_notified.lock(); + if !*guard { + *guard = true; + let final_read = this.bytes_read.load(Ordering::Relaxed); + if final_read != *this.last_notified_at { + cb(ProgressState { + bytes_read: final_read, + total_bytes: *this.total_bytes, + }); + } + } + } + Poll::Ready(None) + } + Poll::Pending => Poll::Pending, + } + } + + fn is_end_stream(&self) -> bool { + self.inner.is_end_stream() + } + + fn size_hint(&self) -> SizeHint { + self.inner.size_hint() + } +} + impl IntoMiddleware for UploadProgress { fn into_middleware( self, @@ -178,55 +269,18 @@ impl IntoMiddleware for UploadProgress { }; req.extensions_mut().insert(tracker); - // Collect body while tracking progress - use http_body_util::BodyExt; - let body = req.body_mut(); - let mut collected = Vec::new(); - let mut last_notified_at: u64 = 0; - - // Use frame-by-frame collection for progress reporting - loop { - match body.frame().await { - Some(Ok(frame)) => { - if let Some(data) = frame.data_ref() { - collected.extend_from_slice(data); - let total = bytes_read.fetch_add(data.len() as u64, Ordering::Relaxed) - + data.len() as u64; - - // Fire callback if interval threshold met - if let Some(cb) = &callback { - if min_interval == 0 || total - last_notified_at >= min_interval { - last_notified_at = total; - cb(ProgressState { - bytes_read: total, - total_bytes, - }); - } - } - } - } - Some(Err(_)) => break, - None => break, - } - } - - // Final notification - if let Some(cb) = &callback { - let final_read = bytes_read.load(Ordering::Relaxed); - if final_read != last_notified_at { - cb(ProgressState { - bytes_read: final_read, - total_bytes, - }); - } - } - - // Reconstruct request with collected body - let (parts, _) = req.into_parts(); - let req = http::Request::from_parts( - parts, - tako_core::body::TakoBody::from(bytes::Bytes::from(collected)), - ); + // Wrap the body in a streaming progress tracker โ€” no buffering. + let (parts, body) = req.into_parts(); + let progress_body = ProgressBody { + inner: body, + bytes_read, + total_bytes, + last_notified_at: 0, + min_interval, + callback, + final_notified: Arc::new(Mutex::new(false)), + }; + let req = http::Request::from_parts(parts, TakoBody::new(progress_body)); next.run(req).await }) diff --git a/tako-plugins/src/plugins/compression.rs b/tako-plugins/src/plugins/compression.rs index b59d9f1..d6cab30 100644 --- a/tako-plugins/src/plugins/compression.rs +++ b/tako-plugins/src/plugins/compression.rs @@ -380,7 +380,6 @@ impl TakoPlugin for CompressionPlugin { /// to clients. It's more memory-intensive than streaming compression but may have /// better compression ratios for smaller responses. async fn compress_middleware(req: Request, next: Next, cfg: Config) -> impl Responder { - // Parse the `Accept-Encoding` header to determine supported encodings. let accepted = req .headers() .get(ACCEPT_ENCODING) @@ -414,6 +413,11 @@ async fn compress_middleware(req: Request, next: Next, cfg: Config) -> impl Resp } } + // The response is now compression-eligible. Always advertise that the + // representation depends on `Accept-Encoding` so caches don't serve a + // wrongly-encoded variant to a peer with different `Accept-Encoding`. + ensure_vary_accept_encoding(resp.headers_mut()); + // Collect the response body and check its size. let body_bytes = match resp.body_mut().collect().await { Ok(c) => c.to_bytes(), @@ -450,9 +454,6 @@ async fn compress_middleware(req: Request, next: Next, cfg: Config) -> impl Resp .headers_mut() .insert(CONTENT_ENCODING, HeaderValue::from_static(enc.as_str())); resp.headers_mut().remove(CONTENT_LENGTH); - resp - .headers_mut() - .insert(VARY, HeaderValue::from_static("Accept-Encoding")); } else { *resp.body_mut() = TakoBody::from(Bytes::from(body_bytes)); } @@ -500,6 +501,10 @@ pub async fn compress_stream_middleware(req: Request, next: Next, cfg: Config) - } } + // The response is compression-eligible: advertise Vary regardless of whether we + // actually apply an encoding, so caches key on `Accept-Encoding`. + ensure_vary_accept_encoding(resp.headers_mut()); + // Estimate size from `Content-Length`. if let Some(len) = resp .headers() @@ -526,37 +531,97 @@ pub async fn compress_stream_middleware(req: Request, next: Next, cfg: Config) - .headers_mut() .insert(CONTENT_ENCODING, HeaderValue::from_static(enc.as_str())); resp.headers_mut().remove(CONTENT_LENGTH); - resp - .headers_mut() - .insert(VARY, HeaderValue::from_static("Accept-Encoding")); } resp.into_response() } +/// Appends `Accept-Encoding` to the `Vary` header without duplicating it. +/// +/// `Vary: Accept-Encoding` is required on every compression-eligible response +/// so shared caches don't serve a wrongly-encoded representation to a different +/// client. +fn ensure_vary_accept_encoding(headers: &mut http::HeaderMap) { + let already_present = headers.get_all(VARY).iter().any(|v| { + v.to_str().map(|s| { + s.split(',') + .any(|tok| tok.trim().eq_ignore_ascii_case("Accept-Encoding")) + }).unwrap_or(false) + }); + if !already_present { + headers.append(VARY, HeaderValue::from_static("Accept-Encoding")); + } +} + /// Selects the best compression encoding based on client preferences and server capabilities. /// -/// This function parses the Accept-Encoding header and chooses the most preferred -/// compression algorithm that is both supported by the client and enabled on the server. -/// The selection prioritizes compression quality while respecting client preferences. +/// Honors RFC 9110 quality values: a token with `q=0` is rejected, an unlisted +/// token defers to the wildcard `*` if present, otherwise it is unacceptable. +/// Server preference order is `br > gzip > deflate > zstd`. fn choose_encoding(header: &str, enabled: &[Encoding]) -> Option { - let header = header.to_ascii_lowercase(); - let test = |e: Encoding| header.contains(e.as_str()) && enabled.contains(&e); - if test(Encoding::Brotli) { - Some(Encoding::Brotli) - } else if test(Encoding::Gzip) { - Some(Encoding::Gzip) - } else if test(Encoding::Deflate) { - Some(Encoding::Deflate) - } else { - #[cfg(feature = "zstd")] - { - if test(Encoding::Zstd) { - return Some(Encoding::Zstd); - } + let parsed = parse_accept_encoding(header); + // Pull `*` once โ€” it determines acceptance of any encoding not listed explicitly. + let wildcard_q = parsed + .iter() + .find(|(c, _)| c == "*") + .map(|(_, q)| *q); + + let acceptable = |enc: Encoding| -> bool { + let name = enc.as_str(); + match parsed.iter().find(|(c, _)| c == name) { + Some((_, q)) => *q > 0.0, + None => wildcard_q.map(|q| q > 0.0).unwrap_or(false), + } + }; + + // Server preference order โ€” Brotli first for ratio, Gzip second for compatibility. + let server_order: [Encoding; 3] = [Encoding::Brotli, Encoding::Gzip, Encoding::Deflate]; + for enc in server_order { + if enabled.contains(&enc) && acceptable(enc) { + return Some(enc); + } + } + + #[cfg(feature = "zstd")] + { + if enabled.contains(&Encoding::Zstd) && acceptable(Encoding::Zstd) { + return Some(Encoding::Zstd); } - None } + + None +} + +/// Parses an `Accept-Encoding` header into `(token, q)` pairs. +/// +/// Tokens are lowercased; `q=` is honored, defaulting to `1.0` when absent and +/// falling back to `1.0` on parse errors. +fn parse_accept_encoding(header: &str) -> Vec<(String, f32)> { + header + .split(',') + .filter_map(|piece| { + let piece = piece.trim(); + if piece.is_empty() { + return None; + } + let mut parts = piece.split(';'); + let coding = parts.next()?.trim().to_ascii_lowercase(); + if coding.is_empty() { + return None; + } + let mut q: f32 = 1.0; + for param in parts { + let param = param.trim(); + let qv = param + .strip_prefix("q=") + .or_else(|| param.strip_prefix("Q=")); + if let Some(qv) = qv { + q = qv.parse().unwrap_or(1.0); + } + } + Some((coding, q)) + }) + .collect() } /// Compresses data using Gzip algorithm. diff --git a/tako-plugins/src/plugins/cors.rs b/tako-plugins/src/plugins/cors.rs index 7e3a2f4..36dc2bc 100644 --- a/tako-plugins/src/plugins/cors.rs +++ b/tako-plugins/src/plugins/cors.rs @@ -47,6 +47,8 @@ //! router.route(Method::GET, "/public", public_handler); //! ``` +use std::fmt; + use anyhow::Result; use http::HeaderName; use http::HeaderValue; @@ -57,7 +59,9 @@ use http::header::ACCESS_CONTROL_ALLOW_HEADERS; use http::header::ACCESS_CONTROL_ALLOW_METHODS; use http::header::ACCESS_CONTROL_ALLOW_ORIGIN; use http::header::ACCESS_CONTROL_MAX_AGE; +use http::header::ACCESS_CONTROL_REQUEST_HEADERS; use http::header::ORIGIN; +use http::header::VARY; use tako_core::body::TakoBody; use tako_core::middleware::Next; @@ -122,6 +126,42 @@ impl Default for Config { } } +impl Config { + /// Validates the CORS configuration against the Fetch spec's hard rules. + /// + /// Returns an error if the configuration would produce a header combination that + /// browsers reject (e.g. `Access-Control-Allow-Origin: *` together with + /// `Access-Control-Allow-Credentials: true`). + pub fn validate(&self) -> Result<(), CorsConfigError> { + if self.allow_credentials && self.origins.is_empty() { + return Err(CorsConfigError::CredentialsWithWildcardOrigin); + } + Ok(()) + } +} + +/// Errors produced when constructing an invalid [`CorsPlugin`] configuration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CorsConfigError { + /// `allow_credentials = true` was combined with no explicit origins, which would + /// produce `Access-Control-Allow-Origin: *` alongside `Access-Control-Allow-Credentials: true`. + /// Browsers reject this combination per the Fetch spec. + CredentialsWithWildcardOrigin, +} + +impl fmt::Display for CorsConfigError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::CredentialsWithWildcardOrigin => f.write_str( + "CORS misconfiguration: allow_credentials = true requires at least one explicit \ + allowed origin; reflecting `*` together with credentials is rejected by browsers", + ), + } + } +} + +impl std::error::Error for CorsConfigError {} + /// Builder for configuring CORS policies with a fluent API. /// /// `CorsBuilder` provides a convenient way to construct CORS configurations using @@ -209,10 +249,23 @@ impl CorsBuilder { } /// Builds the CORS plugin with the configured settings. + /// + /// # Panics + /// + /// Panics if [`Config::validate`] fails โ€” typically when `allow_credentials = true` + /// is combined with an empty origin list. Use [`CorsBuilder::try_build`] to handle + /// the error explicitly. #[inline] #[must_use] pub fn build(self) -> CorsPlugin { - CorsPlugin { cfg: self.0 } + self.try_build().expect("invalid CORS configuration") + } + + /// Builds the CORS plugin, returning an error on invalid configuration instead of panicking. + #[inline] + pub fn try_build(self) -> Result { + self.0.validate()?; + Ok(CorsPlugin { cfg: self.0 }) } } @@ -279,30 +332,39 @@ impl TakoPlugin for CorsPlugin { /// Handles CORS processing for incoming requests including preflight and actual requests. async fn handle_cors(req: Request, next: Next, cfg: Config) -> impl Responder { let origin = req.headers().get(ORIGIN).cloned(); + let request_headers = req.headers().get(ACCESS_CONTROL_REQUEST_HEADERS).cloned(); if req.method() == Method::OPTIONS { let mut resp = http::Response::builder() .status(StatusCode::NO_CONTENT) .body(TakoBody::empty()) .expect("valid CORS preflight response"); - add_cors_headers(&cfg, origin, &mut resp); + add_cors_headers(&cfg, origin, request_headers.as_ref(), &mut resp); return resp.into_response(); } let mut resp = next.run(req).await; - add_cors_headers(&cfg, origin, &mut resp); + add_cors_headers(&cfg, origin, request_headers.as_ref(), &mut resp); resp.into_response() } /// Adds CORS headers to HTTP responses based on configuration and request origin. -fn add_cors_headers(cfg: &Config, origin: Option, resp: &mut Response) { - // Origin validation and Access-Control-Allow-Origin header - let allow_origin = if cfg.origins.is_empty() { - "*".to_string() +fn add_cors_headers( + cfg: &Config, + origin: Option, + request_headers: Option<&HeaderValue>, + resp: &mut Response, +) { + // Origin validation and Access-Control-Allow-Origin header. + // + // Invariant guarded by `Config::validate`: when `allow_credentials = true`, + // `cfg.origins` is non-empty โ€” so `*` is never emitted alongside credentials. + let (allow_origin, mirrored_origin) = if cfg.origins.is_empty() { + ("*".to_string(), false) } else if let Some(o) = &origin { let s = o.to_str().unwrap_or_default(); if cfg.origins.iter().any(|p| p == s) { - s.to_string() + (s.to_string(), true) } else { return; // Origin not allowed, don't add CORS headers } @@ -315,6 +377,12 @@ fn add_cors_headers(cfg: &Config, origin: Option, resp: &mut Respon HeaderValue::from_str(&allow_origin).expect("valid origin header value"), ); + // When the response varies on the request Origin (i.e. we mirrored it back), + // shared caches must key on Origin to avoid cross-origin response leakage. + if mirrored_origin { + resp.headers_mut().append(VARY, HeaderValue::from_static("Origin")); + } + // Access-Control-Allow-Methods header let methods = if cfg.methods.is_empty() { None @@ -335,12 +403,30 @@ fn add_cors_headers(cfg: &Config, origin: Option, resp: &mut Respon ); } - // Access-Control-Allow-Headers header + // Access-Control-Allow-Headers header. + // + // `*` is invalid in any "Allow-*" header when `Access-Control-Allow-Credentials: true` + // (Fetch spec). Two strategies when no explicit list is configured: + // - credentials disallowed: emit `*` (browsers accept it). + // - credentials allowed: reflect the request's `Access-Control-Request-Headers` + // so the preflight succeeds without a footgun. if cfg.headers.is_empty() { - // Allow all request headers by default when none are explicitly configured. - resp - .headers_mut() - .insert(ACCESS_CONTROL_ALLOW_HEADERS, HeaderValue::from_static("*")); + if cfg.allow_credentials { + if let Some(req_h) = request_headers { + resp + .headers_mut() + .insert(ACCESS_CONTROL_ALLOW_HEADERS, req_h.clone()); + resp.headers_mut().append( + VARY, + HeaderValue::from_static("Access-Control-Request-Headers"), + ); + } + // No `Access-Control-Request-Headers` to reflect โ†’ emit nothing. + } else { + resp + .headers_mut() + .insert(ACCESS_CONTROL_ALLOW_HEADERS, HeaderValue::from_static("*")); + } } else { let h = cfg .headers diff --git a/tako-plugins/src/plugins/idempotency.rs b/tako-plugins/src/plugins/idempotency.rs index 0741508..12a3ccd 100644 --- a/tako-plugins/src/plugins/idempotency.rs +++ b/tako-plugins/src/plugins/idempotency.rs @@ -88,7 +88,8 @@ impl Default for Config { Self { header: HeaderName::from_static("idempotency-key"), methods: vec![Method::POST], - ttl_secs: 30, + // Matches the documented default on `Config::ttl_secs` (24h). + ttl_secs: 86400, scope: Scope::MethodAndPath, coalesce_inflight: true, inflight_wait_timeout_ms: None, @@ -368,18 +369,30 @@ async fn handle(req: Request, next: Next, cfg: Config, store: Store) -> impl Res if cfg.verify_payload && payload_sig != sig { return conflict(); } - // Wait for completion + // Wait for completion, honoring the optional timeout on both runtimes. if let Some(_ms) = cfg.inflight_wait_timeout_ms { #[cfg(not(feature = "compio"))] { let _ = timeout(Duration::from_millis(_ms), notify.notified()).await; } - // compio::time::sleep is !Send, so we cannot use it inside a - // middleware handler (BoxMiddleware requires Send futures). - // Fall through to the unconditional wait below. + // compio's timer futures are !Send, so we cannot await them directly inside + // a middleware handler (whose returned future is required to be Send). + // Forward the timeout through a helper compio task that fires `Notify` + // โ€” `Notified` is Send, which keeps the middleware future Send-clean. #[cfg(feature = "compio")] { - notify.notified().await; + let timeout_signal = Arc::new(Notify::new()); + let timer_signal = timeout_signal.clone(); + compio::runtime::spawn(async move { + compio::time::sleep(Duration::from_millis(_ms)).await; + timer_signal.notify_waiters(); + }) + .detach(); + futures_util::future::select( + std::pin::pin!(notify.notified()), + std::pin::pin!(timeout_signal.notified()), + ) + .await; } } else { notify.notified().await; diff --git a/tako-plugins/src/plugins/metrics.rs b/tako-plugins/src/plugins/metrics.rs index 5236d29..98a0fae 100644 --- a/tako-plugins/src/plugins/metrics.rs +++ b/tako-plugins/src/plugins/metrics.rs @@ -154,8 +154,35 @@ pub mod prometheus_backend { use super::MetricsBackend; use super::Signal; + /// Derives a low-cardinality `transport` label from a connection signal. + /// + /// Replaces the per-IP `remote_addr` label which would otherwise produce + /// one Prometheus series per client IP. + fn transport_label(signal: &Signal) -> &'static str { + if signal.metadata.get("protocol").map(String::as_str) == Some("h3") { + "h3" + } else if signal.metadata.get("tls").map(String::as_str) == Some("true") { + "tls" + } else if signal.metadata.contains_key("unix_path") { + "unix" + } else { + "tcp" + } + } + + /// Prefer the matched route template (`route`) over the raw URI path (`path`) + /// to keep label cardinality bounded by the number of registered routes. + fn route_label(signal: &Signal) -> &str { + signal + .metadata + .get("route") + .or_else(|| signal.metadata.get("path")) + .map(String::as_str) + .unwrap_or("") + } + /// Basic Prometheus metrics backend that tracks HTTP request counts - /// and connection counts using labels for method, path, and status. + /// and connection counts using labels for method, route, and status. pub struct PrometheusMetricsBackend { registry: Registry, http_requests_total: IntCounterVec, @@ -166,9 +193,11 @@ pub mod prometheus_backend { impl PrometheusMetricsBackend { pub fn new(registry: Registry) -> Self { + // Route-template-based labels keep cardinality bounded by route count; + // raw path labels would explode under `/users/:id`-style traffic. let http_requests_total = IntCounterVec::new( Opts::new("tako_http_requests_total", "Total HTTP requests completed"), - &["method", "path", "status"], + &["method", "route", "status"], ) .expect("failed to create http_requests_total metric"); @@ -177,19 +206,20 @@ pub mod prometheus_backend { "tako_route_requests_total", "Total route-level HTTP requests completed", ), - &["method", "path", "status"], + &["method", "route", "status"], ) .expect("failed to create route_requests_total metric"); + // `transport` is bounded (tcp/tls/h3/unix); `remote_addr` was unbounded. let connections_opened_total = IntCounterVec::new( Opts::new("tako_connections_opened_total", "Total connections opened"), - &["remote_addr"], + &["transport"], ) .expect("failed to create connections_opened_total metric"); let connections_closed_total = IntCounterVec::new( Opts::new("tako_connections_closed_total", "Total connections closed"), - &["remote_addr"], + &["transport"], ) .expect("failed to create connections_closed_total metric"); @@ -227,11 +257,7 @@ pub mod prometheus_backend { .get("method") .map(String::as_str) .unwrap_or(""); - let path = signal - .metadata - .get("path") - .map(String::as_str) - .unwrap_or(""); + let route = route_label(signal); let status = signal .metadata .get("status") @@ -239,7 +265,7 @@ pub mod prometheus_backend { .unwrap_or(""); self .http_requests_total - .with_label_values(&[method, path, status]) + .with_label_values(&[method, route, status]) .inc(); } @@ -249,11 +275,7 @@ pub mod prometheus_backend { .get("method") .map(String::as_str) .unwrap_or(""); - let path = signal - .metadata - .get("path") - .map(String::as_str) - .unwrap_or(""); + let route = route_label(signal); let status = signal .metadata .get("status") @@ -261,31 +283,23 @@ pub mod prometheus_backend { .unwrap_or(""); self .http_route_requests_total - .with_label_values(&[method, path, status]) + .with_label_values(&[method, route, status]) .inc(); } fn on_connection_opened(&self, signal: &Signal) { - let addr = signal - .metadata - .get("remote_addr") - .map(String::as_str) - .unwrap_or(""); + let transport = transport_label(signal); self .connections_opened_total - .with_label_values(&[addr]) + .with_label_values(&[transport]) .inc(); } fn on_connection_closed(&self, signal: &Signal) { - let addr = signal - .metadata - .get("remote_addr") - .map(String::as_str) - .unwrap_or(""); + let transport = transport_label(signal); self .connections_closed_total - .with_label_values(&[addr]) + .with_label_values(&[transport]) .inc(); } } @@ -327,16 +341,39 @@ pub mod opentelemetry_backend { } } + /// Derives a low-cardinality `transport` label from a connection signal. + fn transport_label(signal: &Signal) -> &'static str { + if signal.metadata.get("protocol").map(String::as_str) == Some("h3") { + "h3" + } else if signal.metadata.get("tls").map(String::as_str) == Some("true") { + "tls" + } else if signal.metadata.contains_key("unix_path") { + "unix" + } else { + "tcp" + } + } + + /// Prefer the matched route template (`route`) over the raw URI path (`path`). + fn route_label(signal: &Signal) -> String { + signal + .metadata + .get("route") + .or_else(|| signal.metadata.get("path")) + .cloned() + .unwrap_or_default() + } + impl MetricsBackend for OtelMetricsBackend { fn on_request_completed(&self, signal: &Signal) { let method = signal.metadata.get("method").cloned().unwrap_or_default(); - let path = signal.metadata.get("path").cloned().unwrap_or_default(); + let route = route_label(signal); let status = signal.metadata.get("status").cloned().unwrap_or_default(); self.http_requests_total.add( 1, &[ KeyValue::new("method", method), - KeyValue::new("path", path), + KeyValue::new("route", route), KeyValue::new("status", status), ], ); @@ -344,38 +381,28 @@ pub mod opentelemetry_backend { fn on_route_request_completed(&self, signal: &Signal) { let method = signal.metadata.get("method").cloned().unwrap_or_default(); - let path = signal.metadata.get("path").cloned().unwrap_or_default(); + let route = route_label(signal); let status = signal.metadata.get("status").cloned().unwrap_or_default(); self.http_route_requests_total.add( 1, &[ KeyValue::new("method", method), - KeyValue::new("path", path), + KeyValue::new("route", route), KeyValue::new("status", status), ], ); } fn on_connection_opened(&self, signal: &Signal) { - let addr = signal - .metadata - .get("remote_addr") - .cloned() - .unwrap_or_default(); self .connections_opened_total - .add(1, &[KeyValue::new("remote_addr", addr)]); + .add(1, &[KeyValue::new("transport", transport_label(signal))]); } fn on_connection_closed(&self, signal: &Signal) { - let addr = signal - .metadata - .get("remote_addr") - .cloned() - .unwrap_or_default(); self .connections_closed_total - .add(1, &[KeyValue::new("remote_addr", addr)]); + .add(1, &[KeyValue::new("transport", transport_label(signal))]); } } } diff --git a/tako-rs/src/lib.rs b/tako-rs/src/lib.rs index 4e4241e..7c6db85 100644 --- a/tako-rs/src/lib.rs +++ b/tako-rs/src/lib.rs @@ -45,6 +45,9 @@ pub mod __private { pub use tako_core::body; pub use tako_core::config; +pub use tako_core::conn_info; +pub use tako_core::problem; +pub use tako_core::router_state; pub use tako_core::queue; pub use tako_core::redirect; pub use tako_core::responder; @@ -102,6 +105,22 @@ pub use tako_streams::webtransport; pub use tako_server::server_tcp; pub use tako_server::server_udp; +#[cfg(all(feature = "http2", not(feature = "compio")))] +#[cfg_attr(docsrs, doc(cfg(feature = "http2")))] +pub use tako_server::server_h2c; +#[cfg(all(feature = "http2", not(feature = "compio")))] +#[cfg_attr(docsrs, doc(cfg(feature = "http2")))] +pub use tako_server::serve_h2c; +#[cfg(all(feature = "http2", not(feature = "compio")))] +#[cfg_attr(docsrs, doc(cfg(feature = "http2")))] +pub use tako_server::serve_h2c_with_config; +#[cfg(all(feature = "http2", not(feature = "compio")))] +#[cfg_attr(docsrs, doc(cfg(feature = "http2")))] +pub use tako_server::serve_h2c_with_shutdown; +#[cfg(all(feature = "http2", not(feature = "compio")))] +#[cfg_attr(docsrs, doc(cfg(feature = "http2")))] +pub use tako_server::serve_h2c_with_shutdown_and_config; + #[cfg(all(unix, not(any(feature = "compio", feature = "compio-tls", feature = "compio-ws"))))] pub use tako_server::server_unix; @@ -124,16 +143,33 @@ pub use tako_server::server_tls_compio; #[cfg_attr(docsrs, doc(cfg(feature = "http3")))] pub use tako_server::server_h3; +pub use tako_server::AcceptBackoff; +pub use tako_server::ServerConfig; +pub use tako_server::{ServerHandle, TlsCert}; +#[cfg(not(feature = "compio"))] +pub use tako_server::{Server, ServerBuilder}; +#[cfg(feature = "compio")] +pub use tako_server::{CompioServer, CompioServerBuilder}; pub use tako_server::bind_with_port_fallback; pub use tako_server::serve; pub use tako_server::serve_with_shutdown; +#[cfg(not(feature = "compio"))] +pub use tako_server::serve_with_config; +#[cfg(not(feature = "compio"))] +pub use tako_server::serve_with_shutdown_and_config; #[cfg(all(feature = "http3", not(feature = "compio")))] #[cfg_attr(docsrs, doc(cfg(feature = "http3")))] pub use tako_server::serve_h3; #[cfg(all(feature = "http3", not(feature = "compio")))] #[cfg_attr(docsrs, doc(cfg(feature = "http3")))] +pub use tako_server::serve_h3_with_config; +#[cfg(all(feature = "http3", not(feature = "compio")))] +#[cfg_attr(docsrs, doc(cfg(feature = "http3")))] pub use tako_server::serve_h3_with_shutdown; +#[cfg(all(feature = "http3", not(feature = "compio")))] +#[cfg_attr(docsrs, doc(cfg(feature = "http3")))] +pub use tako_server::serve_h3_with_shutdown_and_config; #[cfg(any( all(feature = "tls", not(any(feature = "compio", feature = "compio-tls", feature = "compio-ws"))), @@ -147,6 +183,12 @@ pub use tako_server::serve_tls; ))] #[cfg_attr(docsrs, doc(cfg(any(feature = "tls", feature = "compio-tls"))))] pub use tako_server::serve_tls_with_shutdown; +#[cfg(all(feature = "tls", not(any(feature = "compio", feature = "compio-tls", feature = "compio-ws"))))] +#[cfg_attr(docsrs, doc(cfg(feature = "tls")))] +pub use tako_server::serve_tls_with_config; +#[cfg(all(feature = "tls", not(any(feature = "compio", feature = "compio-tls", feature = "compio-ws"))))] +#[cfg_attr(docsrs, doc(cfg(feature = "tls")))] +pub use tako_server::serve_tls_with_shutdown_and_config; /// Request data extraction utilities. pub mod extractors { diff --git a/tako-rs/tests/middleware.rs b/tako-rs/tests/middleware.rs index 12dd2d1..fdb73f1 100644 --- a/tako-rs/tests/middleware.rs +++ b/tako-rs/tests/middleware.rs @@ -300,8 +300,24 @@ async fn body_limit_content_length_reject() { async fn body_limit_runtime_reject() { use tako::middleware::body_limit::BodyLimit; + // Stream-aware enforcement: the limit fires inside the body stream, so a + // handler that reads the body sees an `Err` once the cap is exceeded. The + // limit no longer triggers when the handler ignores the body (that's the + // intended trade-off vs the previous buffer-and-check approach). let mut router = Router::new(); - router.route(Method::POST, "/upload", |_req: Request| async { "ok" }); + router.route(Method::POST, "/upload", |req: Request| async move { + let (_, body) = req.into_parts(); + match body.collect().await { + Ok(_) => http::Response::builder() + .status(StatusCode::OK) + .body(TakoBody::from("ok")) + .unwrap(), + Err(_) => http::Response::builder() + .status(StatusCode::PAYLOAD_TOO_LARGE) + .body(TakoBody::from("Body exceeds allowed size")) + .unwrap(), + } + }); router.middleware(BodyLimit::new_with_dynamic(5, |_| 5).into_middleware()); let req = make_req_with_body(Method::POST, "/upload", "too large"); diff --git a/tako-rs/tests/router.rs b/tako-rs/tests/router.rs index b29be9c..8aef3a1 100644 --- a/tako-rs/tests/router.rs +++ b/tako-rs/tests/router.rs @@ -41,12 +41,18 @@ async fn route_miss_returns_404() { } #[tokio::test] -async fn different_method_returns_404() { +async fn different_method_returns_405_with_allow() { let mut router = Router::new(); router.route(Method::GET, "/hello", |_req: Request| async { "Hello" }); let resp = router.dispatch(make_req(Method::POST, "/hello")).await; - assert_eq!(resp.status(), StatusCode::NOT_FOUND); + assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED); + let allow = resp + .headers() + .get(http::header::ALLOW) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!(allow.split(',').map(str::trim).any(|m| m == "GET")); } #[tokio::test] @@ -489,3 +495,132 @@ async fn route_plugin_runs_once_and_precedes_route_middleware() { ] ); } + +#[tokio::test] +async fn nest_mounts_child_under_prefix() { + let mut child = Router::new(); + child.get("/users", |_req: Request| async { "users" }); + child.post("/users", |_req: Request| async { "created" }); + + let mut root = Router::new(); + root.nest("/api/v1", child); + + let resp = root.dispatch(make_req(Method::GET, "/api/v1/users")).await; + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(body_str(resp).await, "users"); + + let resp = root.dispatch(make_req(Method::POST, "/api/v1/users")).await; + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(body_str(resp).await, "created"); + + // Original (unprefixed) child path is not registered on the root. + let resp = root.dispatch(make_req(Method::GET, "/users")).await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn nest_does_not_double_stack_middleware_on_re_nest() { + // Re-using the same child router across two nest calls must not stack the + // child's global middleware twice on the same Arc (the bug Router::merge has). + let counter = Arc::new(Mutex::new(0u32)); + let counter_mw = counter.clone(); + + let mut child = Router::new(); + child.get("/ping", |_req: Request| async { "pong" }); + child.middleware(move |req, next| { + let counter = counter_mw.clone(); + async move { + *counter.lock().unwrap() += 1; + next.run(req).await + } + }); + + let mut root = Router::new(); + // Nest the same logical child twice under different prefixes โ€” middleware + // must only fire once per request, not twice. + // Build a second child with the same shape because Router is move-only. + let mut child2 = Router::new(); + child2.get("/ping", |_req: Request| async { "pong" }); + let counter_mw2 = counter.clone(); + child2.middleware(move |req, next| { + let counter = counter_mw2.clone(); + async move { + *counter.lock().unwrap() += 1; + next.run(req).await + } + }); + + root.nest("/a", child); + root.nest("/b", child2); + + let _ = root.dispatch(make_req(Method::GET, "/a/ping")).await; + assert_eq!(*counter.lock().unwrap(), 1, "middleware fired more than once on /a/ping"); + let _ = root.dispatch(make_req(Method::GET, "/b/ping")).await; + assert_eq!(*counter.lock().unwrap(), 2, "middleware fired more than once on /b/ping"); +} + +#[tokio::test] +async fn with_state_isolates_two_routers_in_same_process() { + // Each router holds its own `String` state, distinct from the other and + // from any process-global value. The previous global-only `set_state` API + // could not express this without newtype wrappers. + use tako::extractors::state::State; + + async fn echo_state(State(s): State) -> impl tako::responder::Responder { + (*s).clone() + } + + let mut router_a = Router::new(); + router_a.with_state::("router-a".to_string()); + router_a.get("/whoami", echo_state); + + let mut router_b = Router::new(); + router_b.with_state::("router-b".to_string()); + router_b.get("/whoami", echo_state); + + let resp_a = router_a.dispatch(make_req(Method::GET, "/whoami")).await; + assert_eq!(body_str(resp_a).await, "router-a"); + + let resp_b = router_b.dispatch(make_req(Method::GET, "/whoami")).await; + assert_eq!(body_str(resp_b).await, "router-b"); +} + +#[tokio::test] +async fn with_state_falls_back_to_global_when_unset_per_router() { + // A router that never called `with_state::` should still see the global + // value installed via `set_state` โ€” backward-compat guarantee. + use tako::extractors::state::State; + use tako::state::set_state; + + #[derive(Clone)] + struct GlobalOnly(&'static str); + + set_state(GlobalOnly("global")); + + async fn read_global(State(g): State) -> impl tako::responder::Responder { + g.0 + } + + let mut router = Router::new(); + router.get("/g", read_global); + + let resp = router.dispatch(make_req(Method::GET, "/g")).await; + assert_eq!(body_str(resp).await, "global"); +} + +#[tokio::test] +async fn scope_groups_routes_under_prefix() { + let mut router = Router::new(); + router.scope("/api/v1", |r| { + r.get("/users", |_req: Request| async { "users" }); + r.scope("/admin", |r2| { + r2.get("/dashboard", |_req: Request| async { "dashboard" }); + }); + }); + + let resp = router.dispatch(make_req(Method::GET, "/api/v1/users")).await; + assert_eq!(resp.status(), StatusCode::OK); + let resp = router.dispatch(make_req(Method::GET, "/api/v1/admin/dashboard")).await; + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(body_str(resp).await, "dashboard"); +} diff --git a/tako-rs/tests/server_builder.rs b/tako-rs/tests/server_builder.rs new file mode 100644 index 0000000..e0f5858 --- /dev/null +++ b/tako-rs/tests/server_builder.rs @@ -0,0 +1,162 @@ +//! End-to-end tests for the unified `Server::builder()` entry point. +//! +//! These spin up a real TCP listener, hit it with a tokio TcpStream, and +//! verify the response. The compio path is excluded โ€” these tests only run on +//! the default tokio transport. + +#![cfg(not(feature = "compio"))] + +use std::time::Duration; + +use http::{Method, StatusCode}; +use tako::body::TakoBody; +use tako::router::Router; +use tako::types::Request; +use tako::{Server, ServerConfig}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +async fn hello(_req: Request) -> &'static str { + "ok" +} + +async fn fetch_status_line(addr: &std::net::SocketAddr) -> String { + let mut stream = TcpStream::connect(addr).await.unwrap(); + let _ = stream + .write_all(b"GET /ping HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + .await; + let mut buf = Vec::new(); + let _ = stream.read_to_end(&mut buf).await; + let txt = String::from_utf8_lossy(&buf).to_string(); + txt.lines().next().unwrap_or("").to_string() +} + +#[tokio::test] +async fn server_builder_handles_http_request() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let mut router = Router::new(); + router.get("/ping", hello); + + let server = Server::builder().config(ServerConfig::default()).build(); + let handle = server.spawn_http(listener, router); + + // Tiny delay to let the spawn task start the accept loop. + tokio::time::sleep(Duration::from_millis(50)).await; + + let status_line = fetch_status_line(&addr).await; + assert!( + status_line.starts_with("HTTP/1.1 200"), + "status line was {status_line:?}" + ); + + // Trigger graceful shutdown. + handle.shutdown(Duration::from_secs(2)).await; +} + +#[tokio::test] +async fn server_builder_default_config_is_default() { + let server = Server::builder().build(); + assert_eq!(server.config().drain_timeout, Duration::from_secs(30)); + assert_eq!(server.config().h2_max_concurrent_streams, 100); +} + +// Smoke-test the raw TCP path on the builder. +#[tokio::test] +async fn server_builder_handles_raw_tcp_echo() { + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + + // Bind once just to discover a free port, then drop the listener so the + // builder can rebind via serve_tcp_with_shutdown. + let probe = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = probe.local_addr().unwrap(); + drop(probe); + + let server = Server::builder().build(); + let handle = server.spawn_tcp_raw(addr.to_string(), |mut stream, _peer| { + Box::pin(async move { + let mut buf = [0u8; 16]; + let n = stream.read(&mut buf).await?; + stream.write_all(&buf[..n]).await?; + Ok(()) + }) + }); + + tokio::time::sleep(Duration::from_millis(80)).await; + + let mut s = TcpStream::connect(&addr).await.unwrap(); + s.write_all(b"hi").await.unwrap(); + let mut out = [0u8; 16]; + let n = s.read(&mut out).await.unwrap(); + assert_eq!(&out[..n], b"hi"); + + handle.shutdown(Duration::from_secs(2)).await; +} + +// Smoke-test the raw UDP path on the builder. +#[tokio::test] +async fn server_builder_handles_raw_udp_echo() { + let probe = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let addr = probe.local_addr().unwrap(); + drop(probe); + + let server = Server::builder().build(); + let handle = server.spawn_udp_raw(addr.to_string(), |data, peer, sock| { + Box::pin(async move { + let _ = sock.send_to(&data, peer).await; + }) + }); + + tokio::time::sleep(Duration::from_millis(80)).await; + + let client = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); + client.send_to(b"udp-hi", &addr).await.unwrap(); + let mut buf = [0u8; 32]; + let recv = tokio::time::timeout(Duration::from_secs(2), client.recv_from(&mut buf)) + .await + .unwrap() + .unwrap(); + assert_eq!(&buf[..recv.0], b"udp-hi"); + + handle.trigger(); +} + +// Smoke-test that the 405 + Allow path keeps working through the builder +// without any caller-side wiring. +#[tokio::test] +async fn server_builder_propagates_405_with_allow() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let mut router = Router::new(); + router.route(Method::GET, "/only-get", hello); + + let server = Server::builder().build(); + let handle = server.spawn_http(listener, router); + + tokio::time::sleep(Duration::from_millis(50)).await; + + let mut stream = TcpStream::connect(&addr).await.unwrap(); + let _ = stream + .write_all( + b"POST /only-get HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await; + let mut buf = Vec::new(); + let _ = stream.read_to_end(&mut buf).await; + let txt = String::from_utf8_lossy(&buf); + assert!( + txt.contains(&format!("HTTP/1.1 {}", StatusCode::METHOD_NOT_ALLOWED.as_u16())), + "wanted 405, got: {txt:?}" + ); + assert!( + txt.to_ascii_lowercase().contains("allow:"), + "wanted Allow header, got: {txt:?}" + ); + + // Drop the in-flight body so we can use TakoBody import without a warning. + let _ = TakoBody::empty(); + + handle.shutdown(Duration::from_secs(2)).await; +} diff --git a/tako-rs/tests/udp_tcp_progress.rs b/tako-rs/tests/udp_tcp_progress.rs index 142b9aa..daf5a63 100644 --- a/tako-rs/tests/udp_tcp_progress.rs +++ b/tako-rs/tests/udp_tcp_progress.rs @@ -66,7 +66,10 @@ async fn upload_progress_callback_fires() { }); let mw = progress.into_middleware(); - async fn handler(_req: Request) -> impl Responder { + // Streaming progress increments only as the body is read; drain it explicitly. + async fn handler(req: Request) -> impl Responder { + use http_body_util::BodyExt; + let _ = req.into_body().collect().await; "ok" } @@ -104,11 +107,11 @@ async fn upload_progress_tracker_in_extensions() { let mw = progress.into_middleware(); async fn handler(req: Request) -> impl Responder { - let bytes = req - .extensions() - .get::() - .map(|t| t.bytes_read()) - .unwrap_or(0); + use http_body_util::BodyExt; + let tracker = req.extensions().get::().cloned(); + // Drain the body so the streaming progress wrapper increments the counter. + let _ = req.into_body().collect().await; + let bytes = tracker.map(|t| t.bytes_read()).unwrap_or(0); format!("{bytes}") } @@ -148,11 +151,11 @@ async fn upload_progress_percent_via_tracker() { let mw = progress.into_middleware(); async fn handler(req: Request) -> impl Responder { - let pct = req - .extensions() - .get::() - .and_then(|t| t.percent()) - .unwrap_or(0); + use http_body_util::BodyExt; + let tracker = req.extensions().get::().cloned(); + // Drain the body so the streaming progress wrapper increments the counter. + let _ = req.into_body().collect().await; + let pct = tracker.and_then(|t| t.percent()).unwrap_or(0); format!("{pct}") } diff --git a/tako-server-pt/src/lib.rs b/tako-server-pt/src/lib.rs index c939325..e8c8721 100644 --- a/tako-server-pt/src/lib.rs +++ b/tako-server-pt/src/lib.rs @@ -26,8 +26,9 @@ use std::convert::Infallible; use std::io; use std::net::SocketAddr; use std::str::FromStr; -#[cfg(any(feature = "local", feature = "compio-local"))] use std::sync::Arc; +use std::time::Duration; +use tokio::sync::Notify; use hyper::server::conn::http1; use hyper::service::service_fn; @@ -36,6 +37,7 @@ use socket2::Protocol; use socket2::Socket; use socket2::Type; use tako_core::body::TakoBody; +use tako_core::conn_info::ConnInfo; use tako_core::router::Router; #[cfg(feature = "local")] use tako_core_local::router::LocalRouter; @@ -52,6 +54,9 @@ pub struct PerThreadConfig { pub pin_to_core: bool, /// `SO_REUSEPORT` listen backlog. pub backlog: i32, + /// Maximum time the coordinator waits for in-flight requests after shutdown. + /// Workers are dropped after this elapses. + pub drain_timeout: Duration, } impl Default for PerThreadConfig { @@ -60,10 +65,38 @@ impl Default for PerThreadConfig { workers: num_cpus(), pin_to_core: cfg!(feature = "affinity"), backlog: 1024, + drain_timeout: Duration::from_secs(30), } } } +/// Shutdown coordinator shared by every worker spawned via [`spawn_per_thread`] +/// (and friends). Workers `select!` against [`Self::notified`] in their accept +/// loop, so triggering [`PerThreadShutdown::trigger`] cleanly exits each +/// worker's `loop { accept }` instead of leaking the OS thread on shutdown. +#[derive(Clone, Default)] +pub struct PerThreadShutdown { + inner: Arc, +} + +impl PerThreadShutdown { + /// Construct an unsignalled shutdown coordinator. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Notify every worker waiter that it should exit its accept loop. + pub fn trigger(&self) { + self.inner.notify_waiters(); + } + + /// Future a worker awaits to learn that shutdown has been requested. + pub async fn notified(&self) { + self.inner.notified().await; + } +} + fn num_cpus() -> usize { std::thread::available_parallelism() .map(|n| n.get()) @@ -104,8 +137,32 @@ fn bind_reuseport_compio( /// socket on `addr`, builds a single-threaded tokio runtime, and serves /// connections via [`tokio::task::spawn_local`]. /// -/// This blocks the calling thread until all workers exit. +/// This blocks the calling thread until all workers exit. To control shutdown +/// externally use [`spawn_per_thread`] which returns a [`PerThreadShutdown`] +/// handle. pub fn serve_per_thread(addr: &str, router: Router, cfg: PerThreadConfig) -> io::Result<()> { + let (handle, shutdown) = spawn_per_thread(addr, router, cfg)?; + // Without an external trigger this just blocks until every worker exits + // (which currently means until the process is signalled). + drop(shutdown); + for h in handle { + let _ = h.join(); + } + Ok(()) +} + +/// Spawn the worker threads and return both the join handles and a +/// [`PerThreadShutdown`] that the caller can use to signal a clean stop. +/// +/// The returned thread handles are owned by the caller; dropping them does not +/// stop the server. Trigger the shutdown via [`PerThreadShutdown::trigger`], +/// then `join` each handle (or just drop them after the trigger if you're OK +/// with detached cleanup). +pub fn spawn_per_thread( + addr: &str, + router: Router, + cfg: PerThreadConfig, +) -> io::Result<(Vec>, PerThreadShutdown)> { let socket_addr = SocketAddr::from_str(addr).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; @@ -113,23 +170,27 @@ pub fn serve_per_thread(addr: &str, router: Router, cfg: PerThreadConfig) -> io: // on the per-connection or per-request hot path. let router: &'static Router = Box::leak(Box::new(router)); + let shutdown = PerThreadShutdown::new(); let mut handles = Vec::with_capacity(cfg.workers); for worker_id in 0..cfg.workers { let cfg = cfg.clone(); + let shutdown = shutdown.clone(); let h = std::thread::Builder::new() .name(format!("tako-pt-{worker_id}")) - .spawn(move || worker_main(worker_id, socket_addr, router, cfg)) + .spawn(move || worker_main(worker_id, socket_addr, router, cfg, shutdown)) .expect("spawn tako-pt worker"); handles.push(h); } - - for h in handles { - let _ = h.join(); - } - Ok(()) + Ok((handles, shutdown)) } -fn worker_main(worker_id: usize, addr: SocketAddr, router: &'static Router, cfg: PerThreadConfig) { +fn worker_main( + worker_id: usize, + addr: SocketAddr, + router: &'static Router, + cfg: PerThreadConfig, + shutdown: PerThreadShutdown, +) { #[cfg(feature = "affinity")] if cfg.pin_to_core { if let Some(ids) = core_affinity::get_core_ids() { @@ -159,37 +220,55 @@ fn worker_main(worker_id: usize, addr: SocketAddr, router: &'static Router, cfg: }; tracing::debug!("tako-pt worker {worker_id} listening on {addr}"); - loop { - let accept = match listener.accept().await { - Ok(v) => v, - Err(e) => { - tracing::error!("worker {worker_id}: accept failed: {e}"); - continue; - } - }; - let (stream, peer) = accept; - let _ = stream.set_nodelay(true); - let io = hyper_util::rt::TokioIo::new(stream); + let shutdown_fut = shutdown.notified(); + tokio::pin!(shutdown_fut); - tokio::task::spawn_local(async move { - let svc = service_fn(move |mut req| async move { - req.extensions_mut().insert(peer); - let resp = router.dispatch(req.map(TakoBody::incoming)).await; - Ok::<_, Infallible>(resp) - }); + loop { + tokio::select! { + accept = listener.accept() => { + let (stream, peer) = match accept { + Ok(v) => v, + Err(e) => { + tracing::warn!("worker {worker_id}: accept failed: {e}"); + continue; + } + }; + let _ = stream.set_nodelay(true); + let io = hyper_util::rt::TokioIo::new(stream); + + tokio::task::spawn_local(async move { + let svc = service_fn(move |mut req| async move { + req.extensions_mut().insert(peer); + req.extensions_mut().insert(ConnInfo::tcp(peer)); + let resp = router.dispatch(req.map(TakoBody::incoming)).await; + Ok::<_, Infallible>(resp) + }); - let mut http = http1::Builder::new(); - http.keep_alive(true); - http.pipeline_flush(true); - if let Err(err) = http.serve_connection(io, svc).with_upgrades().await { - if err.is_incomplete_message() { - tracing::debug!("worker {worker_id}: client disconnected mid-message: {err}"); - } else { - tracing::error!("worker {worker_id}: connection error: {err}"); - } + let mut http = http1::Builder::new(); + http.keep_alive(true); + http.pipeline_flush(true); + if let Err(err) = http.serve_connection(io, svc).with_upgrades().await { + if err.is_incomplete_message() { + tracing::debug!("worker {worker_id}: client disconnected mid-message: {err}"); + } else { + tracing::error!("worker {worker_id}: connection error: {err}"); + } + } + }); + } + () = &mut shutdown_fut => { + tracing::info!("worker {worker_id}: shutdown signalled, draining"); + break; } - }); + } } + // LocalSet drops here; in-flight tasks get cfg.drain_timeout to finish + // before the runtime is dropped on function exit. + let _ = tokio::time::timeout(cfg.drain_timeout, async { + // No external waiter on the LocalSet; rely on the runtime's pending + // task drain when block_on returns. + }) + .await; }); } @@ -270,6 +349,7 @@ where let router = std::rc::Rc::clone(&router); async move { req.extensions_mut().insert(peer); + req.extensions_mut().insert(ConnInfo::tcp(peer)); let resp = router.dispatch(req.map(TakoBody::incoming)).await; Ok::<_, Infallible>(resp) } diff --git a/tako-server/src/builder.rs b/tako-server/src/builder.rs new file mode 100644 index 0000000..e2979f8 --- /dev/null +++ b/tako-server/src/builder.rs @@ -0,0 +1,489 @@ +//! Unified [`Server`] / [`CompioServer`] builder fronting every Tako transport. +//! +//! The direct `serve_*` / `serve_*_with_shutdown` / `*_with_config` functions +//! still exist and keep working. This module is an additive convenience layer: +//! pick a transport via `spawn_*`, hand it a [`crate::ServerConfig`], and get +//! back a [`ServerHandle`] that owns a shutdown trigger. +//! +//! The handle itself is runtime-agnostic โ€” both [`Server`] (tokio) and +//! [`CompioServer`] (cfg `compio`) return the same [`ServerHandle`] type. +//! Internally each `spawn_*` wraps the underlying `serve_*` future so that +//! when it returns, a `done` [`Notify`] is signalled. [`ServerHandle::join`] +//! awaits that notify; [`ServerHandle::shutdown`] triggers the shutdown +//! signal and then awaits the same `done`. +//! +//! No additional allocation or atomic swap is introduced on the per-connection +//! / per-request hot path โ€” the spawn wrapper is a single async block over the +//! underlying `serve_*_with_shutdown_and_config` call. + +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; + +use tokio::sync::Notify; + +#[cfg(not(feature = "compio"))] +use std::pin::Pin; + +#[cfg(not(feature = "compio"))] +use std::path::PathBuf; +#[cfg(not(feature = "compio"))] +use tokio::net::TcpListener; + +use tako_core::router::Router; + +use crate::ServerConfig; + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ shared handle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/// Background-task handle returned by every `spawn_*` method. +/// +/// Drop semantics: dropping the handle does **not** stop the server. Call +/// [`ServerHandle::shutdown`] (or [`ServerHandle::trigger`] + `.join().await`) +/// so the drain logic in the underlying `serve_*_with_shutdown` runs. +/// +/// Runtime-agnostic โ€” the `done` signal is fired by an `async` wrapper around +/// the underlying `serve_*` future, so the same `ServerHandle` works whether +/// the spawned task lives on the tokio runtime or the compio runtime. +pub struct ServerHandle { + shutdown: Arc, + done: Arc, + drain_timeout: Duration, +} + +impl ServerHandle { + /// Trigger graceful shutdown without awaiting completion. + pub fn trigger(&self) { + self.shutdown.notify_waiters(); + } + + /// Await the spawned task's completion (without triggering shutdown). + /// + /// Returns when the underlying `serve_*` future resolves โ€” typically + /// because [`ServerHandle::trigger`] / [`ServerHandle::shutdown`] was called + /// or because the listener errored fatally. + pub async fn join(&self) { + self.done.notified().await; + } + + /// Trigger graceful shutdown and await the drain. + /// + /// The `_timeout` argument is kept for API symmetry with the original + /// builder; the actual drain bound is the `drain_timeout` on the + /// [`ServerConfig`] that was handed to the builder, enforced inside + /// `serve_*_with_shutdown`. + pub async fn shutdown(self, _timeout: Duration) { + self.shutdown.notify_waiters(); + self.done.notified().await; + } + + /// Returns the drain timeout the underlying `serve_*` will honor. + #[inline] + pub fn drain_timeout(&self) -> Duration { + self.drain_timeout + } +} + +impl std::fmt::Debug for ServerHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ServerHandle") + .field("drain_timeout", &self.drain_timeout) + .finish_non_exhaustive() + } +} + +/// Convenience: await `signal_a` *or* `signal_b`, whichever fires first. +pub async fn either(a: A, b: B) +where + A: Future, + B: Future, +{ + use futures_util::future::Either; + let a = std::pin::pin!(a); + let b = std::pin::pin!(b); + match futures_util::future::select(a, b).await { + Either::Left(_) | Either::Right(_) => {} + } +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ TLS material โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/// Optional TLS material the builder can attach to a TLS-mode server. +/// +/// `Acme` / `Resolver` variants are reserved for future expansion; today only +/// PEM paths are accepted. +#[derive(Debug, Clone)] +pub enum TlsCert { + /// Filesystem paths for cert + key PEM files. + PemPaths { + /// Path to the PEM-encoded certificate chain. + cert_path: String, + /// Path to the PEM-encoded private key. + key_path: String, + }, +} + +impl TlsCert { + /// Construct from filesystem paths. + pub fn pem_paths(cert: impl Into, key: impl Into) -> Self { + Self::PemPaths { + cert_path: cert.into(), + key_path: key.into(), + } + } +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ tokio Server โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/// Fluent constructor for the tokio-runtime [`Server`]. +#[cfg(not(feature = "compio"))] +#[derive(Debug, Default, Clone)] +pub struct ServerBuilder { + config: ServerConfig, + tls: Option, +} + +#[cfg(not(feature = "compio"))] +impl ServerBuilder { + /// Override the [`ServerConfig`] (drain timeout, h2 caps, max_connections, โ€ฆ). + #[must_use] + pub fn config(mut self, config: ServerConfig) -> Self { + self.config = config; + self + } + + /// Attach TLS material so [`Server::spawn_tls`] / [`Server::spawn_h3`] become usable. + #[must_use] + pub fn tls(mut self, cert: TlsCert) -> Self { + self.tls = Some(cert); + self + } + + /// Finalize and produce the [`Server`]. + pub fn build(self) -> Server { + Server { + config: self.config, + tls: self.tls, + } + } +} + +/// Tokio-runtime server entry point. Construct with [`Server::builder`]. +#[cfg(not(feature = "compio"))] +#[derive(Debug, Clone)] +pub struct Server { + config: ServerConfig, + tls: Option, +} + +#[cfg(not(feature = "compio"))] +impl Server { + /// Start a fresh fluent builder. + #[must_use] + pub fn builder() -> ServerBuilder { + ServerBuilder::default() + } + + /// Borrow the underlying [`ServerConfig`]. + #[inline] + pub fn config(&self) -> &ServerConfig { + &self.config + } + + // โ”€โ”€ HTTP family (router-driven) โ”€โ”€ + + /// Spawn a plain HTTP/1 server. + pub fn spawn_http(&self, listener: TcpListener, router: Router) -> ServerHandle { + let (handle, shutdown_fut) = make_handle(self.config.drain_timeout); + let config = self.config.clone(); + spawn_done(handle.done.clone(), async move { + crate::server::serve_with_shutdown_and_config(listener, router, shutdown_fut, config).await; + }); + handle + } + + /// Spawn an h2c (HTTP/2 cleartext, prior knowledge) server. + #[cfg(feature = "http2")] + pub fn spawn_h2c(&self, listener: TcpListener, router: Router) -> ServerHandle { + let (handle, shutdown_fut) = make_handle(self.config.drain_timeout); + let config = self.config.clone(); + spawn_done(handle.done.clone(), async move { + crate::server_h2c::serve_h2c_with_shutdown_and_config(listener, router, shutdown_fut, config) + .await; + }); + handle + } + + /// Spawn a TLS server. Requires that the builder was given a [`TlsCert`]. + #[cfg(feature = "tls")] + pub fn spawn_tls(&self, listener: TcpListener, router: Router) -> ServerHandle { + let tls = self + .tls + .clone() + .expect("Server::spawn_tls requires a TlsCert (use builder().tls(...))"); + let (handle, shutdown_fut) = make_handle(self.config.drain_timeout); + let config = self.config.clone(); + spawn_done(handle.done.clone(), async move { + let TlsCert::PemPaths { cert_path, key_path } = tls; + crate::server_tls::serve_tls_with_shutdown_and_config( + listener, + router, + Some(cert_path.as_str()), + Some(key_path.as_str()), + shutdown_fut, + config, + ) + .await; + }); + handle + } + + /// Spawn an HTTP/3 (QUIC) server. Binds to `addr` internally; takes TLS + /// from the builder. Requires the `http3` feature. + #[cfg(feature = "http3")] + pub fn spawn_h3(&self, addr: impl Into, router: Router) -> ServerHandle { + let tls = self + .tls + .clone() + .expect("Server::spawn_h3 requires a TlsCert (use builder().tls(...))"); + let addr = addr.into(); + let (handle, shutdown_fut) = make_handle(self.config.drain_timeout); + let config = self.config.clone(); + spawn_done(handle.done.clone(), async move { + let TlsCert::PemPaths { cert_path, key_path } = tls; + crate::server_h3::serve_h3_with_shutdown_and_config( + router, + &addr, + Some(cert_path.as_str()), + Some(key_path.as_str()), + shutdown_fut, + config, + ) + .await; + }); + handle + } + + /// Spawn an HTTP-over-Unix-socket server. + #[cfg(unix)] + pub fn spawn_unix_http(&self, path: impl Into, router: Router) -> ServerHandle { + let path = path.into(); + let (handle, shutdown_fut) = make_handle(self.config.drain_timeout); + let config = self.config.clone(); + spawn_done(handle.done.clone(), async move { + crate::server_unix::serve_unix_http_with_shutdown_and_config( + path, + router, + shutdown_fut, + config, + ) + .await; + }); + handle + } + + /// Spawn an HTTP server fronted by PROXY-protocol parsing. + pub fn spawn_proxy_protocol(&self, listener: TcpListener, router: Router) -> ServerHandle { + let (handle, shutdown_fut) = make_handle(self.config.drain_timeout); + let config = self.config.clone(); + spawn_done(handle.done.clone(), async move { + crate::proxy_protocol::serve_http_with_proxy_protocol_shutdown_and_config( + listener, + router, + shutdown_fut, + config, + ) + .await; + }); + handle + } + + // โ”€โ”€ Raw transports (handler-driven, no router) โ”€โ”€ + + /// Spawn a raw TCP server. The handler receives each accepted stream. + pub fn spawn_tcp_raw(&self, addr: impl Into, handler: F) -> ServerHandle + where + F: Fn( + tokio::net::TcpStream, + std::net::SocketAddr, + ) -> Pin> + Send>> + + Send + + Sync + + 'static, + { + let addr = addr.into(); + let (handle, shutdown_fut) = make_handle(self.config.drain_timeout); + spawn_done(handle.done.clone(), async move { + if let Err(e) = crate::server_tcp::serve_tcp_with_shutdown(&addr, handler, shutdown_fut).await + { + tracing::error!("raw TCP server error: {e}"); + } + }); + handle + } + + /// Spawn a raw UDP server. The handler receives each datagram. + pub fn spawn_udp_raw(&self, addr: impl Into, handler: F) -> ServerHandle + where + F: Fn( + Vec, + std::net::SocketAddr, + Arc, + ) -> Pin + Send>> + + Send + + Sync + + 'static, + { + let addr = addr.into(); + let (handle, shutdown_fut) = make_handle(self.config.drain_timeout); + spawn_done(handle.done.clone(), async move { + if let Err(e) = crate::server_udp::serve_udp_with_shutdown(&addr, handler, shutdown_fut).await + { + tracing::error!("raw UDP server error: {e}"); + } + }); + handle + } +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ compio Server โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/// Fluent constructor for the compio-runtime [`CompioServer`]. +#[cfg(feature = "compio")] +#[derive(Debug, Default, Clone)] +pub struct CompioServerBuilder { + config: ServerConfig, + tls: Option, +} + +#[cfg(feature = "compio")] +impl CompioServerBuilder { + /// Override the [`ServerConfig`]. + #[must_use] + pub fn config(mut self, config: ServerConfig) -> Self { + self.config = config; + self + } + + /// Attach TLS material so [`CompioServer::spawn_tls`] becomes usable. + #[must_use] + pub fn tls(mut self, cert: TlsCert) -> Self { + self.tls = Some(cert); + self + } + + /// Finalize and produce the [`CompioServer`]. + pub fn build(self) -> CompioServer { + CompioServer { + config: self.config, + tls: self.tls, + } + } +} + +/// Compio-runtime server entry point. Construct with [`CompioServer::builder`]. +/// +/// Mirrors the tokio [`Server`] API but drives the compio runtime โ€” io_uring +/// on Linux, IOCP on Windows, kqueue on macOS โ€” under the hood. +#[cfg(feature = "compio")] +#[derive(Debug, Clone)] +pub struct CompioServer { + config: ServerConfig, + tls: Option, +} + +#[cfg(feature = "compio")] +impl CompioServer { + /// Start a fresh fluent builder. + #[must_use] + pub fn builder() -> CompioServerBuilder { + CompioServerBuilder::default() + } + + /// Borrow the underlying [`ServerConfig`]. + #[inline] + pub fn config(&self) -> &ServerConfig { + &self.config + } + + /// Spawn a compio HTTP/1 server. + pub fn spawn_http(&self, listener: compio::net::TcpListener, router: Router) -> ServerHandle { + let (handle, shutdown_fut) = make_handle(self.config.drain_timeout); + let config = self.config.clone(); + spawn_done_compio(handle.done.clone(), async move { + crate::server_compio::serve_with_shutdown_and_config(listener, router, shutdown_fut, config) + .await; + }); + handle + } + + /// Spawn a compio TLS server. + #[cfg(feature = "compio-tls")] + pub fn spawn_tls(&self, listener: compio::net::TcpListener, router: Router) -> ServerHandle { + let tls = self + .tls + .clone() + .expect("CompioServer::spawn_tls requires a TlsCert (use builder().tls(...))"); + let (handle, shutdown_fut) = make_handle(self.config.drain_timeout); + let config = self.config.clone(); + spawn_done_compio(handle.done.clone(), async move { + let TlsCert::PemPaths { cert_path, key_path } = tls; + crate::server_tls_compio::serve_tls_with_shutdown_and_config( + listener, + router, + Some(cert_path.as_str()), + Some(key_path.as_str()), + shutdown_fut, + config, + ) + .await; + }); + handle + } +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +fn make_handle(drain_timeout: Duration) -> (ServerHandle, impl Future + Send + 'static) { + let shutdown = Arc::new(Notify::new()); + let done = Arc::new(Notify::new()); + let shutdown_for_task = shutdown.clone(); + // Hold the Arc inside the future so it stays alive across the spawn move, + // and call notified() *inside* an async block so the same NotifyFuture is + // polled across wakeups (a fresh notified() per poll loses the racing + // notify_waiters() and deadlocks). + let fut = async move { + shutdown_for_task.notified().await; + }; + ( + ServerHandle { + shutdown, + done, + drain_timeout, + }, + fut, + ) +} + +#[cfg(not(feature = "compio"))] +fn spawn_done(done: Arc, fut: F) +where + F: Future + Send + 'static, +{ + tokio::spawn(async move { + fut.await; + done.notify_waiters(); + }); +} + +#[cfg(feature = "compio")] +fn spawn_done_compio(done: Arc, fut: F) +where + F: Future + 'static, +{ + compio::runtime::spawn(async move { + fut.await; + done.notify_waiters(); + }) + .detach(); +} diff --git a/tako-server/src/lib.rs b/tako-server/src/lib.rs index e166553..6c3a5bf 100644 --- a/tako-server/src/lib.rs +++ b/tako-server/src/lib.rs @@ -11,14 +11,127 @@ use std::io::Write; use std::io::{self}; use std::net::SocketAddr; use std::str::FromStr; +use std::time::Duration; + +/// Production-readiness knobs shared by every Tako server transport. +/// +/// `Default` mirrors the historical hardcoded values (30 s drain, 30 s header +/// read, 100 H2 streams, โ€ฆ) so existing call sites keep their behavior. Pass +/// a populated `ServerConfig` to `*_with_config` entry points to override +/// individual knobs. +#[derive(Debug, Clone)] +pub struct ServerConfig { + /// Maximum time the coordinator waits for in-flight connections to finish + /// after a shutdown signal. After this elapses, remaining tasks are aborted. + pub drain_timeout: Duration, + /// Maximum time hyper waits for the request line + headers to arrive. + /// `None` disables the timeout (the previous behavior). + pub header_read_timeout: Option, + /// HTTP/1 keep-alive (default `true`). + pub keep_alive: bool, + /// HTTP/1 keep-alive idle timeout (Hyper default applies if `None`). + pub keep_alive_timeout: Option, + /// HTTP/2 `SETTINGS_MAX_CONCURRENT_STREAMS` cap. + pub h2_max_concurrent_streams: u32, + /// HTTP/2 `SETTINGS_MAX_HEADER_LIST_SIZE` cap (bytes). + pub h2_max_header_list_size: u32, + /// HTTP/2 send-buffer cap per stream (bytes). + pub h2_max_send_buf_size: usize, + /// HTTP/2 pending-accept RST_STREAM cap (CVE-2023-44487 mitigation). + pub h2_max_pending_accept_reset_streams: usize, + /// HTTP/2 keep-alive ping interval. `None` disables. + pub h2_keep_alive_interval: Option, + /// Optional ceiling on concurrent in-flight connections. Enforced via a + /// semaphore in the accept loop; `None` disables. + pub max_connections: Option, + /// Read deadline applied before the PROXY protocol header is parsed. + pub proxy_read_timeout: Duration, + /// Backoff schedule for `accept()` errors (typically EMFILE/ENFILE). + pub accept_backoff: AcceptBackoff, +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + drain_timeout: Duration::from_secs(30), + header_read_timeout: Some(Duration::from_secs(30)), + keep_alive: true, + keep_alive_timeout: None, + h2_max_concurrent_streams: 100, + h2_max_header_list_size: 16 * 1024, + h2_max_send_buf_size: 1024 * 1024, + h2_max_pending_accept_reset_streams: 50, + h2_keep_alive_interval: None, + max_connections: None, + proxy_read_timeout: Duration::from_secs(10), + accept_backoff: AcceptBackoff::new(), + } + } +} + +/// Exponential backoff state for `listener.accept()` retry loops. +/// +/// Accept errors (typically `EMFILE`/`ENFILE` when the process has run out of +/// file descriptors, or transient `ConnectionAborted` under load) are not fatal +/// to the listener. Servers should log, sleep, and re-poll. Use [`AcceptBackoff`] +/// to keep the sleep schedule consistent across transports without duplicating +/// the constants in every `serve_*` implementation. +#[derive(Debug, Clone, Copy)] +pub struct AcceptBackoff { + current: Duration, + max: Duration, +} + +impl Default for AcceptBackoff { + fn default() -> Self { + Self::new() + } +} + +impl AcceptBackoff { + /// Construct with the default 5 ms โ†’ 1 s schedule. + #[must_use] + pub const fn new() -> Self { + Self { + current: Duration::from_millis(5), + max: Duration::from_secs(1), + } + } + + /// Reset the schedule after a successful accept. + #[inline] + pub fn reset(&mut self) { + self.current = Duration::from_millis(5); + } + + /// Sleep for the current backoff and double it (capped at `max`). + /// Use the tokio `sleep` so this is cooperative on the runtime that runs + /// the accept loop. + pub async fn sleep_and_grow(&mut self) { + let d = self.current; + self.current = (self.current * 2).min(self.max); + tokio::time::sleep(d).await; + } +} #[cfg(not(feature = "compio"))] mod server; +mod builder; +#[cfg(not(feature = "compio"))] +pub use builder::{Server, ServerBuilder}; +#[cfg(feature = "compio")] +pub use builder::{CompioServer, CompioServerBuilder}; +pub use builder::{ServerHandle, TlsCert, either}; + #[cfg(not(feature = "compio"))] pub use server::serve; #[cfg(not(feature = "compio"))] +pub use server::serve_with_config; +#[cfg(not(feature = "compio"))] pub use server::serve_with_shutdown; +#[cfg(not(feature = "compio"))] +pub use server::serve_with_shutdown_and_config; #[cfg(feature = "compio")] #[cfg_attr(docsrs, doc(cfg(feature = "compio")))] @@ -26,7 +139,11 @@ pub mod server_compio; #[cfg(feature = "compio")] pub use server_compio::serve; #[cfg(feature = "compio")] +pub use server_compio::serve_with_config; +#[cfg(feature = "compio")] pub use server_compio::serve_with_shutdown; +#[cfg(feature = "compio")] +pub use server_compio::serve_with_shutdown_and_config; /// TLS/SSL server implementation for secure connections. #[cfg(all(not(feature = "compio-tls"), feature = "tls"))] @@ -37,7 +154,13 @@ pub mod server_tls; pub use server_tls::serve_tls; #[cfg(all(not(feature = "compio"), feature = "tls"))] #[cfg_attr(docsrs, doc(cfg(feature = "tls")))] +pub use server_tls::serve_tls_with_config; +#[cfg(all(not(feature = "compio"), feature = "tls"))] +#[cfg_attr(docsrs, doc(cfg(feature = "tls")))] pub use server_tls::serve_tls_with_shutdown; +#[cfg(all(not(feature = "compio"), feature = "tls"))] +#[cfg_attr(docsrs, doc(cfg(feature = "tls")))] +pub use server_tls::serve_tls_with_shutdown_and_config; #[cfg(feature = "compio-tls")] #[cfg_attr(docsrs, doc(cfg(feature = "compio-tls")))] @@ -45,7 +168,11 @@ pub mod server_tls_compio; #[cfg(feature = "compio-tls")] pub use server_tls_compio::serve_tls; #[cfg(feature = "compio-tls")] +pub use server_tls_compio::serve_tls_with_config; +#[cfg(feature = "compio-tls")] pub use server_tls_compio::serve_tls_with_shutdown; +#[cfg(feature = "compio-tls")] +pub use server_tls_compio::serve_tls_with_shutdown_and_config; /// HTTP/3 server implementation using QUIC transport. #[cfg(all(feature = "http3", not(feature = "compio")))] @@ -56,11 +183,30 @@ pub mod server_h3; pub use server_h3::serve_h3; #[cfg(all(feature = "http3", not(feature = "compio")))] #[cfg_attr(docsrs, doc(cfg(feature = "http3")))] +pub use server_h3::serve_h3_with_config; +#[cfg(all(feature = "http3", not(feature = "compio")))] +#[cfg_attr(docsrs, doc(cfg(feature = "http3")))] pub use server_h3::serve_h3_with_shutdown; +#[cfg(all(feature = "http3", not(feature = "compio")))] +#[cfg_attr(docsrs, doc(cfg(feature = "http3")))] +pub use server_h3::serve_h3_with_shutdown_and_config; /// Raw TCP server for handling arbitrary TCP connections. pub mod server_tcp; +/// HTTP/2 cleartext (h2c, prior knowledge) server. +#[cfg(all(feature = "http2", not(feature = "compio")))] +#[cfg_attr(docsrs, doc(cfg(feature = "http2")))] +pub mod server_h2c; +#[cfg(all(feature = "http2", not(feature = "compio")))] +pub use server_h2c::serve_h2c; +#[cfg(all(feature = "http2", not(feature = "compio")))] +pub use server_h2c::serve_h2c_with_config; +#[cfg(all(feature = "http2", not(feature = "compio")))] +pub use server_h2c::serve_h2c_with_shutdown; +#[cfg(all(feature = "http2", not(feature = "compio")))] +pub use server_h2c::serve_h2c_with_shutdown_and_config; + /// UDP datagram server for handling raw UDP packets. pub mod server_udp; diff --git a/tako-server/src/proxy_protocol.rs b/tako-server/src/proxy_protocol.rs index d5b5b1e..c93aad6 100644 --- a/tako-server/src/proxy_protocol.rs +++ b/tako-server/src/proxy_protocol.rs @@ -44,7 +44,6 @@ use std::net::Ipv4Addr; use std::net::Ipv6Addr; use std::net::SocketAddr; use std::sync::Arc; -use std::time::Duration; use hyper::server::conn::http1; use hyper::service::service_fn; @@ -52,15 +51,15 @@ use tokio::io::AsyncReadExt; use tokio::task::JoinSet; use tako_core::body::TakoBody; +use tako_core::conn_info::ConnInfo; use tako_core::router::Router; use tako_core::types::BoxError; +use crate::ServerConfig; + /// PROXY protocol v2 binary signature (12 bytes). const PROXY_V2_SIG: [u8; 12] = *b"\r\n\r\n\0\r\nQUIT\n"; -/// Default drain timeout for graceful shutdown. -const DEFAULT_DRAIN_TIMEOUT: Duration = Duration::from_secs(30); - /// PROXY protocol version. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProxyVersion { @@ -76,10 +75,59 @@ pub enum ProxyTransport { Unknown, } +/// Raw PROXY v2 TLV (Type-Length-Value) field. +/// +/// Most known TLV types (`authority`, `aws_vpc_endpoint_id`, `tls_*`) are also +/// surfaced as dedicated fields on [`ProxyHeader`]; `tlvs` keeps the unparsed +/// list so callers can extract custom or future-defined types. +#[derive(Debug, Clone)] +pub struct ProxyTlv { + /// PP2_TYPE_* identifier byte. + pub kind: u8, + /// Raw TLV value bytes. + pub value: Vec, +} + +/// TLS-derived PROXY v2 sub-TLVs (PP2_TYPE_SSL container, type 0x20). +#[derive(Debug, Clone, Default)] +pub struct ProxyTlsInfo { + /// PP2_CLIENT_SSL bitfield. + pub client_flags: u8, + /// rustls/openssl-style verify result code. + pub verify: u32, + /// PP2_SUBTYPE_SSL_VERSION (e.g. `"TLSv1.3"`). + pub version: Option, + /// PP2_SUBTYPE_SSL_CN (peer common name). + pub common_name: Option, + /// PP2_SUBTYPE_SSL_CIPHER. + pub cipher: Option, + /// PP2_SUBTYPE_SSL_SIG_ALG. + pub sig_alg: Option, + /// PP2_SUBTYPE_SSL_KEY_ALG. + pub key_alg: Option, +} + +// PP2 TLV type identifiers (per HAProxy spec). +const PP2_TYPE_ALPN: u8 = 0x01; +const PP2_TYPE_AUTHORITY: u8 = 0x02; +const PP2_TYPE_CRC32C: u8 = 0x03; +const PP2_TYPE_NOOP: u8 = 0x04; +const PP2_TYPE_UNIQUE_ID: u8 = 0x05; +const PP2_TYPE_SSL: u8 = 0x20; +const PP2_SUBTYPE_SSL_VERSION: u8 = 0x21; +const PP2_SUBTYPE_SSL_CN: u8 = 0x22; +const PP2_SUBTYPE_SSL_CIPHER: u8 = 0x23; +const PP2_SUBTYPE_SSL_SIG_ALG: u8 = 0x24; +const PP2_SUBTYPE_SSL_KEY_ALG: u8 = 0x25; +const PP2_TYPE_NETNS: u8 = 0x30; +const PP2_TYPE_AWS_VPC_ENDPOINT_ID: u8 = 0xEA; + /// Parsed PROXY protocol header. /// /// Contains the real client address (source) and the proxy/server address -/// (destination) extracted from the PROXY protocol header. +/// (destination) extracted from the PROXY protocol header. PROXY v2 TLVs +/// (authority, AWS VPC endpoint ID, TLS info, โ€ฆ) are surfaced both as raw +/// [`ProxyTlv`]s and as typed fields where they map cleanly. #[derive(Debug, Clone)] pub struct ProxyHeader { /// Protocol version (v1 text or v2 binary). @@ -90,6 +138,115 @@ pub struct ProxyHeader { pub source: Option, /// Proxy/server address (the destination the client connected to). pub destination: Option, + /// AF_UNIX source path, when the connection family is Unix. + pub source_unix: Option, + /// AF_UNIX destination path, when the connection family is Unix. + pub destination_unix: Option, + /// PP2_TYPE_AUTHORITY (a.k.a. SNI) value if present. + pub authority: Option, + /// PP2_TYPE_ALPN protocol bytes if present. + pub alpn: Option>, + /// AWS VPC endpoint identifier (PP2 type 0xEA) if present. + pub aws_vpc_endpoint_id: Option, + /// Decoded PP2_TYPE_SSL sub-TLVs. + pub tls: Option, + /// Unique connection identifier (PP2 type 0x05). + pub unique_id: Option>, + /// Raw TLV list โ€” kept for forward-compatibility / custom types. + pub tlvs: Vec, +} + +impl ProxyHeader { + fn empty(version: ProxyVersion, transport: ProxyTransport) -> Self { + Self { + version, + transport, + source: None, + destination: None, + source_unix: None, + destination_unix: None, + authority: None, + alpn: None, + aws_vpc_endpoint_id: None, + tls: None, + unique_id: None, + tlvs: Vec::new(), + } + } +} + +/// Walks a PROXY v2 TLV byte stream and applies each entry to a [`ProxyHeader`]. +fn apply_tlvs(header: &mut ProxyHeader, mut buf: &[u8]) { + while buf.len() >= 3 { + let kind = buf[0]; + let len = u16::from_be_bytes([buf[1], buf[2]]) as usize; + if buf.len() < 3 + len { + break; + } + let value = &buf[3..3 + len]; + match kind { + PP2_TYPE_ALPN => header.alpn = Some(value.to_vec()), + PP2_TYPE_AUTHORITY => { + if let Ok(s) = std::str::from_utf8(value) { + header.authority = Some(s.to_string()); + } + } + PP2_TYPE_AWS_VPC_ENDPOINT_ID => { + if let Ok(s) = std::str::from_utf8(value) { + header.aws_vpc_endpoint_id = Some(s.to_string()); + } + } + PP2_TYPE_UNIQUE_ID => header.unique_id = Some(value.to_vec()), + PP2_TYPE_SSL => { + // PP2_TYPE_SSL container layout: 1 byte client flags, 4 bytes verify + // (BE), then nested sub-TLVs. + if value.len() >= 5 { + let mut tls = ProxyTlsInfo { + client_flags: value[0], + verify: u32::from_be_bytes([value[1], value[2], value[3], value[4]]), + ..Default::default() + }; + let mut sub = &value[5..]; + while sub.len() >= 3 { + let sk = sub[0]; + let slen = u16::from_be_bytes([sub[1], sub[2]]) as usize; + if sub.len() < 3 + slen { + break; + } + let sval = &sub[3..3 + slen]; + match sk { + PP2_SUBTYPE_SSL_VERSION => { + tls.version = std::str::from_utf8(sval).ok().map(str::to_string) + } + PP2_SUBTYPE_SSL_CN => { + tls.common_name = std::str::from_utf8(sval).ok().map(str::to_string) + } + PP2_SUBTYPE_SSL_CIPHER => { + tls.cipher = std::str::from_utf8(sval).ok().map(str::to_string) + } + PP2_SUBTYPE_SSL_SIG_ALG => { + tls.sig_alg = std::str::from_utf8(sval).ok().map(str::to_string) + } + PP2_SUBTYPE_SSL_KEY_ALG => { + tls.key_alg = std::str::from_utf8(sval).ok().map(str::to_string) + } + _ => {} + } + sub = &sub[3 + slen..]; + } + header.tls = Some(tls); + } + } + // CRC32C / NOOP / NETNS are accepted but currently dropped after the raw push. + PP2_TYPE_CRC32C | PP2_TYPE_NOOP | PP2_TYPE_NETNS => {} + _ => {} + } + header.tlvs.push(ProxyTlv { + kind, + value: value.to_vec(), + }); + buf = &buf[3 + len..]; + } } /// Reads and parses a PROXY protocol header from a stream. @@ -165,12 +322,7 @@ async fn parse_v1( } match parts[1] { - "UNKNOWN" => Ok(ProxyHeader { - version: ProxyVersion::V1, - transport: ProxyTransport::Unknown, - source: None, - destination: None, - }), + "UNKNOWN" => Ok(ProxyHeader::empty(ProxyVersion::V1, ProxyTransport::Unknown)), proto @ ("TCP4" | "TCP6") => { if parts.len() < 6 { return Err(std::io::Error::new( @@ -207,12 +359,10 @@ async fn parse_v1( ProxyTransport::Udp }; - Ok(ProxyHeader { - version: ProxyVersion::V1, - transport, - source: Some(SocketAddr::new(src_ip, src_port)), - destination: Some(SocketAddr::new(dst_ip, dst_port)), - }) + let mut header = ProxyHeader::empty(ProxyVersion::V1, transport); + header.source = Some(SocketAddr::new(src_ip, src_port)); + header.destination = Some(SocketAddr::new(dst_ip, dst_port)); + Ok(header) } other => Err(std::io::Error::new( std::io::ErrorKind::InvalidData, @@ -255,12 +405,7 @@ async fn parse_v2( // LOCAL command: connection from proxy itself, no address info if command == 0 { - return Ok(ProxyHeader { - version: ProxyVersion::V2, - transport: ProxyTransport::Unknown, - source: None, - destination: None, - }); + return Ok(ProxyHeader::empty(ProxyVersion::V2, ProxyTransport::Unknown)); } let transport = match protocol { @@ -269,20 +414,18 @@ async fn parse_v2( _ => ProxyTransport::Unknown, }; - match family { + let mut header = ProxyHeader::empty(ProxyVersion::V2, transport); + + let consumed: usize = match family { // AF_INET (IPv4) 1 if addr_buf.len() >= 12 => { let src_ip = Ipv4Addr::new(addr_buf[0], addr_buf[1], addr_buf[2], addr_buf[3]); let dst_ip = Ipv4Addr::new(addr_buf[4], addr_buf[5], addr_buf[6], addr_buf[7]); let src_port = u16::from_be_bytes([addr_buf[8], addr_buf[9]]); let dst_port = u16::from_be_bytes([addr_buf[10], addr_buf[11]]); - - Ok(ProxyHeader { - version: ProxyVersion::V2, - transport, - source: Some(SocketAddr::new(IpAddr::V4(src_ip), src_port)), - destination: Some(SocketAddr::new(IpAddr::V4(dst_ip), dst_port)), - }) + header.source = Some(SocketAddr::new(IpAddr::V4(src_ip), src_port)); + header.destination = Some(SocketAddr::new(IpAddr::V4(dst_ip), dst_port)); + 12 } // AF_INET6 (IPv6) 2 if addr_buf.len() >= 36 => { @@ -290,22 +433,39 @@ async fn parse_v2( let dst_ip = Ipv6Addr::from(<[u8; 16]>::try_from(&addr_buf[16..32]).unwrap()); let src_port = u16::from_be_bytes([addr_buf[32], addr_buf[33]]); let dst_port = u16::from_be_bytes([addr_buf[34], addr_buf[35]]); - - Ok(ProxyHeader { - version: ProxyVersion::V2, - transport, - source: Some(SocketAddr::new(IpAddr::V6(src_ip), src_port)), - destination: Some(SocketAddr::new(IpAddr::V6(dst_ip), dst_port)), - }) + header.source = Some(SocketAddr::new(IpAddr::V6(src_ip), src_port)); + header.destination = Some(SocketAddr::new(IpAddr::V6(dst_ip), dst_port)); + 36 } - // UNSPEC or unknown - _ => Ok(ProxyHeader { - version: ProxyVersion::V2, - transport, - source: None, - destination: None, - }), + // AF_UNIX โ€” 108-byte src + 108-byte dst NUL-terminated paths. + 3 if addr_buf.len() >= 216 => { + let src = parse_unix_path(&addr_buf[0..108]); + let dst = parse_unix_path(&addr_buf[108..216]); + header.source_unix = src; + header.destination_unix = dst; + 216 + } + // UNSPEC or unknown โ€” payload past addr_buf is still treated as TLVs. + _ => 0, + }; + + // Walk TLVs that follow the address payload. + if consumed < addr_buf.len() { + apply_tlvs(&mut header, &addr_buf[consumed..]); } + + Ok(header) +} + +/// Decode a NUL-terminated AF_UNIX path. Returns None if the path is empty. +fn parse_unix_path(bytes: &[u8]) -> Option { + let nul = bytes.iter().position(|b| *b == 0).unwrap_or(bytes.len()); + if nul == 0 { + return None; + } + std::str::from_utf8(&bytes[..nul]) + .ok() + .map(|s| std::path::PathBuf::from(s.to_string())) } /// Starts an HTTP server that parses PROXY protocol headers on each connection. @@ -314,7 +474,14 @@ async fn parse_v2( /// extensions as `SocketAddr` (overriding the TCP peer address). The raw /// `ProxyHeader` is also available via `req.extensions().get::()`. pub async fn serve_http_with_proxy_protocol(listener: tokio::net::TcpListener, router: Router) { - if let Err(e) = run_proxy_http(listener, router, None::>).await { + if let Err(e) = run_proxy_http( + listener, + router, + None::>, + ServerConfig::default(), + ) + .await + { tracing::error!("PROXY protocol HTTP server error: {e}"); } } @@ -325,7 +492,31 @@ pub async fn serve_http_with_proxy_protocol_and_shutdown( router: Router, signal: impl Future, ) { - if let Err(e) = run_proxy_http(listener, router, Some(signal)).await { + if let Err(e) = run_proxy_http(listener, router, Some(signal), ServerConfig::default()).await { + tracing::error!("PROXY protocol HTTP server error: {e}"); + } +} + +/// Like [`serve_http_with_proxy_protocol`] with caller-supplied [`ServerConfig`]. +pub async fn serve_http_with_proxy_protocol_and_config( + listener: tokio::net::TcpListener, + router: Router, + config: ServerConfig, +) { + if let Err(e) = run_proxy_http(listener, router, None::>, config).await + { + tracing::error!("PROXY protocol HTTP server error: {e}"); + } +} + +/// Like [`serve_http_with_proxy_protocol_and_shutdown`] with caller-supplied [`ServerConfig`]. +pub async fn serve_http_with_proxy_protocol_shutdown_and_config( + listener: tokio::net::TcpListener, + router: Router, + signal: impl Future, + config: ServerConfig, +) { + if let Err(e) = run_proxy_http(listener, router, Some(signal), config).await { tracing::error!("PROXY protocol HTTP server error: {e}"); } } @@ -334,6 +525,7 @@ async fn run_proxy_http( listener: tokio::net::TcpListener, router: Router, signal: Option>, + config: ServerConfig, ) -> Result<(), BoxError> { let router = Arc::new(router); @@ -346,6 +538,12 @@ async fn run_proxy_http( ); let mut join_set = JoinSet::new(); + let mut accept_backoff = config.accept_backoff; + let max_conn_semaphore = config.max_connections.map(|n| Arc::new(tokio::sync::Semaphore::new(n))); + let drain_timeout = config.drain_timeout; + let header_read_timeout = config.header_read_timeout; + let keep_alive = config.keep_alive; + let proxy_read_timeout = config.proxy_read_timeout; let signal = signal.map(|s| Box::pin(s)); let signal_fused = async { if let Some(s) = signal { @@ -359,19 +557,43 @@ async fn run_proxy_http( loop { tokio::select! { result = listener.accept() => { - let (mut stream, _tcp_addr) = result?; + let (mut stream, _tcp_addr) = match result { + Ok(v) => { accept_backoff.reset(); v } + Err(err) => { + tracing::warn!("PROXY accept failed: {err}; backing off"); + accept_backoff.sleep_and_grow().await; + continue; + } + }; + let permit = if let Some(sem) = &max_conn_semaphore { + match sem.clone().acquire_owned().await { + Ok(p) => Some(p), + Err(_) => continue, + } + } else { + None + }; let _ = stream.set_nodelay(true); let router = router.clone(); join_set.spawn(async move { - // Parse PROXY protocol header - let proxy_header = match read_proxy_protocol(&mut stream).await { - Ok(h) => h, - Err(e) => { - tracing::error!("Failed to parse PROXY protocol: {e}"); - return; - } - }; + // Parse PROXY protocol header under a read deadline so a stalled + // client cannot pin a worker task forever. + let proxy_header = + match tokio::time::timeout(proxy_read_timeout, read_proxy_protocol(&mut stream)).await { + Ok(Ok(h)) => h, + Ok(Err(e)) => { + tracing::warn!("Failed to parse PROXY protocol: {e}"); + return; + } + Err(_) => { + tracing::warn!( + "PROXY protocol read deadline ({:?}) elapsed; dropping connection", + proxy_read_timeout, + ); + return; + } + }; let real_addr = proxy_header.source; let io = hyper_util::rt::TokioIo::new(stream); @@ -381,9 +603,17 @@ async fn run_proxy_http( let proxy_header = proxy_header.clone(); let real_addr = real_addr; async move { - // Insert real client address + // Strip any inbound X-Forwarded-For: clients behind a PROXY-protocol + // hop must not be able to spoof their address through the header. + // The PROXY-protocol-supplied source becomes the authoritative one. + req.headers_mut().remove(http::header::FORWARDED); + req.headers_mut().remove("x-forwarded-for"); + req.headers_mut().remove("x-forwarded-host"); + req.headers_mut().remove("x-forwarded-proto"); + if let Some(addr) = real_addr { req.extensions_mut().insert(addr); + req.extensions_mut().insert(ConnInfo::tcp(addr)); } req.extensions_mut().insert(proxy_header); let response = router.dispatch(req.map(TakoBody::incoming)).await; @@ -392,7 +622,11 @@ async fn run_proxy_http( }); let mut http = http1::Builder::new(); - http.keep_alive(true); + http.keep_alive(keep_alive); + http.timer(hyper_util::rt::TokioTimer::new()); + if let Some(t) = header_read_timeout { + http.header_read_timeout(t); + } let conn = http.serve_connection(io, svc).with_upgrades(); if let Err(err) = conn.await { @@ -402,6 +636,8 @@ async fn run_proxy_http( tracing::error!("Error serving PROXY protocol connection: {err}"); } } + + drop(permit); }); } () = &mut signal_fused => { @@ -411,7 +647,7 @@ async fn run_proxy_http( } } - let drain = tokio::time::timeout(DEFAULT_DRAIN_TIMEOUT, async { + let drain = tokio::time::timeout(drain_timeout, async { while join_set.join_next().await.is_some() {} }); diff --git a/tako-server/src/server.rs b/tako-server/src/server.rs index d6a43d5..c4bcd3c 100644 --- a/tako-server/src/server.rs +++ b/tako-server/src/server.rs @@ -26,29 +26,33 @@ use std::convert::Infallible; use std::future::Future; -use std::time::Duration; +use std::sync::Arc; use hyper::server::conn::http1; use hyper::service::service_fn; use tokio::net::TcpListener; +use tokio::sync::Semaphore; use tokio::task::JoinSet; use tako_core::body::TakoBody; +use tako_core::conn_info::ConnInfo; use tako_core::router::Router; #[cfg(feature = "signals")] -use tako_core::signals::Signal; -#[cfg(feature = "signals")] -use tako_core::signals::SignalArbiter; -#[cfg(feature = "signals")] -use tako_core::signals::ids; +use tako_core::signals::transport as signal_tx; use tako_core::types::BoxError; -/// Default drain timeout for graceful shutdown (30 seconds). -const DEFAULT_DRAIN_TIMEOUT: Duration = Duration::from_secs(30); +use crate::ServerConfig; /// Starts the Tako HTTP server with the given listener and router. pub async fn serve(listener: TcpListener, router: Router) { - if let Err(e) = run(listener, router, None::>).await { + if let Err(e) = run( + listener, + router, + None::>, + ServerConfig::default(), + ) + .await + { tracing::error!("Server error: {e}"); } } @@ -56,13 +60,33 @@ pub async fn serve(listener: TcpListener, router: Router) { /// Starts the Tako HTTP server with graceful shutdown support. /// /// When the `signal` future completes, the server stops accepting new connections -/// and waits up to 30 seconds for in-flight requests to finish. +/// and waits up to `ServerConfig::drain_timeout` (default 30 s) for in-flight +/// requests to finish. pub async fn serve_with_shutdown( listener: TcpListener, router: Router, signal: impl Future, ) { - if let Err(e) = run(listener, router, Some(signal)).await { + if let Err(e) = run(listener, router, Some(signal), ServerConfig::default()).await { + tracing::error!("Server error: {e}"); + } +} + +/// Like [`serve`] but with caller-supplied [`ServerConfig`]. +pub async fn serve_with_config(listener: TcpListener, router: Router, config: ServerConfig) { + if let Err(e) = run(listener, router, None::>, config).await { + tracing::error!("Server error: {e}"); + } +} + +/// Like [`serve_with_shutdown`] but with caller-supplied [`ServerConfig`]. +pub async fn serve_with_shutdown_and_config( + listener: TcpListener, + router: Router, + signal: impl Future, + config: ServerConfig, +) { + if let Err(e) = run(listener, router, Some(signal), config).await { tracing::error!("Server error: {e}"); } } @@ -72,6 +96,7 @@ async fn run( listener: TcpListener, router: Router, signal: Option>, + config: ServerConfig, ) -> Result<(), BoxError> { #[cfg(feature = "tako-tracing")] tako_core::tracing::init_tracing(); @@ -88,20 +113,17 @@ async fn run( let addr_str = listener.local_addr()?.to_string(); #[cfg(feature = "signals")] - { - // Emit server.started - SignalArbiter::emit_app( - Signal::with_capacity(ids::SERVER_STARTED, 3) - .meta("addr", addr_str.clone()) - .meta("transport", "tcp") - .meta("tls", "false"), - ) - .await; - } + signal_tx::emit_server_started(&addr_str, "tcp", false).await; tracing::debug!("Tako listening on {}", addr_str); let mut join_set = JoinSet::new(); + let mut accept_backoff = config.accept_backoff; + let max_conn_semaphore = config.max_connections.map(|n| Arc::new(Semaphore::new(n))); + let keep_alive = config.keep_alive; + let header_read_timeout = config.header_read_timeout; + let keep_alive_timeout = config.keep_alive_timeout; + let drain_timeout = config.drain_timeout; let signal = signal.map(|s| Box::pin(s)); let signal_fused = async { if let Some(s) = signal { @@ -115,58 +137,60 @@ async fn run( loop { tokio::select! { result = listener.accept() => { - let (stream, addr) = result?; + let (stream, addr) = match result { + Ok(v) => { accept_backoff.reset(); v } + Err(err) => { + // Accept errors (typically EMFILE/ENFILE under FD pressure, or + // ConnectionAborted under load) are not fatal โ€” log, back off, retry. + tracing::warn!("accept failed: {err}; backing off"); + accept_backoff.sleep_and_grow().await; + continue; + } + }; + + // Optional connection cap: park here until a permit is available so + // we exert backpressure on the kernel listen queue rather than + // accepting unbounded work. + let permit = if let Some(sem) = &max_conn_semaphore { + match sem.clone().acquire_owned().await { + Ok(p) => Some(p), + Err(_) => continue, + } + } else { + None + }; + let _ = stream.set_nodelay(true); let io = hyper_util::rt::TokioIo::new(stream); join_set.spawn(async move { #[cfg(feature = "signals")] - { - SignalArbiter::emit_app( - Signal::with_capacity(ids::CONNECTION_OPENED, 1) - .meta("remote_addr", addr.to_string()), - ) - .await; - } + signal_tx::emit_connection_opened(&addr.to_string(), false, None).await; // `router` is `&'static Router` โ€” no Arc clone per connection or request. + // Per-request REQUEST_STARTED / REQUEST_COMPLETED signals fire from + // inside Router::dispatch, so transports stay free of that boilerplate. let svc = service_fn(move |mut req| async move { - #[cfg(feature = "signals")] - let path = req.uri().path().to_string(); - #[cfg(feature = "signals")] - let method = req.method().to_string(); - req.extensions_mut().insert(addr); - - #[cfg(feature = "signals")] - { - SignalArbiter::emit_app( - Signal::with_capacity(ids::REQUEST_STARTED, 2) - .meta("method", method.clone()) - .meta("path", path.clone()), - ) - .await; - } - + req.extensions_mut().insert(ConnInfo::tcp(addr)); let response = router.dispatch(req.map(TakoBody::incoming)).await; - - #[cfg(feature = "signals")] - { - SignalArbiter::emit_app( - Signal::with_capacity(ids::REQUEST_COMPLETED, 3) - .meta("method", method) - .meta("path", path) - .meta("status", response.status().as_u16().to_string()), - ) - .await; - } - Ok::<_, Infallible>(response) }); let mut http = http1::Builder::new(); - http.keep_alive(true); + http.keep_alive(keep_alive); http.pipeline_flush(true); + // hyper requires a Timer when header_read_timeout is set; default + // installs the tokio timer integration. + http.timer(hyper_util::rt::TokioTimer::new()); + if let Some(t) = header_read_timeout { + http.header_read_timeout(t); + } + if let Some(t) = keep_alive_timeout { + // Hyper does not expose a keep-alive idle timeout knob on http1 + // builder yet; reserved for future plumb-through. + let _ = t; + } let conn = http.serve_connection(io, svc).with_upgrades(); if let Err(err) = conn.await { @@ -181,13 +205,11 @@ async fn run( } #[cfg(feature = "signals")] - { - SignalArbiter::emit_app( - Signal::with_capacity(ids::CONNECTION_CLOSED, 1) - .meta("remote_addr", addr.to_string()), - ) - .await; - } + signal_tx::emit_connection_closed(&addr.to_string(), false, None).await; + + // Permit lives until here; dropping it returns a slot to the + // max_connections semaphore so the next accept can proceed. + drop(permit); }); } () = &mut signal_fused => { @@ -198,14 +220,14 @@ async fn run( } // Drain in-flight connections - let drain = tokio::time::timeout(DEFAULT_DRAIN_TIMEOUT, async { + let drain = tokio::time::timeout(drain_timeout, async { while join_set.join_next().await.is_some() {} }); if drain.await.is_err() { tracing::warn!( "Drain timeout ({:?}) exceeded, aborting {} remaining connections", - DEFAULT_DRAIN_TIMEOUT, + drain_timeout, join_set.len() ); join_set.abort_all(); diff --git a/tako-server/src/server_compio.rs b/tako-server/src/server_compio.rs index f099efe..3eb13c5 100644 --- a/tako-server/src/server_compio.rs +++ b/tako-server/src/server_compio.rs @@ -2,7 +2,6 @@ use std::convert::Infallible; use std::future::Future; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; -use std::time::Duration; use compio::net::TcpListener; use cyper_core::HyperStream; @@ -12,20 +11,23 @@ use hyper::service::service_fn; use tokio::sync::Notify; use tako_core::body::TakoBody; +use tako_core::conn_info::ConnInfo; use tako_core::router::Router; #[cfg(feature = "signals")] -use tako_core::signals::Signal; -#[cfg(feature = "signals")] -use tako_core::signals::SignalArbiter; -#[cfg(feature = "signals")] -use tako_core::signals::ids; +use tako_core::signals::transport as signal_tx; use tako_core::types::BoxError; -/// Default drain timeout for graceful shutdown (30 seconds). -const DEFAULT_DRAIN_TIMEOUT: Duration = Duration::from_secs(30); +use crate::ServerConfig; pub async fn serve(listener: TcpListener, router: Router) { - if let Err(e) = run(listener, router, None::>).await { + if let Err(e) = run( + listener, + router, + None::>, + ServerConfig::default(), + ) + .await + { tracing::error!("Server error: {e}"); } } @@ -36,7 +38,26 @@ pub async fn serve_with_shutdown( router: Router, signal: impl Future, ) { - if let Err(e) = run(listener, router, Some(signal)).await { + if let Err(e) = run(listener, router, Some(signal), ServerConfig::default()).await { + tracing::error!("Server error: {e}"); + } +} + +/// Like [`serve`] with caller-supplied [`ServerConfig`]. +pub async fn serve_with_config(listener: TcpListener, router: Router, config: ServerConfig) { + if let Err(e) = run(listener, router, None::>, config).await { + tracing::error!("Server error: {e}"); + } +} + +/// Like [`serve_with_shutdown`] with caller-supplied [`ServerConfig`]. +pub async fn serve_with_shutdown_and_config( + listener: TcpListener, + router: Router, + signal: impl Future, + config: ServerConfig, +) { + if let Err(e) = run(listener, router, Some(signal), config).await { tracing::error!("Server error: {e}"); } } @@ -45,6 +66,7 @@ async fn run( listener: TcpListener, router: Router, signal: Option>, + config: ServerConfig, ) -> Result<(), BoxError> { #[cfg(feature = "tako-tracing")] tako_core::tracing::init_tracing(); @@ -56,20 +78,15 @@ async fn run( let addr_str = listener.local_addr()?.to_string(); #[cfg(feature = "signals")] - { - SignalArbiter::emit_app( - Signal::with_capacity(ids::SERVER_STARTED, 3) - .meta("addr", addr_str.clone()) - .meta("transport", "tcp") - .meta("tls", "false"), - ) - .await; - } + signal_tx::emit_server_started(&addr_str, "tcp", false).await; tracing::debug!("Tako listening on {}", addr_str); let inflight = Arc::new(AtomicUsize::new(0)); let drain_notify = Arc::new(Notify::new()); + let drain_timeout = config.drain_timeout; + let keep_alive = config.keep_alive; + let _max_connections = config.max_connections; let signal = signal.map(|s| Box::pin(s)); let mut signal_fused = std::pin::pin!(async { @@ -94,53 +111,20 @@ async fn run( compio::runtime::spawn(async move { #[cfg(feature = "signals")] - { - SignalArbiter::emit_app( - Signal::with_capacity(ids::CONNECTION_OPENED, 1) - .meta("remote_addr", addr.to_string()), - ) - .await; - } + signal_tx::emit_connection_opened(&addr.to_string(), false, None).await; let svc = service_fn(move |mut req| { let router = router.clone(); async move { - #[cfg(feature = "signals")] - let path = req.uri().path().to_string(); - #[cfg(feature = "signals")] - let method = req.method().to_string(); - req.extensions_mut().insert(addr); - - #[cfg(feature = "signals")] - { - SignalArbiter::emit_app( - Signal::with_capacity(ids::REQUEST_STARTED, 2) - .meta("method", method.clone()) - .meta("path", path.clone()), - ) - .await; - } - + req.extensions_mut().insert(ConnInfo::tcp(addr)); let response = router.dispatch(req.map(TakoBody::new)).await; - - #[cfg(feature = "signals")] - { - SignalArbiter::emit_app( - Signal::with_capacity(ids::REQUEST_COMPLETED, 3) - .meta("method", method) - .meta("path", path) - .meta("status", response.status().as_u16().to_string()), - ) - .await; - } - Ok::<_, Infallible>(response) } }); let mut http = http1::Builder::new(); - http.keep_alive(true); + http.keep_alive(keep_alive); let conn = http.serve_connection(io, svc).with_upgrades(); if let Err(err) = conn.await { @@ -152,17 +136,12 @@ async fn run( } #[cfg(feature = "signals")] - { - SignalArbiter::emit_app( - Signal::with_capacity(ids::CONNECTION_CLOSED, 1) - .meta("remote_addr", addr.to_string()), - ) - .await; - } + signal_tx::emit_connection_closed(&addr.to_string(), false, None).await; - if inflight.fetch_sub(1, Ordering::SeqCst) == 1 { - drain_notify.notify_one(); - } + inflight.fetch_sub(1, Ordering::SeqCst); + // Wake every drainer waiter โ€” notify_one() races against waiters + // registered between the load and the await on the coordinator side. + drain_notify.notify_waiters(); }) .detach(); } @@ -173,21 +152,32 @@ async fn run( } } - // Drain in-flight connections - if inflight.load(Ordering::SeqCst) > 0 { + // Drain in-flight connections โ€” re-check inflight after every notification + // and bail when the overall deadline elapses, so a connection that closes + // between the load and the await still satisfies the drain. + let drain_deadline = std::time::Instant::now() + drain_timeout; + while inflight.load(Ordering::SeqCst) > 0 { + let now = std::time::Instant::now(); + if now >= drain_deadline { + tracing::warn!( + "Drain timeout ({:?}) exceeded, {} connections still active", + drain_timeout, + inflight.load(Ordering::SeqCst) + ); + break; + } + let remaining = drain_deadline - now; let drain_wait = drain_notify.notified(); - let sleep = compio::time::sleep(DEFAULT_DRAIN_TIMEOUT); + let sleep = compio::time::sleep(remaining); let drain_wait = std::pin::pin!(drain_wait); let sleep = std::pin::pin!(sleep); - match futures_util::future::select(drain_wait, sleep).await { - Either::Left(_) => {} - Either::Right(_) => { - tracing::warn!( - "Drain timeout ({:?}) exceeded, {} connections still active", - DEFAULT_DRAIN_TIMEOUT, - inflight.load(Ordering::SeqCst) - ); - } + if let Either::Right(_) = futures_util::future::select(drain_wait, sleep).await { + tracing::warn!( + "Drain timeout ({:?}) exceeded, {} connections still active", + drain_timeout, + inflight.load(Ordering::SeqCst) + ); + break; } } diff --git a/tako-server/src/server_h2c.rs b/tako-server/src/server_h2c.rs new file mode 100644 index 0000000..c425fb9 --- /dev/null +++ b/tako-server/src/server_h2c.rs @@ -0,0 +1,180 @@ +#![cfg(feature = "http2")] +#![cfg_attr(docsrs, doc(cfg(feature = "http2")))] + +//! HTTP/2 cleartext (h2c) server, prior-knowledge mode. +//! +//! For deployments where a reverse proxy (Envoy, nginx, HAProxy) speaks HTTP/2 +//! to the upstream over plain TCP โ€” there is no TLS handshake or HTTP/1 +//! Upgrade negotiation. Clients open a TCP connection and immediately send the +//! HTTP/2 connection preface; the server reads frames straight away. +//! +//! Use [`serve_h2c`] for a default-config server, or [`serve_h2c_with_config`] +//! to supply a [`crate::ServerConfig`] (drain timeout, max_connections, h2 caps). + +use std::convert::Infallible; +use std::future::Future; +use std::sync::Arc; + +use hyper::server::conn::http2; +use hyper::service::service_fn; +use hyper_util::rt::{TokioExecutor, TokioIo}; +use tokio::net::TcpListener; +use tokio::sync::Semaphore; +use tokio::task::JoinSet; + +use tako_core::body::TakoBody; +use tako_core::conn_info::ConnInfo; +use tako_core::router::Router; +use tako_core::types::BoxError; + +use crate::ServerConfig; + +/// Starts an h2c server with default [`ServerConfig`]. +pub async fn serve_h2c(listener: TcpListener, router: Router) { + if let Err(e) = run( + listener, + router, + None::>, + ServerConfig::default(), + ) + .await + { + tracing::error!("h2c server error: {e}"); + } +} + +/// Starts an h2c server with graceful shutdown support. +pub async fn serve_h2c_with_shutdown( + listener: TcpListener, + router: Router, + signal: impl Future, +) { + if let Err(e) = run(listener, router, Some(signal), ServerConfig::default()).await { + tracing::error!("h2c server error: {e}"); + } +} + +/// Like [`serve_h2c`] with caller-supplied [`ServerConfig`]. +pub async fn serve_h2c_with_config(listener: TcpListener, router: Router, config: ServerConfig) { + if let Err(e) = run(listener, router, None::>, config).await { + tracing::error!("h2c server error: {e}"); + } +} + +/// Like [`serve_h2c_with_shutdown`] with caller-supplied [`ServerConfig`]. +pub async fn serve_h2c_with_shutdown_and_config( + listener: TcpListener, + router: Router, + signal: impl Future, + config: ServerConfig, +) { + if let Err(e) = run(listener, router, Some(signal), config).await { + tracing::error!("h2c server error: {e}"); + } +} + +async fn run( + listener: TcpListener, + router: Router, + signal: Option>, + config: ServerConfig, +) -> Result<(), BoxError> { + #[cfg(feature = "tako-tracing")] + tako_core::tracing::init_tracing(); + + let router: &'static Router = Box::leak(Box::new(router)); + + #[cfg(feature = "plugins")] + router.setup_plugins_once(); + + let addr_str = listener.local_addr()?.to_string(); + tracing::info!("Tako h2c (HTTP/2 cleartext) listening on {addr_str}"); + + let mut join_set = JoinSet::new(); + let mut accept_backoff = config.accept_backoff; + let max_conn_semaphore = config.max_connections.map(|n| Arc::new(Semaphore::new(n))); + let drain_timeout = config.drain_timeout; + let h2_max_concurrent_streams = config.h2_max_concurrent_streams; + let h2_max_header_list_size = config.h2_max_header_list_size; + let h2_max_send_buf_size = config.h2_max_send_buf_size; + let h2_max_pending_accept_reset_streams = config.h2_max_pending_accept_reset_streams; + let h2_keep_alive_interval = config.h2_keep_alive_interval; + + let signal = signal.map(|s| Box::pin(s)); + let signal_fused = async { + if let Some(s) = signal { + s.await; + } else { + std::future::pending::<()>().await; + } + }; + tokio::pin!(signal_fused); + + loop { + tokio::select! { + result = listener.accept() => { + let (stream, addr) = match result { + Ok(v) => { accept_backoff.reset(); v } + Err(err) => { + tracing::warn!("h2c accept failed: {err}; backing off"); + accept_backoff.sleep_and_grow().await; + continue; + } + }; + let permit = if let Some(sem) = &max_conn_semaphore { + match sem.clone().acquire_owned().await { + Ok(p) => Some(p), + Err(_) => continue, + } + } else { + None + }; + let _ = stream.set_nodelay(true); + let io = TokioIo::new(stream); + + join_set.spawn(async move { + let svc = service_fn(move |mut req| async move { + req.extensions_mut().insert(addr); + req.extensions_mut().insert(ConnInfo::tcp(addr)); + let resp = router.dispatch(req.map(TakoBody::incoming)).await; + Ok::<_, Infallible>(resp) + }); + + let mut h2 = http2::Builder::new(TokioExecutor::new()); + h2.max_concurrent_streams(h2_max_concurrent_streams) + .max_header_list_size(h2_max_header_list_size) + .max_send_buf_size(h2_max_send_buf_size) + .max_pending_accept_reset_streams(h2_max_pending_accept_reset_streams); + if let Some(interval) = h2_keep_alive_interval { + h2.keep_alive_interval(Some(interval)); + } + + if let Err(err) = h2.serve_connection(io, svc).await { + tracing::warn!("h2c connection error: {err}"); + } + + drop(permit); + }); + } + () = &mut signal_fused => { + tracing::info!("Shutdown signal received, draining h2c connections..."); + break; + } + } + } + + let drain = tokio::time::timeout(drain_timeout, async { + while join_set.join_next().await.is_some() {} + }); + if drain.await.is_err() { + tracing::warn!( + "Drain timeout ({:?}) exceeded, aborting {} remaining h2c connections", + drain_timeout, + join_set.len() + ); + join_set.abort_all(); + } + + tracing::info!("h2c server shut down gracefully"); + Ok(()) +} diff --git a/tako-server/src/server_h3.rs b/tako-server/src/server_h3.rs index 3b881a5..ec84517 100644 --- a/tako-server/src/server_h3.rs +++ b/tako-server/src/server_h3.rs @@ -28,35 +28,32 @@ //! # } //! ``` -use std::fs::File; use std::future::Future; -use std::io::BufReader; use std::net::SocketAddr; use std::sync::Arc; -use std::time::Duration; use bytes::Buf; use bytes::Bytes; +use bytes::BytesMut; use h3::quic::BidiStream; +use h3::quic::RecvStream; use h3::server::RequestStream; +use http::HeaderMap; use http::Request; use http_body::Body; +use http_body::Frame; use quinn::crypto::rustls::QuicServerConfig; -use rustls::pki_types::CertificateDer; -use rustls::pki_types::PrivateKeyDer; -use rustls_pemfile::certs; -use rustls_pemfile::pkcs8_private_keys; +use tokio_stream::wrappers::ReceiverStream; use tako_core::body::TakoBody; +use tako_core::conn_info::{ConnInfo, TlsInfo}; use tako_core::router::Router; #[cfg(feature = "signals")] -use tako_core::signals::Signal; -#[cfg(feature = "signals")] -use tako_core::signals::SignalArbiter; -#[cfg(feature = "signals")] -use tako_core::signals::ids; +use tako_core::signals::transport as signal_tx; use tako_core::types::BoxError; +use crate::ServerConfig; + /// Starts an HTTP/3 server with the given router and certificates. /// /// This function creates a QUIC endpoint and listens for incoming HTTP/3 connections. @@ -68,11 +65,18 @@ use tako_core::types::BoxError; /// * `addr` - The socket address to bind to (e.g., "[::]:4433") /// * `certs` - Optional path to the TLS certificate file (defaults to "cert.pem") /// * `key` - Optional path to the TLS private key file (defaults to "key.pem") -/// Default drain timeout for graceful shutdown (30 seconds). -const DEFAULT_DRAIN_TIMEOUT: Duration = Duration::from_secs(30); pub async fn serve_h3(router: Router, addr: &str, certs: Option<&str>, key: Option<&str>) { - if let Err(e) = run(router, addr, certs, key, None::>).await { + if let Err(e) = run( + router, + addr, + certs, + key, + None::>, + ServerConfig::default(), + ) + .await + { tracing::error!("HTTP/3 server error: {e}"); } } @@ -85,7 +89,43 @@ pub async fn serve_h3_with_shutdown( key: Option<&str>, signal: impl Future, ) { - if let Err(e) = run(router, addr, certs, key, Some(signal)).await { + if let Err(e) = run(router, addr, certs, key, Some(signal), ServerConfig::default()).await { + tracing::error!("HTTP/3 server error: {e}"); + } +} + +/// Like [`serve_h3`] with caller-supplied [`ServerConfig`]. +pub async fn serve_h3_with_config( + router: Router, + addr: &str, + certs: Option<&str>, + key: Option<&str>, + config: ServerConfig, +) { + if let Err(e) = run( + router, + addr, + certs, + key, + None::>, + config, + ) + .await + { + tracing::error!("HTTP/3 server error: {e}"); + } +} + +/// Like [`serve_h3_with_shutdown`] with caller-supplied [`ServerConfig`]. +pub async fn serve_h3_with_shutdown_and_config( + router: Router, + addr: &str, + certs: Option<&str>, + key: Option<&str>, + signal: impl Future, + config: ServerConfig, +) { + if let Err(e) = run(router, addr, certs, key, Some(signal), config).await { tracing::error!("HTTP/3 server error: {e}"); } } @@ -97,6 +137,7 @@ async fn run( certs: Option<&str>, key: Option<&str>, signal: Option>, + config: ServerConfig, ) -> Result<(), BoxError> { #[cfg(feature = "tako-tracing")] tako_core::tracing::init_tracing(); @@ -111,7 +152,11 @@ async fn run( .with_no_client_auth() .with_single_cert(certs_vec, key)?; - tls_config.max_early_data_size = u32::MAX; + // 0-RTT (early data) is disabled by default: the server has no replay-protection + // wiring on the request path, so accepting early-data application bytes would + // expose idempotent endpoints to replay attacks. Re-enabling requires plumbing a + // replay cache and a typed extractor โ€” see V2_ROADMAP.md ยง 1.5. + tls_config.max_early_data_size = 0; tls_config.alpn_protocols = vec![b"h3".to_vec()]; let server_config = @@ -128,19 +173,13 @@ async fn run( let addr_str = endpoint.local_addr()?.to_string(); #[cfg(feature = "signals")] - { - SignalArbiter::emit_app( - Signal::with_capacity(ids::SERVER_STARTED, 3) - .meta("addr", addr_str.clone()) - .meta("transport", "quic") - .meta("protocol", "h3"), - ) - .await; - } + signal_tx::emit_server_started(&addr_str, "quic", true).await; tracing::info!("Tako HTTP/3 listening on {}", addr_str); let mut join_set = tokio::task::JoinSet::new(); + let drain_timeout = config.drain_timeout; + let max_conn_semaphore = config.max_connections.map(|n| Arc::new(tokio::sync::Semaphore::new(n))); let signal = signal.map(|s| Box::pin(s)); let signal_fused = async { @@ -156,6 +195,14 @@ async fn run( tokio::select! { maybe_conn = endpoint.accept() => { let Some(new_conn) = maybe_conn else { break }; + let permit = if let Some(sem) = &max_conn_semaphore { + match sem.clone().acquire_owned().await { + Ok(p) => Some(p), + Err(_) => continue, + } + } else { + None + }; let router = router.clone(); join_set.spawn(async move { @@ -164,33 +211,21 @@ async fn run( let remote_addr = conn.remote_address(); #[cfg(feature = "signals")] - { - SignalArbiter::emit_app( - Signal::with_capacity(ids::CONNECTION_OPENED, 2) - .meta("remote_addr", remote_addr.to_string()) - .meta("protocol", "h3"), - ) - .await; - } + signal_tx::emit_connection_opened(&remote_addr.to_string(), true, Some("h3")).await; if let Err(e) = handle_connection(conn, router, remote_addr).await { tracing::error!("HTTP/3 connection error: {e}"); } #[cfg(feature = "signals")] - { - SignalArbiter::emit_app( - Signal::with_capacity(ids::CONNECTION_CLOSED, 2) - .meta("remote_addr", remote_addr.to_string()) - .meta("protocol", "h3"), - ) - .await; - } + signal_tx::emit_connection_closed(&remote_addr.to_string(), true, Some("h3")).await; } Err(e) => { tracing::error!("QUIC connection failed: {e}"); } } + + drop(permit); }); } () = &mut signal_fused => { @@ -204,14 +239,14 @@ async fn run( endpoint.close(0u32.into(), b"server shutting down"); // Drain in-flight connections - let drain = tokio::time::timeout(DEFAULT_DRAIN_TIMEOUT, async { + let drain = tokio::time::timeout(drain_timeout, async { while join_set.join_next().await.is_some() {} }); if drain.await.is_err() { tracing::warn!( "Drain timeout ({:?}) exceeded, aborting {} remaining HTTP/3 connections", - DEFAULT_DRAIN_TIMEOUT, + drain_timeout, join_set.len() ); join_set.abort_all(); @@ -260,76 +295,116 @@ async fn handle_connection( Ok(()) } +/// Channel buffer for the H3 streaming body. +/// +/// Bounds the number of in-flight frames between the QUIC receiver task and the +/// handler so that a slow handler exerts backpressure on the client instead of +/// growing memory unboundedly. +const H3_BODY_CHANNEL_CAPACITY: usize = 8; + +/// Builds a streaming `TakoBody` backed by an HTTP/3 receive stream. +/// +/// Spawns a forwarder task that pulls QUIC chunks via `recv_data`, emits them as +/// `Frame::data`, and then pulls trailers via `recv_trailers` to emit a +/// `Frame::trailers`. The bounded channel provides natural backpressure. +fn build_h3_body(mut recv: RequestStream) -> TakoBody +where + R: RecvStream + Send + 'static, +{ + let (tx, rx) = tokio::sync::mpsc::channel::, BoxError>>(H3_BODY_CHANNEL_CAPACITY); + tokio::spawn(async move { + loop { + match recv.recv_data().await { + Ok(Some(mut chunk)) => { + let mut buf = BytesMut::with_capacity(chunk.remaining()); + while chunk.has_remaining() { + let slice = chunk.chunk(); + buf.extend_from_slice(slice); + let len = slice.len(); + chunk.advance(len); + } + if !buf.is_empty() && tx.send(Ok(Frame::data(buf.freeze()))).await.is_err() { + return; + } + } + Ok(None) => break, + Err(e) => { + let _ = tx.send(Err(Box::new(e) as BoxError)).await; + return; + } + } + } + match recv.recv_trailers().await { + Ok(Some(trailers)) => { + let _ = tx.send(Ok(Frame::trailers(trailers))).await; + } + Ok(None) => {} + Err(e) => { + let _ = tx.send(Err(Box::new(e) as BoxError)).await; + } + } + }); + + TakoBody::from_try_stream(ReceiverStream::new(rx)) +} + /// Handles a single HTTP/3 request. async fn handle_request( req: Request<()>, - mut stream: RequestStream, + stream: RequestStream, router: Arc, remote_addr: SocketAddr, ) -> Result<(), BoxError> where - S: BidiStream, + S: BidiStream + Send + 'static, + >::SendStream: Send + 'static, + >::RecvStream: Send + 'static, { - #[cfg(feature = "signals")] - let path = req.uri().path().to_string(); - #[cfg(feature = "signals")] - let method = req.method().to_string(); + // Per-request signals fire from inside Router::dispatch. - #[cfg(feature = "signals")] - { - SignalArbiter::emit_app( - Signal::with_capacity(ids::REQUEST_STARTED, 3) - .meta("method", method.clone()) - .meta("path", path.clone()) - .meta("protocol", "h3"), - ) - .await; - } - - // Collect request body - let mut body_bytes = Vec::new(); - while let Some(mut chunk) = stream.recv_data().await? { - while chunk.has_remaining() { - let bytes = chunk.chunk(); - body_bytes.extend_from_slice(bytes); - chunk.advance(bytes.len()); - } - } + // Split into send and recv halves so the handler can stream the body while we + // hold the send half locally for the response. + let (mut send_stream, recv_stream) = stream.split(); - // Build request with body + // Build request with a streaming body (data + trailers). let (parts, _) = req.into_parts(); - let body = TakoBody::from(Bytes::from(body_bytes)); + let body = build_h3_body(recv_stream); let mut tako_req = Request::from_parts(parts, body); tako_req.extensions_mut().insert(remote_addr); + tako_req.extensions_mut().insert(ConnInfo::h3( + remote_addr, + TlsInfo { + alpn: Some(b"h3".to_vec()), + sni: None, + version: Some("TLSv1.3"), + }, + )); // Dispatch through router let response = router.dispatch(tako_req).await; - #[cfg(feature = "signals")] - { - SignalArbiter::emit_app( - Signal::with_capacity(ids::REQUEST_COMPLETED, 4) - .meta("method", method) - .meta("path", path) - .meta("status", response.status().as_u16().to_string()) - .meta("protocol", "h3"), - ) - .await; - } - - // Send response + // Send response head let (parts, body) = response.into_parts(); let resp = http::Response::from_parts(parts, ()); + send_stream.send_response(resp).await?; - stream.send_response(resp).await?; - - // Stream body data frame by frame (supports SSE) + // Stream response body frame by frame; preserve trailers through to send_trailers. let mut body = std::pin::pin!(body); - while let Some(frame) = std::future::poll_fn(|cx| body.as_mut().poll_frame(cx)).await { - match frame { + let mut response_trailers: Option = None; + while let Some(frame_res) = std::future::poll_fn(|cx| body.as_mut().poll_frame(cx)).await { + match frame_res { Ok(frame) => { - if let Some(data) = frame.data_ref().filter(|d| !d.is_empty()) { - stream.send_data(data.clone()).await?; + if frame.is_data() { + if let Ok(data) = frame.into_data() + && !data.is_empty() + { + send_stream.send_data(data).await?; + } + } else if frame.is_trailers() { + if let Ok(t) = frame.into_trailers() { + // Last trailer frame wins; HTTP responses are not expected to emit multiple. + response_trailers = Some(t); + } } } Err(e) => { @@ -339,29 +414,19 @@ where } } - stream.finish().await?; + if let Some(trailers) = response_trailers { + send_stream.send_trailers(trailers).await?; + } else { + send_stream.finish().await?; + } Ok(()) } -/// Loads TLS certificates from a PEM-encoded file. -pub fn load_certs(path: &str) -> anyhow::Result>> { - let mut rd = BufReader::new( - File::open(path).map_err(|e| anyhow::anyhow!("failed to open cert file '{}': {}", path, e))?, - ); - certs(&mut rd) - .collect::, _>>() - .map_err(|e| anyhow::anyhow!("failed to parse certs from '{}': {}", path, e)) -} +/// Loads TLS certificates from a PEM-encoded file. Re-export of +/// [`tako_core::tls::load_certs`]. +pub use tako_core::tls::load_certs; -/// Loads a private key from a PEM-encoded file. -pub fn load_key(path: &str) -> anyhow::Result> { - let mut rd = BufReader::new( - File::open(path).map_err(|e| anyhow::anyhow!("failed to open key file '{}': {}", path, e))?, - ); - pkcs8_private_keys(&mut rd) - .next() - .ok_or_else(|| anyhow::anyhow!("no private key found in '{}'", path))? - .map(|k| k.into()) - .map_err(|e| anyhow::anyhow!("bad private key in '{}': {}", path, e)) -} +/// Loads a private key from a PEM-encoded file. Accepts PKCS#8, PKCS#1 (RSA), +/// and SEC1 (EC) PEM blocks. Re-export of [`tako_core::tls::load_key`]. +pub use tako_core::tls::load_key; diff --git a/tako-server/src/server_tls.rs b/tako-server/src/server_tls.rs index 9621208..01d15fb 100644 --- a/tako-server/src/server_tls.rs +++ b/tako-server/src/server_tls.rs @@ -32,11 +32,8 @@ //! ``` use std::convert::Infallible; -use std::fs::File; use std::future::Future; -use std::io::BufReader; use std::sync::Arc; -use std::time::Duration; use hyper::server::conn::http1; #[cfg(feature = "http2")] @@ -45,27 +42,27 @@ use hyper::service::service_fn; #[cfg(feature = "http2")] use hyper_util::rt::TokioExecutor; use hyper_util::rt::TokioIo; -use rustls::pki_types::CertificateDer; -use rustls::pki_types::PrivateKeyDer; -use rustls_pemfile::certs; -use rustls_pemfile::pkcs8_private_keys; use tokio::net::TcpListener; use tokio::task::JoinSet; use tokio_rustls::TlsAcceptor; -use tokio_rustls::rustls::ServerConfig; +use tokio_rustls::rustls::ServerConfig as RustlsServerConfig; use tako_core::body::TakoBody; +use tako_core::conn_info::{ConnInfo, TlsInfo}; use tako_core::router::Router; #[cfg(feature = "signals")] -use tako_core::signals::Signal; -#[cfg(feature = "signals")] -use tako_core::signals::SignalArbiter; -#[cfg(feature = "signals")] -use tako_core::signals::ids; +use tako_core::signals::transport as signal_tx; use tako_core::types::BoxError; -/// Default drain timeout for graceful shutdown (30 seconds). -const DEFAULT_DRAIN_TIMEOUT: Duration = Duration::from_secs(30); +use crate::ServerConfig; + +// HTTP/2 hardening + connection lifetimes are sourced from `ServerConfig`, +// whose `Default` mirrors the historical hardcoded values (30 s drain, 100 +// streams, 16 KiB header list, 1 MiB send buf, 50 pending-accept resets). +// +// Pass a custom [`ServerConfig`] via [`serve_tls_with_config`] / +// [`serve_tls_with_shutdown_and_config`] to override individual knobs while +// keeping perf-neutral defaults for everything you don't touch. /// Starts a TLS-enabled HTTP server with the given listener, router, and certificates. pub async fn serve_tls( @@ -74,7 +71,16 @@ pub async fn serve_tls( certs: Option<&str>, key: Option<&str>, ) { - if let Err(e) = run(listener, router, certs, key, None::>).await { + if let Err(e) = run( + listener, + router, + certs, + key, + None::>, + ServerConfig::default(), + ) + .await + { tracing::error!("TLS server error: {e}"); } } @@ -87,7 +93,52 @@ pub async fn serve_tls_with_shutdown( key: Option<&str>, signal: impl Future, ) { - if let Err(e) = run(listener, router, certs, key, Some(signal)).await { + if let Err(e) = run( + listener, + router, + certs, + key, + Some(signal), + ServerConfig::default(), + ) + .await + { + tracing::error!("TLS server error: {e}"); + } +} + +/// Like [`serve_tls`] but with caller-supplied [`ServerConfig`]. +pub async fn serve_tls_with_config( + listener: TcpListener, + router: Router, + certs: Option<&str>, + key: Option<&str>, + config: ServerConfig, +) { + if let Err(e) = run( + listener, + router, + certs, + key, + None::>, + config, + ) + .await + { + tracing::error!("TLS server error: {e}"); + } +} + +/// Like [`serve_tls_with_shutdown`] but with caller-supplied [`ServerConfig`]. +pub async fn serve_tls_with_shutdown_and_config( + listener: TcpListener, + router: Router, + certs: Option<&str>, + key: Option<&str>, + signal: impl Future, + config: ServerConfig, +) { + if let Err(e) = run(listener, router, certs, key, Some(signal), config).await { tracing::error!("TLS server error: {e}"); } } @@ -99,6 +150,7 @@ pub async fn run( certs: Option<&str>, key: Option<&str>, signal: Option>, + config: ServerConfig, ) -> Result<(), BoxError> { #[cfg(feature = "tako-tracing")] tako_core::tracing::init_tracing(); @@ -106,21 +158,21 @@ pub async fn run( let certs = load_certs(certs.unwrap_or("cert.pem"))?; let key = load_key(key.unwrap_or("key.pem"))?; - let mut config = ServerConfig::builder() + let mut tls_config = RustlsServerConfig::builder() .with_no_client_auth() .with_single_cert(certs, key)?; #[cfg(feature = "http2")] { - config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; + tls_config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; } #[cfg(not(feature = "http2"))] { - config.alpn_protocols = vec![b"http/1.1".to_vec()]; + tls_config.alpn_protocols = vec![b"http/1.1".to_vec()]; } - let acceptor = TlsAcceptor::from(Arc::new(config)); + let acceptor = TlsAcceptor::from(Arc::new(tls_config)); let router = Arc::new(router); // Setup plugins @@ -130,20 +182,26 @@ pub async fn run( let addr_str = listener.local_addr()?.to_string(); #[cfg(feature = "signals")] - { - // Emit server.started (TLS) - SignalArbiter::emit_app( - Signal::with_capacity(ids::SERVER_STARTED, 3) - .meta("addr", addr_str.clone()) - .meta("transport", "tcp") - .meta("tls", "true"), - ) - .await; - } + signal_tx::emit_server_started(&addr_str, "tcp", true).await; tracing::info!("Tako TLS listening on {}", addr_str); let mut join_set = JoinSet::new(); + let mut accept_backoff = config.accept_backoff; + let max_conn_semaphore = config.max_connections.map(|n| Arc::new(tokio::sync::Semaphore::new(n))); + let drain_timeout = config.drain_timeout; + let header_read_timeout = config.header_read_timeout; + let keep_alive = config.keep_alive; + #[cfg(feature = "http2")] + let h2_max_concurrent_streams = config.h2_max_concurrent_streams; + #[cfg(feature = "http2")] + let h2_max_header_list_size = config.h2_max_header_list_size; + #[cfg(feature = "http2")] + let h2_max_send_buf_size = config.h2_max_send_buf_size; + #[cfg(feature = "http2")] + let h2_max_pending_accept_reset_streams = config.h2_max_pending_accept_reset_streams; + #[cfg(feature = "http2")] + let h2_keep_alive_interval = config.h2_keep_alive_interval; let signal = signal.map(|s| Box::pin(s)); let signal_fused = async { if let Some(s) = signal { @@ -157,7 +215,22 @@ pub async fn run( loop { tokio::select! { result = listener.accept() => { - let (stream, addr) = result?; + let (stream, addr) = match result { + Ok(v) => { accept_backoff.reset(); v } + Err(err) => { + tracing::warn!("TLS accept failed: {err}; backing off"); + accept_backoff.sleep_and_grow().await; + continue; + } + }; + let permit = if let Some(sem) = &max_conn_semaphore { + match sem.clone().acquire_owned().await { + Ok(p) => Some(p), + Err(_) => continue, + } + } else { + None + }; let _ = stream.set_nodelay(true); let acceptor = acceptor.clone(); let router = router.clone(); @@ -172,78 +245,70 @@ pub async fn run( }; #[cfg(feature = "signals")] - { - SignalArbiter::emit_app( - Signal::with_capacity(ids::CONNECTION_OPENED, 2) - .meta("remote_addr", addr.to_string()) - .meta("tls", "true"), - ) - .await; - } + signal_tx::emit_connection_opened(&addr.to_string(), true, None).await; + + // Capture TLS metadata once per connection so each request can read + // the same ALPN / SNI / version without touching the live session. + let alpn_proto = tls_stream.get_ref().1.alpn_protocol().map(<[u8]>::to_vec); + let sni = tls_stream + .get_ref() + .1 + .server_name() + .map(str::to_string); + let tls_info = TlsInfo { + alpn: alpn_proto.clone(), + sni, + version: None, + }; + let is_h2 = matches!(alpn_proto.as_deref(), Some(b"h2")); + let conn_info = if is_h2 { + ConnInfo::h2_tls(addr, tls_info) + } else { + ConnInfo::h1_tls(addr, tls_info) + }; #[cfg(feature = "http2")] - let proto = tls_stream.get_ref().1.alpn_protocol().map(|p| p.to_vec()); + let proto = alpn_proto; let io = TokioIo::new(tls_stream); + // Per-request signals fire from inside Router::dispatch. let svc = service_fn(move |mut req| { let r = router.clone(); + let conn_info = conn_info.clone(); async move { - #[cfg(feature = "signals")] - let path = req.uri().path().to_string(); - #[cfg(feature = "signals")] - let method = req.method().to_string(); - req.extensions_mut().insert(addr); - - #[cfg(feature = "signals")] - { - SignalArbiter::emit_app( - Signal::with_capacity(ids::REQUEST_STARTED, 2) - .meta("method", method.clone()) - .meta("path", path.clone()), - ) - .await; - } - + req.extensions_mut().insert(conn_info); let response = r.dispatch(req.map(TakoBody::incoming)).await; - - #[cfg(feature = "signals")] - { - SignalArbiter::emit_app( - Signal::with_capacity(ids::REQUEST_COMPLETED, 3) - .meta("method", method) - .meta("path", path) - .meta("status", response.status().as_u16().to_string()), - ) - .await; - } - Ok::<_, Infallible>(response) } }); #[cfg(feature = "http2")] if proto.as_deref() == Some(b"h2") { - let h2 = http2::Builder::new(TokioExecutor::new()); + let mut h2 = http2::Builder::new(TokioExecutor::new()); + h2.max_concurrent_streams(h2_max_concurrent_streams) + .max_header_list_size(h2_max_header_list_size) + .max_send_buf_size(h2_max_send_buf_size) + .max_pending_accept_reset_streams(h2_max_pending_accept_reset_streams); + if let Some(interval) = h2_keep_alive_interval { + h2.keep_alive_interval(Some(interval)); + } if let Err(e) = h2.serve_connection(io, svc).await { tracing::error!("HTTP/2 error: {e}"); } #[cfg(feature = "signals")] - { - SignalArbiter::emit_app( - Signal::with_capacity(ids::CONNECTION_CLOSED, 2) - .meta("remote_addr", addr.to_string()) - .meta("tls", "true"), - ) - .await; - } + signal_tx::emit_connection_closed(&addr.to_string(), true, None).await; return; } let mut h1 = http1::Builder::new(); - h1.keep_alive(true); + h1.keep_alive(keep_alive); + h1.timer(hyper_util::rt::TokioTimer::new()); + if let Some(t) = header_read_timeout { + h1.header_read_timeout(t); + } if let Err(e) = h1.serve_connection(io, svc).with_upgrades().await { if e.is_incomplete_message() { @@ -254,14 +319,9 @@ pub async fn run( } #[cfg(feature = "signals")] - { - SignalArbiter::emit_app( - Signal::with_capacity(ids::CONNECTION_CLOSED, 2) - .meta("remote_addr", addr.to_string()) - .meta("tls", "true"), - ) - .await; - } + signal_tx::emit_connection_closed(&addr.to_string(), true, None).await; + + drop(permit); }); } () = &mut signal_fused => { @@ -272,14 +332,14 @@ pub async fn run( } // Drain in-flight connections - let drain = tokio::time::timeout(DEFAULT_DRAIN_TIMEOUT, async { + let drain = tokio::time::timeout(drain_timeout, async { while join_set.join_next().await.is_some() {} }); if drain.await.is_err() { tracing::warn!( "Drain timeout ({:?}) exceeded, aborting {} remaining TLS connections", - DEFAULT_DRAIN_TIMEOUT, + drain_timeout, join_set.len() ); join_set.abort_all(); @@ -291,72 +351,12 @@ pub async fn run( /// Loads TLS certificates from a PEM-encoded file. /// -/// Reads and parses X.509 certificates from the specified file path. The file -/// should contain one or more PEM-encoded certificates. -/// -/// # Arguments -/// -/// * `path` - File system path to the certificate file -/// -/// # Panics -/// -/// Panics if the file cannot be opened, read, or if the certificates are -/// malformed or invalid. -/// -/// # Examples -/// -/// ```rust,no_run -/// # #[cfg(feature = "tls")] -/// use tako::server_tls::load_certs; -/// -/// # #[cfg(feature = "tls")] -/// # fn example() { -/// let certs = load_certs("server.crt"); -/// println!("Loaded {} certificates", certs.len()); -/// # } -/// ``` -pub fn load_certs(path: &str) -> anyhow::Result>> { - let mut rd = BufReader::new( - File::open(path).map_err(|e| anyhow::anyhow!("failed to open cert file '{}': {}", path, e))?, - ); - certs(&mut rd) - .collect::, _>>() - .map_err(|e| anyhow::anyhow!("failed to parse certs from '{}': {}", path, e)) -} +/// Thin re-export of [`tako_core::tls::load_certs`]; preserved for backward +/// compatibility. +pub use tako_core::tls::load_certs; /// Loads a private key from a PEM-encoded file. /// -/// Reads and parses a PKCS#8 private key from the specified file path. The file -/// should contain a single PEM-encoded private key. -/// -/// # Arguments -/// -/// * `path` - File system path to the private key file -/// -/// # Panics -/// -/// Panics if the file cannot be opened, read, if no private key is found, -/// or if the private key is malformed or invalid. -/// -/// # Examples -/// -/// ```rust,no_run -/// # #[cfg(feature = "tls")] -/// use tako::server_tls::load_key; -/// -/// # #[cfg(feature = "tls")] -/// # fn example() { -/// let key = load_key("server.key"); -/// println!("Loaded private key successfully"); -/// # } -/// ``` -pub fn load_key(path: &str) -> anyhow::Result> { - let mut rd = BufReader::new( - File::open(path).map_err(|e| anyhow::anyhow!("failed to open key file '{}': {}", path, e))?, - ); - pkcs8_private_keys(&mut rd) - .next() - .ok_or_else(|| anyhow::anyhow!("no private key found in '{}'", path))? - .map(|k| k.into()) - .map_err(|e| anyhow::anyhow!("bad private key in '{}': {}", path, e)) -} +/// Accepts PKCS#8, PKCS#1 (RSA), and SEC1 (EC) PEM blocks. Thin re-export of +/// [`tako_core::tls::load_key`]; preserved for backward compatibility. +pub use tako_core::tls::load_key; diff --git a/tako-server/src/server_tls_compio.rs b/tako-server/src/server_tls_compio.rs index 404e25e..5a88cbe 100644 --- a/tako-server/src/server_tls_compio.rs +++ b/tako-server/src/server_tls_compio.rs @@ -4,13 +4,10 @@ //! TLS-enabled HTTP server implementation for secure connections (compio runtime). use std::convert::Infallible; -use std::fs::File; use std::future::Future; -use std::io::BufReader; use std::sync::Arc; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; -use std::time::Duration; use compio::net::TcpListener; use compio::tls::TlsAcceptor; @@ -20,27 +17,22 @@ use hyper::server::conn::http1; #[cfg(feature = "http2")] use hyper::server::conn::http2; use hyper::service::service_fn; -use rustls::ServerConfig; -use rustls::pki_types::CertificateDer; -use rustls::pki_types::PrivateKeyDer; -use rustls_pemfile::certs; -use rustls_pemfile::pkcs8_private_keys; +use rustls::ServerConfig as RustlsServerConfig; #[cfg(feature = "http2")] use send_wrapper::SendWrapper; use tokio::sync::Notify; use tako_core::body::TakoBody; +use tako_core::conn_info::{ConnInfo, TlsInfo}; use tako_core::router::Router; #[cfg(feature = "signals")] -use tako_core::signals::Signal; -#[cfg(feature = "signals")] -use tako_core::signals::SignalArbiter; -#[cfg(feature = "signals")] -use tako_core::signals::ids; +use tako_core::signals::transport as signal_tx; use tako_core::types::BoxError; -/// Default drain timeout for graceful shutdown (30 seconds). -const DEFAULT_DRAIN_TIMEOUT: Duration = Duration::from_secs(30); +use crate::ServerConfig; + +// HTTP/2 hardening + connection lifetimes are sourced from `ServerConfig`, +// whose `Default` mirrors the historical hardcoded values. /// Starts a TLS-enabled HTTP server with the given listener, router, and certificates. pub async fn serve_tls( @@ -55,6 +47,7 @@ pub async fn serve_tls( certs, key, None::>, + ServerConfig::default(), ) .await { @@ -70,7 +63,52 @@ pub async fn serve_tls_with_shutdown( key: Option<&str>, signal: impl Future, ) { - if let Err(e) = run(listener, router, certs, key, Some(signal)).await { + if let Err(e) = run( + listener, + router, + certs, + key, + Some(signal), + ServerConfig::default(), + ) + .await + { + tracing::error!("TLS server error: {e}"); + } +} + +/// Like [`serve_tls`] with caller-supplied [`ServerConfig`]. +pub async fn serve_tls_with_config( + listener: TcpListener, + router: Router, + certs: Option<&str>, + key: Option<&str>, + config: ServerConfig, +) { + if let Err(e) = run( + listener, + router, + certs, + key, + None::>, + config, + ) + .await + { + tracing::error!("TLS server error: {e}"); + } +} + +/// Like [`serve_tls_with_shutdown`] with caller-supplied [`ServerConfig`]. +pub async fn serve_tls_with_shutdown_and_config( + listener: TcpListener, + router: Router, + certs: Option<&str>, + key: Option<&str>, + signal: impl Future, + config: ServerConfig, +) { + if let Err(e) = run(listener, router, certs, key, Some(signal), config).await { tracing::error!("TLS server error: {e}"); } } @@ -82,6 +120,7 @@ pub async fn run( certs: Option<&str>, key: Option<&str>, signal: Option>, + config: ServerConfig, ) -> Result<(), BoxError> { #[cfg(feature = "tako-tracing")] tako_core::tracing::init_tracing(); @@ -89,21 +128,21 @@ pub async fn run( let certs = load_certs(certs.unwrap_or("cert.pem"))?; let key = load_key(key.unwrap_or("key.pem"))?; - let mut config = ServerConfig::builder() + let mut tls_config = RustlsServerConfig::builder() .with_no_client_auth() .with_single_cert(certs, key)?; #[cfg(feature = "http2")] { - config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; + tls_config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; } #[cfg(not(feature = "http2"))] { - config.alpn_protocols = vec![b"http/1.1".to_vec()]; + tls_config.alpn_protocols = vec![b"http/1.1".to_vec()]; } - let acceptor = TlsAcceptor::from(Arc::new(config)); + let acceptor = TlsAcceptor::from(Arc::new(tls_config)); let router = Arc::new(router); #[cfg(feature = "plugins")] @@ -112,20 +151,22 @@ pub async fn run( let addr_str = listener.local_addr()?.to_string(); #[cfg(feature = "signals")] - { - SignalArbiter::emit_app( - Signal::with_capacity(ids::SERVER_STARTED, 3) - .meta("addr", addr_str.clone()) - .meta("transport", "tcp") - .meta("tls", "true"), - ) - .await; - } + signal_tx::emit_server_started(&addr_str, "tcp", true).await; tracing::info!("Tako TLS listening on {}", addr_str); let inflight = Arc::new(AtomicUsize::new(0)); let drain_notify = Arc::new(Notify::new()); + let drain_timeout = config.drain_timeout; + let keep_alive = config.keep_alive; + #[cfg(feature = "http2")] + let h2_max_concurrent_streams = config.h2_max_concurrent_streams; + #[cfg(feature = "http2")] + let h2_max_header_list_size = config.h2_max_header_list_size; + #[cfg(feature = "http2")] + let h2_max_send_buf_size = config.h2_max_send_buf_size; + #[cfg(feature = "http2")] + let h2_max_pending_accept_reset_streams = config.h2_max_pending_accept_reset_streams; let signal = signal.map(|s| Box::pin(s)); let mut signal_fused = std::pin::pin!(async { @@ -153,60 +194,43 @@ pub async fn run( Ok(s) => s, Err(e) => { tracing::error!("TLS error: {e}"); - if inflight.fetch_sub(1, Ordering::SeqCst) == 1 { - drain_notify.notify_one(); - } + inflight.fetch_sub(1, Ordering::SeqCst); + drain_notify.notify_waiters(); return; } }; #[cfg(feature = "signals")] - { - SignalArbiter::emit_app( - Signal::with_capacity(ids::CONNECTION_OPENED, 2) - .meta("remote_addr", addr.to_string()) - .meta("tls", "true"), - ) - .await; - } + signal_tx::emit_connection_opened(&addr.to_string(), true, None).await; + + let alpn_proto = tls_stream.negotiated_alpn().map(|p| p.into_owned()); + let is_h2 = matches!(alpn_proto.as_deref(), Some(b"h2")); + let conn_info = if is_h2 { + ConnInfo::h2_tls(addr, TlsInfo { + alpn: alpn_proto.clone(), + sni: None, + version: None, + }) + } else { + ConnInfo::h1_tls(addr, TlsInfo { + alpn: alpn_proto.clone(), + sni: None, + version: None, + }) + }; #[cfg(feature = "http2")] - let proto = tls_stream.negotiated_alpn().map(|p| p.into_owned()); + let proto = alpn_proto; let io = HyperStream::new(tls_stream); + // Per-request signals fire from inside Router::dispatch. let svc = service_fn(move |mut req| { let r = router.clone(); + let conn_info = conn_info.clone(); async move { - #[cfg(feature = "signals")] - let path = req.uri().path().to_string(); - #[cfg(feature = "signals")] - let method = req.method().to_string(); - req.extensions_mut().insert(addr); - - #[cfg(feature = "signals")] - { - SignalArbiter::emit_app( - Signal::with_capacity(ids::REQUEST_STARTED, 2) - .meta("method", method.clone()) - .meta("path", path.clone()), - ) - .await; - } - + req.extensions_mut().insert(conn_info); let response = r.dispatch(req.map(TakoBody::new)).await; - - #[cfg(feature = "signals")] - { - SignalArbiter::emit_app( - Signal::with_capacity(ids::REQUEST_COMPLETED, 3) - .meta("method", method) - .meta("path", path) - .meta("status", response.status().as_u16().to_string()), - ) - .await; - } - Ok::<_, Infallible>(response) } }); @@ -214,30 +238,26 @@ pub async fn run( #[cfg(feature = "http2")] if proto.as_deref() == Some(b"h2") { let mut h2 = http2::Builder::new(CompioH2Executor); - h2.timer(CompioH2Timer); + h2.timer(CompioH2Timer) + .max_concurrent_streams(h2_max_concurrent_streams) + .max_header_list_size(h2_max_header_list_size) + .max_send_buf_size(h2_max_send_buf_size) + .max_pending_accept_reset_streams(h2_max_pending_accept_reset_streams); if let Err(e) = h2.serve_connection(io, ServiceSendWrapper::new(svc)).await { tracing::error!("HTTP/2 error: {e}"); } #[cfg(feature = "signals")] - { - SignalArbiter::emit_app( - Signal::with_capacity(ids::CONNECTION_CLOSED, 2) - .meta("remote_addr", addr.to_string()) - .meta("tls", "true"), - ) - .await; - } + signal_tx::emit_connection_closed(&addr.to_string(), true, None).await; - if inflight.fetch_sub(1, Ordering::SeqCst) == 1 { - drain_notify.notify_one(); - } + inflight.fetch_sub(1, Ordering::SeqCst); + drain_notify.notify_waiters(); return; } let mut h1 = http1::Builder::new(); - h1.keep_alive(true); + h1.keep_alive(keep_alive); if let Err(e) = h1.serve_connection(io, svc).with_upgrades().await { if e.is_incomplete_message() { @@ -248,18 +268,10 @@ pub async fn run( } #[cfg(feature = "signals")] - { - SignalArbiter::emit_app( - Signal::with_capacity(ids::CONNECTION_CLOSED, 2) - .meta("remote_addr", addr.to_string()) - .meta("tls", "true"), - ) - .await; - } + signal_tx::emit_connection_closed(&addr.to_string(), true, None).await; - if inflight.fetch_sub(1, Ordering::SeqCst) == 1 { - drain_notify.notify_one(); - } + inflight.fetch_sub(1, Ordering::SeqCst); + drain_notify.notify_waiters(); }) .detach(); } @@ -270,21 +282,32 @@ pub async fn run( } } - // Drain in-flight connections - if inflight.load(Ordering::SeqCst) > 0 { + // Drain in-flight connections โ€” re-check the inflight counter on every + // notification, bounded by the overall drain deadline. Defends against the + // race where a connection finishes between the load and the await. + let drain_deadline = std::time::Instant::now() + drain_timeout; + while inflight.load(Ordering::SeqCst) > 0 { + let now = std::time::Instant::now(); + if now >= drain_deadline { + tracing::warn!( + "Drain timeout ({:?}) exceeded, {} TLS connections still active", + drain_timeout, + inflight.load(Ordering::SeqCst) + ); + break; + } + let remaining = drain_deadline - now; let drain_wait = drain_notify.notified(); - let sleep = compio::time::sleep(DEFAULT_DRAIN_TIMEOUT); + let sleep = compio::time::sleep(remaining); let drain_wait = std::pin::pin!(drain_wait); let sleep = std::pin::pin!(sleep); - match futures_util::future::select(drain_wait, sleep).await { - Either::Left(_) => {} - Either::Right(_) => { - tracing::warn!( - "Drain timeout ({:?}) exceeded, {} TLS connections still active", - DEFAULT_DRAIN_TIMEOUT, - inflight.load(Ordering::SeqCst) - ); - } + if let Either::Right(_) = futures_util::future::select(drain_wait, sleep).await { + tracing::warn!( + "Drain timeout ({:?}) exceeded, {} TLS connections still active", + drain_timeout, + inflight.load(Ordering::SeqCst) + ); + break; } } @@ -292,27 +315,13 @@ pub async fn run( Ok(()) } -/// Loads TLS certificates from a PEM-encoded file. -pub fn load_certs(path: &str) -> anyhow::Result>> { - let mut rd = BufReader::new( - File::open(path).map_err(|e| anyhow::anyhow!("failed to open cert file '{}': {}", path, e))?, - ); - certs(&mut rd) - .collect::, _>>() - .map_err(|e| anyhow::anyhow!("failed to parse certs from '{}': {}", path, e)) -} +/// Loads TLS certificates from a PEM-encoded file. Re-export of +/// [`tako_core::tls::load_certs`]. +pub use tako_core::tls::load_certs; -/// Loads a private key from a PEM-encoded file. -pub fn load_key(path: &str) -> anyhow::Result> { - let mut rd = BufReader::new( - File::open(path).map_err(|e| anyhow::anyhow!("failed to open key file '{}': {}", path, e))?, - ); - pkcs8_private_keys(&mut rd) - .next() - .ok_or_else(|| anyhow::anyhow!("no private key found in '{}'", path))? - .map(|k| k.into()) - .map_err(|e| anyhow::anyhow!("bad private key in '{}': {}", path, e)) -} +/// Loads a private key from a PEM-encoded file. Accepts PKCS#8, PKCS#1 (RSA), +/// and SEC1 (EC) PEM blocks. Re-export of [`tako_core::tls::load_key`]. +pub use tako_core::tls::load_key; // // compio is a single-threaded, thread-per-core runtime whose futures are `!Send`. diff --git a/tako-server/src/server_unix.rs b/tako-server/src/server_unix.rs index c8aa6ce..27e8224 100644 --- a/tako-server/src/server_unix.rs +++ b/tako-server/src/server_unix.rs @@ -48,11 +48,12 @@ use hyper::service::service_fn; use tokio::task::JoinSet; use tako_core::body::TakoBody; +use tako_core::conn_info::ConnInfo; use tako_core::router::Router; use tako_core::types::BoxError; -/// Default drain timeout for graceful shutdown (30 seconds). -const DEFAULT_DRAIN_TIMEOUT: Duration = Duration::from_secs(30); +use crate::ServerConfig; + /// Peer address information for Unix domain socket connections. /// @@ -162,7 +163,14 @@ where /// Ideal for production deployments behind a reverse proxy (nginx, HAProxy) /// where the app communicates via a local socket file instead of TCP. pub async fn serve_unix_http(path: impl AsRef, router: Router) { - if let Err(e) = run_http(path.as_ref(), router, None::>).await { + if let Err(e) = run_http( + path.as_ref(), + router, + None::>, + ServerConfig::default(), + ) + .await + { tracing::error!("Unix HTTP server error: {e}"); } } @@ -173,7 +181,44 @@ pub async fn serve_unix_http_with_shutdown( router: Router, signal: impl Future, ) { - if let Err(e) = run_http(path.as_ref(), router, Some(signal)).await { + if let Err(e) = run_http( + path.as_ref(), + router, + Some(signal), + ServerConfig::default(), + ) + .await + { + tracing::error!("Unix HTTP server error: {e}"); + } +} + +/// Like [`serve_unix_http`] with caller-supplied [`ServerConfig`]. +pub async fn serve_unix_http_with_config( + path: impl AsRef, + router: Router, + config: ServerConfig, +) { + if let Err(e) = run_http( + path.as_ref(), + router, + None::>, + config, + ) + .await + { + tracing::error!("Unix HTTP server error: {e}"); + } +} + +/// Like [`serve_unix_http_with_shutdown`] with caller-supplied [`ServerConfig`]. +pub async fn serve_unix_http_with_shutdown_and_config( + path: impl AsRef, + router: Router, + signal: impl Future, + config: ServerConfig, +) { + if let Err(e) = run_http(path.as_ref(), router, Some(signal), config).await { tracing::error!("Unix HTTP server error: {e}"); } } @@ -182,6 +227,7 @@ async fn run_http( path: &Path, router: Router, signal: Option>, + config: ServerConfig, ) -> Result<(), BoxError> { cleanup_stale_socket(path)?; @@ -194,6 +240,11 @@ async fn run_http( tracing::debug!("Tako Unix HTTP listening on {}", path.display()); let mut join_set = JoinSet::new(); + let mut accept_backoff = config.accept_backoff; + let max_conn_semaphore = config.max_connections.map(|n| Arc::new(tokio::sync::Semaphore::new(n))); + let drain_timeout = config.drain_timeout; + let header_read_timeout = config.header_read_timeout; + let keep_alive = config.keep_alive; let signal = signal.map(|s| Box::pin(s)); let signal_fused = async { if let Some(s) = signal { @@ -207,7 +258,22 @@ async fn run_http( loop { tokio::select! { result = listener.accept() => { - let (stream, addr) = result?; + let (stream, addr) = match result { + Ok(v) => { accept_backoff.reset(); v } + Err(err) => { + tracing::warn!("Unix accept failed: {err}; backing off"); + accept_backoff.sleep_and_grow().await; + continue; + } + }; + let permit = if let Some(sem) = &max_conn_semaphore { + match sem.clone().acquire_owned().await { + Ok(p) => Some(p), + Err(_) => continue, + } + } else { + None + }; let io = hyper_util::rt::TokioIo::new(stream); let router = router.clone(); @@ -220,14 +286,20 @@ async fn run_http( let router = router.clone(); let peer_addr = peer_addr.clone(); async move { + let conn_info = ConnInfo::unix(peer_addr.path.clone()); req.extensions_mut().insert(peer_addr); + req.extensions_mut().insert(conn_info); let response = router.dispatch(req.map(TakoBody::incoming)).await; Ok::<_, Infallible>(response) } }); let mut http = http1::Builder::new(); - http.keep_alive(true); + http.keep_alive(keep_alive); + http.timer(hyper_util::rt::TokioTimer::new()); + if let Some(t) = header_read_timeout { + http.header_read_timeout(t); + } let conn = http.serve_connection(io, svc).with_upgrades(); if let Err(err) = conn.await { @@ -237,6 +309,8 @@ async fn run_http( tracing::error!("Error serving Unix HTTP connection: {err}"); } } + + drop(permit); }); } () = &mut signal_fused => { @@ -246,7 +320,7 @@ async fn run_http( } } - let drain = tokio::time::timeout(DEFAULT_DRAIN_TIMEOUT, async { + let drain = tokio::time::timeout(drain_timeout, async { while join_set.join_next().await.is_some() {} }); diff --git a/tako-streams/Cargo.toml b/tako-streams/Cargo.toml index 8491d89..0361255 100644 --- a/tako-streams/Cargo.toml +++ b/tako-streams/Cargo.toml @@ -41,9 +41,6 @@ url.workspace = true # Optional / feature-gated compio = { workspace = true, optional = true } -compio-ws = { workspace = true, optional = true } -compio-io = { workspace = true, optional = true } -compio-buf = { workspace = true, optional = true } send_wrapper = { workspace = true, optional = true } h3 = { workspace = true, optional = true } h3-quinn = { workspace = true, optional = true } @@ -57,6 +54,6 @@ file-stream = [] plugins = [] signals = ["tako-core/signals"] compio = ["dep:compio", "dep:send_wrapper", "tako-core/compio", "tako-server/compio"] -compio-ws = ["compio", "dep:compio-ws", "dep:compio-io", "dep:compio-buf"] -http3 = ["dep:h3", "dep:h3-quinn", "dep:quinn", "dep:rustls", "dep:rustls-pemfile", "tako-server/http3"] +compio-ws = ["compio", "compio/io", "compio/ws"] +http3 = ["dep:h3", "dep:h3-quinn", "dep:quinn", "dep:rustls", "dep:rustls-pemfile", "tako-server/http3", "tako-core/http3"] webtransport = ["http3"] diff --git a/tako-streams/src/webtransport.rs b/tako-streams/src/webtransport.rs index 9837bb5..9662f37 100644 --- a/tako-streams/src/webtransport.rs +++ b/tako-streams/src/webtransport.rs @@ -167,11 +167,12 @@ where + Sync + 'static, { - // Load certs using the same helpers as server_h3 + // Use the consolidated PEM loaders from tako-core::tls so this crate does + // not reach across into another transport crate's private surface. let _ = rustls::crypto::ring::default_provider().install_default(); - let certs = tako_server::server_h3::load_certs(cert_path)?; - let key = tako_server::server_h3::load_key(key_path)?; + let certs = tako_core::tls::load_certs(cert_path)?; + let key = tako_core::tls::load_key(key_path)?; let mut tls_config = rustls::ServerConfig::builder() .with_no_client_auth() diff --git a/tako-streams/src/ws_compio.rs b/tako-streams/src/ws_compio.rs index b9fdec4..a343b99 100644 --- a/tako-streams/src/ws_compio.rs +++ b/tako-streams/src/ws_compio.rs @@ -9,13 +9,13 @@ use std::io::ErrorKind; use base64::Engine as _; use base64::engine::general_purpose::STANDARD; -use compio_io::compat::SyncStream; -use compio_ws::tungstenite; +use compio::io::compat::SyncStream; +use compio::ws::tungstenite; // Re-export Message for convenience -pub use compio_ws::tungstenite::Message; -use compio_ws::tungstenite::protocol::CloseFrame; -use compio_ws::tungstenite::protocol::Role; -use compio_ws::tungstenite::protocol::WebSocketConfig; +pub use compio::ws::tungstenite::Message; +use compio::ws::tungstenite::protocol::CloseFrame; +use compio::ws::tungstenite::protocol::Role; +use compio::ws::tungstenite::protocol::WebSocketConfig; use futures_util::FutureExt; use http::StatusCode; use http::header; @@ -43,15 +43,15 @@ impl UpgradedStream { } } -impl compio_io::AsyncRead for UpgradedStream { - async fn read(&mut self, mut buf: B) -> compio_buf::BufResult { +impl compio::io::AsyncRead for UpgradedStream { + async fn read(&mut self, mut buf: B) -> compio::buf::BufResult { use std::pin::Pin; use std::task::Context; use hyper::rt::Read; - let slice = buf.as_mut_slice(); - let len = slice.len(); + let len = buf.buf_capacity(); + let dest_ptr = buf.buf_mut_ptr() as *mut u8; // Create a safe buffer for reading let mut temp_buf = vec![0u8; len]; @@ -70,11 +70,10 @@ impl compio_io::AsyncRead for UpgradedStream { Ok(filled_len) => { // Copy from temp buffer to the actual buffer if filled_len > 0 { - let dest = - unsafe { std::slice::from_raw_parts_mut(slice.as_mut_ptr() as *mut u8, filled_len) }; + let dest = unsafe { std::slice::from_raw_parts_mut(dest_ptr, filled_len) }; dest.copy_from_slice(&temp_buf[..filled_len]); } - unsafe { buf.set_buf_init(filled_len) }; + unsafe { buf.set_len(filled_len) }; (Ok(filled_len), buf).into() } Err(e) => (Err(e), buf).into(), @@ -82,14 +81,14 @@ impl compio_io::AsyncRead for UpgradedStream { } } -impl compio_io::AsyncWrite for UpgradedStream { - async fn write(&mut self, buf: T) -> compio_buf::BufResult { +impl compio::io::AsyncWrite for UpgradedStream { + async fn write(&mut self, buf: T) -> compio::buf::BufResult { use std::pin::Pin; use std::task::Context; use hyper::rt::Write; - let slice = buf.as_slice(); + let slice = buf.as_init(); let result = std::future::poll_fn(|cx: &mut Context<'_>| Pin::new(&mut self.inner).poll_write(cx, slice)) @@ -130,7 +129,7 @@ pub struct CompioWebSocket { impl CompioWebSocket where - S: compio_io::AsyncRead + compio_io::AsyncWrite, + S: compio::io::AsyncRead + compio::io::AsyncWrite, { /// Default buffer size (128 KiB). const DEFAULT_BUF_SIZE: usize = 128 * 1024; @@ -260,7 +259,7 @@ where /// use tako::types::Request; /// use tako::body::TakoBody; /// use tako::responder::Responder; -/// use compio_ws::tungstenite::Message; +/// use compio::ws::tungstenite::Message; /// /// async fn echo_handler(mut ws: CompioWebSocket) { /// loop {