sqzass.git

sqzass

~ static site generator in Rust
git clone https://github.com/debuman/sqzass.git
commit b9df84fe33ce8d3d49a8e141cbd8c9d2ef1bc7fb
parent 82cb8bb0d7bb156f11a835eb01ff1318b3d4346d
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Sat, 25 Jul 2026 18:24:03 +0900

Add a dev server with live reload

Builds into memory and serves from there. Nothing touches disk, so the browser
can never fetch a half-written file and saving does not amplify into writes.

The reload script is injected at serve time rather than at build time, which
keeps the build output byte-identical to what ships. A CSS-only change swaps
the stylesheet href instead of reloading, so scroll position and open sidebar
state survive -- without that, iterating on design is miserable. Everything
else reloads, restoring scroll from sessionStorage.

A failed build keeps serving the last good output and shows the error as an
overlay; dropping the site would hide the very page you are trying to fix. A
tab that connects while the build is broken is sent the current error
immediately, and gets an explicit clear when it is fixed, so a reconnecting tab
never sits on a stale "build failed" over a healthy site.

The WebSocket handshake validates Origin and fails closed: a missing or
unparseable Origin is rejected rather than allowed. Bracketed IPv6 authorities
are handled, and a prefix match like localhost.evil.example does not pass.

Path::extension() returns None for dotfiles, so /.nojekyll was being treated as
a directory and 404ing. Caught by a test before it shipped.

Diffstat:
MCargo.lock | 848+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
MCargo.toml | 5+++++
Msrc/lib.rs | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Msrc/main.rs | 37+++++++++++++++++++++++++++++++++++++
Asrc/serve.rs | 541+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 1473 insertion(+), 36 deletion(-)

diff --git a/Cargo.lock b/Cargo.lock
@@ -53,7 +53,7 @@ version = "1.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
 dependencies = [
- "windows-sys",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -64,7 +64,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
 dependencies = [
  "anstyle",
  "once_cell_polyfill",
- "windows-sys",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -73,6 +73,67 @@ version = "1.0.104"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
 
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "axum"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
+dependencies = [
+ "axum-core",
+ "base64",
+ "bytes",
+ "form_urlencoded",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde_core",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_urlencoded",
+ "sha1",
+ "sync_wrapper",
+ "tokio",
+ "tokio-tungstenite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
 [[package]]
 name = "base64"
 version = "0.22.1"
@@ -109,6 +170,21 @@ version = "2.13.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
 
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
 [[package]]
 name = "caseless"
 version = "0.2.2"
@@ -155,7 +231,7 @@ dependencies = [
  "heck",
  "proc-macro2",
  "quote",
- "syn",
+ "syn 3.0.3",
 ]
 
 [[package]]
@@ -195,7 +271,16 @@ checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
 dependencies = [
  "encode_unicode",
  "libc",
- "windows-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
 ]
 
 [[package]]
@@ -207,12 +292,38 @@ dependencies = [
  "cfg-if",
 ]
 
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "data-encoding"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
+
 [[package]]
 name = "deranged"
 version = "0.5.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
 
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
 [[package]]
 name = "encode_unicode"
 version = "1.0.0"
@@ -238,7 +349,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
 dependencies = [
  "libc",
- "windows-sys",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -258,6 +369,15 @@ version = "2.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
 
+[[package]]
+name = "file-id"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9"
+dependencies = [
+ "windows-sys 0.60.2",
+]
+
 [[package]]
 name = "finl_unicode"
 version = "1.4.0"
@@ -280,6 +400,86 @@ version = "1.0.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
 
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "fsevent-sys"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
+
+[[package]]
+name = "futures-sink"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
+
+[[package]]
+name = "futures-task"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
+
+[[package]]
+name = "futures-util"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+]
+
 [[package]]
 name = "getrandom"
 version = "0.4.3"
@@ -288,7 +488,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
 dependencies = [
  "cfg-if",
  "libc",
- "r-efi",
+ "r-efi 6.0.0",
 ]
 
 [[package]]
@@ -303,6 +503,92 @@ version = "0.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
 
+[[package]]
+name = "http"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "http-range-header"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "hyper"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "bytes",
+ "http",
+ "http-body",
+ "hyper",
+ "pin-project-lite",
+ "tokio",
+ "tower-service",
+]
+
 [[package]]
 name = "indexmap"
 version = "2.14.0"
@@ -313,6 +599,26 @@ dependencies = [
  "hashbrown",
 ]
 
+[[package]]
+name = "inotify"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8"
+dependencies = [
+ "bitflags",
+ "inotify-sys",
+ "libc",
+]
+
+[[package]]
+name = "inotify-sys"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "insta"
 version = "1.48.0"
@@ -343,6 +649,26 @@ version = "0.5.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e"
 
+[[package]]
+name = "kqueue"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5"
+dependencies = [
+ "kqueue-sys",
+ "libc",
+]
+
+[[package]]
+name = "kqueue-sys"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
+dependencies = [
+ "bitflags",
+ "libc",
+]
+
 [[package]]
 name = "libc"
 version = "0.2.189"
@@ -361,6 +687,18 @@ version = "0.12.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
 
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "matchit"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+
 [[package]]
 name = "memchr"
 version = "2.8.3"
@@ -373,6 +711,22 @@ version = "0.3.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
 
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
 [[package]]
 name = "minijinja"
 version = "2.21.0"
@@ -393,6 +747,58 @@ dependencies = [
  "simd-adler32",
 ]
 
+[[package]]
+name = "mio"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
+dependencies = [
+ "libc",
+ "log",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "notify"
+version = "8.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
+dependencies = [
+ "bitflags",
+ "fsevent-sys",
+ "inotify",
+ "kqueue",
+ "libc",
+ "log",
+ "mio",
+ "notify-types",
+ "walkdir",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "notify-debouncer-full"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c02b49179cfebc9932238d04d6079912d26de0379328872846118a0fa0dbb302"
+dependencies = [
+ "file-id",
+ "log",
+ "notify",
+ "notify-types",
+ "walkdir",
+]
+
+[[package]]
+name = "notify-types"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
+dependencies = [
+ "bitflags",
+]
+
 [[package]]
 name = "num-conv"
 version = "0.2.2"
@@ -411,6 +817,12 @@ version = "1.70.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
 
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
 [[package]]
 name = "phf"
 version = "0.13.1"
@@ -450,6 +862,12 @@ dependencies = [
  "siphasher",
 ]
 
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
 [[package]]
 name = "plist"
 version = "1.10.0"
@@ -469,6 +887,15 @@ version = "0.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
 
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
 [[package]]
 name = "proc-macro2"
 version = "1.0.107"
@@ -496,12 +923,47 @@ dependencies = [
  "proc-macro2",
 ]
 
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
 [[package]]
 name = "r-efi"
 version = "6.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
 
+[[package]]
+name = "rand"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
+dependencies = [
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
 [[package]]
 name = "regex-automata"
 version = "0.4.16"
@@ -535,9 +997,15 @@ dependencies = [
  "errno",
  "libc",
  "linux-raw-sys",
- "windows-sys",
+ "windows-sys 0.61.2",
 ]
 
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
 [[package]]
 name = "same-file"
 version = "1.0.6"
@@ -574,7 +1042,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 3.0.3",
 ]
 
 [[package]]
@@ -590,6 +1058,17 @@ dependencies = [
  "zmij",
 ]
 
+[[package]]
+name = "serde_path_to_error"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
+dependencies = [
+ "itoa",
+ "serde",
+ "serde_core",
+]
+
 [[package]]
 name = "serde_spanned"
 version = "1.1.1"
@@ -599,6 +1078,39 @@ dependencies = [
  "serde_core",
 ]
 
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "sha1"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
 [[package]]
 name = "simd-adler32"
 version = "0.3.10"
@@ -617,24 +1129,45 @@ version = "1.0.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
 
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
 [[package]]
 name = "smallvec"
 version = "1.15.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
 
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
 [[package]]
 name = "sqzass"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "axum",
  "clap",
  "comrak",
  "insta",
  "minijinja",
+ "notify",
+ "notify-debouncer-full",
  "serde",
  "syntect",
+ "tokio",
  "toml",
+ "tower-http",
  "two-face",
  "walkdir",
 ]
@@ -645,6 +1178,17 @@ version = "0.11.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
 
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
 [[package]]
 name = "syn"
 version = "3.0.3"
@@ -656,6 +1200,12 @@ dependencies = [
  "unicode-ident",
 ]
 
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+
 [[package]]
 name = "syntect"
 version = "5.3.0"
@@ -684,10 +1234,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
 dependencies = [
  "fastrand",
- "getrandom",
+ "getrandom 0.4.3",
  "once_cell",
  "rustix",
- "windows-sys",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -707,7 +1257,7 @@ checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 3.0.3",
 ]
 
 [[package]]
@@ -755,6 +1305,58 @@ version = "0.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
 
+[[package]]
+name = "tokio"
+version = "1.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tokio-tungstenite"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
+dependencies = [
+ "futures-util",
+ "log",
+ "tokio",
+ "tungstenite",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
 [[package]]
 name = "toml"
 version = "1.1.3+spec-1.1.0"
@@ -794,6 +1396,95 @@ version = "1.1.2+spec-1.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
 
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "http-range-header",
+ "httpdate",
+ "mime",
+ "mime_guess",
+ "percent-encoding",
+ "pin-project-lite",
+ "tokio",
+ "tokio-util",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "log",
+ "pin-project-lite",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "tungstenite"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
+dependencies = [
+ "bytes",
+ "data-encoding",
+ "http",
+ "httparse",
+ "log",
+ "rand",
+ "sha1",
+ "thiserror",
+]
+
 [[package]]
 name = "two-face"
 version = "0.5.1"
@@ -811,6 +1502,18 @@ version = "2.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"
 
+[[package]]
+name = "typenum"
+version = "1.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
+
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
 [[package]]
 name = "unicode-ident"
 version = "1.0.24"
@@ -832,6 +1535,12 @@ version = "0.2.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
 
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
 [[package]]
 name = "walkdir"
 version = "2.5.0"
@@ -842,13 +1551,28 @@ dependencies = [
  "winapi-util",
 ]
 
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.3+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
+dependencies = [
+ "wit-bindgen",
+]
+
 [[package]]
 name = "winapi-util"
 version = "0.1.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
 dependencies = [
- "windows-sys",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -857,6 +1581,15 @@ version = "0.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
 
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets",
+]
+
 [[package]]
 name = "windows-sys"
 version = "0.61.2"
@@ -866,12 +1599,83 @@ dependencies = [
  "windows-link",
 ]
 
+[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link",
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
 [[package]]
 name = "winnow"
 version = "1.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
 
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
 [[package]]
 name = "yaml-rust"
 version = "0.4.5"
@@ -881,6 +1685,26 @@ dependencies = [
  "linked-hash-map",
 ]
 
+[[package]]
+name = "zerocopy"
+version = "0.8.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
 [[package]]
 name = "zmij"
 version = "1.0.23"
diff --git a/Cargo.toml b/Cargo.toml
@@ -22,6 +22,7 @@ path = "src/main.rs"
 [dependencies]
 # --- M0 ---
 anyhow    = "1"
+axum = { version = "0.8", features = ["ws"] }
 clap      = { version = "4.6", features = ["derive"] }
 # ⚠️ comrak의 default = ["cli", "syntect-onig", "bon"] 이다. 그대로 쓰면
 #    (1) comrak 자체 CLI 의존성(clap/shell-words/xdg/fmt2io)이 딸려오고
@@ -31,9 +32,13 @@ clap      = { version = "4.6", features = ["derive"] }
 #    구현하므로(업스트림 #330이 재작업 대상으로 지적) 필요 없다.
 comrak    = { version = "0.54", default-features = false }
 minijinja = "2.21"
+notify = "8.2"
+notify-debouncer-full = "0.7"
 serde     = { version = "1", features = ["derive"] }
 syntect = { version = "5.3", default-features = false, features = ["default-fancy"] }
+tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal"] }
 toml      = "1.1"
+tower-http = { version = "0.7", features = ["fs"] }
 two-face = { version = "0.5", default-features = false, features = ["syntect-fancy"] }
 walkdir   = "2"
 
diff --git a/src/lib.rs b/src/lib.rs
@@ -5,10 +5,12 @@ pub mod content;
 pub mod highlight;
 pub mod markdown;
 pub mod render;
+pub mod serve;
 pub mod site;
 
 use anyhow::{Context, Result};
 use minijinja::context;
+use std::collections::BTreeMap;
 use std::path::{Path, PathBuf};
 
 use config::Config;
@@ -37,7 +39,24 @@ pub struct BuildStats {
     pub output_dir: PathBuf,
 }
 
-pub fn build(opts: &BuildOptions) -> Result<BuildStats> {
+/// 빌드 산출물을 메모리에 담는다.
+///
+/// 개발 서버가 디스크를 거치지 않고 이걸 그대로 서빙한다. 그래서 브라우저가 반쯤
+/// 쓰인 파일을 읽는 일이 없고, 저장할 때마다 디스크에 쓰기가 증폭되지도 않는다.
+#[derive(Debug, Default)]
+pub struct BuildOutput {
+    /// 출력 디렉터리 기준 상대 경로 → 내용
+    pub files: BTreeMap<String, Vec<u8>>,
+}
+
+impl BuildOutput {
+    pub fn pages(&self) -> usize {
+        self.files.keys().filter(|k| k.ends_with(".html")).count()
+    }
+}
+
+/// 사이트를 빌드해 메모리에 담는다. 디스크는 건드리지 않는다.
+pub fn build_to_memory(opts: &BuildOptions) -> Result<BuildOutput> {
     let root = &opts.input;
     let mut cfg = Config::load(root)?;
     if let Some(base) = &opts.base_url {
@@ -45,7 +64,6 @@ pub fn build(opts: &BuildOptions) -> Result<BuildStats> {
     }
 
     let drafts = opts.drafts || cfg.build.drafts;
-    let out_dir = resolve_output_dir(opts, &cfg);
 
     let pages = content::discover(root, &cfg, drafts)?;
     let site = Site::build(&pages, &cfg);
@@ -59,26 +77,18 @@ pub fn build(opts: &BuildOptions) -> Result<BuildStats> {
         None
     };
 
-    // 출력 디렉터리를 매번 새로 만든다. 지운 페이지가 유령으로 남는 걸 막는다.
-    if out_dir.exists() {
-        std::fs::remove_dir_all(&out_dir)
-            .with_context(|| format!("{}을(를) 비울 수 없습니다", out_dir.display()))?;
-    }
+    let mut out = BuildOutput::default();
 
     // 하이라이트 스타일시트는 테마에서 생성한다. HTML은 클래스만 담고 있으므로
     // 이 파일 하나를 바꾸면 사이트 전체의 코드 색이 바뀐다.
     if cfg.highlight.enabled {
         let css = highlight::stylesheet(&cfg.highlight)?;
-        let dest = out_dir.join(HIGHLIGHT_CSS_URL.trim_start_matches('/'));
-        if let Some(parent) = dest.parent() {
-            std::fs::create_dir_all(parent)
-                .with_context(|| format!("{}을(를) 만들 수 없습니다", parent.display()))?;
-        }
-        std::fs::write(&dest, css)
-            .with_context(|| format!("{}을(를) 쓸 수 없습니다", dest.display()))?;
+        out.files.insert(
+            HIGHLIGHT_CSS_URL.trim_start_matches('/').to_string(),
+            css.into_bytes(),
+        );
     }
 
-    let mut written = 0usize;
     for page in &pages {
         let html = render_page(
             page,
@@ -88,23 +98,43 @@ pub fn build(opts: &BuildOptions) -> Result<BuildStats> {
             &md,
             highlight_css_url.as_deref(),
         )?;
-        let dest = out_dir.join(&page.out_path);
+        out.files.insert(
+            page.out_path.to_string_lossy().replace('\\', "/"),
+            html.into_bytes(),
+        );
+    }
+
+    // GitHub Pages는 이 파일이 없으면 출력을 Jekyll로 한 번 더 굴려서
+    // `_`로 시작하는 디렉터리를 통째로 삼킨다.
+    out.files.insert(".nojekyll".into(), Vec::new());
+
+    Ok(out)
+}
+
+/// 사이트를 빌드해 디스크에 쓴다.
+pub fn build(opts: &BuildOptions) -> Result<BuildStats> {
+    let cfg = Config::load(&opts.input)?;
+    let out_dir = resolve_output_dir(opts, &cfg);
+    let output = build_to_memory(opts)?;
+
+    // 출력 디렉터리를 매번 새로 만든다. 지운 페이지가 유령으로 남는 걸 막는다.
+    if out_dir.exists() {
+        std::fs::remove_dir_all(&out_dir)
+            .with_context(|| format!("{}을(를) 비울 수 없습니다", out_dir.display()))?;
+    }
+
+    for (rel, bytes) in &output.files {
+        let dest = out_dir.join(rel);
         if let Some(parent) = dest.parent() {
             std::fs::create_dir_all(parent)
                 .with_context(|| format!("{}을(를) 만들 수 없습니다", parent.display()))?;
         }
-        std::fs::write(&dest, html)
+        std::fs::write(&dest, bytes)
             .with_context(|| format!("{}을(를) 쓸 수 없습니다", dest.display()))?;
-        written += 1;
     }
 
-    // GitHub Pages는 이 파일이 없으면 출력을 Jekyll로 한 번 더 굴려서
-    // `_`로 시작하는 디렉터리를 통째로 삼킨다.
-    std::fs::write(out_dir.join(".nojekyll"), "")
-        .with_context(|| format!("{}에 .nojekyll을 쓸 수 없습니다", out_dir.display()))?;
-
     Ok(BuildStats {
-        pages_written: written,
+        pages_written: output.pages(),
         output_dir: out_dir,
     })
 }
diff --git a/src/main.rs b/src/main.rs
@@ -2,6 +2,7 @@ use anyhow::Result;
 use clap::{Parser, Subcommand};
 use std::path::PathBuf;
 
+use sqzass::serve::{ServeOptions, serve};
 use sqzass::{BuildOptions, build, display_path};
 
 #[derive(Parser)]
@@ -20,6 +21,31 @@ struct Cli {
 enum Command {
     /// 사이트를 빌드한다
     Build(BuildArgs),
+    /// 라이브 리로드가 붙은 개발 서버를 띄운다
+    Serve(ServeArgs),
+}
+
+#[derive(clap::Args)]
+struct ServeArgs {
+    /// 사이트 루트 (sqzass.toml이 있는 디렉터리)
+    #[arg(short, long, default_value = ".", value_name = "DIR")]
+    input: PathBuf,
+
+    /// 바인드 주소
+    #[arg(short, long, default_value = "127.0.0.1", value_name = "ADDR")]
+    bind: std::net::IpAddr,
+
+    /// 포트
+    #[arg(short, long, default_value_t = 3000)]
+    port: u16,
+
+    /// 드래프트 페이지도 포함한다
+    #[arg(long)]
+    drafts: bool,
+
+    /// 설정의 base_url을 덮어쓴다
+    #[arg(long, value_name = "URL")]
+    base_url: Option<String>,
 }
 
 #[derive(clap::Args)]
@@ -66,5 +92,16 @@ fn run() -> Result<()> {
             );
             Ok(())
         }
+        Command::Serve(args) => {
+            // `build`는 동기 경로라 런타임을 서버에서만 만든다.
+            let rt = tokio::runtime::Runtime::new()?;
+            rt.block_on(serve(ServeOptions {
+                input: args.input,
+                bind: args.bind,
+                port: args.port,
+                drafts: args.drafts,
+                base_url: args.base_url,
+            }))
+        }
     }
 }
diff --git a/src/serve.rs b/src/serve.rs
@@ -0,0 +1,541 @@
+//! 개발 서버. 인메모리 서빙 + 라이브 리로드.
+
+use crate::{BuildOptions, BuildOutput, build_to_memory};
+use anyhow::{Context, Result};
+use axum::Router;
+use axum::body::Body;
+use axum::extract::{State, WebSocketUpgrade, ws};
+use axum::http::{HeaderMap, StatusCode, Uri, header};
+use axum::response::{IntoResponse, Response};
+use axum::routing::get;
+use notify::RecursiveMode;
+use notify_debouncer_full::new_debouncer;
+use std::net::{IpAddr, SocketAddr};
+use std::path::{Path, PathBuf};
+use std::sync::{Arc, RwLock};
+use std::time::Duration;
+use tokio::sync::broadcast;
+
+/// 라이브 리로드 엔드포인트. 사이트 경로와 겹치지 않게 접두사를 붙인다.
+const LIVE_PATH: &str = "/__sqzass/live";
+/// 편집기의 원자적 저장(임시 파일 + rename)은 한 번의 "저장"에 3~5개 이벤트를 낸다.
+const DEBOUNCE: Duration = Duration::from_millis(150);
+
+#[derive(Debug, Clone)]
+pub struct ServeOptions {
+    pub input: PathBuf,
+    pub bind: IpAddr,
+    pub port: u16,
+    pub drafts: bool,
+    pub base_url: Option<String>,
+}
+
+/// 브라우저로 보내는 신호.
+#[derive(Debug, Clone, PartialEq, Eq)]
+enum Signal {
+    /// 전체 새로고침
+    Reload,
+    /// CSS만 바뀜 — `<link>`의 href만 갈아끼운다. 스크롤과 열린 상태가 유지된다.
+    Css,
+    /// 빌드 실패 — 오버레이를 띄운다. 마지막으로 성공한 사이트는 계속 서빙된다.
+    Error(String),
+    /// 에러가 해소됨
+    Clear,
+}
+
+impl Signal {
+    fn encode(&self) -> String {
+        match self {
+            Signal::Reload => "reload".into(),
+            Signal::Css => "css".into(),
+            Signal::Clear => "clear".into(),
+            // 개행이 프레임을 깨지 않도록 한 줄로 만든다.
+            Signal::Error(msg) => format!("error:{}", msg.replace('\n', "\\n")),
+        }
+    }
+}
+
+struct Shared {
+    output: RwLock<BuildOutput>,
+    /// 현재 미해결 빌드 에러. 뒤늦게 접속한 탭도 이걸 즉시 받는다.
+    error: RwLock<Option<String>>,
+    tx: broadcast::Sender<Signal>,
+}
+
+type SharedState = Arc<Shared>;
+
+pub async fn serve(opts: ServeOptions) -> Result<()> {
+    let build_opts = BuildOptions {
+        input: opts.input.clone(),
+        output: None,
+        drafts: opts.drafts,
+        base_url: opts.base_url.clone(),
+    };
+
+    let initial = build_to_memory(&build_opts)
+        .with_context(|| format!("{}의 첫 빌드 실패", opts.input.display()))?;
+    println!("{} pages", initial.pages());
+
+    let (tx, _) = broadcast::channel(64);
+    let shared: SharedState = Arc::new(Shared {
+        output: RwLock::new(initial),
+        error: RwLock::new(None),
+        tx,
+    });
+
+    let _watcher = spawn_watcher(&opts.input, build_opts, Arc::clone(&shared))?;
+
+    let app = Router::new()
+        .route(LIVE_PATH, get(live_socket))
+        .fallback(get(serve_file))
+        .with_state(shared);
+
+    let addr = SocketAddr::new(opts.bind, opts.port);
+    let listener = tokio::net::TcpListener::bind(addr)
+        .await
+        .with_context(|| format!("{addr}에 바인드할 수 없습니다"))?;
+
+    println!("http://{addr}/  (Ctrl+C to stop)");
+    axum::serve(listener, app)
+        .with_graceful_shutdown(async {
+            let _ = tokio::signal::ctrl_c().await;
+        })
+        .await
+        .context("서버 오류")?;
+    Ok(())
+}
+
+/// 감시 대상 디렉터리. 출력 디렉터리는 넣지 않는다 — 자기가 쓴 걸 보고 다시 빌드하는
+/// 무한 루프가 된다.
+const WATCH_DIRS: &[&str] = &["content", "templates", "static", "i18n", "data"];
+
+fn spawn_watcher(
+    root: &Path,
+    build_opts: BuildOptions,
+    shared: SharedState,
+) -> Result<
+    notify_debouncer_full::Debouncer<
+        notify::RecommendedWatcher,
+        notify_debouncer_full::RecommendedCache,
+    >,
+> {
+    let (tx, rx) = std::sync::mpsc::channel();
+    let mut debouncer =
+        new_debouncer(DEBOUNCE, None, tx).context("파일 감시자를 만들 수 없습니다")?;
+
+    for dir in WATCH_DIRS {
+        let path = root.join(dir);
+        if path.is_dir() {
+            debouncer
+                .watch(&path, RecursiveMode::Recursive)
+                .with_context(|| format!("{}를 감시할 수 없습니다", path.display()))?;
+        }
+    }
+    let cfg = root.join(crate::config::CONFIG_FILE);
+    if cfg.is_file() {
+        debouncer.watch(&cfg, RecursiveMode::NonRecursive).ok();
+    }
+
+    std::thread::spawn(move || {
+        for res in rx {
+            let Ok(events) = res else { continue };
+            let paths: Vec<PathBuf> = events
+                .iter()
+                .flat_map(|e| e.paths.clone())
+                .filter(|p| !is_noise(p))
+                .collect();
+            if paths.is_empty() {
+                continue;
+            }
+            rebuild(&build_opts, &shared, &paths);
+        }
+    });
+
+    Ok(debouncer)
+}
+
+/// 편집기 임시 파일과 VCS 내부 파일은 무시한다. 이걸 안 걸러내면 저장 한 번에
+/// 리빌드가 여러 번 돈다.
+fn is_noise(path: &Path) -> bool {
+    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
+    if path.components().any(|c| c.as_os_str() == ".git") {
+        return true;
+    }
+    name.starts_with('.')
+        || name.ends_with('~')
+        || name.ends_with(".swp")
+        || name.ends_with(".swx")
+        || name.ends_with(".tmp")
+        || (name.starts_with('#') && name.ends_with('#'))
+}
+
+fn rebuild(build_opts: &BuildOptions, shared: &SharedState, paths: &[PathBuf]) {
+    match build_to_memory(build_opts) {
+        Ok(output) => {
+            let had_error = shared.error.read().map(|e| e.is_some()).unwrap_or(false);
+            if let Ok(mut slot) = shared.output.write() {
+                *slot = output;
+            }
+            if had_error {
+                if let Ok(mut e) = shared.error.write() {
+                    *e = None;
+                }
+                let _ = shared.tx.send(Signal::Clear);
+            }
+
+            // CSS만 바뀌었으면 페이지를 새로 그리지 않는다 — 스크롤 위치와 열어둔
+            // details/사이드바 상태가 유지되어야 디자인 작업이 견딜 만해진다.
+            let css_only = paths
+                .iter()
+                .all(|p| p.extension().and_then(|e| e.to_str()) == Some("css"));
+            let signal = if css_only {
+                Signal::Css
+            } else {
+                Signal::Reload
+            };
+            println!("  rebuilt ({})", if css_only { "css" } else { "full" });
+            let _ = shared.tx.send(signal);
+        }
+        Err(err) => {
+            let msg = format!("{err:#}");
+            eprintln!("error: {msg}");
+            if let Ok(mut e) = shared.error.write() {
+                *e = Some(msg.clone());
+            }
+            // 마지막으로 성공한 출력은 그대로 둔다. 빌드가 깨졌다고 사이트가
+            // 사라지면 무엇이 깨졌는지 볼 수가 없다.
+            let _ = shared.tx.send(Signal::Error(msg));
+        }
+    }
+}
+
+// --- HTTP ---
+
+async fn serve_file(State(shared): State<SharedState>, uri: Uri) -> Response {
+    let path = uri.path();
+    let key = resolve_key(path);
+
+    let (body, is_html) = {
+        let files = shared.output.read().expect("출력 락");
+        match files.files.get(&key) {
+            Some(bytes) => (bytes.clone(), key.ends_with(".html")),
+            None => {
+                return (
+                    StatusCode::NOT_FOUND,
+                    [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
+                    format!("<!doctype html><meta charset=utf-8><title>404</title><h1>404</h1><p><code>{}</code> not found.</p>{}", html_escape(path), reload_script()),
+                )
+                    .into_response();
+            }
+        }
+    };
+
+    if !is_html {
+        return ([(header::CONTENT_TYPE, content_type(&key))], body).into_response();
+    }
+
+    // 리로드 스크립트는 **서브 시점에** 주입한다. 빌드 산출물 자체는 프로덕션과
+    // 바이트 단위로 동일하게 유지되어야 한다.
+    let mut html = String::from_utf8_lossy(&body).into_owned();
+    let error = shared.error.read().ok().and_then(|e| e.clone());
+    let injected = format!("{}{}", overlay(error.as_deref()), reload_script());
+    match html.rfind("</body>") {
+        Some(i) => html.insert_str(i, &injected),
+        None => html.push_str(&injected),
+    }
+
+    Response::builder()
+        .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
+        // 두 번의 저장이 같은 초에 들어가면 검증자가 같아져 엉뚱한 304가 나간다.
+        .header(header::CACHE_CONTROL, "no-store")
+        .body(Body::from(html))
+        .expect("응답 생성")
+}
+
+/// 요청 경로를 출력 파일 키로 바꾼다. `/a/b/` → `a/b/index.html`
+fn resolve_key(path: &str) -> String {
+    let p = path.trim_start_matches('/');
+    if p.is_empty() {
+        return "index.html".into();
+    }
+    if p.ends_with('/') {
+        return format!("{p}index.html");
+    }
+    let last = Path::new(p)
+        .file_name()
+        .and_then(|n| n.to_str())
+        .unwrap_or("");
+    // `Path::extension()`은 `.nojekyll` 같은 점 파일에 None을 준다(파일명 전체가
+    // stem으로 취급된다). 그것만 보고 판단하면 점 파일을 디렉터리로 오인한다.
+    if Path::new(p).extension().is_some() || last.starts_with('.') {
+        return p.to_string();
+    }
+    format!("{p}/index.html")
+}
+
+fn content_type(key: &str) -> &'static str {
+    match Path::new(key).extension().and_then(|e| e.to_str()) {
+        Some("html") => "text/html; charset=utf-8",
+        Some("css") => "text/css; charset=utf-8",
+        Some("js") => "text/javascript; charset=utf-8",
+        Some("json") => "application/json",
+        Some("xml") => "application/xml",
+        Some("txt") => "text/plain; charset=utf-8",
+        Some("svg") => "image/svg+xml",
+        Some("png") => "image/png",
+        Some("jpg" | "jpeg") => "image/jpeg",
+        Some("webp") => "image/webp",
+        Some("avif") => "image/avif",
+        Some("ico") => "image/x-icon",
+        Some("woff2") => "font/woff2",
+        _ => "application/octet-stream",
+    }
+}
+
+fn html_escape(s: &str) -> String {
+    s.replace('&', "&amp;")
+        .replace('<', "&lt;")
+        .replace('>', "&gt;")
+}
+
+// --- WebSocket ---
+
+async fn live_socket(
+    State(shared): State<SharedState>,
+    headers: HeaderMap,
+    upgrade: WebSocketUpgrade,
+) -> Response {
+    let origin = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok());
+    let host = headers.get(header::HOST).and_then(|v| v.to_str().ok());
+
+    if !origin_allowed(origin, host) {
+        // Cross-Site WebSocket Hijacking 방어. 판단할 수 없으면 거절한다.
+        return (StatusCode::FORBIDDEN, "origin not allowed").into_response();
+    }
+
+    upgrade.on_upgrade(move |socket| handle_socket(socket, shared))
+}
+
+async fn handle_socket(mut socket: ws::WebSocket, shared: SharedState) {
+    let mut rx = shared.tx.subscribe();
+
+    // 지금 빌드가 깨져 있다면 새로 붙은 탭에도 즉시 알려준다. 이게 없으면 리로드
+    // 도중에 접속한 탭은 왜 화면이 옛날인지 알 수 없다.
+    if let Some(err) = shared.error.read().ok().and_then(|e| e.clone())
+        && socket
+            .send(ws::Message::Text(Signal::Error(err).encode().into()))
+            .await
+            .is_err()
+    {
+        return;
+    }
+
+    while let Ok(signal) = rx.recv().await {
+        if socket
+            .send(ws::Message::Text(signal.encode().into()))
+            .await
+            .is_err()
+        {
+            break;
+        }
+    }
+}
+
+/// WebSocket 핸드셰이크의 Origin 검증.
+///
+/// **판단할 수 없으면 거절한다(fail closed).** Origin이 없거나 파싱이 안 되면 통과시키지
+/// 않는다 — 개발 서버는 로컬에만 떠 있지만, 사용자가 아무 사이트나 열어둔 상태에서
+/// 그 페이지가 이 소켓에 붙어 리빌드 신호를 엿보거나 트리거할 수 있으면 안 된다.
+fn origin_allowed(origin: Option<&str>, host: Option<&str>) -> bool {
+    let Some(origin) = origin else { return false };
+    let Some(origin_host) = host_of_origin(origin) else {
+        return false;
+    };
+    if is_loopback_host(&origin_host) {
+        return true;
+    }
+    match host.and_then(strip_port) {
+        Some(h) => h.eq_ignore_ascii_case(&origin_host),
+        None => false,
+    }
+}
+
+/// `scheme://host[:port]` 에서 host를 뽑는다. 대괄호 IPv6(`[::1]:3000`)를 처리한다.
+fn host_of_origin(origin: &str) -> Option<String> {
+    let rest = origin.split_once("://")?.1;
+    let authority = rest.split(['/', '?', '#']).next()?;
+    strip_port(authority).map(|h| h.to_ascii_lowercase())
+}
+
+fn strip_port(authority: &str) -> Option<&str> {
+    if let Some(end) = authority.find(']') {
+        // 대괄호 IPv6: `[::1]:3000` → `[::1]`
+        return authority.get(..=end);
+    }
+    Some(match authority.rsplit_once(':') {
+        Some((h, port)) if port.chars().all(|c| c.is_ascii_digit()) => h,
+        _ => authority,
+    })
+}
+
+fn is_loopback_host(host: &str) -> bool {
+    matches!(host, "localhost" | "127.0.0.1" | "[::1]" | "::1")
+}
+
+// --- 브라우저로 주입되는 코드 ---
+
+fn overlay(error: Option<&str>) -> String {
+    let Some(err) = error else {
+        return String::new();
+    };
+    format!(
+        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>
+
+{}</div>"#,
+        html_escape(err)
+    )
+}
+
+fn reload_script() -> String {
+    format!(
+        r#"<script>(function(){{
+var path={LIVE_PATH:?};
+var back=500, ws;
+function overlay(msg){{
+  var el=document.getElementById("__sqzass_error");
+  if(!msg){{ if(el) el.remove(); return; }}
+  if(!el){{ el=document.createElement("div"); el.id="__sqzass_error";
+    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");
+    document.body.appendChild(el); }}
+  el.textContent="build failed\n\n"+msg;
+}}
+function swapCss(){{
+  document.querySelectorAll('link[rel="stylesheet"]').forEach(function(l){{
+    var u=new URL(l.href,location.href); u.searchParams.set("__r",Date.now()); l.href=u.href;
+  }});
+}}
+function connect(){{
+  ws=new WebSocket((location.protocol==="https:"?"wss://":"ws://")+location.host+path);
+  ws.onopen=function(){{ back=500; }};
+  ws.onmessage=function(e){{
+    var m=e.data;
+    if(m==="reload"){{ sessionStorage.setItem("__sqzass_y",String(scrollY)); location.reload(); }}
+    else if(m==="css"){{ swapCss(); overlay(null); }}
+    else if(m==="clear"){{ overlay(null); location.reload(); }}
+    else if(m.indexOf("error:")===0){{ overlay(m.slice(6).replace(/\\n/g,"\n")); }}
+  }};
+  // 지수 백오프. 서버를 재시작해도 탭이 알아서 다시 붙는다.
+  ws.onclose=function(){{ setTimeout(connect,back); back=Math.min(back*2,10000); }};
+}}
+var y=sessionStorage.getItem("__sqzass_y");
+if(y!==null){{ sessionStorage.removeItem("__sqzass_y");
+  addEventListener("load",function(){{ scrollTo(0,parseInt(y,10)||0); }}); }}
+connect();
+}})();</script>"#
+    )
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn resolves_directory_urls_to_index() {
+        assert_eq!(resolve_key("/"), "index.html");
+        assert_eq!(resolve_key("/ko/"), "ko/index.html");
+        assert_eq!(
+            resolve_key("/start/installation/"),
+            "start/installation/index.html"
+        );
+    }
+
+    #[test]
+    fn resolves_extensionless_urls_to_index() {
+        // 트레일링 슬래시가 없어도 같은 페이지를 준다. 프로덕션 호스트가
+        // 리다이렉트해 주는 경우가 많아서, 개발 중에만 404가 나면 혼란스럽다.
+        assert_eq!(resolve_key("/start"), "start/index.html");
+    }
+
+    #[test]
+    fn passes_through_files_with_extensions() {
+        assert_eq!(resolve_key("/assets/highlight.css"), "assets/highlight.css");
+        assert_eq!(resolve_key("/.nojekyll"), ".nojekyll");
+    }
+
+    #[test]
+    fn origin_must_be_present_and_parseable() {
+        // fail closed: 판단할 수 없으면 거절
+        assert!(!origin_allowed(None, Some("127.0.0.1:3000")));
+        assert!(!origin_allowed(Some("garbage"), Some("127.0.0.1:3000")));
+        assert!(!origin_allowed(Some(""), Some("127.0.0.1:3000")));
+    }
+
+    #[test]
+    fn loopback_origins_are_allowed() {
+        assert!(origin_allowed(
+            Some("http://localhost:3000"),
+            Some("localhost:3000")
+        ));
+        assert!(origin_allowed(
+            Some("http://127.0.0.1:3000"),
+            Some("127.0.0.1:3000")
+        ));
+        assert!(origin_allowed(
+            Some("http://[::1]:3000"),
+            Some("[::1]:3000")
+        ));
+    }
+
+    #[test]
+    fn foreign_origins_are_rejected() {
+        assert!(!origin_allowed(
+            Some("https://evil.example"),
+            Some("127.0.0.1:3000")
+        ));
+        // 접두사만 같은 도메인도 막아야 한다
+        assert!(!origin_allowed(
+            Some("http://localhost.evil.example"),
+            Some("localhost:3000")
+        ));
+    }
+
+    #[test]
+    fn matching_non_loopback_host_is_allowed() {
+        // LAN의 다른 기기에서 --bind 0.0.0.0 으로 접근하는 경우
+        assert!(origin_allowed(
+            Some("http://192.168.1.50:3000"),
+            Some("192.168.1.50:3000")
+        ));
+        assert!(!origin_allowed(
+            Some("http://192.168.1.99:3000"),
+            Some("192.168.1.50:3000")
+        ));
+    }
+
+    #[test]
+    fn strips_ports_but_keeps_ipv6_brackets() {
+        assert_eq!(strip_port("localhost:3000"), Some("localhost"));
+        assert_eq!(strip_port("example.com"), Some("example.com"));
+        assert_eq!(strip_port("[::1]:3000"), Some("[::1]"));
+        assert_eq!(strip_port("[::1]"), Some("[::1]"));
+    }
+
+    #[test]
+    fn editor_temp_files_are_ignored() {
+        assert!(is_noise(Path::new("content/.post.md.swp")));
+        assert!(is_noise(Path::new("content/post.md~")));
+        assert!(is_noise(Path::new("content/#post.md#")));
+        assert!(is_noise(Path::new("/repo/.git/index")));
+        assert!(!is_noise(Path::new("content/post.md")));
+        assert!(!is_noise(Path::new("templates/base.html")));
+    }
+
+    #[test]
+    fn signals_encode_on_one_line() {
+        assert_eq!(Signal::Reload.encode(), "reload");
+        assert_eq!(Signal::Css.encode(), "css");
+        let e = Signal::Error("line1\nline2".into()).encode();
+        assert!(!e.contains('\n'), "개행이 프레임을 깬다: {e}");
+        assert!(e.starts_with("error:"));
+    }
+}