diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4867f43..0d64cb4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,6 +11,36 @@ env: RUSTFLAGS: -Dwarnings jobs: + build_and_test: + name: Build and test + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macOS-latest] + rust: [stable] + + steps: + - uses: actions/checkout@master + + - name: Install ${{ matrix.rust }} + uses: actions-rs/toolchain@v1 + with: + toolchain: ${{ matrix.rust }} + target: wasm32-wasip2 + override: true + + - name: check + uses: actions-rs/cargo@v1 + with: + command: check + args: --all --bins --examples + + - name: tests + uses: actions-rs/cargo@v1 + with: + command: test + args: --all + check_fmt_and_docs: name: Checking fmt and docs runs-on: ubuntu-latest diff --git a/Cargo.toml b/Cargo.toml index dfac910..33b9d39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,12 @@ [package] name = "wstd" -version = "0.4.0" -license = "MIT OR Apache-2.0 OR Apache-2.0 WITH LLVM-exception" +version.workspace = true +license.workspace = true repository = "https://github.com/yoshuawuyts/wstd" documentation = "https://docs.rs/wstd" description = "An async standard library for Wasm Components and WASI 0.2" readme = "README.md" -edition = "2021" -resolver = "2" +edition.workspace = true keywords = ["WebAssembly", "async", "stdlib", "Components"] categories = [] authors = [ @@ -17,8 +16,46 @@ authors = [ [features] [dependencies] +slab.workspace = true +url.workspace = true +wasi.workspace = true +wstd-macro.workspace = true + +[dev-dependencies] +anyhow.workspace = true +serde_json.workspace = true +test-programs-artifacts.workspace = true +wasmtime.workspace = true +wasmtime-wasi.workspace = true +wasmtime-wasi-http.workspace = true + +[workspace] +members = [ + "macro", + "test-programs", + "test-programs/artifacts", +] +resolver = "2" + +[workspace.package] +version = "0.4.0" +edition = "2021" +license = "MIT OR Apache-2.0 OR Apache-2.0 WITH LLVM-exception" + +[workspace.dependencies] +anyhow = "1" +cargo_metadata = "0.18.1" +heck = "0.5" +quote = "1.0" +serde_json = "1" slab = "0.4.9" +syn = "2.0" +test-programs = { path = "test-programs" } +test-programs-artifacts = { path = "test-programs/artifacts" } url = "2.5.0" wasi = "0.13.1" - -[dev-dependencies] +wasmtime = "26" +wasmtime-wasi = "26" +wasmtime-wasi-http = "26" +wstd = { path = "." } +wstd-macro = { path = "macro" } diff --git a/examples/http_get.rs b/examples/http_get.rs new file mode 100644 index 0000000..c366e66 --- /dev/null +++ b/examples/http_get.rs @@ -0,0 +1,34 @@ +use std::error::Error; +use wstd::http::{Client, Method, Request, Url}; +use wstd::io::AsyncRead; + +#[wstd::main] +async fn main() -> Result<(), Box> { + let request = Request::new(Method::Get, Url::parse("https://postman-echo.com/get")?); + let mut response = Client::new().send(request).await?; + + let content_type = response + .headers() + .get(&"content-type".into()) + .ok_or_else(|| "response expected to have content-type header")?; + assert_eq!(content_type.len(), 1, "one header value for content-type"); + assert_eq!(content_type[0], b"application/json; charset=utf-8"); + + // Would much prefer read_to_end here: + let mut body_buf = vec![0; 4096]; + let body_len = response.body().read(&mut body_buf).await?; + body_buf.truncate(body_len); + + let val: serde_json::Value = serde_json::from_slice(&body_buf)?; + let body_url = val + .get("url") + .ok_or_else(|| "body json has url")? + .as_str() + .ok_or_else(|| "body json url is str")?; + assert!( + body_url.contains("postman-echo.com/get"), + "expected body url to contain the authority and path, got: {body_url}" + ); + + Ok(()) +} diff --git a/examples/tcp_echo_server.rs b/examples/tcp_echo_server.rs new file mode 100644 index 0000000..fdd21e9 --- /dev/null +++ b/examples/tcp_echo_server.rs @@ -0,0 +1,18 @@ +use wstd::io; +use wstd::iter::AsyncIterator; +use wstd::net::TcpListener; + +#[wstd::main] +async fn main() -> io::Result<()> { + let listener = TcpListener::bind("127.0.0.1:8080").await?; + println!("Listening on {}", listener.local_addr()?); + println!("type `nc localhost 8080` to create a TCP client"); + + let mut incoming = listener.incoming(); + while let Some(stream) = incoming.next().await { + let stream = stream?; + println!("Accepted from: {}", stream.peer_addr()?); + io::copy(&stream, &stream).await?; + } + Ok(()) +} diff --git a/macro/Cargo.toml b/macro/Cargo.toml new file mode 100644 index 0000000..d6d0979 --- /dev/null +++ b/macro/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "wstd-macro" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +proc-macro = true + +[dependencies] +syn = { workspace = true, features = ["full"] } +quote.workspace = true diff --git a/macro/src/lib.rs b/macro/src/lib.rs new file mode 100644 index 0000000..02ba6b1 --- /dev/null +++ b/macro/src/lib.rs @@ -0,0 +1,46 @@ +use proc_macro::TokenStream; +use quote::{quote, quote_spanned}; +use syn::{parse_macro_input, spanned::Spanned, ItemFn}; + +#[proc_macro_attribute] +pub fn attr_macro_main(_attr: TokenStream, item: TokenStream) -> TokenStream { + let input = parse_macro_input!(item as ItemFn); + + if input.sig.asyncness.is_none() { + return quote_spanned! { input.sig.fn_token.span()=> + compile_error!("fn must be `async fn`"); + } + .into(); + } + + if input.sig.ident != "main" { + return quote_spanned! { input.sig.ident.span()=> + compile_error!("only `async fn main` can be used for #[wstd::main]"); + } + .into(); + } + + if !input.sig.inputs.is_empty() { + return quote_spanned! { input.sig.inputs.span()=> + compile_error!("arguments to main are not supported"); + } + .into(); + } + let attrs = input.attrs; + let output = input.sig.output; + let block = input.block; + quote! { + pub fn main() #output { + + #(#attrs)* + async fn __run() #output { + #block + } + + ::wstd::runtime::block_on(async { + __run().await + }) + } + } + .into() +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..ea32b2d --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.82.0" +targets = ["wasm32-wasip2"] diff --git a/src/lib.rs b/src/lib.rs index 5504758..9c40b07 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,27 +12,14 @@ //! //! **TCP echo server** //! -//! ```rust -//! use wstd::io; -//! use wstd::iter::AsyncIterator; -//! use wstd::net::TcpListener; -//! use wstd::runtime::block_on; +//! ```rust,no_run +#![doc = include_str!("../examples/tcp_echo_server.rs")] +//! ``` //! -//! fn main() -> io::Result<()> { -//! block_on(async move { -//! let listener = TcpListener::bind("127.0.0.1:8080").await?; -//! println!("Listening on {}", listener.local_addr()?); -//! println!("type `nc localhost 8080` to create a TCP client"); +//! **HTTP Client** //! -//! let mut incoming = listener.incoming(); -//! while let Some(stream) = incoming.next().await { -//! let stream = stream?; -//! println!("Accepted from: {}", stream.peer_addr()?); -//! io::copy(&stream, &stream).await?; -//! } -//! Ok(()) -//! }) -//! } +//! ```rust,no_run +#![doc = include_str!("../examples/http_get.rs")] //! ``` //! //! # Design Decisions @@ -64,3 +51,5 @@ pub mod net; pub mod rand; pub mod runtime; pub mod time; + +pub use wstd_macro::attr_macro_main as main; diff --git a/test-programs/Cargo.toml b/test-programs/Cargo.toml new file mode 100644 index 0000000..af2458b --- /dev/null +++ b/test-programs/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "test-programs" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +serde_json.workspace = true +wstd.workspace = true diff --git a/test-programs/artifacts/Cargo.toml b/test-programs/artifacts/Cargo.toml new file mode 100644 index 0000000..cd8adef --- /dev/null +++ b/test-programs/artifacts/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "test-programs-artifacts" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish = false + +[dependencies] + +[build-dependencies] +cargo_metadata.workspace = true +heck.workspace = true diff --git a/test-programs/artifacts/build.rs b/test-programs/artifacts/build.rs new file mode 100644 index 0000000..ee4b29c --- /dev/null +++ b/test-programs/artifacts/build.rs @@ -0,0 +1,62 @@ +use heck::ToShoutySnakeCase; +use std::env::var_os; +use std::path::PathBuf; +use std::process::Command; + +fn main() { + let out_dir = PathBuf::from(var_os("OUT_DIR").expect("OUT_DIR env var exists")); + + let meta = cargo_metadata::MetadataCommand::new() + .exec() + .expect("cargo metadata"); + let test_programs_meta = meta + .packages + .iter() + .find(|p| p.name == "test-programs") + .expect("test-programs is in cargo metadata"); + let test_programs_root = test_programs_meta.manifest_path.parent().unwrap(); + println!( + "cargo:rerun-if-changed={}", + test_programs_root.as_os_str().to_str().unwrap() + ); + + let status = Command::new("cargo") + .arg("build") + .arg("--target=wasm32-wasip2") + .arg("--package=test-programs") + .env("CARGO_TARGET_DIR", &out_dir) + .env("CARGO_PROFILE_DEV_DEBUG", "2") + .env("RUSTFLAGS", rustflags()) + .env_remove("CARGO_ENCODED_RUSTFLAGS") + .status() + .expect("cargo build test programs"); + assert!(status.success()); + + let mut generated_code = "// THIS FILE IS GENERATED CODE\n".to_string(); + + for binary in test_programs_meta + .targets + .iter() + .filter(|t| t.kind == ["bin"]) + { + let component_path = out_dir + .join("wasm32-wasip2") + .join("debug") + .join(format!("{}.wasm", binary.name)); + + let const_name = binary.name.to_shouty_snake_case(); + generated_code += &format!( + "pub const {const_name}: &str = {:?};\n", + component_path.as_os_str().to_str().expect("path is str") + ); + } + + std::fs::write(out_dir.join("gen.rs"), generated_code).unwrap(); +} + +fn rustflags() -> &'static str { + match option_env!("RUSTFLAGS") { + Some(s) if s.contains("-D warnings") => "-D warnings", + _ => "", + } +} diff --git a/test-programs/artifacts/src/lib.rs b/test-programs/artifacts/src/lib.rs new file mode 100644 index 0000000..26a930a --- /dev/null +++ b/test-programs/artifacts/src/lib.rs @@ -0,0 +1 @@ +include!(concat!(env!("OUT_DIR"), "/gen.rs")); diff --git a/test-programs/src/bin/http_get.rs b/test-programs/src/bin/http_get.rs new file mode 100644 index 0000000..9b035a1 --- /dev/null +++ b/test-programs/src/bin/http_get.rs @@ -0,0 +1 @@ +include!("../../../examples/http_get.rs"); diff --git a/test-programs/src/bin/tcp_echo_server.rs b/test-programs/src/bin/tcp_echo_server.rs new file mode 100644 index 0000000..d46adb1 --- /dev/null +++ b/test-programs/src/bin/tcp_echo_server.rs @@ -0,0 +1 @@ +include!("../../../examples/tcp_echo_server.rs"); diff --git a/tests/test-programs.rs b/tests/test-programs.rs new file mode 100644 index 0000000..de7f12b --- /dev/null +++ b/tests/test-programs.rs @@ -0,0 +1,129 @@ +use anyhow::{anyhow, Context, Result}; +use wasmtime::{ + component::{Component, Linker, ResourceTable}, + Config, Engine, Store, +}; +use wasmtime_wasi::{pipe::MemoryOutputPipe, WasiCtx, WasiView}; +use wasmtime_wasi_http::{WasiHttpCtx, WasiHttpView}; + +struct Ctx { + table: ResourceTable, + wasi: WasiCtx, + http: WasiHttpCtx, +} + +impl WasiView for Ctx { + fn table(&mut self) -> &mut ResourceTable { + &mut self.table + } + fn ctx(&mut self) -> &mut WasiCtx { + &mut self.wasi + } +} + +impl WasiHttpView for Ctx { + fn table(&mut self) -> &mut ResourceTable { + &mut self.table + } + fn ctx(&mut self) -> &mut WasiHttpCtx { + &mut self.http + } +} + +fn run_in_wasmtime(wasm: &[u8], stdout: Option) -> Result<()> { + let config = Config::default(); + let engine = Engine::new(&config).context("creating engine")?; + let component = Component::new(&engine, wasm).context("loading component")?; + + let mut linker: Linker = Linker::new(&engine); + wasmtime_wasi::add_to_linker_sync(&mut linker).context("add wasi to linker")?; + wasmtime_wasi_http::add_only_http_to_linker_sync(&mut linker) + .context("add wasi-http to linker")?; + + let mut builder = WasiCtx::builder(); + builder.inherit_stderr().inherit_network(); + let wasi = match stdout { + Some(stdout) => builder.stdout(stdout).build(), + None => builder.inherit_stdout().build(), + }; + let mut store = Store::new( + &engine, + Ctx { + table: ResourceTable::new(), + wasi, + http: WasiHttpCtx::new(), + }, + ); + + let instance = linker.instantiate(&mut store, &component)?; + let run_interface = instance + .get_export(&mut store, None, "wasi:cli/run@0.2.0") + .ok_or_else(|| anyhow!("wasi:cli/run missing?"))?; + let run_func_export = instance + .get_export(&mut store, Some(&run_interface), "run") + .ok_or_else(|| anyhow!("run export missing?"))?; + let run_func = instance + .get_typed_func::<(), (Result<(), ()>,)>(&mut store, &run_func_export) + .context("run as typed func")?; + + println!("entering wasm..."); + let (runtime_result,) = run_func.call(&mut store, ())?; + runtime_result.map_err(|()| anyhow!("run returned an error"))?; + println!("done"); + + Ok(()) +} + +#[test] +fn tcp_echo_server() -> Result<()> { + use std::io::{Read, Write}; + use std::net::TcpStream; + use std::thread::sleep; + use std::time::Duration; + + println!("testing {}", test_programs_artifacts::TCP_ECHO_SERVER); + let wasm = std::fs::read(test_programs_artifacts::TCP_ECHO_SERVER).context("read wasm")?; + + let pipe = wasmtime_wasi::pipe::MemoryOutputPipe::new(1024 * 1024); + let write_end = pipe.clone(); + let wasmtime_thread = std::thread::spawn(move || run_in_wasmtime(&wasm, Some(write_end))); + + 'wait: loop { + sleep(Duration::from_millis(100)); + for line in pipe.contents().split(|c| *c == b'\n') { + if line.starts_with(b"Listening on") { + break 'wait; + } + } + } + + let mut tcpstream = + TcpStream::connect("127.0.0.1:8080").context("connect to wasm echo server")?; + println!("connected to wasm echo server"); + + const MESSAGE: &[u8] = b"hello, echoserver!\n"; + + tcpstream.write_all(MESSAGE).context("write to socket")?; + println!("wrote to echo server"); + + let mut readback = Vec::new(); + tcpstream + .read_to_end(&mut readback) + .context("read from socket")?; + + println!("read from wasm server"); + assert_eq!(MESSAGE, readback); + + if wasmtime_thread.is_finished() { + wasmtime_thread.join().expect("wasmtime panicked")?; + } + Ok(()) +} + +#[test] +fn http_get() -> Result<()> { + println!("testing {}", test_programs_artifacts::HTTP_GET); + let wasm = std::fs::read(test_programs_artifacts::HTTP_GET).context("read wasm")?; + + run_in_wasmtime(&wasm, None) +} diff --git a/tests/test.rs b/tests/test.rs deleted file mode 100644 index 8b13789..0000000 --- a/tests/test.rs +++ /dev/null @@ -1 +0,0 @@ -