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