1use std::fmt;
8use std::io::{BufRead, BufReader, Write};
9use std::net::{TcpListener, TcpStream, ToSocketAddrs};
10use std::path::PathBuf;
11use std::sync::Arc;
12use std::time::Duration;
13
14const MAX_BODY_BYTES: usize = 1 << 20; const MAX_HEAD_LINE_BYTES: usize = 64 * 1024;
24
25const MAX_HEADER_COUNT: usize = 256;
28
29const READ_TIMEOUT: Duration = Duration::from_secs(30);
32
33use crate::assets::asset_for;
34use crate::atelier::AtelierWebState;
35use crate::live::{
36 DEFAULT_PANE, DEFAULT_RESOURCE, DefaultLiveSurfaceFactory, LiveSessionTable,
37 LiveSurfaceFactory, decode_intent_body, encode_patches, encode_scene, error_json,
38};
39use sim_kernel::Cx;
40use sim_lib_net_core::{CapOutcome, read_capped_line};
41use sim_lib_server::{CookbookWebResponse, CookbookWebState};
42
43pub struct ServeConfig {
45 pub addr: String,
47 pub atelier_root: PathBuf,
49 pub dry_run: bool,
52 pub cookbook: Option<Arc<CookbookWebState>>,
55}
56
57impl fmt::Debug for ServeConfig {
58 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59 f.debug_struct("ServeConfig")
60 .field("addr", &self.addr)
61 .field("atelier_root", &self.atelier_root)
62 .field("dry_run", &self.dry_run)
63 .field("cookbook", &self.cookbook.as_ref().map(|_| "<provided>"))
64 .finish()
65 }
66}
67
68impl Default for ServeConfig {
69 fn default() -> Self {
70 Self {
71 addr: "127.0.0.1:8787".to_owned(),
72 atelier_root: PathBuf::from(".sim/atelier"),
73 dry_run: false,
74 cookbook: None,
75 }
76 }
77}
78
79pub fn serve_with_cx(cx: &mut Cx, config: &ServeConfig) -> std::io::Result<()> {
88 serve_with_surface_factory(cx, config, Box::new(DefaultLiveSurfaceFactory))
89}
90
91pub fn serve_with_surface_factory(
98 cx: &mut Cx,
99 config: &ServeConfig,
100 surface_factory: Box<dyn LiveSurfaceFactory + Send + Sync>,
101) -> std::io::Result<()> {
102 if config.dry_run {
103 println!("sim-web-shell: dry-run OK");
104 return Ok(());
105 }
106
107 let listener = bind(&config.addr)?;
108 let local = listener.local_addr()?;
109 let mut state = ShellState::with_surface_factory(config, cx, surface_factory)?;
110 println!("sim-web-shell: serving shell on http://{local}");
111 for stream in listener.incoming() {
112 match stream {
113 Ok(stream) => {
114 if let Err(err) = handle(stream, &mut state) {
115 eprintln!("sim-web-shell: connection error: {err}");
116 }
117 }
118 Err(err) => eprintln!("sim-web-shell: accept error: {err}"),
119 }
120 }
121 Ok(())
122}
123
124fn bind(addr: &str) -> std::io::Result<TcpListener> {
125 let resolved = addr.to_socket_addrs()?.next().ok_or_else(|| {
126 std::io::Error::new(std::io::ErrorKind::InvalidInput, "no socket address")
127 })?;
128 TcpListener::bind(resolved)
129}
130
131struct ShellState<'a> {
132 atelier: AtelierWebState,
133 cookbook: Arc<CookbookWebState>,
134 cookbook_cx: &'a mut Cx,
135 live: LiveSessionTable,
136}
137
138impl<'a> ShellState<'a> {
139 #[cfg(test)]
140 fn new(config: &ServeConfig, cx: &'a mut Cx) -> std::io::Result<Self> {
141 Self::with_surface_factory(config, cx, Box::new(DefaultLiveSurfaceFactory))
142 }
143
144 fn with_surface_factory(
145 config: &ServeConfig,
146 cx: &'a mut Cx,
147 surface_factory: Box<dyn LiveSurfaceFactory + Send + Sync>,
148 ) -> std::io::Result<Self> {
149 Ok(Self {
155 atelier: AtelierWebState::load(config.atelier_root.clone()),
156 cookbook: match &config.cookbook {
157 Some(cookbook) => Arc::clone(cookbook),
158 None => Arc::new(CookbookWebState::seeded().map_err(io_error)?),
159 },
160 cookbook_cx: cx,
161 live: LiveSessionTable::new(surface_factory),
162 })
163 }
164}
165
166#[cfg(test)]
167pub(crate) fn cookbook_index_for_test(
168 cx: &mut Cx,
169 config: &ServeConfig,
170) -> std::io::Result<CookbookWebResponse> {
171 let state = ShellState::new(config, cx)?;
172 Ok(state
173 .cookbook
174 .handle_request("GET", "/api/cookbook", Some(&mut *state.cookbook_cx)))
175}
176
177fn io_error(err: impl std::fmt::Display) -> std::io::Error {
178 std::io::Error::other(err.to_string())
179}
180
181fn handle(mut stream: TcpStream, state: &mut ShellState<'_>) -> std::io::Result<()> {
182 let _ = stream.set_read_timeout(Some(READ_TIMEOUT));
185 let request = match read_request(&mut stream)? {
186 ReadOutcome::Request(request) => request,
187 ReadOutcome::TooLarge => {
188 write_response(
189 &mut stream,
190 413,
191 "Payload Too Large",
192 "text/plain; charset=utf-8",
193 b"payload too large",
194 )?;
195 return Ok(());
196 }
197 ReadOutcome::Invalid => {
198 write_response(
199 &mut stream,
200 400,
201 "Bad Request",
202 "text/plain; charset=utf-8",
203 b"bad request",
204 )?;
205 return Ok(());
206 }
207 };
208 if path_of(&request.target) == "/api/session/intent" {
209 return write_session_intent(&mut stream, &request, &mut state.live);
210 }
211 if path_of(&request.target) == "/api/session/open" {
212 return write_session_open(&mut stream, &request, &mut state.live);
213 }
214 if path_of(&request.target) == "/api/session/close" {
215 return write_session_close(&mut stream, &request, &mut state.live);
216 }
217 if request.target.starts_with("/api/cookbook") {
218 let response = state.cookbook.handle_request(
221 &request.method,
222 &request.target,
223 Some(&mut *state.cookbook_cx),
224 );
225 return write_cookbook_response(&mut stream, &response);
226 }
227 if let Some(response) = state.atelier.response(&request.method, &request.target) {
228 return write_response(
229 &mut stream,
230 response.status,
231 status_text(response.status),
232 response.content_type,
233 response.body.as_bytes(),
234 );
235 }
236 if request.method != "GET" {
237 write_response(
238 &mut stream,
239 405,
240 "Method Not Allowed",
241 "text/plain; charset=utf-8",
242 b"method not allowed",
243 )?;
244 return Ok(());
245 }
246 match asset_for(&request.target) {
247 Some(asset) => write_response(&mut stream, 200, "OK", asset.content_type, asset.body),
248 None => write_response(
249 &mut stream,
250 404,
251 "Not Found",
252 "text/plain; charset=utf-8",
253 b"not found",
254 ),
255 }
256}
257
258#[derive(Debug)]
259struct RequestLine {
260 method: String,
261 target: String,
262 body: String,
263}
264
265#[derive(Debug)]
268enum ReadOutcome {
269 Request(RequestLine),
270 TooLarge,
271 Invalid,
272}
273
274fn read_request(stream: &mut TcpStream) -> std::io::Result<ReadOutcome> {
276 let mut reader = BufReader::new(stream);
277 read_request_from(&mut reader)
278}
279
280fn read_request_from(reader: &mut impl BufRead) -> std::io::Result<ReadOutcome> {
285 let mut request_line = String::new();
286 match read_capped_line(reader, &mut request_line, MAX_HEAD_LINE_BYTES)? {
287 CapOutcome::TooLarge => return Ok(ReadOutcome::TooLarge),
289 CapOutcome::Eof => return Ok(ReadOutcome::Invalid),
290 CapOutcome::Line => {}
291 }
292 let mut content_length = 0usize;
297 let mut header = String::new();
298 let mut header_count = 0usize;
299 loop {
300 header_count += 1;
301 if header_count > MAX_HEADER_COUNT {
302 return Ok(ReadOutcome::TooLarge);
303 }
304 match read_capped_line(reader, &mut header, MAX_HEAD_LINE_BYTES)? {
305 CapOutcome::TooLarge => return Ok(ReadOutcome::TooLarge),
306 CapOutcome::Eof => break,
307 CapOutcome::Line => {}
308 }
309 if header == "\r\n" || header == "\n" {
310 break;
311 }
312 if let Some((name, value)) = header.split_once(':')
313 && name.trim().eq_ignore_ascii_case("content-length")
314 {
315 content_length = value.trim().parse().unwrap_or(0);
316 }
317 }
318 if content_length > MAX_BODY_BYTES {
320 return Ok(ReadOutcome::TooLarge);
321 }
322 let mut body = vec![0u8; content_length];
323 if content_length > 0 {
324 reader.read_exact(&mut body)?;
327 }
328 let body = String::from_utf8_lossy(&body).into_owned();
329 let mut parts = request_line.split_whitespace();
330 let method = parts.next();
331 let target = parts.next();
332 match (method, target) {
333 (Some(method @ ("GET" | "POST")), Some(target)) => Ok(ReadOutcome::Request(RequestLine {
334 method: method.to_owned(),
335 target: target.to_owned(),
336 body,
337 })),
338 _ => Ok(ReadOutcome::Invalid),
339 }
340}
341
342fn write_session_intent(
347 stream: &mut (impl Write + ?Sized),
348 request: &RequestLine,
349 live: &mut LiveSessionTable,
350) -> std::io::Result<()> {
351 if request.method != "POST" {
352 return write_json(stream, 405, &error_json("intent route requires POST"));
353 }
354 let session_id = match query_value(&request.target, "session") {
355 Ok(Some(value)) => value,
356 Ok(None) => return write_json(stream, 400, &error_json("missing session id")),
357 Err(err) => return write_json(stream, 400, &error_json(&err.to_string())),
358 };
359 let pane = match query_value(&request.target, "pane") {
360 Ok(Some(value)) => value,
361 Ok(None) => DEFAULT_PANE.to_owned(),
362 Err(err) => return write_json(stream, 400, &error_json(&err.to_string())),
363 };
364 let intent = match decode_intent_body(&request.body) {
365 Ok(intent) => intent,
366 Err(err) => return write_json(stream, 400, &error_json(&err)),
367 };
368 match live.submit(&session_id, &pane, &intent) {
369 Ok(updates) => write_json(stream, 200, &encode_patches(&updates)),
370 Err(err) => write_json(stream, 400, &error_json(&err.to_string())),
371 }
372}
373
374fn write_session_open(
377 stream: &mut (impl Write + ?Sized),
378 request: &RequestLine,
379 live: &mut LiveSessionTable,
380) -> std::io::Result<()> {
381 if request.method != "GET" {
382 return write_json(stream, 405, &error_json("open route requires GET"));
383 }
384 let session_id = match query_value(&request.target, "session") {
385 Ok(value) => value,
386 Err(err) => return write_json(stream, 400, &error_json(&err.to_string())),
387 };
388 let resource = match query_value(&request.target, "resource") {
389 Ok(Some(value)) => value,
390 Ok(None) => DEFAULT_RESOURCE.to_owned(),
391 Err(err) => return write_json(stream, 400, &error_json(&err.to_string())),
392 };
393 let pane = match query_value(&request.target, "pane") {
394 Ok(Some(value)) => value,
395 Ok(None) => DEFAULT_PANE.to_owned(),
396 Err(err) => return write_json(stream, 400, &error_json(&err.to_string())),
397 };
398 match live.open(session_id.as_deref(), &resource, &pane) {
399 Ok((session_id, scene)) => {
400 write_json(stream, 200, &encode_session_open(&session_id, &scene))
401 }
402 Err(err) => write_json(stream, 400, &error_json(&err.to_string())),
403 }
404}
405
406fn write_session_close(
409 stream: &mut (impl Write + ?Sized),
410 request: &RequestLine,
411 live: &mut LiveSessionTable,
412) -> std::io::Result<()> {
413 if request.method != "POST" {
414 return write_json(stream, 405, &error_json("close route requires POST"));
415 }
416 let session_id = match query_value(&request.target, "session") {
417 Ok(Some(value)) => value,
418 Ok(None) => return write_json(stream, 400, &error_json("missing session id")),
419 Err(err) => return write_json(stream, 400, &error_json(&err.to_string())),
420 };
421 match live.close(&session_id) {
422 Ok(()) => write_json(stream, 200, r#"{"ok":true}"#),
423 Err(err) => write_json(stream, 400, &error_json(&err)),
424 }
425}
426
427fn encode_session_open(session_id: &str, scene: &sim_kernel::Expr) -> String {
428 let mut value: serde_json::Value =
429 serde_json::from_str(&encode_scene(scene)).expect("encode_scene emits JSON object");
430 if let Some(object) = value.as_object_mut() {
431 object.insert(
432 "session".to_owned(),
433 serde_json::Value::String(session_id.to_owned()),
434 );
435 }
436 value.to_string()
437}
438
439fn path_of(target: &str) -> &str {
441 target.split(['?', '#']).next().unwrap_or(target)
442}
443
444#[derive(Debug, Clone, PartialEq, Eq)]
445struct QueryError {
446 key: String,
447 reason: String,
448}
449
450impl QueryError {
451 fn new(key: &str, reason: impl Into<String>) -> Self {
452 Self {
453 key: key.to_owned(),
454 reason: reason.into(),
455 }
456 }
457}
458
459impl fmt::Display for QueryError {
460 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
461 write!(
462 f,
463 "malformed query value for '{}': {}",
464 self.key, self.reason
465 )
466 }
467}
468
469fn query_value(target: &str, key: &str) -> Result<Option<String>, QueryError> {
478 let Some((_, query_and_fragment)) = target.split_once('?') else {
479 return Ok(None);
480 };
481 let query = query_and_fragment
482 .split('#')
483 .next()
484 .unwrap_or(query_and_fragment);
485 for pair in query.split('&') {
486 let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
487 if name == key {
488 return percent_decode_query(value)
489 .map(Some)
490 .map_err(|reason| QueryError::new(key, reason));
491 }
492 }
493 Ok(None)
494}
495
496fn percent_decode_query(value: &str) -> Result<String, String> {
497 let bytes = value.as_bytes();
498 let mut decoded = Vec::with_capacity(bytes.len());
499 let mut index = 0usize;
500 while index < bytes.len() {
501 match bytes[index] {
502 b'%' => {
503 if index + 2 >= bytes.len() {
504 return Err("incomplete percent escape".to_owned());
505 }
506 let high = hex_digit(bytes[index + 1])
507 .ok_or_else(|| "invalid percent escape".to_owned())?;
508 let low = hex_digit(bytes[index + 2])
509 .ok_or_else(|| "invalid percent escape".to_owned())?;
510 decoded.push((high << 4) | low);
511 index += 3;
512 }
513 b'+' => {
514 decoded.push(b' ');
515 index += 1;
516 }
517 byte => {
518 decoded.push(byte);
519 index += 1;
520 }
521 }
522 }
523 String::from_utf8(decoded).map_err(|_| "decoded value is not UTF-8".to_owned())
524}
525
526fn hex_digit(byte: u8) -> Option<u8> {
527 match byte {
528 b'0'..=b'9' => Some(byte - b'0'),
529 b'a'..=b'f' => Some(byte - b'a' + 10),
530 b'A'..=b'F' => Some(byte - b'A' + 10),
531 _ => None,
532 }
533}
534
535fn write_json(stream: &mut (impl Write + ?Sized), status: u16, body: &str) -> std::io::Result<()> {
537 write_response(
538 stream,
539 status,
540 status_text(status),
541 "application/json; charset=utf-8",
542 body.as_bytes(),
543 )
544}
545
546fn write_cookbook_response(
547 stream: &mut (impl Write + ?Sized),
548 response: &CookbookWebResponse,
549) -> std::io::Result<()> {
550 write_response(
551 stream,
552 response.status,
553 status_text(response.status),
554 response.content_type,
555 response.body.as_bytes(),
556 )
557}
558
559fn write_response(
560 stream: &mut (impl Write + ?Sized),
561 status: u16,
562 reason: &str,
563 content_type: &str,
564 body: &[u8],
565) -> std::io::Result<()> {
566 let header = format!(
567 "HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
568 body.len()
569 );
570 stream.write_all(header.as_bytes())?;
571 stream.write_all(body)?;
572 stream.flush()
573}
574
575fn status_text(status: u16) -> &'static str {
576 match status {
577 200 => "OK",
578 201 => "Created",
579 204 => "No Content",
580 301 => "Moved Permanently",
581 302 => "Found",
582 304 => "Not Modified",
583 400 => "Bad Request",
584 401 => "Unauthorized",
585 403 => "Forbidden",
586 404 => "Not Found",
587 405 => "Method Not Allowed",
588 409 => "Conflict",
589 413 => "Payload Too Large",
590 422 => "Unprocessable Entity",
591 429 => "Too Many Requests",
592 500 => "Internal Server Error",
593 501 => "Not Implemented",
594 503 => "Service Unavailable",
595 other => match other / 100 {
598 1 => "Informational",
599 2 => "OK",
600 3 => "Redirection",
601 4 => "Client Error",
602 _ => "Internal Server Error",
603 },
604 }
605}
606
607#[cfg(test)]
608#[path = "serve_tests.rs"]
609mod tests;