tower_webdev/
webdev_service.rs

1use std::{
2    path::PathBuf,
3    process::Stdio,
4    task::{Context, Poll},
5};
6
7use futures_util::future::BoxFuture;
8use http::{Request, Response};
9use http_body::Body as HttpBody;
10use http_body_util::Either;
11use hyper::body::Incoming;
12use insecure_reverse_proxy::{HttpReverseProxyService, InsecureReverseProxyService};
13use serde::{Deserialize, Serialize};
14use tokio::{
15    io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
16    process::{ChildStdout, Command},
17};
18use tower::Service;
19use tower_http::services::{fs::ServeFileSystemResponseBody, ServeDir};
20
21#[derive(Debug, Clone, Deserialize, Serialize)]
22pub struct Config {
23    /// Compile all pages on startup
24    mode: Mode,
25    /// The command in the $PATH that is assumed to run for web project. e.g. pnpm, npm, yarn, etc.
26    command: String,
27    /// The subcommand for `self.command` that will install dependencies.
28    install_command: String,
29    /// Directory to execute the command in.
30    root: PathBuf,
31    /// Path for the output files
32    target: PathBuf,
33    /// Dev server port to proxy.
34    dev_server_port: u32,
35}
36
37impl Config {
38    pub fn new_pnpm(mode: Mode, root: impl Into<PathBuf>) -> Self {
39        let root = root.into();
40
41        Self {
42            mode,
43            command: "pnpm".into(),
44            install_command: "install".into(),
45            target: root.join("dist"),
46            root,
47            dev_server_port: 3000,
48        }
49    }
50
51    pub fn root(mut self, value: impl Into<PathBuf>) -> Self {
52        self.target = value.into();
53
54        self
55    }
56
57    pub fn target(mut self, value: impl Into<PathBuf>) -> Self {
58        self.target = value.into();
59
60        self
61    }
62
63    pub fn dev_server_port(mut self, value: u32) -> Self {
64        self.dev_server_port = value;
65
66        self
67    }
68
69    fn ensure_target_exists(&self) -> std::io::Result<()> {
70        std::fs::create_dir_all(&self.target)
71    }
72}
73
74#[derive(Debug, Clone, Deserialize, Serialize)]
75#[serde(rename_all = "lowercase")]
76pub enum Mode {
77    Production,
78    Development,
79}
80
81impl Mode {
82    pub fn assumed() -> Self {
83        #[cfg(debug_assertions)]
84        let mode = Self::Development;
85
86        #[cfg(not(debug_assertions))]
87        let mode = Self::Production;
88
89        mode
90    }
91}
92
93pub struct WebdevService<B> {
94    config: Config,
95    inner_service: InnerService<B>,
96}
97
98impl<B> Clone for WebdevService<B> {
99    fn clone(&self) -> Self {
100        WebdevService {
101            config: self.config.clone(),
102            inner_service: self.inner_service.clone(),
103        }
104    }
105}
106
107impl<B> WebdevService<B> {
108    pub async fn new(
109        config: Config,
110    ) -> Result<Self, Box<dyn std::error::Error + Send + Sync + 'static>>
111    where
112        B: HttpBody + Send + Unpin + 'static,
113        B::Data: Send,
114        B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
115    {
116        config.ensure_target_exists()?;
117
118        let this = Self {
119            inner_service: InnerService::from_config(&config),
120            config,
121        };
122
123        this.execute_install().await?;
124
125        match &this.config.mode {
126            Mode::Development => {
127                this.execute_dev().await?;
128            }
129            Mode::Production => {
130                this.execute_dev().await?;
131            }
132        }
133
134        Ok(this)
135    }
136}
137
138pub type WebdevResponse = Either<ServeFileSystemResponseBody, Incoming>;
139
140impl<Body> Service<Request<Body>> for WebdevService<Body>
141where
142    Body: HttpBody + Send + Unpin + 'static,
143    Body::Data: Send,
144    Body::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
145{
146    type Response = Response<WebdevResponse>;
147    type Error = std::convert::Infallible;
148    type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
149
150    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
151        match &mut self.inner_service {
152            InnerService::ServeDir(serve_dir) => {
153                <ServeDir as Service<Request<Body>>>::poll_ready(serve_dir, cx)
154            }
155            InnerService::ReverseProxy(proxy) => {
156                <HttpReverseProxyService<Body> as Service<Request<Body>>>::poll_ready(proxy, cx)
157            }
158        }
159    }
160
161    fn call(&mut self, request: Request<Body>) -> Self::Future {
162        match &self.inner_service {
163            InnerService::ServeDir(serve_dir) => {
164                let mut serve_dir = serve_dir.clone();
165
166                Box::pin(async move {
167                    let res = serve_dir.call(request).await.unwrap();
168
169                    Ok(res.map(Either::Left))
170                })
171            }
172            InnerService::ReverseProxy(proxy) => {
173                let mut proxy = proxy.clone();
174
175                Box::pin(async move {
176                    let res = proxy.call(request).await.unwrap();
177
178                    Ok(res.map(Either::Right))
179                })
180            }
181        }
182    }
183}
184
185enum InnerService<Body> {
186    ReverseProxy(HttpReverseProxyService<Body>),
187    ServeDir(ServeDir),
188}
189
190impl<B> Clone for InnerService<B> {
191    fn clone(&self) -> Self {
192        match self {
193            Self::ReverseProxy(p) => Self::ReverseProxy(p.clone()),
194            Self::ServeDir(s) => Self::ServeDir(s.clone()),
195        }
196    }
197}
198
199impl<Body> InnerService<Body> {
200    fn from_config(config: &Config) -> Self
201    where
202        Body: HttpBody + Send + Unpin + 'static,
203        Body::Data: Send,
204    {
205        match &config.mode {
206            Mode::Development => Self::ReverseProxy(InsecureReverseProxyService::new_http(
207                format!("http://localhost:{}", config.dev_server_port),
208            )),
209            _ => {
210                let serve_dir = ServeDir::new(&config.target);
211
212                Self::ServeDir(serve_dir)
213            }
214        }
215    }
216}
217
218#[allow(unused)]
219impl<B> WebdevService<B> {
220    async fn execute_install(
221        &self,
222    ) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
223        let mut command = Command::new(&self.config.command);
224        command.current_dir(&self.config.root.canonicalize()?);
225
226        command.args(["install"]);
227        command.stdout(Stdio::piped());
228
229        let mut build_process = command.spawn()?;
230
231        let stdout = build_process
232            .stdout
233            .take()
234            .expect("build_process did not have a handle to stdout");
235
236        write_stdout(stdout, "install");
237
238        match build_process.wait().await {
239            Ok(status) => {
240                if !status.success() {
241                    tracing::error!("build process exited with error");
242
243                    std::process::exit(status.code().unwrap_or(1));
244                }
245            }
246            Err(error) => {
247                tracing::error!("error waiting for build process: {}", error);
248            }
249        }
250
251        Ok(())
252    }
253
254    async fn execute_build(
255        &self,
256    ) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
257        let mut command = Command::new(&self.config.command);
258        command.current_dir(&self.config.root.canonicalize()?);
259
260        command.args(["build"]);
261        command.stdout(Stdio::piped());
262
263        let mut build_process = command.spawn()?;
264
265        let stdout = build_process
266            .stdout
267            .take()
268            .expect("build_process did not have a handle to stdout");
269
270        write_stdout(stdout, "build");
271
272        match build_process.wait().await {
273            Ok(status) => {
274                if !status.success() {
275                    tracing::error!("build process exited with error");
276
277                    std::process::exit(status.code().unwrap_or(1));
278                }
279            }
280            Err(error) => {
281                tracing::error!("error waiting for build process: {}", error);
282            }
283        }
284
285        Ok(())
286    }
287
288    async fn execute_dev(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
289        let mut command = Command::new(&self.config.command);
290        command.current_dir(&self.config.root.canonicalize()?);
291        command.args(["dev"]);
292        command.stdout(Stdio::piped());
293
294        let mut build_process = command.spawn()?;
295
296        let stdout = build_process
297            .stdout
298            .take()
299            .expect("dev process did not have a handle to stdout");
300
301        write_stdout(stdout, "dev");
302
303        tokio::spawn(async move {
304            match build_process.wait().await {
305                Ok(status) => {
306                    if !status.success() {
307                        tracing::error!("dev process exited with error");
308
309                        std::process::exit(status.code().unwrap_or(1));
310                    }
311                }
312                Err(error) => {
313                    tracing::error!("error waiting for dev process: {}", error);
314                }
315            }
316        });
317
318        Ok(())
319    }
320}
321
322fn write_stdout(stdout: ChildStdout, prefix: &'static str) {
323    let mut reader = BufReader::new(stdout).lines();
324
325    tokio::spawn(async move {
326        let mut output = tokio::io::stdout();
327
328        while let Ok(Some(line)) = reader.next_line().await {
329            output
330                .write_all(format!("webdev {prefix}: ").as_bytes())
331                .await
332                .unwrap();
333            output.write_all(line.as_bytes()).await.unwrap();
334            output.write_all(b"\n").await.unwrap();
335            output.flush().await.unwrap();
336        }
337    });
338}