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