Skip to main content

waterui_cli/hydrolysis/
platform.rs

1//! Hydrolysis platform build and package utilities.
2
3use std::ffi::OsString;
4use std::net::{Ipv4Addr, SocketAddr};
5use std::path::{Path, PathBuf};
6
7use eyre::{Context, bail};
8use futures_util::FutureExt as _;
9use smol::{
10    channel::{Sender, bounded},
11    fs,
12    io::{AsyncReadExt, AsyncWriteExt},
13    net::TcpListener,
14};
15use target_lexicon::Triple;
16use tracing::info;
17
18use crate::{
19    assets, browser_runtime,
20    build::{BuildOptions, RustBuild, RustDynamicLibraries, RustLinkage},
21    device::Artifact,
22    hydrolysis::backend::HydrolysisBackend,
23    platform::{PackageOptions, TargetPlatform},
24    project::Project,
25    toolchain::{ToolchainError, windows_arm64_llvm::WindowsArm64LlvmToolchain},
26    utils::{command, run_command_os, which},
27};
28#[cfg(target_os = "macos")]
29use crate::{
30    macos_bundle::{
31        MacOsUsageDescription, package_binary_as_app, package_cef_helper_app,
32        sign_macos_app as sign_app,
33    },
34    project::BrowserRuntimePlan,
35};
36
37#[cfg(target_os = "macos")]
38const HYDROLYSIS_INIT_HINT: &str = "water run --platform macos --backend hydrolysis";
39#[cfg(target_os = "linux")]
40const HYDROLYSIS_INIT_HINT: &str = "water run --platform linux --backend hydrolysis";
41#[cfg(target_os = "windows")]
42const HYDROLYSIS_INIT_HINT: &str = "water run --platform windows --backend hydrolysis";
43#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
44const HYDROLYSIS_INIT_HINT: &str = "initialize hydrolysis backend on macOS, Linux, or Windows";
45
46/// Cargo profile directory the hydrolysis backend's artifacts land in.
47async fn hydrolysis_profile_dir(
48    project: &Project,
49    platform: TargetPlatform,
50    profile: &str,
51    linkage: RustLinkage,
52) -> eyre::Result<PathBuf> {
53    Ok(project
54        .water_target_dir(linkage)
55        .await?
56        .join(platform.triple().to_string())
57        .join(profile))
58}
59
60/// Loader search path the platform's dynamic linker resolves the shared runtime through.
61const fn hydrolysis_loader_search_path(platform: TargetPlatform) -> Option<&'static str> {
62    match platform {
63        TargetPlatform::MacOS => Some("@executable_path/../Frameworks"),
64        TargetPlatform::Linux => Some("$ORIGIN"),
65        _ => None,
66    }
67}
68
69/// Build hydrolysis binary for the host platform.
70///
71/// # Errors
72/// Returns an error if the platform is unsupported, the backend is missing, or Cargo fails.
73pub async fn build_hydrolysis(
74    project: &Project,
75    platform: TargetPlatform,
76    options: BuildOptions,
77) -> eyre::Result<PathBuf> {
78    build_hydrolysis_with_envs_and_features(project, platform, options, &[], &[]).await
79}
80
81/// Build hydrolysis binary for the host platform with extra Cargo environment variables.
82///
83/// # Errors
84/// Returns an error if the platform is unsupported, the backend is missing, or Cargo fails.
85pub async fn build_hydrolysis_with_envs(
86    project: &Project,
87    platform: TargetPlatform,
88    options: BuildOptions,
89    extra_envs: &[(String, OsString)],
90) -> eyre::Result<PathBuf> {
91    build_hydrolysis_with_envs_and_features(project, platform, options, extra_envs, &[]).await
92}
93
94/// Build hydrolysis binary for the host platform with extra Cargo environment variables and features.
95///
96/// # Errors
97/// Returns an error if the platform is unsupported, the backend is missing, or Cargo fails.
98pub async fn build_hydrolysis_with_envs_and_features(
99    project: &Project,
100    platform: TargetPlatform,
101    options: BuildOptions,
102    extra_envs: &[(String, OsString)],
103    extra_features: &[&str],
104) -> eyre::Result<PathBuf> {
105    if !is_hydrolysis_native_platform(platform) {
106        bail!("Hydrolysis backend is only supported on macOS, Linux, and Windows");
107    }
108
109    let backend_path = project.backend_path::<HydrolysisBackend>();
110    let cargo_toml = backend_path.join("Cargo.toml");
111
112    if !cargo_toml.exists() {
113        bail!(
114            "Hydrolysis backend not found at {}. Run `{HYDROLYSIS_INIT_HINT}` to initialize it.",
115            backend_path.display(),
116        );
117    }
118
119    // Stage assets and the Windows icon resource before the backend is built.
120    // The generated `build.rs` expects `app-icon.ico` to exist when targeting Windows.
121    copy_assets_and_fonts(
122        project,
123        &backend_path,
124        options.sccache_path(),
125        options.uses_dev_server(),
126    )
127    .await?;
128
129    let llvm_envs = WindowsArm64LlvmToolchain
130        .cargo_envs(&crate::toolchain::Host::current())
131        .await
132        .map_err(|error| match error {
133            ToolchainError::Fixable(_) => eyre::eyre!(
134                "Windows ARM64 LLVM toolchain is missing. Run `water doctor --fix` to install it automatically."
135            ),
136            ToolchainError::Unfixable(unfixable) => {
137                eyre::eyre!("Windows ARM64 LLVM toolchain check failed: {unfixable}")
138            }
139        })?;
140
141    let mut build = RustBuild::new(&backend_path, platform.triple())
142        .with_project(project)
143        .with_target_dir(project.water_target_dir(options.linkage()).await?)
144        .with_features(extra_features.iter().copied())
145        .with_linkage(
146            options.linkage(),
147            &format!("{}/dev", project.crate_name()),
148            hydrolysis_loader_search_path(platform),
149        )
150        .with_envs(llvm_envs)
151        .with_envs(options.cargo_envs().iter().cloned())
152        .with_envs(extra_envs.iter().cloned());
153    if let Some(sccache_path) = options.sccache_path() {
154        build = build.with_sccache(sccache_path.to_path_buf());
155    }
156    build
157        .build_binary(
158            project.hydrolysis_backend_crate_name().as_str(),
159            options.is_release(),
160        )
161        .await
162        .wrap_err("Failed to build hydrolysis backend with cargo")?;
163
164    build
165        .lib_output_dir(options.is_release())
166        .await
167        .map_err(Into::into)
168}
169
170/// Resolve the built hydrolysis backend binary path for the given profile.
171///
172/// # Errors
173/// Returns an error if neither the canonical nor underscored backend binary can be found.
174pub async fn built_hydrolysis_binary_path(
175    project: &Project,
176    platform: TargetPlatform,
177    profile: &str,
178    linkage: RustLinkage,
179) -> eyre::Result<PathBuf> {
180    let target_dir = hydrolysis_profile_dir(project, platform, profile, linkage).await?;
181    let binary_name = project.hydrolysis_backend_crate_name();
182    let binary_path = if cfg!(windows) {
183        target_dir.join(format!("{binary_name}.exe"))
184    } else {
185        target_dir.join(binary_name.as_ref())
186    };
187    if binary_path.exists() {
188        return Ok(binary_path);
189    }
190
191    let alt_binary_name = binary_name.replace('-', "_");
192    let underscored_path = if cfg!(windows) {
193        target_dir.join(format!("{alt_binary_name}.exe"))
194    } else {
195        target_dir.join(alt_binary_name)
196    };
197    if underscored_path.exists() {
198        return Ok(underscored_path);
199    }
200
201    bail!(
202        "Built hydrolysis binary not found at {} or {}",
203        binary_path.display(),
204        underscored_path.display()
205    );
206}
207
208/// Stage the shared `WaterUI` runtime and Rust standard library next to a raw
209/// Hydrolysis development binary.
210///
211/// Packaged applications stage these libraries in their platform runtime
212/// directory. Preview and test binaries execute directly from Cargo's profile
213/// directory, whose `@loader_path`/`$ORIGIN` entry resolves this adjacent copy.
214///
215/// # Errors
216/// Returns an error if the binary has no parent directory or the required shared
217/// libraries cannot be resolved and staged.
218pub(crate) async fn stage_hydrolysis_shared_runtime(
219    binary_path: &Path,
220    platform: TargetPlatform,
221) -> eyre::Result<()> {
222    if !is_hydrolysis_native_platform(platform) {
223        bail!("Hydrolysis shared runtime can only be staged for macOS, Linux, and Windows");
224    }
225    let runtime_dir = binary_path.parent().ok_or_else(|| {
226        eyre::eyre!(
227            "Hydrolysis binary path has no output directory: {}",
228            binary_path.display()
229        )
230    })?;
231    let libraries = RustDynamicLibraries::resolve(runtime_dir, &platform.triple()).await?;
232    synchronize_shared_runtime(runtime_dir, Some(&libraries), &platform.triple()).await
233}
234
235/// Clean Cargo build artifacts for hydrolysis.
236///
237/// # Errors
238/// Returns an error if `cargo clean` fails or generated web output cannot be removed.
239pub async fn clean_hydrolysis(project: &Project) -> eyre::Result<()> {
240    let backend_path = project.backend_path::<HydrolysisBackend>();
241    let cargo_toml = backend_path.join("Cargo.toml");
242
243    if !cargo_toml.exists() {
244        return Ok(());
245    }
246
247    // The target directories are shared with every other generated backend, so only
248    // this backend's own package is cleaned — its dependency artifacts stay for
249    // the other backends that resolve them identically.
250    for linkage in [RustLinkage::SharedRuntime, RustLinkage::Static] {
251        let shared_target_dir = project.water_target_dir(linkage).await?;
252        if !shared_target_dir.exists() {
253            continue;
254        }
255        let clean_args: Vec<OsString> = vec![
256            "clean".into(),
257            "--manifest-path".into(),
258            cargo_toml.as_os_str().to_owned(),
259            "--target-dir".into(),
260            shared_target_dir.as_os_str().to_owned(),
261            "--package".into(),
262            project.hydrolysis_backend_crate_name().as_str().into(),
263        ];
264        run_command_os("cargo", clean_args).await?;
265    }
266    let dist_web = backend_path.join("dist/web");
267    if dist_web.exists() {
268        fs::remove_dir_all(&dist_web).await?;
269    }
270    let dist_web_dev = backend_path.join("dist/web-dev");
271    if dist_web_dev.exists() {
272        fs::remove_dir_all(&dist_web_dev).await?;
273    }
274    Ok(())
275}
276
277/// Package a hydrolysis app.
278///
279/// Linux/Windows return a binary artifact path.
280/// macOS returns a `.app` bundle path.
281///
282/// # Errors
283/// Returns an error if packaging prerequisites are missing, assets cannot be staged, or output artifacts cannot be produced.
284pub async fn package_hydrolysis(
285    project: &Project,
286    platform: TargetPlatform,
287    options: PackageOptions,
288) -> eyre::Result<Artifact> {
289    if platform == TargetPlatform::Web {
290        let site_root = package_hydrolysis_web_site(project, options.is_debug(), false).await?;
291        return Ok(Artifact::new(project.bundle_identifier(), site_root));
292    }
293
294    if !is_hydrolysis_native_platform(platform) {
295        bail!(
296            "Hydrolysis backend is only supported on macOS, Linux, and Windows for binary packaging"
297        );
298    }
299
300    let profile = if options.is_debug() {
301        "debug"
302    } else {
303        "release"
304    };
305    let backend_path = project.backend_path::<HydrolysisBackend>();
306    copy_assets_and_fonts(project, &backend_path, None, options.uses_dev_server()).await?;
307
308    let linkage = if options.uses_shared_rust_runtime() {
309        RustLinkage::SharedRuntime
310    } else {
311        RustLinkage::Static
312    };
313    let final_binary_path =
314        built_hydrolysis_binary_path(project, platform, profile, linkage).await?;
315    let profile_directory = final_binary_path.parent().ok_or_else(|| {
316        eyre::eyre!(
317            "Hydrolysis binary path has no profile directory: {}",
318            final_binary_path.display()
319        )
320    })?;
321    let runtime_plan = project
322        .browser_runtime_plan(platform, crate::platform::TargetBackend::Hydrolysis)
323        .await?;
324    let shared_libraries = if options.uses_shared_rust_runtime() {
325        let lib_dir = final_binary_path.parent().ok_or_else(|| {
326            eyre::eyre!(
327                "Hydrolysis binary path has no output directory: {}",
328                final_binary_path.display()
329            )
330        })?;
331        Some(RustDynamicLibraries::resolve(lib_dir, &platform.triple()).await?)
332    } else {
333        None
334    };
335
336    #[cfg(target_os = "macos")]
337    {
338        if platform == TargetPlatform::MacOS {
339            return package_hydrolysis_macos(
340                project,
341                platform,
342                &backend_path,
343                &final_binary_path,
344                profile_directory,
345                runtime_plan,
346                shared_libraries.as_ref(),
347            )
348            .await;
349        }
350    }
351
352    let runtime_dir = final_binary_path.parent().ok_or_else(|| {
353        eyre::eyre!(
354            "Hydrolysis binary path has no output directory: {}",
355            final_binary_path.display()
356        )
357    })?;
358    synchronize_shared_runtime(runtime_dir, shared_libraries.as_ref(), &platform.triple()).await?;
359    browser_runtime::stage(runtime_plan, platform, profile_directory, runtime_dir).await?;
360
361    if platform == TargetPlatform::Linux {
362        let executable_name = final_binary_path
363            .file_name()
364            .and_then(std::ffi::OsStr::to_str)
365            .ok_or_else(|| eyre::eyre!("Hydrolysis binary has no valid executable name"))?;
366        crate::platforming::linux_share::write_linux_share_material(
367            project,
368            &project.manifest().package.name,
369            executable_name,
370            &backend_path.join("dist/share"),
371        )
372        .await?;
373    }
374
375    Ok(Artifact::new(
376        project.bundle_identifier(),
377        final_binary_path,
378    ))
379}
380
381#[cfg(target_os = "macos")]
382async fn package_hydrolysis_macos(
383    project: &Project,
384    platform: TargetPlatform,
385    backend_path: &Path,
386    binary_path: &Path,
387    profile_directory: &Path,
388    runtime_plan: BrowserRuntimePlan,
389    shared_libraries: Option<&RustDynamicLibraries>,
390) -> eyre::Result<Artifact> {
391    let app_name = project
392        .manifest()
393        .package
394        .name
395        .chars()
396        .filter(|character| character.is_alphanumeric() || *character == ' ')
397        .collect::<String>();
398    let app_name = if app_name.is_empty() {
399        "WaterUIHydrolysis".to_string()
400    } else {
401        app_name
402    };
403    let dist_dir = backend_path.join("dist");
404    fs::create_dir_all(&dist_dir).await?;
405    let usage_descriptions = project
406        .manifest()
407        .permissions
408        .iter()
409        .filter(|(_, entry)| entry.is_enabled())
410        .flat_map(|(key, entry)| {
411            key.macos_usage_description_keys()
412                .iter()
413                .map(|&plist_key| MacOsUsageDescription {
414                    plist_key,
415                    description: entry.description().to_string(),
416                })
417        })
418        .collect::<Vec<_>>();
419    let icns = assets::project_macos_icns(project)?;
420    let app_path = package_binary_as_app(
421        binary_path,
422        project.bundle_identifier(),
423        &app_name,
424        &usage_descriptions,
425        Some(&backend_path.join("resources")),
426        &icns,
427        &dist_dir,
428    )
429    .await?;
430    synchronize_shared_runtime(
431        &app_path.join("Contents/Frameworks"),
432        shared_libraries,
433        &platform.triple(),
434    )
435    .await?;
436    browser_runtime::stage_macos_app(runtime_plan, profile_directory, &app_path.join("Contents"))
437        .await?;
438    if runtime_plan.requires_cef() {
439        let helper_binary = binary_path
440            .parent()
441            .expect("Hydrolysis application binary must have a profile directory")
442            .join("waterui-cef-helper");
443        let _helper_apps = package_cef_helper_app(
444            &app_path,
445            binary_path,
446            &helper_binary,
447            project.bundle_identifier(),
448        )
449        .await?;
450    }
451    sign_app(
452        &app_path,
453        project.bundle_identifier(),
454        !usage_descriptions.is_empty(),
455    )
456    .await?;
457    Ok(Artifact::new(project.bundle_identifier(), app_path))
458}
459
460async fn synchronize_shared_runtime(
461    destination: &Path,
462    libraries: Option<&RustDynamicLibraries>,
463    triple: &Triple,
464) -> eyre::Result<()> {
465    if let Some(libraries) = libraries {
466        libraries.stage(destination).await
467    } else {
468        RustDynamicLibraries::remove_staged(destination, triple).await
469    }
470}
471
472/// Check if a platform is supported by the hydrolysis backend.
473#[must_use]
474pub const fn is_hydrolysis_platform(platform: TargetPlatform) -> bool {
475    matches!(
476        platform,
477        TargetPlatform::Linux
478            | TargetPlatform::MacOS
479            | TargetPlatform::Windows
480            | TargetPlatform::Web
481    )
482}
483
484const fn is_hydrolysis_native_platform(platform: TargetPlatform) -> bool {
485    matches!(
486        platform,
487        TargetPlatform::Linux | TargetPlatform::MacOS | TargetPlatform::Windows
488    )
489}
490
491async fn copy_assets_and_fonts(
492    project: &Project,
493    backend_path: &Path,
494    sccache_path: Option<&Path>,
495    dev_server: bool,
496) -> eyre::Result<()> {
497    let resources_dir = backend_path.join("resources");
498    fs::create_dir_all(&resources_dir).await?;
499    let manifest =
500        assets::stage_project_assets_for_gtk(project, &resources_dir, sccache_path, dev_server)
501            .await?;
502
503    // The generated crate's build script embeds this into the executable's
504    // resources when targeting Windows.
505    fs::write(
506        backend_path.join("app-icon.ico"),
507        assets::project_windows_ico(project)?,
508    )
509    .await?;
510
511    let mut font_declarations = assets::scan_fonts(project).await?;
512    font_declarations.extend(assets::hydrolysis_default_font_declarations());
513    let mut resolved_fonts = assets::resolve_fonts(font_declarations).await?;
514    resolved_fonts.extend(assets::scan_project_font_assets(&manifest)?);
515    if !resolved_fonts.is_empty() {
516        let fonts_dest = resources_dir.join("fonts");
517        assets::copy_fonts(&resolved_fonts, &fonts_dest).await?;
518        info!(
519            "Copied {} fonts to hydrolysis resources",
520            resolved_fonts.len()
521        );
522    }
523    Ok(())
524}
525
526/// Build the Hydrolysis web site in debug mode for `water run --platform web`.
527///
528/// # Errors
529/// Returns an error if the web site cannot be packaged.
530pub async fn prepare_hydrolysis_web_dev_site(project: &Project) -> eyre::Result<PathBuf> {
531    package_hydrolysis_web_site(project, true, true).await
532}
533
534/// A lightweight static-file server for packaged Hydrolysis web output.
535#[derive(Debug)]
536pub struct HydrolysisWebDevServer {
537    address: SocketAddr,
538    shutdown_tx: Option<Sender<()>>,
539    _task: smol::Task<()>,
540}
541
542impl HydrolysisWebDevServer {
543    /// Start serving the provided Hydrolysis web site root on a random localhost port.
544    ///
545    /// # Errors
546    /// Returns an error if the local TCP listener cannot be bound.
547    pub async fn start(site_root: PathBuf) -> eyre::Result<Self> {
548        let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
549            .await
550            .wrap_err("Failed to bind Hydrolysis web dev server")?;
551        let address = listener
552            .local_addr()
553            .wrap_err("Failed to resolve Hydrolysis web dev server address")?;
554        let (shutdown_tx, shutdown_rx) = bounded::<()>(1);
555
556        let task = smol::spawn(async move {
557            let shutdown = shutdown_rx.recv().fuse();
558            futures_util::pin_mut!(shutdown);
559
560            loop {
561                let accept = listener.accept().fuse();
562                futures_util::pin_mut!(accept);
563
564                match futures_util::future::select(accept, shutdown.as_mut()).await {
565                    futures_util::future::Either::Left((Ok((mut stream, _peer)), _)) => {
566                        let site_root = site_root.clone();
567                        smol::spawn(async move {
568                            if let Err(error) = serve_http_request(&mut stream, &site_root).await {
569                                tracing::warn!(
570                                    target: "waterui::hydrolysis::web",
571                                    error = %error,
572                                    "Hydrolysis web dev server request failed"
573                                );
574                            }
575                        })
576                        .detach();
577                    }
578                    futures_util::future::Either::Left((Err(error), _)) => {
579                        tracing::warn!(
580                            target: "waterui::hydrolysis::web",
581                            error = %error,
582                            "Hydrolysis web dev server accept failed"
583                        );
584                        break;
585                    }
586                    futures_util::future::Either::Right((_shutdown, _)) => break,
587                }
588            }
589        });
590
591        Ok(Self {
592            address,
593            shutdown_tx: Some(shutdown_tx),
594            _task: task,
595        })
596    }
597
598    /// Get the bound localhost address for this server instance.
599    #[must_use]
600    pub const fn address(&self) -> SocketAddr {
601        self.address
602    }
603}
604
605impl Drop for HydrolysisWebDevServer {
606    fn drop(&mut self) {
607        if let Some(tx) = self.shutdown_tx.take() {
608            let _ = tx.try_send(());
609        }
610    }
611}
612
613async fn package_hydrolysis_web_site(
614    project: &Project,
615    debug: bool,
616    dev_site: bool,
617) -> eyre::Result<PathBuf> {
618    let backend_path = project.backend_path::<HydrolysisBackend>();
619    let cargo_toml = backend_path.join("Cargo.toml");
620    let lib_rs = backend_path.join("src/lib.rs");
621    if !cargo_toml.exists() {
622        bail!(
623            "Hydrolysis backend not found at {}. Run `water backend add hydrolysis` first.",
624            backend_path.display(),
625        );
626    }
627    if !lib_rs.exists() {
628        bail!(
629            "Hydrolysis backend at {} is missing src/lib.rs for web packaging. Re-scaffold the backend and try again.",
630            backend_path.display(),
631        );
632    }
633
634    let site_root = backend_path.join(if dev_site { "dist/web-dev" } else { "dist/web" });
635    if site_root.exists() {
636        fs::remove_dir_all(&site_root).await?;
637    }
638    fs::create_dir_all(&site_root).await?;
639
640    // The shell is written after the bundle so the page knows the wasm size.
641    build_hydrolysis_web_bundle(&backend_path, &site_root, debug).await?;
642    super::web_launch::write_web_shell(project, &site_root).await?;
643    copy_web_assets_and_fonts(project, &site_root).await?;
644
645    Ok(site_root)
646}
647
648async fn build_hydrolysis_web_bundle(
649    backend_path: &Path,
650    site_root: &Path,
651    debug: bool,
652) -> eyre::Result<()> {
653    let wasm_pack = which("wasm-pack")
654        .await
655        .wrap_err("wasm-pack is required to build Hydrolysis web bundles")?;
656    let pkg_dir = site_root.join("pkg");
657    fs::create_dir_all(&pkg_dir).await?;
658
659    let mut wasm_pack_cmd = smol::process::Command::new(wasm_pack);
660    let wasm_pack_cmd = command(&mut wasm_pack_cmd);
661    wasm_pack_cmd
662        .current_dir(backend_path)
663        .arg("build")
664        .arg("--target")
665        .arg("web")
666        .arg("--out-dir")
667        .arg(&pkg_dir)
668        .arg("--out-name")
669        .arg("app");
670    if debug {
671        wasm_pack_cmd.arg("--dev");
672    } else {
673        wasm_pack_cmd.arg("--release");
674    }
675
676    let output = wasm_pack_cmd.output().await?;
677    if !output.status.success() {
678        let stderr = String::from_utf8_lossy(&output.stderr);
679        let stdout = String::from_utf8_lossy(&output.stdout);
680        let details = if stderr.trim().is_empty() {
681            stdout.to_string()
682        } else {
683            stderr.to_string()
684        };
685        bail!(
686            "Failed to build Hydrolysis web bundle with wasm-pack (status {}):\n{}",
687            output.status,
688            details
689        );
690    }
691
692    Ok(())
693}
694
695async fn copy_web_assets_and_fonts(project: &Project, site_root: &Path) -> eyre::Result<()> {
696    assets::stage_project_assets_for_web(project, site_root).await?;
697    assets::stage_hydrolysis_web_fonts(project, site_root).await?;
698    Ok(())
699}
700
701async fn serve_http_request(
702    stream: &mut smol::net::TcpStream,
703    site_root: &Path,
704) -> eyre::Result<()> {
705    let mut buffer = vec![0u8; 8192];
706    let bytes_read = stream
707        .read(&mut buffer)
708        .await
709        .wrap_err("Failed to read HTTP request")?;
710    if bytes_read == 0 {
711        return Ok(());
712    }
713
714    let request = std::str::from_utf8(&buffer[..bytes_read])
715        .wrap_err("Hydrolysis web dev server received non-UTF-8 request head")?;
716    let request_line = request
717        .lines()
718        .next()
719        .ok_or_else(|| eyre::eyre!("Hydrolysis web dev server received an empty request"))?;
720    let mut parts = request_line.split_whitespace();
721    let method = parts.next().unwrap_or_default();
722    let path = parts.next().unwrap_or("/");
723    if method != "GET" && method != "HEAD" {
724        write_response(
725            stream,
726            405,
727            "text/plain; charset=utf-8",
728            b"Method Not Allowed",
729        )
730        .await?;
731        return Ok(());
732    }
733
734    let Ok(file_path) = resolve_site_path(site_root, path) else {
735        write_response(stream, 404, "text/plain; charset=utf-8", b"Not Found").await?;
736        return Ok(());
737    };
738    let body = match fs::read(&file_path).await {
739        Ok(body) => body,
740        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
741            write_response(stream, 404, "text/plain; charset=utf-8", b"Not Found").await?;
742            return Ok(());
743        }
744        Err(error) => {
745            return Err(error).wrap_err_with(|| format!("Failed to read {}", file_path.display()));
746        }
747    };
748    let mime = mime_type_for_path(&file_path);
749    if method == "HEAD" {
750        write_headers(stream, 200, mime, body.len()).await?;
751        return Ok(());
752    }
753    write_response(stream, 200, mime, &body).await
754}
755
756fn resolve_site_path(site_root: &Path, request_path: &str) -> eyre::Result<PathBuf> {
757    let request_path = request_path.split('?').next().unwrap_or("/");
758    let trimmed = request_path.trim_start_matches('/');
759    let relative = if trimmed.is_empty() {
760        "index.html"
761    } else {
762        trimmed
763    };
764
765    let mut resolved = site_root.to_path_buf();
766    for segment in relative.split('/') {
767        if segment.is_empty() || segment == "." {
768            continue;
769        }
770        if segment == ".." || segment.contains('\\') {
771            bail!("Hydrolysis web dev server rejected invalid path {request_path}");
772        }
773        resolved.push(segment);
774    }
775
776    if resolved.is_dir() {
777        resolved.push("index.html");
778    }
779    if !resolved.exists() {
780        bail!("Hydrolysis web dev server could not resolve {request_path}");
781    }
782    Ok(resolved)
783}
784
785async fn write_headers(
786    stream: &mut smol::net::TcpStream,
787    status_code: u16,
788    content_type: &str,
789    content_length: usize,
790) -> eyre::Result<()> {
791    let status_text = match status_code {
792        200 => "OK",
793        404 => "Not Found",
794        405 => "Method Not Allowed",
795        _ => "Internal Server Error",
796    };
797    let headers = format!(
798        "HTTP/1.1 {status_code} {status_text}\r\nContent-Type: {content_type}\r\nContent-Length: {content_length}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n"
799    );
800    stream.write_all(headers.as_bytes()).await?;
801    Ok(())
802}
803
804async fn write_response(
805    stream: &mut smol::net::TcpStream,
806    status_code: u16,
807    content_type: &str,
808    body: &[u8],
809) -> eyre::Result<()> {
810    write_headers(stream, status_code, content_type, body.len()).await?;
811    stream.write_all(body).await?;
812    stream.flush().await?;
813    Ok(())
814}
815
816fn mime_type_for_path(path: &Path) -> &'static str {
817    match path.extension().and_then(std::ffi::OsStr::to_str) {
818        Some("html") => "text/html; charset=utf-8",
819        Some("js") => "text/javascript; charset=utf-8",
820        Some("css") => "text/css; charset=utf-8",
821        Some("json") => "application/json",
822        Some("wasm") => "application/wasm",
823        Some("svg") => "image/svg+xml",
824        Some("png") => "image/png",
825        Some("jpg" | "jpeg") => "image/jpeg",
826        Some("gif") => "image/gif",
827        Some("webp") => "image/webp",
828        Some("avif") => "image/avif",
829        Some("ico") => "image/x-icon",
830        Some("txt") => "text/plain; charset=utf-8",
831        _ => "application/octet-stream",
832    }
833}