diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..22001ab --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +.PHONY: help build-macos build-linux + +help: + @echo "Story Kit – cross-platform build targets" + @echo "" + @echo " make build-macos Build native macOS release binary" + @echo " make build-linux Build static Linux x86_64 release binary (requires cross + Docker)" + @echo "" + @echo "Prerequisites:" + @echo " build-macos: Rust stable toolchain, pnpm" + @echo " build-linux: cargo install cross AND Docker Desktop running" + @echo "" + @echo "Output:" + @echo " macOS : target/release/story-kit-server" + @echo " Linux : target/x86_64-unknown-linux-musl/release/story-kit-server" + +## Build a native macOS release binary. +## The frontend is compiled by build.rs (pnpm build) and embedded via rust-embed. +## Verify dynamic deps afterwards: otool -L target/release/story-kit-server +build-macos: + cargo build --release + +## Build a fully static Linux x86_64 binary using the musl libc target. +## cross (https://github.com/cross-rs/cross) handles the Docker-based cross-compilation. +## Install cross: cargo install cross +## The resulting binary has zero dynamic library dependencies (ldd reports "not a dynamic executable"). +build-linux: + cross build --release --target x86_64-unknown-linux-musl diff --git a/README.md b/README.md index 4920adc..538e106 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,55 @@ cargo build --release ./target/release/story-kit-server ``` +## Cross-Platform Distribution + +Story Kit ships as a **single self-contained binary** with the React frontend embedded via +`rust-embed`. No Rust toolchain, Node.js, or extra libraries are required on the target machine. + +### macOS + +```bash +# Native build – no extra tools required beyond Rust + pnpm +make build-macos +# Output: target/release/story-kit-server + +# Verify only system frameworks are linked (Security.framework, libSystem.B.dylib, etc.) +otool -L target/release/story-kit-server +``` + +### Linux (static x86_64, zero dynamic deps) + +The Linux build uses the `x86_64-unknown-linux-musl` target to produce a fully static binary. + +**Prerequisites:** + +```bash +# Install cross – a Rust cross-compilation tool backed by Docker +cargo install cross + +# Ensure Docker Desktop (or Docker Engine) is running +``` + +**Build:** + +```bash +make build-linux +# Output: target/x86_64-unknown-linux-musl/release/story-kit-server + +# Verify the binary is statically linked +file target/x86_64-unknown-linux-musl/release/story-kit-server +# Expected: ELF 64-bit LSB executable, x86-64, statically linked + +ldd target/x86_64-unknown-linux-musl/release/story-kit-server +# Expected: not a dynamic executable +``` + +**Running on any Linux x86_64 machine:** + +```bash +# No Rust, Node, glibc, or any other library needed – just copy and run +./story-kit-server +``` ## Testing diff --git a/server/src/http/assets.rs b/server/src/http/assets.rs index 6868146..b35d638 100644 --- a/server/src/http/assets.rs +++ b/server/src/http/assets.rs @@ -65,3 +65,49 @@ pub fn embedded_file(Path(path): Path) -> Response { pub fn embedded_index() -> Response { serve_embedded("index.html") } + +#[cfg(test)] +mod tests { + use super::*; + use poem::http::StatusCode; + + #[test] + fn non_asset_path_spa_fallback_or_not_found() { + // Non-asset paths fall back to index.html for SPA client-side routing. + // In release builds (with embedded dist/) this returns 200. + // In debug builds without a built frontend dist/ it returns 404. + let response = serve_embedded("__nonexistent_spa_route__.html"); + assert!( + response.status() == StatusCode::OK || response.status() == StatusCode::NOT_FOUND, + "unexpected status: {}", + response.status() + ); + } + + #[test] + fn missing_asset_path_prefix_returns_not_found() { + // assets/ prefix: no SPA fallback – returns 404 if the file does not exist + let response = serve_embedded("assets/__nonexistent__.js"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[test] + fn serve_embedded_does_not_panic_on_empty_path() { + // Empty path normalises to index.html; OK in release, 404 in debug without dist/ + let response = serve_embedded(""); + assert!( + response.status() == StatusCode::OK || response.status() == StatusCode::NOT_FOUND, + "unexpected status: {}", + response.status() + ); + } + + #[test] + fn embedded_assets_struct_is_iterable() { + // Verifies that rust-embed compiled the EmbeddedAssets struct correctly. + // In debug builds without a built frontend dist/ directory the iterator is empty; that is + // expected. In release builds it will contain all bundled frontend files. + let _files: Vec<_> = EmbeddedAssets::iter().collect(); + // No assertion needed – the test passes as long as it compiles and does not panic. + } +}