diff --git a/CHANGELOG.md b/CHANGELOG.md
index f201b8a..e3e2b7c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,8 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Added
+- `cargo xtask watch --serve` serves the packaging output with directory listings
+ and downloads at `http://127.0.0.1:9932/`, including while rebuilds are paused.
- `cargo xtask watch` rebuilds and packs after a resettable 10-second quiet period,
with automatic rebuilds paused while `dist/NOREBUILD` exists.
- Separate gameplay floor and structure layers, with buildable/removable floors
diff --git a/Cargo.lock b/Cargo.lock
index 2ce960c..a90b663 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -297,6 +297,12 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
+[[package]]
+name = "ascii"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
+
[[package]]
name = "ash"
version = "0.38.0+1.3.281"
@@ -2169,6 +2175,12 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "chunked_transfer"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
+
[[package]]
name = "cipher"
version = "0.4.4"
@@ -3525,6 +3537,12 @@ dependencies = [
"digest 0.11.3",
]
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
[[package]]
name = "hybrid-array"
version = "0.4.15"
@@ -6268,6 +6286,18 @@ dependencies = [
"strict-num",
]
+[[package]]
+name = "tiny_http"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
+dependencies = [
+ "ascii",
+ "chunked_transfer",
+ "httpdate",
+ "log",
+]
+
[[package]]
name = "tinystr"
version = "0.8.4"
@@ -7520,8 +7550,10 @@ name = "xtask"
version = "0.1.0"
dependencies = [
"flate2",
+ "percent-encoding",
"serde_json",
"tar",
+ "tiny_http",
"zip",
]
diff --git a/README.md b/README.md
index 813cfdf..5e74452 100644
--- a/README.md
+++ b/README.md
@@ -511,6 +511,12 @@ that timer. It watches Git-tracked and non-ignored untracked files, including
assets, manifests and packaging code, and ignores `dist/`, `target/`, Git metadata
and the selected output directory. Git must be installed. No build runs on startup.
+Add `--serve` to browse and download the output files over HTTP:
+`cargo xtask watch --serve` serves `dist/` at .
+With `--out-dir DIR`, it serves that directory instead. The server is local-only,
+provides directory listings and file downloads, and remains available during
+builds and while rebuilds are paused. Stop the watcher to stop serving.
+
Create `dist/NOREBUILD` to pause automatic builds. Changes remain queued while
the file exists; deleting it starts a fresh 10-second wait for pending changes.
An already running build finishes, and subsequent builds never overlap. Build
diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml
index 3f9040f..01bd0d0 100644
--- a/xtask/Cargo.toml
+++ b/xtask/Cargo.toml
@@ -6,8 +6,10 @@ publish = false
[dependencies]
flate2 = "1"
+percent-encoding = "2"
serde_json = "1"
tar = { version = "0.4", default-features = false }
+tiny_http = "0.12"
zip = { version = "9", default-features = false, features = ["deflate-flate2"] }
[lints]
diff --git a/xtask/src/main.rs b/xtask/src/main.rs
index e892169..3f4d0f6 100644
--- a/xtask/src/main.rs
+++ b/xtask/src/main.rs
@@ -2,9 +2,10 @@
//!
//! ```text
//! cargo xtask pack [--target TRIPLE] [--format zip|tar.gz] [--out-dir DIR] [--debug]
-//! cargo xtask watch [pack options]
+//! cargo xtask watch [pack options] [--serve]
//! ```
+mod serve;
mod watch;
use std::{
@@ -28,7 +29,9 @@ fn main() -> ExitCode {
let mut args = env::args().skip(1);
let result = match args.next().as_deref() {
Some("pack") => PackOptions::parse(args).and_then(|opts| pack(&opts)),
- Some("watch") => PackOptions::parse(args).and_then(|opts| watch::run(&opts)),
+ Some("watch") => {
+ PackOptions::parse_watch(args).and_then(|(opts, serve)| watch::run(&opts, serve))
+ }
Some("-h" | "--help") | None => {
print_help();
return ExitCode::SUCCESS;
@@ -54,7 +57,9 @@ fn print_help() {
--target TRIPLE cross-compile for TRIPLE (default: host)\n \
--format zip|tar.gz archive format (default: zip for Windows, tar.gz otherwise)\n \
--out-dir DIR output directory (default: dist)\n \
- --debug pack a debug build instead of the stripped `dist` profile"
+ --debug pack a debug build instead of the stripped `dist` profile\n\n\
+ watch options:\n \
+ --serve serve the output directory at http://127.0.0.1:9932"
);
}
@@ -99,7 +104,16 @@ struct PackOptions {
impl PackOptions {
fn parse(mut args: impl Iterator- ) -> Result {
+ Self::parse_options(&mut args, false).map(|(opts, _)| opts)
+ }
+
+ fn parse_watch(mut args: impl Iterator
- ) -> Result<(Self, bool)> {
+ Self::parse_options(&mut args, true)
+ }
+
+ fn parse_options(args: &mut impl Iterator
- , watch: bool) -> Result<(Self, bool)> {
let mut opts = Self::default();
+ let mut serve = false;
while let Some(arg) = args.next() {
let mut value = |name: &str| args.next().ok_or_else(|| format!("{name} needs a value"));
match arg.as_str() {
@@ -107,14 +121,15 @@ impl PackOptions {
"--format" => opts.format = Some(Format::parse(&value("--format")?)?),
"--out-dir" => opts.out_dir = Some(value("--out-dir")?.into()),
"--debug" => opts.debug = true,
+ "--serve" if watch => serve = true,
"-h" | "--help" => {
print_help();
std::process::exit(0);
}
- other => return Err(format!("unknown pack option `{other}`").into()),
+ other => return Err(format!("unknown option `{other}`").into()),
}
}
- Ok(opts)
+ Ok((opts, serve))
}
}
diff --git a/xtask/src/serve.rs b/xtask/src/serve.rs
new file mode 100644
index 0000000..e1ff0cb
--- /dev/null
+++ b/xtask/src/serve.rs
@@ -0,0 +1,235 @@
+use std::{
+ fs::{self, File},
+ io,
+ path::{Component, Path},
+ thread::{self, JoinHandle},
+};
+
+use percent_encoding::{NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode};
+use tiny_http::{Header, Method, Request, Response, Server};
+
+use crate::Result;
+
+pub fn start(root: &Path) -> Result> {
+ let server = Server::http("127.0.0.1:9932")
+ .map_err(|error| format!("could not start HTTP server: {error}"))?;
+ let root = root.to_path_buf();
+ println!("Serving {} at http://127.0.0.1:9932/", root.display());
+ Ok(thread::spawn(move || {
+ for request in server.incoming_requests() {
+ if let Err(error) = respond(request, &root) {
+ eprintln!("HTTP request failed: {error}");
+ }
+ }
+ }))
+}
+
+fn header(name: &str, value: &str) -> Header {
+ Header::from_bytes(name, value).expect("valid HTTP header")
+}
+
+fn text(request: Request, status: u16, body: &str) -> io::Result<()> {
+ request.respond(
+ Response::from_string(body)
+ .with_status_code(status)
+ .with_header(header("Cache-Control", "no-store")),
+ )
+}
+
+fn respond(request: Request, root: &Path) -> io::Result<()> {
+ if !matches!(request.method(), Method::Get | Method::Head) {
+ return request.respond(
+ Response::from_string("Method not allowed")
+ .with_status_code(405)
+ .with_header(header("Allow", "GET, HEAD")),
+ );
+ }
+ let url = request.url().split('?').next().unwrap_or("/");
+ let Ok(decoded) = percent_decode_str(url).decode_utf8() else {
+ return text(request, 400, "Invalid URL encoding");
+ };
+ if !decoded.starts_with('/')
+ || decoded.contains(['\\', '\0'])
+ || Path::new(decoded.trim_start_matches('/'))
+ .components()
+ .any(|part| !matches!(part, Component::Normal(_) | Component::CurDir))
+ {
+ return text(request, 403, "Forbidden");
+ }
+ let path = match root.join(decoded.trim_start_matches('/')).canonicalize() {
+ Ok(path) if path.starts_with(root) => path,
+ Ok(_) => return text(request, 403, "Forbidden"),
+ Err(error) if error.kind() == io::ErrorKind::NotFound => {
+ return text(request, 404, "Not found");
+ }
+ Err(error) => {
+ eprintln!("Could not resolve HTTP path: {error}");
+ return text(request, 500, "Could not read path");
+ }
+ };
+ if path.is_dir() {
+ if !url.ends_with('/') {
+ let location = format!("{url}/");
+ return request
+ .respond(Response::empty(301).with_header(header("Location", &location)));
+ }
+ let listing = match listing(&path) {
+ Ok(listing) => listing,
+ Err(error) => {
+ eprintln!("Could not list {}: {error}", path.display());
+ return text(request, 500, "Could not list directory");
+ }
+ };
+ return request.respond(
+ Response::from_string(listing)
+ .with_header(header("Content-Type", "text/html; charset=utf-8"))
+ .with_header(header("Cache-Control", "no-store"))
+ .with_header(header("Content-Security-Policy", "default-src 'none'")),
+ );
+ }
+ if !path.is_file() {
+ return text(request, 403, "Forbidden");
+ }
+ match File::open(&path) {
+ Ok(file) => request.respond(
+ Response::from_file(file)
+ .with_header(header("Content-Type", "application/octet-stream"))
+ .with_header(header("X-Content-Type-Options", "nosniff"))
+ .with_header(header("Cache-Control", "no-store")),
+ ),
+ Err(error) => {
+ eprintln!("Could not open {}: {error}", path.display());
+ text(request, 500, "Could not read file")
+ }
+ }
+}
+
+fn listing(path: &Path) -> io::Result {
+ let mut entries = fs::read_dir(path)?.collect::>>()?;
+ entries.sort_by_key(|entry| entry.file_name());
+ let mut html = String::from(
+ "Station downloads\
+
Station downloads
",
+ );
+ for entry in entries {
+ let name = entry.file_name();
+ let name = name
+ .to_str()
+ .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "filename is not UTF-8"))?;
+ let href = utf8_percent_encode(name, NON_ALPHANUMERIC);
+ let label = name
+ .replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
+ .replace('\'', "'");
+ html.push_str(&format!("- {label}
"));
+ }
+ html.push_str("
");
+ Ok(html)
+}
+
+#[cfg(test)]
+mod tests {
+ use std::{
+ io::{Read, Write},
+ net::TcpStream,
+ time::{Duration, SystemTime, UNIX_EPOCH},
+ };
+
+ use super::*;
+ use crate::PackOptions;
+
+ #[test]
+ fn serve_is_watch_only_and_does_not_consume_pack_option_values() {
+ let parse =
+ |args: &[&str]| PackOptions::parse_watch(args.iter().map(|arg| (*arg).to_owned()));
+ let (options, serve) = parse(&["--serve", "--debug", "--out-dir", "custom"]).unwrap();
+ assert!(serve);
+ assert!(options.debug);
+ assert_eq!(options.out_dir.unwrap(), Path::new("custom"));
+ assert!(!parse(&["--debug"]).unwrap().1);
+ let (options, serve) = parse(&["--out-dir", "--serve"]).unwrap();
+ assert!(!serve);
+ assert_eq!(options.out_dir.unwrap(), Path::new("--serve"));
+ assert!(PackOptions::parse(["--serve".to_owned()].into_iter()).is_err());
+ }
+
+ #[test]
+ fn http_lists_downloads_supports_head_and_confines_paths() {
+ let temp = std::env::temp_dir().join(format!(
+ "station-http-{}-{}",
+ std::process::id(),
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ ));
+ let root = temp.join("dist");
+ fs::create_dir_all(root.join("nested")).unwrap();
+ let root = root.canonicalize().unwrap();
+ fs::write(root.join("build & .zip"), [0, 1, 255, 10]).unwrap();
+ fs::write(root.join("nested/file.txt"), "nested contents").unwrap();
+ fs::write(temp.join("private.txt"), "private").unwrap();
+ let server = Server::http("127.0.0.1:0").unwrap();
+ let exchange = |method: &str, url: &str| {
+ let mut stream = TcpStream::connect(server.server_addr().to_ip().unwrap()).unwrap();
+ stream
+ .set_read_timeout(Some(Duration::from_secs(3)))
+ .unwrap();
+ write!(
+ stream,
+ "{method} {url} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"
+ )
+ .unwrap();
+ let request = server
+ .recv_timeout(Duration::from_secs(3))
+ .unwrap()
+ .unwrap();
+ respond(request, &root).unwrap();
+ let mut bytes = Vec::new();
+ stream.read_to_end(&mut bytes).unwrap();
+ let split = bytes
+ .windows(4)
+ .position(|part| part == b"\r\n\r\n")
+ .unwrap();
+ (
+ String::from_utf8(bytes[..split].to_vec()).unwrap(),
+ bytes[split + 4..].to_vec(),
+ )
+ };
+ let (headers, body) = exchange("GET", "/");
+ assert!(headers.starts_with("HTTP/1.1 200"));
+ let html = String::from_utf8(body).unwrap();
+ assert!(html.contains("build & <test>.zip"));
+ assert!(html.contains("href=\"./build%20%26%20%3Ctest%3E%2Ezip\""));
+ let (headers, body) = exchange("GET", "/build%20%26%20%3Ctest%3E.zip?download=1");
+ assert!(headers.starts_with("HTTP/1.1 200"));
+ assert_eq!(body, [0, 1, 255, 10]);
+ let (headers, body) = exchange("HEAD", "/build%20%26%20%3Ctest%3E.zip");
+ assert!(headers.starts_with("HTTP/1.1 200"));
+ assert!(headers.to_lowercase().contains("content-length: 4"));
+ assert!(body.is_empty());
+ assert!(exchange("GET", "/nested").0.starts_with("HTTP/1.1 301"));
+ assert_eq!(exchange("GET", "/nested/file.txt").1, b"nested contents");
+ assert!(exchange("GET", "/missing").0.starts_with("HTTP/1.1 404"));
+ assert!(exchange("POST", "/").0.starts_with("HTTP/1.1 405"));
+ for url in [
+ "/../private.txt",
+ "/%2e%2e/private.txt",
+ "/..%5cprivate.txt",
+ "/%00",
+ ] {
+ assert!(exchange("GET", url).0.starts_with("HTTP/1.1 403"), "{url}");
+ }
+ assert!(exchange("GET", "/%FF").0.starts_with("HTTP/1.1 400"));
+ #[cfg(unix)]
+ {
+ std::os::unix::fs::symlink(temp.join("private.txt"), root.join("link")).unwrap();
+ assert!(exchange("GET", "/link").0.starts_with("HTTP/1.1 403"));
+ }
+ fs::write(root.join("nested/file.txt"), "new contents").unwrap();
+ assert_eq!(exchange("GET", "/nested/file.txt").1, b"new contents");
+ fs::remove_dir_all(temp).unwrap();
+ }
+}
diff --git a/xtask/src/watch.rs b/xtask/src/watch.rs
index cf34dba..7b7209f 100644
--- a/xtask/src/watch.rs
+++ b/xtask/src/watch.rs
@@ -36,7 +36,7 @@ impl PendingBuild {
}
}
-pub fn run(options: &PackOptions) -> Result<()> {
+pub fn run(options: &PackOptions, serve: bool) -> Result<()> {
let root = workspace_root().canonicalize()?;
let output = root.join(options.out_dir.as_deref().unwrap_or(Path::new("dist")));
fs::create_dir_all(&output)?;
@@ -44,6 +44,11 @@ pub fn run(options: &PackOptions) -> Result<()> {
if root.starts_with(&output) {
return Err("watch output directory must not contain the workspace".into());
}
+ let server = if serve {
+ Some(crate::serve::start(&output)?)
+ } else {
+ None
+ };
let excluded = [root.join("dist"), root.join("target"), output];
let guard = root.join("dist/NOREBUILD");
let mut previous = snapshot(&root, &excluded)?;
@@ -51,6 +56,9 @@ pub fn run(options: &PackOptions) -> Result<()> {
let mut build: Option = None;
println!("Watching sources; packing after 10 seconds without changes. Ctrl-C to stop.");
loop {
+ if server.as_ref().is_some_and(thread::JoinHandle::is_finished) {
+ return Err("HTTP server stopped unexpectedly".into());
+ }
let current = snapshot(&root, &excluded)?;
let changed = current != previous;
previous = current;