diff --git a/AGENTS.md b/AGENTS.md index 55e3d65..d8c5e8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,10 +40,10 @@ route definitions must use matchit 0.8 style ## Examples the demo crates under `examples/app-demo/` share router logic via `app-demo-core`. Local smoke testing flows through - `cargo run -p anyedge-cli -- dev`, which serves the demo router on - http://127.0.0.1:8787 when the default features (including `dev-example`) are enabled. - Build provider targets with `app-demo-adapter-fastly` / `app-demo-adapter-cloudflare` - when you need Fastly or Cloudflare binaries. + `cargo run -p anyedge-cli --features dev-example -- dev`, which serves the demo + router on http://127.0.0.1:8787. Build provider targets with + `app-demo-adapter-fastly` / `app-demo-adapter-cloudflare` when you need Fastly + or Cloudflare binaries. ## Style– prefer colocating tests with implementation modules, favour async/await-friendly code that compiles to Wasm, and avoid runtime-specific diff --git a/Cargo.lock b/Cargo.lock index 82669a3..62136e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,9 +52,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.20" +version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", "anstyle-parse", @@ -105,6 +105,33 @@ name = "anyedge-adapter" version = "0.1.0" dependencies = [ "once_cell", + "tempfile", + "toml", +] + +[[package]] +name = "anyedge-adapter-axum" +version = "0.1.0" +dependencies = [ + "anyedge-adapter", + "anyedge-core", + "anyhow", + "async-trait", + "axum", + "bytes", + "ctor", + "futures", + "futures-util", + "http", + "log", + "simple_logger", + "tempfile", + "thiserror 2.0.17", + "tokio", + "toml", + "tower", + "tracing", + "walkdir", ] [[package]] @@ -121,7 +148,6 @@ dependencies = [ "futures", "futures-util", "log", - "toml", "walkdir", "wasm-bindgen-test", "web-sys", @@ -148,7 +174,6 @@ dependencies = [ "log", "log-fastly", "tempfile", - "toml", "walkdir", ] @@ -157,6 +182,7 @@ name = "anyedge-cli" version = "0.1.0" dependencies = [ "anyedge-adapter", + "anyedge-adapter-axum", "anyedge-adapter-cloudflare", "anyedge-adapter-fastly", "anyedge-core", @@ -188,7 +214,7 @@ dependencies = [ "http", "http-body", "log", - "matchit 0.8.6", + "matchit 0.8.4", "serde", "serde_json", "serde_urlencoded", @@ -218,6 +244,21 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "app-demo-adapter-axum" +version = "0.1.0" +dependencies = [ + "anyedge-adapter-axum", + "anyedge-core", + "anyhow", + "app-demo-core", + "axum", + "log", + "simple_logger", + "tokio", + "tracing", +] + [[package]] name = "app-demo-adapter-cloudflare" version = "0.1.0" @@ -297,12 +338,82 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit 0.8.4", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -383,9 +494,9 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "cc" -version = "1.2.39" +version = "1.2.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1354349954c6fc9cb0deab020f27f783cf0b604e8bb754dc4658ecf0d29c35f" +checksum = "e1d05d92f4b1fd76aad469d46cdd858ca761576082cd37df81416691e50199fb" dependencies = [ "find-msvc-tools", "shlex", @@ -456,6 +567,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + [[package]] name = "compression-codecs" version = "0.4.31" @@ -761,9 +882,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959" +checksum = "0399f9d26e5191ce32c498bebd31e7a3ceabc2745f0ac54af3f335126c3f24b3" [[package]] name = "flate2" @@ -956,6 +1077,68 @@ dependencies = [ "http", ] +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "iana-time-zone" version = "0.1.64" @@ -1194,9 +1377,9 @@ checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" [[package]] name = "matchit" -version = "0.8.6" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f926ade0c4e170215ae43342bf13b9310a437609c81f29f86c5df6657582ef9" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" @@ -1270,6 +1453,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "object" version = "0.37.3" @@ -1305,20 +1497,19 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e0a3a33733faeaf8651dfee72dd0f388f0c8e5ad496a3478fa5a922f49cfa8" +checksum = "989e7521a040efde50c3ab6bbadafbe15ab6dc042686926be59ac35d74607df4" dependencies = [ "memchr", - "thiserror 2.0.17", "ucd-trie", ] [[package]] name = "pest_derive" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc58706f770acb1dbd0973e6530a3cff4746fb721207feb3a8a6064cd0b6c663" +checksum = "187da9a3030dbafabbbfb20cb323b976dc7b7ce91fcd84f2f74d6e31d378e2de" dependencies = [ "pest", "pest_generator", @@ -1326,9 +1517,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d4f36811dfe07f7b8573462465d5cb8965fffc2e71ae377a33aecf14c2c9a2f" +checksum = "49b401d98f5757ebe97a26085998d6c0eecec4995cad6ab7fc30ffdf4b052843" dependencies = [ "pest", "pest_meta", @@ -1339,9 +1530,9 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42919b05089acbd0a5dcd5405fb304d17d1053847b81163d09c4ad18ce8e8420" +checksum = "72f27a2cfee9f9039c4d86faa5af122a0ac3851441a34865b8a043b46be0065a" dependencies = [ "pest", "sha2 0.10.9", @@ -1563,6 +1754,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -1625,6 +1827,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "simple_logger" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8c5dfa5e08767553704aa0ffd9d9794d527103c736aba9854773851fd7497eb" +dependencies = [ + "colored", + "log", + "time", + "windows-sys 0.48.0", +] + [[package]] name = "slab" version = "0.4.11" @@ -1637,6 +1860,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -1671,6 +1904,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "synstructure" version = "0.13.2" @@ -1742,7 +1981,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" dependencies = [ "deranged", + "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde", "time-core", @@ -1786,7 +2028,22 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "slab", + "socket2", + "tokio-macros", + "windows-sys 0.59.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", ] [[package]] @@ -1828,6 +2085,28 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d163a63c116ce562a22cda521fcc4d79152e7aba014456fb5eb442f6d6a10109" +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + [[package]] name = "tower-service" version = "0.3.3" @@ -1840,6 +2119,7 @@ version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -1867,9 +2147,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "ucd-trie" @@ -2164,6 +2444,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -2191,6 +2480,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -2224,6 +2528,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -2236,6 +2546,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -2248,6 +2564,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -2272,6 +2594,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -2284,6 +2612,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -2296,6 +2630,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -2308,6 +2648,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index e421ca6..c1508bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "crates/anyedge-adapter-axum", "crates/anyedge-adapter-cloudflare", "crates/anyedge-adapter-fastly", "crates/anyedge-adapter", @@ -7,6 +8,7 @@ members = [ "crates/anyedge-core", "crates/anyedge-macros", "examples/app-demo/crates/app-demo-adapter-cloudflare", + "examples/app-demo/crates/app-demo-adapter-axum", "examples/app-demo/crates/app-demo-core", "examples/app-demo/crates/app-demo-adapter-fastly", ] @@ -19,6 +21,7 @@ authors = ["AnyEdge Team "] license = "Apache-2.0" [workspace.dependencies] +anyedge-adapter-axum = { path = "crates/anyedge-adapter-axum", default-features = false } anyedge-adapter-cloudflare = { path = "crates/anyedge-adapter-cloudflare", default-features = false } anyedge-adapter-fastly = { path = "crates/anyedge-adapter-fastly", default-features = false } anyedge-adapter = { path = "crates/anyedge-adapter" } @@ -28,6 +31,7 @@ app-demo-core = { path = "examples/app-demo/crates/app-demo-core" } async-compression = { version = "0.4", features = ["futures-io", "gzip", "brotli"] } async-stream = "0.3" async-trait = "0.1" +axum = { version = "0.8", default-features = true } brotli = "8" bytes = "1" chrono = "0.4" @@ -47,11 +51,12 @@ once_cell = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_urlencoded = "0.7" -simple_logger = "4" +simple_logger = "5" tempfile = "3" thiserror = "2" toml = { version = "0.9" } -tower = { version = "0.4", features = ["util"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] } +tower = { version = "0.5", features = ["util"] } tower-layer = "0.3" tower-service = "0.3" tracing = "0.1" diff --git a/README.md b/README.md index 1e583c7..4b230ef 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -# AnyEdge Prototype +# AnyEdge -AnyEdge is an experiment in writing HTTP workloads once and deploying them to -multiple edge adapters. The crates in this workspace stay runtime-agnostic -(Tokio free, no OS primitives) so they can compile cleanly to WebAssembly -targets such as Fastly Compute@Edge and Cloudflare Workers. +AnyEdge is a production-ready toolkit for writing an HTTP workload once and +deploying it across multiple edge providers. The core stays runtime-agnostic so +it compiles cleanly to WebAssembly targets (Fastly Compute@Edge, Cloudflare +Workers) and to native hosts (Axum/Tokio) without code changes. ## Workspace layout @@ -11,26 +11,36 @@ targets such as Fastly Compute@Edge and Cloudflare Workers. - `crates/anyedge-macros` - procedural macros that power `#[anyedge_core::action]` and related derive helpers. - `crates/anyedge-adapter-fastly` - Fastly Compute@Edge bridge that maps Fastly request/response types into the shared model and exposes `FastlyRequestContext` plus logging conveniences. - `crates/anyedge-adapter-cloudflare` - Cloudflare Workers bridge providing `CloudflareRequestContext` and logger bootstrap helpers. +- `crates/anyedge-adapter-axum` - host-side adapter that wraps `RouterService` in Axum/Tokio services for local development and native deployments (the dev server now runs through this crate). - `crates/anyedge-cli` - CLI for project scaffolding, the local dev server, and adapter-aware build/deploy helpers. Ships with an optional demo dependency. -- `examples/app-demo` - reference application built on the shared router. Includes `crates/app-demo-core` (routes), `crates/app-demo-adapter-fastly` (Fastly binary + `fastly.toml`), and `crates/app-demo-adapter-cloudflare` (Workers entrypoint + `wrangler.toml`). +- `examples/app-demo` - reference application built on the shared router. Includes `crates/app-demo-core` (routes), `crates/app-demo-adapter-fastly` (Fastly binary + `fastly.toml`), `crates/app-demo-adapter-cloudflare` (Workers entrypoint + `wrangler.toml`), and `crates/app-demo-adapter-axum` (native dev server). ## Quick start ```bash -# Launch the built-in dev server (serves the demo router on http://127.0.0.1:8787) -cargo run -p anyedge-cli -- dev +# Install the CLI (from this workspace or a published crate) +cargo install --path crates/anyedge-cli -# Exercise the demo endpoints +# Scaffold a new AnyEdge app targeting Fastly, Cloudflare, and Axum +anyedge new my-app --adapters fastly cloudflare axum +cd my-app + +# Start the local Axum-powered dev server +anyedge dev + +# Hit one of the generated endpoints curl http://127.0.0.1:8787/echo/alice -curl -sS -X POST http://127.0.0.1:8787/echo \ - -H 'content-type: application/json' \ - -d '{"name":"Edge"}' -# Execute the full test suite +# Run your workspace tests cargo test + +# Optional: explore the demo project bundled with this repo +cargo run -p anyedge-cli --features dev-example -- dev ``` -The CLI enables the `dev-example` feature by default, so `anyedge dev` boots the demo router from `examples/app-demo`. Disable the example dependency with `cargo run -p anyedge-cli --no-default-features --features cli -- dev` to spin up a stub router instead. +To run the demo router from `examples/app-demo`, enable the optional +`dev-example` feature as shown above. Without that feature the CLI always loads +the manifest in your current project directory. The demo routes showcase core features: @@ -69,12 +79,15 @@ new projects. The `anyedge-cli` crate produces the `anyedge` binary (enabled by the `cli` feature). Run it locally with `cargo run -p anyedge-cli -- `. Key subcommands: -- `anyedge dev` - starts the local HTTP server (uses the demo router when `dev-example` is enabled). +- `anyedge new` - scaffolds a fully wired workspace (pass `--adapters` to pick your targets). +- `anyedge dev` - starts the local Axum HTTP server using the current project's manifest (pass `--features dev-example` when running from this repository to boot the demo app). - `anyedge build --adapter fastly` - builds the Fastly example to `wasm32-wasip1` and copies the artifact into `anyedge/pkg/`. - `anyedge serve --adapter fastly` - shells out to `fastly compute serve` after locating the Fastly manifest. - `anyedge deploy --adapter fastly` - wraps `fastly compute deploy`. +- `anyedge build --adapter axum` - builds your native entrypoint (useful for containers or local integration tests). +- `anyedge serve --adapter axum` - runs the generated Axum entrypoint with `cargo run`, ideal for local or containerised development. -Fastly is the only adapter wired into the CLI today; add new adapters by extending `anyedge_adapter_*::cli`. +Adapters register themselves lazily through their `anyedge_adapter_*::cli` modules. With the Axum adapter available you can generate, serve, and test a native host target without leaving the workspace. ## Logging @@ -84,6 +97,7 @@ functions so you can install the right backend when your app boots: - Fastly: call `anyedge_adapter_fastly::init_logger()` (wraps `log_fastly`). - Cloudflare Workers: call `anyedge_adapter_cloudflare::init_logger()` (logs via Workers `console_log!`). +- Axum/native: install a standard logger (`simple_logger`, `tracing-subscriber`, etc.) before booting `anyedge_adapter_axum::AxumDevServer`. - Other targets: initialise a fallback logger such as `simple_logger` before building your app. @@ -115,7 +129,15 @@ cargo build -p app-demo-adapter-cloudflare --target wasm32-unknown-unknown wrangler dev --config crates/app-demo-adapter-cloudflare/wrangler.toml ``` -Both adapters translate provider request/response shapes into the shared `anyedge-core` model and stash provider metadata in the request extensions so handlers can reach runtime-specific APIs. +Axum / native hosts: + +```bash +# Build or run using the scaffolded commands +anyedge build --adapter axum +anyedge serve --adapter axum +``` + +The Fastly and Cloudflare adapters translate provider request/response shapes into the shared `anyedge-core` model and stash provider metadata in the request extensions so handlers can reach runtime-specific APIs. ## Path parameters @@ -135,10 +157,9 @@ listing at a custom path. ## Streaming responses Handlers can return `Body::stream` to yield response chunks progressively. The -router keeps the stream intact all the way to the adapters; today both the -Fastly and Cloudflare bridges buffer chunks sequentially while writing to the -provider runtime APIs, so long-lived streams remain compatible with Wasm -targets. Responses compressed with gzip or brotli are transparently +router keeps the stream intact all the way to the adapters; the Fastly and +Cloudflare bridges buffer chunks sequentially while writing to the provider +runtime APIs, so long-lived streams remain compatible with Wasm targets. Responses compressed with gzip or brotli are transparently decoded before they reach handlers so you can reformat or transform the payload before sending it downstream. diff --git a/crates/anyedge-adapter-axum/Cargo.toml b/crates/anyedge-adapter-axum/Cargo.toml new file mode 100644 index 0000000..a660eca --- /dev/null +++ b/crates/anyedge-adapter-axum/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "anyedge-adapter-axum" +edition = { workspace = true } +version = { workspace = true } +authors = { workspace = true } +license = { workspace = true } + +[features] +default = ["axum"] +axum = ["dep:axum", "dep:tokio", "dep:tower", "dep:futures-util"] +cli = ["dep:anyedge-adapter", "anyedge-adapter/cli", "dep:ctor", "dep:toml", "dep:walkdir"] + +[dependencies] +anyhow = { workspace = true } +anyedge-core = { path = "../anyedge-core" } +anyedge-adapter = { path = "../anyedge-adapter", optional = true, features = ["cli"] } +axum = { workspace = true, optional = true } +bytes = { workspace = true } +ctor = { workspace = true, optional = true } +futures = { workspace = true } +futures-util = { workspace = true, optional = true } +http = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, optional = true } +tower = { workspace = true, optional = true } +tracing = { workspace = true } +toml = { workspace = true, optional = true } +walkdir = { workspace = true, optional = true } +log = { workspace = true } +simple_logger = { workspace = true } + +[dev-dependencies] +async-trait = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } +axum = { workspace = true, features = ["macros"] } +tempfile = { workspace = true } diff --git a/crates/anyedge-adapter-axum/src/cli.rs b/crates/anyedge-adapter-axum/src/cli.rs new file mode 100644 index 0000000..c8b54f4 --- /dev/null +++ b/crates/anyedge-adapter-axum/src/cli.rs @@ -0,0 +1,358 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyedge_adapter::cli_support::{ + find_manifest_upwards, find_workspace_root, path_distance, read_package_name, +}; +use anyedge_adapter::scaffold::{ + register_adapter_blueprint, AdapterBlueprint, AdapterFileSpec, CommandTemplates, + DependencySpec, LoggingDefaults, ManifestSpec, ReadmeInfo, TemplateRegistration, +}; +use anyedge_adapter::{register_adapter, Adapter, AdapterAction}; +use ctor::ctor; +use toml::Value; +use walkdir::WalkDir; + +static AXUM_TEMPLATE_REGISTRATIONS: &[TemplateRegistration] = &[ + TemplateRegistration { + name: "axum_Cargo_toml", + contents: include_str!("templates/Cargo.toml.hbs"), + }, + TemplateRegistration { + name: "axum_src_main_rs", + contents: include_str!("templates/src/main.rs.hbs"), + }, + TemplateRegistration { + name: "axum_axum_toml", + contents: include_str!("templates/axum.toml.hbs"), + }, +]; + +static AXUM_FILE_SPECS: &[AdapterFileSpec] = &[ + AdapterFileSpec { + template: "axum_Cargo_toml", + output: "Cargo.toml", + }, + AdapterFileSpec { + template: "axum_src_main_rs", + output: "src/main.rs", + }, + AdapterFileSpec { + template: "axum_axum_toml", + output: "axum.toml", + }, +]; + +static AXUM_DEPENDENCIES: &[DependencySpec] = &[ + DependencySpec { + key: "dep_anyedge_core_axum", + repo_crate: "crates/anyedge-core", + fallback: "anyedge-core = { git = \"ssh://git@github.com/stackpop/anyedge.git\", package = \"anyedge-core\" }", + features: &[], + }, + DependencySpec { + key: "dep_anyedge_adapter_axum", + repo_crate: "crates/anyedge-adapter-axum", + fallback: + "anyedge-adapter-axum = { git = \"ssh://git@github.com/stackpop/anyedge.git\", package = \"anyedge-adapter-axum\", default-features = false }", + features: &["axum"], + }, +]; + +static AXUM_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { + id: "axum", + display_name: "Axum", + crate_suffix: "adapter-axum", + dependency_crate: "anyedge-adapter-axum", + dependency_repo_path: "crates/anyedge-adapter-axum", + template_registrations: AXUM_TEMPLATE_REGISTRATIONS, + files: AXUM_FILE_SPECS, + extra_dirs: &["src"], + dependencies: AXUM_DEPENDENCIES, + manifest: ManifestSpec { + manifest_filename: "axum.toml", + build_target: "native", + build_profile: "dev", + build_features: &[], + }, + commands: CommandTemplates { + build: "cargo build -p {crate}", + serve: "cargo run -p {crate}", + deploy: "# configure deployment for Axum", + }, + logging: LoggingDefaults { + endpoint: None, + level: "info", + echo_stdout: Some(true), + }, + readme: ReadmeInfo { + description: "{display} adapter entrypoint.", + dev_heading: "{display} (local)", + dev_steps: &["cd {crate_dir}", "cargo run"], + }, + run_module: "anyedge_adapter_axum", +}; + +struct AxumCliAdapter; + +static AXUM_ADAPTER: AxumCliAdapter = AxumCliAdapter; + +impl Adapter for AxumCliAdapter { + fn name(&self) -> &'static str { + "axum" + } + + fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { + match action { + AdapterAction::Build => build(args), + AdapterAction::Deploy => deploy(args), + AdapterAction::Serve => serve(args), + } + } +} + +pub fn register() { + register_adapter(&AXUM_ADAPTER); + register_adapter_blueprint(&AXUM_BLUEPRINT); +} + +#[ctor] +fn register_ctor() { + register(); +} + +fn build(extra_args: &[String]) -> Result<(), String> { + let project = locate_project()?; + run_cargo(&project, "build", extra_args) +} + +fn serve(extra_args: &[String]) -> Result<(), String> { + let project = locate_project()?; + run_cargo(&project, "run", extra_args) +} + +fn deploy(_extra_args: &[String]) -> Result<(), String> { + Err("Axum adapter does not define a deploy command. Extend your workspace manifest with one if needed.".into()) +} + +struct AxumProject { + crate_dir: PathBuf, + cargo_manifest: PathBuf, + crate_name: String, + port: u16, +} + +fn locate_project() -> Result { + let cwd = std::env::current_dir().map_err(|err| err.to_string())?; + let manifest = find_axum_manifest(&cwd)?; + read_axum_project(&manifest) +} + +fn run_cargo(project: &AxumProject, subcommand: &str, extra_args: &[String]) -> Result<(), String> { + let display = project.crate_dir.display(); + println!( + "[anyedge] Axum {subcommand} ({}) in {} (port: {})", + project.crate_name, display, project.port + ); + let mut command = Command::new("cargo"); + command.arg(subcommand); + command.arg("--manifest-path"); + command.arg( + project + .cargo_manifest + .to_str() + .ok_or_else(|| format!("invalid manifest path {}", project.cargo_manifest.display()))?, + ); + command.args(extra_args); + command.current_dir(&project.crate_dir); + let status = command + .status() + .map_err(|err| format!("failed to run cargo {subcommand}: {err}"))?; + if status.success() { + Ok(()) + } else { + Err(format!("cargo {subcommand} failed with status {}", status)) + } +} + +fn find_axum_manifest(start: &Path) -> Result { + if let Some(found) = find_manifest_upwards(start, "axum.toml") { + return Ok(found); + } + + let root = find_workspace_root(start); + let mut candidates: Vec = WalkDir::new(&root) + .follow_links(true) + .max_depth(8) + .into_iter() + .filter_map(Result::ok) + .map(|entry| entry.into_path()) + .filter(|path| { + path.file_name() + .map(|name| name == "axum.toml") + .unwrap_or(false) + && path + .parent() + .map(|dir| dir.join("Cargo.toml").exists()) + .unwrap_or(false) + }) + .collect(); + + if candidates.is_empty() { + return Err("could not locate axum.toml".into()); + } + + candidates.sort_by_key(|path| { + let parent = path.parent().unwrap_or(Path::new("")); + path_distance(start, parent) + }); + + Ok(candidates.remove(0)) +} + +fn read_axum_project(manifest: &Path) -> Result { + let contents = fs::read_to_string(manifest) + .map_err(|err| format!("failed to read {}: {err}", manifest.display()))?; + let value: Value = toml::from_str(&contents) + .map_err(|err| format!("failed to parse {}: {err}", manifest.display()))?; + + let adapter = value + .get("adapter") + .and_then(Value::as_table) + .ok_or_else(|| format!("adapter table missing in {}", manifest.display()))?; + + let crate_dir = adapter + .get("crate_dir") + .and_then(Value::as_str) + .ok_or_else(|| format!("adapter.crate_dir missing in {}", manifest.display()))?; + + let manifest_dir = manifest.parent().unwrap_or_else(|| Path::new(".")); + let crate_dir = manifest_dir.join(crate_dir); + let cargo_manifest = crate_dir.join("Cargo.toml"); + if !cargo_manifest.exists() { + return Err(format!( + "Cargo.toml missing at {} referenced by {}", + cargo_manifest.display(), + manifest.display() + )); + } + + let crate_name = adapter + .get("crate") + .and_then(Value::as_str) + .map(|s| s.to_string()) + .unwrap_or_else(|| { + read_package_name(&cargo_manifest).unwrap_or_else(|_| { + crate_dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("axum-adapter") + .to_string() + }) + }); + + let port = match adapter.get("port").and_then(Value::as_integer) { + Some(value) => { + if !(1..=u16::MAX as i64).contains(&value) { + return Err(format!( + "adapter.port in {} must be between 1 and 65535", + manifest.display() + )); + } + value as u16 + } + None => 8787, + }; + + Ok(AxumProject { + crate_dir, + cargo_manifest, + crate_name, + port, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use anyedge_adapter::cli_support::find_manifest_upwards; + use tempfile::tempdir; + + #[test] + fn read_axum_project_loads_defaults() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\n", + ) + .unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let project = read_axum_project(&root.join("axum.toml")).expect("project"); + assert_eq!(project.crate_name, "demo"); + assert_eq!(project.crate_dir, root); + assert_eq!(project.cargo_manifest, root.join("Cargo.toml")); + assert_eq!(project.port, 8787); + } + + #[test] + fn find_manifest_upwards_locates_parent() { + let dir = tempdir().unwrap(); + let root = dir.path(); + let nested = root.join("nested/level"); + fs::create_dir_all(&nested).unwrap(); + fs::write(root.join("Cargo.toml"), "[workspace]").unwrap(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\n", + ) + .unwrap(); + + let found = find_manifest_upwards(&nested, "axum.toml").expect("manifest"); + assert_eq!(found, root.join("axum.toml")); + } + + #[test] + fn read_axum_project_uses_custom_port() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\nport = 4001\n", + ) + .unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let project = read_axum_project(&root.join("axum.toml")).expect("project"); + assert_eq!(project.port, 4001); + } + + #[test] + fn read_axum_project_rejects_invalid_port() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\nport = 70000\n", + ) + .unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let result = read_axum_project(&root.join("axum.toml")); + assert!(result.is_err()); + } +} diff --git a/crates/anyedge-adapter-axum/src/lib.rs b/crates/anyedge-adapter-axum/src/lib.rs new file mode 100644 index 0000000..a5a73dc --- /dev/null +++ b/crates/anyedge-adapter-axum/src/lib.rs @@ -0,0 +1,10 @@ +//! Axum adapter for AnyEdge routers and applications. + +#[cfg(feature = "axum")] +mod server; + +#[cfg(feature = "axum")] +pub use server::{run_app, AnyEdgeAxumService, AxumDevServer, AxumDevServerConfig}; + +#[cfg(feature = "cli")] +pub mod cli; diff --git a/crates/anyedge-adapter-axum/src/server/convert.rs b/crates/anyedge-adapter-axum/src/server/convert.rs new file mode 100644 index 0000000..042357a --- /dev/null +++ b/crates/anyedge-adapter-axum/src/server/convert.rs @@ -0,0 +1,123 @@ +use axum::body::Body as AxumBody; +use axum::http::{Request, Response, StatusCode}; +use futures::executor::block_on; +use futures_util::{pin_mut, StreamExt}; +use tracing::error; + +use anyedge_core::body::Body; +use anyedge_core::http::{Request as CoreRequest, Response as CoreResponse}; + +/// Convert an Axum/Hyper request into an AnyEdge core request while preserving streaming bodies. +pub fn into_core_request(request: Request) -> CoreRequest { + let (parts, body) = request.into_parts(); + let stream = body.into_data_stream(); + let body = Body::from_stream(stream); + CoreRequest::from_parts(parts, body) +} + +/// Convert an AnyEdge response into one consumable by Axum/Hyper. +/// +/// Streaming responses are collected into an in-memory buffer. While this sacrifices +/// incremental flushing, it keeps the adapter compatible with the non-`Send` streaming type used by +/// `anyedge_core::Body` and works well for local development. +pub fn into_axum_response(response: CoreResponse) -> Response { + let (parts, body) = response.into_parts(); + let body = match body { + Body::Once(bytes) => AxumBody::from(bytes), + Body::Stream(stream) => { + let result = block_on(async { + let mut buf = Vec::new(); + let stream = stream; + pin_mut!(stream); + while let Some(chunk) = stream.next().await { + let bytes = chunk?; + buf.extend_from_slice(&bytes); + } + Ok::, anyhow::Error>(buf) + }); + match result { + Ok(buf) => AxumBody::from(buf), + Err(err) => { + error!("streaming response error: {err}"); + let body = AxumBody::from("streaming response error"); + let mut response = Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(body) + .expect("error response"); + response.headers_mut().insert( + axum::http::header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("text/plain; charset=utf-8"), + ); + return response; + } + } + } + }; + + Response::from_parts(parts, body) +} + +#[cfg(test)] +mod tests { + use super::*; + use anyedge_core::body::Body; + use anyedge_core::http::{response_builder, Method, StatusCode}; + use futures::stream; + + #[test] + fn converts_axum_request_into_core_request() { + let request = Request::builder() + .method(Method::POST) + .uri("/demo") + .header("x-test", "1") + .body(AxumBody::from("payload")) + .expect("request"); + + let core_request = into_core_request(request); + assert_eq!(core_request.method(), &Method::POST); + assert_eq!(core_request.uri().path(), "/demo"); + assert_eq!(core_request.headers()["x-test"], "1"); + match core_request.into_body() { + Body::Once(_) => panic!("body should be wrapped as stream"), + Body::Stream(_) => {} // streaming bodies stay streaming + } + } + + #[test] + fn converts_core_response_stream_into_axum_body() { + let stream = stream::iter(vec![ + Ok::<_, anyhow::Error>(bytes::Bytes::from_static(b"hel")), + Ok(bytes::Bytes::from_static(b"lo")), + ]); + let body = Body::from_stream(stream); + let response = response_builder() + .status(StatusCode::OK) + .header("content-type", "text/plain") + .body(body) + .expect("response"); + + let axum_response = into_axum_response(response); + assert_eq!(axum_response.status(), StatusCode::OK); + assert_eq!( + axum_response + .headers() + .get("content-type") + .expect("header") + .to_str() + .unwrap(), + "text/plain" + ); + + let collected = block_on(async { + let mut data = Vec::new(); + let mut stream = axum_response.into_body().into_data_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.expect("chunk"); + data.extend_from_slice(&chunk); + } + data + }); + + assert_eq!(collected, b"hello"); + } +} diff --git a/crates/anyedge-adapter-axum/src/server/mod.rs b/crates/anyedge-adapter-axum/src/server/mod.rs new file mode 100644 index 0000000..318c085 --- /dev/null +++ b/crates/anyedge-adapter-axum/src/server/mod.rs @@ -0,0 +1,6 @@ +mod convert; +mod runner; +mod service; + +pub use runner::{run_app, AxumDevServer, AxumDevServerConfig}; +pub use service::AnyEdgeAxumService; diff --git a/crates/anyedge-adapter-axum/src/server/runner.rs b/crates/anyedge-adapter-axum/src/server/runner.rs new file mode 100644 index 0000000..01c2fb6 --- /dev/null +++ b/crates/anyedge-adapter-axum/src/server/runner.rs @@ -0,0 +1,116 @@ +use std::net::{SocketAddr, TcpListener as StdTcpListener}; + +use anyhow::Context; +use axum::Router; +use tokio::runtime::Builder as RuntimeBuilder; +use tokio::signal; +use tower::{service_fn, Service}; + +use anyedge_core::app::Hooks; +use anyedge_core::manifest::ManifestLoader; +use anyedge_core::router::RouterService; +use log::LevelFilter; +use simple_logger::SimpleLogger; + +use super::service::AnyEdgeAxumService; + +/// Configuration used when running the dev server embedding AnyEdge into Axum. +#[derive(Clone)] +pub struct AxumDevServerConfig { + pub addr: SocketAddr, + pub enable_ctrl_c: bool, +} + +impl Default for AxumDevServerConfig { + fn default() -> Self { + Self { + addr: SocketAddr::from(([127, 0, 0, 1], 8787)), + enable_ctrl_c: true, + } + } +} + +/// Blocking dev server runner used by the AnyEdge CLI. +pub struct AxumDevServer { + router: RouterService, + config: AxumDevServerConfig, +} + +impl AxumDevServer { + pub fn new(router: RouterService) -> Self { + Self { + router, + config: AxumDevServerConfig::default(), + } + } + + pub fn with_config(router: RouterService, config: AxumDevServerConfig) -> Self { + Self { router, config } + } + + pub fn run(self) -> anyhow::Result<()> { + let runtime = RuntimeBuilder::new_multi_thread() + .enable_all() + .build() + .context("failed to build tokio runtime")?; + + runtime.block_on(async move { self.run_async().await }) + } + + async fn run_async(self) -> anyhow::Result<()> { + let AxumDevServer { router, config } = self; + + // Allow binding to already-open listener if caller created one to surface errors early. + let listener = StdTcpListener::bind(config.addr) + .with_context(|| format!("failed to bind dev server to {}", config.addr))?; + listener + .set_nonblocking(true) + .context("failed to set listener to non-blocking")?; + + let listener = tokio::net::TcpListener::from_std(listener) + .context("failed to adopt std listener into tokio")?; + + let service = AnyEdgeAxumService::new(router); + let router = Router::new().fallback_service(service_fn(move |req| { + let mut svc = service.clone(); + async move { svc.call(req).await } + })); + + let shutdown = if config.enable_ctrl_c { + Some(async { + let _ = signal::ctrl_c().await; + }) + } else { + None + }; + + let server = axum::serve(listener, router.into_make_service()); + if let Some(shutdown) = shutdown { + let server = server.with_graceful_shutdown(shutdown); + server.await.context("axum server error")?; + } else { + server.await.context("axum server error")?; + } + + Ok(()) + } +} + +pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { + let manifest = ManifestLoader::load_from_str(manifest_src); + let logging = manifest.manifest().logging_or_default("axum"); + + let level: LevelFilter = logging.level.into(); + let level = if logging.echo_stdout.unwrap_or(true) { + level + } else { + LevelFilter::Off + }; + + SimpleLogger::new().with_level(level).init().ok(); + + let app = A::build_app(); + let router = app.router().clone(); + + AxumDevServer::new(router).run() +} diff --git a/crates/anyedge-adapter-axum/src/server/service.rs b/crates/anyedge-adapter-axum/src/server/service.rs new file mode 100644 index 0000000..a4d1688 --- /dev/null +++ b/crates/anyedge-adapter-axum/src/server/service.rs @@ -0,0 +1,47 @@ +use std::convert::Infallible; +use std::future::Future; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use axum::body::Body as AxumBody; +use axum::http::{Request, Response}; +use tokio::{runtime::Handle, task}; +use tower::Service; + +use anyedge_core::router::RouterService; + +use super::convert::{into_axum_response, into_core_request}; + +/// Tower service that adapts AnyEdge router requests to Axum/Hyper compatible responses. +#[derive(Clone)] +pub struct AnyEdgeAxumService { + router: RouterService, +} + +impl AnyEdgeAxumService { + pub fn new(router: RouterService) -> Self { + Self { router } + } +} + +impl Service> for AnyEdgeAxumService { + type Response = Response; + type Error = Infallible; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, request: Request) -> Self::Future { + let router = self.router.clone(); + Box::pin(async move { + let core_request = into_core_request(request); + let core_response = task::block_in_place(move || { + Handle::current().block_on(router.oneshot(core_request)) + }); + let response = into_axum_response(core_response); + Ok(response) + }) + } +} diff --git a/crates/anyedge-adapter-axum/src/templates/Cargo.toml.hbs b/crates/anyedge-adapter-axum/src/templates/Cargo.toml.hbs new file mode 100644 index 0000000..a0306ce --- /dev/null +++ b/crates/anyedge-adapter-axum/src/templates/Cargo.toml.hbs @@ -0,0 +1,20 @@ +[package] +name = "{{proj_axum}}" +version = "0.1.0" +edition = "2021" +publish = false + +[[bin]] +name = "{{proj_axum}}" +path = "src/main.rs" + +[dependencies] +{{proj_core}} = { path = "../{{proj_core}}" } +{{{dep_anyedge_adapter_axum}}} +anyedge-core = { workspace = true } +anyhow = { workspace = true } +axum = { workspace = true } +log = { workspace = true } +simple_logger = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +tracing = { workspace = true } diff --git a/crates/anyedge-adapter-axum/src/templates/axum.toml.hbs b/crates/anyedge-adapter-axum/src/templates/axum.toml.hbs new file mode 100644 index 0000000..6ab31c7 --- /dev/null +++ b/crates/anyedge-adapter-axum/src/templates/axum.toml.hbs @@ -0,0 +1,4 @@ +[adapter] +crate = "{{proj_axum}}" +crate_dir = "." +port = 8787 diff --git a/crates/anyedge-adapter-axum/src/templates/src/main.rs.hbs b/crates/anyedge-adapter-axum/src/templates/src/main.rs.hbs new file mode 100644 index 0000000..87f8e51 --- /dev/null +++ b/crates/anyedge-adapter-axum/src/templates/src/main.rs.hbs @@ -0,0 +1,8 @@ +use {{proj_core_mod}}::App; + +fn main() { + if let Err(err) = anyedge_adapter_axum::run_app::(include_str!("../../../anyedge.toml")) { + eprintln!("axum adapter failed: {err}"); + std::process::exit(1); + } +} diff --git a/crates/anyedge-adapter-cloudflare/Cargo.toml b/crates/anyedge-adapter-cloudflare/Cargo.toml index 26abd51..65898ed 100644 --- a/crates/anyedge-adapter-cloudflare/Cargo.toml +++ b/crates/anyedge-adapter-cloudflare/Cargo.toml @@ -8,11 +8,11 @@ license = { workspace = true } [features] default = [] cloudflare = ["dep:worker"] -cli = ["dep:anyedge-adapter", "dep:ctor", "dep:toml", "dep:walkdir"] +cli = ["dep:anyedge-adapter", "anyedge-adapter/cli", "dep:ctor", "dep:walkdir"] [dependencies] anyedge-core = { path = "../anyedge-core" } -anyedge-adapter = { path = "../anyedge-adapter", optional = true } +anyedge-adapter = { path = "../anyedge-adapter", optional = true, features = ["cli"] } async-trait = { workspace = true } brotli = { workspace = true } bytes = { workspace = true } @@ -21,7 +21,6 @@ futures = { workspace = true } futures-util = { workspace = true } log = { workspace = true } ctor = { workspace = true, optional = true } -toml = { workspace = true, optional = true } worker = { version = "0.6", default-features = false, features = ["http"], optional = true } walkdir = { workspace = true, optional = true } wasm-bindgen-test = "0.3" diff --git a/crates/anyedge-adapter-cloudflare/src/cli.rs b/crates/anyedge-adapter-cloudflare/src/cli.rs index a3fdcb7..50a2fa0 100644 --- a/crates/anyedge-adapter-cloudflare/src/cli.rs +++ b/crates/anyedge-adapter-cloudflare/src/cli.rs @@ -2,6 +2,9 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; +use anyedge_adapter::cli_support::{ + find_manifest_upwards, find_workspace_root, path_distance, read_package_name, +}; use anyedge_adapter::scaffold::{ register_adapter_blueprint, AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, ManifestSpec, ReadmeInfo, TemplateRegistration, @@ -234,7 +237,7 @@ fn register_ctor() { } fn find_wrangler_manifest(start: &Path) -> Result { - if let Some(found) = find_manifest_upwards(start) { + if let Some(found) = find_manifest_upwards(start, "wrangler.toml") { return Ok(found); } @@ -268,52 +271,6 @@ fn find_wrangler_manifest(start: &Path) -> Result { Ok(candidates.remove(0)) } -fn find_manifest_upwards(start: &Path) -> Option { - let mut current = Some(start); - while let Some(dir) = current { - let candidate = dir.join("wrangler.toml"); - if candidate.exists() && dir.join("Cargo.toml").exists() { - return Some(candidate); - } - current = dir.parent(); - } - None -} - -fn read_package_name(manifest: &Path) -> Result { - let contents = fs::read_to_string(manifest) - .map_err(|e| format!("failed to read {}: {e}", manifest.display()))?; - let table: toml::Value = toml::from_str(&contents) - .map_err(|e| format!("failed to parse {}: {e}", manifest.display()))?; - if let Some(name) = table - .get("package") - .and_then(|pkg| pkg.get("name")) - .and_then(|name| name.as_str()) - { - return Ok(name.to_string()); - } - if let Some(name) = table.get("name").and_then(|value| value.as_str()) { - return Ok(name.to_string()); - } - Err(format!( - "package.name or name missing from {}", - manifest.display() - )) -} - -fn find_workspace_root(dir: &Path) -> PathBuf { - let mut current: Option<&Path> = Some(dir); - let mut candidate: Option = None; - - while let Some(path) = current { - if path.join("Cargo.toml").exists() { - candidate = Some(path.to_path_buf()); - } - current = path.parent(); - } - candidate.unwrap_or_else(|| dir.to_path_buf()) -} - fn locate_artifact( workspace_root: &Path, manifest_dir: &Path, @@ -354,19 +311,3 @@ fn locate_artifact( crate_name )) } - -fn path_distance(a: &Path, b: &Path) -> usize { - let a_components: Vec<_> = a.components().collect(); - let b_components: Vec<_> = b.components().collect(); - - let mut common = 0; - for (ac, bc) in a_components.iter().zip(&b_components) { - if ac == bc { - common += 1; - } else { - break; - } - } - - (a_components.len() - common) + (b_components.len() - common) -} diff --git a/crates/anyedge-adapter-fastly/Cargo.toml b/crates/anyedge-adapter-fastly/Cargo.toml index e6b5aab..920745f 100644 --- a/crates/anyedge-adapter-fastly/Cargo.toml +++ b/crates/anyedge-adapter-fastly/Cargo.toml @@ -7,12 +7,12 @@ license = { workspace = true } [features] default = [] -cli = ["dep:anyedge-adapter", "dep:ctor", "dep:toml", "dep:walkdir"] -fastly = ["dep:fastly", "dep:log-fastly", "dep:toml"] +cli = ["dep:anyedge-adapter", "anyedge-adapter/cli", "dep:ctor", "dep:walkdir"] +fastly = ["dep:fastly", "dep:log-fastly"] [dependencies] anyedge-core = { path = "../anyedge-core" } -anyedge-adapter = { path = "../anyedge-adapter", optional = true } +anyedge-adapter = { path = "../anyedge-adapter", optional = true, features = ["cli"] } async-stream = { workspace = true } async-trait = { workspace = true } brotli = { workspace = true } @@ -26,7 +26,6 @@ log = { workspace = true } log-fastly = { workspace = true, optional = true } fern = { workspace = true } chrono = { workspace = true } -toml = { workspace = true, optional = true } walkdir = { workspace = true, optional = true } [dev-dependencies] diff --git a/crates/anyedge-adapter-fastly/src/cli.rs b/crates/anyedge-adapter-fastly/src/cli.rs index 5c52f0d..5619396 100644 --- a/crates/anyedge-adapter-fastly/src/cli.rs +++ b/crates/anyedge-adapter-fastly/src/cli.rs @@ -2,6 +2,9 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; +use anyedge_adapter::cli_support::{ + find_manifest_upwards, find_workspace_root, path_distance, read_package_name, +}; use anyedge_adapter::scaffold::{ register_adapter_blueprint, AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, ManifestSpec, ReadmeInfo, TemplateRegistration, @@ -226,7 +229,7 @@ fn register_ctor() { } fn find_fastly_manifest(start: &Path) -> Result { - if let Some(found) = find_manifest_upwards(start) { + if let Some(found) = find_manifest_upwards(start, "fastly.toml") { return Ok(found); } @@ -260,52 +263,6 @@ fn find_fastly_manifest(start: &Path) -> Result { Ok(candidates.remove(0)) } -fn find_manifest_upwards(start: &Path) -> Option { - let mut current = Some(start); - while let Some(dir) = current { - let candidate = dir.join("fastly.toml"); - if candidate.exists() && dir.join("Cargo.toml").exists() { - return Some(candidate); - } - current = dir.parent(); - } - None -} - -fn read_package_name(manifest: &Path) -> Result { - let contents = fs::read_to_string(manifest) - .map_err(|e| format!("failed to read {}: {e}", manifest.display()))?; - let table: toml::Value = toml::from_str(&contents) - .map_err(|e| format!("failed to parse {}: {e}", manifest.display()))?; - if let Some(name) = table - .get("package") - .and_then(|pkg| pkg.get("name")) - .and_then(|name| name.as_str()) - { - return Ok(name.to_string()); - } - if let Some(name) = table.get("name").and_then(|value| value.as_str()) { - return Ok(name.to_string()); - } - Err(format!( - "package.name or name missing from {}", - manifest.display() - )) -} - -fn find_workspace_root(dir: &Path) -> PathBuf { - let mut current: Option<&Path> = Some(dir); - let mut candidate: Option = None; - - while let Some(path) = current { - if path.join("Cargo.toml").exists() { - candidate = Some(path.to_path_buf()); - } - current = path.parent(); - } - candidate.unwrap_or_else(|| dir.to_path_buf()) -} - fn locate_artifact( workspace_root: &Path, manifest_dir: &Path, @@ -348,25 +305,10 @@ fn locate_artifact( )) } -fn path_distance(a: &Path, b: &Path) -> usize { - let a_components: Vec<_> = a.components().collect(); - let b_components: Vec<_> = b.components().collect(); - - let mut common = 0; - for (ac, bc) in a_components.iter().zip(&b_components) { - if ac == bc { - common += 1; - } else { - break; - } - } - - (a_components.len() - common) + (b_components.len() - common) -} - #[cfg(test)] mod tests { use super::*; + use anyedge_adapter::cli_support::read_package_name; use tempfile::tempdir; #[test] diff --git a/crates/anyedge-adapter/Cargo.toml b/crates/anyedge-adapter/Cargo.toml index 8f60817..3adb6e6 100644 --- a/crates/anyedge-adapter/Cargo.toml +++ b/crates/anyedge-adapter/Cargo.toml @@ -4,7 +4,13 @@ version = "0.1.0" edition = "2021" description = "Adapter registry and traits for AnyEdge adapters" +[features] +default = [] +cli = ["dep:toml"] + [dependencies] once_cell = { workspace = true } +toml = { workspace = true, optional = true } [dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/anyedge-adapter/src/cli_support.rs b/crates/anyedge-adapter/src/cli_support.rs new file mode 100644 index 0000000..c0fce5c --- /dev/null +++ b/crates/anyedge-adapter/src/cli_support.rs @@ -0,0 +1,139 @@ +#![allow(dead_code)] + +use std::fs; +use std::path::{Path, PathBuf}; + +/// Walks up the directory tree looking for `manifest_name` alongside a `Cargo.toml`. +pub fn find_manifest_upwards(start: &Path, manifest_name: &str) -> Option { + let mut current = Some(start); + while let Some(dir) = current { + let candidate = dir.join(manifest_name); + if candidate.exists() && dir.join("Cargo.toml").exists() { + return Some(candidate); + } + current = dir.parent(); + } + None +} + +/// Returns the nearest ancestor containing a `Cargo.toml`, defaulting to `dir` when none are found. +pub fn find_workspace_root(dir: &Path) -> PathBuf { + let mut current: Option<&Path> = Some(dir); + let mut candidate: Option = None; + + while let Some(path) = current { + if path.join("Cargo.toml").exists() { + candidate = Some(path.to_path_buf()); + } + current = path.parent(); + } + + candidate.unwrap_or_else(|| dir.to_path_buf()) +} + +/// Calculates the path distance between two directories based on shared leading components. +pub fn path_distance(a: &Path, b: &Path) -> usize { + let a_components: Vec<_> = a.components().collect(); + let b_components: Vec<_> = b.components().collect(); + + let mut common = 0; + for (ac, bc) in a_components.iter().zip(&b_components) { + if ac == bc { + common += 1; + } else { + break; + } + } + + (a_components.len() - common) + (b_components.len() - common) +} + +/// Reads the crate name from a `Cargo.toml`, supporting both the inline and `[package]` forms. +pub fn read_package_name(manifest: &Path) -> Result { + let contents = fs::read_to_string(manifest) + .map_err(|err| format!("failed to read {}: {err}", manifest.display()))?; + let table: toml::Value = toml::from_str(&contents) + .map_err(|err| format!("failed to parse {}: {err}", manifest.display()))?; + + if let Some(name) = table + .get("package") + .and_then(|pkg| pkg.get("name")) + .and_then(|value| value.as_str()) + { + return Ok(name.to_string()); + } + + if let Some(name) = table.get("name").and_then(|value| value.as_str()) { + return Ok(name.to_string()); + } + + Err(format!( + "package.name or name missing from {}", + manifest.display() + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn workspace_root_defaults_to_dir_when_no_cargo_toml() { + let dir = tempdir().unwrap(); + let root = dir.path(); + let child = root.join("nested"); + fs::create_dir_all(&child).unwrap(); + + assert_eq!(find_workspace_root(&child), child); + } + + #[test] + fn workspace_root_finds_nearest_manifest() { + let dir = tempdir().unwrap(); + let root = dir.path(); + let child = root.join("nested"); + fs::create_dir_all(&child).unwrap(); + fs::write(root.join("Cargo.toml"), "[workspace]").unwrap(); + + assert_eq!(find_workspace_root(&child), root); + } + + #[test] + fn path_distance_counts_divergence() { + let a = Path::new("/a/b/c"); + let b = Path::new("/a/b/d/e"); + assert_eq!(path_distance(a, b), 3); + } + + #[test] + fn read_package_prefers_package_table() { + let dir = tempdir().unwrap(); + let manifest = dir.path().join("Cargo.toml"); + fs::write(&manifest, "[package]\nname = \"demo\"\n").unwrap(); + let name = read_package_name(&manifest).unwrap(); + assert_eq!(name, "demo"); + } + + #[test] + fn read_package_falls_back_to_name() { + let dir = tempdir().unwrap(); + let manifest = dir.path().join("Cargo.toml"); + fs::write(&manifest, "name = \"demo\"").unwrap(); + let name = read_package_name(&manifest).unwrap(); + assert_eq!(name, "demo"); + } + + #[test] + fn find_manifest_upwards_matches_manifest_name() { + let dir = tempdir().unwrap(); + let root = dir.path(); + let child = root.join("nested/level"); + fs::create_dir_all(&child).unwrap(); + fs::write(root.join("Cargo.toml"), "[workspace]").unwrap(); + fs::write(root.join("demo.toml"), "[cfg]\n").unwrap(); + + let found = find_manifest_upwards(&child, "demo.toml").expect("manifest"); + assert_eq!(found, root.join("demo.toml")); + } +} diff --git a/crates/anyedge-adapter/src/lib.rs b/crates/anyedge-adapter/src/lib.rs index 4de01d6..5b59436 100644 --- a/crates/anyedge-adapter/src/lib.rs +++ b/crates/anyedge-adapter/src/lib.rs @@ -3,3 +3,6 @@ mod registry; pub use registry::{get_adapter, register_adapter, registered_adapters, Adapter, AdapterAction}; pub mod scaffold; + +#[cfg(feature = "cli")] +pub mod cli_support; diff --git a/crates/anyedge-cli/Cargo.toml b/crates/anyedge-cli/Cargo.toml index db75f1a..88ccd09 100644 --- a/crates/anyedge-cli/Cargo.toml +++ b/crates/anyedge-cli/Cargo.toml @@ -7,6 +7,7 @@ description = "AnyEdge CLI: build and deploy to multiple edge adapters" [dependencies] anyedge-core = { workspace = true } anyedge-adapter = { path = "../anyedge-adapter" } +anyedge-adapter-axum = { workspace = true, features = ["cli", "axum"], optional = true } anyedge-adapter-cloudflare = { workspace = true, features = ["cli"], optional = true } anyedge-adapter-fastly = { workspace = true, features = ["cli"], optional = true } app-demo-core = { path = "../../examples/app-demo/crates/app-demo-core", package = "app-demo-core", optional = true } @@ -27,7 +28,7 @@ tempfile = { workspace = true } [features] default = [ "cli", - "dev-example", + "anyedge-adapter-axum", "anyedge-adapter-fastly", "anyedge-adapter-cloudflare", ] diff --git a/crates/anyedge-cli/README.md b/crates/anyedge-cli/README.md index 08f571f..a12fe81 100644 --- a/crates/anyedge-cli/README.md +++ b/crates/anyedge-cli/README.md @@ -9,7 +9,7 @@ The crate exposes two cargo features: | Feature | Description | Enabled by default | |----------------|----------------------------------------------------------|--------------------| | `cli` | Builds the command-line interface (`anyedge` binary). | ✅ | -| `dev-example` | Pulls in `examples/app-demo/app-demo-core` so `anyedge dev` can boot the bundled demo app. Disable this if you want to ship the CLI without the example workspace. | ✅ (for local dev) | +| `dev-example` | Pulls in `examples/app-demo/app-demo-core` so `anyedge dev` can boot the bundled demo app. Enable only when you want the sample router available. | ❌ | When you just need the CLI functionality (e.g. packaging for distribution), build without the demo feature: @@ -17,10 +17,10 @@ When you just need the CLI functionality (e.g. packaging for distribution), buil cargo build -p anyedge-cli --no-default-features --features cli ``` -For contributors, the default feature set keeps `dev-example` turned on, allowing `anyedge dev` to run the demo app out of the box: +For contributors working on the demo, enable the extra feature: ```bash -cargo run -p anyedge-cli --features cli -- dev +cargo run -p anyedge-cli --features "cli,dev-example" -- dev ``` ## Commands @@ -28,7 +28,7 @@ cargo run -p anyedge-cli --features cli -- dev _(summaries only; see `anyedge --help` for details)_ - `anyedge new ` – Scaffold a new AnyEdge project (templates still evolving). -- `anyedge dev` – Serve the demo app locally (uses the `dev-example` feature by default). +- `anyedge dev` – Serve the current project locally (add `--features dev-example` to run the bundled demo). - `anyedge build --adapter fastly` – Compile the Fastly crate to `wasm32-wasip1` and drop the artifact in `pkg/`. - `anyedge deploy --adapter fastly` – Invoke the Fastly CLI (`fastly compute deploy`) from the detected Fastly crate. - `anyedge serve --adapter fastly` – Run `fastly compute serve` in the Fastly crate directory for local testing (requires Fastly CLI). diff --git a/crates/anyedge-cli/src/dev_server.rs b/crates/anyedge-cli/src/dev_server.rs index 3f505f7..8f881ae 100644 --- a/crates/anyedge-cli/src/dev_server.rs +++ b/crates/anyedge-cli/src/dev_server.rs @@ -1,161 +1,52 @@ -use std::io::{Read, Write}; -use std::net::{TcpListener, TcpStream}; -use std::time::Duration; +#![cfg(feature = "anyedge-adapter-axum")] -#[cfg(not(feature = "dev-example"))] -use anyedge_core::action; -use anyedge_core::body::Body; -use anyedge_core::http::{ - request_builder, HeaderName, HeaderValue, Method, Response as CoreResponse, Uri, -}; +use std::net::SocketAddr; +use std::path::PathBuf; + +use anyedge_adapter_axum::{AxumDevServer, AxumDevServerConfig}; +use anyedge_core::manifest::ManifestLoader; use anyedge_core::router::RouterService; -use futures::{executor::block_on, pin_mut, StreamExt}; + +use crate::adapter; +use crate::adapter::Action; + +#[cfg(not(feature = "dev-example"))] +use anyedge_core::{action, extractor::Path, response::Text}; +#[cfg(feature = "dev-example")] +use anyedge_core::app::Hooks; #[cfg(feature = "dev-example")] use app_demo_core::App; pub fn run_dev() { - println!("[anyedge] dev: starting local server on http://127.0.0.1:8787"); - let router = build_dev_router(); - if let Err(err) = run_local_server("127.0.0.1:8787", router) { - eprintln!("[anyedge] dev server error: {err}"); - } -} - -fn run_local_server(addr: &str, router: RouterService) -> std::io::Result<()> { - let listener = TcpListener::bind(addr)?; - for stream in listener.incoming() { - let mut stream = stream?; - stream.set_read_timeout(Some(Duration::from_secs(5)))?; - if let Err(err) = handle_conn(&mut stream, router.clone()) { - eprintln!("[anyedge] conn error: {err}"); - } + match try_run_manifest_axum() { + Ok(true) => return, + Ok(false) => {} + Err(err) => eprintln!("[anyedge] dev manifest error: {err}"), } - Ok(()) -} - -fn handle_conn(stream: &mut TcpStream, router: RouterService) -> std::io::Result<()> { - let mut buf = [0u8; 8192]; - let mut read = 0usize; - // Read until CRLF CRLF or buffer fills - loop { - let n = stream.read(&mut buf[read..])?; - if n == 0 { - break; - } - read += n; - if read >= 4 && buf[..read].windows(4).any(|window| window == b"\r\n\r\n") { - break; - } - if read == buf.len() { - break; - } - } - - let request = request_from_buffer(&buf[..read])?; - let response = block_on(router.oneshot(request)); - write_response(stream, response) -} - -fn request_from_buffer(raw: &[u8]) -> std::io::Result { - let req_text = String::from_utf8_lossy(raw); - let mut lines = req_text.lines(); - let request_line = lines.next().unwrap_or(""); - let mut parts = request_line.split_whitespace(); - let method_token = parts.next().unwrap_or("GET"); - let path_token = parts.next().unwrap_or("/"); - - let method = Method::from_bytes(method_token.as_bytes()).unwrap_or(Method::GET); - let uri = path_token - .parse::() - .unwrap_or_else(|_| "/".parse::().expect("static URI")); - let mut req = request_builder() - .method(method) - .uri(uri) - .body(Body::empty()) - .map_err(std::io::Error::other)?; - - for line in lines { - if line.is_empty() { - break; - } - if let Some((name, value)) = line.split_once(':') { - if let (Ok(header_name), Ok(header_value)) = ( - HeaderName::from_bytes(name.trim().as_bytes()), - HeaderValue::from_str(value.trim()), - ) { - req.headers_mut().append(header_name, header_value); - } - } - } + let addr = SocketAddr::from(([127, 0, 0, 1], 8787)); + println!( + "[anyedge] dev: starting local server on http://{}:{}", + addr.ip(), + addr.port() + ); - Ok(req) -} - -fn write_response(stream: &mut TcpStream, response: CoreResponse) -> std::io::Result<()> { - let (head, body) = serialize_response(response)?; - stream.write_all(&head)?; - stream.write_all(&body)?; - Ok(()) -} - -fn serialize_response(response: CoreResponse) -> std::io::Result<(Vec, Vec)> { - let (parts, body) = response.into_parts(); - let status = parts.status; - let reason = status.canonical_reason().unwrap_or("OK"); - - let mut head = Vec::new(); - head.extend_from_slice(b"HTTP/1.1 "); - let status_code = status.as_u16().to_string(); - head.extend_from_slice(status_code.as_bytes()); - head.push(b' '); - head.extend_from_slice(reason.as_bytes()); - head.extend_from_slice(b"\r\n"); - - let mut has_content_length = false; - for (name, value) in parts.headers.iter() { - if name.as_str().eq_ignore_ascii_case("content-length") { - has_content_length = true; - } - head.extend_from_slice(name.as_str().as_bytes()); - head.extend_from_slice(b": "); - head.extend_from_slice(value.to_str().unwrap_or("").as_bytes()); - head.extend_from_slice(b"\r\n"); - } - - let body_bytes: Vec = match body { - Body::Once(bytes) => bytes.to_vec(), - Body::Stream(stream_body) => { - let collected = block_on(async move { - let mut buf = Vec::new(); - pin_mut!(stream_body); - while let Some(chunk) = stream_body.next().await { - let chunk = chunk.map_err(|err| std::io::Error::other(err.to_string()))?; - buf.extend_from_slice(&chunk); - } - Ok::, std::io::Error>(buf) - })?; - collected - } + let router = build_dev_router(); + let config = AxumDevServerConfig { + addr, + ..AxumDevServerConfig::default() }; - if !has_content_length { - head.extend_from_slice(b"Content-Length: "); - head.extend_from_slice(body_bytes.len().to_string().as_bytes()); - head.extend_from_slice(b"\r\n"); + let server = AxumDevServer::with_config(router, config); + if let Err(err) = server.run() { + eprintln!("[anyedge] dev server error: {err}"); } - - head.extend_from_slice(b"\r\n"); - - Ok((head, body_bytes)) } fn build_dev_router() -> RouterService { #[cfg(feature = "dev-example")] { - use anyedge_core::app::Hooks; - let demo_app = App::build_app(); demo_app.router().clone() } @@ -188,69 +79,33 @@ async fn dev_root() -> Text<&'static str> { #[cfg(not(feature = "dev-example"))] #[action] -async fn dev_echo(Path(params): anyedge_core::extractor::Path) -> Text { +async fn dev_echo(Path(params): Path) -> Text { Text::new(format!("hello {}", params.name)) } -#[cfg(test)] -mod tests { - use super::*; - use anyedge_core::http::{header::HOST, response_builder, Method, StatusCode}; - use anyedge_core::response::Text; - - #[anyedge_core::action] - async fn hello() -> Text<&'static str> { - Text::new("hello world") - } - - #[test] - fn request_from_buffer_parses_basic_get() { - let request = request_from_buffer( - b"GET /demo HTTP/1.1 -Host: example +fn try_run_manifest_axum() -> Result { + let manifest = match load_manifest_optional()? { + Some(manifest) => manifest, + None => return Ok(false), + }; -", - ) - .expect("request"); - assert_eq!(request.method(), Method::GET); - assert_eq!(request.uri().path(), "/demo"); - assert_eq!( - request - .headers() - .get(HOST) - .and_then(|value| value.to_str().ok()), - Some("example") - ); + if manifest.manifest().adapters.contains_key("axum") { + adapter::execute("axum", Action::Serve, Some(&manifest), &[]) + .map_err(|err| format!("serve command failed: {err}"))?; + return Ok(true); } - #[test] - fn serialize_response_includes_headers_and_body() { - let response = response_builder() - .status(StatusCode::OK) - .header("x-test", "value") - .body(Body::text("hi")) - .expect("response"); - let (head, body) = serialize_response(response).expect("serialize"); - let head_text = String::from_utf8(head).expect("utf8"); - assert!(head_text.starts_with("HTTP/1.1 200 OK")); - assert!(head_text.contains("Content-Length: 2")); - assert!(head_text.contains("x-test: value")); - assert!(head_text.contains("\r\n\r\n")); - assert_eq!(body, b"hi"); - } + Ok(false) +} - #[test] - fn router_handles_request_via_helpers() { - let router = RouterService::builder().get("/", hello).build(); - let request = request_from_buffer( - b"GET / HTTP/1.1 -Host: localhost +fn load_manifest_optional() -> Result, String> { + let path = std::env::var("ANYEDGE_MANIFEST") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("anyedge.toml")); -", - ) - .expect("request"); - let response = block_on(router.oneshot(request)); - let (_head, body) = serialize_response(response).expect("serialize"); - assert_eq!(body, b"hello world"); + match ManifestLoader::from_path(&path) { + Ok(manifest) => Ok(Some(manifest)), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(format!("failed to load {}: {err}", path.display())), } } diff --git a/crates/anyedge-cli/src/generator.rs b/crates/anyedge-cli/src/generator.rs index dddf21f..17d4176 100644 --- a/crates/anyedge-cli/src/generator.rs +++ b/crates/anyedge-cli/src/generator.rs @@ -67,7 +67,6 @@ struct AdapterArtifacts { adapter_ids: Vec, workspace_members: Vec, manifest_sections: String, - logging_sections: String, readme_adapter_crates: String, readme_adapter_dev: String, } @@ -110,16 +109,22 @@ pub fn generate_new(args: NewArgs) -> std::io::Result<()> { fn seed_workspace_dependencies() -> BTreeMap { let mut deps = BTreeMap::new(); deps.insert("bytes".to_string(), "bytes = \"1\"".to_string()); + deps.insert("anyhow".to_string(), "anyhow = \"1\"".to_string()); deps.insert( "futures".to_string(), "futures = { version = \"0.3\", default-features = false, features = [\"std\", \"executor\"] }" .to_string(), ); + deps.insert("axum".to_string(), "axum = \"0.8\"".to_string()); deps.insert( "serde".to_string(), "serde = { version = \"1\", features = [\"derive\"] }".to_string(), ); deps.insert("log".to_string(), "log = \"0.4\"".to_string()); + deps.insert( + "simple_logger".to_string(), + "simple_logger = \"4\"".to_string(), + ); deps.insert( "worker".to_string(), "worker = { version = \"0.6\", default-features = false, features = [\"http\"] }" @@ -127,6 +132,11 @@ fn seed_workspace_dependencies() -> BTreeMap { ); deps.insert("fastly".to_string(), "fastly = \"0.11\"".to_string()); deps.insert("once_cell".to_string(), "once_cell = \"1\"".to_string()); + deps.insert( + "tokio".to_string(), + "tokio = { version = \"1\", features = [\"macros\", \"rt-multi-thread\"] }".to_string(), + ); + deps.insert("tracing".to_string(), "tracing = \"0.1\"".to_string()); deps } @@ -160,7 +170,6 @@ fn collect_adapter_data( let mut adapter_ids = Vec::new(); let mut workspace_members = Vec::new(); let mut manifest_sections = String::new(); - let mut logging_sections = String::new(); let mut readme_adapter_crates = String::new(); let mut readme_adapter_dev = String::new(); @@ -242,23 +251,28 @@ fn collect_adapter_data( ) .unwrap(); - let mut logging_section = String::new(); - writeln!(logging_section, "[logging.{}]", blueprint.id).unwrap(); + manifest_section.push('\n'); + writeln!(manifest_section, "[adapters.{}.logging]", blueprint.id).unwrap(); if blueprint.id == "fastly" { - writeln!(logging_section, "endpoint = \"{}_log\"", layout.project_mod).unwrap(); + writeln!( + manifest_section, + "endpoint = \"{}_log\"", + layout.project_mod + ) + .unwrap(); } else if let Some(endpoint) = blueprint.logging.endpoint { - writeln!(logging_section, "endpoint = \"{}\"", endpoint).unwrap(); + writeln!(manifest_section, "endpoint = \"{}\"", endpoint).unwrap(); } - writeln!(logging_section, "level = \"{}\"", blueprint.logging.level).unwrap(); + writeln!(manifest_section, "level = \"{}\"", blueprint.logging.level).unwrap(); if let Some(echo_stdout) = blueprint.logging.echo_stdout { writeln!( - logging_section, + manifest_section, "echo_stdout = {}", if echo_stdout { "true" } else { "false" } ) .unwrap(); } - logging_section.push('\n'); + manifest_section.push('\n'); let description = blueprint .readme @@ -280,8 +294,6 @@ fn collect_adapter_data( readme_adapter_dev.push('\n'); manifest_sections.push_str(&manifest_section); - logging_sections.push_str(&logging_section); - workspace_members.push(format!(" \"crates/{}\",", crate_name)); adapter_ids.push(blueprint.id.to_string()); @@ -297,7 +309,6 @@ fn collect_adapter_data( adapter_ids, workspace_members, manifest_sections, - logging_sections, readme_adapter_crates, readme_adapter_dev, }) @@ -337,10 +348,6 @@ fn build_base_data( "adapter_manifest_sections".into(), Value::String(artifacts.manifest_sections.clone()), ); - data.insert( - "logging_sections".into(), - Value::String(artifacts.logging_sections.clone()), - ); data.insert( "readme_adapter_crates".into(), Value::String(artifacts.readme_adapter_crates.clone()), diff --git a/crates/anyedge-cli/src/main.rs b/crates/anyedge-cli/src/main.rs index 63542df..19b6624 100644 --- a/crates/anyedge-cli/src/main.rs +++ b/crates/anyedge-cli/src/main.rs @@ -4,7 +4,7 @@ mod adapter; #[cfg(feature = "cli")] mod args; -#[cfg(feature = "cli")] +#[cfg(all(feature = "cli", feature = "anyedge-adapter-axum"))] mod dev_server; #[cfg(feature = "cli")] mod generator; @@ -56,7 +56,18 @@ fn main() { } } Command::Dev => { - dev_server::run_dev(); + #[cfg(feature = "anyedge-adapter-axum")] + { + dev_server::run_dev(); + } + + #[cfg(not(feature = "anyedge-adapter-axum"))] + { + eprintln!( + "anyedge-cli built without `anyedge-adapter-axum`; rebuild with that feature to use `anyedge dev`." + ); + std::process::exit(1); + } } } } diff --git a/crates/anyedge-cli/src/templates/root/anyedge.toml.hbs b/crates/anyedge-cli/src/templates/root/anyedge.toml.hbs index 7d86f5b..305929b 100644 --- a/crates/anyedge-cli/src/templates/root/anyedge.toml.hbs +++ b/crates/anyedge-cli/src/templates/root/anyedge.toml.hbs @@ -59,5 +59,3 @@ adapters = [{{{adapter_list}}}] env = "API_TOKEN" {{{adapter_manifest_sections}}} - -{{{logging_sections}}} diff --git a/crates/anyedge-core/src/manifest.rs b/crates/anyedge-core/src/manifest.rs index ae6be47..7d2a022 100644 --- a/crates/anyedge-core/src/manifest.rs +++ b/crates/anyedge-core/src/manifest.rs @@ -112,12 +112,24 @@ impl Manifest { } fn finalize(&mut self) { - self.logging_resolved = self - .logging - .adapters - .iter() - .map(|(adapter, cfg)| (adapter.clone(), ResolvedLoggingConfig::from_manifest(cfg))) - .collect(); + let mut resolved = BTreeMap::new(); + + for (adapter, cfg) in &self.adapters { + if cfg.logging.is_specified() { + resolved.insert( + adapter.clone(), + ResolvedLoggingConfig::from_manifest(&cfg.logging), + ); + } + } + + for (adapter, cfg) in &self.logging.adapters { + resolved + .entry(adapter.clone()) + .or_insert_with(|| ResolvedLoggingConfig::from_manifest(cfg)); + } + + self.logging_resolved = resolved; } } @@ -249,6 +261,9 @@ pub struct ManifestAdapter { #[serde(default)] #[validate(nested)] pub commands: ManifestAdapterCommands, + #[serde(default)] + #[validate(nested)] + pub logging: ManifestLoggingConfig, } #[derive(Debug, Default, Deserialize, Validate)] @@ -338,6 +353,12 @@ impl ResolvedLoggingConfig { } } +impl ManifestLoggingConfig { + fn is_specified(&self) -> bool { + self.level.is_some() || self.endpoint.is_some() || self.echo_stdout.is_some() + } +} + #[derive(Clone, Debug, Eq, PartialEq)] pub enum HttpMethod { Get, diff --git a/docs/manifest.md b/docs/manifest.md index e71a75c..3e94a1b 100644 --- a/docs/manifest.md +++ b/docs/manifest.md @@ -47,6 +47,12 @@ build = "cargo build --release --target wasm32-wasip1 -p demo-adapter-fastly" serve = "fastly compute serve -C crates/demo-adapter-fastly" deploy = "fastly compute deploy -C crates/demo-adapter-fastly" +[adapters.fastly.logging] +endpoint = "stdout" +level = "info" +echo_stdout = true + + [adapters.cloudflare.adapter] crate = "crates/demo-adapter-cloudflare" manifest = "crates/demo-adapter-cloudflare/wrangler.toml" @@ -60,12 +66,7 @@ build = "cargo build --release --target wasm32-unknown-unknown -p demo-adapter-c serve = "wrangler dev --config crates/demo-adapter-cloudflare/wrangler.toml" deploy = "wrangler publish --config crates/demo-adapter-cloudflare/wrangler.toml" -[logging.fastly] -endpoint = "stdout" -level = "info" -echo_stdout = true - -[logging.cloudflare] +[adapters.cloudflare.logging] level = "info" ``` @@ -125,9 +126,13 @@ Describes how a provider adapter is built and invoked. - `[adapters..build]`: Build target, profile, and optional feature list. - `[adapters..commands]`: Convenience commands for build/serve/deploy. -### `[logging.]` +The AnyEdge CLI will, when present, run these commands for `build`, `serve`, +and `deploy` before falling back to the adapter's built-in behaviour. That lets +you customise provider tooling (e.g. add flags) without recompiling the CLI. + +### `[adapters..logging]` -Optional logging configuration per provider. Current fields: +Optional logging configuration nested under each adapter. Current fields: - `endpoint` (Fastly only): Name passed to `init_logger` (defaults to `stdout`). - `level`: Log level (`trace`, `debug`, `info`, `warn`, `error`, `off`). Defaults to `info`. diff --git a/examples/app-demo/Cargo.toml b/examples/app-demo/Cargo.toml index 60c7e14..4e40650 100644 --- a/examples/app-demo/Cargo.toml +++ b/examples/app-demo/Cargo.toml @@ -3,19 +3,26 @@ members = [ "crates/app-demo-core", "crates/app-demo-adapter-fastly", "crates/app-demo-adapter-cloudflare", + "crates/app-demo-adapter-axum", ] resolver = "2" [workspace.dependencies] anyedge-adapter-cloudflare = { path = "../../crates/anyedge-adapter-cloudflare" } anyedge-adapter-fastly = { path = "../../crates/anyedge-adapter-fastly" } +anyedge-adapter-axum = { path = "../../crates/anyedge-adapter-axum" } anyedge-core = { path = "../../crates/anyedge-core" } app-demo-core = { path = "crates/app-demo-core" } +anyhow = "1" +axum = "0.7" bytes = "1" fastly = "0.11" futures = { version = "0.3", default-features = false, features = ["std", "executor"] } log = "0.4" once_cell = "1" +simple_logger = "4" serde = { version = "1", features = ["derive"] } serde_json = "1" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +tracing = "0.1" worker = { version = "0.6", default-features = false, features = ["http"] } diff --git a/examples/app-demo/anyedge.toml b/examples/app-demo/anyedge.toml index d8b08a1..00d364d 100644 --- a/examples/app-demo/anyedge.toml +++ b/examples/app-demo/anyedge.toml @@ -10,28 +10,28 @@ id = "root" path = "/" methods = ["GET"] handler = "app_demo_core::handlers::root" -adapters = ["fastly", "cloudflare"] +adapters = ["fastly", "cloudflare", "axum"] [[triggers.http]] id = "echo" path = "/echo/{name}" methods = ["GET"] handler = "app_demo_core::handlers::echo" -adapters = ["fastly", "cloudflare"] +adapters = ["fastly", "cloudflare", "axum"] [[triggers.http]] id = "headers" path = "/headers" methods = ["GET"] handler = "app_demo_core::handlers::headers" -adapters = ["fastly", "cloudflare"] +adapters = ["fastly", "cloudflare", "axum"] [[triggers.http]] id = "stream" path = "/stream" methods = ["GET"] handler = "app_demo_core::handlers::stream" -adapters = ["fastly", "cloudflare"] +adapters = ["fastly", "cloudflare", "axum"] body-mode = "stream" [[triggers.http]] @@ -39,14 +39,14 @@ id = "echo_json" path = "/echo" methods = ["POST"] handler = "app_demo_core::handlers::echo_json" -adapters = ["fastly", "cloudflare"] +adapters = ["fastly", "cloudflare", "axum"] [[triggers.http]] id = "routes" path = "/__anyedge/routes" methods = ["GET"] handler = "app_demo_core::handlers::list_routes" -adapters = ["fastly", "cloudflare"] +adapters = ["fastly", "cloudflare", "axum"] [environment] @@ -59,7 +59,7 @@ value = "https://example.com/api" [[environment.secrets]] name = "API_TOKEN" description = "Example secret placeholder." -adapters = ["fastly", "cloudflare"] +adapters = ["fastly", "cloudflare", "axum"] env = "API_TOKEN" [adapters.fastly.adapter] @@ -76,6 +76,11 @@ build = "cargo build --release --target wasm32-wasip1 -p app-demo-adapter-fastly serve = "fastly compute serve -C crates/app-demo-adapter-fastly" deploy = "fastly compute deploy -C crates/app-demo-adapter-fastly" +[adapters.fastly.logging] +endpoint = "app_demo_log" +level = "info" +echo_stdout = true + [adapters.cloudflare.adapter] crate = "crates/app-demo-adapter-cloudflare" manifest = "crates/app-demo-adapter-cloudflare/wrangler.toml" @@ -90,10 +95,22 @@ build = "cargo build --release --target wasm32-unknown-unknown -p app-demo-adapt serve = "wrangler dev --config crates/app-demo-adapter-cloudflare/wrangler.toml" deploy = "wrangler deploy --config crates/app-demo-adapter-cloudflare/wrangler.toml" -[logging.fastly] -endpoint = "app_demo_log" +[adapters.cloudflare.logging] level = "info" -echo_stdout = true -[logging.cloudflare] +[adapters.axum.adapter] +crate = "crates/app-demo-adapter-axum" +manifest = "crates/app-demo-adapter-axum/axum.toml" + +[adapters.axum.build] +target = "native" +profile = "dev" + +[adapters.axum.commands] +build = "cargo build -p app-demo-adapter-axum" +serve = "cargo run -p app-demo-adapter-axum" +deploy = "# configure deployment for Axum" + +[adapters.axum.logging] level = "info" +echo_stdout = true diff --git a/examples/app-demo/crates/app-demo-adapter-axum/Cargo.toml b/examples/app-demo/crates/app-demo-adapter-axum/Cargo.toml new file mode 100644 index 0000000..8c8cfe0 --- /dev/null +++ b/examples/app-demo/crates/app-demo-adapter-axum/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "app-demo-adapter-axum" +version = "0.1.0" +edition = "2021" +publish = false + +[[bin]] +name = "app-demo-adapter-axum" +path = "src/main.rs" + +[dependencies] +app-demo-core = { workspace = true } +anyedge-core = { workspace = true } +anyhow = { workspace = true } +anyedge-adapter-axum = { workspace = true, features = ["axum"] } +axum = { workspace = true } +log = { workspace = true } +simple_logger = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +tracing = { workspace = true } diff --git a/examples/app-demo/crates/app-demo-adapter-axum/axum.toml b/examples/app-demo/crates/app-demo-adapter-axum/axum.toml new file mode 100644 index 0000000..364f8e0 --- /dev/null +++ b/examples/app-demo/crates/app-demo-adapter-axum/axum.toml @@ -0,0 +1,4 @@ +[adapter] +crate = "app-demo-adapter-axum" +crate_dir = "." +port = 8787 diff --git a/examples/app-demo/crates/app-demo-adapter-axum/src/main.rs b/examples/app-demo/crates/app-demo-adapter-axum/src/main.rs new file mode 100644 index 0000000..9eebed6 --- /dev/null +++ b/examples/app-demo/crates/app-demo-adapter-axum/src/main.rs @@ -0,0 +1,8 @@ +use app_demo_core::App; + +fn main() { + if let Err(err) = anyedge_adapter_axum::run_app::(include_str!("../../../anyedge.toml")) { + eprintln!("app-demo-adapter-axum failed: {err}"); + std::process::exit(1); + } +}