diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..25c308f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo test --all-features + + format: + name: Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --check + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo clippy --all-features -- -D warnings diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2c6f2e0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,155 @@ +name: Release + +on: + push: + tags: ['v*'] + branches: [main] + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + name: Build ${{ matrix.name }} + if: startsWith(github.ref, 'refs/tags/') + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + name: linux-x64 + - target: x86_64-unknown-linux-musl + os: ubuntu-latest + name: linux-x64-musl + musl: true + - target: aarch64-unknown-linux-gnu + os: ubuntu-latest + name: linux-arm64 + cross: true + - target: x86_64-apple-darwin + os: macos-latest + name: macos-x64 + - target: aarch64-apple-darwin + os: macos-latest + name: macos-arm64 + - target: x86_64-pc-windows-msvc + os: windows-latest + name: windows-x64 + - target: aarch64-pc-windows-msvc + os: windows-latest + name: windows-arm64 + + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + + - name: Install musl-tools + if: matrix.musl + run: sudo apt-get update && sudo apt-get install -y musl-tools + + - name: Install cross-compiler (Linux ARM) + if: matrix.cross + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV + + - name: Build + run: cargo build --release --all-features --target ${{ matrix.target }} + + - name: Package (Unix) + if: matrix.os != 'windows-latest' + run: | + cd target/${{ matrix.target }}/release + tar -czvf ../../../http-relay-${{ matrix.name }}.tar.gz http-relay + + - name: Package (Windows) + if: matrix.os == 'windows-latest' + run: | + cd target/${{ matrix.target }}/release + 7z a ../../../http-relay-${{ matrix.name }}.zip http-relay.exe + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: http-relay-${{ matrix.name }} + path: http-relay-${{ matrix.name }}.* + + release: + name: Create Release + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + files: artifacts/* + generate_release_notes: true + + deploy-demo: + name: Deploy Demo + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: demo/package-lock.json + + - name: Install dependencies + working-directory: demo + run: npm ci + + - name: Build + working-directory: demo + run: npm run build + env: + NEXT_PUBLIC_RELAY_URL: https://httprelay.pubky.app + + - name: Add .nojekyll + run: touch demo/out/.nojekyll + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: demo/out + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 7a4e226..2253d94 100644 --- a/.gitignore +++ b/.gitignore @@ -32,5 +32,11 @@ Cargo.lock *.bak *.tmp +cache.sqlite* -.claude/ \ No newline at end of file +# Demo (Next.js) +demo/.next/ +demo/out/ + +# Claude Code +.claude/ diff --git a/Cargo.toml b/Cargo.toml index a6bd626..941d8be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,24 +1,56 @@ [package] name = "http-relay" description = "A Rust implementation of _some_ of [Http relay spec](https://httprelay.io/)." -version = "0.5.1" +version = "0.6.0" edition = "2021" -authors = ["SeverinAlexB", "SHAcollision", "Nuh"] +authors = ["SeverinAlexB"] license = "MIT" -homepage = "https://github.com/pubky/pubky-http-relay" -repository = "https://github.com/pubky/pubky-http-relay" +homepage = "https://github.com/pubky/http-relay" +repository = "https://github.com/pubky/http-relay" keywords = ["httprelay", "http", "relay"] categories = ["web-programming"] [dependencies] -anyhow = "1.0.99" -axum = "0.8.6" -axum-server = "0.7.2" +anyhow = "1.0.100" +bytes = "1" futures-util = "0.3.31" -tokio = { version = "1.47.1", features = ["full"] } -tracing = "0.1.41" -url = "2.5.4" -tower-http = { version = "0.6.6", features = ["cors", "trace"] } +tokio = { version = "1.49.0", features = ["full"] } +tracing = "0.1.44" + +# Optional: server feature +axum = { version = "0.8.8", optional = true } +http-body = { version = "1.0", optional = true } +axum-server = { version = "0.8.0", optional = true } +tower-http = { version = "0.6.8", features = ["cors", "trace"], optional = true } +url = { version = "2.5.8", optional = true } + +# Optional: persist feature +rusqlite = { version = "0.32", features = ["bundled"], optional = true } + +# Optional: CLI (binary only) +clap = { version = "4", features = ["derive"], optional = true } +tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true } + +[features] +default = ["cli", "persist", "link-compat"] + +# HTTP server (axum + handlers + middleware) +server = ["dep:axum", "dep:http-body", "dep:axum-server", "dep:tower-http", "dep:url"] + +# SQLite persistence (without this: in-memory HashMap storage) +persist = ["dep:rusqlite"] + +# Legacy /link/{id} endpoint compatibility (deprecated) +link-compat = [] + +# CLI binary +cli = ["dep:clap", "dep:tracing-subscriber", "server"] + +[[bin]] +name = "http-relay" +path = "src/main.rs" +required-features = ["cli"] [dev-dependencies] -axum-test = "17.3.0" +axum-test = "18.7.0" +http-body-util = "0.1" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b878576 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM rust:1.84-alpine AS builder + +RUN apk add --no-cache musl-dev + +WORKDIR /app +COPY Cargo.toml Cargo.lock ./ +COPY src ./src + +RUN cargo build --release --all-features + +FROM scratch + +COPY --from=builder /app/target/release/http-relay /http-relay + +EXPOSE 8080 + +ENTRYPOINT ["/http-relay"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..537fe61 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Synonym + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index e6c6e42..97eff92 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,356 @@ -# HTTP Relay +# http-relay -A Rust implementation of _some_ of [Http relay spec](https://httprelay.io/). +[![Crates.io](https://img.shields.io/crates/v/http-relay.svg)](https://crates.io/crates/http-relay) +[![CI](https://github.com/pubky/http-relay/actions/workflows/ci.yml/badge.svg)](https://github.com/pubky/http-relay/actions/workflows/ci.yml) +[![Documentation](https://docs.rs/http-relay/badge.svg)](https://docs.rs/http-relay) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +An HTTP relay for reliable asynchronous message passing between producers and consumers, +with store-and-forward semantics and explicit acknowledgment. -## Example +Built primarily for [Pubky](https://pubky.org) applications, but usable as a general-purpose relay. + +**[Try the interactive demo](https://pubky.github.io/http-relay/)** + +## What is this? + +An HTTP relay enables decoupled communication between distributed services. +Producers POST messages to a channel; consumers GET them. The relay handles +the coordination, storage, and delivery confirmation. + +**The problem it solves:** When a mobile app requests data and then gets +backgrounded or killed by the OS, the HTTP response never arrives—but from +the server's perspective, it was sent successfully. This relay ensures +messages persist until the consumer explicitly acknowledges receipt. + +**Use cases:** +- Mobile apps that need reliable message delivery despite OS backgrounding +- Services that can't communicate directly (NAT traversal, firewall bypass) +- Decoupled microservices with delivery confirmation requirements + +## Features + +- **Store-and-forward** - Messages persist until explicitly acknowledged +- **At-least-once delivery** - Consumers can retry; message stays available until ACKed +- **Delivery confirmation** - Producers can block until consumer ACKs, or check status +- **Mobile-friendly timeouts** - 25s default stays under typical proxy limits (nginx, Cloudflare) +- **Content-Type preservation** - Forwards producer's Content-Type to consumer +- **Legacy compatibility** - `/link/{id}` endpoint for existing integrations + +## Installation + +```bash +cargo install http-relay +``` + +Or add as a dependency: + +```toml +[dependencies] +http-relay = "0.6" +``` + +## Usage + +### As CLI + +```bash +# Default: bind to 127.0.0.1:8080 (localhost only) +http-relay + +# Bind to all interfaces (for production/Docker) +http-relay --bind 0.0.0.0 + +# Custom configuration +http-relay --bind 0.0.0.0 --port 15412 --inbox-cache-ttl 300 --inbox-timeout 25 -vv +``` + +**Options:** + +| Flag | Description | Default | +|------|-------------|---------| +| `--bind ` | Bind address | `127.0.0.1` | +| `--port ` | HTTP port (0 = random) | `8080` | +| `--inbox-cache-ttl ` | Message TTL for inbox | `300` | +| `--inbox-timeout ` | Inbox long-poll timeout | `25` | +| `--max-body-size ` | Max request body size | `2048` (2KB) | +| `--max-entries ` | Max entries in waiting list | `10000` | +| `--persist-db ` | SQLite database path for persistence | (in-memory) | +| `-v` | Verbosity (repeat for more) | warn | +| `-q, --quiet` | Silence output | - | + +### As Library ```rust +use http_relay::HttpRelayBuilder; + #[tokio::main] -async fn main() { - let http_relay = http_relay::HttpRelay::builder() +async fn main() -> anyhow::Result<()> { + let relay = HttpRelayBuilder::default() .http_port(15412) .run() - .await - .unwrap(); + .await?; + + println!("Running at {}", relay.local_link_url()); + + tokio::signal::ctrl_c().await?; + relay.shutdown().await +} +``` + +## API + +### Inbox Endpoints + +The primary API. Store-and-forward with explicit acknowledgment. + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/inbox/{id}` | Store message (returns 200 immediately) | +| `GET` | `/inbox/{id}` | Retrieve message (long-poll, waits up to 25s) | +| `DELETE` | `/inbox/{id}` | ACK - confirms delivery | +| `GET` | `/inbox/{id}/ack` | Returns `true` or `false` (was message ACKed?) | +| `GET` | `/inbox/{id}/await` | Block until ACKed (25s default timeout) | + +#### POST `/inbox/{id}` - Store Message + +Producer stores a message. Returns immediately without waiting for a consumer. + +```bash +curl -X POST http://localhost:8080/inbox/my-channel \ + -H "Content-Type: application/json" \ + -d '{"hello": "world"}' +``` + +**Responses:** +- `200 OK` - Message stored successfully +- `503 Service Unavailable` - Server at capacity + +#### GET `/inbox/{id}` - Retrieve Message (Long-Poll) + +Consumer retrieves the stored message. If no message is available, waits up to +25 seconds (configurable) for one to arrive. + +```bash +curl http://localhost:8080/inbox/my-channel +``` + +**Responses:** +- `200 OK` - Returns message with original Content-Type +- `408 Request Timeout` - No message arrived within timeout (25s default) + +#### DELETE `/inbox/{id}` - Acknowledge Delivery + +Consumer acknowledges successful receipt. Clears the message from storage. + +```bash +curl -X DELETE http://localhost:8080/inbox/my-channel +``` + +**Responses:** +- `200 OK` - Message acknowledged and cleared + +#### GET `/inbox/{id}/ack` - Check ACK Status + +Producer checks if message was acknowledged. - println!( - "Running http relay {}", - http_relay.local_link_url().as_str() - ); +```bash +curl http://localhost:8080/inbox/my-channel/ack +``` + +**Responses:** +- `200 OK` - Body contains `true` (ACKed) or `false` (pending) +- `404 Not Found` - No message exists (not posted yet, or expired) + +#### GET `/inbox/{id}/await` - Wait for ACK + +Producer blocks until consumer acknowledges the message. + +```bash +curl http://localhost:8080/inbox/my-channel/await +``` + +**Responses:** +- `200 OK` - Consumer ACKed the message +- `408 Request Timeout` - No ACK received within timeout (default 25s) + +### Typical Flow + +```mermaid +sequenceDiagram + participant P as Producer + participant R as Relay + participant C as Consumer + + P->>R: POST /inbox/{id} + activate R + Note right of R: Store in SQLite
acked = false + R-->>P: 200 OK + deactivate R + + P->>R: GET /inbox/{id}/await + activate R + Note right of R: Subscribe to ACK
(blocks up to 25s) + + C->>R: GET /inbox/{id} + R-->>C: 200 OK + message + + C->>R: DELETE /inbox/{id} + activate R + Note right of R: Set acked = true
Clear message body
Notify ACK waiters + R-->>C: 200 OK + deactivate R + + R-->>P: 200 OK (ACKed) + deactivate R +``` + +**Step by step:** + +1. **Producer** POSTs message to `/inbox/{id}` — returns 200 immediately +2. **Producer** calls GET `/inbox/{id}/await` — blocks waiting for ACK +3. **Consumer** GETs message from `/inbox/{id}` — receives payload (or waits up to 25s if not yet available) +4. **Consumer** DELETEs `/inbox/{id}` — acknowledges receipt +5. **Producer's** `/await` call returns 200 — delivery confirmed + +The consumer can call GET before the producer posts—it will long-poll up to 25s for the message to arrive. No polling loop needed. + +### Link Endpoint (Legacy) + +Implements the standard [HTTP Relay spec](https://httprelay.io/). Maintained for +backwards compatibility but not recommended for new integrations. + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/link/{id}` | Send message, block until consumer retrieves (10 min timeout) | +| `GET` | `/link/{id}` | Retrieve message, block until producer sends (10 min timeout) | + +**Why prefer `/inbox`:** The `/link` endpoint has no ACK mechanism—if the consumer +disconnects after receiving data, the producer still gets `200 OK`. The 10-minute +timeout also exceeds typical proxy limits. + +## Client Implementation Patterns + +### Producer: Store and Wait for ACK - tokio::signal::ctrl_c().await.unwrap(); +```javascript +async function produceToRelay(channelId, data) { + // Store the message (returns immediately) + while (true) { + try { + const storeResponse = await fetch(`http://relay.example.com/inbox/${channelId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); - http_relay.shutdown().await.unwrap(); + if (storeResponse.status === 200) break; + throw new Error(`Failed to store: ${storeResponse.status}`); + } catch (error) { + // Network error - retry after brief delay + await new Promise(resolve => setTimeout(resolve, 1000)); + continue; + } + } + + // Wait for consumer to ACK (blocks up to 25s per call) + while (true) { + try { + const awaitResponse = await fetch( + `http://relay.example.com/inbox/${channelId}/await` + ); + + if (awaitResponse.status === 200) { + return; // Consumer ACKed - delivery confirmed + } + + if (awaitResponse.status === 408) { + continue; // Timeout - keep waiting + } + + throw new Error(`Unexpected status: ${awaitResponse.status}`); + } catch (error) { + // Network error - retry after brief delay + await new Promise(resolve => setTimeout(resolve, 1000)); + continue; + } + } +} +``` + +### Consumer: Retrieve and ACK + +```javascript +async function consumeFromRelay(channelId) { + // Long-poll until message is available (waits up to 25s per call) + while (true) { + try { + const response = await fetch(`http://relay.example.com/inbox/${channelId}`); + + if (response.status === 200) { + const data = await response.text(); + + // ACK the message (critical - producer is waiting for this) + // Retry ACK on network error - message won't be re-delivered after success + while (true) { + try { + await fetch(`http://relay.example.com/inbox/${channelId}`, { + method: 'DELETE', + }); + break; + } catch (error) { + await new Promise(resolve => setTimeout(resolve, 1000)); + continue; + } + } + + return data; + } + + if (response.status === 408) { + continue; // Timeout - no message yet, retry + } + + throw new Error(`Unexpected status: ${response.status}`); + } catch (error) { + // Network error (app backgrounded, connection dropped, etc.) + // Wait briefly then retry - message is still safe on the relay + await new Promise(resolve => setTimeout(resolve, 1000)); + continue; + } + } } ``` + +### Key Points + +- **Network errors are recoverable**: Because messages persist until ACKed, + both producer and consumer can safely retry on connection drops. +- **Consumer must ACK**: Call DELETE after processing. Until then, the message + remains available (at-least-once delivery). +- **Producer can verify delivery**: Use /await to block until ACK, or /ack to + check status without blocking. +- **Message TTL**: Unacknowledged messages expire after 5 minutes (configurable). + +## Limitations + +### TCP Cannot Detect Sudden Disconnects + +When a consumer's network disappears suddenly (Wi-Fi off, tunnel, app killed), +the relay cannot detect this immediately. TCP acknowledgments can take 30+ +seconds to fail when packets vanish. + +This is why `/inbox` uses explicit ACKs: the producer only knows delivery +succeeded when the consumer calls DELETE. If the consumer crashes before ACKing, +the message remains available for retry. + +## Development + +```bash +# Run tests +cargo test + +# Run with debug logging +RUST_LOG=debug cargo run +``` + diff --git a/demo/.gitignore b/demo/.gitignore new file mode 100644 index 0000000..2263ed4 --- /dev/null +++ b/demo/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.next/ +out/ +.env*.local +*.tsbuildinfo +next-env.d.ts diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 0000000..c43e151 --- /dev/null +++ b/demo/README.md @@ -0,0 +1,59 @@ +# HTTP Relay Demo + +A simple web UI to test the http-relay `/link2` endpoint. + +## Quick Start + +```bash +# 1. Start the relay (from repo root) +cargo run + +# 2. Start the demo (from this folder) +npm install +npm run dev +``` + +Open http://localhost:3000 + +## Usage + +1. **Set Channel ID** - Click "Random" or enter your own +2. **Start Consumer** - Waits for a message on the channel +3. **Send from Producer** - Delivers message to the waiting consumer +4. **Watch the log** - See the request/response flow + +The consumer and producer retry automatically on 408 timeouts until they connect. + +## Sharing Channels + +The channel ID syncs with the URL. Share links like: + +``` +http://localhost:3000?channel=my-channel +``` + +Your friend opens the link → same channel ID is pre-filled → they can immediately start as consumer or producer. + +## Configuration + +### Relay URL + +Default: `http://localhost:8080` + +Set via environment variable: +```bash +NEXT_PUBLIC_RELAY_URL=https://relay.example.com npm run dev +``` + +Or via URL query param: +``` +http://localhost:3000?relay=https://relay.example.com +``` + +### Endpoint + +Toggle between `/link2` (recommended, with caching) and `/link` (deprecated) in the UI. + +### Channel ID + +Any string, shared between consumer and producer. Can be set via `?channel=` query param. diff --git a/demo/app/layout.tsx b/demo/app/layout.tsx new file mode 100644 index 0000000..e80dcb1 --- /dev/null +++ b/demo/app/layout.tsx @@ -0,0 +1,18 @@ +export const metadata = { + title: 'HTTP Relay Demo', + description: 'Demo for testing http-relay /inbox endpoint operations', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + + {children} + + + ) +} diff --git a/demo/app/page.tsx b/demo/app/page.tsx new file mode 100644 index 0000000..51b0075 --- /dev/null +++ b/demo/app/page.tsx @@ -0,0 +1,462 @@ +'use client' + +import { useState, useRef, useCallback, useEffect, Suspense } from 'react' +import { useSearchParams } from 'next/navigation' + +type LogEntry = { + timestamp: Date + type: 'consumer' | 'producer' | 'info' | 'error' + message: string +} + +function generateRandomId() { + return Math.random().toString(36).substring(2, 10) +} + +const DEFAULT_RELAY_URL = + process.env.NEXT_PUBLIC_RELAY_URL || 'http://localhost:8080' + +function HomeContent() { + const searchParams = useSearchParams() + const [relayUrl, setRelayUrl] = useState(DEFAULT_RELAY_URL) + const [channelId, setChannelId] = useState('') + const [producerContent, setProducerContent] = useState('hello world') + const [logs, setLogs] = useState([]) + + const [postLoading, setPostLoading] = useState(false) + const [checkAckLoading, setCheckAckLoading] = useState(false) + const [sendAckLoading, setSendAckLoading] = useState(false) + const [getMessageActive, setGetMessageActive] = useState(false) + const [awaitAckActive, setAwaitAckActive] = useState(false) + + const getMessageAbortRef = useRef(null) + const awaitAckAbortRef = useRef(null) + + useEffect(() => { + const channel = searchParams.get('channel') + const relay = searchParams.get('relay') + if (channel) setChannelId(channel) + if (relay) setRelayUrl(relay) + }, [searchParams]) + + const updateChannelId = useCallback((newId: string) => { + setChannelId(newId) + setLogs([]) + const url = new URL(window.location.href) + if (newId) { + url.searchParams.set('channel', newId) + } else { + url.searchParams.delete('channel') + } + window.history.replaceState({}, '', url.toString()) + }, []) + + const addLog = useCallback((type: LogEntry['type'], message: string) => { + setLogs((prev) => [...prev, { timestamp: new Date(), type, message }]) + }, []) + + const clearLogs = () => setLogs([]) + + const handlePost = async () => { + if (!channelId.trim()) { + addLog('error', 'Channel ID is required') + return + } + + setPostLoading(true) + const id = channelId.trim() + + try { + addLog('producer', `POST /inbox/${id} with: ${producerContent}`) + const response = await fetch(`${relayUrl}/inbox/${id}`, { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: producerContent, + }) + + if (response.status === 200) { + addLog('producer', 'Message stored (200)') + } else { + addLog('error', `Failed with status: ${response.status}`) + } + } catch (err) { + addLog('error', `Error: ${err}`) + } + + setPostLoading(false) + } + + const handleCheckAck = async () => { + if (!channelId.trim()) { + addLog('error', 'Channel ID is required') + return + } + + setCheckAckLoading(true) + const id = channelId.trim() + + try { + addLog('producer', `GET /inbox/${id}/ack`) + const response = await fetch(`${relayUrl}/inbox/${id}/ack`) + + if (response.status === 200) { + const data = await response.text() + addLog('producer', `ACK status: ${data}`) + } else { + addLog('error', `Failed with status: ${response.status}`) + } + } catch (err) { + addLog('error', `Error: ${err}`) + } + + setCheckAckLoading(false) + } + + const handleAwaitAck = async () => { + if (!channelId.trim()) { + addLog('error', 'Channel ID is required') + return + } + + if (awaitAckActive) { + awaitAckAbortRef.current?.abort() + return + } + + setAwaitAckActive(true) + awaitAckAbortRef.current = new AbortController() + const id = channelId.trim() + + try { + addLog('producer', `GET /inbox/${id}/await (waiting...)`) + const response = await fetch(`${relayUrl}/inbox/${id}/await`, { + signal: awaitAckAbortRef.current.signal, + }) + + if (response.status === 200) { + addLog('producer', 'ACKed!') + } else if (response.status === 408) { + addLog('producer', 'Timeout (408)') + } else { + addLog('error', `Failed with status: ${response.status}`) + } + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + addLog('producer', 'Await ACK cancelled') + } else { + addLog('error', `Error: ${err}`) + } + } + + setAwaitAckActive(false) + } + + const handleGetMessage = async () => { + if (!channelId.trim()) { + addLog('error', 'Channel ID is required') + return + } + + if (getMessageActive) { + getMessageAbortRef.current?.abort() + return + } + + setGetMessageActive(true) + getMessageAbortRef.current = new AbortController() + const id = channelId.trim() + + try { + addLog('consumer', `GET /inbox/${id} (waiting...)`) + const response = await fetch(`${relayUrl}/inbox/${id}`, { + signal: getMessageAbortRef.current.signal, + }) + + if (response.status === 200) { + const data = await response.text() + addLog('consumer', `Received: ${data}`) + } else if (response.status === 408) { + addLog('consumer', 'Timeout (408)') + } else { + addLog('error', `Failed with status: ${response.status}`) + } + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + addLog('consumer', 'GET cancelled') + } else { + addLog('error', `Error: ${err}`) + } + } + + setGetMessageActive(false) + } + + const handleSendAck = async () => { + if (!channelId.trim()) { + addLog('error', 'Channel ID is required') + return + } + + setSendAckLoading(true) + const id = channelId.trim() + + try { + addLog('consumer', `DELETE /inbox/${id}`) + const response = await fetch(`${relayUrl}/inbox/${id}`, { + method: 'DELETE', + }) + + if (response.status === 200) { + addLog('consumer', 'ACK sent (200)') + } else if (response.status === 404) { + addLog('consumer', 'Nothing to ACK (404)') + } else { + addLog('error', `Failed with status: ${response.status}`) + } + } catch (err) { + addLog('error', `Error: ${err}`) + } + + setSendAckLoading(false) + } + + const sectionStyle: React.CSSProperties = { + backgroundColor: 'white', + padding: '16px', + borderRadius: '8px', + marginBottom: '16px', + boxShadow: '0 1px 3px rgba(0,0,0,0.1)', + } + + const inputStyle: React.CSSProperties = { + padding: '8px 12px', + border: '1px solid #ddd', + borderRadius: '4px', + fontSize: '14px', + width: '100%', + boxSizing: 'border-box', + } + + const buttonStyle = ( + color: string, + isLoading: boolean = false + ): React.CSSProperties => ({ + padding: '8px 16px', + backgroundColor: color, + color: 'white', + border: 'none', + borderRadius: '4px', + cursor: 'pointer', + fontSize: '14px', + marginRight: '8px', + opacity: isLoading ? 0.7 : 1, + }) + + return ( +
+

HTTP Relay Demo

+ + {/* Configuration Section */} +
+

+ Configuration +

+
+
+ + setRelayUrl(e.target.value)} + placeholder="http://localhost:8080" + style={inputStyle} + /> +
+
+ +
+ updateChannelId(e.target.value)} + placeholder="my-channel" + style={{ ...inputStyle, flex: 1 }} + /> + +
+
+
+
+ + {/* Producer Section */} +
+

Producer

+
+