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, EmbeddedDir};
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
109const FACADE_RECIPE_BOOKS: &[(&str, EmbeddedDir)] = &[
115 ("view", sim_lib_view::RECIPES),
116 ("web-bridge", sim_lib_web_bridge::RECIPES),
117];
118
119struct ShellState<'a> {
120 atelier: AtelierWebState,
121 cookbook: CookbookWebState,
122 cookbook_cx: &'a mut Cx,
123 live: LiveSession,
124}
125
126impl<'a> ShellState<'a> {
127 fn new(config: &ServeConfig, cx: &'a mut Cx) -> std::io::Result<Self> {
128 Ok(Self {
134 atelier: AtelierWebState::load(config.atelier_root.clone()),
135 cookbook: CookbookWebState::seeded_with_books(FACADE_RECIPE_BOOKS).map_err(io_error)?,
136 cookbook_cx: cx,
137 live: LiveSession::new().map_err(io_error)?,
138 })
139 }
140}
141
142fn install_codecs(cx: &mut Cx) -> SimResult<()> {
145 let json = JsonCodecLib::new(cx.registry_mut().fresh_codec_id());
146 cx.load_lib(&json)?;
147 let binary = BinaryCodecLib::new(cx.registry_mut().fresh_codec_id());
148 cx.load_lib(&binary)?;
149 let chat = ChatCodecLib::new(cx.registry_mut().fresh_codec_id());
150 cx.load_lib(&chat)?;
151 let algol = AlgolCodecLib::new(cx.registry_mut().fresh_codec_id());
152 cx.load_lib(&algol)?;
153 Ok(())
154}
155
156fn io_error(err: impl std::fmt::Display) -> std::io::Error {
157 std::io::Error::other(err.to_string())
158}
159
160fn handle(mut stream: TcpStream, state: &mut ShellState<'_>) -> std::io::Result<()> {
161 let _ = stream.set_read_timeout(Some(READ_TIMEOUT));
164 let request = match read_request(&mut stream)? {
165 ReadOutcome::Request(request) => request,
166 ReadOutcome::TooLarge => {
167 write_response(
168 &mut stream,
169 413,
170 "Payload Too Large",
171 "text/plain; charset=utf-8",
172 b"payload too large",
173 )?;
174 return Ok(());
175 }
176 ReadOutcome::Invalid => {
177 write_response(
178 &mut stream,
179 400,
180 "Bad Request",
181 "text/plain; charset=utf-8",
182 b"bad request",
183 )?;
184 return Ok(());
185 }
186 };
187 if path_of(&request.target) == "/api/session/intent" {
188 return write_session_intent(&mut stream, &request, &mut state.live);
189 }
190 if path_of(&request.target) == "/api/session/open" {
191 return write_session_open(&mut stream, &request, &mut state.live);
192 }
193 if request.target.starts_with("/api/cookbook") {
194 let response = state.cookbook.handle_request(
197 &request.method,
198 &request.target,
199 Some(&mut *state.cookbook_cx),
200 );
201 return write_cookbook_response(&mut stream, &response);
202 }
203 if let Some(response) = state.atelier.response(&request.method, &request.target) {
204 return write_response(
205 &mut stream,
206 response.status,
207 status_text(response.status),
208 response.content_type,
209 response.body.as_bytes(),
210 );
211 }
212 if request.method != "GET" {
213 write_response(
214 &mut stream,
215 405,
216 "Method Not Allowed",
217 "text/plain; charset=utf-8",
218 b"method not allowed",
219 )?;
220 return Ok(());
221 }
222 match asset_for(&request.target) {
223 Some(asset) => write_response(&mut stream, 200, "OK", asset.content_type, asset.body),
224 None => write_response(
225 &mut stream,
226 404,
227 "Not Found",
228 "text/plain; charset=utf-8",
229 b"not found",
230 ),
231 }
232}
233
234#[derive(Debug)]
235struct RequestLine {
236 method: String,
237 target: String,
238 body: String,
239}
240
241#[derive(Debug)]
244enum ReadOutcome {
245 Request(RequestLine),
246 TooLarge,
247 Invalid,
248}
249
250fn read_request(stream: &mut TcpStream) -> std::io::Result<ReadOutcome> {
252 let mut reader = BufReader::new(stream);
253 read_request_from(&mut reader)
254}
255
256fn read_request_from(reader: &mut impl BufRead) -> std::io::Result<ReadOutcome> {
261 let mut request_line = String::new();
262 match read_capped_line(reader, &mut request_line, MAX_HEAD_LINE_BYTES)? {
263 CapOutcome::TooLarge => return Ok(ReadOutcome::TooLarge),
265 CapOutcome::Eof => return Ok(ReadOutcome::Invalid),
266 CapOutcome::Line => {}
267 }
268 let mut content_length = 0usize;
273 let mut header = String::new();
274 let mut header_count = 0usize;
275 loop {
276 header_count += 1;
277 if header_count > MAX_HEADER_COUNT {
278 return Ok(ReadOutcome::TooLarge);
279 }
280 match read_capped_line(reader, &mut header, MAX_HEAD_LINE_BYTES)? {
281 CapOutcome::TooLarge => return Ok(ReadOutcome::TooLarge),
282 CapOutcome::Eof => break,
283 CapOutcome::Line => {}
284 }
285 if header == "\r\n" || header == "\n" {
286 break;
287 }
288 if let Some((name, value)) = header.split_once(':')
289 && name.trim().eq_ignore_ascii_case("content-length")
290 {
291 content_length = value.trim().parse().unwrap_or(0);
292 }
293 }
294 if content_length > MAX_BODY_BYTES {
296 return Ok(ReadOutcome::TooLarge);
297 }
298 let mut body = vec![0u8; content_length];
299 if content_length > 0 {
300 reader.read_exact(&mut body)?;
303 }
304 let body = String::from_utf8_lossy(&body).into_owned();
305 let mut parts = request_line.split_whitespace();
306 let method = parts.next();
307 let target = parts.next();
308 match (method, target) {
309 (Some(method @ ("GET" | "POST")), Some(target)) => Ok(ReadOutcome::Request(RequestLine {
310 method: method.to_owned(),
311 target: target.to_owned(),
312 body,
313 })),
314 _ => Ok(ReadOutcome::Invalid),
315 }
316}
317
318fn write_session_intent(
323 stream: &mut (impl Write + ?Sized),
324 request: &RequestLine,
325 live: &mut LiveSession,
326) -> std::io::Result<()> {
327 if request.method != "POST" {
328 return write_json(stream, 405, &error_json("intent route requires POST"));
329 }
330 let pane = query_value(&request.target, "pane").unwrap_or_else(|| DEFAULT_PANE.to_owned());
331 let intent = match decode_intent_body(&request.body) {
332 Ok(intent) => intent,
333 Err(err) => return write_json(stream, 400, &error_json(&err)),
334 };
335 match live.submit(&pane, &intent) {
336 Ok(updates) => write_json(stream, 200, &encode_patches(&updates)),
337 Err(err) => write_json(stream, 400, &error_json(&err.to_string())),
338 }
339}
340
341fn write_session_open(
344 stream: &mut (impl Write + ?Sized),
345 request: &RequestLine,
346 live: &mut LiveSession,
347) -> std::io::Result<()> {
348 if request.method != "GET" {
349 return write_json(stream, 405, &error_json("open route requires GET"));
350 }
351 let resource =
352 query_value(&request.target, "resource").unwrap_or_else(|| DEFAULT_RESOURCE.to_owned());
353 let pane = query_value(&request.target, "pane").unwrap_or_else(|| DEFAULT_PANE.to_owned());
354 match live.open(&resource, &pane) {
355 Ok(scene) => write_json(stream, 200, &encode_scene(&scene)),
356 Err(err) => write_json(stream, 400, &error_json(&err.to_string())),
357 }
358}
359
360fn path_of(target: &str) -> &str {
362 target.split(['?', '#']).next().unwrap_or(target)
363}
364
365fn query_value(target: &str, key: &str) -> Option<String> {
374 let (_, query) = target.split_once('?')?;
375 query.split('&').find_map(|pair| {
376 let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
377 (name == key).then(|| value.to_owned())
378 })
379}
380
381fn write_json(stream: &mut (impl Write + ?Sized), status: u16, body: &str) -> std::io::Result<()> {
383 write_response(
384 stream,
385 status,
386 status_text(status),
387 "application/json; charset=utf-8",
388 body.as_bytes(),
389 )
390}
391
392fn write_cookbook_response(
393 stream: &mut (impl Write + ?Sized),
394 response: &CookbookWebResponse,
395) -> std::io::Result<()> {
396 write_response(
397 stream,
398 response.status,
399 status_text(response.status),
400 response.content_type,
401 response.body.as_bytes(),
402 )
403}
404
405fn write_response(
406 stream: &mut (impl Write + ?Sized),
407 status: u16,
408 reason: &str,
409 content_type: &str,
410 body: &[u8],
411) -> std::io::Result<()> {
412 let header = format!(
413 "HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
414 body.len()
415 );
416 stream.write_all(header.as_bytes())?;
417 stream.write_all(body)?;
418 stream.flush()
419}
420
421fn status_text(status: u16) -> &'static str {
422 match status {
423 200 => "OK",
424 201 => "Created",
425 204 => "No Content",
426 301 => "Moved Permanently",
427 302 => "Found",
428 304 => "Not Modified",
429 400 => "Bad Request",
430 401 => "Unauthorized",
431 403 => "Forbidden",
432 404 => "Not Found",
433 405 => "Method Not Allowed",
434 409 => "Conflict",
435 413 => "Payload Too Large",
436 422 => "Unprocessable Entity",
437 429 => "Too Many Requests",
438 500 => "Internal Server Error",
439 501 => "Not Implemented",
440 503 => "Service Unavailable",
441 other => match other / 100 {
444 1 => "Informational",
445 2 => "OK",
446 3 => "Redirection",
447 4 => "Client Error",
448 _ => "Internal Server Error",
449 },
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use super::{
456 MAX_BODY_BYTES, MAX_HEAD_LINE_BYTES, MAX_HEADER_COUNT, ReadOutcome, read_request_from,
457 };
458 use std::io::{BufReader, Cursor};
459
460 fn parse(raw: &str) -> ReadOutcome {
461 let mut reader = BufReader::new(Cursor::new(raw.as_bytes().to_vec()));
462 read_request_from(&mut reader).expect("read")
463 }
464
465 #[test]
466 fn oversized_content_length_is_rejected_before_allocation() {
467 let raw = "POST /api/session/intent HTTP/1.1\r\nContent-Length: 4000000000\r\n\r\n";
469 assert!(
470 matches!(parse(raw), ReadOutcome::TooLarge),
471 "an oversized Content-Length must yield TooLarge (413)"
472 );
473 }
474
475 #[test]
476 fn content_length_at_the_cap_boundary_is_rejected_when_over() {
477 let over = MAX_BODY_BYTES + 1;
478 let raw = format!("POST /x HTTP/1.1\r\nContent-Length: {over}\r\n\r\n");
479 assert!(matches!(parse(&raw), ReadOutcome::TooLarge));
480 }
481
482 #[test]
483 fn an_oversized_request_line_is_rejected_before_growing_memory() {
484 let mut raw = String::from("GET /");
487 raw.push_str(&"a".repeat(MAX_HEAD_LINE_BYTES + 16));
488 raw.push_str(" HTTP/1.1\r\n\r\n");
489 assert!(
490 matches!(parse(&raw), ReadOutcome::TooLarge),
491 "an oversized request line must yield TooLarge (413)"
492 );
493 }
494
495 #[test]
496 fn an_oversized_header_line_is_rejected_before_growing_memory() {
497 let mut raw = String::from("GET /x HTTP/1.1\r\nX-Big: ");
498 raw.push_str(&"a".repeat(MAX_HEAD_LINE_BYTES + 16));
499 raw.push_str("\r\n\r\n");
500 assert!(
501 matches!(parse(&raw), ReadOutcome::TooLarge),
502 "an oversized header line must yield TooLarge (413)"
503 );
504 }
505
506 #[test]
507 fn too_many_header_lines_are_rejected() {
508 let mut raw = String::from("GET /x HTTP/1.1\r\n");
509 for _ in 0..(MAX_HEADER_COUNT + 8) {
510 raw.push_str("X-Pad: 1\r\n");
511 }
512 raw.push_str("\r\n");
513 assert!(
514 matches!(parse(&raw), ReadOutcome::TooLarge),
515 "an endless header block must yield TooLarge (413)"
516 );
517 }
518
519 #[test]
520 fn empty_input_is_invalid_not_a_panic() {
521 assert!(
524 matches!(parse(""), ReadOutcome::Invalid),
525 "an empty request must yield Invalid (400)"
526 );
527 }
528
529 #[test]
530 fn a_small_body_within_the_cap_reads() {
531 let raw = "POST /x HTTP/1.1\r\nContent-Length: 5\r\n\r\nhello";
532 match parse(raw) {
533 ReadOutcome::Request(line) => {
534 assert_eq!(line.method, "POST");
535 assert_eq!(line.body, "hello");
536 }
537 other => panic!("expected a parsed request, got {other:?}"),
538 }
539 }
540}