Skip to main content

sim_web_shell/
serve.rs

1//! Minimal blocking HTTP/1.1 server for the Web shell.
2//!
3//! The server serves embedded assets, the cookbook API adapter, and the Atelier
4//! shell cache API. Runtime transport remains the Intent/Scene bridge over
5//! `realize`/`EvalFabric`.
6
7use std::io::{BufRead, BufReader, Write};
8use std::net::{TcpListener, TcpStream, ToSocketAddrs};
9use std::path::PathBuf;
10use std::sync::Arc;
11use std::time::Duration;
12
13/// Largest request body the shell will read. A larger declared `Content-Length`
14/// is rejected with 413 before any allocation, so a hostile header cannot force
15/// an unbounded `vec![0u8; n]`.
16const MAX_BODY_BYTES: usize = 1 << 20; // 1 MiB.
17
18/// Per-read timeout on a connection, so a peer that declares a body but then
19/// dribbles (or stalls) cannot block the single-threaded server forever.
20const READ_TIMEOUT: Duration = Duration::from_secs(30);
21
22use crate::assets::asset_for;
23use crate::atelier::AtelierWebState;
24use crate::live::{
25    DEFAULT_PANE, DEFAULT_RESOURCE, LiveSession, decode_intent_body, encode_patches, encode_scene,
26    error_json,
27};
28use sim_codec_algol::AlgolCodecLib;
29use sim_codec_binary::BinaryCodecLib;
30use sim_codec_chat::ChatCodecLib;
31use sim_codec_json::JsonCodecLib;
32use sim_codec_lisp::LispCodecLib;
33use sim_kernel::{Cx, DefaultFactory, EagerPolicy, Result as SimResult, read_eval_capability};
34use sim_lib_server::{CookbookWebResponse, CookbookWebState};
35use sim_lib_stream_core::install_stream_core_shapes_lib;
36
37/// Configuration for the shell server.
38pub struct ServeConfig {
39    /// The address to bind, e.g. `127.0.0.1:8787`.
40    pub addr: String,
41    /// Directory containing generated Atelier cache files.
42    pub atelier_root: PathBuf,
43}
44
45impl Default for ServeConfig {
46    fn default() -> Self {
47        Self {
48            addr: "127.0.0.1:8787".to_owned(),
49            atelier_root: PathBuf::from(".sim/atelier"),
50        }
51    }
52}
53
54/// Bind and serve the shell until the process is terminated.
55pub fn serve(config: &ServeConfig) -> std::io::Result<()> {
56    let listener = bind(&config.addr)?;
57    let local = listener.local_addr()?;
58    let mut state = ShellState::new(config)?;
59    println!("sim-web-shell: serving shell on http://{local}");
60    for stream in listener.incoming() {
61        match stream {
62            Ok(stream) => {
63                if let Err(err) = handle(stream, &mut state) {
64                    eprintln!("sim-web-shell: connection error: {err}");
65                }
66            }
67            Err(err) => eprintln!("sim-web-shell: accept error: {err}"),
68        }
69    }
70    Ok(())
71}
72
73fn bind(addr: &str) -> std::io::Result<TcpListener> {
74    let resolved = addr.to_socket_addrs()?.next().ok_or_else(|| {
75        std::io::Error::new(std::io::ErrorKind::InvalidInput, "no socket address")
76    })?;
77    TcpListener::bind(resolved)
78}
79
80struct ShellState {
81    atelier: AtelierWebState,
82    cookbook: CookbookWebState,
83    cookbook_cx: Cx,
84    live: LiveSession,
85}
86
87impl ShellState {
88    fn new(config: &ServeConfig) -> std::io::Result<Self> {
89        Ok(Self {
90            atelier: AtelierWebState::load(config.atelier_root.clone()),
91            cookbook: CookbookWebState::seeded().map_err(io_error)?,
92            cookbook_cx: cookbook_cx().map_err(io_error)?,
93            live: LiveSession::new().map_err(io_error)?,
94        })
95    }
96}
97
98fn cookbook_cx() -> SimResult<Cx> {
99    let mut cx = Cx::new(Arc::new(EagerPolicy), Arc::new(DefaultFactory));
100    cx.grant(read_eval_capability());
101    install_codecs(&mut cx)?;
102    install_stream_core_shapes_lib(&mut cx)?;
103    Ok(cx)
104}
105
106fn install_codecs(cx: &mut Cx) -> SimResult<()> {
107    let lisp = LispCodecLib::new(cx.registry_mut().fresh_codec_id())?;
108    cx.load_lib(&lisp)?;
109    let json = JsonCodecLib::new(cx.registry_mut().fresh_codec_id());
110    cx.load_lib(&json)?;
111    let binary = BinaryCodecLib::new(cx.registry_mut().fresh_codec_id());
112    cx.load_lib(&binary)?;
113    let chat = ChatCodecLib::new(cx.registry_mut().fresh_codec_id());
114    cx.load_lib(&chat)?;
115    let algol = AlgolCodecLib::new(cx.registry_mut().fresh_codec_id());
116    cx.load_lib(&algol)?;
117    Ok(())
118}
119
120fn io_error(err: impl std::fmt::Display) -> std::io::Error {
121    std::io::Error::other(err.to_string())
122}
123
124fn handle(mut stream: TcpStream, state: &mut ShellState) -> std::io::Result<()> {
125    // Bound how long a single read may block; a slow-loris peer cannot pin the
126    // server. A failure to set the timeout is non-fatal (e.g. exotic streams).
127    let _ = stream.set_read_timeout(Some(READ_TIMEOUT));
128    let request = match read_request(&mut stream)? {
129        ReadOutcome::Request(request) => request,
130        ReadOutcome::TooLarge => {
131            write_response(
132                &mut stream,
133                413,
134                "Payload Too Large",
135                "text/plain; charset=utf-8",
136                b"payload too large",
137            )?;
138            return Ok(());
139        }
140        ReadOutcome::Invalid => {
141            write_response(
142                &mut stream,
143                400,
144                "Bad Request",
145                "text/plain; charset=utf-8",
146                b"bad request",
147            )?;
148            return Ok(());
149        }
150    };
151    if path_of(&request.target) == "/api/session/intent" {
152        return write_session_intent(&mut stream, &request, &mut state.live);
153    }
154    if path_of(&request.target) == "/api/session/open" {
155        return write_session_open(&mut stream, &request, &mut state.live);
156    }
157    if request.target.starts_with("/api/cookbook") {
158        let response = state.cookbook.handle_request(
159            &request.method,
160            &request.target,
161            Some(&mut state.cookbook_cx),
162        );
163        return write_cookbook_response(&mut stream, &response);
164    }
165    if let Some(response) = state.atelier.response(&request.method, &request.target) {
166        return write_response(
167            &mut stream,
168            response.status,
169            status_text(response.status),
170            response.content_type,
171            response.body.as_bytes(),
172        );
173    }
174    if request.method != "GET" {
175        write_response(
176            &mut stream,
177            405,
178            "Method Not Allowed",
179            "text/plain; charset=utf-8",
180            b"method not allowed",
181        )?;
182        return Ok(());
183    }
184    match asset_for(&request.target) {
185        Some(asset) => write_response(&mut stream, 200, "OK", asset.content_type, asset.body),
186        None => write_response(
187            &mut stream,
188            404,
189            "Not Found",
190            "text/plain; charset=utf-8",
191            b"not found",
192        ),
193    }
194}
195
196#[derive(Debug)]
197struct RequestLine {
198    method: String,
199    target: String,
200    body: String,
201}
202
203/// The outcome of reading one request: a parsed request, an oversized body
204/// (answer 413), or an otherwise-unparseable request (answer 400).
205#[derive(Debug)]
206enum ReadOutcome {
207    Request(RequestLine),
208    TooLarge,
209    Invalid,
210}
211
212/// Read the request line, scan headers for `Content-Length`, and read the body.
213fn read_request(stream: &mut TcpStream) -> std::io::Result<ReadOutcome> {
214    let mut reader = BufReader::new(stream);
215    read_request_from(&mut reader)
216}
217
218/// Parse a request from any buffered reader, bounding the body at
219/// [`MAX_BODY_BYTES`]. A declared `Content-Length` over the cap returns
220/// [`ReadOutcome::TooLarge`] before any allocation, and the body read is capped
221/// at the same limit so a lying header cannot over-read.
222fn read_request_from(reader: &mut impl BufRead) -> std::io::Result<ReadOutcome> {
223    let mut request_line = String::new();
224    if reader.read_line(&mut request_line)? == 0 {
225        return Ok(ReadOutcome::Invalid);
226    }
227    // Drain the rest of the header block, capturing the body length, so the peer
228    // is not left mid-write.
229    let mut content_length = 0usize;
230    let mut header = String::new();
231    loop {
232        header.clear();
233        let read = reader.read_line(&mut header)?;
234        if read == 0 || header == "\r\n" || header == "\n" {
235            break;
236        }
237        if let Some((name, value)) = header.split_once(':')
238            && name.trim().eq_ignore_ascii_case("content-length")
239        {
240            content_length = value.trim().parse().unwrap_or(0);
241        }
242    }
243    // Reject an oversized declared body before allocating anything for it.
244    if content_length > MAX_BODY_BYTES {
245        return Ok(ReadOutcome::TooLarge);
246    }
247    let mut body = vec![0u8; content_length];
248    if content_length > 0 {
249        // Read at most the cap even if the header under-declared (defence in
250        // depth): `body` is already capped, so `read_exact` cannot grow it.
251        reader.read_exact(&mut body)?;
252    }
253    let body = String::from_utf8_lossy(&body).into_owned();
254    let mut parts = request_line.split_whitespace();
255    let method = parts.next();
256    let target = parts.next();
257    match (method, target) {
258        (Some(method @ ("GET" | "POST")), Some(target)) => Ok(ReadOutcome::Request(RequestLine {
259            method: method.to_owned(),
260            target: target.to_owned(),
261            body,
262        })),
263        _ => Ok(ReadOutcome::Invalid),
264    }
265}
266
267/// Handle `POST /api/session/intent`: decode the Intent from the request body,
268/// submit it to the live session, and respond with the resulting Scene patches.
269/// Decode and validation failures respond with a structured error, never a
270/// panic.
271fn write_session_intent(
272    stream: &mut (impl Write + ?Sized),
273    request: &RequestLine,
274    live: &mut LiveSession,
275) -> std::io::Result<()> {
276    if request.method != "POST" {
277        return write_json(stream, 405, &error_json("intent route requires POST"));
278    }
279    let pane = query_value(&request.target, "pane").unwrap_or_else(|| DEFAULT_PANE.to_owned());
280    let intent = match decode_intent_body(&request.body) {
281        Ok(intent) => intent,
282        Err(err) => return write_json(stream, 400, &error_json(&err)),
283    };
284    match live.submit(&pane, &intent) {
285        Ok(updates) => write_json(stream, 200, &encode_patches(&updates)),
286        Err(err) => write_json(stream, 400, &error_json(&err.to_string())),
287    }
288}
289
290/// Handle `GET /api/session/open?resource=...&pane=...`: open the resource into
291/// the pane and respond with its initial Scene.
292fn write_session_open(
293    stream: &mut (impl Write + ?Sized),
294    request: &RequestLine,
295    live: &mut LiveSession,
296) -> std::io::Result<()> {
297    if request.method != "GET" {
298        return write_json(stream, 405, &error_json("open route requires GET"));
299    }
300    let resource =
301        query_value(&request.target, "resource").unwrap_or_else(|| DEFAULT_RESOURCE.to_owned());
302    let pane = query_value(&request.target, "pane").unwrap_or_else(|| DEFAULT_PANE.to_owned());
303    match live.open(&resource, &pane) {
304        Ok(scene) => write_json(stream, 200, &encode_scene(&scene)),
305        Err(err) => write_json(stream, 400, &error_json(&err.to_string())),
306    }
307}
308
309/// The path portion of a request target, with any query or fragment stripped.
310fn path_of(target: &str) -> &str {
311    target.split(['?', '#']).next().unwrap_or(target)
312}
313
314/// The first value of a query-string key in a request target, if present. Only a
315/// plain `key=value` split is performed; values are expected to be simple
316/// identifiers (pane and resource names).
317fn query_value(target: &str, key: &str) -> Option<String> {
318    let (_, query) = target.split_once('?')?;
319    query.split('&').find_map(|pair| {
320        let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
321        (name == key).then(|| value.to_owned())
322    })
323}
324
325/// Write a JSON body with the given status.
326fn write_json(stream: &mut (impl Write + ?Sized), status: u16, body: &str) -> std::io::Result<()> {
327    write_response(
328        stream,
329        status,
330        status_text(status),
331        "application/json; charset=utf-8",
332        body.as_bytes(),
333    )
334}
335
336fn write_cookbook_response(
337    stream: &mut (impl Write + ?Sized),
338    response: &CookbookWebResponse,
339) -> std::io::Result<()> {
340    write_response(
341        stream,
342        response.status,
343        status_text(response.status),
344        response.content_type,
345        response.body.as_bytes(),
346    )
347}
348
349fn write_response(
350    stream: &mut (impl Write + ?Sized),
351    status: u16,
352    reason: &str,
353    content_type: &str,
354    body: &[u8],
355) -> std::io::Result<()> {
356    let header = format!(
357        "HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
358        body.len()
359    );
360    stream.write_all(header.as_bytes())?;
361    stream.write_all(body)?;
362    stream.flush()
363}
364
365fn status_text(status: u16) -> &'static str {
366    match status {
367        200 => "OK",
368        201 => "Created",
369        204 => "No Content",
370        301 => "Moved Permanently",
371        302 => "Found",
372        304 => "Not Modified",
373        400 => "Bad Request",
374        401 => "Unauthorized",
375        403 => "Forbidden",
376        404 => "Not Found",
377        405 => "Method Not Allowed",
378        409 => "Conflict",
379        413 => "Payload Too Large",
380        422 => "Unprocessable Entity",
381        429 => "Too Many Requests",
382        500 => "Internal Server Error",
383        501 => "Not Implemented",
384        503 => "Service Unavailable",
385        // Fall back to the reason phrase for the status class rather than
386        // mislabeling every unlisted code as "OK".
387        other => match other / 100 {
388            1 => "Informational",
389            2 => "OK",
390            3 => "Redirection",
391            4 => "Client Error",
392            _ => "Internal Server Error",
393        },
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::{MAX_BODY_BYTES, ReadOutcome, read_request_from};
400    use std::io::{BufReader, Cursor};
401
402    fn parse(raw: &str) -> ReadOutcome {
403        let mut reader = BufReader::new(Cursor::new(raw.as_bytes().to_vec()));
404        read_request_from(&mut reader).expect("read")
405    }
406
407    #[test]
408    fn oversized_content_length_is_rejected_before_allocation() {
409        // A 4 GB declared body must be refused with 413, never allocated.
410        let raw = "POST /api/session/intent HTTP/1.1\r\nContent-Length: 4000000000\r\n\r\n";
411        assert!(
412            matches!(parse(raw), ReadOutcome::TooLarge),
413            "an oversized Content-Length must yield TooLarge (413)"
414        );
415    }
416
417    #[test]
418    fn content_length_at_the_cap_boundary_is_rejected_when_over() {
419        let over = MAX_BODY_BYTES + 1;
420        let raw = format!("POST /x HTTP/1.1\r\nContent-Length: {over}\r\n\r\n");
421        assert!(matches!(parse(&raw), ReadOutcome::TooLarge));
422    }
423
424    #[test]
425    fn a_small_body_within_the_cap_reads() {
426        let raw = "POST /x HTTP/1.1\r\nContent-Length: 5\r\n\r\nhello";
427        match parse(raw) {
428            ReadOutcome::Request(line) => {
429                assert_eq!(line.method, "POST");
430                assert_eq!(line.body, "hello");
431            }
432            other => panic!("expected a parsed request, got {other:?}"),
433        }
434    }
435}