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}
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
52pub 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
104fn 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 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#[derive(Debug)]
204enum ReadOutcome {
205 Request(RequestLine),
206 TooLarge,
207 Invalid,
208}
209
210fn read_request(stream: &mut TcpStream) -> std::io::Result<ReadOutcome> {
212 let mut reader = BufReader::new(stream);
213 read_request_from(&mut reader)
214}
215
216fn 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 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 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 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
265fn 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
288fn 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
307fn path_of(target: &str) -> &str {
309 target.split(['?', '#']).next().unwrap_or(target)
310}
311
312fn 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
323fn 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 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 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}