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