├── .github └── workflows │ └── rust.yml ├── .gitignore ├── Cargo.toml ├── LICENSE ├── bin └── wasmtime-http.rs ├── build.rs ├── crates ├── as │ ├── index.ts │ ├── package.json │ ├── raw.ts │ ├── readme.md │ └── tsconfig.json ├── wasi-experimental-http-wasmtime │ ├── Cargo.toml │ ├── readme.md │ └── src │ │ └── lib.rs └── wasi-experimental-http │ ├── Cargo.toml │ ├── readme.md │ └── src │ ├── lib.rs │ └── raw.rs ├── readme.md ├── tests ├── as │ ├── index.ts │ ├── package.json │ └── tsconfig.json ├── integration.rs └── rust │ ├── Cargo.toml │ └── src │ └── lib.rs └── witx ├── readme.md └── wasi_experimental_http.witx /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: [ubuntu-latest, macos-latest, windows-latest] 18 | runs-on: ${{ matrix.os }} 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Build 23 | run: rustup target add wasm32-wasi && cargo build --verbose 24 | - name: Run simple test 25 | shell: bash 26 | run: | 27 | if [ "$RUNNER_OS" == "Windows" ]; then 28 | # Attempting to run the unit tests on Windows 29 | # results in the linker failing because it cannot 30 | # compile the Rust client library because of the 31 | # missing external symbols, so we are only running 32 | # the integration tests on Windows. 33 | cargo test -- --nocapture 34 | else 35 | cargo test --all -- --nocapture 36 | fi 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | Cargo.lock 3 | *.wasm 4 | build/ 5 | node_modules/ 6 | package-lock.json 7 | build 8 | *.exe 9 | *.pdb 10 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "wasi-experimental-http-wasmtime-sample" 3 | version = "0.10.0" 4 | authors = [ "Radu Matei " ] 5 | edition = "2021" 6 | 7 | [dependencies] 8 | anyhow = "1.0" 9 | futures = "0.3" 10 | http = "0.2" 11 | reqwest = { version = "0.11", default-features = true, features = [ 12 | "json", 13 | "blocking", 14 | ] } 15 | structopt = "0.3" 16 | tokio = { version = "1.4", features = [ "full" ] } 17 | wasmtime = "0.35" 18 | wasmtime-wasi = "0.35" 19 | wasi-common = "0.35" 20 | wasi-cap-std-sync = "0.35" 21 | wasi-experimental-http = { path = "crates/wasi-experimental-http" } 22 | wasi-experimental-http-wasmtime = { path = "crates/wasi-experimental-http-wasmtime" } 23 | 24 | [workspace] 25 | members = [ 26 | "crates/wasi-experimental-http", 27 | "crates/wasi-experimental-http-wasmtime", 28 | "tests/rust", 29 | ] 30 | 31 | [[bin]] 32 | name = "wasmtime-http" 33 | path = "bin/wasmtime-http.rs" 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Microsoft Corporation. All rights reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -------------------------------------------------------------------------------- /bin/wasmtime-http.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | ffi::OsStr, 3 | path::{Component, PathBuf}, 4 | }; 5 | 6 | use anyhow::{bail, Error}; 7 | use structopt::StructOpt; 8 | use wasi_cap_std_sync::WasiCtxBuilder; 9 | use wasi_experimental_http_wasmtime::{HttpCtx, HttpState}; 10 | use wasmtime::{AsContextMut, Engine, Func, Instance, Linker, Store, Val, ValType}; 11 | use wasmtime_wasi::*; 12 | 13 | #[derive(Debug, StructOpt)] 14 | #[structopt(name = "wasmtime-http")] 15 | struct Opt { 16 | #[structopt(help = "The path of the WebAssembly module to run")] 17 | module: String, 18 | 19 | #[structopt( 20 | short = "i", 21 | long = "invoke", 22 | default_value = "_start", 23 | help = "The name of the function to run" 24 | )] 25 | invoke: String, 26 | 27 | #[structopt( 28 | short = "e", 29 | long = "env", 30 | value_name = "NAME=VAL", 31 | parse(try_from_str = parse_env_var), 32 | help = "Pass an environment variable to the program" 33 | )] 34 | vars: Vec<(String, String)>, 35 | 36 | #[structopt( 37 | short = "a", 38 | long = "allowed-host", 39 | help = "Host the guest module is allowed to make outbound HTTP requests to" 40 | )] 41 | allowed_hosts: Option>, 42 | 43 | #[structopt( 44 | short = "c", 45 | long = "concurrency", 46 | help = "The maximum number of concurrent requests a module can make to allowed hosts" 47 | )] 48 | max_concurrency: Option, 49 | 50 | #[structopt(value_name = "ARGS", help = "The arguments to pass to the module")] 51 | module_args: Vec, 52 | } 53 | 54 | #[tokio::main(flavor = "multi_thread")] 55 | async fn main() -> Result<(), Error> { 56 | let opt = Opt::from_args(); 57 | let method = opt.invoke.clone(); 58 | // println!("{:?}", opt); 59 | let (instance, mut store) = create_instance( 60 | opt.module, 61 | opt.vars, 62 | opt.module_args.clone(), 63 | opt.allowed_hosts, 64 | opt.max_concurrency, 65 | )?; 66 | let func = instance 67 | .get_func(&mut store, method.as_str()) 68 | .unwrap_or_else(|| panic!("cannot find function {}", method)); 69 | 70 | invoke_func(func, opt.module_args, &mut store)?; 71 | 72 | Ok(()) 73 | } 74 | 75 | fn create_instance( 76 | filename: String, 77 | vars: Vec<(String, String)>, 78 | args: Vec, 79 | allowed_hosts: Option>, 80 | max_concurrent_requests: Option, 81 | ) -> Result<(Instance, Store), Error> { 82 | let mut wasmtime_config = wasmtime::Config::default(); 83 | wasmtime_config.wasm_multi_memory(true); 84 | wasmtime_config.wasm_module_linking(true); 85 | let engine = Engine::new(&wasmtime_config)?; 86 | let mut linker = Linker::new(&engine); 87 | 88 | let args = compute_argv(filename.clone(), &args); 89 | 90 | let wasi = WasiCtxBuilder::new() 91 | .inherit_stdin() 92 | .inherit_stdout() 93 | .inherit_stderr() 94 | .envs(&vars)? 95 | .args(&args)? 96 | .build(); 97 | 98 | let http = HttpCtx { 99 | allowed_hosts, 100 | max_concurrent_requests, 101 | }; 102 | 103 | let ctx = WasmtimeHttpCtx { wasi, http }; 104 | 105 | let mut store = Store::new(&engine, ctx); 106 | wasmtime_wasi::add_to_linker(&mut linker, |cx: &mut WasmtimeHttpCtx| -> &mut WasiCtx { 107 | &mut cx.wasi 108 | })?; 109 | // Link `wasi_experimental_http` 110 | let http = HttpState::new()?; 111 | http.add_to_linker(&mut linker, |cx: &WasmtimeHttpCtx| -> HttpCtx { 112 | cx.http.clone() 113 | })?; 114 | 115 | let module = wasmtime::Module::from_file(store.engine(), filename)?; 116 | let instance = linker.instantiate(&mut store, &module)?; 117 | 118 | Ok((instance, store)) 119 | } 120 | 121 | // Invoke function given module arguments and print results. 122 | // Adapted from https://github.com/bytecodealliance/wasmtime/blob/main/src/commands/run.rs. 123 | fn invoke_func(func: Func, args: Vec, mut store: impl AsContextMut) -> Result<(), Error> { 124 | let ty = func.ty(&mut store); 125 | 126 | let mut args = args.iter(); 127 | let mut values = Vec::new(); 128 | for ty in ty.params() { 129 | let val = match args.next() { 130 | Some(s) => s, 131 | None => { 132 | bail!("not enough arguments for invocation") 133 | } 134 | }; 135 | values.push(match ty { 136 | ValType::I32 => Val::I32(val.parse()?), 137 | ValType::I64 => Val::I64(val.parse()?), 138 | ValType::F32 => Val::F32(val.parse()?), 139 | ValType::F64 => Val::F64(val.parse()?), 140 | t => bail!("unsupported argument type {:?}", t), 141 | }); 142 | } 143 | 144 | let mut results = vec![]; 145 | func.call(&mut store, &values, &mut results)?; 146 | for result in results { 147 | match result { 148 | Val::I32(i) => println!("{}", i), 149 | Val::I64(i) => println!("{}", i), 150 | Val::F32(f) => println!("{}", f32::from_bits(f)), 151 | Val::F64(f) => println!("{}", f64::from_bits(f)), 152 | Val::ExternRef(_) => println!(""), 153 | Val::FuncRef(_) => println!(""), 154 | Val::V128(i) => println!("{}", i), 155 | }; 156 | } 157 | 158 | Ok(()) 159 | } 160 | 161 | fn parse_env_var(s: &str) -> Result<(String, String), Error> { 162 | let parts: Vec<_> = s.splitn(2, '=').collect(); 163 | if parts.len() != 2 { 164 | bail!("must be of the form `key=value`"); 165 | } 166 | Ok((parts[0].to_owned(), parts[1].to_owned())) 167 | } 168 | 169 | fn compute_argv(module: String, args: &[String]) -> Vec { 170 | let mut result = Vec::new(); 171 | let module = PathBuf::from(module); 172 | // Add argv[0], which is the program name. Only include the base name of the 173 | // main wasm module, to avoid leaking path information. 174 | result.push( 175 | module 176 | .components() 177 | .next_back() 178 | .map(Component::as_os_str) 179 | .and_then(OsStr::to_str) 180 | .unwrap_or("") 181 | .to_owned(), 182 | ); 183 | 184 | // Add the remaining arguments. 185 | for arg in args.iter() { 186 | result.push(arg.clone()); 187 | } 188 | 189 | result 190 | } 191 | 192 | struct WasmtimeHttpCtx { 193 | pub wasi: WasiCtx, 194 | pub http: HttpCtx, 195 | } 196 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | use std::process::{self, Command}; 2 | 3 | const TESTS_DIR: &str = "tests"; 4 | const RUST_EXAMPLE: &str = "rust"; 5 | const AS_EXAMPLE: &str = "as"; 6 | 7 | const RUST_GUEST_RAW: &str = "crates/wasi-experimental-http/src/raw.rs"; 8 | const AS_GUEST_RAW: &str = "crates/as/raw.ts"; 9 | const MD_GUEST_API: &str = "witx/readme.md"; 10 | 11 | const WITX_CODEGEN_VERSION: &str = "0.11.0"; 12 | 13 | fn main() { 14 | println!("cargo:rerun-if-changed=build.rs"); 15 | println!("cargo:rerun-if-changed=tests/rust/src/lib.rs"); 16 | println!("cargo:rerun-if-changed=crates/wasi-experimental-http/src/lib.rs"); 17 | println!("cargo:rerun-if-changed=tests/as/index.ts"); 18 | println!("cargo:rerun-if-changed=crates/as/index.ts"); 19 | println!("cargo:rerun-if-changed=witx/wasi_experimental_http.witx"); 20 | 21 | generate_from_witx("rust", RUST_GUEST_RAW); 22 | generate_from_witx("assemblyscript", AS_GUEST_RAW); 23 | generate_from_witx("markdown", MD_GUEST_API); 24 | 25 | cargo_build_example(TESTS_DIR, RUST_EXAMPLE); 26 | as_build_example(TESTS_DIR, AS_EXAMPLE); 27 | } 28 | 29 | fn cargo_build_example(dir: &str, example: &str) { 30 | let dir = format!("{}/{}", dir, example); 31 | 32 | run( 33 | vec!["cargo", "build", "--target", "wasm32-wasi", "--release"], 34 | Some(dir), 35 | ); 36 | } 37 | 38 | fn as_build_example(dir: &str, example: &str) { 39 | let dir = format!("{}/{}", dir, example); 40 | 41 | run(vec!["npm", "install"], Some(dir.clone())); 42 | run(vec!["npm", "run", "asbuild"], Some(dir)); 43 | } 44 | 45 | fn check_witx_codegen() { 46 | match process::Command::new("witx-codegen").spawn() { 47 | Ok(_) => { 48 | eprintln!("witx-codegen already installed"); 49 | } 50 | Err(_) => { 51 | println!("cannot find witx-codegen, attempting to install"); 52 | run( 53 | vec![ 54 | "cargo", 55 | "install", 56 | "witx-codegen", 57 | "--version", 58 | WITX_CODEGEN_VERSION, 59 | ], 60 | None, 61 | ); 62 | } 63 | } 64 | } 65 | 66 | fn generate_from_witx(codegen_type: &str, output: &str) { 67 | check_witx_codegen(); 68 | 69 | run( 70 | vec![ 71 | "witx-codegen", 72 | "--output-type", 73 | codegen_type, 74 | "--output", 75 | output, 76 | "witx/wasi_experimental_http.witx", 77 | ], 78 | None, 79 | ); 80 | } 81 | 82 | fn run + AsRef>(args: Vec, dir: Option) { 83 | let mut cmd = Command::new(get_os_process()); 84 | cmd.stdout(process::Stdio::piped()); 85 | cmd.stderr(process::Stdio::piped()); 86 | 87 | if let Some(dir) = dir { 88 | cmd.current_dir(dir); 89 | }; 90 | 91 | cmd.arg("-c"); 92 | cmd.arg( 93 | args.into_iter() 94 | .map(Into::into) 95 | .collect::>() 96 | .join(" "), 97 | ); 98 | 99 | println!("running {:#?}", cmd); 100 | 101 | cmd.output().unwrap(); 102 | } 103 | 104 | fn get_os_process() -> String { 105 | if cfg!(target_os = "windows") { 106 | String::from("powershell.exe") 107 | } else { 108 | String::from("/bin/bash") 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /crates/as/index.ts: -------------------------------------------------------------------------------- 1 | // required to use --abort=as-wasi 2 | // @ts-ignore 3 | import { Console } from "as-wasi"; 4 | import * as raw from "./raw"; 5 | 6 | /** Send an HTTP request and return an HTTP response. 7 | * 8 | * It is recommended to use the `RequestBuilder` helper class. 9 | */ 10 | export function request(req: Request): Response { 11 | return raw_request( 12 | req.url, 13 | methodEnumToString(req.method), 14 | headersToString(req.headers), 15 | req.body 16 | ); 17 | } 18 | 19 | /** An HTTP response. */ 20 | export class Response { 21 | /** The HTTP response status code. */ 22 | public status: StatusCode; 23 | 24 | /** The response handle */ 25 | handle: raw.ResponseHandle; 26 | 27 | constructor(status: u16, handle: u32) { 28 | this.status = status; 29 | this.handle = handle; 30 | } 31 | 32 | /** Read a part of the response body in a supplied buffer */ 33 | public bodyRead(buffer: ArrayBuffer): usize { 34 | let buf_read_ptr = memory.data(8); 35 | if ( 36 | raw.bodyRead( 37 | this.handle, 38 | changetype(buffer), 39 | buffer.byteLength, 40 | buf_read_ptr 41 | ) != 0 42 | ) { 43 | return 0; 44 | } 45 | return load(buf_read_ptr); 46 | } 47 | 48 | /** Read the entire response body */ 49 | public bodyReadAll(): Uint8Array { 50 | let chunk = new Uint8Array(4096); 51 | let buf = new Array(); 52 | while (true) { 53 | let count = this.bodyRead(chunk.buffer); 54 | if (count <= 0) { 55 | return changetype(buf); 56 | } 57 | for (let i: u32 = 0; i < (count as u32); i++) { 58 | buf.push(chunk[i]); 59 | } 60 | } 61 | } 62 | 63 | /** Get a single header value given its key */ 64 | public headerGet(name: string): string { 65 | let name_buf = String.UTF8.encode(name); 66 | let name_ptr = changetype(name_buf); 67 | let name_len = name_buf.byteLength; 68 | 69 | let value_buf = new Uint8Array(4096); 70 | let value_buf_ptr = changetype(value_buf.buffer); 71 | let value_buf_len = value_buf.byteLength; 72 | let value_len_ptr = memory.data(8); 73 | 74 | if ( 75 | raw.headerGet( 76 | this.handle, 77 | name_ptr, 78 | name_len, 79 | value_buf_ptr, 80 | value_buf_len, 81 | value_len_ptr 82 | ) != 0 83 | ) { 84 | return ""; 85 | } 86 | 87 | let value = value_buf.subarray(0, load(value_len_ptr)); 88 | return String.UTF8.decode(value.buffer); 89 | } 90 | 91 | /** Read all response headers into a header map */ 92 | public headerGetAll(): Map { 93 | let headers_buf = new Uint8Array(4 * 1024); 94 | let headers_buf_ptr = changetype(headers_buf.buffer); 95 | let headers_len_ptr = memory.data(8); 96 | 97 | if ( 98 | raw.headersGetAll( 99 | this.handle, 100 | headers_buf_ptr, 101 | headers_buf.byteLength, 102 | headers_len_ptr 103 | ) != 0 104 | ) { 105 | return new Map(); 106 | } 107 | 108 | let headers = String.UTF8.decode( 109 | headers_buf.subarray(0, load(headers_len_ptr)).buffer 110 | ); 111 | return stringToHeaderMap(headers); 112 | } 113 | 114 | public close(): void { 115 | raw.close(this.handle); 116 | } 117 | } 118 | 119 | /** An HTTP request. 120 | * 121 | * It is recommended to use the a `RequestBuilder` 122 | * to create and send HTTP requests. 123 | */ 124 | export class Request { 125 | /** The URL of the request. */ 126 | public url: string; 127 | /** The HTTP method of the request. */ 128 | public method: Method; 129 | /** The request headers. */ 130 | public headers: Map; 131 | /** The request body as bytes. */ 132 | public body: ArrayBuffer; 133 | 134 | constructor( 135 | url: string, 136 | method: Method = Method.GET, 137 | headers: Map = new Map(), 138 | body: ArrayBuffer = new ArrayBuffer(0) 139 | ) { 140 | this.url = url; 141 | this.method = method; 142 | this.headers = headers; 143 | this.body = body; 144 | } 145 | } 146 | 147 | export class RequestBuilder { 148 | private request: Request; 149 | 150 | constructor(url: string) { 151 | this.request = new Request(url); 152 | } 153 | 154 | /** Set the request's HTTP method. */ 155 | public method(m: Method): RequestBuilder { 156 | this.request.method = m; 157 | return this; 158 | } 159 | 160 | /** Add a new pair of header key and header value to the request. */ 161 | public header(key: string, value: string): RequestBuilder { 162 | this.request.headers.set(key, value); 163 | return this; 164 | } 165 | 166 | /** Set the request's body. */ 167 | public body(b: ArrayBuffer): RequestBuilder { 168 | this.request.body = b; 169 | return this; 170 | } 171 | 172 | /** Send the request and return an HTTP response. */ 173 | public send(): Response { 174 | return request(this.request); 175 | } 176 | } 177 | 178 | function raw_request( 179 | url: string, 180 | method: string, 181 | headers: string, 182 | body: ArrayBuffer 183 | ): Response { 184 | let url_buf = String.UTF8.encode(url); 185 | let url_ptr = changetype(url_buf); 186 | let url_len = url_buf.byteLength; 187 | 188 | let method_buf = String.UTF8.encode(method); 189 | let method_ptr = changetype(method_buf); 190 | let method_len = method_buf.byteLength; 191 | 192 | let req_headers_buf = String.UTF8.encode(headers); 193 | let req_headers_ptr = changetype(req_headers_buf); 194 | let req_headers_len = req_headers_buf.byteLength; 195 | 196 | let req_body_ptr = changetype(body); 197 | let req_body_len = body.byteLength; 198 | 199 | let status_code_ptr = memory.data(8); 200 | let res_handle_ptr = memory.data(8); 201 | 202 | let err = raw.req( 203 | url_ptr, 204 | url_len, 205 | method_ptr, 206 | method_len, 207 | req_headers_ptr, 208 | req_headers_len, 209 | req_body_ptr, 210 | req_body_len, 211 | status_code_ptr, 212 | res_handle_ptr 213 | ); 214 | 215 | if (err != 0) { 216 | // Based on the error code, read and log the error. 217 | Console.log("ERROR CODE: " + err.toString()); 218 | Console.log("ERROR MESSAGE: " + errorToHumanReadableMessage(err)); 219 | abort(); 220 | } 221 | 222 | let status = load(status_code_ptr) as u16; 223 | let handle = load(res_handle_ptr) as u32; 224 | 225 | return new Response(status, handle); 226 | } 227 | 228 | /** Transform the header map into a string. */ 229 | function headersToString(headers: Map): string { 230 | let res = ""; 231 | let keys = headers.keys() as string[]; 232 | let values = headers.values() as string[]; 233 | for (let index = 0, len = keys.length; index < len; ++index) { 234 | res += keys[index] + ":" + values[index] + "\n"; 235 | } 236 | return res; 237 | } 238 | 239 | /** Transform the string representation of the headers into a map. */ 240 | function stringToHeaderMap(headersStr: string): Map { 241 | let res = new Map(); 242 | let parts = headersStr.split("\n"); 243 | // the result of the split contains an empty part as well 244 | for (let index = 0, len = parts.length - 1; index < len; index++) { 245 | let p = parts[index].split(":"); 246 | res.set(p[0], p[1]); 247 | } 248 | 249 | return res; 250 | } 251 | 252 | /** The standard HTTP methods. */ 253 | export enum Method { 254 | GET, 255 | HEAD, 256 | POST, 257 | PUT, 258 | DELETE, 259 | CONNECT, 260 | OPTIONS, 261 | TRACE, 262 | PATCH, 263 | } 264 | 265 | /** Return the string representation of the HTTP method. */ 266 | function methodEnumToString(m: Method): string { 267 | switch (m) { 268 | case Method.GET: 269 | return "GET"; 270 | case Method.HEAD: 271 | return "HEAD"; 272 | case Method.POST: 273 | return "POST"; 274 | case Method.PUT: 275 | return "PUT"; 276 | case Method.DELETE: 277 | return "DELET"; 278 | case Method.CONNECT: 279 | return "CONNECT"; 280 | case Method.OPTIONS: 281 | return "OPTIONS"; 282 | case Method.TRACE: 283 | return "TRACE"; 284 | case Method.PATCH: 285 | return "PATCH"; 286 | 287 | default: 288 | return ""; 289 | } 290 | } 291 | 292 | function errorToHumanReadableMessage(e: u32): string { 293 | switch (e) { 294 | case 1: 295 | return "Invalid WASI HTTP handle."; 296 | case 2: 297 | return "Memory not found."; 298 | case 3: 299 | return "Memory access error."; 300 | case 4: 301 | return "Buffer too small"; 302 | case 5: 303 | return "Header not found."; 304 | case 6: 305 | return "UTF-8 error."; 306 | case 7: 307 | return "Destination URL not allowed."; 308 | case 8: 309 | return "Invalid HTTP method."; 310 | case 9: 311 | return "Invalid encoding."; 312 | case 10: 313 | return "Invalid URL."; 314 | case 11: 315 | return "Unable to send HTTP request."; 316 | case 12: 317 | return "Runtime error."; 318 | case 13: 319 | return "Too many sessions."; 320 | 321 | default: 322 | return "Unknown error."; 323 | } 324 | } 325 | 326 | /** The standard HTTP status codes. */ 327 | export enum StatusCode { 328 | CONTINUE = 100, 329 | SWITCHING_PROTOCOL = 101, 330 | PROCESSING = 102, 331 | EARLY_HINTS = 103, 332 | 333 | OK = 200, 334 | CREATED = 201, 335 | ACCEPTED = 202, 336 | NON_AUTHORITATIVE_INFORMATION = 203, 337 | NO_CONTENT = 204, 338 | RESET_CONTENT = 205, 339 | PARTIAL_CONTENT = 206, 340 | MULTI_STATUS = 207, 341 | ALREADY_REPORTED = 208, 342 | IM_USED = 226, 343 | 344 | MULTIPLE_CHOICE = 300, 345 | MOVED_PERMANENTLY = 301, 346 | FOUND = 302, 347 | SEE_OTHER = 303, 348 | NOT_MODIFIED = 304, 349 | USE_PROXY = 305, 350 | UNUSED = 306, 351 | TEMPORARY_REDIRECT = 307, 352 | PERMANENT_REDIRECT = 308, 353 | 354 | BAD_REQUEST = 400, 355 | UNAUTHORIZED = 401, 356 | PAYMENT_REQUIRED = 402, 357 | FORBIDDEN = 403, 358 | NOT_FOUND = 404, 359 | METHOD_NOT_ALLOWED = 405, 360 | NOT_ACCEPTABLE = 406, 361 | PROXY_AUTHENTICATION_REQUIRED = 407, 362 | REQUEST_TIMEOUT = 408, 363 | CONFLICT = 409, 364 | GONE = 410, 365 | LENGTH_REQUIRED = 411, 366 | PRECONDITION_FAILED = 412, 367 | PAYLOAD_TOO_LARGE = 413, 368 | URI_TOO_LONG = 414, 369 | UNSUPPORTED_MEDIA_TYPE = 415, 370 | RANGE_NOT_SATISFIABLE = 416, 371 | EXPECTATION_FAILED = 417, 372 | IM_A_TEAPOT = 418, 373 | MISDIRECTED_REQUEST = 421, 374 | UNPROCESSABLE_ENTITY = 422, 375 | LOCKED = 423, 376 | FAILED_DEPENDENCY = 424, 377 | TOO_EARLY = 425, 378 | UPGRADE_REQUIRED = 426, 379 | PRECONDITION_REQURIED = 428, 380 | TOO_MANY_REQUESTS = 429, 381 | REQUEST_HEADER_FIELDS_TOO_LARGE = 431, 382 | UNAVAILABLE_FOR_LEGAL_REASONS = 451, 383 | 384 | INTERNAL_SERVER_ERROR = 500, 385 | NOT_IMPLELENTED = 501, 386 | BAD_GATEWAY = 502, 387 | SERVICE_UNAVAILABLE = 503, 388 | GATEWAY_TIMEOUT = 504, 389 | HTTP_VERSION_NOT_SUPPORTED = 505, 390 | VARIANT_ALSO_NEGOTIATES = 506, 391 | INSUFFICIENT_STORAGE = 507, 392 | LOOP_DETECTED = 508, 393 | NOT_EXTENDED = 510, 394 | NETWORK_AUTHENTICATION_REQUIRED = 511, 395 | } 396 | -------------------------------------------------------------------------------- /crates/as/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@deislabs/wasi-experimental-http", 3 | "main": "index.ts", 4 | "ascMain": "index.ts", 5 | "types": "index.ts", 6 | "version": "0.10.0", 7 | "description": "Experimental HTTP library for AssemblyScript", 8 | "author": { 9 | "name": "The DeisLabs team at Microsoft" 10 | }, 11 | "homepage": "https://github.com/deislabs/wasi-experimental-http", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/deislabs/wasi-experimental-http" 15 | }, 16 | "bugs": { 17 | "url": "https://github.com/deislabs/wasi-experimental-http/issues" 18 | }, 19 | "license": "MIT", 20 | "scripts": { 21 | "asbuild": "asc index.ts -b build/optimized.wasm --use abort=wasi_abort --debug" 22 | }, 23 | "dependencies": { 24 | "as-wasi": "0.4.4", 25 | "assemblyscript": "^0.18.16" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /crates/as/raw.ts: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | * This file was automatically generated by witx-codegen - Do not edit manually. 4 | */ 5 | 6 | export type WasiHandle = i32; 7 | export type Char8 = u8; 8 | export type Char32 = u32; 9 | export type WasiPtr = usize; 10 | export type WasiMutPtr = usize; 11 | export type WasiStringBytesPtr = WasiPtr; 12 | 13 | @unmanaged 14 | export class WasiString { 15 | ptr: WasiStringBytesPtr; 16 | length: usize; 17 | 18 | constructor(str: string) { 19 | let wasiString = String.UTF8.encode(str, false); 20 | // @ts-ignore: cast 21 | this.ptr = changetype(wasiString); 22 | this.length = wasiString.byteLength; 23 | } 24 | 25 | toString(): string { 26 | let tmp = new ArrayBuffer(this.length as u32); 27 | memory.copy(changetype(tmp), this.ptr, this.length); 28 | return String.UTF8.decode(tmp); 29 | } 30 | } 31 | 32 | @unmanaged 33 | export class WasiSlice { 34 | ptr: WasiPtr; 35 | length: usize; 36 | 37 | constructor(array: ArrayBufferView) { 38 | // @ts-ignore: cast 39 | this.ptr = array.dataStart; 40 | this.length = array.byteLength; 41 | } 42 | } 43 | 44 | @unmanaged 45 | export class WasiMutSlice { 46 | ptr: WasiMutPtr; 47 | length: usize; 48 | 49 | constructor(array: ArrayBufferView) { 50 | // @ts-ignore: cast 51 | this.ptr = array.dataStart; 52 | this.length = array.byteLength; 53 | } 54 | } 55 | 56 | /** 57 | * ---------------------- Module: [wasi_experimental_http] ---------------------- 58 | */ 59 | 60 | export type HttpError = u32; 61 | 62 | export namespace HttpError { 63 | export const SUCCESS: HttpError = 0; 64 | export const INVALID_HANDLE: HttpError = 1; 65 | export const MEMORY_NOT_FOUND: HttpError = 2; 66 | export const MEMORY_ACCESS_ERROR: HttpError = 3; 67 | export const BUFFER_TOO_SMALL: HttpError = 4; 68 | export const HEADER_NOT_FOUND: HttpError = 5; 69 | export const UTF_8_ERROR: HttpError = 6; 70 | export const DESTINATION_NOT_ALLOWED: HttpError = 7; 71 | export const INVALID_METHOD: HttpError = 8; 72 | export const INVALID_ENCODING: HttpError = 9; 73 | export const INVALID_URL: HttpError = 10; 74 | export const REQUEST_ERROR: HttpError = 11; 75 | export const RUNTIME_ERROR: HttpError = 12; 76 | export const TOO_MANY_SESSIONS: HttpError = 13; 77 | } 78 | 79 | /** 80 | * HTTP status code 81 | */ 82 | export type StatusCode = u16; 83 | 84 | /** 85 | * An HTTP body being sent 86 | */ 87 | export type OutgoingBody = WasiSlice; 88 | 89 | /** 90 | * Buffer for an HTTP body being received 91 | */ 92 | export type IncomingBody = WasiMutSlice; 93 | 94 | /** 95 | * A response handle 96 | */ 97 | export type ResponseHandle = WasiHandle; 98 | 99 | /** 100 | * Buffer to store a header value 101 | */ 102 | export type HeaderValueBuf = WasiMutSlice; 103 | 104 | /** 105 | * Number of bytes having been written 106 | */ 107 | export type WrittenBytes = usize; 108 | 109 | /** 110 | * Send a request 111 | */ 112 | // @ts-ignore: decorator 113 | @external("wasi_experimental_http", "req") 114 | export declare function req( 115 | url_ptr: WasiPtr, 116 | url_len: usize, 117 | method_ptr: WasiPtr, 118 | method_len: usize, 119 | headers_ptr: WasiPtr, 120 | headers_len: usize, 121 | body_ptr: WasiPtr, 122 | body_len: usize, 123 | result_0_ptr: WasiMutPtr, 124 | result_1_ptr: WasiMutPtr 125 | ): HttpError; 126 | 127 | /** 128 | * Close a request handle 129 | */ 130 | // @ts-ignore: decorator 131 | @external("wasi_experimental_http", "close") 132 | export declare function close( 133 | response_handle: ResponseHandle 134 | ): HttpError; 135 | 136 | /** 137 | * Get the value associated with a header 138 | */ 139 | // @ts-ignore: decorator 140 | @external("wasi_experimental_http", "header_get") 141 | export declare function headerGet( 142 | response_handle: ResponseHandle, 143 | header_name_ptr: WasiPtr, 144 | header_name_len: usize, 145 | header_value_buf_ptr: WasiMutPtr, 146 | header_value_buf_len: usize, 147 | result_ptr: WasiMutPtr 148 | ): HttpError; 149 | 150 | /** 151 | * Get the entire response header map 152 | */ 153 | // @ts-ignore: decorator 154 | @external("wasi_experimental_http", "headers_get_all") 155 | export declare function headersGetAll( 156 | response_handle: ResponseHandle, 157 | header_value_buf_ptr: WasiMutPtr, 158 | header_value_buf_len: usize, 159 | result_ptr: WasiMutPtr 160 | ): HttpError; 161 | 162 | /** 163 | * Fill a buffer with the streamed content of a response body 164 | */ 165 | // @ts-ignore: decorator 166 | @external("wasi_experimental_http", "body_read") 167 | export declare function bodyRead( 168 | response_handle: ResponseHandle, 169 | body_buf_ptr: WasiMutPtr, 170 | body_buf_len: usize, 171 | result_ptr: WasiMutPtr 172 | ): HttpError; 173 | 174 | -------------------------------------------------------------------------------- /crates/as/readme.md: -------------------------------------------------------------------------------- 1 | # `@deislabs/wasi-experimental-http` 2 | 3 | [![npm version](https://badge.fury.io/js/%40deislabs%2Fwasi-experimental-http.svg)](https://badge.fury.io/js/%40deislabs%2Fwasi-experimental-http) 4 | 5 | Experimental HTTP client library for AssemblyScript. 6 | 7 | ### Using this library 8 | 9 | First, install the package to your project: 10 | 11 | ```bash 12 | $ npm install @deislabs/wasi-experimental-http --save 13 | ``` 14 | 15 | Then, import the package and create a request using the `RequestBuilder`: 16 | 17 | ```typescript 18 | // @ts-ignore 19 | import { Console } from "as-wasi"; 20 | import { 21 | Method, 22 | RequestBuilder, 23 | Response, 24 | } from "@deislabs/wasi-experimental-http"; 25 | 26 | export function post(): void { 27 | let body = String.UTF8.encode("testing the body"); 28 | let res = new RequestBuilder("https://postman-echo.com/post") 29 | .header("Content-Type", "text/plain") 30 | .method(Method.POST) 31 | .body(body) 32 | .send(); 33 | 34 | print(res); 35 | } 36 | 37 | function print(res: Response): void { 38 | Console.log(res.status.toString()); 39 | Console.log(res.getHeader("Content-Type")); 40 | let result = String.UTF8.decode(res.bodyReadAll().buffer); 41 | Console.log(result); 42 | } 43 | ``` 44 | 45 | After building a WebAssembly module using the AssemblyScript compiler, the 46 | module can be executed in a Wasmtime runtime that has the experimental HTTP 47 | functionality enabled (the crate to configure it can be found in this repo): 48 | 49 | ``` 50 | { 51 | "content-length": "374", 52 | "connection": "keep-alive", 53 | "set-cookie": "sails.Path=/; HttpOnly", 54 | "vary": "Accept-Encoding", 55 | "content-type": "application/json; charset=utf-8", 56 | "date": "Fri, 26 Feb 2021 18:31:03 GMT", 57 | "etag": "W/\"176-Ky4OTmr3Xbcl3yNah8w2XIQapGU\"", 58 | } 59 | {"args":{},"data":"Testing with a request body. Does this actually work?","files":{},"form":{},"headers":{"x-forwarded-proto":"https","x-forwarded-port":"443","host":"postman-echo.com","x-amzn-trace-id":"Root=1-60393e67-02d1c8033bcf4f1e74a4523e","content-length":"53","content-type":"text/plain","abc":"def","accept":"*/*"},"json":null,"url":"https://postman-echo.com/post"} 60 | "200 OK" 61 | ``` 62 | -------------------------------------------------------------------------------- /crates/as/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/assemblyscript/std/assembly.json", 3 | "include": [ 4 | "./**/*.ts" 5 | ] 6 | } -------------------------------------------------------------------------------- /crates/wasi-experimental-http-wasmtime/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "wasi-experimental-http-wasmtime" 3 | version = "0.10.0" 4 | authors = [ "Radu Matei " ] 5 | edition = "2021" 6 | repository = "https://github.com/deislabs/wasi-experimental-http" 7 | license = "MIT" 8 | description = "Experimental HTTP library for WebAssembly in Wasmtime" 9 | readme = "readme.md" 10 | 11 | [dependencies] 12 | anyhow = "1.0" 13 | bytes = "1" 14 | futures = "0.3" 15 | http = "0.2" 16 | reqwest = { version = "0.11", default-features = true, features = [ 17 | "json", 18 | "blocking", 19 | ] } 20 | thiserror = "1.0" 21 | tokio = { version = "1.4.0", features = [ "full" ] } 22 | tracing = { version = "0.1", features = [ "log" ] } 23 | url = "2.2.1" 24 | wasmtime = "0.35" 25 | wasmtime-wasi = "0.35" 26 | wasi-common = "0.35" 27 | -------------------------------------------------------------------------------- /crates/wasi-experimental-http-wasmtime/readme.md: -------------------------------------------------------------------------------- 1 | # `wasi-experimental-http-wasmtime` 2 | 3 | ![Crates.io](https://img.shields.io/crates/v/wasi-experimental-http-wasmtime) 4 | 5 | Experimental HTTP library for WebAssembly in Wasmtime 6 | 7 | ### Adding support to a Wasmtime runtime 8 | 9 | The easiest way to add support is by using the 10 | [Wasmtime linker](https://docs.rs/wasmtime/0.26.0/wasmtime/struct.Linker.html): 11 | 12 | ```rust 13 | let store = Store::default(); 14 | let mut linker = Linker::new(&store); 15 | let wasi = Wasi::new(&store, ctx); 16 | 17 | // link the WASI core functions 18 | wasi.add_to_linker(&mut linker)?; 19 | 20 | // link the experimental HTTP support 21 | let allowed_hosts = Some(vec!["https://postman-echo.com".to_string()]); 22 | let max_concurrent_requests = Some(42); 23 | 24 | let http = HttpCtx::new(allowed_domains, max_concurrent_requests)?; 25 | http.add_to_linker(&mut linker)?; 26 | ``` 27 | 28 | The Wasmtime implementation also enables allowed domains - an optional and 29 | configurable list of domains or hosts that guest modules are allowed to send 30 | requests to. If `None` or an empty vector is passed, guest modules are **NOT** 31 | allowed to make HTTP requests to any server. (Note that the hosts passed MUST 32 | have the protocol also specified - i.e. `https://my-domain.com`, or 33 | `http://192.168.0.1`, and if making requests to a subdomain, the subdomain MUST 34 | be in the allowed list. See the the library tests for more examples). 35 | -------------------------------------------------------------------------------- /crates/wasi-experimental-http-wasmtime/src/lib.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Error; 2 | use bytes::Bytes; 3 | use futures::executor::block_on; 4 | use http::{header::HeaderName, HeaderMap, HeaderValue}; 5 | use reqwest::{Client, Method}; 6 | use std::{ 7 | collections::HashMap, 8 | str::FromStr, 9 | sync::{Arc, PoisonError, RwLock}, 10 | }; 11 | use tokio::runtime::Handle; 12 | use url::Url; 13 | use wasmtime::*; 14 | 15 | const MEMORY: &str = "memory"; 16 | const ALLOW_ALL_HOSTS: &str = "insecure:allow-all"; 17 | 18 | pub type WasiHttpHandle = u32; 19 | 20 | /// Response body for HTTP requests, consumed by guest modules. 21 | struct Body { 22 | bytes: Bytes, 23 | pos: usize, 24 | } 25 | 26 | /// An HTTP response abstraction that is persisted across multiple 27 | /// host calls. 28 | struct Response { 29 | headers: HeaderMap, 30 | body: Body, 31 | } 32 | 33 | /// Host state for the responses of the instance. 34 | #[derive(Default)] 35 | struct State { 36 | responses: HashMap, 37 | current_handle: WasiHttpHandle, 38 | } 39 | 40 | #[derive(Debug, thiserror::Error)] 41 | enum HttpError { 42 | #[error("Invalid handle: [{0}]")] 43 | InvalidHandle(WasiHttpHandle), 44 | #[error("Memory not found")] 45 | MemoryNotFound, 46 | #[error("Memory access error")] 47 | MemoryAccessError(#[from] wasmtime::MemoryAccessError), 48 | #[error("Buffer too small")] 49 | BufferTooSmall, 50 | #[error("Header not found")] 51 | HeaderNotFound, 52 | #[error("UTF-8 error")] 53 | Utf8Error(#[from] std::str::Utf8Error), 54 | #[error("Destination not allowed")] 55 | DestinationNotAllowed(String), 56 | #[error("Invalid method")] 57 | InvalidMethod, 58 | #[error("Invalid encoding")] 59 | InvalidEncoding, 60 | #[error("Invalid URL")] 61 | InvalidUrl, 62 | #[error("HTTP error")] 63 | RequestError(#[from] reqwest::Error), 64 | #[error("Runtime error")] 65 | RuntimeError, 66 | #[error("Too many sessions")] 67 | TooManySessions, 68 | } 69 | 70 | impl From for u32 { 71 | fn from(e: HttpError) -> u32 { 72 | match e { 73 | HttpError::InvalidHandle(_) => 1, 74 | HttpError::MemoryNotFound => 2, 75 | HttpError::MemoryAccessError(_) => 3, 76 | HttpError::BufferTooSmall => 4, 77 | HttpError::HeaderNotFound => 5, 78 | HttpError::Utf8Error(_) => 6, 79 | HttpError::DestinationNotAllowed(_) => 7, 80 | HttpError::InvalidMethod => 8, 81 | HttpError::InvalidEncoding => 9, 82 | HttpError::InvalidUrl => 10, 83 | HttpError::RequestError(_) => 11, 84 | HttpError::RuntimeError => 12, 85 | HttpError::TooManySessions => 13, 86 | } 87 | } 88 | } 89 | 90 | impl From>> for HttpError { 91 | fn from(_: PoisonError>) -> Self { 92 | HttpError::RuntimeError 93 | } 94 | } 95 | 96 | impl From>> for HttpError { 97 | fn from(_: PoisonError>) -> Self { 98 | HttpError::RuntimeError 99 | } 100 | } 101 | 102 | impl From> for HttpError { 103 | fn from(_: PoisonError<&mut State>) -> Self { 104 | HttpError::RuntimeError 105 | } 106 | } 107 | 108 | struct HostCalls; 109 | 110 | impl HostCalls { 111 | /// Remove the current handle from the state. 112 | /// Depending on the implementation, guest modules might 113 | /// have to manually call `close`. 114 | // TODO (@radu-matei) 115 | // Fix the clippy warning. 116 | #[allow(clippy::unnecessary_wraps)] 117 | fn close(st: Arc>, handle: WasiHttpHandle) -> Result<(), HttpError> { 118 | let mut st = st.write()?; 119 | st.responses.remove(&handle); 120 | Ok(()) 121 | } 122 | 123 | /// Read `buf_len` bytes from the response of `handle` and 124 | /// write them into `buf_ptr`. 125 | fn body_read( 126 | st: Arc>, 127 | memory: Memory, 128 | mut store: impl AsContextMut, 129 | handle: WasiHttpHandle, 130 | buf_ptr: u32, 131 | buf_len: u32, 132 | buf_read_ptr: u32, 133 | ) -> Result<(), HttpError> { 134 | let mut st = st.write()?; 135 | 136 | let mut body = &mut st.responses.get_mut(&handle).unwrap().body; 137 | let mut context = store.as_context_mut(); 138 | 139 | // Write at most either the remaining of the response body, or the entire 140 | // length requested by the guest. 141 | let available = std::cmp::min(buf_len as _, body.bytes.len() - body.pos); 142 | memory.write( 143 | &mut context, 144 | buf_ptr as _, 145 | &body.bytes[body.pos..body.pos + available], 146 | )?; 147 | body.pos += available; 148 | // Write the number of bytes written back to the guest. 149 | memory.write( 150 | &mut context, 151 | buf_read_ptr as _, 152 | &(available as u32).to_le_bytes(), 153 | )?; 154 | Ok(()) 155 | } 156 | 157 | /// Get a response header value given a key. 158 | #[allow(clippy::too_many_arguments)] 159 | fn header_get( 160 | st: Arc>, 161 | memory: Memory, 162 | mut store: impl AsContextMut, 163 | handle: WasiHttpHandle, 164 | name_ptr: u32, 165 | name_len: u32, 166 | value_ptr: u32, 167 | value_len: u32, 168 | value_written_ptr: u32, 169 | ) -> Result<(), HttpError> { 170 | let st = st.read()?; 171 | 172 | // Get the current response headers. 173 | let headers = &st 174 | .responses 175 | .get(&handle) 176 | .ok_or(HttpError::InvalidHandle(handle))? 177 | .headers; 178 | 179 | let mut store = store.as_context_mut(); 180 | 181 | // Read the header key from the module's memory. 182 | let key = string_from_memory(&memory, &mut store, name_ptr, name_len)?.to_ascii_lowercase(); 183 | // Attempt to get the corresponding value from the resposne headers. 184 | let value = headers.get(key).ok_or(HttpError::HeaderNotFound)?; 185 | if value.len() > value_len as _ { 186 | return Err(HttpError::BufferTooSmall); 187 | } 188 | // Write the header value and its length. 189 | memory.write(&mut store, value_ptr as _, value.as_bytes())?; 190 | memory.write( 191 | &mut store, 192 | value_written_ptr as _, 193 | &(value.len() as u32).to_le_bytes(), 194 | )?; 195 | Ok(()) 196 | } 197 | 198 | fn headers_get_all( 199 | st: Arc>, 200 | memory: Memory, 201 | mut store: impl AsContextMut, 202 | handle: WasiHttpHandle, 203 | buf_ptr: u32, 204 | buf_len: u32, 205 | buf_written_ptr: u32, 206 | ) -> Result<(), HttpError> { 207 | let st = st.read()?; 208 | 209 | let headers = &st 210 | .responses 211 | .get(&handle) 212 | .ok_or(HttpError::InvalidHandle(handle))? 213 | .headers; 214 | 215 | let headers = match header_map_to_string(headers) { 216 | Ok(res) => res, 217 | Err(_) => return Err(HttpError::RuntimeError), 218 | }; 219 | 220 | if headers.len() > buf_len as _ { 221 | return Err(HttpError::BufferTooSmall); 222 | } 223 | 224 | let mut store = store.as_context_mut(); 225 | 226 | memory.write(&mut store, buf_ptr as _, headers.as_bytes())?; 227 | memory.write( 228 | &mut store, 229 | buf_written_ptr as _, 230 | &(headers.len() as u32).to_le_bytes(), 231 | )?; 232 | Ok(()) 233 | } 234 | 235 | /// Execute a request for a guest module, given 236 | /// the request data. 237 | #[allow(clippy::too_many_arguments)] 238 | fn req( 239 | st: Arc>, 240 | allowed_hosts: Option<&[String]>, 241 | max_concurrent_requests: Option, 242 | memory: Memory, 243 | mut store: impl AsContextMut, 244 | url_ptr: u32, 245 | url_len: u32, 246 | method_ptr: u32, 247 | method_len: u32, 248 | req_headers_ptr: u32, 249 | req_headers_len: u32, 250 | req_body_ptr: u32, 251 | req_body_len: u32, 252 | status_code_ptr: u32, 253 | res_handle_ptr: u32, 254 | ) -> Result<(), HttpError> { 255 | let span = tracing::trace_span!("req"); 256 | let _enter = span.enter(); 257 | 258 | let mut st = st.write()?; 259 | if let Some(max) = max_concurrent_requests { 260 | if st.responses.len() > (max - 1) as usize { 261 | return Err(HttpError::TooManySessions); 262 | } 263 | }; 264 | 265 | let mut store = store.as_context_mut(); 266 | 267 | // Read the request parts from the module's linear memory and check early if 268 | // the guest is allowed to make a request to the given URL. 269 | let url = string_from_memory(&memory, &mut store, url_ptr, url_len)?; 270 | if !is_allowed(url.as_str(), allowed_hosts)? { 271 | return Err(HttpError::DestinationNotAllowed(url)); 272 | } 273 | 274 | let method = Method::from_str( 275 | string_from_memory(&memory, &mut store, method_ptr, method_len)?.as_str(), 276 | ) 277 | .map_err(|_| HttpError::InvalidMethod)?; 278 | let req_body = slice_from_memory(&memory, &mut store, req_body_ptr, req_body_len)?; 279 | let headers = string_to_header_map( 280 | string_from_memory(&memory, &mut store, req_headers_ptr, req_headers_len)?.as_str(), 281 | ) 282 | .map_err(|_| HttpError::InvalidEncoding)?; 283 | 284 | // Send the request. 285 | let (status, resp_headers, resp_body) = 286 | request(url.as_str(), headers, method, req_body.as_slice())?; 287 | tracing::debug!( 288 | status, 289 | ?resp_headers, 290 | body_len = resp_body.as_ref().len(), 291 | "got HTTP response, writing back to memory" 292 | ); 293 | 294 | // Write the status code to the guest. 295 | memory.write(&mut store, status_code_ptr as _, &status.to_le_bytes())?; 296 | 297 | // Construct the response, add it to the current state, and write 298 | // the handle to the guest. 299 | let response = Response { 300 | headers: resp_headers, 301 | body: Body { 302 | bytes: resp_body, 303 | pos: 0, 304 | }, 305 | }; 306 | 307 | let initial_handle = st.current_handle; 308 | while st.responses.get(&st.current_handle).is_some() { 309 | st.current_handle += 1; 310 | if st.current_handle == initial_handle { 311 | return Err(HttpError::TooManySessions); 312 | } 313 | } 314 | let handle = st.current_handle; 315 | st.responses.insert(handle, response); 316 | memory.write(&mut store, res_handle_ptr as _, &handle.to_le_bytes())?; 317 | 318 | Ok(()) 319 | } 320 | } 321 | 322 | /// Per-instance context data used to control whether the guest 323 | /// is allowed to make an outbound HTTP request. 324 | #[derive(Clone)] 325 | pub struct HttpCtx { 326 | pub allowed_hosts: Option>, 327 | pub max_concurrent_requests: Option, 328 | } 329 | 330 | /// Experimental HTTP extension object for Wasmtime. 331 | pub struct HttpState { 332 | state: Arc>, 333 | } 334 | 335 | impl HttpState { 336 | /// Module the HTTP extension is going to be defined as. 337 | pub const MODULE: &'static str = "wasi_experimental_http"; 338 | 339 | /// Create a new HTTP extension object. 340 | /// `allowed_hosts` may be `None` (no outbound connections allowed) 341 | /// or a list of allowed host names. 342 | pub fn new() -> Result { 343 | let state = Arc::new(RwLock::new(State::default())); 344 | Ok(HttpState { state }) 345 | } 346 | 347 | pub fn add_to_linker( 348 | &self, 349 | linker: &mut Linker, 350 | get_cx: impl Fn(&T) -> HttpCtx + Send + Sync + 'static, 351 | ) -> Result<(), Error> { 352 | let st = self.state.clone(); 353 | linker.func_wrap( 354 | Self::MODULE, 355 | "close", 356 | move |handle: WasiHttpHandle| -> u32 { 357 | match HostCalls::close(st.clone(), handle) { 358 | Ok(()) => 0, 359 | Err(e) => e.into(), 360 | } 361 | }, 362 | )?; 363 | 364 | let st = self.state.clone(); 365 | linker.func_wrap( 366 | Self::MODULE, 367 | "body_read", 368 | move |mut caller: Caller<'_, T>, 369 | handle: WasiHttpHandle, 370 | buf_ptr: u32, 371 | buf_len: u32, 372 | buf_read_ptr: u32| 373 | -> u32 { 374 | let memory = match memory_get(&mut caller) { 375 | Ok(m) => m, 376 | Err(e) => return e.into(), 377 | }; 378 | 379 | let ctx = caller.as_context_mut(); 380 | 381 | match HostCalls::body_read( 382 | st.clone(), 383 | memory, 384 | ctx, 385 | handle, 386 | buf_ptr, 387 | buf_len, 388 | buf_read_ptr, 389 | ) { 390 | Ok(()) => 0, 391 | Err(e) => e.into(), 392 | } 393 | }, 394 | )?; 395 | 396 | let st = self.state.clone(); 397 | linker.func_wrap( 398 | Self::MODULE, 399 | "header_get", 400 | move |mut caller: Caller<'_, T>, 401 | handle: WasiHttpHandle, 402 | name_ptr: u32, 403 | name_len: u32, 404 | value_ptr: u32, 405 | value_len: u32, 406 | value_written_ptr: u32| 407 | -> u32 { 408 | let memory = match memory_get(&mut caller) { 409 | Ok(m) => m, 410 | Err(e) => return e.into(), 411 | }; 412 | 413 | let ctx = caller.as_context_mut(); 414 | 415 | match HostCalls::header_get( 416 | st.clone(), 417 | memory, 418 | ctx, 419 | handle, 420 | name_ptr, 421 | name_len, 422 | value_ptr, 423 | value_len, 424 | value_written_ptr, 425 | ) { 426 | Ok(()) => 0, 427 | Err(e) => e.into(), 428 | } 429 | }, 430 | )?; 431 | 432 | let st = self.state.clone(); 433 | linker.func_wrap( 434 | Self::MODULE, 435 | "headers_get_all", 436 | move |mut caller: Caller<'_, T>, 437 | handle: WasiHttpHandle, 438 | buf_ptr: u32, 439 | buf_len: u32, 440 | buf_read_ptr: u32| 441 | -> u32 { 442 | let memory = match memory_get(&mut caller) { 443 | Ok(m) => m, 444 | Err(e) => return e.into(), 445 | }; 446 | 447 | let ctx = caller.as_context_mut(); 448 | 449 | match HostCalls::headers_get_all( 450 | st.clone(), 451 | memory, 452 | ctx, 453 | handle, 454 | buf_ptr, 455 | buf_len, 456 | buf_read_ptr, 457 | ) { 458 | Ok(()) => 0, 459 | Err(e) => e.into(), 460 | } 461 | }, 462 | )?; 463 | 464 | let st = self.state.clone(); 465 | linker.func_wrap( 466 | Self::MODULE, 467 | "req", 468 | move |mut caller: Caller<'_, T>, 469 | url_ptr: u32, 470 | url_len: u32, 471 | method_ptr: u32, 472 | method_len: u32, 473 | req_headers_ptr: u32, 474 | req_headers_len: u32, 475 | req_body_ptr: u32, 476 | req_body_len: u32, 477 | status_code_ptr: u32, 478 | res_handle_ptr: u32| 479 | -> u32 { 480 | let memory = match memory_get(&mut caller) { 481 | Ok(m) => m, 482 | Err(e) => return e.into(), 483 | }; 484 | 485 | let ctx = caller.as_context_mut(); 486 | let http_ctx = get_cx(ctx.data()); 487 | 488 | match HostCalls::req( 489 | st.clone(), 490 | http_ctx.allowed_hosts.as_deref(), 491 | http_ctx.max_concurrent_requests, 492 | memory, 493 | ctx, 494 | url_ptr, 495 | url_len, 496 | method_ptr, 497 | method_len, 498 | req_headers_ptr, 499 | req_headers_len, 500 | req_body_ptr, 501 | req_body_len, 502 | status_code_ptr, 503 | res_handle_ptr, 504 | ) { 505 | Ok(()) => 0, 506 | Err(e) => e.into(), 507 | } 508 | }, 509 | )?; 510 | 511 | Ok(()) 512 | } 513 | } 514 | 515 | #[tracing::instrument] 516 | fn request( 517 | url: &str, 518 | headers: HeaderMap, 519 | method: Method, 520 | body: &[u8], 521 | ) -> Result<(u16, HeaderMap, Bytes), HttpError> { 522 | tracing::debug!( 523 | %url, 524 | ?headers, 525 | ?method, 526 | body_len = body.len(), 527 | "performing request" 528 | ); 529 | let url: Url = url.parse().map_err(|_| HttpError::InvalidUrl)?; 530 | let body = body.to_vec(); 531 | match Handle::try_current() { 532 | Ok(r) => { 533 | // If running in a Tokio runtime, spawn a new blocking executor 534 | // that will send the HTTP request, and block on its execution. 535 | // This attempts to avoid any deadlocks from other operations 536 | // already executing on the same executor (compared with just 537 | // blocking on the current one). 538 | // 539 | // This should only be a temporary workaround, until we take 540 | // advantage of async functions in Wasmtime. 541 | tracing::trace!("tokio runtime available, spawning request on tokio thread"); 542 | block_on(r.spawn_blocking(move || { 543 | let client = Client::builder().build().unwrap(); 544 | let res = block_on( 545 | client 546 | .request(method, url) 547 | .headers(headers) 548 | .body(body) 549 | .send(), 550 | )?; 551 | Ok(( 552 | res.status().as_u16(), 553 | res.headers().clone(), 554 | block_on(res.bytes())?, 555 | )) 556 | })) 557 | .map_err(|_| HttpError::RuntimeError)? 558 | } 559 | Err(_) => { 560 | tracing::trace!("no tokio runtime available, using blocking request"); 561 | let res = reqwest::blocking::Client::new() 562 | .request(method, url) 563 | .headers(headers) 564 | .body(body) 565 | .send()?; 566 | return Ok((res.status().as_u16(), res.headers().clone(), res.bytes()?)); 567 | } 568 | } 569 | } 570 | 571 | /// Get the exported memory block called `memory`. 572 | /// This will return an `HttpError::MemoryNotFound` if the module does 573 | /// not export a memory block. 574 | fn memory_get(caller: &mut Caller<'_, T>) -> Result { 575 | if let Some(Extern::Memory(mem)) = caller.get_export(MEMORY) { 576 | Ok(mem) 577 | } else { 578 | Err(HttpError::MemoryNotFound) 579 | } 580 | } 581 | 582 | /// Get a slice of length `len` from `memory`, starting at `offset`. 583 | /// This will return an `HttpError::BufferTooSmall` if the size of the 584 | /// requested slice is larger than the memory size. 585 | fn slice_from_memory( 586 | memory: &Memory, 587 | mut ctx: impl AsContextMut, 588 | offset: u32, 589 | len: u32, 590 | ) -> Result, HttpError> { 591 | let required_memory_size = offset.checked_add(len).ok_or(HttpError::BufferTooSmall)? as usize; 592 | 593 | if required_memory_size > memory.data_size(&mut ctx) { 594 | return Err(HttpError::BufferTooSmall); 595 | } 596 | 597 | let mut buf = vec![0u8; len as usize]; 598 | memory.read(&mut ctx, offset as usize, buf.as_mut_slice())?; 599 | Ok(buf) 600 | } 601 | 602 | /// Read a string of byte length `len` from `memory`, starting at `offset`. 603 | fn string_from_memory( 604 | memory: &Memory, 605 | ctx: impl AsContextMut, 606 | offset: u32, 607 | len: u32, 608 | ) -> Result { 609 | let slice = slice_from_memory(memory, ctx, offset, len)?; 610 | Ok(std::str::from_utf8(&slice)?.to_string()) 611 | } 612 | 613 | /// Check if guest module is allowed to send request to URL, based on the list of 614 | /// allowed hosts defined by the runtime. 615 | /// If `None` is passed, the guest module is not allowed to send the request. 616 | fn is_allowed(url: &str, allowed_hosts: Option<&[String]>) -> Result { 617 | let url_host = Url::parse(url) 618 | .map_err(|_| HttpError::InvalidUrl)? 619 | .host_str() 620 | .ok_or(HttpError::InvalidUrl)? 621 | .to_owned(); 622 | match allowed_hosts { 623 | Some(domains) => { 624 | // check domains has any "insecure:allow-all" wildcard 625 | if domains.iter().any(|domain| domain == ALLOW_ALL_HOSTS) { 626 | Ok(true) 627 | } else { 628 | let allowed: Result, _> = domains.iter().map(|d| Url::parse(d)).collect(); 629 | let allowed = allowed.map_err(|_| HttpError::InvalidUrl)?; 630 | 631 | Ok(allowed 632 | .iter() 633 | .map(|u| u.host_str().unwrap()) 634 | .any(|x| x == url_host.as_str())) 635 | } 636 | } 637 | None => Ok(false), 638 | } 639 | } 640 | 641 | // The following two functions are copied from the `wasi_experimental_http` 642 | // crate, because the Windows linker apparently cannot handle unresolved 643 | // symbols from a crate, even when the caller does not actually use any of the 644 | // external symbols. 645 | // 646 | // https://github.com/rust-lang/rust/issues/86125 647 | 648 | /// Decode a header map from a string. 649 | fn string_to_header_map(s: &str) -> Result { 650 | let mut headers = HeaderMap::new(); 651 | for entry in s.lines() { 652 | let mut parts = entry.splitn(2, ':'); 653 | #[allow(clippy::or_fun_call)] 654 | let k = parts.next().ok_or(anyhow::format_err!( 655 | "Invalid serialized header: [{}]", 656 | entry 657 | ))?; 658 | let v = parts.next().unwrap(); 659 | headers.insert(HeaderName::from_str(k)?, HeaderValue::from_str(v)?); 660 | } 661 | Ok(headers) 662 | } 663 | 664 | /// Encode a header map as a string. 665 | fn header_map_to_string(hm: &HeaderMap) -> Result { 666 | let mut res = String::new(); 667 | for (name, value) in hm 668 | .iter() 669 | .map(|(name, value)| (name.as_str(), std::str::from_utf8(value.as_bytes()))) 670 | { 671 | let value = value?; 672 | anyhow::ensure!( 673 | !name 674 | .chars() 675 | .any(|x| x.is_control() || "(),/:;<=>?@[\\]{}".contains(x)), 676 | "Invalid header name" 677 | ); 678 | anyhow::ensure!( 679 | !value.chars().any(|x| x.is_control()), 680 | "Invalid header value" 681 | ); 682 | res.push_str(&format!("{}:{}\n", name, value)); 683 | } 684 | Ok(res) 685 | } 686 | 687 | #[test] 688 | #[allow(clippy::bool_assert_comparison)] 689 | fn test_allowed_domains() { 690 | let allowed_domains = vec![ 691 | "https://api.brigade.sh".to_string(), 692 | "https://example.com".to_string(), 693 | "http://192.168.0.1".to_string(), 694 | ]; 695 | 696 | assert_eq!( 697 | true, 698 | is_allowed( 699 | "https://api.brigade.sh/healthz", 700 | Some(allowed_domains.as_ref()) 701 | ) 702 | .unwrap() 703 | ); 704 | assert_eq!( 705 | true, 706 | is_allowed( 707 | "https://example.com/some/path/with/more/paths", 708 | Some(allowed_domains.as_ref()) 709 | ) 710 | .unwrap() 711 | ); 712 | assert_eq!( 713 | true, 714 | is_allowed("http://192.168.0.1/login", Some(allowed_domains.as_ref())).unwrap() 715 | ); 716 | assert_eq!( 717 | false, 718 | is_allowed("https://test.brigade.sh", Some(allowed_domains.as_ref())).unwrap() 719 | ); 720 | } 721 | 722 | #[test] 723 | #[allow(clippy::bool_assert_comparison)] 724 | fn test_allowed_domains_with_wildcard() { 725 | let allowed_domains = vec![ 726 | "https://example.com".to_string(), 727 | ALLOW_ALL_HOSTS.to_string(), 728 | "http://192.168.0.1".to_string(), 729 | ]; 730 | 731 | assert_eq!( 732 | true, 733 | is_allowed( 734 | "https://api.brigade.sh/healthz", 735 | Some(allowed_domains.as_ref()) 736 | ) 737 | .unwrap() 738 | ); 739 | assert_eq!( 740 | true, 741 | is_allowed( 742 | "https://example.com/some/path/with/more/paths", 743 | Some(allowed_domains.as_ref()) 744 | ) 745 | .unwrap() 746 | ); 747 | assert_eq!( 748 | true, 749 | is_allowed("http://192.168.0.1/login", Some(allowed_domains.as_ref())).unwrap() 750 | ); 751 | assert_eq!( 752 | true, 753 | is_allowed("https://test.brigade.sh", Some(allowed_domains.as_ref())).unwrap() 754 | ); 755 | } 756 | 757 | #[test] 758 | #[should_panic] 759 | #[allow(clippy::bool_assert_comparison)] 760 | fn test_url_parsing() { 761 | let allowed_domains = vec![ALLOW_ALL_HOSTS.to_string()]; 762 | 763 | is_allowed("not even a url", Some(allowed_domains.as_ref())).unwrap(); 764 | } 765 | -------------------------------------------------------------------------------- /crates/wasi-experimental-http/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "wasi-experimental-http" 3 | version = "0.10.0" 4 | authors = [ "Radu Matei " ] 5 | edition = "2018" 6 | repository = "https://github.com/deislabs/wasi-experimental-http" 7 | license = "MIT" 8 | description = "Experimental HTTP library for WebAssembly" 9 | readme = "readme.md" 10 | 11 | [dependencies] 12 | anyhow = "1.0" 13 | bytes = "1" 14 | http = "0.2" 15 | thiserror = "1.0" 16 | tracing = { version = "0.1", features = [ "log" ] } 17 | -------------------------------------------------------------------------------- /crates/wasi-experimental-http/readme.md: -------------------------------------------------------------------------------- 1 | # `wasi-experimental-http` 2 | 3 | ![Crates.io](https://img.shields.io/crates/v/wasi-experimental-http) 4 | 5 | Experimental HTTP library for WebAssembly 6 | 7 | ### Using the crate 8 | 9 | First, add this crate to your project. Then, it can be used to create and send 10 | an HTTP request to a server: 11 | 12 | ```rust 13 | use bytes::Bytes; 14 | use http; 15 | use wasi_experimental_http; 16 | 17 | #[no_mangle] 18 | pub extern "C" fn _start() { 19 | let url = "https://postman-echo.com/post".to_string(); 20 | let req = http::request::Builder::new() 21 | .method(http::Method::POST) 22 | .uri(&url) 23 | .header("Content-Type", "text/plain") 24 | .header("abc", "def"); 25 | let b = Bytes::from("Testing with a request body. Does this actually work?"); 26 | let req = req.body(Some(b)).unwrap(); 27 | 28 | let res = wasi_experimental_http::request(req).expect("cannot make request"); 29 | let str = std::str::from_utf8(&res.body_read_all()).unwrap().to_string(); 30 | println!("{:#?}", res.header_get("Content-Type")); 31 | println!("{}", str); 32 | println!("{:#?}", res.status_code); 33 | } 34 | ``` 35 | 36 | Build the module using the `wasm32-wasi` target, then execute in a Wasmtime 37 | runtime that has the experimental HTTP functionality enabled (the crate to 38 | configure it can be found in this repo): 39 | 40 | ``` 41 | { 42 | "content-length": "374", 43 | "connection": "keep-alive", 44 | "set-cookie": "sails.Path=/; HttpOnly", 45 | "vary": "Accept-Encoding", 46 | "content-type": "application/json; charset=utf-8", 47 | "date": "Fri, 26 Feb 2021 18:31:03 GMT", 48 | "etag": "W/\"176-Ky4OTmr3Xbcl3yNah8w2XIQapGU\"", 49 | } 50 | {"args":{},"data":"Testing with a request body. Does this actually work?","files":{},"form":{},"headers":{"x-forwarded-proto":"https","x-forwarded-port":"443","host":"postman-echo.com","x-amzn-trace-id":"Root=1-60393e67-02d1c8033bcf4f1e74a4523e","content-length":"53","content-type":"text/plain","abc":"def","accept":"*/*"},"json":null,"url":"https://postman-echo.com/post"} 51 | "200 OK" 52 | ``` 53 | -------------------------------------------------------------------------------- /crates/wasi-experimental-http/src/lib.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Error}; 2 | use bytes::Bytes; 3 | use http::{self, header::HeaderName, HeaderMap, HeaderValue, Request, StatusCode}; 4 | use std::{ 5 | convert::{TryFrom, TryInto}, 6 | str::FromStr, 7 | }; 8 | 9 | #[allow(dead_code)] 10 | #[allow(clippy::mut_from_ref)] 11 | #[allow(clippy::too_many_arguments)] 12 | pub(crate) mod raw; 13 | 14 | /// HTTP errors 15 | #[derive(Debug, thiserror::Error)] 16 | pub enum HttpError { 17 | #[error("Invalid handle")] 18 | InvalidHandle, 19 | #[error("Memory not found")] 20 | MemoryNotFound, 21 | #[error("Memory access error")] 22 | MemoryAccessError, 23 | #[error("Buffer too small")] 24 | BufferTooSmall, 25 | #[error("Header not found")] 26 | HeaderNotFound, 27 | #[error("UTF-8 error")] 28 | Utf8Error, 29 | #[error("Destination not allowed")] 30 | DestinationNotAllowed, 31 | #[error("Invalid method")] 32 | InvalidMethod, 33 | #[error("Invalid encoding")] 34 | InvalidEncoding, 35 | #[error("Invalid URL")] 36 | InvalidUrl, 37 | #[error("HTTP error")] 38 | RequestError, 39 | #[error("Runtime error")] 40 | RuntimeError, 41 | #[error("Too many sessions")] 42 | TooManySessions, 43 | #[error("Unknown WASI error")] 44 | UnknownError, 45 | } 46 | 47 | // TODO(@radu-matei) 48 | // 49 | // This error is not really used in the public API. 50 | impl From for HttpError { 51 | fn from(e: raw::Error) -> Self { 52 | match e { 53 | raw::Error::WasiError(errno) => match errno { 54 | 1 => HttpError::InvalidHandle, 55 | 2 => HttpError::MemoryNotFound, 56 | 3 => HttpError::MemoryAccessError, 57 | 4 => HttpError::BufferTooSmall, 58 | 5 => HttpError::HeaderNotFound, 59 | 6 => HttpError::Utf8Error, 60 | 7 => HttpError::DestinationNotAllowed, 61 | 8 => HttpError::InvalidMethod, 62 | 9 => HttpError::InvalidEncoding, 63 | 10 => HttpError::InvalidUrl, 64 | 11 => HttpError::RequestError, 65 | 12 => HttpError::RuntimeError, 66 | 13 => HttpError::TooManySessions, 67 | 68 | _ => HttpError::UnknownError, 69 | }, 70 | } 71 | } 72 | } 73 | 74 | /// An HTTP response 75 | pub struct Response { 76 | handle: raw::ResponseHandle, 77 | pub status_code: StatusCode, 78 | } 79 | 80 | /// Automatically call `close` to remove the current handle 81 | /// when the response object goes out of scope. 82 | impl Drop for Response { 83 | fn drop(&mut self) { 84 | raw::close(self.handle).unwrap(); 85 | } 86 | } 87 | 88 | impl Response { 89 | /// Read a response body in a streaming fashion. 90 | /// `buf` is an arbitrary large buffer, that may be partially filled after each call. 91 | /// The function returns the actual number of bytes that were written, and `0` 92 | /// when the end of the stream has been reached. 93 | pub fn body_read(&mut self, buf: &mut [u8]) -> Result { 94 | let read = raw::body_read(self.handle, buf.as_mut_ptr(), buf.len())?; 95 | Ok(read) 96 | } 97 | 98 | /// Read the entire body until the end of the stream. 99 | pub fn body_read_all(&mut self) -> Result, Error> { 100 | // TODO(@radu-matei) 101 | // 102 | // Do we want to have configurable chunk sizes? 103 | let mut chunk = [0u8; 4096]; 104 | let mut v = vec![]; 105 | loop { 106 | let read = self.body_read(&mut chunk)?; 107 | if read == 0 { 108 | return Ok(v); 109 | } 110 | v.extend_from_slice(&chunk[0..read]); 111 | } 112 | } 113 | 114 | /// Get the value of the `name` header. 115 | /// Returns `HttpError::HeaderNotFound` if no such header was found. 116 | pub fn header_get(&self, name: String) -> Result { 117 | let name = name; 118 | 119 | // Set the initial capacity of the expected header value to 4 kilobytes. 120 | // If the response value size is larger, double the capacity and 121 | // attempt to read again, but only until reaching 64 kilobytes. 122 | // 123 | // This is to avoid a potentially malicious web server from returning a 124 | // response header that would make the guest allocate all of its possible 125 | // memory. 126 | // The maximum is set to 64 kilobytes, as it is usually the maximum value 127 | // known servers will allow until returning 413 Entity Too Large. 128 | let mut capacity = 4 * 1024; 129 | let max_capacity: usize = 64 * 1024; 130 | 131 | loop { 132 | let mut buf = vec![0u8; capacity]; 133 | match raw::header_get( 134 | self.handle, 135 | name.as_ptr(), 136 | name.len(), 137 | buf.as_mut_ptr(), 138 | buf.len(), 139 | ) { 140 | Ok(written) => { 141 | buf.truncate(written); 142 | return Ok(String::from_utf8(buf)?); 143 | } 144 | Err(e) => match Into::::into(e) { 145 | HttpError::BufferTooSmall => { 146 | if capacity < max_capacity { 147 | capacity *= 2; 148 | continue; 149 | } else { 150 | return Err(e.into()); 151 | } 152 | } 153 | _ => return Err(e.into()), 154 | }, 155 | }; 156 | } 157 | } 158 | 159 | /// Get the entire response header map for a given request. 160 | // If clients know the specific header key, they should use 161 | // `header_get` to avoid allocating memory for the entire 162 | // header map. 163 | pub fn headers_get_all(&self) -> Result { 164 | // The fixed capacity for the header map is 64 kilobytes. 165 | // If a server sends a header map that is larger than this, 166 | // the client will return an error. 167 | // The same note applies - most known servers will limit 168 | // response headers to 64 kilobytes at most before returning 169 | // 413 Entity Too Large. 170 | // 171 | // It might make sense to increase the size here in the same 172 | // way it is done in `header_get`, if there are valid use 173 | // cases where it is required. 174 | let capacity = 64 * 1024; 175 | let mut buf = vec![0u8; capacity]; 176 | 177 | match raw::headers_get_all(self.handle, buf.as_mut_ptr(), buf.len()) { 178 | Ok(written) => { 179 | buf.truncate(written); 180 | let str = String::from_utf8(buf)?; 181 | Ok(string_to_header_map(&str)?) 182 | } 183 | Err(e) => Err(e.into()), 184 | } 185 | } 186 | } 187 | 188 | /// Send an HTTP request. 189 | /// The function returns a `Response` object, that includes the status, 190 | /// as well as methods to access the headers and the body. 191 | #[tracing::instrument] 192 | pub fn request(req: Request>) -> Result { 193 | let url = req.uri().to_string(); 194 | tracing::debug!(%url, headers = ?req.headers(), "performing http request using wasmtime function"); 195 | 196 | let headers = header_map_to_string(req.headers())?; 197 | let method = req.method().as_str().to_string(); 198 | let body = match req.body() { 199 | None => Default::default(), 200 | Some(body) => body.as_ref(), 201 | }; 202 | let (status_code, handle) = raw::req( 203 | url.as_ptr(), 204 | url.len(), 205 | method.as_ptr(), 206 | method.len(), 207 | headers.as_ptr(), 208 | headers.len(), 209 | body.as_ptr(), 210 | body.len(), 211 | )?; 212 | Ok(Response { 213 | handle, 214 | status_code: StatusCode::from_u16(status_code)?, 215 | }) 216 | } 217 | 218 | /// Send an HTTP request and get a fully formed HTTP response. 219 | pub fn send_request( 220 | req: http::Request>, 221 | ) -> Result>, Error> { 222 | request(req)?.try_into() 223 | } 224 | 225 | impl TryFrom for http::Response> { 226 | type Error = anyhow::Error; 227 | 228 | fn try_from(outbound_res: Response) -> Result { 229 | let mut outbound_res = outbound_res; 230 | let status = outbound_res.status_code.as_u16(); 231 | let headers = outbound_res.headers_get_all()?; 232 | let body = Some(Bytes::from(outbound_res.body_read_all()?)); 233 | 234 | let mut res = http::Response::builder().status(status); 235 | append_response_headers(&mut res, &headers)?; 236 | Ok(res.body(body)?) 237 | } 238 | } 239 | 240 | fn append_response_headers( 241 | http_res: &mut http::response::Builder, 242 | hm: &HeaderMap, 243 | ) -> Result<(), Error> { 244 | let headers = http_res 245 | .headers_mut() 246 | .context("error building the response headers")?; 247 | 248 | for (k, v) in hm { 249 | headers.insert(k, v.clone()); 250 | } 251 | 252 | Ok(()) 253 | } 254 | 255 | /// Encode a header map as a string. 256 | pub fn header_map_to_string(hm: &HeaderMap) -> Result { 257 | let mut res = String::new(); 258 | for (name, value) in hm 259 | .iter() 260 | .map(|(name, value)| (name.as_str(), std::str::from_utf8(value.as_bytes()))) 261 | { 262 | let value = value?; 263 | anyhow::ensure!( 264 | !name 265 | .chars() 266 | .any(|x| x.is_control() || "(),/:;<=>?@[\\]{}".contains(x)), 267 | "Invalid header name" 268 | ); 269 | anyhow::ensure!( 270 | !value.chars().any(|x| x.is_control()), 271 | "Invalid header value" 272 | ); 273 | res.push_str(&format!("{}:{}\n", name, value)); 274 | } 275 | Ok(res) 276 | } 277 | 278 | /// Decode a header map from a string. 279 | pub fn string_to_header_map(s: &str) -> Result { 280 | let mut headers = HeaderMap::new(); 281 | for entry in s.lines() { 282 | let mut parts = entry.splitn(2, ':'); 283 | #[allow(clippy::or_fun_call)] 284 | let k = parts.next().ok_or(anyhow::format_err!( 285 | "Invalid serialized header: [{}]", 286 | entry 287 | ))?; 288 | let v = parts.next().unwrap(); 289 | headers.insert(HeaderName::from_str(k)?, HeaderValue::from_str(v)?); 290 | } 291 | Ok(headers) 292 | } 293 | 294 | #[cfg(test)] 295 | mod tests { 296 | use super::*; 297 | use http::{HeaderMap, HeaderValue}; 298 | 299 | #[test] 300 | fn test_header_map_to_string() { 301 | let mut hm = HeaderMap::new(); 302 | hm.insert("custom-header", HeaderValue::from_static("custom-value")); 303 | hm.insert("custom-header2", HeaderValue::from_static("custom-value2")); 304 | let str = header_map_to_string(&hm).unwrap(); 305 | assert_eq!( 306 | "custom-header:custom-value\ncustom-header2:custom-value2\n", 307 | str 308 | ); 309 | } 310 | } 311 | -------------------------------------------------------------------------------- /crates/wasi-experimental-http/src/raw.rs: -------------------------------------------------------------------------------- 1 | 2 | // 3 | // This file was automatically generated by witx-codegen - Do not edit manually. 4 | // 5 | 6 | #[derive(Debug, Copy, Clone, Eq, PartialEq)] 7 | pub enum Error { 8 | WasiError(i32), 9 | } 10 | impl std::error::Error for Error {} 11 | impl std::fmt::Display for Error { 12 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 13 | match self { 14 | Error::WasiError(e) => write!(f, "Wasi error {}", e), 15 | } 16 | } 17 | } 18 | 19 | pub type WasiHandle = i32; 20 | pub type Char8 = u8; 21 | pub type Char32 = u32; 22 | pub type WasiPtr = *const T; 23 | pub type WasiMutPtr = *mut T; 24 | pub type WasiStringBytesPtr = WasiPtr; 25 | 26 | #[repr(C)] 27 | #[derive(Copy, Clone, Debug)] 28 | pub struct WasiSlice { 29 | ptr: WasiPtr, 30 | len: usize, 31 | } 32 | 33 | #[repr(C)] 34 | #[derive(Copy, Clone, Debug)] 35 | pub struct WasiMutSlice { 36 | ptr: WasiMutPtr, 37 | len: usize, 38 | } 39 | 40 | impl WasiSlice { 41 | pub fn as_slice(&self) -> &[T] { 42 | unsafe { std::slice::from_raw_parts(self.ptr, self.len) } 43 | } 44 | 45 | pub fn from_slice(&self, slice: &[T]) -> Self { 46 | WasiSlice { 47 | ptr: slice.as_ptr() as _, 48 | len: slice.len(), 49 | } 50 | } 51 | } 52 | 53 | impl WasiMutSlice { 54 | pub fn as_slice(&self) -> &[T] { 55 | unsafe { std::slice::from_raw_parts(self.ptr, self.len) } 56 | } 57 | 58 | pub fn as_mut_slice(&self) -> &mut [T] { 59 | unsafe { std::slice::from_raw_parts_mut(self.ptr, self.len) } 60 | } 61 | 62 | pub fn from_slice(&self, slice: &[T]) -> Self { 63 | WasiMutSlice { 64 | ptr: slice.as_ptr() as _, 65 | len: slice.len(), 66 | } 67 | } 68 | 69 | pub fn from_mut_slice(&self, slice: &mut [T]) -> Self { 70 | WasiMutSlice { 71 | ptr: slice.as_mut_ptr(), 72 | len: slice.len(), 73 | } 74 | } 75 | } 76 | 77 | #[repr(C)] 78 | #[derive(Copy, Clone, Debug)] 79 | pub struct WasiString { 80 | ptr: WasiStringBytesPtr, 81 | len: usize, 82 | } 83 | 84 | impl> From for WasiString { 85 | fn from(s: T) -> Self { 86 | let s = s.as_ref(); 87 | WasiString { 88 | ptr: s.as_ptr() as _, 89 | len: s.len(), 90 | } 91 | } 92 | } 93 | 94 | impl WasiString { 95 | pub fn as_str(&self) -> Result<&str, std::str::Utf8Error> { 96 | std::str::from_utf8(unsafe { std::slice::from_raw_parts(self.ptr, self.len) }) 97 | } 98 | 99 | pub fn as_slice(&self) -> &[u8] { 100 | unsafe { std::slice::from_raw_parts(self.ptr, self.len) } 101 | } 102 | 103 | pub fn from_slice(&self, slice: &[u8]) -> Self { 104 | WasiString { 105 | ptr: slice.as_ptr() as _, 106 | len: slice.len(), 107 | } 108 | } 109 | } 110 | 111 | /// ---------------------- Module: [wasi_experimental_http] ---------------------- 112 | 113 | pub type HttpError = u32; 114 | 115 | #[allow(non_snake_case)] 116 | pub mod HTTP_ERROR { 117 | use super::HttpError; 118 | pub const SUCCESS: HttpError = 0; 119 | pub const INVALID_HANDLE: HttpError = 1; 120 | pub const MEMORY_NOT_FOUND: HttpError = 2; 121 | pub const MEMORY_ACCESS_ERROR: HttpError = 3; 122 | pub const BUFFER_TOO_SMALL: HttpError = 4; 123 | pub const HEADER_NOT_FOUND: HttpError = 5; 124 | pub const UTF_8_ERROR: HttpError = 6; 125 | pub const DESTINATION_NOT_ALLOWED: HttpError = 7; 126 | pub const INVALID_METHOD: HttpError = 8; 127 | pub const INVALID_ENCODING: HttpError = 9; 128 | pub const INVALID_URL: HttpError = 10; 129 | pub const REQUEST_ERROR: HttpError = 11; 130 | pub const RUNTIME_ERROR: HttpError = 12; 131 | pub const TOO_MANY_SESSIONS: HttpError = 13; 132 | } 133 | 134 | /// HTTP status code 135 | pub type StatusCode = u16; 136 | 137 | /// An HTTP body being sent 138 | pub type OutgoingBody = WasiSlice; 139 | 140 | /// Buffer for an HTTP body being received 141 | pub type IncomingBody = WasiMutSlice; 142 | 143 | /// A response handle 144 | pub type ResponseHandle = WasiHandle; 145 | 146 | /// Buffer to store a header value 147 | pub type HeaderValueBuf = WasiMutSlice; 148 | 149 | /// Number of bytes having been written 150 | pub type WrittenBytes = usize; 151 | 152 | /// Send a request 153 | pub fn req( 154 | url_ptr: WasiPtr, 155 | url_len: usize, 156 | method_ptr: WasiPtr, 157 | method_len: usize, 158 | headers_ptr: WasiPtr, 159 | headers_len: usize, 160 | body_ptr: WasiPtr, 161 | body_len: usize, 162 | ) -> Result<(StatusCode, ResponseHandle), Error> { 163 | #[link(wasm_import_module = "wasi_experimental_http")] 164 | extern "C" { 165 | fn req( 166 | url_ptr: WasiPtr, 167 | url_len: usize, 168 | method_ptr: WasiPtr, 169 | method_len: usize, 170 | headers_ptr: WasiPtr, 171 | headers_len: usize, 172 | body_ptr: WasiPtr, 173 | body_len: usize, 174 | result_0_ptr: WasiMutPtr, 175 | result_1_ptr: WasiMutPtr, 176 | ) -> HttpError; 177 | } 178 | let mut result_0_ptr = std::mem::MaybeUninit::uninit(); 179 | let mut result_1_ptr = std::mem::MaybeUninit::uninit(); 180 | let res = unsafe { req( 181 | url_ptr, 182 | url_len, 183 | method_ptr, 184 | method_len, 185 | headers_ptr, 186 | headers_len, 187 | body_ptr, 188 | body_len, 189 | result_0_ptr.as_mut_ptr(), 190 | result_1_ptr.as_mut_ptr(), 191 | )}; 192 | if res != 0 { 193 | return Err(Error::WasiError(res as _)); 194 | } 195 | Ok(unsafe { (result_0_ptr.assume_init(), result_1_ptr.assume_init()) }) 196 | } 197 | 198 | /// Close a request handle 199 | pub fn close( 200 | response_handle: ResponseHandle, 201 | ) -> Result<(), Error> { 202 | #[link(wasm_import_module = "wasi_experimental_http")] 203 | extern "C" { 204 | fn close( 205 | response_handle: ResponseHandle, 206 | ) -> HttpError; 207 | } 208 | let res = unsafe { close( 209 | response_handle, 210 | )}; 211 | if res != 0 { 212 | return Err(Error::WasiError(res as _)); 213 | } 214 | Ok(()) 215 | } 216 | 217 | /// Get the value associated with a header 218 | pub fn header_get( 219 | response_handle: ResponseHandle, 220 | header_name_ptr: WasiPtr, 221 | header_name_len: usize, 222 | header_value_buf_ptr: WasiMutPtr, 223 | header_value_buf_len: usize, 224 | ) -> Result { 225 | #[link(wasm_import_module = "wasi_experimental_http")] 226 | extern "C" { 227 | fn header_get( 228 | response_handle: ResponseHandle, 229 | header_name_ptr: WasiPtr, 230 | header_name_len: usize, 231 | header_value_buf_ptr: WasiMutPtr, 232 | header_value_buf_len: usize, 233 | result_ptr: WasiMutPtr, 234 | ) -> HttpError; 235 | } 236 | let mut result_ptr = std::mem::MaybeUninit::uninit(); 237 | let res = unsafe { header_get( 238 | response_handle, 239 | header_name_ptr, 240 | header_name_len, 241 | header_value_buf_ptr, 242 | header_value_buf_len, 243 | result_ptr.as_mut_ptr(), 244 | )}; 245 | if res != 0 { 246 | return Err(Error::WasiError(res as _)); 247 | } 248 | Ok(unsafe { result_ptr.assume_init() }) 249 | } 250 | 251 | /// Get the entire response header map 252 | pub fn headers_get_all( 253 | response_handle: ResponseHandle, 254 | header_value_buf_ptr: WasiMutPtr, 255 | header_value_buf_len: usize, 256 | ) -> Result { 257 | #[link(wasm_import_module = "wasi_experimental_http")] 258 | extern "C" { 259 | fn headers_get_all( 260 | response_handle: ResponseHandle, 261 | header_value_buf_ptr: WasiMutPtr, 262 | header_value_buf_len: usize, 263 | result_ptr: WasiMutPtr, 264 | ) -> HttpError; 265 | } 266 | let mut result_ptr = std::mem::MaybeUninit::uninit(); 267 | let res = unsafe { headers_get_all( 268 | response_handle, 269 | header_value_buf_ptr, 270 | header_value_buf_len, 271 | result_ptr.as_mut_ptr(), 272 | )}; 273 | if res != 0 { 274 | return Err(Error::WasiError(res as _)); 275 | } 276 | Ok(unsafe { result_ptr.assume_init() }) 277 | } 278 | 279 | /// Fill a buffer with the streamed content of a response body 280 | pub fn body_read( 281 | response_handle: ResponseHandle, 282 | body_buf_ptr: WasiMutPtr, 283 | body_buf_len: usize, 284 | ) -> Result { 285 | #[link(wasm_import_module = "wasi_experimental_http")] 286 | extern "C" { 287 | fn body_read( 288 | response_handle: ResponseHandle, 289 | body_buf_ptr: WasiMutPtr, 290 | body_buf_len: usize, 291 | result_ptr: WasiMutPtr, 292 | ) -> HttpError; 293 | } 294 | let mut result_ptr = std::mem::MaybeUninit::uninit(); 295 | let res = unsafe { body_read( 296 | response_handle, 297 | body_buf_ptr, 298 | body_buf_len, 299 | result_ptr.as_mut_ptr(), 300 | )}; 301 | if res != 0 { 302 | return Err(Error::WasiError(res as _)); 303 | } 304 | Ok(unsafe { result_ptr.assume_init() }) 305 | } 306 | 307 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # `wasi-experimental-http` 2 | 3 | This is an experiment intended to provide a _temporary_ workaround until the 4 | WASI networking API is stable, and is compatible with [Wasmtime v0.26][24] by 5 | using the `wasi_experiemental_http_wasmtime` crate. We expect that once [the 6 | WASI sockets proposal][sockets-wip] gets adopted and implemented in language 7 | toolchains, the need for this library will vanish. 8 | 9 | ### Writing a module that makes an HTTP request 10 | 11 | We use the `wasi-experimental-http` crate (from this repository) and the `http` 12 | crate to create an HTTP request from a WebAssembly module, make a host call to 13 | the runtime using the request, then get a response back: 14 | 15 | ```rust 16 | use bytes::Bytes; 17 | use http; 18 | use wasi_experimental_http; 19 | 20 | #[no_mangle] 21 | pub extern "C" fn _start() { 22 | let url = "https://postman-echo.com/post".to_string(); 23 | let req = http::request::Builder::new() 24 | .method(http::Method::POST) 25 | .uri(&url) 26 | .header("Content-Type", "text/plain") 27 | .header("abc", "def"); 28 | let b = Bytes::from("Testing with a request body. Does this actually work?"); 29 | let req = req.body(Some(b)).unwrap(); 30 | 31 | let res = wasi_experimental_http::request(req).expect("cannot make request"); 32 | let str = std::str::from_utf8(&res.body_read_all()).unwrap().to_string(); 33 | println!("{:#?}", res.header_get("Content-Type")); 34 | println!("{}", str); 35 | println!("{:#?}", res.status_code); 36 | } 37 | ``` 38 | 39 | Build the module using the `wasm32-wasi` target, then follow the next section to 40 | update a Wasmtime runtime with the experimental HTTP support. 41 | 42 | ### Adding support to a Wasmtime runtime 43 | 44 | The easiest way to add support is by using the 45 | [Wasmtime linker](https://docs.rs/wasmtime/0.26.0/wasmtime/struct.Linker.html): 46 | 47 | ```rust 48 | let store = Store::default(); 49 | let mut linker = Linker::new(&store); 50 | let wasi = Wasi::new(&store, ctx); 51 | 52 | // link the WASI core functions 53 | wasi.add_to_linker(&mut linker)?; 54 | 55 | // link the experimental HTTP support 56 | let allowed_hosts = Some(vec!["https://postman-echo.com".to_string()]); 57 | let max_concurrent_requests = Some(42); 58 | 59 | let http = HttpCtx::new(allowed_domains, max_concurrent_requests)?; 60 | http.add_to_linker(&mut linker)?; 61 | ``` 62 | 63 | Then, executing the module above will send the HTTP request and write the 64 | response: 65 | 66 | ``` 67 | { 68 | "content-length": "374", 69 | "connection": "keep-alive", 70 | "set-cookie": "sails.Path=/; HttpOnly", 71 | "vary": "Accept-Encoding", 72 | "content-type": "application/json; charset=utf-8", 73 | "date": "Fri, 26 Feb 2021 18:31:03 GMT", 74 | "etag": "W/\"176-Ky4OTmr3Xbcl3yNah8w2XIQapGU\"", 75 | } 76 | {"args":{},"data":"Testing with a request body. Does this actually work?","files":{},"form":{},"headers":{"x-forwarded-proto":"https","x-forwarded-port":"443","host":"postman-echo.com","x-amzn-trace-id":"Root=1-60393e67-02d1c8033bcf4f1e74a4523e","content-length":"53","content-type":"text/plain","abc":"def","accept":"*/*"},"json":null,"url":"https://postman-echo.com/post"} 77 | "200 OK" 78 | ``` 79 | 80 | The Wasmtime implementation also enables allowed hosts - an optional and 81 | configurable list of domains or hosts that guest modules are allowed to send 82 | requests to. If `None` or an empty vector is passed, guest modules are **NOT** 83 | allowed to make HTTP requests to any server. (Note that the hosts passed MUST 84 | have the protocol also specified - i.e. `https://my-domain.com`, or 85 | `http://192.168.0.1`, and if making requests to a subdomain, the subdomain MUST 86 | be in the allowed list. See the the library tests for more examples). 87 | 88 | Note that the Wasmtime version currently supported is 89 | [0.26](https://docs.rs/wasmtime/0.26.0/wasmtime/). 90 | 91 | ### Sending HTTP requests from AssemblyScript 92 | 93 | This repository also contains an AssemblyScript implementation for sending HTTP 94 | requests: 95 | 96 | ```typescript 97 | // @ts-ignore 98 | import * as wasi from "as-wasi"; 99 | import { 100 | Method, 101 | RequestBuilder, 102 | Response, 103 | } from "@deislabs/wasi-experimental-http"; 104 | 105 | export function _start_(): void { 106 | let body = String.UTF8.encode("testing the body"); 107 | let res = new RequestBuilder("https://postman-echo.com/post") 108 | .header("Content-Type", "text/plain") 109 | .method(Method.POST) 110 | .body(body) 111 | .send(); 112 | wasi.Console.log(res.status.toString()); 113 | wasi.Console.log(res.headersGetAll.toString()); 114 | wasi.Console.log(String.UTF8.decode(res.bodyReadAll().buffer)); 115 | } 116 | ``` 117 | 118 | ### Testing using the `wasmtime-http` binary 119 | 120 | This project also adds a convenience binary for testing modules with HTTP 121 | support, `wasmtime-http` - a simple program that mimics the `wasmtime run` 122 | command, but also adds support for sending HTTP requests. 123 | 124 | ```` 125 | ➜ cargo run --bin wasmtime-http -- --help 126 | wasmtime-http 0.1.0 127 | 128 | USAGE: 129 | wasmtime-http [OPTIONS] [--] [ARGS]... 130 | 131 | FLAGS: 132 | -h, --help Prints help information 133 | -V, --version Prints version information 134 | 135 | OPTIONS: 136 | -a, --allowed-host ... Host the guest module is allowed to make outbound HTTP requests to 137 | -i, --invoke The name of the function to run [default: _start] 138 | -c, --concurrency The maximum number of concurrent requests a module can make to allowed 139 | hosts 140 | -e, --env ... Pass an environment variable to the program 141 | 142 | ARGS: 143 | The path of the WebAssembly module to run 144 | ... The arguments to pass to the module``` 145 | ```` 146 | 147 | ### Known limitations 148 | 149 | - there is no support for streaming HTTP responses, which this means guest 150 | modules have to wait until the entire body has been written by the runtime 151 | before reading it. 152 | - request and response bodies are [`Bytes`](https://docs.rs/bytes/1.0.1/bytes/). 153 | - the current WITX definitions are experimental, and currently only used to 154 | generate guest bindings. 155 | - this library does not aim to add support for running HTTP servers in 156 | WebAssembly. 157 | 158 | ### Code of Conduct 159 | 160 | This project has adopted the 161 | [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 162 | 163 | For more information see the 164 | [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 165 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any 166 | additional questions or comments. 167 | 168 | [24]: https://github.com/bytecodealliance/wasmtime/releases/tag/v0.26.0 169 | [sockets-wip]: https://github.com/WebAssembly/WASI/pull/312 170 | 171 | ``` 172 | 173 | ``` 174 | -------------------------------------------------------------------------------- /tests/as/index.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import { Console } from "as-wasi"; 3 | import { Method, RequestBuilder, Response } from "../../crates/as"; 4 | 5 | export function post(): void { 6 | let body = String.UTF8.encode("testing the body"); 7 | let res = new RequestBuilder("https://postman-echo.com/post") 8 | .header("Content-Type", "text/plain") 9 | .header("abc", "def") 10 | .method(Method.POST) 11 | .body(body) 12 | .send(); 13 | 14 | check(res, 200, "content-type"); 15 | res.close(); 16 | } 17 | 18 | export function get(): void { 19 | let res = new RequestBuilder("https://some-random-api.ml/facts/dog") 20 | .method(Method.GET) 21 | .send(); 22 | 23 | check(res, 200, "content-type"); 24 | let bytes = res.bodyReadAll(); 25 | let body = String.UTF8.decode(bytes.buffer); 26 | if (!body.includes("")) { 27 | Console.write("got " + body); 28 | abort(); 29 | } 30 | res.close(); 31 | } 32 | 33 | export function concurrent(): void { 34 | let req1 = makeReq(); 35 | let req2 = makeReq(); 36 | let req3 = makeReq(); 37 | } 38 | 39 | function makeReq(): Response { 40 | return new RequestBuilder("https://some-random-api.ml/facts/dog") 41 | .method(Method.GET) 42 | .send(); 43 | } 44 | 45 | function check( 46 | res: Response, 47 | expectedStatus: u32, 48 | expectedHeader: string 49 | ): void { 50 | if (res.status != expectedStatus) { 51 | Console.write( 52 | "expected status " + 53 | expectedStatus.toString() + 54 | " got " + 55 | res.status.toString() 56 | ); 57 | abort(); 58 | } 59 | 60 | let headerValue = res.headerGet(expectedHeader); 61 | if (!headerValue) { 62 | abort(); 63 | } 64 | 65 | let headers = res.headerGetAll(); 66 | if (headers.size == 0) { 67 | abort(); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /tests/as/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "asbuild": "asc index.ts -b build/optimized.wasm --use abort=wasi_abort --debug" 4 | }, 5 | "dependencies": { 6 | "as-wasi": "0.4.4", 7 | "assemblyscript": "^0.18.16" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tests/as/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/assemblyscript/std/assembly.json", 3 | "include": ["./**/*.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tests/integration.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | mod tests { 3 | use anyhow::Error; 4 | use std::time::Instant; 5 | use wasi_experimental_http_wasmtime::{HttpCtx, HttpState}; 6 | use wasmtime::*; 7 | use wasmtime_wasi::sync::WasiCtxBuilder; 8 | use wasmtime_wasi::*; 9 | 10 | const ALLOW_ALL_HOSTS: &str = "insecure:allow-all"; 11 | 12 | // We run the same test in a Tokio and non-Tokio environment 13 | // in order to make sure both scenarios are working. 14 | 15 | #[test] 16 | #[should_panic] 17 | fn test_none_allowed() { 18 | setup_tests(None, None); 19 | } 20 | 21 | #[tokio::test(flavor = "multi_thread")] 22 | #[should_panic] 23 | async fn test_async_none_allowed() { 24 | setup_tests(None, None); 25 | } 26 | 27 | #[test] 28 | #[should_panic] 29 | fn test_without_allowed_domains() { 30 | setup_tests(Some(vec![]), None); 31 | } 32 | 33 | #[tokio::test(flavor = "multi_thread")] 34 | #[should_panic] 35 | async fn test_async_without_allowed_domains() { 36 | setup_tests(Some(vec![]), None); 37 | } 38 | 39 | #[test] 40 | fn test_with_allowed_domains() { 41 | setup_tests( 42 | Some(vec![ 43 | "https://some-random-api.ml".to_string(), 44 | "https://postman-echo.com".to_string(), 45 | ]), 46 | None, 47 | ); 48 | } 49 | 50 | #[tokio::test(flavor = "multi_thread")] 51 | async fn test_async_with_allowed_domains() { 52 | setup_tests( 53 | Some(vec![ 54 | "https://some-random-api.ml".to_string(), 55 | "https://postman-echo.com".to_string(), 56 | ]), 57 | None, 58 | ); 59 | } 60 | 61 | #[test] 62 | fn test_with_wildcard_domain() { 63 | setup_tests(Some(vec![ALLOW_ALL_HOSTS.to_string()]), None); 64 | } 65 | 66 | #[tokio::test(flavor = "multi_thread")] 67 | async fn test_async_with_wildcard_domain() { 68 | setup_tests(Some(vec![ALLOW_ALL_HOSTS.to_string()]), None); 69 | } 70 | 71 | #[test] 72 | #[should_panic] 73 | fn test_concurrent_requests_rust() { 74 | let module = "target/wasm32-wasi/release/simple_wasi_http_tests.wasm".to_string(); 75 | make_concurrent_requests(module); 76 | } 77 | #[tokio::test(flavor = "multi_thread")] 78 | #[should_panic] 79 | async fn test_async_concurrent_requests_rust() { 80 | let module = "target/wasm32-wasi/release/simple_wasi_http_tests.wasm".to_string(); 81 | make_concurrent_requests(module); 82 | } 83 | 84 | #[test] 85 | #[should_panic] 86 | fn test_concurrent_requests_as() { 87 | let module = "tests/as/build/optimized.wasm".to_string(); 88 | make_concurrent_requests(module); 89 | } 90 | 91 | fn make_concurrent_requests(module: String) { 92 | let func = "concurrent"; 93 | let (instance, mut store) = create_instance( 94 | module, 95 | Some(vec!["https://some-random-api.ml".to_string()]), 96 | Some(2), 97 | ) 98 | .unwrap(); 99 | let func = instance 100 | .get_func(&mut store, func) 101 | .unwrap_or_else(|| panic!("cannot find function {}", func)); 102 | 103 | func.call(&mut store, &[], &mut []).unwrap(); 104 | } 105 | 106 | fn setup_tests(allowed_domains: Option>, max_concurrent_requests: Option) { 107 | let modules = vec![ 108 | "target/wasm32-wasi/release/simple_wasi_http_tests.wasm", 109 | "tests/as/build/optimized.wasm", 110 | ]; 111 | let test_funcs = vec!["get", "post"]; 112 | 113 | for module in modules { 114 | let (instance, store) = create_instance( 115 | module.to_string(), 116 | allowed_domains.clone(), 117 | max_concurrent_requests, 118 | ) 119 | .unwrap(); 120 | run_tests(&instance, store, &test_funcs).unwrap(); 121 | } 122 | } 123 | 124 | /// Execute the module's `_start` function. 125 | fn run_tests( 126 | instance: &Instance, 127 | mut store: Store, 128 | test_funcs: &[&str], 129 | ) -> Result<(), Error> { 130 | for func_name in test_funcs.iter() { 131 | let func = instance 132 | .get_func(&mut store, func_name) 133 | .unwrap_or_else(|| panic!("cannot find function {}", func_name)); 134 | func.call(&mut store, &[], &mut [])?; 135 | } 136 | 137 | Ok(()) 138 | } 139 | 140 | /// Create a Wasmtime::Instance from a compiled module and 141 | /// link the WASI imports. 142 | fn create_instance( 143 | filename: String, 144 | allowed_hosts: Option>, 145 | max_concurrent_requests: Option, 146 | ) -> Result<(Instance, Store), Error> { 147 | let start = Instant::now(); 148 | let engine = Engine::default(); 149 | let mut linker = Linker::new(&engine); 150 | 151 | let wasi = WasiCtxBuilder::new() 152 | .inherit_stdin() 153 | .inherit_stdout() 154 | .inherit_stderr() 155 | .build(); 156 | 157 | let http = HttpCtx { 158 | allowed_hosts, 159 | max_concurrent_requests, 160 | }; 161 | 162 | let ctx = IntegrationTestsCtx { wasi, http }; 163 | 164 | let mut store = Store::new(&engine, ctx); 165 | wasmtime_wasi::add_to_linker( 166 | &mut linker, 167 | |cx: &mut IntegrationTestsCtx| -> &mut WasiCtx { &mut cx.wasi }, 168 | )?; 169 | 170 | // Link `wasi_experimental_http` 171 | let http = HttpState::new()?; 172 | http.add_to_linker(&mut linker, |cx: &IntegrationTestsCtx| -> HttpCtx { 173 | cx.http.clone() 174 | })?; 175 | 176 | let module = wasmtime::Module::from_file(store.engine(), filename)?; 177 | 178 | let instance = linker.instantiate(&mut store, &module)?; 179 | let duration = start.elapsed(); 180 | println!("module instantiation time: {:#?}", duration); 181 | Ok((instance, store)) 182 | } 183 | 184 | struct IntegrationTestsCtx { 185 | pub wasi: WasiCtx, 186 | pub http: HttpCtx, 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /tests/rust/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "simple-wasi-http-tests" 3 | version = "0.1.0" 4 | authors = ["Radu Matei "] 5 | edition = "2018" 6 | 7 | [lib] 8 | crate-type = ["cdylib"] 9 | 10 | [dependencies] 11 | bytes = "1" 12 | http = "0.2" 13 | wasi-experimental-http = { path = "../../crates/wasi-experimental-http" } 14 | -------------------------------------------------------------------------------- /tests/rust/src/lib.rs: -------------------------------------------------------------------------------- 1 | use bytes::Bytes; 2 | 3 | #[no_mangle] 4 | pub extern "C" fn get() { 5 | let url = "https://some-random-api.ml/facts/dog".to_string(); 6 | let req = http::request::Builder::new().uri(&url).body(None).unwrap(); 7 | let mut res = wasi_experimental_http::request(req).expect("cannot make get request"); 8 | let str = std::str::from_utf8(&res.body_read_all().unwrap()) 9 | .unwrap() 10 | .to_string(); 11 | assert_eq!(str.is_empty(), false); 12 | assert_eq!(res.status_code, 200); 13 | assert!(!res 14 | .header_get("content-type".to_string()) 15 | .unwrap() 16 | .is_empty()); 17 | 18 | let header_map = res.headers_get_all().unwrap(); 19 | assert_ne!(header_map.len(), 0); 20 | } 21 | 22 | #[no_mangle] 23 | pub extern "C" fn post() { 24 | let url = "https://postman-echo.com/post".to_string(); 25 | let req = http::request::Builder::new() 26 | .method(http::Method::POST) 27 | .uri(&url) 28 | .header("Content-Type", "text/plain") 29 | .header("abc", "def"); 30 | let b = Bytes::from("Testing with a request body. Does this actually work?"); 31 | let req = req.body(Some(b)).unwrap(); 32 | 33 | let mut res = wasi_experimental_http::request(req).expect("cannot make post request"); 34 | let _ = std::str::from_utf8(&res.body_read_all().unwrap()) 35 | .unwrap() 36 | .to_string(); 37 | assert_eq!(res.status_code, 200); 38 | assert!(!res 39 | .header_get("content-type".to_string()) 40 | .unwrap() 41 | .is_empty()); 42 | 43 | let header_map = res.headers_get_all().unwrap(); 44 | assert_ne!(header_map.len(), 0); 45 | } 46 | 47 | #[allow(unused_variables)] 48 | #[no_mangle] 49 | pub extern "C" fn concurrent() { 50 | let url = "https://some-random-api.ml/facts/dog".to_string(); 51 | // the responses are unused to avoid dropping them. 52 | let req1 = make_req(url.clone()); 53 | let req2 = make_req(url.clone()); 54 | let req3 = make_req(url); 55 | } 56 | 57 | fn make_req(url: String) -> wasi_experimental_http::Response { 58 | let req = http::request::Builder::new().uri(&url).body(None).unwrap(); 59 | wasi_experimental_http::request(req).expect("cannot make get request") 60 | } 61 | -------------------------------------------------------------------------------- /witx/readme.md: -------------------------------------------------------------------------------- 1 | 2 | # Module: wasi_experimental_http 3 | 4 | ## Table of contents 5 | 6 | ### Types list: 7 | 8 | [**[All](#types)**] - [_[`http_error`](#http_error)_] - [_[`status_code`](#status_code)_] - [_[`outgoing_body`](#outgoing_body)_] - [_[`incoming_body`](#incoming_body)_] - [_[`response_handle`](#response_handle)_] - [_[`header_value_buf`](#header_value_buf)_] - [_[`written_bytes`](#written_bytes)_] 9 | 10 | ### Functions list: 11 | 12 | [**[All](#functions)**] - [[`req()`](#req)] - [[`close()`](#close)] - [[`header_get()`](#header_get)] - [[`headers_get_all()`](#headers_get_all)] - [[`body_read()`](#body_read)] 13 | 14 | ## Types 15 | 16 | ### _[`http_error`](#http_error)_ 17 | 18 | Enumeration with tag type: `u32`, and the following members: 19 | 20 | * **`success`**: _[`http_error`](#http_error)_ 21 | * **`invalid_handle`**: _[`http_error`](#http_error)_ 22 | * **`memory_not_found`**: _[`http_error`](#http_error)_ 23 | * **`memory_access_error`**: _[`http_error`](#http_error)_ 24 | * **`buffer_too_small`**: _[`http_error`](#http_error)_ 25 | * **`header_not_found`**: _[`http_error`](#http_error)_ 26 | * **`utf8_error`**: _[`http_error`](#http_error)_ 27 | * **`destination_not_allowed`**: _[`http_error`](#http_error)_ 28 | * **`invalid_method`**: _[`http_error`](#http_error)_ 29 | * **`invalid_encoding`**: _[`http_error`](#http_error)_ 30 | * **`invalid_url`**: _[`http_error`](#http_error)_ 31 | * **`request_error`**: _[`http_error`](#http_error)_ 32 | * **`runtime_error`**: _[`http_error`](#http_error)_ 33 | * **`too_many_sessions`**: _[`http_error`](#http_error)_ 34 | 35 | --- 36 | 37 | ### _[`status_code`](#status_code)_ 38 | Alias for `u16`. 39 | 40 | 41 | > HTTP status code 42 | 43 | 44 | --- 45 | 46 | ### _[`outgoing_body`](#outgoing_body)_ 47 | Alias for `u8` slice. 48 | 49 | 50 | > An HTTP body being sent 51 | 52 | 53 | --- 54 | 55 | ### _[`incoming_body`](#incoming_body)_ 56 | Alias for `u8` mutable slice. 57 | 58 | 59 | > Buffer for an HTTP body being received 60 | 61 | 62 | --- 63 | 64 | ### _[`response_handle`](#response_handle)_ 65 | Alias for `handle`. 66 | 67 | 68 | > A response handle 69 | 70 | 71 | --- 72 | 73 | ### _[`header_value_buf`](#header_value_buf)_ 74 | Alias for `u8` mutable slice. 75 | 76 | 77 | > Buffer to store a header value 78 | 79 | 80 | --- 81 | 82 | ### _[`written_bytes`](#written_bytes)_ 83 | Alias for `usize`. 84 | 85 | 86 | > Number of bytes having been written 87 | 88 | 89 | --- 90 | 91 | ## Functions 92 | 93 | ### [`req()`](#req) 94 | Returned error type: _[`http_error`](#http_error)_ 95 | 96 | #### Input: 97 | 98 | * **`url`**: `string` 99 | * **`method`**: `string` 100 | * **`headers`**: `string` 101 | * **`body`**: _[`outgoing_body`](#outgoing_body)_ 102 | 103 | #### Output: 104 | 105 | * _[`status_code`](#status_code)_ mutable pointer 106 | * _[`response_handle`](#response_handle)_ mutable pointer 107 | 108 | > Send a request 109 | 110 | 111 | --- 112 | 113 | ### [`close()`](#close) 114 | Returned error type: _[`http_error`](#http_error)_ 115 | 116 | #### Input: 117 | 118 | * **`response_handle`**: _[`response_handle`](#response_handle)_ 119 | 120 | This function has no output. 121 | 122 | > Close a request handle 123 | 124 | 125 | --- 126 | 127 | ### [`header_get()`](#header_get) 128 | Returned error type: _[`http_error`](#http_error)_ 129 | 130 | #### Input: 131 | 132 | * **`response_handle`**: _[`response_handle`](#response_handle)_ 133 | * **`header_name`**: `string` 134 | * **`header_value_buf`**: _[`header_value_buf`](#header_value_buf)_ 135 | 136 | #### Output: 137 | 138 | * _[`written_bytes`](#written_bytes)_ mutable pointer 139 | 140 | > Get the value associated with a header 141 | 142 | 143 | --- 144 | 145 | ### [`headers_get_all()`](#headers_get_all) 146 | Returned error type: _[`http_error`](#http_error)_ 147 | 148 | #### Input: 149 | 150 | * **`response_handle`**: _[`response_handle`](#response_handle)_ 151 | * **`header_value_buf`**: _[`header_value_buf`](#header_value_buf)_ 152 | 153 | #### Output: 154 | 155 | * _[`written_bytes`](#written_bytes)_ mutable pointer 156 | 157 | > Get the entire response header map 158 | 159 | 160 | --- 161 | 162 | ### [`body_read()`](#body_read) 163 | Returned error type: _[`http_error`](#http_error)_ 164 | 165 | #### Input: 166 | 167 | * **`response_handle`**: _[`response_handle`](#response_handle)_ 168 | * **`body_buf`**: _[`incoming_body`](#incoming_body)_ 169 | 170 | #### Output: 171 | 172 | * _[`written_bytes`](#written_bytes)_ mutable pointer 173 | 174 | > Fill a buffer with the streamed content of a response body 175 | 176 | 177 | --- 178 | 179 | -------------------------------------------------------------------------------- /witx/wasi_experimental_http.witx: -------------------------------------------------------------------------------- 1 | ;;; Experimental HTTP API for WebAssembly 2 | (module $wasi_experimental_http 3 | (typename $http_error 4 | (enum (@witx tag u32) 5 | ;;; Success 6 | $success 7 | ;;; Invalid handle 8 | $invalid_handle 9 | ;;; Memory not found 10 | $memory_not_found 11 | ;;; Memory access error 12 | $memory_access_error 13 | ;;; Buffer too small 14 | $buffer_too_small 15 | ;;; Header not found 16 | $header_not_found 17 | ;;; UTF-8 error 18 | $utf8_error 19 | ;;; Destination not allowed 20 | $destination_not_allowed 21 | ;;; Invalid method 22 | $invalid_method 23 | ;;; Invalid encoding 24 | $invalid_encoding 25 | ;;; Invalid URL 26 | $invalid_url 27 | ;;; Request error 28 | $request_error 29 | ;;; Runtime error 30 | $runtime_error 31 | ;;; Too many sessions 32 | $too_many_sessions 33 | ) 34 | ) 35 | 36 | ;;; Handles for the HTTP extensions 37 | (resource $http_handle) 38 | 39 | ;;; HTTP status code 40 | (typename $status_code u16) 41 | 42 | ;;; An HTTP body being sent 43 | (typename $outgoing_body (in-buffer u8)) 44 | 45 | ;;; Buffer for an HTTP body being received 46 | (typename $incoming_body (out-buffer u8)) 47 | 48 | ;;; A response handle 49 | (typename $response_handle (handle $http_handle)) 50 | 51 | ;;; Buffer to store a header value 52 | (typename $header_value_buf (out-buffer u8)) 53 | 54 | ;;; Number of bytes having been written 55 | (typename $written_bytes (@witx usize)) 56 | 57 | ;;; Send a request 58 | (@interface func (export "req") 59 | (param $url string) 60 | (param $method string) 61 | (param $headers string) 62 | (param $body $outgoing_body) 63 | (result $error (expected (tuple $status_code $response_handle) (error $http_error))) 64 | ) 65 | 66 | ;;; Close a request handle 67 | (@interface func (export "close") 68 | (param $response_handle $response_handle) 69 | (result $error (expected (error $http_error))) 70 | ) 71 | 72 | ;;; Get the value associated with a header 73 | (@interface func (export "header_get") 74 | (param $response_handle $response_handle) 75 | (param $header_name string) 76 | (param $header_value_buf $header_value_buf) 77 | (result $error (expected $written_bytes (error $http_error))) 78 | ) 79 | 80 | ;;; Get the entire response header map 81 | (@interface func (export "headers_get_all") 82 | (param $response_handle $response_handle) 83 | (param $header_value_buf $header_value_buf) 84 | (result $error (expected $written_bytes (error $http_error))) 85 | ) 86 | 87 | ;;; Fill a buffer with the streamed content of a response body 88 | (@interface func (export "body_read") 89 | (param $response_handle $response_handle) 90 | (param $body_buf $incoming_body) 91 | (result $error (expected $written_bytes (error $http_error))) 92 | ) 93 | ) 94 | --------------------------------------------------------------------------------