diff --git a/.github/workflows/plugins.yml b/.github/workflows/plugins.yml new file mode 100644 index 0000000..83cd6ee --- /dev/null +++ b/.github/workflows/plugins.yml @@ -0,0 +1,34 @@ +on: + push: + paths: + - "plugins/**" + - ".github/**" + +name: Plugins + +jobs: + check: + name: Check Plugins + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v2 + + - name: Install stable toolchain + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + + - name: Check PHP plugin + uses: actions-rs/cargo@v1 + with: + command: check + args: --manifest-path plugins/php/Cargo.toml + + - name: Check Hot Reload plugin + uses: actions-rs/cargo@v1 + with: + command: check + args: --manifest-path plugins/hot-reload/Cargo.toml \ No newline at end of file diff --git a/build.bat b/build.bat index f5cabbb..d5704a4 100644 --- a/build.bat +++ b/build.bat @@ -6,6 +6,8 @@ :: - Linux with all features (TLS and plugins) :: - PHP plugin for Windows :: - PHP plugin for Linux +:: - Hot Reload plugin for Windows +:: - Hot Reload plugin for Linux :: :: Requires Rust to be installed both normally and in WSL. @@ -55,6 +57,20 @@ robocopy target/release ../../dist libphp.so > nul cd ../../dist rename libphp.so php_plugin_linux.so +echo Building Hot Reload plugin for Windows... +cd ../plugins/hot-reload +cargo build --release -q +robocopy target/release ../../dist hot_reload.dll > nul +cd ../../dist +rename hot_reload.dll hot_reload_plugin_windows.dll + +echo Building Hot Reload plugin for Linux... +cd ../plugins/hot-reload +wsl $HOME/.cargo/bin/cargo build --release -q +robocopy target/release ../../dist libhot_reload.so > nul +cd ../../dist +rename libhot_reload.so hot_reload_plugin_linux.so + cd .. echo Build complete. \ No newline at end of file diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index ee958fa..68970db 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -13,6 +13,7 @@ - [Configuration](server/configuration.md) - [Using PHP](server/using-php.md) - [Using HTTPS](server/https.md) + - [Using Hot Reload](server/hot-reload.md) - [Creating a Plugin](server/creating-a-plugin.md) - [Humphrey WebSocket](websocket/index.md) - [Synchronous](websocket/sync/index.md) diff --git a/docs/src/introduction.md b/docs/src/introduction.md index 0880994..22a8ed6 100644 --- a/docs/src/introduction.md +++ b/docs/src/introduction.md @@ -27,7 +27,7 @@ This book is up-to-date with the following crate versions. | Crate | Version | | ----- | ------- | | Humphrey Core | 0.6.0 | -| Humphrey Server | 0.5.0 | +| Humphrey Server | 0.6.0 | | Humphrey WebSocket | 0.4.0 | | Humphrey JSON | 0.2.0 | | Humphrey Auth | 0.1.3 | \ No newline at end of file diff --git a/docs/src/server/hot-reload.md b/docs/src/server/hot-reload.md new file mode 100644 index 0000000..b0f5a44 --- /dev/null +++ b/docs/src/server/hot-reload.md @@ -0,0 +1,22 @@ +# Hot Reload +Humphrey supports hot reload through a first-party plugin, provided that the server was compiled with the `plugins` feature enabled and the plugin is installed. + +The Hot Reload plugin is able to automatically reload webpages when the source code changes. It is not recommended for use in production, but is useful for development. It should also be noted that, when using a front-end framework such as React, the framework's built-in HMR (hot module reloading) functionality should be used instead of this plugin. + +HTML pages are reloaded by requesting the updated page through a `fetch` call, then writing this to the page. This avoids the need for the page to be reloaded manually. CSS and JavaScript are reloaded by requesting the updated data, then replacing the old script or stylesheet. Images are reloaded in the same way. Other resources are currently unable to be dynamically reloaded. + +When JavaScript is reloaded, the updated script will be executed upon load in the same context as the old script. This means that any `const` declarations may cause errors, but this is unavoidable as without executing the new script, none of the changes can be used. For this reason, the Hot Reload plugin is more suitable for design changes than for functionality changes. + +**Warning:** Hot Reload disables caching so that changes are immediately visible. + +## Configuration +In the plugins section of the configuration file, add the following: + +```conf +hot-reload { + library "path/to/hot-reload.dll" # Path to the compiled library + ws_route "/ws" # Route to the WebSocket endpoint +} +``` + +Specifying the WebSocket route is optional. If not specified, the default is `/__hot-reload-ws` in order to avoid conflicts with other configured WebSocket endpoints. \ No newline at end of file diff --git a/examples/plugin/Cargo.toml b/examples/plugin/Cargo.toml index 2110e80..e3e1f58 100644 --- a/examples/plugin/Cargo.toml +++ b/examples/plugin/Cargo.toml @@ -8,6 +8,7 @@ edition = "2018" [dependencies] humphrey = { path = "../../humphrey" } humphrey_server = { path = "../../humphrey-server", features = ["plugins"] } +humphrey_ws = { path = "../../humphrey-ws" } [lib] crate-type = ["cdylib", "rlib"] diff --git a/examples/plugin/src/lib.rs b/examples/plugin/src/lib.rs index d423cfb..fd9a337 100644 --- a/examples/plugin/src/lib.rs +++ b/examples/plugin/src/lib.rs @@ -1,11 +1,14 @@ use humphrey::http::headers::HeaderType; use humphrey::http::{Request, Response, StatusCode}; +use humphrey::stream::Stream; use humphrey_server::config::RouteConfig; use humphrey_server::declare_plugin; use humphrey_server::plugins::plugin::Plugin; use humphrey_server::server::server::AppState; +use humphrey_ws::{websocket_handler, Message, WebsocketStream}; + use std::sync::Arc; #[derive(Debug, Default)] @@ -41,7 +44,37 @@ impl Plugin for ExamplePlugin { None } - fn on_response(&self, response: &mut Response, state: Arc) { + fn on_websocket_request( + &self, + request: &mut Request, + stream: Stream, + state: Arc, + _: Option<&RouteConfig>, + ) -> Option { + state.logger.info(&format!( + "Example plugin read a WebSocket request from {}", + request.address + )); + + // If the requested resource is "/override" then override the response (which would ordinarily be closing the WebSocket connection). For this example, we'll just complete the WebSocket handshake and send a message back to the client. + if &request.uri == "/override" { + state + .logger + .info("Example plugin overrode a WebSocket connection"); + + websocket_handler(|mut stream: WebsocketStream, _| { + stream + .send(Message::new(b"Response overridden by example plugin :)")) + .ok(); + })(request.clone(), stream, state); + + return None; + } + + Some(stream) + } + + fn on_response(&self, response: &mut Response, state: Arc, _: &RouteConfig) { // Insert a header to the response response.headers.add("X-Example-Plugin", "true"); diff --git a/humphrey-server/Cargo.toml b/humphrey-server/Cargo.toml index 87f5362..39717e3 100644 --- a/humphrey-server/Cargo.toml +++ b/humphrey-server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "humphrey_server" -version = "0.5.2" +version = "0.6.0" edition = "2018" license = "MIT" homepage = "https://github.com/w-henderson/Humphrey" diff --git a/humphrey-server/src/config/config.rs b/humphrey-server/src/config/config.rs index f5b01ae..520304e 100644 --- a/humphrey-server/src/config/config.rs +++ b/humphrey-server/src/config/config.rs @@ -57,7 +57,7 @@ pub struct HostConfig { } /// Represents the type of a route. -#[derive(Copy, Clone, Debug, PartialEq)] +#[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum RouteType { /// Serve a single file. File, @@ -67,6 +67,8 @@ pub enum RouteType { Proxy, /// Redirect clients to another server. Redirect, + /// Proxies WebSocket requests to this route to another server. + ExclusiveWebSocket, } /// Represents configuration for a specific route. @@ -506,6 +508,14 @@ fn parse_route( }); } else if !conf.contains_key("websocket") { return Err("Invalid route configuration, every route must contain either the `file`, `directory`, `proxy` or `redirect` field, unless it defines a WebSocket proxy with the `websocket` field"); + } else { + routes.push(RouteConfig { + route_type: RouteType::ExclusiveWebSocket, + matches: wild.to_string(), + path: None, + load_balancer: None, + websocket_proxy, + }); } } diff --git a/humphrey-server/src/plugins/manager.rs b/humphrey-server/src/plugins/manager.rs index 49cdee2..a986e7d 100644 --- a/humphrey-server/src/plugins/manager.rs +++ b/humphrey-server/src/plugins/manager.rs @@ -6,6 +6,7 @@ use crate::config::RouteConfig; use crate::plugins::plugin::{Plugin, PluginLoadResult}; use crate::server::server::AppState; use humphrey::http::{Request, Response}; +use humphrey::stream::Stream; use libloading::Library; use std::collections::HashMap; @@ -84,10 +85,32 @@ impl PluginManager { None } + /// Calls the `on_websocket_request` function on every plugin. + /// If a plugin handles the stream, the function immediately returns. + pub fn on_websocket_request( + &self, + request: &mut Request, + mut stream: Stream, + state: Arc, + route: Option<&RouteConfig>, + ) -> Option { + for plugin in &self.plugins { + if let Some(returned_stream) = + plugin.on_websocket_request(request, stream, state.clone(), route) + { + stream = returned_stream; + } else { + return None; + } + } + + Some(stream) + } + /// Calls the `on_response` function on every plugin. - pub fn on_response(&self, response: &mut Response, state: Arc) { + pub fn on_response(&self, response: &mut Response, state: Arc, route: &RouteConfig) { for plugin in &self.plugins { - plugin.on_response(response, state.clone()); + plugin.on_response(response, state.clone(), route); } } diff --git a/humphrey-server/src/plugins/plugin.rs b/humphrey-server/src/plugins/plugin.rs index 0e40609..285fad8 100644 --- a/humphrey-server/src/plugins/plugin.rs +++ b/humphrey-server/src/plugins/plugin.rs @@ -8,6 +8,7 @@ use crate::config::RouteConfig; use crate::server::server::AppState; use humphrey::http::{Request, Response}; +use humphrey::stream::Stream; use std::any::Any; use std::collections::HashMap; @@ -46,9 +47,23 @@ pub trait Plugin: Any + Send + Sync + Debug { None } + /// Called when a WebSocket request is received but before it is processed. May modify the request in-place. + /// Unlike `on_request`, this method should return `None` if the WebSocket request is being handled by the plugin, and should return the stream back to Humphrey if the request is not being handled by the plugin. + /// + /// **Important:** If the plugin returns `Some(stream)`, it must not modify or close the stream. + fn on_websocket_request( + &self, + request: &mut Request, + stream: Stream, + state: Arc, + route: Option<&RouteConfig>, + ) -> Option { + Some(stream) + } + /// Called when a response has been generated but not yet sent. /// May modify the response in-place. - fn on_response(&self, response: &mut Response, state: Arc) {} + fn on_response(&self, response: &mut Response, state: Arc, route: &RouteConfig) {} /// Called when the plugin is about to be unloaded. /// Any clean-up should be done here. diff --git a/humphrey-server/src/server/server.rs b/humphrey-server/src/server/server.rs index 3b7822f..bc062ff 100644 --- a/humphrey-server/src/server/server.rs +++ b/humphrey-server/src/server/server.rs @@ -1,6 +1,6 @@ //! Provides the core server functionality and manages the underlying Humphrey app. -use humphrey::http::{Request, Response}; +use humphrey::http::{Request, Response, StatusCode}; use humphrey::monitor::event::ToEventMask; use humphrey::monitor::MonitorConfig; use humphrey::stream::Stream; @@ -71,16 +71,18 @@ pub fn main(config: Config) { let monitor_state = app.get_state(); spawn(move || monitor_thread(monitor_rx, monitor_state)); - for route in init_app_routes(&state.config.default_host, 0).routes { + let top_level_routes = init_app_routes(&state.config.default_host, 0); + + for route in top_level_routes.routes { app = app.with_route(&route.route, route.handler); } - for (host_index, host) in state.config.hosts.iter().enumerate() { - app = app.with_host(&host.matches, init_app_routes(host, host_index + 1)); + for websocket_route in top_level_routes.websocket_routes { + app = app.with_websocket_route(&websocket_route.route, websocket_route.handler); } - if let Some(proxy) = state.config.default_websocket_proxy.as_ref() { - app = app.with_websocket_route("/*", websocket_handler(proxy.to_string())); + for (host_index, host) in state.config.hosts.iter().enumerate() { + app = app.with_host(&host.matches, init_app_routes(host, host_index + 1)); } #[cfg(feature = "tls")] @@ -97,6 +99,11 @@ pub fn main(config: Config) { } } + #[cfg(feature = "plugins")] + { + app = app.with_websocket_route("/*", catch_all_websocket_route); + } + let addr = format!("{}:{}", state.config.address, state.config.port); let logger = &state.logger; @@ -145,9 +152,10 @@ fn init_app_routes(host: &HostConfig, host_index: usize) -> SubApp { request_handler(request, state, host_index, route_index) }); - if let Some(proxy) = route.websocket_proxy.as_ref() { - subapp = - subapp.with_websocket_route(&route.matches, websocket_handler(proxy.to_string())); + if route.websocket_proxy.is_some() { + subapp = subapp.with_websocket_route(&route.matches, move |request, stream, state| { + websocket_handler(request, stream, state, host_index, route_index) + }); } } @@ -189,7 +197,7 @@ fn request_handler( .unwrap_or_else(|| inner_request_handler(request, state.clone(), host, route)); // If no plugin overrides the response, generate it in the normal way // Pass the response to plugins before it is sent to the client - plugins.on_response(&mut response, state.clone()); + plugins.on_response(&mut response, state.clone(), route_config); response } @@ -225,15 +233,64 @@ fn inner_request_handler( RouteType::Redirect => { redirect_handler(request, state.clone(), route.path.as_ref().unwrap()) } + RouteType::ExclusiveWebSocket => Response::new( + StatusCode::NotFound, + "This route only accepts WebSocket requests", + ), + } +} + +#[cfg(not(feature = "plugins"))] +fn websocket_handler( + request: Request, + stream: Stream, + state: Arc, + host: usize, + route: usize, +) { + inner_websocket_handler(request, stream, state, host, route) +} + +#[cfg(feature = "plugins")] +fn websocket_handler( + mut request: Request, + stream: Stream, + state: Arc, + host: usize, + route: usize, +) { + let plugins = state.plugin_manager.read().unwrap(); + + let route_config = state.config.get_route(host, route); + + if let Some(stream) = + plugins.on_websocket_request(&mut request, stream, state.clone(), Some(route_config)) + { + inner_websocket_handler(request, stream, state.clone(), host, route) } } -fn websocket_handler(target: String) -> impl Fn(Request, Stream, Arc) { - move |request: Request, source: Stream, state: Arc| { - proxy_websocket(request, source, &target, state).ok(); +fn inner_websocket_handler( + request: Request, + stream: Stream, + state: Arc, + host: usize, + route: usize, +) { + let route = state.config.get_route(host, route); + + if let Some(target) = route.websocket_proxy.as_ref() { + proxy_websocket(request, stream, &target.clone(), state).ok(); } } +#[cfg(feature = "plugins")] +fn catch_all_websocket_route(mut request: Request, stream: Stream, state: Arc) { + let plugins = state.plugin_manager.read().unwrap(); + + plugins.on_websocket_request(&mut request, stream, state.clone(), None); +} + fn proxy_websocket( request: Request, mut source: Stream, @@ -300,6 +357,8 @@ fn load_plugins(config: &Config, state: Arc) -> Result { for plugin in &config.plugins { unsafe { let app_state = state.clone(); + + #[allow(clippy::significant_drop_in_scrutinee)] match manager.load_plugin(&plugin.library, &plugin.config, app_state) { PluginLoadResult::Ok(name) => { state.logger.info(&format!("Initialised plugin {}", name)); diff --git a/plugins/hot-reload/Cargo.toml b/plugins/hot-reload/Cargo.toml new file mode 100644 index 0000000..2d9c784 --- /dev/null +++ b/plugins/hot-reload/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "hot-reload" +version = "0.1.0" +edition = "2021" + +[dependencies] +humphrey = { path = "../../humphrey" } +humphrey_server = { path = "../../humphrey-server", features = ["plugins"] } +humphrey_ws = { path = "../../humphrey-ws" } +notify = "4.0.17" + +[build-dependencies] +minify-js = "0.1.5" + +[lib] +crate-type = ["cdylib"] + +[workspace] \ No newline at end of file diff --git a/plugins/hot-reload/build.rs b/plugins/hot-reload/build.rs new file mode 100644 index 0000000..8ea609f --- /dev/null +++ b/plugins/hot-reload/build.rs @@ -0,0 +1,21 @@ +use minify_js::minify; + +use std::env::var; +use std::fs::{read_to_string, write}; + +fn main() { + println!("cargo:rerun-if-changed=js/main.js"); + + let cargo_manifest_dir = var("CARGO_MANIFEST_DIR").unwrap(); + let out_dir = var("OUT_DIR").unwrap(); + + let js = read_to_string(cargo_manifest_dir + "/js/main.js") + .expect("Failed to read JavaScript source code") + .as_bytes() + .to_vec(); + + let mut output = Vec::with_capacity(js.len()); + minify(js, &mut output).expect("Failed to minify JavaScript"); + + write(out_dir + "/inject.js", output).expect("Failed to write minifed JavaScript to file"); +} diff --git a/plugins/hot-reload/js/main.js b/plugins/hot-reload/js/main.js new file mode 100644 index 0000000..394af59 --- /dev/null +++ b/plugins/hot-reload/js/main.js @@ -0,0 +1,99 @@ +if (typeof __HUMPHREY_INIT === "undefined" || __HUMPHREY_INIT !== true) { + var __HUMPHREY_INIT = false; + + var __HUMPHREY_WS_ADDR = window.location.protocol === "https:" ? "wss" : "ws" + + `://${window.location.host}` + + __HUMPHREY_WS_ROUTE; + + var __HUMPHREY_LAST_UPDATES = {}; + + const __HUMPHREY_WS = new WebSocket(__HUMPHREY_WS_ADDR); + + __HUMPHREY_WS.onopen = () => { + __HUMPHREY_INIT = true; + console.log("[Humphrey Hot Reload] Connected to server, waiting for changes"); + } + + __HUMPHREY_WS.onmessage = async (e) => { + const data = e.data; + const url = window.location.pathname; + + // If the last update happened too recently, ignore it + // I'm not sure why this happens but it might be due to applications using multiple writes to save a file + if (__HUMPHREY_LAST_UPDATES[data] && __HUMPHREY_LAST_UPDATES[data] > new Date().getTime() - 200) { + return; + } + + __HUMPHREY_LAST_UPDATES[data] = new Date().getTime(); + + // If the current page was changed, reload it. + if (data === url + || (url.endsWith('/') && data === url + "index.html") + || (url.endsWith('/') && data === url + "index.htm")) { + console.log("[Humphrey Hot Reload] Reloading page"); + + return await reloadPage(); + } + + // Update any `src` attributes that point to the changed URL. + const srcElements = Array.from(document.querySelectorAll("[src]")); + const sources = srcElements.map(e => e.getAttribute("src")); + const indexes = sources.reduce((previous, current, index) => { + if (removeHash(current) === data || removeHash(current) === removeSlash(data)) return [...previous, index]; + return previous; + }, []); + + for (let index of indexes) { + const source = removeHash(sources[index]); + const element = srcElements[index]; + + const newElement = document.createElement(element.tagName); + newElement.innerHTML = element.innerHTML; + for (let attr of element.attributes) { + if (attr.name === "src") continue; + newElement.setAttribute(attr.name, attr.value); + } + + newElement.setAttribute("src", `${source}#${new Date().getTime()}`); + element.parentNode.insertBefore(newElement, element); + element.remove(); + + console.log(`[Humphrey Hot Reload] Reloading ${source}`); + } + + // Update any CSS `` tags that point to the changed URL. + const cssElements = Array.from(document.querySelectorAll("link[href]")); + const cssSources = cssElements.map(e => e.getAttribute("href")); + const cssIndexes = cssSources.reduce((previous, current, index) => { + if (removeHash(current) === data || removeHash(current) === removeSlash(data)) return [...previous, index]; + return previous; + }, []); + + for (let index of cssIndexes) { + const element = cssElements[index]; + const source = removeHash(cssSources[index]); + element.setAttribute("href", `${source}#${new Date().getTime()}`); + + console.log(`[Humphrey Hot Reload] Reloading ${source}`); + } + }; +} + +async function reloadPage() { + return fetch(window.location.href) + .then(res => res.text()) + .then(text => { + document.open(); + document.write(text); + document.close(); + }); +} + +function removeHash(s) { + return s.split('#')[0]; +} + +function removeSlash(s) { + if (s.startsWith('/')) return s.substring(1); + return s; +} \ No newline at end of file diff --git a/plugins/hot-reload/src/injector.rs b/plugins/hot-reload/src/injector.rs new file mode 100644 index 0000000..6295b91 --- /dev/null +++ b/plugins/hot-reload/src/injector.rs @@ -0,0 +1,37 @@ +use crate::listen::get_url_prefix; + +use humphrey::http::Response; +use humphrey_server::config::RouteConfig; + +pub fn inject_js(response: &mut Response, js: &[u8]) { + if let Some(index) = response.body.windows(7).position(|w| w == b"") { + let mut to_inject = Vec::with_capacity(js.len() + 17); + to_inject.extend_from_slice(b""); + + response.body.splice(index..index, to_inject); + } +} + +pub fn inject_variables(response: &mut Response, route: &RouteConfig, ws_route: &str) { + if let Some(index) = response.body.windows(7).position(|w| w == b"") { + response.body.splice( + index..index, + format!( + r#""#, + route.path.as_ref().unwrap(), + get_url_prefix(&route.matches).unwrap(), + ws_route + ) + .as_bytes() + .to_vec(), + ); + } +} diff --git a/plugins/hot-reload/src/lib.rs b/plugins/hot-reload/src/lib.rs new file mode 100644 index 0000000..49534a4 --- /dev/null +++ b/plugins/hot-reload/src/lib.rs @@ -0,0 +1,101 @@ +mod injector; +mod listen; + +use humphrey::http::{Request, Response, StatusCode}; + +use humphrey::stream::Stream; +use humphrey_server::config::extended_hashmap::ExtendedMap; +use humphrey_server::config::RouteConfig; +use humphrey_server::declare_plugin; +use humphrey_server::plugins::plugin::{Plugin, PluginLoadResult}; +use humphrey_server::AppState; + +use humphrey_ws::{websocket_handler, WebsocketStream}; + +use std::collections::HashMap; +use std::fmt::Debug; +use std::sync::{Arc, Mutex}; + +static INJECTED_JS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/inject.js")); + +#[derive(Default)] +pub struct HotReloadPlugin { + ws_route: String, + streams: Arc>>, +} + +impl Plugin for HotReloadPlugin { + fn name(&self) -> &'static str { + "Hot Reload" + } + + fn on_load( + &mut self, + config: &HashMap, + state: Arc, + ) -> PluginLoadResult<(), &'static str> { + if !state.config.hosts.is_empty() { + return PluginLoadResult::NonFatal( + "Warning: Hot Reload plugin cannot be used with custom host configuration", + ); + } + + self.ws_route = config.get_optional("ws_route", "/__hot-reload-ws".into()); + + if listen::init(self.streams.clone(), state).is_err() { + return PluginLoadResult::Fatal( + "Could not initialise Hot Reload plugin due to listener error", + ); + } + + PluginLoadResult::Ok(()) + } + + fn on_websocket_request( + &self, + request: &mut Request, + stream: Stream, + state: Arc, + _: Option<&RouteConfig>, + ) -> Option { + if request.uri == self.ws_route { + websocket_handler(|stream, _| self.streams.lock().unwrap().push(stream))( + request.clone(), + stream, + state.clone(), + ); + + state.logger.info(format!( + "{}: Hot Reload WebSocket connection opened", + request.address + )); + + None + } else { + Some(stream) + } + } + + fn on_response(&self, response: &mut Response, _: Arc, route: &RouteConfig) { + let content_type = response.headers.get("Content-Type"); + + if response.status_code == StatusCode::OK && content_type == Some("text/html") { + injector::inject_variables(response, route, &self.ws_route); + injector::inject_js(response, INJECTED_JS); + } else if content_type + .map(|s| s.starts_with("image/") || s == "text/javascript" || s == "text/css") + .unwrap_or(false) + { + response.headers.remove("Cache-Control"); + response.headers.add("Cache-Control", "no-store"); + } + } +} + +impl Debug for HotReloadPlugin { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HotReloadPlugin").finish() + } +} + +declare_plugin!(HotReloadPlugin, HotReloadPlugin::default); diff --git a/plugins/hot-reload/src/listen.rs b/plugins/hot-reload/src/listen.rs new file mode 100644 index 0000000..a95ed61 --- /dev/null +++ b/plugins/hot-reload/src/listen.rs @@ -0,0 +1,101 @@ +use humphrey_server::config::RouteType; +use humphrey_server::AppState; +use humphrey_ws::{Message, WebsocketStream}; + +use notify::{raw_watcher, Op, RecursiveMode, Watcher}; + +use std::error::Error; +use std::path::PathBuf; +use std::sync::mpsc::channel; +use std::sync::{Arc, Mutex}; +use std::thread::spawn; + +struct WatchedRoute { + path: PathBuf, + url_prefix: String, +} + +pub fn init( + streams: Arc>>, + state: Arc, +) -> Result<(), Box> { + let (tx, rx) = channel(); + let mut watcher = raw_watcher(tx)?; + let mut watched_routes = Vec::new(); + + for route in &state.config.default_host.routes { + match route.route_type { + RouteType::File | RouteType::Directory => { + let path = PathBuf::from(route.path.as_ref().unwrap()).canonicalize()?; + watcher.watch(&path, RecursiveMode::Recursive)?; + + state.logger.debug(format!( + "Hot Reload: Watching for changes on {}", + path.display() + )); + + watched_routes.push(WatchedRoute { + path, + url_prefix: get_url_prefix(&route.matches)?, + }); + } + _ => (), + } + } + + spawn(move || { + // Watcher must be moved onto the thread so it doesn't get dropped. + // This is because `Drop` disconnects the channel. + let _watcher_on_thread = watcher; + + loop { + let event = rx.recv().unwrap(); + + if event.path.is_none() || event.op.is_err() || event.op.unwrap() != Op::WRITE { + continue; + } + + let path = event.path.unwrap(); + + let mut streams = streams.lock().unwrap(); + + for route in &watched_routes { + if path.starts_with(&route.path) { + let url = (route.url_prefix.clone() + + path.strip_prefix(&route.path).unwrap().to_str().unwrap()) + .replace('\\', "/"); + + state.logger.debug(format!("Hot Reload: Reloading {}", url)); + + let mut to_remove = Vec::with_capacity(streams.len()); + + #[allow(clippy::significant_drop_in_scrutinee)] + for (i, stream) in streams.iter_mut().enumerate() { + if stream.send(Message::new(url.clone())).is_err() { + to_remove.push(i); + } + } + + for i in to_remove.iter().rev() { + streams.swap_remove(*i); + } + } + } + } + }); + + Ok(()) +} + +pub fn get_url_prefix(s: &str) -> Result { + let ends_with_wildcard = s.ends_with('*'); + let number_of_wildcards = s.chars().filter(|c| *c == '*').count(); + + if ends_with_wildcard && number_of_wildcards == 1 { + Ok(s.trim_end_matches('*').to_string()) + } else if number_of_wildcards == 0 { + Ok(s.to_string()) + } else { + Err("Invalid URL prefix".to_string()) + } +}