diff --git a/Cargo.lock b/Cargo.lock
index 791ed64..eeb9f95 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -8,6 +8,12 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+[[package]]
+name = "bytes"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
+
[[package]]
name = "coap-hello-udp"
version = "0.1.0"
@@ -145,6 +151,7 @@ version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
+ "bytes",
"libc",
"mio",
"pin-project-lite",
diff --git a/Cargo.toml b/Cargo.toml
index 5700b04..9134b97 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,7 +5,7 @@ edition = "2024"
[dependencies]
coap-lite = "0.13.3"
-tokio = { version = "1.53.1", features = ["rt-multi-thread", "net", "macros", "signal", "time"] }
+tokio = { version = "1.53.1", features = ["rt-multi-thread", "net", "macros", "signal", "time", "io-util"] }
[profile.release]
opt-level = 3
diff --git a/README.md b/README.md
index 56d2c62..b6505a2 100644
--- a/README.md
+++ b/README.md
@@ -32,7 +32,44 @@ Listens on `udp://0.0.0.0:5683` (CoAP's IANA-assigned port).
(`{"name":"Imran"}`). An empty payload returns `4.00`. An unknown path returns
`4.04`; a known path with the wrong method returns `4.05`.
-## Try it
+## Check it in a browser
+
+Browsers speak HTTP over TCP; this server speaks CoAP over UDP, so `coap://`
+URLs cannot be opened directly. `src/bin/bridge.rs` is a small HTTP -> CoAP
+proxy that closes the gap. Run both:
+
+```bash
+cargo run --release --bin coap-hello-udp
+```
+
+```bash
+cargo run --release --bin bridge
+```
+
+Then open for a test console with a name field and a
+button per route. Every response shows both the CoAP code and the HTTP status
+it was mapped to; the raw CoAP code is also returned in an `X-Coap-Code` header.
+
+These are now ordinary URLs — clickable, curl-able, bookmarkable:
+
+| URL | Response |
+|--------------------------------------------------|------------------------------|
+| `http://localhost:8080/` | the test console |
+| `http://localhost:8080/hello` | `Hello, World!` |
+| `http://localhost:8080/hello?name=Imran` | `Hello, Imran!` |
+| `http://localhost:8080/health` | `{"status":"ok","uptime":12}`|
+
+POST needs a body, so it is easiest from the console page or curl:
+
+```bash
+curl -X POST -d "Imran" http://localhost:8080/hello
+```
+
+The bridge is a development convenience: a deliberately small HTTP/1.1 subset
+with no keep-alive, no chunked bodies and no TLS. Do not expose it. Set
+`HTTP_PORT` if 8080 is taken.
+
+## Try it from the CLI
With the bundled client:
@@ -64,15 +101,19 @@ decoding.
| Path | What it is |
|---------------------|---------------------------------------------------------|
| `src/main.rs` | The server: socket, read loop, route table, helpers. |
+| `src/lib.rs` | Shared CoAP client used by the CLI and the bridge. |
| `src/bin/client.rs` | CLI client for smoke tests. |
+| `src/bin/bridge.rs` | HTTP -> CoAP proxy so a browser can reach the server. |
+| `static/index.html` | The browser test console the bridge serves at `/`. |
| `nodejs/` | The earlier Node.js version, kept for reference. |
## Config
-| Env var | Default |
-|-------------|-----------|
-| `COAP_PORT` | `5683` |
-| `COAP_HOST` | `0.0.0.0` |
+| Env var | Default | Used by |
+|-------------|-----------|----------------|
+| `COAP_PORT` | `5683` | server, client, bridge |
+| `COAP_HOST` | `0.0.0.0` | server, client, bridge |
+| `HTTP_PORT` | `8080` | bridge |
## Design notes
diff --git a/src/bin/bridge.rs b/src/bin/bridge.rs
new file mode 100644
index 0000000..b21661c
--- /dev/null
+++ b/src/bin/bridge.rs
@@ -0,0 +1,181 @@
+//! HTTP -> CoAP bridge, so a browser can exercise the CoAP server.
+//!
+//! Browsers speak HTTP over TCP; the server speaks CoAP over UDP. This sits in
+//! the middle: it accepts an ordinary HTTP request, reissues it as CoAP, and
+//! returns the reply with the CoAP response code mapped onto an HTTP status
+//! (and echoed verbatim in the `X-Coap-Code` header).
+//!
+//! Development convenience only — it is a deliberately small HTTP/1.1 subset
+//! (no keep-alive, no chunked bodies, no TLS). Do not expose it.
+
+use std::net::SocketAddr;
+
+use coap_hello_udp::{parse_method, request};
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::net::{TcpListener, TcpStream};
+
+const MAX_REQUEST: usize = 64 * 1024;
+const PAGE: &str = include_str!("../../static/index.html");
+
+#[tokio::main]
+async fn main() -> std::io::Result<()> {
+ let http_port = std::env::var("HTTP_PORT").unwrap_or_else(|_| "8080".into());
+ let coap_host = std::env::var("COAP_HOST").unwrap_or_else(|_| "127.0.0.1".into());
+ let coap_port = std::env::var("COAP_PORT").unwrap_or_else(|_| "5683".into());
+
+ let listener = TcpListener::bind(format!("0.0.0.0:{http_port}")).await?;
+ println!("HTTP -> CoAP bridge on http://localhost:{http_port}");
+ println!(" forwarding to coap://{coap_host}:{coap_port}");
+ println!(" open http://localhost:{http_port}/ in a browser");
+
+ loop {
+ let (stream, peer) = match listener.accept().await {
+ Ok(v) => v,
+ Err(e) => {
+ eprintln!("accept error: {e}");
+ continue;
+ }
+ };
+
+ // Each connection blocks on a CoAP round trip, so give it its own task.
+ let (host, port) = (coap_host.clone(), coap_port.clone());
+ tokio::spawn(async move {
+ if let Err(e) = serve(stream, peer, &host, &port).await {
+ eprintln!("connection error from {peer}: {e}");
+ }
+ });
+ }
+}
+
+async fn serve(
+ mut stream: TcpStream,
+ peer: SocketAddr,
+ coap_host: &str,
+ coap_port: &str,
+) -> std::io::Result<()> {
+ let Some((method, target, body)) = read_request(&mut stream).await? else {
+ return Ok(());
+ };
+
+ println!("{method} {target} from {peer}");
+
+ let (path, query) = match target.split_once('?') {
+ Some((p, q)) => (p, Some(q)),
+ None => (target.as_str(), None),
+ };
+
+ let response = match (method.as_str(), path) {
+ ("GET", "/") => http_response(200, "text/html; charset=utf-8", PAGE, None),
+ (_, "/favicon.ico") => http_response(204, "text/plain", "", None),
+
+ // Anything else is proxied straight through to the CoAP server.
+ _ => match parse_method(&method) {
+ Err(e) => http_response(405, "text/plain; charset=utf-8", &e, None),
+ Ok(coap_method) => {
+ match request(coap_host, coap_port, coap_method, path, query, &body).await {
+ Ok(reply) => {
+ let status = reply.http_status();
+ let content_type = if reply.payload.starts_with('{') {
+ "application/json"
+ } else {
+ "text/plain; charset=utf-8"
+ };
+ http_response(status, content_type, &reply.payload, Some(&reply.code))
+ }
+ Err(e) => http_response(
+ 502,
+ "text/plain; charset=utf-8",
+ &format!("Bad Gateway: {e}\nIs the CoAP server running on {coap_host}:{coap_port}?"),
+ None,
+ ),
+ }
+ }
+ },
+ };
+
+ stream.write_all(&response).await?;
+ stream.flush().await
+}
+
+/// Read one HTTP/1.1 request: the request line, the headers, and a
+/// `Content-Length` body if present. Returns `(method, target, body)`.
+async fn read_request(
+ stream: &mut TcpStream,
+) -> std::io::Result