mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-11 21:18:29 +09:00
576 lines
17 KiB
Rust
576 lines
17 KiB
Rust
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
use crate::app_wasm::resolve_app_dir;
|
|
use crate::functions::sha256_file;
|
|
use anyhow::{Context, Result, bail, ensure};
|
|
use clap::Args;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::ffi::OsStr;
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Stdio;
|
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
use tokio::process::{Child, Command};
|
|
use tokio::sync::watch;
|
|
use tokio::time::timeout;
|
|
use walkdir::WalkDir;
|
|
|
|
const DEFAULT_SKIP_DIRS: &[&str] = &[".git", "node_modules", "dist", "target", "pkg", "pkgs"];
|
|
|
|
#[derive(Debug, Args, Clone)]
|
|
pub struct AppDevServerArgs {
|
|
#[arg(long)]
|
|
app_dir: Option<PathBuf>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
#[serde(untagged)]
|
|
enum InputFingerprint {
|
|
Sha256 {
|
|
sha256: String,
|
|
#[serde(skip)]
|
|
legacy_timestamp_ms: Option<f64>,
|
|
},
|
|
LegacyTimestamp(f64),
|
|
}
|
|
|
|
impl InputFingerprint {
|
|
fn matches(&self, current: &Self) -> bool {
|
|
match (self, current) {
|
|
(
|
|
Self::Sha256 { sha256, .. },
|
|
Self::Sha256 {
|
|
sha256: current, ..
|
|
},
|
|
) => sha256 == current,
|
|
(
|
|
Self::LegacyTimestamp(timestamp),
|
|
Self::Sha256 {
|
|
legacy_timestamp_ms: Some(current),
|
|
..
|
|
},
|
|
) => timestamp == current,
|
|
(Self::LegacyTimestamp(timestamp), Self::LegacyTimestamp(current)) => {
|
|
timestamp == current
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn is_legacy(&self) -> bool {
|
|
matches!(self, Self::LegacyTimestamp(_))
|
|
}
|
|
}
|
|
|
|
type StepInputs = BTreeMap<String, InputFingerprint>;
|
|
|
|
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct StepMetadata {
|
|
inputs: StepInputs,
|
|
}
|
|
|
|
type Metadata = BTreeMap<String, StepMetadata>;
|
|
|
|
pub async fn run(args: AppDevServerArgs) -> Result<()> {
|
|
let project_root = resolve_app_dir(args.app_dir)?;
|
|
let mut server = AppDevServer::new(project_root);
|
|
server.run().await
|
|
}
|
|
|
|
struct AppDevServer {
|
|
project_root: PathBuf,
|
|
metadata_file: PathBuf,
|
|
metadata: Metadata,
|
|
}
|
|
|
|
impl AppDevServer {
|
|
fn new(project_root: PathBuf) -> Self {
|
|
Self {
|
|
metadata_file: project_root.join(".devserver-cache.json"),
|
|
project_root,
|
|
metadata: Metadata::default(),
|
|
}
|
|
}
|
|
|
|
async fn run(&mut self) -> Result<()> {
|
|
self.load_metadata();
|
|
let (shutdown_tx, mut shutdown_rx) = watch::channel(false);
|
|
tokio::spawn(listen_for_shutdown(shutdown_tx));
|
|
|
|
self.run_cached_step(
|
|
"wasm",
|
|
gather_wasm_inputs,
|
|
"pnpm wasm:codegen",
|
|
|server, shutdown| Box::pin(server.run_command("pnpm", &["wasm:codegen"], shutdown)),
|
|
&mut shutdown_rx,
|
|
)
|
|
.await?;
|
|
self.run_cached_step(
|
|
"colors",
|
|
gather_color_inputs,
|
|
"pnpm generate:colors",
|
|
|server, shutdown| Box::pin(server.run_command("pnpm", &["generate:colors"], shutdown)),
|
|
&mut shutdown_rx,
|
|
)
|
|
.await?;
|
|
self.run_cached_step(
|
|
"messageLayout",
|
|
gather_message_layout_inputs,
|
|
"pnpm generate:message-layout",
|
|
|server, shutdown| {
|
|
Box::pin(server.run_command("pnpm", &["generate:message-layout"], shutdown))
|
|
},
|
|
&mut shutdown_rx,
|
|
)
|
|
.await?;
|
|
self.run_cached_step(
|
|
"masks",
|
|
gather_mask_inputs,
|
|
"pnpm generate:masks",
|
|
|server, shutdown| Box::pin(server.run_command("pnpm", &["generate:masks"], shutdown)),
|
|
&mut shutdown_rx,
|
|
)
|
|
.await?;
|
|
self.run_cached_step(
|
|
"cssTypes",
|
|
gather_css_module_inputs,
|
|
"pnpm generate:css-types",
|
|
|server, shutdown| {
|
|
Box::pin(server.run_command("pnpm", &["generate:css-types"], shutdown))
|
|
},
|
|
&mut shutdown_rx,
|
|
)
|
|
.await?;
|
|
|
|
self.run_cached_step(
|
|
"lingui",
|
|
gather_lingui_inputs,
|
|
"pnpm lingui:compile",
|
|
|server, shutdown| Box::pin(server.run_command("pnpm", &["lingui:compile"], shutdown)),
|
|
&mut shutdown_rx,
|
|
)
|
|
.await?;
|
|
|
|
if *shutdown_rx.borrow() {
|
|
return Ok(());
|
|
}
|
|
|
|
self.clean_dist()?;
|
|
let mut css_type_watcher = self.start_css_type_watcher()?;
|
|
let rspack_result = self.run_rspack(&mut shutdown_rx).await;
|
|
terminate_child(&mut css_type_watcher).await;
|
|
rspack_result
|
|
}
|
|
|
|
fn load_metadata(&mut self) {
|
|
match fs::read_to_string(&self.metadata_file) {
|
|
Ok(raw) => match serde_json::from_str::<Metadata>(&raw) {
|
|
Ok(metadata) => {
|
|
self.metadata = metadata;
|
|
}
|
|
Err(error) => {
|
|
eprintln!(
|
|
"Failed to parse dev server metadata cache, falling back to full rebuild: {error}"
|
|
);
|
|
self.metadata = Metadata::default();
|
|
}
|
|
},
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
|
self.metadata = Metadata::default();
|
|
}
|
|
Err(error) => {
|
|
eprintln!(
|
|
"Failed to read dev server metadata cache, falling back to full rebuild: {error}"
|
|
);
|
|
self.metadata = Metadata::default();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn save_metadata(&self) -> Result<()> {
|
|
if let Some(parent) = self.metadata_file.parent() {
|
|
fs::create_dir_all(parent)
|
|
.with_context(|| format!("Failed to create {}", parent.display()))?;
|
|
}
|
|
fs::write(
|
|
&self.metadata_file,
|
|
serde_json::to_string_pretty(&self.metadata)?,
|
|
)
|
|
.with_context(|| format!("Failed to write {}", self.metadata_file.display()))
|
|
}
|
|
|
|
async fn run_cached_step<G, E>(
|
|
&mut self,
|
|
step_name: &'static str,
|
|
gather_inputs: G,
|
|
label: &'static str,
|
|
execute: E,
|
|
shutdown: &mut watch::Receiver<bool>,
|
|
) -> Result<()>
|
|
where
|
|
G: Fn(&Path) -> Result<StepInputs>,
|
|
E: for<'a> FnOnce(
|
|
&'a AppDevServer,
|
|
&'a mut watch::Receiver<bool>,
|
|
)
|
|
-> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + 'a>>,
|
|
{
|
|
if *shutdown.borrow() {
|
|
return Ok(());
|
|
}
|
|
let inputs = gather_inputs(&self.project_root)?;
|
|
if !self.should_run_step(step_name, &inputs) {
|
|
let needs_upgrade = self
|
|
.metadata
|
|
.get(step_name)
|
|
.is_some_and(|entry| entry.inputs.values().any(InputFingerprint::is_legacy));
|
|
if needs_upgrade {
|
|
self.metadata
|
|
.insert(step_name.to_string(), StepMetadata { inputs });
|
|
self.save_metadata()?;
|
|
}
|
|
println!("Skipping {label} (no changes detected)");
|
|
return Ok(());
|
|
}
|
|
|
|
execute(self, shutdown).await?;
|
|
if *shutdown.borrow() {
|
|
return Ok(());
|
|
}
|
|
self.metadata
|
|
.insert(step_name.to_string(), StepMetadata { inputs });
|
|
self.save_metadata()
|
|
}
|
|
|
|
fn should_run_step(&self, step_name: &str, inputs: &StepInputs) -> bool {
|
|
let Some(entry) = self.metadata.get(step_name) else {
|
|
return true;
|
|
};
|
|
entry.inputs.len() != inputs.len()
|
|
|| entry.inputs.iter().any(|(path, cached)| {
|
|
inputs
|
|
.get(path)
|
|
.is_none_or(|current| !cached.matches(current))
|
|
})
|
|
}
|
|
|
|
async fn run_command(
|
|
&self,
|
|
command: &str,
|
|
args: &[&str],
|
|
shutdown: &mut watch::Receiver<bool>,
|
|
) -> Result<()> {
|
|
if *shutdown.borrow() {
|
|
return Ok(());
|
|
}
|
|
|
|
let mut child = spawn_child(command, args, &self.project_root)?;
|
|
let status = wait_for_child(command, args, &mut child, shutdown).await?;
|
|
if *shutdown.borrow() {
|
|
return Ok(());
|
|
}
|
|
ensure!(
|
|
status.success(),
|
|
"{} exited with status {}",
|
|
display_command(command, args),
|
|
status.code().unwrap_or(1)
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn clean_dist(&self) -> Result<()> {
|
|
let dist_path = self.project_root.join("dist");
|
|
match fs::remove_dir_all(&dist_path) {
|
|
Ok(()) => Ok(()),
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
|
Err(error) => {
|
|
Err(error).with_context(|| format!("Failed to remove {}", dist_path.display()))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn start_css_type_watcher(&self) -> Result<Child> {
|
|
let tcm = self.project_root.join("node_modules/.bin/tcm");
|
|
println!(
|
|
"+ {} src --pattern '**/*.module.css' --watch --silent",
|
|
tcm.display()
|
|
);
|
|
Command::new(tcm)
|
|
.args(["src", "--pattern", "**/*.module.css", "--watch", "--silent"])
|
|
.current_dir(&self.project_root)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::inherit())
|
|
.stderr(Stdio::inherit())
|
|
.spawn()
|
|
.context("Failed to start CSS type watcher")
|
|
}
|
|
|
|
async fn run_rspack(&self, shutdown: &mut watch::Receiver<bool>) -> Result<()> {
|
|
let rspack = self.project_root.join("node_modules/.bin/rspack");
|
|
let rspack_string = rspack.to_string_lossy().to_string();
|
|
let mut child = spawn_child(
|
|
&rspack_string,
|
|
&["serve", "--mode", "development"],
|
|
&self.project_root,
|
|
)?;
|
|
let status = wait_for_child(
|
|
&rspack_string,
|
|
&["serve", "--mode", "development"],
|
|
&mut child,
|
|
shutdown,
|
|
)
|
|
.await?;
|
|
if *shutdown.borrow() {
|
|
return Ok(());
|
|
}
|
|
if status.success() {
|
|
Ok(())
|
|
} else {
|
|
bail!(
|
|
"rspack serve exited with status {}",
|
|
status.code().unwrap_or(1)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn collect_file_digests(project_root: &Path, paths: &[PathBuf]) -> Result<StepInputs> {
|
|
let mut result = BTreeMap::new();
|
|
for rel_path in paths {
|
|
let absolute_path = project_root.join(rel_path);
|
|
let metadata = fs::metadata(&absolute_path)
|
|
.with_context(|| format!("Failed to stat {}", absolute_path.display()))?;
|
|
ensure!(
|
|
metadata.is_file(),
|
|
"Expected {} to be a file when collecting dev server cache inputs.",
|
|
rel_path.display()
|
|
);
|
|
result.insert(rel_path_key(rel_path), fingerprint_file(&absolute_path)?);
|
|
}
|
|
Ok(result)
|
|
}
|
|
|
|
fn collect_directory_digests<P>(
|
|
project_root: &Path,
|
|
root_rel: &Path,
|
|
predicate: P,
|
|
) -> Result<StepInputs>
|
|
where
|
|
P: Fn(&str) -> bool,
|
|
{
|
|
let skip_dirs: BTreeSet<&str> = DEFAULT_SKIP_DIRS.iter().copied().collect();
|
|
let root = project_root.join(root_rel);
|
|
let mut result = BTreeMap::new();
|
|
if !root.exists() {
|
|
return Ok(result);
|
|
}
|
|
|
|
for entry in WalkDir::new(&root)
|
|
.into_iter()
|
|
.filter_entry(|entry| should_walk_entry(entry.path(), &skip_dirs))
|
|
{
|
|
let entry = entry.with_context(|| format!("Failed to read {}", root.display()))?;
|
|
if !entry.file_type().is_file() {
|
|
continue;
|
|
}
|
|
let rel_from_root = entry
|
|
.path()
|
|
.strip_prefix(&root)
|
|
.with_context(|| format!("Failed to relativize {}", entry.path().display()))?
|
|
.to_path_buf();
|
|
let rel_path = root_rel.join(rel_from_root);
|
|
let key = rel_path_key(&rel_path);
|
|
if !predicate(&key) {
|
|
continue;
|
|
}
|
|
result.insert(key, fingerprint_file(entry.path())?);
|
|
}
|
|
Ok(result)
|
|
}
|
|
|
|
fn fingerprint_file(path: &Path) -> Result<InputFingerprint> {
|
|
let sha256 = sha256_file(path)?;
|
|
let metadata = fs::metadata(path)
|
|
.with_context(|| format!("Failed to stat {} after hashing", path.display()))?;
|
|
Ok(InputFingerprint::Sha256 {
|
|
sha256,
|
|
legacy_timestamp_ms: Some(timestamp_ms(metadata.modified()?)?),
|
|
})
|
|
}
|
|
|
|
fn should_walk_entry(path: &Path, skip_dirs: &BTreeSet<&str>) -> bool {
|
|
path.file_name()
|
|
.and_then(OsStr::to_str)
|
|
.is_none_or(|name| !skip_dirs.contains(name))
|
|
}
|
|
|
|
fn gather_wasm_inputs(project_root: &Path) -> Result<StepInputs> {
|
|
let markdown_parser_rust_dir = PathBuf::from("../packages/markdown_parser/rust");
|
|
let mut inputs = collect_file_digests(
|
|
project_root,
|
|
&[
|
|
PathBuf::from("../tools/ci/Cargo.toml"),
|
|
PathBuf::from("../tools/ci/src/app_dev_server.rs"),
|
|
PathBuf::from("../tools/ci/src/app_wasm.rs"),
|
|
PathBuf::from("../tools/ci/src/common.rs"),
|
|
PathBuf::from("../tools/ci/src/lib.rs"),
|
|
markdown_parser_rust_dir.join("Cargo.toml"),
|
|
],
|
|
)?;
|
|
inputs.extend(collect_directory_digests(
|
|
project_root,
|
|
Path::new("rust/libfluxcore"),
|
|
|path| !path.contains("/target/"),
|
|
)?);
|
|
inputs.extend(collect_directory_digests(
|
|
project_root,
|
|
Path::new("rust/libfluxwebp"),
|
|
|path| !path.contains("/target/"),
|
|
)?);
|
|
inputs.extend(collect_directory_digests(
|
|
project_root,
|
|
&markdown_parser_rust_dir,
|
|
|path| !path.contains("/target/"),
|
|
)?);
|
|
Ok(inputs)
|
|
}
|
|
|
|
fn gather_color_inputs(project_root: &Path) -> Result<StepInputs> {
|
|
collect_file_digests(
|
|
project_root,
|
|
&[PathBuf::from("scripts/GenerateColorSystem.ts")],
|
|
)
|
|
}
|
|
|
|
fn gather_message_layout_inputs(project_root: &Path) -> Result<StepInputs> {
|
|
collect_file_digests(
|
|
project_root,
|
|
&[
|
|
PathBuf::from("scripts/GenerateMessageLayoutCss.ts"),
|
|
PathBuf::from("src/features/theme/layout/MessageLayoutSpec.ts"),
|
|
],
|
|
)
|
|
}
|
|
|
|
fn gather_mask_inputs(project_root: &Path) -> Result<StepInputs> {
|
|
collect_file_digests(
|
|
project_root,
|
|
&[
|
|
PathBuf::from("scripts/GenerateAvatarMasks.ts"),
|
|
PathBuf::from("src/features/ui/constants/TypingConstants.ts"),
|
|
],
|
|
)
|
|
}
|
|
|
|
fn gather_css_module_inputs(project_root: &Path) -> Result<StepInputs> {
|
|
collect_directory_digests(project_root, Path::new("src"), |path| {
|
|
path.ends_with(".module.css")
|
|
})
|
|
}
|
|
|
|
fn gather_lingui_inputs(project_root: &Path) -> Result<StepInputs> {
|
|
collect_directory_digests(
|
|
project_root,
|
|
Path::new("src/features/i18n/locales"),
|
|
|path| path.ends_with(".po"),
|
|
)
|
|
}
|
|
|
|
fn spawn_child(command: &str, args: &[&str], cwd: &Path) -> Result<Child> {
|
|
println!("+ {}", display_command(command, args));
|
|
Command::new(command)
|
|
.args(args)
|
|
.current_dir(cwd)
|
|
.stdin(Stdio::inherit())
|
|
.stdout(Stdio::inherit())
|
|
.stderr(Stdio::inherit())
|
|
.spawn()
|
|
.with_context(|| format!("Failed to run {}", display_command(command, args)))
|
|
}
|
|
|
|
async fn wait_for_child(
|
|
command: &str,
|
|
args: &[&str],
|
|
child: &mut Child,
|
|
shutdown: &mut watch::Receiver<bool>,
|
|
) -> Result<std::process::ExitStatus> {
|
|
tokio::select! {
|
|
status = child.wait() => {
|
|
status.with_context(|| format!("Failed to wait for {}", display_command(command, args)))
|
|
}
|
|
changed = shutdown.changed() => {
|
|
let _ = changed;
|
|
terminate_child(child).await;
|
|
child.wait().await.with_context(|| format!("Failed to wait for {}", display_command(command, args)))
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn terminate_child(child: &mut Child) {
|
|
if child.id().is_none() {
|
|
return;
|
|
}
|
|
let _ = child.start_kill();
|
|
let _ = timeout(Duration::from_secs(5), child.wait()).await;
|
|
}
|
|
|
|
async fn listen_for_shutdown(shutdown_tx: watch::Sender<bool>) {
|
|
let signal = wait_for_shutdown_signal().await;
|
|
println!("\nReceived {signal}, shutting down fluxer app dev server...");
|
|
let _ = shutdown_tx.send(true);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
async fn wait_for_shutdown_signal() -> &'static str {
|
|
let mut sigterm = match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
|
{
|
|
Ok(signal) => signal,
|
|
Err(_) => {
|
|
let _ = tokio::signal::ctrl_c().await;
|
|
return "SIGINT";
|
|
}
|
|
};
|
|
tokio::select! {
|
|
_ = tokio::signal::ctrl_c() => "SIGINT",
|
|
_ = sigterm.recv() => "SIGTERM",
|
|
}
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
async fn wait_for_shutdown_signal() -> &'static str {
|
|
let _ = tokio::signal::ctrl_c().await;
|
|
"SIGINT"
|
|
}
|
|
|
|
fn rel_path_key(path: &Path) -> String {
|
|
path.to_string_lossy().replace('\\', "/")
|
|
}
|
|
|
|
fn timestamp_ms(timestamp: SystemTime) -> Result<f64> {
|
|
Ok(timestamp
|
|
.duration_since(UNIX_EPOCH)
|
|
.context("File timestamp predates UNIX epoch")?
|
|
.as_secs_f64()
|
|
* 1000.0)
|
|
}
|
|
|
|
fn display_command(command: &str, args: &[&str]) -> String {
|
|
std::iter::once(command.to_string())
|
|
.chain(args.iter().map(|arg| quote_arg(arg)))
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
}
|
|
|
|
fn quote_arg(arg: &str) -> String {
|
|
if arg
|
|
.chars()
|
|
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/' | ':' | '='))
|
|
{
|
|
arg.to_string()
|
|
} else {
|
|
format!("{arg:?}")
|
|
}
|
|
}
|