La Phase 4 a ajouté deux fonctionnalités qui changent la façon dont les développeurs interagissent avec sh0 pendant le développement actif. La première, sh0 watch, supprime le besoin de taper sh0 push après chaque modification. La seconde, le streaming des logs de build via WebSocket, remplace la boucle de polling HTTP de 1,5 seconde par une livraison de logs en temps réel.
Aucune de ces fonctionnalités n'est requise pour déployer. Les deux rendent le déploiement invisible -- ce qui est exactement l'objectif.
sh0 watch -- Le surveillant de fichiers
Le concept est simple : surveiller le répertoire du projet pour détecter les changements, attendre deux secondes de stabilisation, puis relancer sh0 push. Le développeur enregistre un fichier, et en quelques secondes, la version mise à jour est en ligne.
$ sh0 watch
Watching /Users/dev/my-app (debounce: 2000ms)
Pushing my-app
Detected nodejs (Next.js) -- 85/100 health
42 files (2.3 MB) packaged
Uploading OK 0.8s
Building OK 32.4s
[ok] Live in 35.3s
-> https://my-app.sh0.app
Watching for changes...
[14:23:05] Change detected: src/App.tsx
Pushing my-app
43 files (2.3 MB) packaged
Uploading OK 0.6s
Building OK 28.1s
[ok] Live in 30.2s
-> https://my-app.sh0.app
Watching for changes...Architecture
Le surveillant utilise notify version 7, qui fournit des API natives d'événements filesystem : kqueue sur macOS, inotify sur Linux. L'architecture repose sur une boucle d'événements basée sur des canaux :
rustpub async fn run(client: &Sh0Client, args: &WatchArgs) -> Result<()> {
let project_path = resolve_path(args.path.as_deref())?;
// Push initial
push::run(client, &push_args).await?;
// Configurer le surveillant de fichiers
let (tx, rx) = std::sync::mpsc::channel();
let mut watcher = notify::RecommendedWatcher::new(tx, notify::Config::default())?;
watcher.watch(&project_path, notify::RecursiveMode::Recursive)?;
println!(" Watching for changes...");
// Boucle d'événements
loop {
tokio::select! {
_ = tokio::signal::ctrl_c() => {
println!("\n Stopped watching");
break;
}
_ = process_events(&rx, &project_path, client, &push_args, args.debounce) => {}
}
}
Ok(())
}La macro tokio::select! permet un arrêt gracieux : le surveillant répond immédiatement à Ctrl+C, même pendant une attente de stabilisation ou un push actif. Sans cela, un Ctrl+C pendant un push nécessiterait que le push se termine (ou échoue) avant que le surveillant ne s'arrête.
Stabilisation (debouncing)
Les éditeurs de fichiers n'enregistrent pas de manière atomique. Une seule action « Enregistrer » dans VS Code peut produire trois à cinq événements filesystem : l'éditeur écrit dans un fichier temporaire, le renomme par-dessus l'original, met à jour le .git/index, et déclenche éventuellement un formateur qui écrit à nouveau.
Sans stabilisation, chacun de ces événements déclencherait un push séparé. La logique de stabilisation collecte les événements pendant une fenêtre configurable (2000 ms par défaut) avant de déclencher :
rustasync fn process_events(
rx: &Receiver<notify::Result<Event>>,
project_path: &Path,
client: &Sh0Client,
push_args: &PushArgs,
debounce_ms: u64,
) -> Result<()> {
// Attendre le premier événement
let event = rx.recv()?;
// Drainer tous les événements dans la fenêtre de stabilisation
tokio::time::sleep(Duration::from_millis(debounce_ms)).await;
while rx.try_recv().is_ok() {}
// Filtrer : ignorer les changements sur les chemins exclus
if should_ignore_event(&event, project_path) {
return Ok(());
}
// Re-push
if let Err(e) = push::run(client, push_args).await {
eprintln!(" Push failed: {}", e);
// Continuer à surveiller -- ne pas quitter en cas d'échec du push
}
println!(" Watching for changes...");
Ok(())
}La décision de conception clé concerne le chemin d'erreur : les échecs de push affichent une erreur et continuent à surveiller. Une erreur de syntaxe dans le code du développeur ne doit pas tuer le surveillant. Le développeur corrige l'erreur, enregistre à nouveau, et le surveillant détecte automatiquement le changement suivant.
Logique d'exclusion partagée
L'audit global a découvert que watch.rs avait sa propre logique d'exclusion qui divergeait de celle de push.rs. Les deux modules devaient ignorer les mêmes motifs (.git/, node_modules/, .sh0/, etc.), mais le surveillant avait une version simplifiée qui manquait certains motifs.
La correction a été d'extraire should_ignore_public() et load_ignore_patterns() de push.rs et de les partager :
rust// Dans push.rs (rendu pub(crate))
pub(crate) fn should_ignore_public(
path: &Path,
ignore_patterns: &[String],
) -> bool {
// Vérifier les motifs ALWAYS_EXCLUDE
// Vérifier les motifs configurés par l'utilisateur depuis .sh0ignore/.dockerignore/.gitignore
// ...
}Désormais, push et watch utilisent une logique d'exclusion identique. Une modification d'un fichier dans node_modules/ ne déclenche pas de re-push, quel que soit le chemin de code qui l'évalue.
Streaming des logs de build via WebSocket
Le sh0 push de la Phase 1 interrogeait le statut du déploiement toutes les 1,5 secondes via HTTP. Cela fonctionne, mais présente deux problèmes :
- Latence : les lignes de log apparaissent jusqu'à 1,5 seconde après que le serveur les écrit
- Charge : chaque interrogation est un cycle requête-réponse HTTP complet, avec sérialisation JSON, requêtes en base de données et surcharge réseau
Le streaming WebSocket résout les deux. Le serveur pousse le nouveau contenu de log dès qu'il apparaît, avec une latence inférieure à 100 ms et sans surcharge de polling.
Côté serveur : le endpoint de stream
Le nouveau endpoint se trouve à GET /api/v1/deployments/:id/stream. Il met à niveau la connexion HTTP vers un WebSocket et diffuse le contenu des logs de build :
rustpub async fn deploy_stream(
ws: WebSocketUpgrade,
Path(deploy_id): Path<String>,
State(state): State<AppState>,
auth: Auth,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_stream(socket, deploy_id, state, auth))
}
async fn handle_stream(
mut socket: WebSocket,
deploy_id: String,
state: AppState,
_auth: Auth,
) {
let mut last_log_len = 0;
loop {
// Récupérer l'état actuel du déploiement
let deployment = match Deployment::find_by_id(&state.db, &deploy_id) {
Ok(Some(d)) => d,
_ => break,
};
// Envoyer le nouveau contenu de log
if let Some(ref log) = deployment.build_log {
if log.len() > last_log_len {
let new_content = &log[last_log_len..];
if socket.send(Message::Text(new_content.to_string())).await.is_err() {
break; // Client déconnecté
}
last_log_len = log.len();
}
}
// Vérifier l'état terminal
match deployment.status.as_str() {
"running" | "failed" => {
let status_msg = serde_json::json!({
"type": "status",
"status": deployment.status,
"duration_ms": deployment.duration_ms,
});
let _ = socket.send(Message::Text(status_msg.to_string())).await;
break;
}
_ => {}
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
}L'intervalle de polling côté serveur est de 500 ms (contre 1500 ms côté client), ce qui signifie que les lignes de log apparaissent plus vite. Mais le vrai gain est que le serveur n'envoie des données que lorsqu'il y a du nouveau contenu. Une période d'inactivité pendant le pull d'une image Docker ne produit aucun message, tandis qu'une rafale de sortie de build est diffusée immédiatement.
L'authentification suit le même motif que les endpoints WebSocket existants de sh0 (le terminal et le streaming de logs) : le token est passé comme paramètre de requête (?token=...), car les connexions WebSocket ne peuvent pas définir d'en-têtes personnalisés pendant le handshake de mise à niveau.
L'audit global a découvert que le token n'était pas encodé en URL dans le paramètre de requête. Un token contenant des caractères +, = ou & corromprait l'URL. La correction a été une seule ligne :
rustlet encoded_token = percent_encoding::utf8_percent_encode(
&self.token,
percent_encoding::NON_ALPHANUMERIC,
);
let ws_url = format!("{}?token={}", base_ws_url, encoded_token);Côté client : WebSocket d'abord, repli HTTP
La commande push essaie désormais le streaming WebSocket en premier et se rabat sur le polling HTTP si la connexion échoue :
rust// Essayer le streaming WebSocket d'abord
match stream_build_log_ws(client, &deploy_id, &spinner).await {
Ok(result) => handle_stream_result(result, &spinner),
Err(_) => {
// WebSocket échoué -- repli sur le polling HTTP
poll_build_log_http(client, &deploy_id, &spinner).await?
}
}Le repli est important pour deux raisons :
- Proxys inverses : certaines configurations réseau suppriment les en-têtes de mise à niveau WebSocket
- Anciens serveurs sh0 : un CLI construit avec le support WebSocket doit toujours fonctionner avec des serveurs qui n'ont pas le endpoint de stream
Le chemin de polling HTTP est le code original de la Phase 1, refactorisé dans sa propre fonction mais par ailleurs inchangé. Le chemin WebSocket utilise tokio-tungstenite, qui était déjà une dépendance pour la commande sh0 logs.
Détection de phase partagée
Les deux chemins de code WebSocket et HTTP doivent détecter les phases de build à partir de la sortie des logs (pour mettre à jour le message du spinner). Le code de polling original avait une détection de phase en ligne. La refactorisation l'a extraite dans un helper partagé :
rustfn update_phase_from_log(line: &str, spinner: &ProgressBar) {
if line.contains("[STEP") {
if line.contains("Pulling") {
spinner.set_message("Pulling image");
} else if line.contains("Building") {
spinner.set_message("Building");
} else if line.contains("Starting") {
spinner.set_message("Starting");
}
}
}stream_build_log_ws() et poll_build_log_http() appellent toutes deux cette fonction pour chaque nouvelle ligne de log. Le spinner affiche la phase de build en cours, que les données soient arrivées via WebSocket ou HTTP.
La différence d'expérience développeur
Avec les Phases 1-3, déployer pendant le développement ressemblait à ceci :
# Modifier le code
# Enregistrer
$ sh0 push # Taper la commande
# Attendre 1-2 secondes pour la première ligne de log
# Attendre 30 secondes pour le build
# Vérifier l'URLAvec la Phase 4 :
$ sh0 watch # Taper une seule fois
# Modifier le code
# Enregistrer
# Les lignes de log apparaissent immédiatement
# Le build se termine
# L'URL est en ligne
# Continuer à coder...Le développeur tape une seule commande au début de sa session et ne pense plus jamais au déploiement. Chaque enregistrement déclenche un push. Chaque ligne de log est diffusée en temps réel. La boucle de feedback passe de « enregistrer, taper la commande, attendre le polling » à « enregistrer, voir les logs ».
Il ne s'agit pas d'économiser des frappes. Il s'agit de maintenir le développeur en état de flow. Le moment où il doit changer de contexte -- de l'écriture de code à l'exécution d'une commande de déploiement -- il perd sa concentration. Le mode watch élimine complètement ce changement de contexte.
Vérification
Les deux fonctionnalités compilent proprement et passent tous les tests existants :
cargo check: zéro erreur, zéro avertissementcargo test -p sh0: 37/37 réussiscargo test: tout le workspace passe
Le streaming WebSocket est conçu pour être testé en intégration (serveur + client), ce qui nécessite une instance sh0 en cours d'exécution. Les tests unitaires couvrent la logique de détection de phase et le comportement de repli.
Prochain dans la série : L'auditeur a trouvé ce que le constructeur a manqué -- Une plongée dans la méthodologie d'audit multi-sessions : 5 Critiques, 12 Importants et 19 Mineurs sur 3 200 lignes de code.