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