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 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
35pub struct ServeConfig {
37 pub addr: String,
39 pub atelier_root: PathBuf,
41 pub dry_run: bool,
44}
45
46impl Default for ServeConfig {
47 fn default() -> Self {
48 Self {
49 addr: "127.0.0.1:8787".to_owned(),
50 atelier_root: PathBuf::from(".sim/atelier"),
51 dry_run: false,
52 }
53 }
54}
55
56pub fn serve_with_cx(cx: &mut Cx, config: &ServeConfig) -> std::io::Result<()> {
62 cx.grant(read_eval_capability());
63 install_codecs(cx).map_err(io_error)?;
64 install_stream_core_shapes_lib(cx).map_err(io_error)?;
65
66 if config.dry_run {
67 println!("sim-web-shell: dry-run OK");
68 return Ok(());
69 }
70
71 let listener = bind(&config.addr)?;
72 let local = listener.local_addr()?;
73 let mut state = ShellState::new(config, cx)?;
74 println!("sim-web-shell: serving shell on http://{local}");
75 for stream in listener.incoming() {
76 match stream {
77 Ok(stream) => {
78 if let Err(err) = handle(stream, &mut state) {
79 eprintln!("sim-web-shell: connection error: {err}");
80 }
81 }
82 Err(err) => eprintln!("sim-web-shell: accept error: {err}"),
83 }
84 }
85 Ok(())
86}
87
88fn bind(addr: &str) -> std::io::Result<TcpListener> {
89 let resolved = addr.to_socket_addrs()?.next().ok_or_else(|| {
90 std::io::Error::new(std::io::ErrorKind::InvalidInput, "no socket address")
91 })?;
92 TcpListener::bind(resolved)
93}
94
95struct ShellState<'a> {
96 atelier: AtelierWebState,
97 cookbook: CookbookWebState,
98 cookbook_cx: &'a mut Cx,
99 live: LiveSession,
100}
101
102impl<'a> ShellState<'a> {
103 fn new(config: &ServeConfig, cx: &'a mut Cx) -> std::io::Result<Self> {
104 Ok(Self {
105 atelier: AtelierWebState::load(config.atelier_root.clone()),
106 cookbook: CookbookWebState::seeded().map_err(io_error)?,
107 cookbook_cx: cx,
108 live: LiveSession::new().map_err(io_error)?,
109 })
110 }
111}
112
113fn install_codecs(cx: &mut Cx) -> SimResult<()> {
116 let json = JsonCodecLib::new(cx.registry_mut().fresh_codec_id());
117 cx.load_lib(&json)?;
118 let binary = BinaryCodecLib::new(cx.registry_mut().fresh_codec_id());
119 cx.load_lib(&binary)?;
120 let chat = ChatCodecLib::new(cx.registry_mut().fresh_codec_id());
121 cx.load_lib(&chat)?;
122 let algol = AlgolCodecLib::new(cx.registry_mut().fresh_codec_id());
123 cx.load_lib(&algol)?;
124 Ok(())
125}
126
127fn io_error(err: impl std::fmt::Display) -> std::io::Error {
128 std::io::Error::other(err.to_string())
129}
130
131fn handle(mut stream: TcpStream, state: &mut ShellState<'_>) -> std::io::Result<()> {
132 let _ = stream.set_read_timeout(Some(READ_TIMEOUT));
135 let request = match read_request(&mut stream)? {
136 ReadOutcome::Request(request) => request,
137 ReadOutcome::TooLarge => {
138 write_response(
139 &mut stream,
140 413,
141 "Payload Too Large",
142 "text/plain; charset=utf-8",
143 b"payload too large",
144 )?;
145 return Ok(());
146 }
147 ReadOutcome::Invalid => {
148 write_response(
149 &mut stream,
150 400,
151 "Bad Request",
152 "text/plain; charset=utf-8",
153 b"bad request",
154 )?;
155 return Ok(());
156 }
157 };
158 if path_of(&request.target) == "/api/session/intent" {
159 return write_session_intent(&mut stream, &request, &mut state.live);
160 }
161 if path_of(&request.target) == "/api/session/open" {
162 return write_session_open(&mut stream, &request, &mut state.live);
163 }
164 if request.target.starts_with("/api/cookbook") {
165 let response = state.cookbook.handle_request(
166 &request.method,
167 &request.target,
168 Some(&mut *state.cookbook_cx),
169 );
170 return write_cookbook_response(&mut stream, &response);
171 }
172 if let Some(response) = state.atelier.response(&request.method, &request.target) {
173 return write_response(
174 &mut stream,
175 response.status,
176 status_text(response.status),
177 response.content_type,
178 response.body.as_bytes(),
179 );
180 }
181 if request.method != "GET" {
182 write_response(
183 &mut stream,
184 405,
185 "Method Not Allowed",
186 "text/plain; charset=utf-8",
187 b"method not allowed",
188 )?;
189 return Ok(());
190 }
191 match asset_for(&request.target) {
192 Some(asset) => write_response(&mut stream, 200, "OK", asset.content_type, asset.body),
193 None => write_response(
194 &mut stream,
195 404,
196 "Not Found",
197 "text/plain; charset=utf-8",
198 b"not found",
199 ),
200 }
201}
202
203#[derive(Debug)]
204struct RequestLine {
205 method: String,
206 target: String,
207 body: String,
208}
209
210#[derive(Debug)]
213enum ReadOutcome {
214 Request(RequestLine),
215 TooLarge,
216 Invalid,
217}
218
219fn read_request(stream: &mut TcpStream) -> std::io::Result<ReadOutcome> {
221 let mut reader = BufReader::new(stream);
222 read_request_from(&mut reader)
223}
224
225fn read_request_from(reader: &mut impl BufRead) -> std::io::Result<ReadOutcome> {
230 let mut request_line = String::new();
231 if reader.read_line(&mut request_line)? == 0 {
232 return Ok(ReadOutcome::Invalid);
233 }
234 let mut content_length = 0usize;
237 let mut header = String::new();
238 loop {
239 header.clear();
240 let read = reader.read_line(&mut header)?;
241 if read == 0 || header == "\r\n" || header == "\n" {
242 break;
243 }
244 if let Some((name, value)) = header.split_once(':')
245 && name.trim().eq_ignore_ascii_case("content-length")
246 {
247 content_length = value.trim().parse().unwrap_or(0);
248 }
249 }
250 if content_length > MAX_BODY_BYTES {
252 return Ok(ReadOutcome::TooLarge);
253 }
254 let mut body = vec![0u8; content_length];
255 if content_length > 0 {
256 reader.read_exact(&mut body)?;
259 }
260 let body = String::from_utf8_lossy(&body).into_owned();
261 let mut parts = request_line.split_whitespace();
262 let method = parts.next();
263 let target = parts.next();
264 match (method, target) {
265 (Some(method @ ("GET" | "POST")), Some(target)) => Ok(ReadOutcome::Request(RequestLine {
266 method: method.to_owned(),
267 target: target.to_owned(),
268 body,
269 })),
270 _ => Ok(ReadOutcome::Invalid),
271 }
272}
273
274fn write_session_intent(
279 stream: &mut (impl Write + ?Sized),
280 request: &RequestLine,
281 live: &mut LiveSession,
282) -> std::io::Result<()> {
283 if request.method != "POST" {
284 return write_json(stream, 405, &error_json("intent route requires POST"));
285 }
286 let pane = query_value(&request.target, "pane").unwrap_or_else(|| DEFAULT_PANE.to_owned());
287 let intent = match decode_intent_body(&request.body) {
288 Ok(intent) => intent,
289 Err(err) => return write_json(stream, 400, &error_json(&err)),
290 };
291 match live.submit(&pane, &intent) {
292 Ok(updates) => write_json(stream, 200, &encode_patches(&updates)),
293 Err(err) => write_json(stream, 400, &error_json(&err.to_string())),
294 }
295}
296
297fn write_session_open(
300 stream: &mut (impl Write + ?Sized),
301 request: &RequestLine,
302 live: &mut LiveSession,
303) -> std::io::Result<()> {
304 if request.method != "GET" {
305 return write_json(stream, 405, &error_json("open route requires GET"));
306 }
307 let resource =
308 query_value(&request.target, "resource").unwrap_or_else(|| DEFAULT_RESOURCE.to_owned());
309 let pane = query_value(&request.target, "pane").unwrap_or_else(|| DEFAULT_PANE.to_owned());
310 match live.open(&resource, &pane) {
311 Ok(scene) => write_json(stream, 200, &encode_scene(&scene)),
312 Err(err) => write_json(stream, 400, &error_json(&err.to_string())),
313 }
314}
315
316fn path_of(target: &str) -> &str {
318 target.split(['?', '#']).next().unwrap_or(target)
319}
320
321fn query_value(target: &str, key: &str) -> Option<String> {
325 let (_, query) = target.split_once('?')?;
326 query.split('&').find_map(|pair| {
327 let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
328 (name == key).then(|| value.to_owned())
329 })
330}
331
332fn write_json(stream: &mut (impl Write + ?Sized), status: u16, body: &str) -> std::io::Result<()> {
334 write_response(
335 stream,
336 status,
337 status_text(status),
338 "application/json; charset=utf-8",
339 body.as_bytes(),
340 )
341}
342
343fn write_cookbook_response(
344 stream: &mut (impl Write + ?Sized),
345 response: &CookbookWebResponse,
346) -> std::io::Result<()> {
347 write_response(
348 stream,
349 response.status,
350 status_text(response.status),
351 response.content_type,
352 response.body.as_bytes(),
353 )
354}
355
356fn write_response(
357 stream: &mut (impl Write + ?Sized),
358 status: u16,
359 reason: &str,
360 content_type: &str,
361 body: &[u8],
362) -> std::io::Result<()> {
363 let header = format!(
364 "HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
365 body.len()
366 );
367 stream.write_all(header.as_bytes())?;
368 stream.write_all(body)?;
369 stream.flush()
370}
371
372fn status_text(status: u16) -> &'static str {
373 match status {
374 200 => "OK",
375 201 => "Created",
376 204 => "No Content",
377 301 => "Moved Permanently",
378 302 => "Found",
379 304 => "Not Modified",
380 400 => "Bad Request",
381 401 => "Unauthorized",
382 403 => "Forbidden",
383 404 => "Not Found",
384 405 => "Method Not Allowed",
385 409 => "Conflict",
386 413 => "Payload Too Large",
387 422 => "Unprocessable Entity",
388 429 => "Too Many Requests",
389 500 => "Internal Server Error",
390 501 => "Not Implemented",
391 503 => "Service Unavailable",
392 other => match other / 100 {
395 1 => "Informational",
396 2 => "OK",
397 3 => "Redirection",
398 4 => "Client Error",
399 _ => "Internal Server Error",
400 },
401 }
402}
403
404#[cfg(test)]
405mod tests {
406 use super::{MAX_BODY_BYTES, ReadOutcome, read_request_from};
407 use std::io::{BufReader, Cursor};
408
409 fn parse(raw: &str) -> ReadOutcome {
410 let mut reader = BufReader::new(Cursor::new(raw.as_bytes().to_vec()));
411 read_request_from(&mut reader).expect("read")
412 }
413
414 #[test]
415 fn oversized_content_length_is_rejected_before_allocation() {
416 let raw = "POST /api/session/intent HTTP/1.1\r\nContent-Length: 4000000000\r\n\r\n";
418 assert!(
419 matches!(parse(raw), ReadOutcome::TooLarge),
420 "an oversized Content-Length must yield TooLarge (413)"
421 );
422 }
423
424 #[test]
425 fn content_length_at_the_cap_boundary_is_rejected_when_over() {
426 let over = MAX_BODY_BYTES + 1;
427 let raw = format!("POST /x HTTP/1.1\r\nContent-Length: {over}\r\n\r\n");
428 assert!(matches!(parse(&raw), ReadOutcome::TooLarge));
429 }
430
431 #[test]
432 fn a_small_body_within_the_cap_reads() {
433 let raw = "POST /x HTTP/1.1\r\nContent-Length: 5\r\n\r\nhello";
434 match parse(raw) {
435 ReadOutcome::Request(line) => {
436 assert_eq!(line.method, "POST");
437 assert_eq!(line.body, "hello");
438 }
439 other => panic!("expected a parsed request, got {other:?}"),
440 }
441 }
442}