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
/// Private unwrap macro for unrecoverable error handling
#[macro_use]
mod unwrap;
/// Use static files
mod files;
/// Watch folders and send websocket updates
#[cfg(feature = "watch")]
mod watch;

use std::{convert::Infallible, fs, path::Path};

use http::{Method, Request, Response, StatusCode};
use hyper::{
    service::{make_service_fn, service_fn},
    Body, Server,
};

use crate::{Port, DEV_BUILD_DIR};

pub use files::{dev_script, fallback_404};
#[cfg(feature = "watch")]
pub use watch::watch;

/// Create server and listen on localhost port
///
/// Similar to GitHub Pages router
///
/// Reads file on every request: this should not be a problem for a dev server
pub fn listen(port: Port, public: &str, port_ws: Port) {
    // Create runtime
    let runtime = unwrap!(
        tokio::runtime::Builder::new_current_thread()
            .enable_io()
            .build(),
        "Failed to build tokio runtime"
    );

    let public = public.to_string().clone();

    // Block on server running
    unwrap!(
        runtime.block_on(async {
            // Create service for router
            // Moves `public`
            let make_svc =
            make_service_fn(move |_| {
                let public = public.clone();
                async move {
                    Ok::<_, Infallible>(service_fn(move |req| {
                        server_router(req, public.clone(), port_ws)
                    }))
                }
            });

            // Parse IP address
            let addr = unwrap!(
                format!("127.0.0.1:{}", port).parse(),
                "Failed to parse constant IP address"
            );

            // Create and start server
            Server::bind(&addr).serve(make_svc).await
        }),
        // Generic runtime error
        err: "Error in server runtime: `{err:?}`"
    );
}

/// Route path to read and return file.
///
/// Accepts '/foo', '/foo.html', and '/foo/index.html' patterns
///
/// If no possible file was found, use 404 route (same as <URL>/404 request).
/// If no custom 404 page was found, use fallback 404 page
async fn server_router(
    req: Request<Body>,
    public: String,
    port_ws: Port,
) -> Result<Response<Body>, Infallible> {
    // Check if is GET request
    if req.method() == Method::GET {
        let path = req.uri().path();

        // Map public files to source public folder
        if path.starts_with("/public/") {
            let path = path.replacen("/public", &public, 1);
            return Ok(Response::new(Body::from(read_and_unwrap(&path))));
        }

        // Return corresponding file as body if exists
        // Routes everything but `/public/` files
        if let Some(file) = get_best_possible_file(path) {
            return Ok(Response::new(file));
        }
    }

    // 404 route
    Ok(unwrap!(
        Response::builder().status(StatusCode::NOT_FOUND).body(
            if let Some(file) = get_best_possible_file("/404.html") {
                // If custom 404 route is defined (requesting route `/404.html`)
                file
            } else {
                // Fallback 404 response
                Body::from(fallback_404(port_ws))
            },
        ),
        // Should not error
        err: "Failed to build 404 route response `{err:?}`",
    ))
}

/// Loops through files in `possible_path_suffixes` to find best file match, and reads file
///
/// Returns as `Option<Body>`, to allow non-UTF-8 file formats (such as images).
/// Returns `None` if no files were found
///
/// Panics if file exists, but was unable to be read
fn get_best_possible_file(path: &str) -> Option<Body> {
    let possible_suffixes = possible_path_suffixes(path);

    for suffix in possible_suffixes {
        let path = &format!("{DEV_BUILD_DIR}{path}{suffix}");

        // If file exists, and not directory
        if Path::new(path).is_file() {
            // Returns file content as `Body`
            // Automatically parses to string, if is valid UTF-8, otherwise uses buffer
            return Some(read_and_unwrap(path));
        }
    }
    None
}

/// Read file and convert to body
///
/// Panics if IO error occurs
fn read_and_unwrap(path: &str) -> Body {
    Body::from(unwrap!(
        fs::read(path),
        // Should only happen due to insufficient permissions or similar, not 'file not exist' error
        "Could not read file '{}'",
        path
    ))
}

/// Gets the possible path 'suffixes' from the path string
///
/// If path ends with '.html', or starts with '/styles', then return a slice of an empty string.
/// This path should refer to a file literally
///
/// Otherwise, return a slice of: an empty string (for a literal file), '.html', and '/index.html' (for file path shorthand).
/// Suffixes are returned in that order, to match a file based on specificity
///
/// Paths starting with `/public/` should never be routed through here
//TODO Make error for /public/ routing here
fn possible_path_suffixes(path: &str) -> &'static [&'static str] {
    if path.ends_with(".html") || path.starts_with("/styles/") {
        &[""]
    } else {
        &["", ".html", "/index.html"]
    }
}