Ver código fonte

feat(cli): hotreload support for frontend static files, closes #2173 (#5256)

Co-authored-by: Lucas Nogueira <lucas@tauri.studio>
Amr Bashir 2 anos atrás
pai
commit
54c337e06f

+ 5 - 0
.changes/cli-static-files-hot-reload.md

@@ -0,0 +1,5 @@
+---
+"cli.rs": "minor"
+---
+
+Hot-reload the frontend when `tauri.conf.json > build > devPath` points to a directory.

+ 5 - 0
.changes/utils-mimetype.md

@@ -0,0 +1,5 @@
+---
+"tauri-utils": "patch"
+---
+
+Add `mime_type` module.

+ 0 - 1
core/tauri-runtime/Cargo.toml

@@ -30,7 +30,6 @@ tauri-utils = { version = "1.1.1", path = "../tauri-utils" }
 uuid = { version = "1", features = [ "v4" ] }
 http = "0.2.4"
 http-range = "0.1.4"
-infer = "0.7"
 raw-window-handle = "0.5"
 rand = "0.8"
 

+ 2 - 2
core/tauri-runtime/src/http/mod.rs

@@ -3,16 +3,16 @@
 // SPDX-License-Identifier: MIT
 
 // custom wry types
-mod mime_type;
 mod request;
 mod response;
 
 pub use self::{
-  mime_type::MimeType,
   request::{Request, RequestParts},
   response::{Builder as ResponseBuilder, Response, ResponseParts},
 };
 
+pub use tauri_utils::mime_type::MimeType;
+
 // re-expose default http types
 pub use http::{header, method, status, uri::InvalidUri, version, Uri};
 

+ 1 - 0
core/tauri-utils/Cargo.toml

@@ -35,6 +35,7 @@ glob = { version = "0.3.0", optional = true }
 walkdir = { version = "2", optional = true }
 memchr = "2.4"
 semver = "1"
+infer = "0.7"
 
 [target."cfg(target_os = \"linux\")".dependencies]
 heck = "0.4"

+ 1 - 0
core/tauri-utils/src/lib.rs

@@ -14,6 +14,7 @@ pub mod assets;
 pub mod config;
 pub mod html;
 pub mod io;
+pub mod mime_type;
 pub mod platform;
 /// Prepare application resources and sidecars.
 #[cfg(feature = "resources")]

+ 3 - 0
core/tauri-runtime/src/http/mime_type.rs → core/tauri-utils/src/mime_type.rs

@@ -2,11 +2,14 @@
 // SPDX-License-Identifier: Apache-2.0
 // SPDX-License-Identifier: MIT
 
+//! Determine a mime type from a URI or file contents.
+
 use std::fmt;
 
 const MIMETYPE_PLAIN: &str = "text/plain";
 
 /// [Web Compatible MimeTypes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types#important_mime_types_for_web_developers)
+#[allow(missing_docs)]
 pub enum MimeType {
   Css,
   Csv,

+ 228 - 17
tooling/cli/Cargo.lock

@@ -91,6 +91,17 @@ version = "0.9.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d67af77d68a931ecd5cbd8a3b5987d63a1d1d1278f7f6a60ae33db485cdebb69"
 
+[[package]]
+name = "async-trait"
+version = "0.1.57"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76464446b8bc32758d7e88ee1a804d9914cd9b1cb264c029899680b0be29826f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "attohttpc"
 version = "0.22.0"
@@ -121,6 +132,56 @@ version = "1.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
 
+[[package]]
+name = "axum"
+version = "0.5.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9e3356844c4d6a6d6467b8da2cffb4a2820be256f50a3a386c9d152bab31043"
+dependencies = [
+ "async-trait",
+ "axum-core",
+ "base64",
+ "bitflags",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "itoa 1.0.2",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sha-1 0.10.0",
+ "sync_wrapper",
+ "tokio",
+ "tokio-tungstenite",
+ "tower",
+ "tower-http",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.2.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9f0c0a60006f2a293d82d571f635042a72edf927539b7685bd62d361963839b"
+dependencies = [
+ "async-trait",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "mime",
+ "tower-layer",
+ "tower-service",
+]
+
 [[package]]
 name = "base64"
 version = "0.13.0"
@@ -277,6 +338,27 @@ dependencies = [
  "jobserver",
 ]
 
+[[package]]
+name = "cfb"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74f89d248799e3f15f91b70917f65381062a01bb8e222700ea0e5a7ff9785f9c"
+dependencies = [
+ "byteorder",
+ "uuid 0.8.2",
+]
+
+[[package]]
+name = "cfb"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
+dependencies = [
+ "byteorder",
+ "fnv",
+ "uuid 1.1.2",
+]
+
 [[package]]
 name = "cfg-if"
 version = "0.1.10"
@@ -729,16 +811,15 @@ dependencies = [
 
 [[package]]
 name = "exr"
-version = "1.4.2"
+version = "1.5.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "14cc0e06fb5f67e5d6beadf3a382fec9baca1aa751c6d5368fdeee7e5932c215"
+checksum = "c9a7880199e74c6d3fe45579df2f436c5913a71405494cb89d59234d86b47dc5"
 dependencies = [
  "bit_field",
- "deflate",
  "flume",
  "half",
- "inflate",
  "lebe",
+ "miniz_oxide",
  "smallvec",
  "threadpool",
 ]
@@ -912,9 +993,9 @@ checksum = "bbf4d2a7a308fd4578637c0b17c7e1c7ba127b8f6ba00b29f717e9655d85eb68"
 
 [[package]]
 name = "futures-sink"
-version = "0.3.21"
+version = "0.3.24"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "21163e139fa306126e6eedaf49ecdb4588f939600f0b1e770f4205ee4b7fa868"
+checksum = "21b20ba5a92e727ba30e72834706623d94ac93a725410b6a6b6fbc1b07f7ba56"
 
 [[package]]
 name = "futures-task"
@@ -930,6 +1011,7 @@ checksum = "44fb6cb1be61cc1d2e43b262516aafcf63b241cffdb1d3fa115f91d9c7b09c90"
 dependencies = [
  "futures-core",
  "futures-io",
+ "futures-sink",
  "futures-task",
  "memchr",
  "pin-project-lite",
@@ -1148,6 +1230,12 @@ dependencies = [
  "pin-project-lite",
 ]
 
+[[package]]
+name = "http-range-header"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bfe8eed0a9285ef776bb792479ea3834e8b94e13d615c2f66d03dd50a435a29"
+
 [[package]]
 name = "httparse"
 version = "1.8.0"
@@ -1227,9 +1315,9 @@ dependencies = [
 
 [[package]]
 name = "image"
-version = "0.24.3"
+version = "0.24.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e30ca2ecf7666107ff827a8e481de6a132a9b687ed3bb20bb1c144a36c00964"
+checksum = "bd8e4fb07cf672b1642304e731ef8a6a4c7891d67bb4fd4f5ce58cd6ed86803c"
 dependencies = [
  "bytemuck",
  "byteorder",
@@ -1275,12 +1363,21 @@ dependencies = [
 ]
 
 [[package]]
-name = "inflate"
-version = "0.4.5"
+name = "infer"
+version = "0.7.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1cdb29978cc5797bd8dcc8e5bf7de604891df2a8dc576973d71a281e916db2ff"
+checksum = "20b2b533137b9cad970793453d4f921c2e91312a6d88b1085c07bc15fc51bb3b"
 dependencies = [
- "adler32",
+ "cfb 0.6.1",
+]
+
+[[package]]
+name = "infer"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f178e61cdbfe084aa75a2f4f7a25a5bb09701a47ae1753608f194b15783c937a"
+dependencies = [
+ "cfb 0.7.3",
 ]
 
 [[package]]
@@ -1471,9 +1568,9 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
 
 [[package]]
 name = "lebe"
-version = "0.5.1"
+version = "0.5.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7efd1d698db0759e6ef11a7cd44407407399a910c774dd804c64c032da7826ff"
+checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8"
 
 [[package]]
 name = "libc"
@@ -1572,6 +1669,12 @@ version = "0.1.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f"
 
+[[package]]
+name = "matchit"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73cbba799671b762df5a175adf59ce145165747bb891505c43d09aefbbf38beb"
+
 [[package]]
 name = "md5"
 version = "0.7.0"
@@ -1618,9 +1721,9 @@ dependencies = [
 
 [[package]]
 name = "miniz_oxide"
-version = "0.5.3"
+version = "0.5.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6f5c75688da582b8ffc1f1799e9db273f32133c49e048f614d22ec3256773ccc"
+checksum = "96590ba8f175222643a85693f33d26e9c8a015f599c216509b1a6894af675d34"
 dependencies = [
  "adler",
 ]
@@ -2119,7 +2222,7 @@ checksum = "54be6e404f5317079812fc8f9f5279de376d8856929e21c184ecf6bbd692a11d"
 dependencies = [
  "maplit",
  "pest",
- "sha-1",
+ "sha-1 0.8.2",
 ]
 
 [[package]]
@@ -2862,6 +2965,17 @@ dependencies = [
  "opaque-debug 0.2.3",
 ]
 
+[[package]]
+name = "sha-1"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "028f48d513f9678cda28f6e4064755b3fbb2af6acd672f2c209b62323f7aea0f"
+dependencies = [
+ "cfg-if 1.0.0",
+ "cpufeatures",
+ "digest 0.10.3",
+]
+
 [[package]]
 name = "sha1"
 version = "0.10.1"
@@ -3034,6 +3148,12 @@ dependencies = [
  "unicode-ident",
 ]
 
+[[package]]
+name = "sync_wrapper"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20518fe4a4c9acf048008599e464deb21beeae3d3578418951a189c235a7a9a8"
+
 [[package]]
 name = "sysctl"
 version = "0.4.4"
@@ -3100,6 +3220,7 @@ name = "tauri-cli"
 version = "1.1.1"
 dependencies = [
  "anyhow",
+ "axum",
  "base64",
  "clap 3.2.7",
  "colored",
@@ -3109,11 +3230,14 @@ dependencies = [
  "glob",
  "handlebars",
  "heck 0.4.0",
+ "html5ever",
  "ignore",
  "image",
  "include_dir",
+ "infer 0.9.0",
  "json-patch",
  "jsonschema",
+ "kuchiki",
  "lazy_static",
  "libc",
  "log",
@@ -3132,6 +3256,7 @@ dependencies = [
  "tauri-icns",
  "tauri-utils",
  "tempfile",
+ "tokio",
  "toml",
  "toml_edit",
  "unicode-width",
@@ -3172,6 +3297,7 @@ dependencies = [
  "glob",
  "heck 0.4.0",
  "html5ever",
+ "infer 0.7.0",
  "json-patch",
  "json5",
  "kuchiki",
@@ -3352,9 +3478,33 @@ dependencies = [
  "once_cell",
  "pin-project-lite",
  "socket2",
+ "tokio-macros",
  "winapi 0.3.9",
 ]
 
+[[package]]
+name = "tokio-macros"
+version = "1.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9724f9a975fb987ef7a3cd9be0350edcbe130698af5b8f7a631e23d42d052484"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tokio-tungstenite"
+version = "0.17.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f714dd15bead90401d77e04243611caec13726c2408afd5b31901dfcdcb3b181"
+dependencies = [
+ "futures-util",
+ "log",
+ "tokio",
+ "tungstenite",
+]
+
 [[package]]
 name = "tokio-util"
 version = "0.7.4"
@@ -3389,6 +3539,47 @@ dependencies = [
  "itertools",
 ]
 
+[[package]]
+name = "tower"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project",
+ "pin-project-lite",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c530c8675c1dbf98facee631536fa116b5fb6382d7dd6dc1b118d970eafe3ba"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-range-header",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "343bc9466d3fe6b0f960ef45960509f84480bf4fd96f92901afe7ff3df9d3a62"
+
 [[package]]
 name = "tower-service"
 version = "0.3.2"
@@ -3402,6 +3593,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2fce9567bd60a67d08a16488756721ba392f24f29006402881e43b19aac64307"
 dependencies = [
  "cfg-if 1.0.0",
+ "log",
  "pin-project-lite",
  "tracing-core",
 ]
@@ -3430,6 +3622,25 @@ version = "0.2.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642"
 
+[[package]]
+name = "tungstenite"
+version = "0.17.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e27992fd6a8c29ee7eef28fc78349aa244134e10ad447ce3b9f0ac0ed0fa4ce0"
+dependencies = [
+ "base64",
+ "byteorder",
+ "bytes",
+ "http",
+ "httparse",
+ "log",
+ "rand 0.8.5",
+ "sha-1 0.10.0",
+ "thiserror",
+ "url",
+ "utf-8",
+]
+
 [[package]]
 name = "typenum"
 version = "1.15.0"

+ 6 - 0
tooling/cli/Cargo.toml

@@ -76,6 +76,12 @@ log = { version = "0.4.17", features = [ "kv_unstable", "kv_unstable_std" ] }
 env_logger = "0.9.0"
 icns = { package = "tauri-icns", version = "0.1" }
 image = { version = "0.24", default-features = false, features = [ "ico" ] }
+axum = { version = "0.5.16", features = ["ws"] }
+html5ever = "0.25"
+infer = "0.9"
+kuchiki = "0.8"
+tokio = { version = "1.21.1", features = ["macros", "sync"] }
+
 
 [target."cfg(windows)".dependencies]
 winapi = { version = "0.3", features = [ "handleapi", "processenv", "winbase", "wincon", "winnt" ] }

+ 36 - 10
tooling/cli/src/dev.rs

@@ -6,7 +6,7 @@ use crate::{
   helpers::{
     app_paths::{app_dir, tauri_dir},
     command_env,
-    config::{get as get_config, AppUrl, BeforeDevCommand, WindowUrl},
+    config::{get as get_config, reload as reload_config, AppUrl, BeforeDevCommand, WindowUrl},
   },
   interface::{AppInterface, ExitReason, Interface},
   CommandExt, Result,
@@ -202,16 +202,42 @@ fn command_internal(mut options: Options) -> Result<()> {
     cargo_features.extend(features.clone());
   }
 
+  let mut dev_path = config
+    .lock()
+    .unwrap()
+    .as_ref()
+    .unwrap()
+    .build
+    .dev_path
+    .clone();
+  if let AppUrl::Url(WindowUrl::App(path)) = &dev_path {
+    use crate::helpers::web_dev_server::{start_dev_server, SERVER_URL};
+    if path.exists() {
+      let path = path.canonicalize()?;
+      start_dev_server(path);
+      dev_path = AppUrl::Url(WindowUrl::External(SERVER_URL.parse().unwrap()));
+
+      // TODO: in v2, use an env var to pass the url to the app context
+      // or better separate the config passed from the cli internally and
+      // config passed by the user in `--config` into to separate env vars
+      // and the context merges, the user first, then the internal cli config
+      if let Some(c) = options.config {
+        let mut c: tauri_utils::config::Config = serde_json::from_str(&c)?;
+        c.build.dev_path = dev_path.clone();
+        options.config = Some(serde_json::to_string(&c).unwrap());
+      } else {
+        options.config = Some(format!(
+          r#"{{ "build": {{ "devPath": "{}" }} }}"#,
+          SERVER_URL
+        ))
+      }
+    }
+  }
+
+  let config = reload_config(options.config.as_deref())?;
+
   if std::env::var_os("TAURI_SKIP_DEVSERVER_CHECK") != Some("true".into()) {
-    if let AppUrl::Url(WindowUrl::External(dev_server_url)) = config
-      .lock()
-      .unwrap()
-      .as_ref()
-      .unwrap()
-      .build
-      .dev_path
-      .clone()
-    {
+    if let AppUrl::Url(WindowUrl::External(dev_server_url)) = dev_path {
       let host = dev_server_url
         .host()
         .unwrap_or_else(|| panic!("No host name in the URL"));

+ 29 - 0
tooling/cli/src/helpers/auto-reload.js

@@ -0,0 +1,29 @@
+
+// taken from https://github.com/thedodd/trunk/blob/5c799dc35f1f1d8f8d3d30c8723cbb761a9b6a08/src/autoreload.js
+
+(function () {
+  var url = 'ws:' + '//' + window.location.host + '/_tauri-cli/ws';
+  var poll_interval = 5000;
+  var reload_upon_connect = () => {
+    window.setTimeout(
+      () => {
+        // when we successfully reconnect, we'll force a
+        // reload (since we presumably lost connection to
+        // trunk due to it being killed, so it will have
+        // rebuilt on restart)
+        var ws = new WebSocket(url);
+        ws.onopen = () => window.location.reload();
+        ws.onclose = reload_upon_connect;
+      },
+      poll_interval);
+  };
+
+  var ws = new WebSocket(url);
+  ws.onmessage = (ev) => {
+    const msg = JSON.parse(ev.data);
+    if (msg.reload) {
+      window.location.reload();
+    }
+  };
+  ws.onclose = reload_upon_connect;
+})()

+ 1 - 0
tooling/cli/src/helpers/mod.rs

@@ -7,6 +7,7 @@ pub mod config;
 pub mod framework;
 pub mod template;
 pub mod updater_signature;
+pub mod web_dev_server;
 
 use std::{
   collections::HashMap,

+ 154 - 0
tooling/cli/src/helpers/web_dev_server.rs

@@ -0,0 +1,154 @@
+use axum::{
+  extract::{ws::WebSocket, WebSocketUpgrade},
+  http::{header::CONTENT_TYPE, Request, StatusCode},
+  response::IntoResponse,
+  routing::get,
+  Router, Server,
+};
+use html5ever::{namespace_url, ns, LocalName, QualName};
+use kuchiki::{traits::TendrilSink, NodeRef};
+use notify::{watcher, DebouncedEvent, RecursiveMode, Watcher};
+use std::{
+  net::SocketAddr,
+  path::{Path, PathBuf},
+  str::FromStr,
+  sync::Arc,
+  thread,
+  time::Duration,
+};
+use tauri_utils::mime_type::MimeType;
+use tokio::sync::broadcast::{channel, Sender};
+
+const AUTO_RELOAD_SCRIPT: &str = include_str!("./auto-reload.js");
+pub const SERVER_URL: &str = "http://127.0.0.1:1430";
+
+struct State {
+  serve_dir: PathBuf,
+  tx: Sender<()>,
+}
+
+pub fn start_dev_server<P: AsRef<Path>>(path: P) {
+  let serve_dir = path.as_ref().to_path_buf();
+
+  std::thread::spawn(move || {
+    tokio::runtime::Builder::new_current_thread()
+      .enable_io()
+      .build()
+      .unwrap()
+      .block_on(async move {
+        let (tx, _) = channel(1);
+
+        let tokio_tx = tx.clone();
+        let serve_dir_ = serve_dir.clone();
+        thread::spawn(move || {
+          let (tx, rx) = std::sync::mpsc::channel();
+          let mut watcher = watcher(tx, Duration::from_secs(1)).unwrap();
+          watcher.watch(serve_dir_, RecursiveMode::Recursive).unwrap();
+
+          loop {
+            if let Ok(e) = rx.recv() {
+              match e {
+                DebouncedEvent::Create(_)
+                | DebouncedEvent::Remove(_)
+                | DebouncedEvent::Rename(_, _)
+                | DebouncedEvent::Write(_) => {
+                  let _ = tokio_tx.send(());
+                }
+                _ => {}
+              }
+            }
+          }
+        });
+
+        let state = Arc::new(State { serve_dir, tx });
+        let router = Router::new()
+          .fallback(
+            Router::new().nest(
+              "/",
+              get({
+                let state = state.clone();
+                move |req| handler(req, state)
+              })
+              .handle_error(|_error| async move { StatusCode::INTERNAL_SERVER_ERROR }),
+            ),
+          )
+          .route(
+            "/_tauri-cli/ws",
+            get(move |ws: WebSocketUpgrade| async move {
+              ws.on_upgrade(|socket| async move { ws_handler(socket, state).await })
+            }),
+          );
+        Server::bind(&SocketAddr::from_str(SERVER_URL.split('/').nth(2).unwrap()).unwrap())
+          .serve(router.into_make_service())
+          .await
+          .unwrap();
+      })
+  });
+}
+
+async fn handler<T>(req: Request<T>, state: Arc<State>) -> impl IntoResponse {
+  let uri = req.uri().to_string();
+  let uri = if uri == "/" {
+    &uri
+  } else {
+    uri.strip_prefix('/').unwrap_or(&uri)
+  };
+
+  let file = std::fs::read(state.serve_dir.join(&uri))
+    .or_else(|_| std::fs::read(state.serve_dir.join(format!("{}.html", &uri))))
+    .or_else(|_| std::fs::read(state.serve_dir.join(format!("{}/index.html", &uri))))
+    .or_else(|_| std::fs::read(state.serve_dir.join("index.html")));
+
+  file
+    .map(|mut f| {
+      let mime_type = MimeType::parse(&f, uri);
+      if mime_type == MimeType::Html.to_string() {
+        let mut document = kuchiki::parse_html().one(String::from_utf8_lossy(&f).into_owned());
+        fn with_html_head<F: FnOnce(&NodeRef)>(document: &mut NodeRef, f: F) {
+          if let Ok(ref node) = document.select_first("head") {
+            f(node.as_node())
+          } else {
+            let node = NodeRef::new_element(
+              QualName::new(None, ns!(html), LocalName::from("head")),
+              None,
+            );
+            f(&node);
+            document.prepend(node)
+          }
+        }
+
+        with_html_head(&mut document, |head| {
+          let script_el =
+            NodeRef::new_element(QualName::new(None, ns!(html), "script".into()), None);
+          script_el.append(NodeRef::new_text(AUTO_RELOAD_SCRIPT));
+          head.prepend(script_el);
+        });
+
+        f = document.to_string().as_bytes().to_vec();
+      }
+
+      (StatusCode::OK, [(CONTENT_TYPE, mime_type)], f)
+    })
+    .unwrap_or_else(|_| {
+      (
+        StatusCode::NOT_FOUND,
+        [(CONTENT_TYPE, "text/plain".into())],
+        vec![],
+      )
+    })
+}
+
+async fn ws_handler(mut ws: WebSocket, state: Arc<State>) {
+  let mut rx = state.tx.subscribe();
+  while tokio::select! {
+      _ = ws.recv() => return,
+      fs_reload_event = rx.recv() => fs_reload_event.is_ok(),
+  } {
+    let ws_send = ws.send(axum::extract::ws::Message::Text(
+      r#"{"reload": true}"#.to_owned(),
+    ));
+    if ws_send.await.is_err() {
+      break;
+    }
+  }
+}