sqzass.git

sqzass

~ A static site generator written in Rust
git clone https://github.com/debuman/sqzass.git

src/serve.rs (20252B)

      1 //! 개발 서버. 인메모리 서빙 + 라이브 리로드.
      2 
      3 use crate::{BuildOptions, BuildOutput, build_to_memory};
      4 use anyhow::{Context, Result};
      5 use axum::Router;
      6 use axum::body::Body;
      7 use axum::extract::{State, WebSocketUpgrade, ws};
      8 use axum::http::{HeaderMap, StatusCode, Uri, header};
      9 use axum::response::{IntoResponse, Response};
     10 use axum::routing::get;
     11 use notify::RecursiveMode;
     12 use notify_debouncer_full::new_debouncer;
     13 use std::net::{IpAddr, SocketAddr};
     14 use std::path::{Path, PathBuf};
     15 use std::sync::{Arc, RwLock};
     16 use std::time::Duration;
     17 use tokio::sync::broadcast;
     18 
     19 /// 라이브 리로드 엔드포인트. 사이트 경로와 겹치지 않게 접두사를 붙인다.
     20 const LIVE_PATH: &str = "/__sqzass/live";
     21 /// 편집기의 원자적 저장(임시 파일 + rename)은 한 번의 "저장"에 3~5개 이벤트를 낸다.
     22 const DEBOUNCE: Duration = Duration::from_millis(150);
     23 
     24 #[derive(Debug, Clone)]
     25 pub struct ServeOptions {
     26     pub input: PathBuf,
     27     pub bind: IpAddr,
     28     pub port: u16,
     29     pub drafts: bool,
     30     pub base_url: Option<String>,
     31 }
     32 
     33 /// 브라우저로 보내는 신호.
     34 #[derive(Debug, Clone, PartialEq, Eq)]
     35 enum Signal {
     36     /// 전체 새로고침
     37     Reload,
     38     /// CSS만 바뀜 — `<link>`의 href만 갈아끼운다. 스크롤과 열린 상태가 유지된다.
     39     Css,
     40     /// 빌드 실패 — 오버레이를 띄운다. 마지막으로 성공한 사이트는 계속 서빙된다.
     41     Error(String),
     42     /// 에러가 해소됨
     43     Clear,
     44 }
     45 
     46 impl Signal {
     47     fn encode(&self) -> String {
     48         match self {
     49             Signal::Reload => "reload".into(),
     50             Signal::Css => "css".into(),
     51             Signal::Clear => "clear".into(),
     52             // 개행이 프레임을 깨지 않도록 한 줄로 만든다.
     53             Signal::Error(msg) => format!("error:{}", msg.replace('\n', "\\n")),
     54         }
     55     }
     56 }
     57 
     58 struct Shared {
     59     output: RwLock<BuildOutput>,
     60     /// 현재 미해결 빌드 에러. 뒤늦게 접속한 탭도 이걸 즉시 받는다.
     61     error: RwLock<Option<String>>,
     62     tx: broadcast::Sender<Signal>,
     63 }
     64 
     65 type SharedState = Arc<Shared>;
     66 
     67 pub async fn serve(opts: ServeOptions) -> Result<()> {
     68     let build_opts = BuildOptions {
     69         input: opts.input.clone(),
     70         output: None,
     71         drafts: opts.drafts,
     72         base_url: opts.base_url.clone(),
     73         profile: false,
     74     };
     75 
     76     let initial = build_to_memory(&build_opts)
     77         .with_context(|| format!("{}의 첫 빌드 실패", opts.input.display()))?;
     78     println!("{} pages", initial.pages);
     79 
     80     let (tx, _) = broadcast::channel(64);
     81     let shared: SharedState = Arc::new(Shared {
     82         output: RwLock::new(initial),
     83         error: RwLock::new(None),
     84         tx,
     85     });
     86 
     87     let _watcher = spawn_watcher(&opts.input, build_opts, Arc::clone(&shared))?;
     88 
     89     let app = Router::new()
     90         .route(LIVE_PATH, get(live_socket))
     91         .fallback(get(serve_file))
     92         .with_state(shared);
     93 
     94     let addr = SocketAddr::new(opts.bind, opts.port);
     95     let listener = tokio::net::TcpListener::bind(addr)
     96         .await
     97         .with_context(|| format!("{addr}에 바인드할 수 없습니다"))?;
     98 
     99     println!("http://{addr}/  (Ctrl+C to stop)");
    100     axum::serve(listener, app)
    101         .with_graceful_shutdown(async {
    102             let _ = tokio::signal::ctrl_c().await;
    103         })
    104         .await
    105         .context("서버 오류")?;
    106     Ok(())
    107 }
    108 
    109 /// 감시 대상 디렉터리. 출력 디렉터리는 넣지 않는다 — 자기가 쓴 걸 보고 다시 빌드하는
    110 /// 무한 루프가 된다.
    111 const WATCH_DIRS: &[&str] = &["content", "templates", "static", "i18n"];
    112 
    113 fn spawn_watcher(
    114     root: &Path,
    115     build_opts: BuildOptions,
    116     shared: SharedState,
    117 ) -> Result<
    118     notify_debouncer_full::Debouncer<
    119         notify::RecommendedWatcher,
    120         notify_debouncer_full::RecommendedCache,
    121     >,
    122 > {
    123     let (tx, rx) = std::sync::mpsc::channel();
    124     let mut debouncer =
    125         new_debouncer(DEBOUNCE, None, tx).context("파일 감시자를 만들 수 없습니다")?;
    126 
    127     for dir in WATCH_DIRS {
    128         let path = root.join(dir);
    129         if path.is_dir() {
    130             debouncer
    131                 .watch(&path, RecursiveMode::Recursive)
    132                 .with_context(|| format!("{}를 감시할 수 없습니다", path.display()))?;
    133         }
    134     }
    135     let cfg = root.join(crate::config::CONFIG_FILE);
    136     if cfg.is_file() {
    137         debouncer.watch(&cfg, RecursiveMode::NonRecursive).ok();
    138     }
    139 
    140     std::thread::spawn(move || {
    141         for res in rx {
    142             let Ok(events) = res else { continue };
    143             let paths: Vec<PathBuf> = events
    144                 .iter()
    145                 .filter(|e| is_change(&e.kind))
    146                 .flat_map(|e| e.paths.clone())
    147                 .filter(|p| !is_noise(p))
    148                 .collect();
    149             if paths.is_empty() {
    150                 continue;
    151             }
    152             rebuild(&build_opts, &shared, &paths);
    153         }
    154     });
    155 
    156     Ok(debouncer)
    157 }
    158 
    159 /// 파일을 *바꾼* 이벤트만 리빌드 사유다. inotify의 감시 마스크에는 IN_OPEN이
    160 /// 들어 있어서 읽기도 이벤트를 만든다 — 이걸 안 거르면 리빌드가 content/를
    161 /// 읽는 것 자체가 다음 리빌드를 예약해, 첫 저장 이후 영원히 도는 루프가 된다.
    162 fn is_change(kind: &notify::EventKind) -> bool {
    163     !matches!(kind, notify::EventKind::Access(_))
    164 }
    165 
    166 /// 편집기 임시 파일과 VCS 내부 파일은 무시한다. 이걸 안 걸러내면 저장 한 번에
    167 /// 리빌드가 여러 번 돈다.
    168 fn is_noise(path: &Path) -> bool {
    169     let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
    170     if path.components().any(|c| c.as_os_str() == ".git") {
    171         return true;
    172     }
    173     name.starts_with('.')
    174         || name.ends_with('~')
    175         || name.ends_with(".swp")
    176         || name.ends_with(".swx")
    177         || name.ends_with(".tmp")
    178         || (name.starts_with('#') && name.ends_with('#'))
    179 }
    180 
    181 fn rebuild(build_opts: &BuildOptions, shared: &SharedState, paths: &[PathBuf]) {
    182     match build_to_memory(build_opts) {
    183         Ok(output) => {
    184             let had_error = shared.error.read().map(|e| e.is_some()).unwrap_or(false);
    185             if let Ok(mut slot) = shared.output.write() {
    186                 *slot = output;
    187             }
    188             if had_error {
    189                 if let Ok(mut e) = shared.error.write() {
    190                     *e = None;
    191                 }
    192                 let _ = shared.tx.send(Signal::Clear);
    193             }
    194 
    195             // CSS만 바뀌었으면 페이지를 새로 그리지 않는다 — 스크롤 위치와 열어둔
    196             // details/사이드바 상태가 유지되어야 디자인 작업이 견딜 만해진다.
    197             let css_only = paths
    198                 .iter()
    199                 .all(|p| p.extension().and_then(|e| e.to_str()) == Some("css"));
    200             let signal = if css_only {
    201                 Signal::Css
    202             } else {
    203                 Signal::Reload
    204             };
    205             println!("  rebuilt ({})", if css_only { "css" } else { "full" });
    206             let _ = shared.tx.send(signal);
    207         }
    208         Err(err) => {
    209             let msg = format!("{err:#}");
    210             eprintln!("error: {msg}");
    211             if let Ok(mut e) = shared.error.write() {
    212                 *e = Some(msg.clone());
    213             }
    214             // 마지막으로 성공한 출력은 그대로 둔다. 빌드가 깨졌다고 사이트가
    215             // 사라지면 무엇이 깨졌는지 볼 수가 없다.
    216             let _ = shared.tx.send(Signal::Error(msg));
    217         }
    218     }
    219 }
    220 
    221 // --- HTTP ---
    222 
    223 async fn serve_file(State(shared): State<SharedState>, uri: Uri) -> Response {
    224     let path = uri.path();
    225     let key = resolve_key(path);
    226 
    227     let (body, is_html) = {
    228         let files = shared.output.read().expect("출력 락");
    229         match files.files.get(&key) {
    230             Some(bytes) => (bytes.clone(), key.ends_with(".html")),
    231             None => {
    232                 return (
    233                     StatusCode::NOT_FOUND,
    234                     [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
    235                     format!("<!doctype html><meta charset=utf-8><title>404</title><h1>404</h1><p><code>{}</code> not found.</p>{}", html_escape(path), reload_script()),
    236                 )
    237                     .into_response();
    238             }
    239         }
    240     };
    241 
    242     if !is_html {
    243         return ([(header::CONTENT_TYPE, content_type(&key))], body).into_response();
    244     }
    245 
    246     // 리로드 스크립트는 **서브 시점에** 주입한다. 빌드 산출물 자체는 프로덕션과
    247     // 바이트 단위로 동일하게 유지되어야 한다.
    248     let mut html = String::from_utf8_lossy(&body).into_owned();
    249     let error = shared.error.read().ok().and_then(|e| e.clone());
    250     let injected = format!("{}{}", overlay(error.as_deref()), reload_script());
    251     match html.rfind("</body>") {
    252         Some(i) => html.insert_str(i, &injected),
    253         None => html.push_str(&injected),
    254     }
    255 
    256     Response::builder()
    257         .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
    258         // 두 번의 저장이 같은 초에 들어가면 검증자가 같아져 엉뚱한 304가 나간다.
    259         .header(header::CACHE_CONTROL, "no-store")
    260         .body(Body::from(html))
    261         .expect("응답 생성")
    262 }
    263 
    264 /// 요청 경로를 출력 파일 키로 바꾼다. `/a/b/` → `a/b/index.html`
    265 fn resolve_key(path: &str) -> String {
    266     let p = path.trim_start_matches('/');
    267     if p.is_empty() {
    268         return "index.html".into();
    269     }
    270     if p.ends_with('/') {
    271         return format!("{p}index.html");
    272     }
    273     let last = Path::new(p)
    274         .file_name()
    275         .and_then(|n| n.to_str())
    276         .unwrap_or("");
    277     // `Path::extension()`은 `.nojekyll` 같은 점 파일에 None을 준다(파일명 전체가
    278     // stem으로 취급된다). 그것만 보고 판단하면 점 파일을 디렉터리로 오인한다.
    279     if Path::new(p).extension().is_some() || last.starts_with('.') {
    280         return p.to_string();
    281     }
    282     format!("{p}/index.html")
    283 }
    284 
    285 fn content_type(key: &str) -> &'static str {
    286     match Path::new(key).extension().and_then(|e| e.to_str()) {
    287         Some("html") => "text/html; charset=utf-8",
    288         Some("css") => "text/css; charset=utf-8",
    289         Some("js") => "text/javascript; charset=utf-8",
    290         Some("json") => "application/json",
    291         Some("xml") => "application/xml",
    292         Some("txt") => "text/plain; charset=utf-8",
    293         Some("svg") => "image/svg+xml",
    294         Some("png") => "image/png",
    295         Some("jpg" | "jpeg") => "image/jpeg",
    296         Some("webp") => "image/webp",
    297         Some("avif") => "image/avif",
    298         Some("ico") => "image/x-icon",
    299         Some("woff2") => "font/woff2",
    300         _ => "application/octet-stream",
    301     }
    302 }
    303 
    304 fn html_escape(s: &str) -> String {
    305     s.replace('&', "&amp;")
    306         .replace('<', "&lt;")
    307         .replace('>', "&gt;")
    308 }
    309 
    310 // --- WebSocket ---
    311 
    312 async fn live_socket(
    313     State(shared): State<SharedState>,
    314     headers: HeaderMap,
    315     upgrade: WebSocketUpgrade,
    316 ) -> Response {
    317     let origin = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok());
    318     let host = headers.get(header::HOST).and_then(|v| v.to_str().ok());
    319 
    320     if !origin_allowed(origin, host) {
    321         // Cross-Site WebSocket Hijacking 방어. 판단할 수 없으면 거절한다.
    322         return (StatusCode::FORBIDDEN, "origin not allowed").into_response();
    323     }
    324 
    325     upgrade.on_upgrade(move |socket| handle_socket(socket, shared))
    326 }
    327 
    328 async fn handle_socket(mut socket: ws::WebSocket, shared: SharedState) {
    329     let mut rx = shared.tx.subscribe();
    330 
    331     // 지금 빌드가 깨져 있다면 새로 붙은 탭에도 즉시 알려준다. 이게 없으면 리로드
    332     // 도중에 접속한 탭은 왜 화면이 옛날인지 알 수 없다.
    333     if let Some(err) = shared.error.read().ok().and_then(|e| e.clone())
    334         && socket
    335             .send(ws::Message::Text(Signal::Error(err).encode().into()))
    336             .await
    337             .is_err()
    338     {
    339         return;
    340     }
    341 
    342     while let Ok(signal) = rx.recv().await {
    343         if socket
    344             .send(ws::Message::Text(signal.encode().into()))
    345             .await
    346             .is_err()
    347         {
    348             break;
    349         }
    350     }
    351 }
    352 
    353 /// WebSocket 핸드셰이크의 Origin 검증.
    354 ///
    355 /// **판단할 수 없으면 거절한다(fail closed).** Origin이 없거나 파싱이 안 되면 통과시키지
    356 /// 않는다 — 개발 서버는 로컬에만 떠 있지만, 사용자가 아무 사이트나 열어둔 상태에서
    357 /// 그 페이지가 이 소켓에 붙어 리빌드 신호를 엿보거나 트리거할 수 있으면 안 된다.
    358 fn origin_allowed(origin: Option<&str>, host: Option<&str>) -> bool {
    359     let Some(origin) = origin else { return false };
    360     let Some(origin_host) = host_of_origin(origin) else {
    361         return false;
    362     };
    363     if is_loopback_host(&origin_host) {
    364         return true;
    365     }
    366     match host.and_then(strip_port) {
    367         Some(h) => h.eq_ignore_ascii_case(&origin_host),
    368         None => false,
    369     }
    370 }
    371 
    372 /// `scheme://host[:port]` 에서 host를 뽑는다. 대괄호 IPv6(`[::1]:3000`)를 처리한다.
    373 fn host_of_origin(origin: &str) -> Option<String> {
    374     let rest = origin.split_once("://")?.1;
    375     let authority = rest.split(['/', '?', '#']).next()?;
    376     strip_port(authority).map(|h| h.to_ascii_lowercase())
    377 }
    378 
    379 fn strip_port(authority: &str) -> Option<&str> {
    380     if let Some(end) = authority.find(']') {
    381         // 대괄호 IPv6: `[::1]:3000` → `[::1]`
    382         return authority.get(..=end);
    383     }
    384     Some(match authority.rsplit_once(':') {
    385         Some((h, port)) if port.chars().all(|c| c.is_ascii_digit()) => h,
    386         _ => authority,
    387     })
    388 }
    389 
    390 fn is_loopback_host(host: &str) -> bool {
    391     matches!(host, "localhost" | "127.0.0.1" | "[::1]" | "::1")
    392 }
    393 
    394 // --- 브라우저로 주입되는 코드 ---
    395 
    396 fn overlay(error: Option<&str>) -> String {
    397     let Some(err) = error else {
    398         return String::new();
    399     };
    400     format!(
    401         r#"<div id="__sqzass_error" style="position:fixed;inset:0;z-index:2147483647;background:#1a1114;color:#ffd7d7;font:13px/1.6 ui-monospace,monospace;padding:2rem;overflow:auto;white-space:pre-wrap"><strong style="color:#ff8080;font-size:15px">build failed</strong>
    402 
    403 {}</div>"#,
    404         html_escape(err)
    405     )
    406 }
    407 
    408 fn reload_script() -> String {
    409     format!(
    410         r#"<script>(function(){{
    411 var path={LIVE_PATH:?};
    412 var back=500, ws;
    413 function overlay(msg){{
    414   var el=document.getElementById("__sqzass_error");
    415   if(!msg){{ if(el) el.remove(); return; }}
    416   if(!el){{ el=document.createElement("div"); el.id="__sqzass_error";
    417     el.setAttribute("style","position:fixed;inset:0;z-index:2147483647;background:#1a1114;color:#ffd7d7;font:13px/1.6 ui-monospace,monospace;padding:2rem;overflow:auto;white-space:pre-wrap");
    418     document.body.appendChild(el); }}
    419   el.textContent="build failed\n\n"+msg;
    420 }}
    421 function swapCss(){{
    422   document.querySelectorAll('link[rel="stylesheet"]').forEach(function(l){{
    423     var u=new URL(l.href,location.href); u.searchParams.set("__r",Date.now()); l.href=u.href;
    424   }});
    425 }}
    426 function connect(){{
    427   ws=new WebSocket((location.protocol==="https:"?"wss://":"ws://")+location.host+path);
    428   ws.onopen=function(){{ back=500; }};
    429   ws.onmessage=function(e){{
    430     var m=e.data;
    431     if(m==="reload"){{ sessionStorage.setItem("__sqzass_y",String(scrollY)); location.reload(); }}
    432     else if(m==="css"){{ swapCss(); overlay(null); }}
    433     else if(m==="clear"){{ overlay(null); location.reload(); }}
    434     else if(m.indexOf("error:")===0){{ overlay(m.slice(6).replace(/\\n/g,"\n")); }}
    435   }};
    436   // 지수 백오프. 서버를 재시작해도 탭이 알아서 다시 붙는다.
    437   ws.onclose=function(){{ setTimeout(connect,back); back=Math.min(back*2,10000); }};
    438 }}
    439 var y=sessionStorage.getItem("__sqzass_y");
    440 if(y!==null){{ sessionStorage.removeItem("__sqzass_y");
    441   addEventListener("load",function(){{ scrollTo(0,parseInt(y,10)||0); }}); }}
    442 connect();
    443 }})();</script>"#
    444     )
    445 }
    446 
    447 #[cfg(test)]
    448 mod tests {
    449     use super::*;
    450 
    451     #[test]
    452     fn resolves_directory_urls_to_index() {
    453         assert_eq!(resolve_key("/"), "index.html");
    454         assert_eq!(resolve_key("/ko/"), "ko/index.html");
    455         assert_eq!(
    456             resolve_key("/start/installation/"),
    457             "start/installation/index.html"
    458         );
    459     }
    460 
    461     #[test]
    462     fn resolves_extensionless_urls_to_index() {
    463         // 트레일링 슬래시가 없어도 같은 페이지를 준다. 프로덕션 호스트가
    464         // 리다이렉트해 주는 경우가 많아서, 개발 중에만 404가 나면 혼란스럽다.
    465         assert_eq!(resolve_key("/start"), "start/index.html");
    466     }
    467 
    468     #[test]
    469     fn passes_through_files_with_extensions() {
    470         assert_eq!(resolve_key("/assets/highlight.css"), "assets/highlight.css");
    471         assert_eq!(resolve_key("/.nojekyll"), ".nojekyll");
    472     }
    473 
    474     #[test]
    475     fn reads_do_not_count_as_changes() {
    476         // 리빌드 자신이 content/를 읽으며 내는 Access(Open)이 리빌드 사유가
    477         // 되면 첫 저장 이후 영원히 도는 루프다. 변경 부류는 전부 통과해야 한다.
    478         use notify::EventKind;
    479         use notify::event::{AccessKind, CreateKind, ModifyKind, RemoveKind};
    480         assert!(!is_change(&EventKind::Access(AccessKind::Open(
    481             notify::event::AccessMode::Any
    482         ))));
    483         assert!(!is_change(&EventKind::Access(AccessKind::Any)));
    484         assert!(is_change(&EventKind::Modify(ModifyKind::Any)));
    485         assert!(is_change(&EventKind::Create(CreateKind::Any)));
    486         assert!(is_change(&EventKind::Remove(RemoveKind::Any)));
    487         assert!(is_change(&EventKind::Any));
    488     }
    489 
    490     #[test]
    491     fn origin_must_be_present_and_parseable() {
    492         // fail closed: 판단할 수 없으면 거절
    493         assert!(!origin_allowed(None, Some("127.0.0.1:3000")));
    494         assert!(!origin_allowed(Some("garbage"), Some("127.0.0.1:3000")));
    495         assert!(!origin_allowed(Some(""), Some("127.0.0.1:3000")));
    496     }
    497 
    498     #[test]
    499     fn loopback_origins_are_allowed() {
    500         assert!(origin_allowed(
    501             Some("http://localhost:3000"),
    502             Some("localhost:3000")
    503         ));
    504         assert!(origin_allowed(
    505             Some("http://127.0.0.1:3000"),
    506             Some("127.0.0.1:3000")
    507         ));
    508         assert!(origin_allowed(
    509             Some("http://[::1]:3000"),
    510             Some("[::1]:3000")
    511         ));
    512     }
    513 
    514     #[test]
    515     fn foreign_origins_are_rejected() {
    516         assert!(!origin_allowed(
    517             Some("https://evil.example"),
    518             Some("127.0.0.1:3000")
    519         ));
    520         // 접두사만 같은 도메인도 막아야 한다
    521         assert!(!origin_allowed(
    522             Some("http://localhost.evil.example"),
    523             Some("localhost:3000")
    524         ));
    525     }
    526 
    527     #[test]
    528     fn matching_non_loopback_host_is_allowed() {
    529         // LAN의 다른 기기에서 --bind 0.0.0.0 으로 접근하는 경우
    530         assert!(origin_allowed(
    531             Some("http://192.168.1.50:3000"),
    532             Some("192.168.1.50:3000")
    533         ));
    534         assert!(!origin_allowed(
    535             Some("http://192.168.1.99:3000"),
    536             Some("192.168.1.50:3000")
    537         ));
    538     }
    539 
    540     #[test]
    541     fn strips_ports_but_keeps_ipv6_brackets() {
    542         assert_eq!(strip_port("localhost:3000"), Some("localhost"));
    543         assert_eq!(strip_port("example.com"), Some("example.com"));
    544         assert_eq!(strip_port("[::1]:3000"), Some("[::1]"));
    545         assert_eq!(strip_port("[::1]"), Some("[::1]"));
    546     }
    547 
    548     #[test]
    549     fn editor_temp_files_are_ignored() {
    550         assert!(is_noise(Path::new("content/.post.md.swp")));
    551         assert!(is_noise(Path::new("content/post.md~")));
    552         assert!(is_noise(Path::new("content/#post.md#")));
    553         assert!(is_noise(Path::new("/repo/.git/index")));
    554         assert!(!is_noise(Path::new("content/post.md")));
    555         assert!(!is_noise(Path::new("templates/base.html")));
    556     }
    557 
    558     #[test]
    559     fn signals_encode_on_one_line() {
    560         assert_eq!(Signal::Reload.encode(), "reload");
    561         assert_eq!(Signal::Css.encode(), "css");
    562         let e = Signal::Error("line1\nline2".into()).encode();
    563         assert!(!e.contains('\n'), "개행이 프레임을 깬다: {e}");
    564         assert!(e.starts_with("error:"));
    565     }
    566 }