Skip to main content

euv_cli/server/
fn.rs

1use super::*;
2
3/// Sets the global application state.
4///
5/// # Arguments
6///
7/// - `Arc<AppState>` - The shared application state to store globally.
8///
9/// # Returns
10///
11/// - `Result<(), EuvError>` - Indicates success or failure of the initialization.
12pub(crate) fn set_global_state(state: Arc<AppState>) -> Result<(), EuvError> {
13    APP_STATE.set(state).map_err(|_: Arc<AppState>| {
14        EuvError::Message(ERROR_GLOBAL_STATE_ALREADY_INITIALIZED.to_string())
15    })
16}
17
18/// Retrieves the global application state.
19///
20/// # Returns
21///
22/// - `Option<Arc<AppState>>` - The global state if initialized.
23pub(crate) fn get_global_state() -> Option<Arc<AppState>> {
24    APP_STATE.get().cloned()
25}
26
27/// Resolves the bootstrap script body to inline into the generated HTML.
28///
29/// The wasm-bindgen JS bridge wasm-pack wrote into the output directory is
30/// read, its module graph is inlined, and the result is wrapped in a
31/// self-contained IIFE that fetches the `.wasm` module and calls `main()`.
32/// The page then carries no JavaScript request of its own, which is what
33/// lets [`remove_generated_js`](crate::build::remove_generated_js) delete
34/// every generated `.js` afterwards.
35///
36/// There is no fallback path. A missing bridge, an unreadable snippet, or
37/// a construct the inliner cannot reproduce is an error: silently
38/// degrading to `<script type="module">import … from './pkg/x.js'` would
39/// leave the deployed tree importing a file this build is about to delete.
40///
41/// # Arguments
42///
43/// - `&HtmlConfig` - The HTML configuration holding the serving root and import path.
44///
45/// # Returns
46///
47/// - `Result<String, EuvError>` - The bootstrap script body to inline into
48///   the generated HTML, or the reason the bridge could not be inlined.
49async fn resolve_inline_js(config: &HtmlConfig) -> Result<String, EuvError> {
50    let (pkg_dir, wasm_url): (PathBuf, String) = resolve_bridge_location(config)?;
51    let js_name: String = config
52        .get_import_path()
53        .rsplit('/')
54        .next()
55        .unwrap_or_default()
56        .to_string();
57    let js_path: PathBuf = pkg_dir.join(&js_name);
58    if !js_path.is_file() {
59        return Err(EuvError::IoPath {
60            message: format!(
61                "wasm-pack wrote no JS bridge to inline; {} is missing, so this build \
62                 cannot produce a JavaScript-free page",
63                js_path.display()
64            ),
65            path: js_path,
66            error: io::Error::new(io::ErrorKind::NotFound, "no wasm-bindgen JS bridge"),
67        });
68    }
69    build_inline_bridge(&pkg_dir, &js_name, &wasm_url).await
70}
71
72/// Derives the wasm-pack output directory and the `.wasm` URL the inlined
73/// bootstrap must fetch, from the configured import path alone.
74///
75/// The import path is relative to the serving root, so its directory part
76/// names the output directory even when `--out-dir` points somewhere other
77/// than `{www-dir}/pkg`, at any nesting depth, or at an absolute location.
78/// The `.wasm` URL is the bridge's own URL with the `.js` suffix swapped,
79/// which keeps it correct for every one of those layouts.
80///
81/// # Arguments
82///
83/// - `&HtmlConfig` - The HTML configuration holding the serving root and import path.
84///
85/// # Returns
86///
87/// - `Result<(PathBuf, String), EuvError>` - The output directory and the
88///   `.wasm` URL, or an error when the import path does not name a bridge.
89fn resolve_bridge_location(config: &HtmlConfig) -> Result<(PathBuf, String), EuvError> {
90    let import_path: &str = config.get_import_path();
91    let Some((relative_dir, js_name)) = import_path.rsplit_once('/') else {
92        return Err(EuvError::Message(format!(
93            "import path '{import_path}' does not name a generated bridge, so this build \
94             cannot produce a JavaScript-free page"
95        )));
96    };
97    let js_name: &str = js_name.split(['?', '#']).next().unwrap_or(js_name);
98    let Some(stem) = js_name.strip_suffix(JS_EXTENSION) else {
99        return Err(EuvError::Message(format!(
100            "import path '{import_path}' does not name a JavaScript bridge, so this build \
101             cannot produce a JavaScript-free page"
102        )));
103    };
104    let pkg_dir: PathBuf = config.get_serving_root().join(relative_dir);
105    let wasm_url: String = format!("{relative_dir}/{stem}{WASM_FILE_SUFFIX}");
106    Ok((pkg_dir, wasm_url))
107}
108
109/// Normalises every bootstrap in `template` to the inline form.
110///
111/// A template that already uses [`INLINE_JS_PLACEHOLDER`] is returned
112/// unchanged. A legacy template that still imports the bridge by URL has
113/// each `<script>` element carrying that import replaced, as a whole
114/// element, by the inline-bootstrap placeholder — the author's other
115/// scripts, their attributes, and their bodies are left exactly as
116/// written, so a page that also embeds analytics or a font loader keeps
117/// them and never ends up calling `main()` twice.
118///
119/// A template with neither placeholder gets the placeholder injected into
120/// its `</body>`, because a page without it can never boot the WASM module.
121///
122/// # Arguments
123///
124/// - `&str` - The template source.
125///
126/// # Returns
127///
128/// - `Result<String, EuvError>` - The template with exactly one bootstrap
129///   placeholder, or an error naming what made that impossible.
130pub(crate) fn normalize_bootstrap(template: &str) -> Result<String, EuvError> {
131    let script_tag: String = format!("<script>{INLINE_JS_PLACEHOLDER}</script>");
132    let template: String = replace_module_bootstrap(template, &script_tag);
133    if template.contains(IMPORT_PATH_PLACEHOLDER) {
134        return Err(EuvError::Message(String::from(
135            "the index.html template still carries __IMPORT_PATH__ outside a bootstrap \
136             script; replace it with __EUV_INLINE_JS__",
137        )));
138    }
139    match template.matches(INLINE_JS_PLACEHOLDER).count() {
140        1 => return Ok(template),
141        0 => {}
142        _ => {
143            return Err(EuvError::Message(String::from(
144                "the index.html template must contain exactly one WASM bootstrap",
145            )));
146        }
147    }
148    let body_close: usize = template.rfind("</body>").ok_or_else(|| {
149        EuvError::Message(String::from(
150            "the index.html template has no __EUV_INLINE_JS__ placeholder and no </body> to \
151             inject one before; add the placeholder so the page boots its WASM module",
152        ))
153    })?;
154    let mut out: String = String::with_capacity(template.len() + script_tag.len());
155    out.push_str(&template[..body_close]);
156    out.push_str(&script_tag);
157    out.push('\n');
158    out.push_str(&template[body_close..]);
159    Ok(out)
160}
161
162/// Replaces every `<script>` element whose body imports a module with
163/// `replacement`, leaving every other byte of `template` untouched.
164///
165/// # Arguments
166///
167/// - `&str` - The complete HTML template.
168/// - `&str` - The inline bootstrap placeholder element.
169///
170/// # Returns
171///
172/// - `String` - The template with only legacy WASM bootstrap elements replaced.
173fn replace_module_bootstrap(template: &str, replacement: &str) -> String {
174    let lowered: String = template.to_ascii_lowercase();
175    let mut out: String = String::with_capacity(template.len());
176    let mut cursor: usize = 0;
177    while let Some(open_offset) = lowered[cursor..].find("<script") {
178        let open: usize = cursor + open_offset;
179        let Some(open_end) = lowered[open..].find('>').map(|end: usize| open + end + 1) else {
180            break;
181        };
182        let tag: &str = &template[open..open_end];
183        let tag_end: Option<usize> = lowered[open_end..]
184            .find("</script")
185            .map(|end: usize| open_end + end);
186        let Some(close_end) = tag_end
187            .and_then(|close: usize| lowered[close..].find('>').map(|end: usize| close + end + 1))
188        else {
189            break;
190        };
191        let body: &str = &template[open_end..tag_end.unwrap_or(close_end)];
192        out.push_str(&template[cursor..open]);
193        if body.contains(IMPORT_PATH_PLACEHOLDER) && !tag.contains("src=") && !tag.contains("src =")
194        {
195            out.push_str(replacement);
196        } else {
197            out.push_str(&template[open..close_end]);
198        }
199        cursor = close_end;
200    }
201    out.push_str(&template[cursor..]);
202    out
203}
204
205/// Generates `index.html` based on the build profile.
206///
207/// Uses `INDEX_HTML_RELEASE` when `is_release` is `true` (no live-reload script),
208/// otherwise uses `INDEX_HTML_DEV` (includes live-reload instrumentation).
209///
210/// The wasm-bindgen JS bridge is **mandatory and self-contained**: Rust
211/// reads the bridge wasm-pack wrote, inlines its module graph, and embeds
212/// the body as a `<script>` in the page. The `.js`, `.d.ts`, `.js.map` and
213/// `snippets/` files it inlined are then deleted, so the deployed tree
214/// holds no JavaScript at all. A bridge that cannot be inlined fails the
215/// build rather than degrading to an external module import.
216///
217/// A custom `--index-html` template may either carry
218/// `__EUV_INLINE_JS__` or still carry the legacy `__IMPORT_PATH__` module
219/// bootstrap, in which case the whole `<script>` element that imports the
220/// bridge is replaced by the inline one and every other script the author
221/// wrote is preserved verbatim.
222///
223/// # Arguments
224///
225/// - `&HtmlConfig` - The HTML generation configuration.
226///
227/// # Returns
228///
229/// - `Result<String, EuvError>` - The generated HTML content written to disk.
230pub(crate) async fn generate_html(config: &HtmlConfig) -> Result<String, EuvError> {
231    let template_content: String = if let Some(custom_path) = config.try_get_custom_index_html() {
232        let bytes: Vec<u8> =
233            read(custom_path)
234                .await
235                .map_err(|error: io::Error| EuvError::IoPath {
236                    message: ERROR_READ_CUSTOM_INDEX_HTML.to_string(),
237                    path: custom_path.to_path_buf(),
238                    error,
239                })?;
240        String::from_utf8(bytes).map_err(|error: FromUtf8Error| EuvError::Utf8 {
241            message: ERROR_CUSTOM_INDEX_HTML_NOT_UTF8.to_string(),
242            error,
243        })?
244    } else if config.get_is_release() {
245        INDEX_HTML_RELEASE.to_string()
246    } else {
247        INDEX_HTML_DEV.to_string()
248    };
249    let inline_js: String = resolve_inline_js(config).await?;
250    let template_content: String = normalize_bootstrap(&template_content)?;
251    let base_href_tag: String = if let Some(prefix) = config.try_get_serving_route_prefix() {
252        let path: String = format!("/{prefix}");
253        format!("<base href=\"{}/\" />", path.trim_end_matches('/'))
254    } else {
255        String::new()
256    };
257    let html: String = template_content
258        .replace(BASE_HREF_PLACEHOLDER, &base_href_tag)
259        .replace(RELOAD_ROUTE_PLACEHOLDER, RELOAD_ROUTE)
260        .replace(INLINE_JS_PLACEHOLDER, &inline_js)
261        .replace(LOADING_STYLE_PLACEHOLDER, LOADING_STYLE)
262        .replace(LOADING_ELEMENT_PLACEHOLDER, LOADING_ELEMENT)
263        .replace(LOADING_REMOVAL_PLACEHOLDER, LOADING_REMOVAL_SCRIPT);
264    let index_path: PathBuf = config.get_serving_root().join(INDEX_HTML_FILE_NAME);
265    create_dir_all(config.get_serving_root())
266        .await
267        .map_err(|error: io::Error| EuvError::Io {
268            message: ERROR_CREATE_STATIC_DIRECTORY.to_string(),
269            error,
270        })?;
271    write(&index_path, &html)
272        .await
273        .map_err(|error: io::Error| EuvError::Io {
274            message: ERROR_WRITE_INDEX_HTML.to_string(),
275            error,
276        })?;
277    Ok(html)
278}
279
280/// Resolves the effective www directory, handling wasm-pack nested output.
281///
282/// # Arguments
283///
284/// - `&Path` - The candidate www directory path.
285///
286/// # Returns
287///
288/// - `PathBuf` - The resolved www directory containing `index.html`.
289pub async fn resolve_www_dir(www_dir: &Path) -> PathBuf {
290    if metadata(www_dir.join(INDEX_HTML_FILE_NAME)).await.is_ok() {
291        return www_dir.to_path_buf();
292    }
293    let parent_name: Option<&str> = www_dir
294        .file_name()
295        .and_then(|file_name_os_str: &ffi::OsStr| file_name_os_str.to_str());
296    if let Some(name) = parent_name {
297        let nested: PathBuf = www_dir.join(name);
298        if metadata(nested.join(INDEX_HTML_FILE_NAME)).await.is_ok() {
299            return nested;
300        }
301    }
302    www_dir.to_path_buf()
303}
304
305/// Resolves the pkg directory for serving WASM artifacts.
306///
307/// Delegates to `resolve_out_dir` which respects `--out-dir`
308/// from wasm-pack args or defaults to `{www_dir}/pkg`.
309///
310/// # Arguments
311///
312/// - `&ModeArgs` - The CLI arguments for resolving out_dir.
313///
314/// # Returns
315///
316/// - `PathBuf` - The resolved pkg directory containing WASM build artifacts.
317pub fn resolve_pkg_dir(args: &ModeArgs) -> PathBuf {
318    resolve_out_dir(args)
319}
320
321/// Resolves a file path within a base directory with path-traversal protection.
322///
323/// Canonicalizes both the file path and the base directory, then verifies
324/// that the canonical file path starts with the canonical base directory.
325///
326/// # Arguments
327///
328/// - `&Path` - The base directory to resolve within.
329/// - `&str` - The relative path to the file.
330///
331/// # Returns
332///
333/// - `Option<PathBuf>` - The resolved file path if valid, `None` otherwise.
334pub async fn resolve_file_in_base(base: &Path, path: &str) -> Option<PathBuf> {
335    let file_path: PathBuf = base.join(path);
336    let canonical_path: PathBuf = canonicalize(&file_path).await.ok()?;
337    let base_canonical: PathBuf = canonicalize(base).await.ok()?;
338    canonical_path
339        .starts_with(&base_canonical)
340        .then_some(file_path)
341}