Skip to main content

vite_static_actix_web/
lib.rs

1#![warn(clippy::pedantic)]
2
3use std::{ops::Deref, rc::Rc};
4use vite_static_shared::{Manifest, StaticManifestChunk};
5
6use actix_web::{
7    Error, HttpMessage as _, HttpRequest, HttpResponse,
8    dev::{
9        AppService, HttpServiceFactory, ResourceDef, Service, ServiceFactory, ServiceRequest,
10        ServiceResponse, always_ready,
11    },
12    http::{
13        Method, StatusCode,
14        header::{
15            CONTENT_TYPE, CacheControl, CacheDirective, ETag, EntityTag, IfMatch, IfNoneMatch,
16        },
17    },
18};
19use futures_util::future::{FutureExt, LocalBoxFuture, Ready, ok};
20
21/// Actix service for serving Vite Static.
22///
23/// ```rust
24/// // <snip>
25///
26/// HttpServer::new(|| {
27///     App::new()
28///         // <snip>
29///         .service(ActixFiles::new("/", MyViteStatic.boxed()))
30/// })
31/// .bind(("127.0.0.1", 8080))?
32/// .run()
33/// .await
34///
35/// // <snip>
36/// ```
37#[derive(Clone)]
38pub struct ActixFiles {
39    path: String,
40    manifest: Rc<Box<dyn Manifest<'static>>>,
41    cache_control: CacheControl,
42}
43
44impl ActixFiles {
45    /// Creates new [`ActixFiles`] service.
46    ///
47    /// Takes path (where serve static) and [`Manifest`] itself.
48    ///
49    /// ```rust
50    /// ActixFiles::new("/", MyViteStatic.boxed())
51    /// ActixFiles::new("/static", MyViteStaticWithChangedBase.boxed())
52    /// // ^-- in vite config `{ base: "/static" }`
53    /// ```
54    #[must_use]
55    pub fn new(path: &str, manifest: Box<dyn Manifest<'static>>) -> Self {
56        Self {
57            path: path.to_string(),
58            manifest: Rc::new(manifest),
59            cache_control: CacheControl(vec![CacheDirective::MaxAge(604800)]),
60        }
61    }
62
63    /// Sets [`CacheControl`] for served static.
64    ///
65    /// By default, `CacheControl` is set to "Max Age of 7 days".
66    ///
67    /// ```rust
68    /// ActixFiles::new("/", MyViteStatic.boxed())
69    ///     .cache_control(CacheControl(vec![CacheDirective::MaxAge(604800)])) // 7 days
70    /// ```
71    #[must_use]
72    pub fn cache_control(mut self, value: CacheControl) -> Self {
73        self.cache_control = value;
74        self
75    }
76}
77
78impl HttpServiceFactory for ActixFiles {
79    fn register(self, config: &mut AppService) {
80        let path = self.path.trim_start_matches('/');
81        let rdef = if config.is_root() {
82            ResourceDef::root_prefix(path)
83        } else {
84            ResourceDef::prefix(path)
85        };
86        // TODO: guards
87        config.register_service(rdef, None, self, None);
88    }
89}
90
91impl ServiceFactory<ServiceRequest> for ActixFiles {
92    type Response = ServiceResponse;
93    type Error = Error;
94    type Config = ();
95    type Service = ActixFilesService;
96    type InitError = ();
97    type Future = LocalBoxFuture<'static, Result<Self::Service, Self::InitError>>;
98
99    fn new_service(&self, _cfg: Self::Config) -> Self::Future {
100        ok(ActixFilesService {
101            inner: self.clone(),
102        })
103        .boxed_local()
104    }
105}
106
107/// Internal implementation of [`ActixFiles`] service.
108///
109/// Use [`ActixFiles`] instead!
110pub struct ActixFilesService {
111    inner: ActixFiles,
112}
113
114impl Deref for ActixFilesService {
115    type Target = ActixFiles;
116
117    fn deref(&self) -> &Self::Target {
118        &self.inner
119    }
120}
121
122impl ActixFilesService {
123    fn respond_to(&self, req: &HttpRequest, chunk: &'static StaticManifestChunk) -> HttpResponse {
124        let mut response = HttpResponse::Ok();
125        response.insert_header((CONTENT_TYPE, chunk.mime_type));
126
127        let etag = EntityTag::new_strong(chunk.hash.to_string());
128        response.insert_header(ETag(etag.clone()));
129
130        let precondition_failed = match req.get_header::<IfMatch>() {
131            None | Some(IfMatch::Any) => false,
132            Some(IfMatch::Items(items)) => items.iter().all(|item| item.strong_ne(&etag)),
133        };
134        if precondition_failed {
135            return response.status(StatusCode::PRECONDITION_FAILED).finish();
136        }
137
138        let not_modified = match req.get_header::<IfNoneMatch>() {
139            None => false,
140            Some(IfNoneMatch::Any) => true,
141            Some(IfNoneMatch::Items(items)) => items.iter().all(|item| item.weak_ne(&etag)),
142        };
143        if not_modified {
144            return response.status(StatusCode::NOT_MODIFIED).finish();
145        }
146
147        response.insert_header(self.cache_control.clone());
148        response.body(chunk.contents)
149    }
150}
151
152impl Service<ServiceRequest> for ActixFilesService {
153    type Response = ServiceResponse;
154    type Error = Error;
155    type Future = Ready<Result<Self::Response, Self::Error>>;
156
157    always_ready!();
158
159    fn call(&self, req: ServiceRequest) -> Self::Future {
160        match *req.method() {
161            Method::GET => (),
162            _ => {
163                return ok(ServiceResponse::new(
164                    req.into_parts().0,
165                    HttpResponse::MethodNotAllowed().body("Only GET requests are allowed"),
166                ));
167            }
168        }
169
170        let (req, _) = req.into_parts();
171        let requested_path = req.match_info().unprocessed().trim_start_matches('/');
172
173        let response = match self.manifest.chunk(requested_path) {
174            Some(chunk) => self.respond_to(&req, chunk),
175            None => HttpResponse::NotFound().body("Not found"),
176        };
177
178        ok(ServiceResponse::new(req, response))
179    }
180}