web_modules 0.5.1

Pure-Rust, buildless toolchain for ES modules and Web Components
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
//! Axum integration: serve a frontend from a set of **known roots**.
//!
//! A [`Frontend`] is a list of roots (each an embedded `include_dir!` tree or a
//! filesystem directory) mounted under a URL prefix. The default is **one root at
//! `/`**; you can add several, each under its own prefix.
//!
//! - [`Frontend::router`] serves the roots **statically**, as-is (no compiler, no
//!   watcher).
//! - [`Frontend::dev`] *(feature `dev`)* compiles TS/SCSS on the fly and watches the
//!   filesystem roots, with embedded roots as the static fallback.
//! - [`Frontend::auto`] picks `dev` in debug builds, `router` in release.
//!
//! A request can never resolve to a file outside a known root (the containment in
//! `super::serving`), the same boundary the planned processor sandbox will use.
//!
//! ```ignore
//! use web_modules::{include_dir::{include_dir, Dir}, serve, Frontend};
//! static DIST: Dir = include_dir!("$OUT_DIR/dist"); // baked by build.rs via `build`
//!
//! # async fn run() -> std::io::Result<()> {
//! // debug → live-reload from `web/`; release → embedded `DIST`.
//! let app = Frontend::embedded(&DIST).source("web").auto();
//! serve(app, "127.0.0.1:8080".parse().unwrap()).await
//! # }
//! ```

use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use axum::{
    extract::State,
    http::{header, HeaderMap, StatusCode, Uri},
    response::{IntoResponse, Response},
    Router,
};
use include_dir::Dir;

use super::serving::{
    content_type, has_source_extension, has_traversal, is_source_file, relative_under,
    resolve_file, Resolved,
};
use crate::reject::{Presets, Reject};
use crate::SymlinkMode;

/// The backing of a served root: assets baked into the binary, or a directory read
/// from the filesystem at runtime.
enum Source {
    Embedded(&'static Dir<'static>),
    Dir(PathBuf),
}

/// One served root: a URL `prefix` (normalised, no surrounding `/`; `""` = site root),
/// a [`Source`], and whether the live server watches it.
struct Root {
    prefix: String,
    source: Source,
    /// Whether `live()` watches this root. Read only by the dev server, so it's dead in
    /// a lean static build (`axum` without `dev`).
    #[cfg_attr(not(feature = "dev"), allow(dead_code))]
    watch: bool,
}

impl Root {
    fn new(prefix: &str, source: Source) -> Self {
        Self {
            prefix: prefix.trim_matches('/').to_string(),
            source,
            watch: true,
        }
    }
}

/// Serve a frontend from one or more known roots. Default: a single root at `/`.
#[derive(Default)]
pub struct Frontend {
    roots: Vec<Root>,
    /// Paths refused by the static router (config / secrets / source). Default: all presets.
    /// See [`Reject`](crate::reject::Reject). (The [`dev`](Self::dev) path uses all presets.)
    reject: Reject,
    /// What a symlink in a filesystem root means (default: the safe
    /// [`Follow`](SymlinkMode::Follow)). Carried into [`dev`](Self::dev) too.
    symlinks: SymlinkMode,
}

impl Frontend {
    /// No roots yet; add them with [`mount_embedded`](Self::mount_embedded) /
    /// [`mount_dir`](Self::mount_dir).
    pub fn new() -> Self {
        Self::default()
    }

    /// One **embedded** root at `/` (baked `include_dir!` assets, production).
    pub fn embedded(dir: &'static Dir<'static>) -> Self {
        Self {
            roots: vec![Root::new("", Source::Embedded(dir))],
            ..Self::default()
        }
    }

    /// Select which reject [`Presets`] the static router refuses (default:
    /// [`Presets::ALL`]). Replaces the current selection; compose with the bitwise
    /// operators, e.g. `Presets::ALL & !Presets::CONFIG`. Add individual patterns on top
    /// with [`reject`](Self::reject). Applies to [`router`](Self::router); [`dev`](Self::dev)
    /// always refuses all presets.
    pub fn reject_preset(mut self, presets: Presets) -> Self {
        self.reject = presets.into();
        self
    }

    /// Reject one extra pattern (`*.ext`, `name/`, or an exact `name`) on top of the
    /// selected [`presets`](Self::reject_preset). Case-insensitive. Repeatable.
    pub fn reject(mut self, pattern: impl AsRef<str>) -> Self {
        self.reject.add(pattern);
        self
    }

    /// What a symlink in a filesystem root means (default: the safe
    /// [`SymlinkMode::Follow`]). Applies to [`router`](Self::router) and is carried
    /// into [`dev`](Self::dev).
    pub fn symlinks(mut self, mode: SymlinkMode) -> Self {
        self.symlinks = mode;
        self
    }

    /// One **filesystem** root at `/`, served as-is at runtime.
    pub fn dir(path: impl Into<PathBuf>) -> Self {
        Self {
            roots: vec![Root::new("", Source::Dir(path.into()))],
            ..Self::default()
        }
    }

    /// Add a filesystem source root at `/` (your `.ts`/`.scss` dir for live mode), a
    /// convenience for `mount_dir("/", path)`. Repeatable.
    pub fn source(self, path: impl Into<PathBuf>) -> Self {
        self.mount_dir("/", path)
    }

    /// Mount an embedded tree under `prefix`. Repeatable.
    pub fn mount_embedded(mut self, prefix: impl AsRef<str>, dir: &'static Dir<'static>) -> Self {
        self.roots
            .push(Root::new(prefix.as_ref(), Source::Embedded(dir)));
        self
    }

    /// Mount a filesystem directory under `prefix`. Repeatable.
    pub fn mount_dir(mut self, prefix: impl AsRef<str>, path: impl Into<PathBuf>) -> Self {
        self.roots
            .push(Root::new(prefix.as_ref(), Source::Dir(path.into())));
        self
    }

    /// Static file serving over the roots (embedded or filesystem), as-is, no
    /// compiler, no watcher. Most-specific prefix wins; same-prefix ties resolve to
    /// the **first root added**.
    pub fn router(self) -> Router {
        Router::new()
            .fallback(serve_static)
            .with_state(Arc::new(StaticState {
                roots: self.roots,
                reject: self.reject,
                symlinks: self.symlinks,
            }))
    }

    /// Compile TS/SCSS on the fly + watch the **filesystem** roots, live-reloading the
    /// browser; embedded roots are the static fallback. Requires the `dev` feature.
    /// The [`symlinks`](Self::symlinks) mode is carried over; the reject list is not —
    /// the dev path always refuses all presets.
    #[cfg(feature = "dev")]
    pub fn dev(self) -> Router {
        let mut mounts = Vec::new();
        let mut embedded = None;
        for root in self.roots {
            match root.source {
                Source::Dir(dir) => {
                    let mount = if root.prefix.is_empty() {
                        crate::mount::Mount::root(dir)
                    } else {
                        crate::mount::Mount::new(&root.prefix, dir)
                    };
                    mounts.push(mount.watched(root.watch));
                }
                // First embedded root is the fallback tree.
                Source::Embedded(dir) => embedded = embedded.or(Some(dir)),
            }
        }
        let config = crate::dev::DevConfig {
            symlinks: self.symlinks,
            ..Default::default()
        };
        crate::dev::build_router(mounts, embedded, config)
    }

    /// [`dev`](Self::dev) in debug builds (with the `dev` feature), else
    /// [`router`](Self::router) (static).
    pub fn auto(self) -> Router {
        #[cfg(all(debug_assertions, feature = "dev"))]
        return self.dev();
        #[cfg(not(all(debug_assertions, feature = "dev")))]
        return self.router();
    }
}

/// The static router's shared state: the roots, the reject policy, and the symlink
/// mode.
struct StaticState {
    roots: Vec<Root>,
    reject: Reject,
    symlinks: SymlinkMode,
}

/// Static handler: answer via [`respond`] — on the blocking pool when filesystem
/// roots are involved, directly when every root is embedded (in-memory, nothing
/// blocks).
async fn serve_static(
    State(state): State<Arc<StaticState>>,
    headers: HeaderMap,
    uri: Uri,
) -> Response {
    let gzip_ok = accepts_gzip(&headers);
    // Filesystem roots resolve with blocking syscalls (canonicalize, read); run them
    // on the blocking pool so a slow disk doesn't stall the async workers.
    if state
        .roots
        .iter()
        .any(|root| matches!(root.source, Source::Dir(_)))
    {
        let path = uri.path().to_string();
        return match tokio::task::spawn_blocking(move || respond(&state, &path, gzip_ok)).await {
            Ok(response) => response,
            // A panic inside the responder is this request's 500, not a dead worker.
            Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
        };
    }
    respond(&state, uri.path(), gzip_ok)
}

/// The response for a request path: resolve it against the roots (most-specific
/// prefix first, same-prefix ties in declaration order) and serve the first match,
/// staying inside each root (per the symlink mode).
fn respond(state: &StaticState, path: &str, gzip_ok: bool) -> Response {
    let StaticState {
        roots,
        reject,
        symlinks,
    } = state;
    let raw = path.trim_start_matches('/');
    let requested = if raw.is_empty() || raw.ends_with('/') {
        format!("{raw}index.html")
    } else {
        raw.to_string()
    };
    if has_traversal(&requested) {
        return StatusCode::NOT_FOUND.into_response();
    }
    // Reject list: refuse config / secret / source paths (checked on the request string here;
    // the resolved file is re-checked in the filesystem branch below).
    if reject.rejects(&requested) {
        crate::reject::warn_rejected(&requested);
        return StatusCode::NOT_FOUND.into_response();
    }
    let mut candidates: Vec<(&Root, String)> = roots
        .iter()
        .filter_map(|root| relative_under(&root.prefix, &requested).map(|rel| (root, rel)))
        .collect();
    candidates.sort_by_key(|(root, _)| std::cmp::Reverse(root.prefix.len()));
    for (root, rel) in candidates {
        // Skip directory (empty) paths, and never serve a *source* file raw.
        if rel.is_empty() || is_source_file(&rel) {
            continue;
        }
        // The original content type, even when serving a pre-compressed `.gz`.
        let ct = content_type(&rel);
        match &root.source {
            Source::Embedded(dir) => {
                if gzip_ok {
                    if let Some(gz) = dir.get_file(format!("{rel}.gz")) {
                        return gz_response(ct, gz.contents().to_vec());
                    }
                }
                if let Some(file) = dir.get_file(&rel) {
                    return ([(header::CONTENT_TYPE, ct)], file.contents()).into_response();
                }
            }
            Source::Dir(path) => {
                if gzip_ok {
                    // A sidecar never redirects: only a real `.gz` file counts, and
                    // the plain probe below decides what a symlink means.
                    if let Some(Resolved::File(gz)) =
                        resolve_file(path, &format!("{rel}.gz"), *symlinks)
                    {
                        // Don't hand back a gzipped *source* (e.g. an `app.scss.gz`):
                        // strip `.gz` and re-check the resolved name.
                        let degz_is_source = gz
                            .file_stem()
                            .is_some_and(|s| has_source_extension(Path::new(s)));
                        if !degz_is_source {
                            if let Ok(bytes) = std::fs::read(&gz) {
                                return gz_response(ct, bytes);
                            }
                        }
                    }
                }
                match resolve_file(path, &rel, *symlinks) {
                    Some(Resolved::File(file)) => {
                        // Re-check the *resolved* path, not just the request string: a
                        // case-insensitive or name-folding FS can open a source/rejected file the
                        // request didn't reveal (`app.SCSS`, or `secret.php.` on Windows), which the
                        // lexical guards above miss. Check the file name (the fold-prone part), not the
                        // absolute path whose parent dirs are out of our control.
                        let name = file.file_name().and_then(|n| n.to_str()).unwrap_or("");
                        if reject.rejects(name) {
                            crate::reject::warn_rejected(&rel);
                            continue;
                        }
                        if !has_source_extension(&file) {
                            return match std::fs::read(&file) {
                                Ok(bytes) => ([(header::CONTENT_TYPE, ct)], bytes).into_response(),
                                Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
                                    .into_response(),
                            };
                        }
                    }
                    // Redirect modes: the symlink's content is the Location; a
                    // redirect from the most-specific root is definitive.
                    #[cfg(feature = "symlink-move")]
                    Some(Resolved::Redirect(location)) => {
                        return match super::symlink_move::redirect_response(
                            &location,
                            *symlinks == SymlinkMode::Move,
                        ) {
                            Some(response) => response,
                            None => StatusCode::NOT_FOUND.into_response(),
                        };
                    }
                    None => {}
                }
            }
        }
    }
    StatusCode::NOT_FOUND.into_response()
}

/// Whether the client's `Accept-Encoding` lists `gzip`.
fn accepts_gzip(headers: &HeaderMap) -> bool {
    headers
        .get(header::ACCEPT_ENCODING)
        .and_then(|v| v.to_str().ok())
        .is_some_and(|v| {
            v.split(',')
                .any(|e| e.split(';').next().map(str::trim) == Some("gzip"))
        })
}

/// Serve pre-compressed bytes with `Content-Encoding: gzip` and the original type.
fn gz_response(content_type: String, bytes: Vec<u8>) -> Response {
    (
        [
            (header::CONTENT_TYPE, content_type),
            (header::CONTENT_ENCODING, "gzip".to_string()),
        ],
        bytes,
    )
        .into_response()
}

/// Bind `addr` and serve `app` until the process stops.
pub async fn serve(app: Router, addr: SocketAddr) -> std::io::Result<()> {
    let listener = tokio::net::TcpListener::bind(addr).await?;
    println!("web-modules: serving on http://{addr}/  (Ctrl-C to stop)");
    axum::serve(listener, app).await
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn root_normalises_prefix() {
        let r = Root::new("/ui/", Source::Dir(PathBuf::from("x")));
        assert_eq!(r.prefix, "ui");
        assert!(r.watch);
    }

    #[test]
    fn frontend_defaults_to_the_safe_symlink_mode() {
        assert_eq!(Frontend::dir("web").symlinks, SymlinkMode::Follow);
        assert_eq!(
            Frontend::dir("web")
                .symlinks(SymlinkMode::FollowUnsafe)
                .symlinks,
            SymlinkMode::FollowUnsafe
        );
    }

    #[test]
    fn default_constructors_place_one_root_at_slash() {
        assert_eq!(Frontend::dir("web").roots.len(), 1);
        assert_eq!(Frontend::dir("web").roots[0].prefix, "");
        // `new()` starts empty; `source`/`mount_*` add roots.
        assert_eq!(Frontend::new().roots.len(), 0);
        assert_eq!(
            Frontend::new()
                .source("web")
                .mount_dir("/api", "api")
                .roots
                .len(),
            2
        );
    }
}