diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e1279c6 --- /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 + + 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 -- -D warnings diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4432070 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,106 @@ +name: Release + +on: + push: + tags: ['v*'] + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + name: Build ${{ matrix.name }} + 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 --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 diff --git a/Cargo.toml b/Cargo.toml index a6bd626..1d56149 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,24 +1,33 @@ [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" +axum = "0.8.8" +http-body = "1.0" +axum-server = "0.8.0" +clap = { version = "4", features = ["derive"] } 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"] } +lru = "0.16.3" +tokio = { version = "1.49.0", features = ["full"] } +tracing = "0.1.44" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +url = "2.5.8" +tower-http = { version = "0.6.8", features = ["cors", "trace"] } + +[[bin]] +name = "http-relay" +path = "src/main.rs" [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..87b4d20 --- /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 + +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..161ac74 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,233 @@ -# 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) +A Rust implementation of the `/link` endpoint from the [HTTP Relay spec](https://httprelay.io/) +for asynchronous producer/consumer message passing. -## Example +## What is this? + +An HTTP relay enables decoupled communication between distributed services. +Instead of direct synchronous calls, producers and consumers communicate through +relay endpoints, each waiting for their counterpart to arrive. + +**Non-standard extension:** Adds a `/link2` endpoint optimized for mobile +clients with caching and shorter timeouts. + +**Use cases:** +- Connecting services that can't communicate directly +- Mobile apps that need resilient message delivery with retry support +- Decoupled microservice communication + +## Features + +- **Async producer/consumer model** - Producers POST data, consumers GET it +- **Two endpoint variants:** + - `/link/{id}` - **Deprecated.** Standard relay (10 min timeout) + - `/link2/{id}` - **Recommended.** Mobile-friendly with caching (25s timeout, 5 min cache TTL) +- **Mobile resilience** - Cached responses allow retries after connection drops +- **Content-Type preservation** - Forwards producer's Content-Type to consumer +- **Configurable timeouts and caching** + +## 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 --link2-cache-ttl 300 --link2-timeout 25 -vv +``` + +**Options:** + +| Flag | Description | Default | +|------|-------------|---------| +| `--bind ` | Bind address | `127.0.0.1` | +| `--port ` | HTTP port (0 = random) | `8080` | +| `--link2-cache-ttl ` | Cache TTL for link2 | `300` | +| `--link2-timeout ` | Link2 endpoint timeout | `25` | +| `--max-body-size ` | Max request body size | `10240` (10KB) | +| `--max-pending ` | Max pending requests | `10000` | +| `--max-cache ` | Max cached entries | `10000` | +| `-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 +} +``` - println!( - "Running http relay {}", - http_relay.local_link_url().as_str() - ); +## API - tokio::signal::ctrl_c().await.unwrap(); +### POST `/link/{id}` or `/link2/{id}` + +Producer sends a message. Waits for a consumer to retrieve it. + +```bash +curl -X POST http://localhost:8080/link/my-channel \ + -H "Content-Type: application/json" \ + -d '{"hello": "world"}' +``` + +**Responses:** +- `200 OK` - Consumer received the message (confirmed via two-phase ACK) +- `408 Request Timeout` - No consumer arrived, or consumer disconnected before receiving +- `503 Service Unavailable` - Server at capacity (max pending reached) + +### GET `/link/{id}` or `/link2/{id}` + +Consumer retrieves a message. Waits for a producer to send one. + +```bash +curl http://localhost:8080/link/my-channel +``` - http_relay.shutdown().await.unwrap(); +**Responses:** +- `200 OK` - Returns producer's payload with original Content-Type +- `408 Request Timeout` - No producer arrived in time +- `503 Service Unavailable` - Server at capacity (max pending reached) + +### Endpoint Differences + +| Aspect | `/link/{id}` | `/link2/{id}` | +|--------|--------------|---------------| +| Status | **Deprecated** | **Recommended** | +| Timeout | 10 minutes | 25 seconds | +| Caching | No | Yes (5 min TTL) | + +**Use `/link2` for all integrations.** It handles proxy timeouts gracefully and +supports retries via caching. The `/link` endpoint is deprecated and remains +only for backwards compatibility with existing clients. + +### Why Link2 Exists + +The original `/link` endpoint has a problem on mobile devices. When a mobile +app requests data from the relay and then gets backgrounded or killed by the +OS, the HTTP response never actually arrives on the device. From the relay's +perspective, the value was consumed successfully. But the mobile app never +received it—and when the user reopens the app, the value is gone. + +`/link2` solves this with three mechanisms: + +1. **Caching**: After a successful delivery, the value is cached for 5 minutes. + If the mobile app was killed, it can retry and still receive the value. + +2. **Shorter timeout**: The 25-second timeout stays safely under typical proxy + timeouts (nginx, Cloudflare often use 30s), preventing unexpected connection drops. + +3. **Two-phase acknowledgment**: The producer only receives `200 OK` after the + consumer has actually received the response body. If the consumer disconnects + mid-transfer, the producer gets `408` and can retry. This prevents the relay + from reporting success when the data never reached the client. + +## Client Implementation Patterns + +Because `/link2` has a short timeout (25s), clients should implement retry +loops. The producer/consumer may not connect on the first attempt, and that's +expected behavior. + +### Consumer: Retry Until Value Received + +The consumer loops until it successfully receives the producer's payload: + +```javascript +async function consumeFromRelay(channelId) { + while (true) { + const response = await fetch(`http://relay.example.com/link2/${channelId}`); + + if (response.status === 200) { + return await response.text(); // Success - got the value + } + + if (response.status === 408) { + continue; // Timeout - producer hasn't arrived yet, retry + } + + throw new Error(`Unexpected status: ${response.status}`); + } +} +``` + +### Producer: Retry Until Consumer Receives + +The producer loops until a consumer successfully retrieves the message: + +```javascript +async function produceToRelay(channelId, data) { + while (true) { + const response = await fetch(`http://relay.example.com/link2/${channelId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + + if (response.status === 200) { + return; // Success - consumer got the value + } + + if (response.status === 408) { + continue; // Timeout - no consumer yet, retry + } + + throw new Error(`Unexpected status: ${response.status}`); + } } ``` + +### Why Retry Loops? + +- **Short timeouts are intentional**: Proxies (nginx, cloudflare) often + have 30s timeouts. The 25s link2 timeout stays safely under this limit. +- **Cache enables resilience**: Once delivered, the value is cached for 5 min. + If a consumer's connection drops, they can retry and still receive it. +- **Two-phase ACK ensures correctness**: Producers only get `200 OK` after the + consumer actually received the data. A `408` means retry is safe and necessary. +- **408 is not an error**: It just means the counterpart hasn't arrived yet, + or disconnected before completing the transfer. Keep trying until success. + +## 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..1dde437 --- /dev/null +++ b/demo/app/layout.tsx @@ -0,0 +1,18 @@ +export const metadata = { + title: 'HTTP Relay Demo', + description: 'Demo for testing http-relay /link2 endpoint', +} + +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..8e09c9d --- /dev/null +++ b/demo/app/page.tsx @@ -0,0 +1,388 @@ +'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 [endpoint, setEndpoint] = useState<'link' | 'link2'>('link2') + const [channelId, setChannelId] = useState('') + const [producerContent, setProducerContent] = useState('hello world') + const [logs, setLogs] = useState([]) + const [consumerRunning, setConsumerRunning] = useState(false) + const [producerRunning, setProducerRunning] = useState(false) + + const consumerAbortRef = useRef(null) + const producerAbortRef = useRef(null) + + // Read config from URL on mount + useEffect(() => { + const channel = searchParams.get('channel') + const relay = searchParams.get('relay') + if (channel) setChannelId(channel) + if (relay) setRelayUrl(relay) + }, [searchParams]) + + // Update URL when channel changes + const updateChannelId = useCallback((newId: string) => { + setChannelId(newId) + 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 startConsumer = async () => { + if (!channelId.trim()) { + addLog('error', 'Channel ID is required') + return + } + + setConsumerRunning(true) + consumerAbortRef.current = new AbortController() + const id = channelId.trim() + + addLog('consumer', `Starting consumer loop for ID: ${id}`) + + while (true) { + if (consumerAbortRef.current?.signal.aborted) { + addLog('consumer', 'Consumer stopped by user') + break + } + + try { + addLog('consumer', `GET ${relayUrl}/${endpoint}/${id}`) + const response = await fetch(`${relayUrl}/${endpoint}/${id}`, { + signal: consumerAbortRef.current?.signal, + }) + + if (response.status === 200) { + const data = await response.text() + addLog('consumer', `Received: ${data}`) + break + } + + if (response.status === 408) { + addLog('consumer', '408 Timeout - retrying...') + continue + } + + addLog('error', `Unexpected status: ${response.status}`) + break + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + addLog('consumer', 'Consumer stopped by user') + break + } + addLog('error', `Consumer error: ${err}`) + break + } + } + + setConsumerRunning(false) + } + + const stopConsumer = () => { + consumerAbortRef.current?.abort() + } + + const startProducer = async () => { + if (!channelId.trim()) { + addLog('error', 'Channel ID is required') + return + } + + setProducerRunning(true) + producerAbortRef.current = new AbortController() + const id = channelId.trim() + const content = producerContent + + addLog('producer', `Starting producer loop for ID: ${id}`) + + while (true) { + if (producerAbortRef.current?.signal.aborted) { + addLog('producer', 'Producer stopped by user') + break + } + + try { + addLog('producer', `POST ${relayUrl}/${endpoint}/${id} with: ${content}`) + const response = await fetch(`${relayUrl}/${endpoint}/${id}`, { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: content, + signal: producerAbortRef.current?.signal, + }) + + if (response.status === 200) { + addLog('producer', 'Consumer received the message!') + break + } + + if (response.status === 408) { + addLog('producer', '408 Timeout - retrying...') + continue + } + + addLog('error', `Unexpected status: ${response.status}`) + break + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + addLog('producer', 'Producer stopped by user') + break + } + addLog('error', `Producer error: ${err}`) + break + } + } + + setProducerRunning(false) + } + + const stopProducer = () => { + producerAbortRef.current?.abort() + } + + const clearLogs = () => setLogs([]) + + 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 = (active: boolean, color: string): React.CSSProperties => ({ + padding: '8px 16px', + backgroundColor: active ? '#999' : color, + color: 'white', + border: 'none', + borderRadius: '4px', + cursor: active ? 'not-allowed' : 'pointer', + fontSize: '14px', + marginRight: '8px', + }) + + return ( +
+

HTTP Relay Demo

+ + {/* Config Section */} +
+

Configuration

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

Consumer

+
+ {!consumerRunning ? ( + + ) : ( + + )} + {consumerRunning && ( + + Waiting for producer... + + )} +
+
+ + {/* Producer Section */} +
+

Producer

+
+