1use std::io::{BufRead, BufReader, Write};
8use std::net::{TcpListener, TcpStream, ToSocketAddrs};
9use std::path::PathBuf;
10use std::time::Duration;
11
12const MAX_BODY_BYTES: usize = 1 << 20; const MAX_HEAD_LINE_BYTES: usize = 64 * 1024;
22
23const MAX_HEADER_COUNT: usize = 256;
26
27const READ_TIMEOUT: Duration = Duration::from_secs(30);
30
31use crate::assets::asset_for;
32use crate::atelier::AtelierWebState;
33use crate::live::{
34 DEFAULT_PANE, DEFAULT_RESOURCE, LiveSession, decode_intent_body, encode_patches, encode_scene,
35 error_json,
36};
37use sim_codec_algol::AlgolCodecLib;
38use sim_codec_binary::BinaryCodecLib;
39use sim_codec_chat::ChatCodecLib;
40use sim_codec_json::JsonCodecLib;
41use sim_kernel::{Cx, Result as SimResult};
42use sim_lib_net_core::{CapOutcome, read_capped_line};
43use sim_lib_server::{CookbookWebResponse, CookbookWebState};
44use sim_lib_stream_core::install_stream_core_shapes_lib;
45
46#[derive(Debug)]
48pub struct ServeConfig {
49 pub addr: String,
51 pub atelier_root: PathBuf,
53 pub dry_run: bool,
56}
57
58impl Default for ServeConfig {
59 fn default() -> Self {
60 Self {
61 addr: "127.0.0.1:8787".to_owned(),
62 atelier_root: PathBuf::from(".sim/atelier"),
63 dry_run: false,
64 }
65 }
66}
67
68pub fn serve_with_cx(cx: &mut Cx, config: &ServeConfig) -> std::io::Result<()> {
77 install_codecs(cx).map_err(io_error)?;
78 install_stream_core_shapes_lib(cx).map_err(io_error)?;
79
80 if config.dry_run {
81 println!("sim-web-shell: dry-run OK");
82 return Ok(());
83 }
84
85 let listener = bind(&config.addr)?;
86 let local = listener.local_addr()?;
87 let mut state = ShellState::new(config, cx)?;
88 println!("sim-web-shell: serving shell on http://{local}");
89 for stream in listener.incoming() {
90 match stream {
91 Ok(stream) => {
92 if let Err(err) = handle(stream, &mut state) {
93 eprintln!("sim-web-shell: connection error: {err}");
94 }
95 }
96 Err(err) => eprintln!("sim-web-shell: accept error: {err}"),
97 }
98 }
99 Ok(())
100}
101
102fn bind(addr: &str) -> std::io::Result<TcpListener> {
103 let resolved = addr.to_socket_addrs()?.next().ok_or_else(|| {
104 std::io::Error::new(std::io::ErrorKind::InvalidInput, "no socket address")
105 })?;
106 TcpListener::bind(resolved)
107}
108
109struct ShellState<'a> {
110 atelier: AtelierWebState,
111 cookbook: CookbookWebState,
112 cookbook_cx: &'a mut Cx,
113 live: LiveSession,
114}
115
116impl<'a> ShellState<'a> {
117 fn new(config: &ServeConfig, cx: &'a mut Cx) -> std::io::Result<Self> {
118 Ok(Self {
124 atelier: AtelierWebState::load(config.atelier_root.clone()),
125 cookbook: CookbookWebState::seeded().map_err(io_error)?,
126 cookbook_cx: cx,
127 live: LiveSession::new().map_err(io_error)?,
128 })
129 }
130}
131
132fn install_codecs(cx: &mut Cx) -> SimResult<()> {
135 let json = JsonCodecLib::new(cx.registry_mut().fresh_codec_id());
136 cx.load_lib(&json)?;
137 let binary = BinaryCodecLib::new(cx.registry_mut().fresh_codec_id());
138 cx.load_lib(&binary)?;
139 let chat = ChatCodecLib::new(cx.registry_mut().fresh_codec_id());
140 cx.load_lib(&chat)?;
141 let algol = AlgolCodecLib::new(cx.registry_mut().fresh_codec_id());
142 cx.load_lib(&algol)?;
143 Ok(())
144}
145
146fn io_error(err: impl std::fmt::Display) -> std::io::Error {
147 std::io::Error::other(err.to_string())
148}
149
150fn handle(mut stream: TcpStream, state: &mut ShellState<'_>) -> std::io::Result<()> {
151 let _ = stream.set_read_timeout(Some(READ_TIMEOUT));
154 let request = match read_request(&mut stream)? {
155 ReadOutcome::Request(request) => request,
156 ReadOutcome::TooLarge => {
157 write_response(
158 &mut stream,
159 413,
160 "Payload Too Large",
161 "text/plain; charset=utf-8",
162 b"payload too large",
163 )?;
164 return Ok(());
165 }
166 ReadOutcome::Invalid => {
167 write_response(
168 &mut stream,
169 400,
170 "Bad Request",
171 "text/plain; charset=utf-8",
172 b"bad request",
173 )?;
174 return Ok(());
175 }
176 };
177 if path_of(&request.target) == "/api/session/intent" {
178 return write_session_intent(&mut stream, &request, &mut state.live);
179 }
180 if path_of(&request.target) == "/api/session/open" {
181 return write_session_open(&mut stream, &request, &mut state.live);
182 }
183 if request.target.starts_with("/api/cookbook") {
184 let response = state.cookbook.handle_request(
187 &request.method,
188 &request.target,
189 Some(&mut *state.cookbook_cx),
190 );
191 return write_cookbook_response(&mut stream, &response);
192 }
193 if let Some(response) = state.atelier.response(&request.method, &request.target) {
194 return write_response(
195 &mut stream,
196 response.status,
197 status_text(response.status),
198 response.content_type,
199 response.body.as_bytes(),
200 );
201 }
202 if request.method != "GET" {
203 write_response(
204 &mut stream,
205 405,
206 "Method Not Allowed",
207 "text/plain; charset=utf-8",
208 b"method not allowed",
209 )?;
210 return Ok(());
211 }
212 match asset_for(&request.target) {
213 Some(asset) => write_response(&mut stream, 200, "OK", asset.content_type, asset.body),
214 None => write_response(
215 &mut stream,
216 404,
217 "Not Found",
218 "text/plain; charset=utf-8",
219 b"not found",
220 ),
221 }
222}
223
224#[derive(Debug)]
225struct RequestLine {
226 method: String,
227 target: String,
228 body: String,
229}
230
231#[derive(Debug)]
234enum ReadOutcome {
235 Request(RequestLine),
236 TooLarge,
237 Invalid,
238}
239
240fn read_request(stream: &mut TcpStream) -> std::io::Result<ReadOutcome> {
242 let mut reader = BufReader::new(stream);
243 read_request_from(&mut reader)
244}
245
246fn read_request_from(reader: &mut impl BufRead) -> std::io::Result<ReadOutcome> {
251 let mut request_line = String::new();
252 match read_capped_line(reader, &mut request_line, MAX_HEAD_LINE_BYTES)? {
253 CapOutcome::TooLarge => return Ok(ReadOutcome::TooLarge),
255 CapOutcome::Eof => return Ok(ReadOutcome::Invalid),
256 CapOutcome::Line => {}
257 }
258 let mut content_length = 0usize;
263 let mut header = String::new();
264 let mut header_count = 0usize;
265 loop {
266 header_count += 1;
267 if header_count > MAX_HEADER_COUNT {
268 return Ok(ReadOutcome::TooLarge);
269 }
270 match read_capped_line(reader, &mut header, MAX_HEAD_LINE_BYTES)? {
271 CapOutcome::TooLarge => return Ok(ReadOutcome::TooLarge),
272 CapOutcome::Eof => break,
273 CapOutcome::Line => {}
274 }
275 if header == "\r\n" || header == "\n" {
276 break;
277 }
278 if let Some((name, value)) = header.split_once(':')
279 && name.trim().eq_ignore_ascii_case("content-length")
280 {
281 content_length = value.trim().parse().unwrap_or(0);
282 }
283 }
284 if content_length > MAX_BODY_BYTES {
286 return Ok(ReadOutcome::TooLarge);
287 }
288 let mut body = vec![0u8; content_length];
289 if content_length > 0 {
290 reader.read_exact(&mut body)?;
293 }
294 let body = String::from_utf8_lossy(&body).into_owned();
295 let mut parts = request_line.split_whitespace();
296 let method = parts.next();
297 let target = parts.next();
298 match (method, target) {
299 (Some(method @ ("GET" | "POST")), Some(target)) => Ok(ReadOutcome::Request(RequestLine {
300 method: method.to_owned(),
301 target: target.to_owned(),
302 body,
303 })),
304 _ => Ok(ReadOutcome::Invalid),
305 }
306}
307
308fn write_session_intent(
313 stream: &mut (impl Write + ?Sized),
314 request: &RequestLine,
315 live: &mut LiveSession,
316) -> std::io::Result<()> {
317 if request.method != "POST" {
318 return write_json(stream, 405, &error_json("intent route requires POST"));
319 }
320 let pane = query_value(&request.target, "pane").unwrap_or_else(|| DEFAULT_PANE.to_owned());
321 let intent = match decode_intent_body(&request.body) {
322 Ok(intent) => intent,
323 Err(err) => return write_json(stream, 400, &error_json(&err)),
324 };
325 match live.submit(&pane, &intent) {
326 Ok(updates) => write_json(stream, 200, &encode_patches(&updates)),
327 Err(err) => write_json(stream, 400, &error_json(&err.to_string())),
328 }
329}
330
331fn write_session_open(
334 stream: &mut (impl Write + ?Sized),
335 request: &RequestLine,
336 live: &mut LiveSession,
337) -> std::io::Result<()> {
338 if request.method != "GET" {
339 return write_json(stream, 405, &error_json("open route requires GET"));
340 }
341 let resource =
342 query_value(&request.target, "resource").unwrap_or_else(|| DEFAULT_RESOURCE.to_owned());
343 let pane = query_value(&request.target, "pane").unwrap_or_else(|| DEFAULT_PANE.to_owned());
344 match live.open(&resource, &pane) {
345 Ok(scene) => write_json(stream, 200, &encode_scene(&scene)),
346 Err(err) => write_json(stream, 400, &error_json(&err.to_string())),
347 }
348}
349
350fn path_of(target: &str) -> &str {
352 target.split(['?', '#']).next().unwrap_or(target)
353}
354
355fn query_value(target: &str, key: &str) -> Option<String> {
364 let (_, query) = target.split_once('?')?;
365 query.split('&').find_map(|pair| {
366 let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
367 (name == key).then(|| value.to_owned())
368 })
369}
370
371fn write_json(stream: &mut (impl Write + ?Sized), status: u16, body: &str) -> std::io::Result<()> {
373 write_response(
374 stream,
375 status,
376 status_text(status),
377 "application/json; charset=utf-8",
378 body.as_bytes(),
379 )
380}
381
382fn write_cookbook_response(
383 stream: &mut (impl Write + ?Sized),
384 response: &CookbookWebResponse,
385) -> std::io::Result<()> {
386 write_response(
387 stream,
388 response.status,
389 status_text(response.status),
390 response.content_type,
391 response.body.as_bytes(),
392 )
393}
394
395fn write_response(
396 stream: &mut (impl Write + ?Sized),
397 status: u16,
398 reason: &str,
399 content_type: &str,
400 body: &[u8],
401) -> std::io::Result<()> {
402 let header = format!(
403 "HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
404 body.len()
405 );
406 stream.write_all(header.as_bytes())?;
407 stream.write_all(body)?;
408 stream.flush()
409}
410
411fn status_text(status: u16) -> &'static str {
412 match status {
413 200 => "OK",
414 201 => "Created",
415 204 => "No Content",
416 301 => "Moved Permanently",
417 302 => "Found",
418 304 => "Not Modified",
419 400 => "Bad Request",
420 401 => "Unauthorized",
421 403 => "Forbidden",
422 404 => "Not Found",
423 405 => "Method Not Allowed",
424 409 => "Conflict",
425 413 => "Payload Too Large",
426 422 => "Unprocessable Entity",
427 429 => "Too Many Requests",
428 500 => "Internal Server Error",
429 501 => "Not Implemented",
430 503 => "Service Unavailable",
431 other => match other / 100 {
434 1 => "Informational",
435 2 => "OK",
436 3 => "Redirection",
437 4 => "Client Error",
438 _ => "Internal Server Error",
439 },
440 }
441}
442
443#[cfg(test)]
444mod tests {
445 use super::{
446 MAX_BODY_BYTES, MAX_HEAD_LINE_BYTES, MAX_HEADER_COUNT, ReadOutcome, read_request_from,
447 };
448 use std::io::{BufReader, Cursor};
449
450 fn parse(raw: &str) -> ReadOutcome {
451 let mut reader = BufReader::new(Cursor::new(raw.as_bytes().to_vec()));
452 read_request_from(&mut reader).expect("read")
453 }
454
455 #[test]
456 fn oversized_content_length_is_rejected_before_allocation() {
457 let raw = "POST /api/session/intent HTTP/1.1\r\nContent-Length: 4000000000\r\n\r\n";
459 assert!(
460 matches!(parse(raw), ReadOutcome::TooLarge),
461 "an oversized Content-Length must yield TooLarge (413)"
462 );
463 }
464
465 #[test]
466 fn content_length_at_the_cap_boundary_is_rejected_when_over() {
467 let over = MAX_BODY_BYTES + 1;
468 let raw = format!("POST /x HTTP/1.1\r\nContent-Length: {over}\r\n\r\n");
469 assert!(matches!(parse(&raw), ReadOutcome::TooLarge));
470 }
471
472 #[test]
473 fn an_oversized_request_line_is_rejected_before_growing_memory() {
474 let mut raw = String::from("GET /");
477 raw.push_str(&"a".repeat(MAX_HEAD_LINE_BYTES + 16));
478 raw.push_str(" HTTP/1.1\r\n\r\n");
479 assert!(
480 matches!(parse(&raw), ReadOutcome::TooLarge),
481 "an oversized request line must yield TooLarge (413)"
482 );
483 }
484
485 #[test]
486 fn an_oversized_header_line_is_rejected_before_growing_memory() {
487 let mut raw = String::from("GET /x HTTP/1.1\r\nX-Big: ");
488 raw.push_str(&"a".repeat(MAX_HEAD_LINE_BYTES + 16));
489 raw.push_str("\r\n\r\n");
490 assert!(
491 matches!(parse(&raw), ReadOutcome::TooLarge),
492 "an oversized header line must yield TooLarge (413)"
493 );
494 }
495
496 #[test]
497 fn too_many_header_lines_are_rejected() {
498 let mut raw = String::from("GET /x HTTP/1.1\r\n");
499 for _ in 0..(MAX_HEADER_COUNT + 8) {
500 raw.push_str("X-Pad: 1\r\n");
501 }
502 raw.push_str("\r\n");
503 assert!(
504 matches!(parse(&raw), ReadOutcome::TooLarge),
505 "an endless header block must yield TooLarge (413)"
506 );
507 }
508
509 #[test]
510 fn empty_input_is_invalid_not_a_panic() {
511 assert!(
514 matches!(parse(""), ReadOutcome::Invalid),
515 "an empty request must yield Invalid (400)"
516 );
517 }
518
519 #[test]
520 fn a_small_body_within_the_cap_reads() {
521 let raw = "POST /x HTTP/1.1\r\nContent-Length: 5\r\n\r\nhello";
522 match parse(raw) {
523 ReadOutcome::Request(line) => {
524 assert_eq!(line.method, "POST");
525 assert_eq!(line.body, "hello");
526 }
527 other => panic!("expected a parsed request, got {other:?}"),
528 }
529 }
530}