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: ¬ify::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('&', "&")
306 .replace('<', "<")
307 .replace('>', ">")
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 }