524 lines
24 KiB
Markdown
524 lines
24 KiB
Markdown
# Middleware Axum Server Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Give the previously-orphaned `zesdex-middleware` crate (`SessionAuthLayer`, `default_cors_layer`, `RateLimitLayer`) a genuine integration point by adding an optional HTTP transport for the existing daemon, alongside (not replacing) the current Unix-socket transport.
|
|
|
|
**Important scope note — read before implementing:** unlike the OAuth/session/CMS wiring plans, there is **no existing HTTP server to fix or complete** — research confirmed zero axum usage anywhere in `zesdex-backend` and no design doc describing what one should do. This plan is therefore new-feature work, deliberately scoped as narrowly as possible: it exposes the *exact same* `ClientRequest`/`DaemonFrame` protocol the Unix-socket daemon already speaks, over HTTP, gated by the three middlewares. It does **not** invent a new REST API surface (no per-resource endpoints for settings/sessions/memory) — that would be scope creep beyond "give this crate a caller."
|
|
|
|
**Architecture:** Extract the daemon's per-request handling logic (`handle_daemon_client`'s match-on-`ClientRequest` body plus `send_daemon_update`) into transport-agnostic functions shared by both the existing Unix-socket loop and a new axum route. The state-owning thread gains an `mpsc` channel; the axum handler sends `(ClientRequest, oneshot::Sender<Vec<DaemonFrame>>)` and awaits the reply. `--http-port <PORT>` is a new opt-in CLI flag on `--daemon` — when absent, behavior is byte-for-byte identical to today (Unix socket only).
|
|
|
|
**Tech Stack:** Rust, `axum`, `tokio` (already workspace deps), `zesdex-middleware`.
|
|
|
|
## Global Constraints
|
|
|
|
- The Unix-socket transport's behavior must be provably unchanged — the refactor in Task 1 extracts logic without altering it, verified by the existing (or newly added, if none exist) daemon tests passing identically before and after.
|
|
- No new `#[allow(...)]` attributes.
|
|
- Tests are inline `#[cfg(test)] mod tests`.
|
|
- Run `cargo test --workspace` and `cargo clippy --workspace --all-targets -- -D warnings` before each commit.
|
|
- The HTTP transport is opt-in (`--http-port`) and OFF by default — it must not change any existing invocation's behavior.
|
|
|
|
---
|
|
|
|
### Task 1: Extract transport-agnostic request handling from `handle_daemon_client`
|
|
|
|
**Files:**
|
|
- Modify: `crates/zesdex-backend/src/main.rs` (functions `handle_daemon_client` ~line 336, `send_daemon_update` ~line 207)
|
|
|
|
**Interfaces:**
|
|
- Produces: `fn build_state_update_frame(state: &AppStateRest) -> ipc::protocol::DaemonFrame` (pure builder, extracted from `send_daemon_update`) and `fn process_client_request(state: &mut AppStateRest, req: ipc::protocol::ClientRequest) -> (bool, Vec<ipc::protocol::DaemonFrame>)` (pure state-mutation + frame-collection, extracted from `handle_daemon_client`'s match body) — both consumed by Task 3's axum handler and Task 2's refactored Unix-socket loop.
|
|
|
|
- [ ] **Step 1: Extract `build_state_update_frame`**
|
|
|
|
In `crates/zesdex-backend/src/main.rs`, split `send_daemon_update` (current body at line ~207-238) into a pure builder plus a thin I/O wrapper:
|
|
|
|
```rust
|
|
/// Flatten the daemon's `AppStateRest` into a `StatePayload` wrapped in a
|
|
/// `DaemonFrame::StateUpdate` — the pure, transport-agnostic half of what
|
|
/// was previously `send_daemon_update`.
|
|
///
|
|
/// Why: the client never shares memory with the daemon, so every action
|
|
/// on the daemon side is followed by a full state push rather than a diff.
|
|
fn build_state_update_frame(state: &app::state::rest::AppStateRest) -> ipc::protocol::DaemonFrame {
|
|
use ipc::protocol::{DaemonFrame, MessageEntry, ToastEntry, StatePayload};
|
|
|
|
let messages: Vec<MessageEntry> = state.transcript_cache.messages.iter().map(|m| {
|
|
MessageEntry {
|
|
role: format!("{:?}", m.role),
|
|
content: m.content.clone(),
|
|
timestamp: m.timestamp,
|
|
}
|
|
}).collect();
|
|
|
|
let toasts: Vec<ToastEntry> = state.misc.toasts.iter().map(|t| {
|
|
ToastEntry {
|
|
kind: format!("{:?}", t.kind),
|
|
message: t.message.clone(),
|
|
created_at: t.created_at,
|
|
lifetime_ms: t.lifetime_ms,
|
|
}
|
|
}).collect();
|
|
|
|
let overlay = if state.misc.overlay.is_active() {
|
|
Some(format!("{:?}", state.misc.overlay))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Keep every remaining `StatePayload` field exactly as the original
|
|
// `send_daemon_update` built it (input buffer/cursor, etc.) — copy the
|
|
// rest of the struct-literal body unchanged from the pre-refactor code.
|
|
DaemonFrame::StateUpdate(Box::new(StatePayload {
|
|
session_id: state.session_id.clone(),
|
|
messages,
|
|
toasts,
|
|
overlay,
|
|
// ...(remaining fields copied verbatim from the original function)
|
|
}))
|
|
}
|
|
|
|
/// Send a `DaemonFrame::StateUpdate` to an attached Unix-socket client.
|
|
fn send_daemon_update(conn: &mut ipc::conn::Connection, state: &app::state::rest::AppStateRest) -> Result<()> {
|
|
conn.send(&build_state_update_frame(state))
|
|
}
|
|
```
|
|
|
|
(Read the full original `send_daemon_update` body first — `sed`/`grep -n -A 45 "fn send_daemon_update" crates/zesdex-backend/src/main.rs` — and carry over every `StatePayload` field exactly; the excerpt above only shows the fields already visible in this plan's earlier research, do not drop any field the original builds.)
|
|
|
|
- [ ] **Step 2: Extract `process_client_request`**
|
|
|
|
Replace `handle_daemon_client`'s inner `match req { ... }` block with a new standalone function that returns frames instead of writing to a `Connection`:
|
|
|
|
```rust
|
|
/// Apply one `ClientRequest` to `state` and collect the `DaemonFrame`(s) it
|
|
/// produces — the pure, transport-agnostic half of what was previously
|
|
/// inlined in `handle_daemon_client`'s read loop.
|
|
///
|
|
/// Return: `(keep_running, frames)` — `keep_running` is `false` only for
|
|
/// `ClientRequest::Close`; `frames` always ends with a `StateUpdate` frame,
|
|
/// preceded by a `ClipboardCopy` frame if a copy was pending.
|
|
fn process_client_request(
|
|
state: &mut app::state::rest::AppStateRest,
|
|
req: ipc::protocol::ClientRequest,
|
|
) -> (bool, Vec<ipc::protocol::DaemonFrame>) {
|
|
use app::runtime::actions::{Action, apply_action};
|
|
use ipc::protocol::ClientRequest;
|
|
|
|
let mut running = true;
|
|
match req {
|
|
ClientRequest::Tick => {
|
|
apply_action(state, Action::Tick);
|
|
}
|
|
ClientRequest::KeyPress { key, ctrl, alt, shift } => {
|
|
let mut modifiers = crossterm::event::KeyModifiers::NONE;
|
|
if ctrl { modifiers |= crossterm::event::KeyModifiers::CONTROL; }
|
|
if alt { modifiers |= crossterm::event::KeyModifiers::ALT; }
|
|
if shift { modifiers |= crossterm::event::KeyModifiers::SHIFT; }
|
|
let key_event = crossterm::event::KeyEvent::new(key_action_to_code(&key), modifiers);
|
|
let actions = controller::input::handle_key(key_event, state);
|
|
for action in actions {
|
|
apply_action(state, action);
|
|
}
|
|
apply_action(state, Action::Tick);
|
|
}
|
|
ClientRequest::Submit(text) => {
|
|
state.input.buffer = text;
|
|
let enter_event = crossterm::event::KeyEvent::new(crossterm::event::KeyCode::Enter, crossterm::event::KeyModifiers::NONE);
|
|
let actions = controller::input::handle_key(enter_event, state);
|
|
for action in actions {
|
|
apply_action(state, action);
|
|
}
|
|
apply_action(state, Action::Tick);
|
|
}
|
|
ClientRequest::Paste(text) => {
|
|
state.input.buffer.insert_str(state.input.cursor, &text);
|
|
state.input.cursor += text.len();
|
|
state.dirty = true;
|
|
apply_action(state, Action::Tick);
|
|
}
|
|
ClientRequest::Resize(w, h) => {
|
|
apply_action(state, Action::Resize(w, h));
|
|
apply_action(state, Action::Tick);
|
|
}
|
|
ClientRequest::ScrollUp => {
|
|
apply_action(state, Action::ScrollUp);
|
|
apply_action(state, Action::Tick);
|
|
}
|
|
ClientRequest::ScrollDown => {
|
|
apply_action(state, Action::ScrollDown);
|
|
apply_action(state, Action::Tick);
|
|
}
|
|
ClientRequest::Close => {
|
|
running = false;
|
|
}
|
|
}
|
|
|
|
let mut frames = Vec::new();
|
|
if let Some(text) = state.misc.pending_clipboard_copy.take() {
|
|
frames.push(ipc::protocol::DaemonFrame::ClipboardCopy(text));
|
|
}
|
|
frames.push(build_state_update_frame(state));
|
|
(running, frames)
|
|
}
|
|
```
|
|
|
|
(Every match arm's body is copied verbatim from the pre-refactor `handle_daemon_client` — no logic changes, only relocation.)
|
|
|
|
- [ ] **Step 3: Rewrite `handle_daemon_client` as a thin wrapper**
|
|
|
|
```rust
|
|
fn handle_daemon_client(
|
|
mut conn: ipc::conn::Connection,
|
|
state: &mut app::state::rest::AppStateRest,
|
|
) -> Result<()> {
|
|
use ipc::protocol::ClientRequest;
|
|
|
|
loop {
|
|
match conn.receive::<ClientRequest>()? {
|
|
Some(req) => {
|
|
let (running, frames) = process_client_request(state, req);
|
|
for frame in frames {
|
|
conn.send(&frame)?;
|
|
}
|
|
if !running {
|
|
break;
|
|
}
|
|
}
|
|
None => break,
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
(Note: the original sent `ClipboardCopy` then a `StateUpdate` as two separate `conn.send` calls per request — the `for frame in frames` loop preserves that exact ordering since `process_client_request` pushes them in the same order.)
|
|
|
|
- [ ] **Step 4: Build and test**
|
|
|
|
Run: `cargo build -p zesdex-backend && cargo test -p zesdex-backend`
|
|
Expected: no errors, all existing tests pass.
|
|
|
|
- [ ] **Step 5: Manual regression check on the Unix-socket path**
|
|
|
|
Run the daemon + attach flow manually (`cargo run -p zesdex-backend -- --daemon` in one terminal, `cargo run -p zesdex-backend -- --attach <session-id>` in another) and confirm keypresses, submit, resize, scroll, and clean close all behave exactly as before this refactor.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add crates/zesdex-backend/src/main.rs
|
|
git commit -m "refactor(backend): ekstrak process_client_request/build_state_update_frame agar transport-agnostic"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Add an `mpsc`-bridged worker so the state owner can serve two transports
|
|
|
|
**Files:**
|
|
- Modify: `crates/zesdex-backend/src/main.rs` (`run_daemon`, ~line 427)
|
|
|
|
**Interfaces:**
|
|
- Produces: `run_daemon` spawns the existing Unix-socket accept loop on the calling thread as today, but if `--http-port` is set (Task 4), a second axum server (Task 3) sends requests into the same state via a shared `std::sync::mpsc::Sender<(ClientRequest, std::sync::mpsc::Sender<Vec<DaemonFrame>>)>` that the daemon's main loop polls alongside the Unix-socket `accept()`.
|
|
|
|
- [ ] **Step 1: Add a request channel to the daemon loop**
|
|
|
|
Read the current `run_daemon` in full first: `grep -n -A 60 "fn run_daemon" crates/zesdex-backend/src/main.rs`
|
|
|
|
Introduce, near the top of `run_daemon` (after `state` is constructed, before the accept loop):
|
|
|
|
```rust
|
|
// Bridge channel: lets an (optional) HTTP transport submit
|
|
// `ClientRequest`s into this thread's owned `AppStateRest`, exactly as
|
|
// the Unix-socket accept loop does. `bridge_rx` is polled with a
|
|
// short timeout alongside `server.accept()` so both transports can
|
|
// make progress on the single thread that owns `state`.
|
|
let (bridge_tx, bridge_rx) = std::sync::mpsc::channel::<(
|
|
ipc::protocol::ClientRequest,
|
|
std::sync::mpsc::Sender<Vec<ipc::protocol::DaemonFrame>>,
|
|
)>();
|
|
```
|
|
|
|
- [ ] **Step 2: Poll the bridge channel in the accept loop**
|
|
|
|
Locate the existing `loop { match server.accept() { ... } }` (or equivalent) in `run_daemon`. Since `UnixListener::accept()` blocks, switch it to non-blocking with a short poll interval so the bridge channel also gets serviced:
|
|
|
|
```rust
|
|
server.set_nonblocking(true)?; // confirm `IpcServer` exposes this — if not, add a thin `set_nonblocking` passthrough to `zesdex-ipc`'s `IpcServer` in this same task
|
|
loop {
|
|
// Drain any pending HTTP-bridged requests first.
|
|
while let Ok((req, reply_tx)) = bridge_rx.try_recv() {
|
|
let (_running, frames) = process_client_request(&mut state, req);
|
|
let _ = reply_tx.send(frames);
|
|
}
|
|
|
|
match server.accept() {
|
|
Ok(conn) => {
|
|
handle_daemon_client(conn, &mut state)?;
|
|
}
|
|
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
|
std::thread::sleep(std::time::Duration::from_millis(20));
|
|
}
|
|
Err(e) => {
|
|
eprintln!("daemon: accept error: {e}");
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
(If `IpcServer` doesn't currently expose `set_nonblocking`, add it to `crates/zesdex-ipc/src/server.rs` as a one-line passthrough to the underlying `UnixListener::set_nonblocking`, with a doc comment explaining why: enables polling the HTTP bridge channel on the same thread without blocking indefinitely on Unix-socket `accept()`.)
|
|
|
|
- [ ] **Step 3: Thread `bridge_tx` out to Task 3**
|
|
|
|
Have `run_daemon` pass a clone of `bridge_tx` to the HTTP-server-spawning code added in Task 4 (only reached when `--http-port` is set).
|
|
|
|
- [ ] **Step 4: Build**
|
|
|
|
Run: `cargo check -p zesdex-backend`
|
|
Expected: no errors (Task 4 hasn't added the HTTP server yet, so `bridge_tx` may show an "unused" warning until then — acceptable transiently within this plan's own task sequence, but must be resolved by the time Task 4 finishes; do not leave an `#[allow(dead_code)]` on it in the interim).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add crates/zesdex-backend/src/main.rs crates/zesdex-ipc/src/server.rs
|
|
git commit -m "feat(backend): tambahkan channel jembatan mpsc di run_daemon untuk transport HTTP opsional"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Add the axum HTTP bridge endpoint using `zesdex-middleware`
|
|
|
|
**Files:**
|
|
- Create: `crates/zesdex-backend/src/ipc_http.rs`
|
|
- Modify: `crates/zesdex-backend/src/main.rs` (module declaration + call site)
|
|
- Modify: `crates/zesdex-backend/Cargo.toml` (confirm `axum`/`tokio` already present — they are, per workspace deps; no change needed, just verify with `grep -E "^axum|^tokio" crates/zesdex-backend/Cargo.toml`)
|
|
|
|
**Interfaces:**
|
|
- Consumes: `zesdex_middleware::auth::{SessionAuthLayer, SessionIdentity}`, `zesdex_middleware::cors::default_cors_layer`, `zesdex_middleware::rate_limit::{RateLimiter, RateLimitLayer}` (with `trust_proxy_headers: false` per the `2026-07-16-security-quickfixes.md` plan's Task 2), the `bridge_tx` sender from Task 2.
|
|
- Produces: `pub async fn serve_http_bridge(port: u16, store: zesdex_entities::seaorm::common::store::Store, bridge_tx: std::sync::mpsc::Sender<(ClientRequest, std::sync::mpsc::Sender<Vec<DaemonFrame>>)>) -> anyhow::Result<()>` — spawned as a tokio task by `run_daemon` when `--http-port` is set.
|
|
|
|
- [ ] **Step 1: Write the route handler**
|
|
|
|
Create `crates/zesdex-backend/src/ipc_http.rs`:
|
|
|
|
```rust
|
|
//! Optional HTTP transport for the daemon, bridging to the same
|
|
//! `ClientRequest`/`DaemonFrame` protocol the Unix-socket transport uses.
|
|
//!
|
|
//! Exists solely to give `zesdex-middleware`'s `SessionAuthLayer`,
|
|
//! `default_cors_layer`, and `RateLimitLayer` a real caller — it
|
|
//! deliberately does NOT introduce a new REST API surface; the one route
|
|
//! below is a thin bridge onto the pre-existing IPC protocol.
|
|
use axum::extract::State;
|
|
use axum::http::StatusCode;
|
|
use axum::response::{IntoResponse, Json};
|
|
use axum::routing::post;
|
|
use axum::Router;
|
|
|
|
use ipc::protocol::{ClientRequest, DaemonFrame};
|
|
|
|
type BridgeSender = std::sync::mpsc::Sender<(ClientRequest, std::sync::mpsc::Sender<Vec<DaemonFrame>>)>;
|
|
|
|
#[derive(Clone)]
|
|
struct HttpBridgeState {
|
|
bridge_tx: std::sync::Arc<std::sync::Mutex<BridgeSender>>,
|
|
}
|
|
|
|
/// Handle one bridged `ClientRequest`, blocking (on a blocking-safe tokio
|
|
/// task) until the daemon's state-owning thread replies.
|
|
///
|
|
/// Flow: build a one-shot `std::sync::mpsc` reply channel → send
|
|
/// `(req, reply_tx)` into the daemon's bridge channel → block on
|
|
/// `reply_rx.recv()` via `tokio::task::spawn_blocking` (since the daemon
|
|
/// thread's reply is synchronous, not a future) → return the frames as
|
|
/// JSON.
|
|
///
|
|
/// Return: `200` with the frame list on success, `500` if the daemon
|
|
/// thread is gone (channel send/receive failed) or the bridge send failed.
|
|
async fn handle_request(
|
|
State(state): State<HttpBridgeState>,
|
|
Json(req): Json<ClientRequest>,
|
|
) -> impl IntoResponse {
|
|
let (reply_tx, reply_rx) = std::sync::mpsc::channel();
|
|
let send_result = state
|
|
.bridge_tx
|
|
.lock()
|
|
.map_err(|_| ())
|
|
.and_then(|tx| tx.send((req, reply_tx)).map_err(|_| ()));
|
|
|
|
if send_result.is_err() {
|
|
return (StatusCode::INTERNAL_SERVER_ERROR, Json(Vec::<DaemonFrame>::new()));
|
|
}
|
|
|
|
let frames = tokio::task::spawn_blocking(move || reply_rx.recv().unwrap_or_default())
|
|
.await
|
|
.unwrap_or_default();
|
|
|
|
(StatusCode::OK, Json(frames))
|
|
}
|
|
|
|
/// Serve the HTTP bridge on `127.0.0.1:<port>`, gated by session auth,
|
|
/// CORS, and rate limiting from `zesdex-middleware`.
|
|
///
|
|
/// Why 127.0.0.1 only: this bridge is meant for local attach clients that
|
|
/// prefer HTTP over a Unix socket (e.g. a browser-based frontend on the
|
|
/// same machine), not a remote API — it is never exposed beyond loopback.
|
|
///
|
|
/// Return: `Err` if the port can't be bound; otherwise runs until the
|
|
/// process exits (mirrors the Unix-socket daemon's lifetime).
|
|
pub async fn serve_http_bridge(
|
|
port: u16,
|
|
store: zesdex_entities::seaorm::common::store::Store,
|
|
bridge_tx: BridgeSender,
|
|
) -> anyhow::Result<()> {
|
|
let http_state = HttpBridgeState {
|
|
bridge_tx: std::sync::Arc::new(std::sync::Mutex::new(bridge_tx)),
|
|
};
|
|
|
|
let rate_limiter = zesdex_middleware::rate_limit::RateLimiter::new(/* existing constructor args, e.g. window/limit — read crates/zesdex-middleware/src/rate_limit.rs's `RateLimiter::new` signature first */);
|
|
|
|
let app = Router::new()
|
|
.route("/ipc/request", post(handle_request))
|
|
.layer(zesdex_middleware::auth::SessionAuthLayer::new(store))
|
|
.layer(zesdex_middleware::cors::default_cors_layer())
|
|
.layer(zesdex_middleware::rate_limit::RateLimitLayer::new(rate_limiter))
|
|
.with_state(http_state);
|
|
|
|
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
|
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
|
tracing::info!("[http-bridge] listening on {addr}");
|
|
axum::serve(listener, app.into_make_service_with_connect_info::<std::net::SocketAddr>()).await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn serve_http_bridge_rejects_requests_without_session_header() {
|
|
let store = zesdex_entities::seaorm::common::store::Store::new();
|
|
let (tx, _rx) = std::sync::mpsc::channel();
|
|
// Bind on port 0 equivalent isn't directly expressible via this
|
|
// function's fixed-port signature — for this test, spawn the
|
|
// server on an ephemeral high port and hit it with `reqwest`,
|
|
// asserting a 401 when `X-Session-Id` is absent. Pick a
|
|
// collision-unlikely test port derived from the process id to
|
|
// avoid flaky parallel-test port clashes:
|
|
let port = 20000 + (std::process::id() % 10000) as u16;
|
|
let server = tokio::spawn(serve_http_bridge(port, store, tx));
|
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
|
|
|
let resp = reqwest::Client::new()
|
|
.post(format!("http://127.0.0.1:{port}/ipc/request"))
|
|
.json(&ClientRequest::Tick)
|
|
.send()
|
|
.await
|
|
.expect("request should reach the server");
|
|
assert_eq!(resp.status(), reqwest::StatusCode::UNAUTHORIZED);
|
|
|
|
server.abort();
|
|
}
|
|
}
|
|
```
|
|
|
|
(The `RateLimiter::new` call needs its actual constructor arguments — read `crates/zesdex-middleware/src/rate_limit.rs` first to fill these in precisely; after applying `2026-07-16-security-quickfixes.md`'s Task 2, prefer `RateLimiter::new(...)` — the safe, non-proxy-trusting constructor — over `with_proxy_trust`, since this bridge sits directly on loopback with no fronting proxy.)
|
|
|
|
- [ ] **Step 2: Register the module**
|
|
|
|
In `crates/zesdex-backend/src/main.rs`, add near the other `mod`/`use` declarations:
|
|
|
|
```rust
|
|
mod ipc_http;
|
|
```
|
|
|
|
- [ ] **Step 3: Run the test**
|
|
|
|
Run: `cargo test -p zesdex-backend serve_http_bridge_rejects -- --nocapture`
|
|
Expected: pass (needs `SessionAuthLayer` to actually reject unauthenticated requests — if the test fails because `SessionAuthLayer`'s validation logic doesn't match this expectation, read `crates/zesdex-middleware/src/auth.rs`'s `validate_session`/`SessionAuthMiddleware::call` in full and adjust the test to match its actual documented rejection behavior rather than changing the middleware itself, since that's pre-existing, previously-audited code out of this plan's scope).
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add crates/zesdex-backend/src/ipc_http.rs crates/zesdex-backend/src/main.rs
|
|
git commit -m "feat(backend): tambahkan HTTP bridge axum untuk IPC, memakai zesdex-middleware"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Add the `--http-port` CLI flag
|
|
|
|
**Files:**
|
|
- Modify: `crates/zesdex-backend/src/main.rs` (argument parsing near the `--daemon`/`--attach` flags, and the end of `run_daemon` where the bridge is spawned)
|
|
|
|
**Interfaces:** none new — wires Task 2's `bridge_tx` and Task 3's `serve_http_bridge` together, gated by the flag.
|
|
|
|
- [ ] **Step 1: Add flag parsing**
|
|
|
|
Read the existing flag-parsing code first: `grep -n -B2 -A10 "\-\-daemon\|\-\-attach" crates/zesdex-backend/src/main.rs | head -40`
|
|
|
|
Add a `--http-port <PORT>` flag using the same parsing style already present (whatever library/manual parsing the existing flags use), defaulting to `None` (HTTP transport disabled) when absent.
|
|
|
|
- [ ] **Step 2: Spawn the HTTP bridge conditionally in `run_daemon`**
|
|
|
|
After Task 2's `bridge_tx`/`bridge_rx` setup, add:
|
|
|
|
```rust
|
|
if let Some(port) = http_port {
|
|
let store_for_http = zesdex_entities::seaorm::common::store::Store::new();
|
|
let bridge_tx_for_http = bridge_tx.clone();
|
|
std::thread::spawn(move || {
|
|
let rt = tokio::runtime::Runtime::new().expect("failed to create tokio runtime for HTTP bridge");
|
|
if let Err(e) = rt.block_on(ipc_http::serve_http_bridge(port, store_for_http, bridge_tx_for_http)) {
|
|
tracing::error!("[http-bridge] server error: {e}");
|
|
}
|
|
});
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Build and test**
|
|
|
|
Run: `cargo build --workspace && cargo test --workspace`
|
|
Expected: no errors, all pass.
|
|
|
|
- [ ] **Step 4: Manual smoke test — HTTP transport off by default**
|
|
|
|
Run: `cargo run -p zesdex-backend -- --daemon` (no `--http-port`). Confirm the daemon starts and the Unix-socket path works exactly as before (attach a client, verify interaction).
|
|
|
|
- [ ] **Step 5: Manual smoke test — HTTP transport enabled**
|
|
|
|
Run: `cargo run -p zesdex-backend -- --daemon --http-port 18080`. From another terminal, `curl -X POST http://127.0.0.1:18080/ipc/request -H 'Content-Type: application/json' -H 'X-Session-Id: <a-real-session-id>' -d '"Tick"'` and confirm a `200` with a JSON frame list; retry without the `X-Session-Id` header and confirm `401`.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add crates/zesdex-backend/src/main.rs
|
|
git commit -m "feat(backend): tambahkan flag --http-port opsional untuk daemon HTTP bridge"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Run the full workspace verification
|
|
|
|
- [ ] **Step 1: Full build**
|
|
|
|
Run: `cargo build --workspace`
|
|
|
|
- [ ] **Step 2: Full test suite**
|
|
|
|
Run: `cargo test --workspace`
|
|
|
|
- [ ] **Step 3: Full clippy**
|
|
|
|
Run: `cargo clippy --workspace --all-targets -- -D warnings`
|
|
|
|
- [ ] **Step 4: Confirm `zesdex-middleware` is no longer orphaned**
|
|
|
|
Run: `grep -rln "zesdex_middleware::" crates/zesdex-backend/src`
|
|
Expected: `crates/zesdex-backend/src/ipc_http.rs` (this plan's new file).
|
|
|
|
- [ ] **Step 5: Commit (if any cleanup was needed)**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "chore: verifikasi akhir wiring zesdex-middleware ke daemon HTTP bridge"
|
|
```
|