Blog
Une seule dépendance : construire un serveur de production avec juste le package ws
Notre serveur Node.js a une seule dépendance runtime. Une seule. Voici pourquoi nous avons fait ce choix et ce que ça signifie pour la fiabilité, la sécurité et la maintenance.
Suivez les mises à jour produit et ingénierie de cette catégorie.
Voir la catégorieOuvrez le package.json du serveur Tactic Remote. Sous dependencies, vous trouverez exactement une entrée :
C'est tout. Un package. La bibliothèque WebSocket ws. Tout le reste — le serveur HTTP, l'authentification, la limitation de débit, la gestion de terminal, la navigation de fichiers, la validation des entrées, l'exécution de processus — est construit sur les modules natifs de Node.js.
Ce n'est pas du minimalisme accidentel. C'est une décision d'ingénierie délibérée avec des conséquences concrètes pour la fiabilité, la sécurité et les utilisateurs de notre logiciel.
Pourquoi c'est important
Le serveur tourne sur votre Mac, gérant vos sessions Claude Code. Il a accès à votre terminal, votre système de fichiers et vos processus en cours. Chaque dépendance que vous ajoutez à un tel système est :
- Une surface d'attaque. Chaque package npm est du code qui s'exécute avec vos permissions, écrit par quelqu'un que vous ne connaissez pas, avec des dépendances transitives que vous n'avez pas auditées.
- Un fardeau de maintenance. Les dépendances ont besoin de mises à jour. Les mises à jour peuvent introduire des changements cassants ou des vulnérabilités. Plus vous en avez, plus souvent vous faites
npm audit fixau lieu de construire des fonctionnalités. - Un risque de fiabilité. Quand une dépendance casse (leftpad, colors, node-ipc), votre serveur casse. Les workflows de développement de vos utilisateurs s'arrêtent.
Pour un serveur qui se situe entre un humain et une IA écrivant du code sur sa machine, le coût d'une compromission de dépendance est sévère. Nous avons décidé que le bon nombre de dépendances externes était aussi proche de zéro que possible.
Ce que nous avons construit sans dépendances
Serveur HTTP
Node.js fournit http.createServer(). Ce n'est pas Express, mais nous servons exactement deux endpoints HTTP :
Deux routes. Pas de chaînes de middleware, pas de parseurs de body, pas de bibliothèques de routage. Le gestionnaire de requêtes est une seule fonction qui vérifie le chemin URL avec ===, lit le corps de la requête avec req.on('data'), et appelle le gestionnaire approprié.
Est-ce « webscale » ? Non. Est-ce que ça doit l'être ? Non plus. Ce serveur gère une connexion d'un iPhone. Une bibliothèque de routage serait du théâtre d'ingénierie.
Authentification
Le système d'auth supporte deux formats d'en-tête pour la vérification de clé API :
Le repli sur Sec-WebSocket-Protocol existe parce que URLSessionWebSocketTask sur iOS ne peut pas définir d'en-têtes arbitraires sur la requête d'upgrade. C'est une limitation documentée d'iOS — vous pouvez définir l'en-tête de protocole, mais pas d'en-têtes personnalisés. Plutôt que d'importer une bibliothèque WebSocket qui contourne cela, nous utilisons l'en-tête de protocole comme canal d'authentification.
L'authentification est vérifiée une fois à la connexion. En cas d'échec, le WebSocket se ferme avec le code 1008 (Policy Violation). Pas de tokens de session, pas de flux de refresh, pas de bibliothèques JWT. Une clé API statique, comparée avec ===.
Limitation de débit
Une fenêtre glissante en mémoire, 40 lignes de code :
30 tentatives d'authentification échouées par minute par IP. Au-delà, le serveur retourne 429 Too Many Requests avec un champ retryAfter. Quand la fenêtre expire, le compteur se réinitialise.
Un rate limiter basé sur Redis serait plus sophistiqué. Il ajouterait aussi une dépendance Redis. Pour un serveur gérant un seul client, un Map en mémoire avec nettoyage périodique est approprié.
Validation des entrées
Chaque saisie utilisateur est validée avant de toucher le système. Voici la surface de validation complète :
| Entrée | Validation | Raison |
|---|---|---|
| Noms de session | /^[a-zA-Z0-9_-]{1,64}$/ | Les noms de session tmux pouvant contenir des métacaractères shell |
| Commande Claude | /^claude(\s+--?[a-zA-Z0-9-]+)*$/ | Uniquement claude avec des flags optionnels — pas de pipes, redirections, chaînages |
| Noms de touches tmux | Liste blanche explicite de 18 touches | Empêcher l'injection arbitraire de frappes |
| Chemins de fichiers | path.resolve() + startsWith() | Empêcher la traversée de répertoire |
| Caractères uniques | /^[a-zA-Z0-9!@#$%^&*()...]$/ | Uniquement de l'ASCII imprimable pour la saisie |
Pas de bibliothèque de validation. Des expressions régulières et de la comparaison de chaînes, en ligne et lisibles.
Exécution de processus
C'est la pièce critique en termes de sécurité. Le serveur exécute des commandes tmux en votre nom. Si cette couche a une vulnérabilité, un attaquant peut exécuter des commandes arbitraires sur votre machine.
Notre réponse : child_process.spawn() avec shell: false, encapsulé dans une seule fonction par laquelle passe chaque commande externe :
shell: false signifie que les arguments sont passés directement au processus, pas via /bin/sh. Il n'y a pas d'interpolation de chaîne, pas d'expansion de template literal, pas d'opportunité d'injection shell. Si vous passez ["send-keys", "-t", sessionName, "-l", content] comme tableau d'arguments, c'est exactement ce que tmux reçoit dans son argv. Les points-virgules, pipes, backticks, $() — aucun d'entre eux n'a de signification spéciale.
Chaque opération tmux est un wrapper fin autour de cette fonction :
Pas de bibliothèque wrapper tmux. Pas d'ORM pour les sessions terminal. Juste spawn avec des tableaux d'arguments explicites.
Protection contre la traversée de chemin
La navigation de fichiers restreint l'accès à l'arborescence du répertoire personnel. Les répertoires sensibles sont toujours bloqués :
Correspondance segment par segment. Pas includes() sur la chaîne du chemin, ce qui donnerait un faux positif sur un répertoire nommé my-.ssh-backup. La vérification cherche des segments de chemin consécutifs qui correspondent exactement au pattern du répertoire sensible.
Tous les chemins sont résolus en chemins absolus via path.resolve() avant vérification. Cela neutralise la traversée avec .., les attaques par lien symbolique et les astuces de chemins relatifs.
Le protocole WebSocket
Le package ws nous donne un serveur WebSocket. Par-dessus, nous avons construit un système dual-protocole qui gère simultanément notre format de message plat original et un format structuré plus récent.
Pourquoi deux protocoles
Nous avons d'abord livré le protocole plat :
Ça fonctionne, mais il n'y a pas de corrélation de requête. Si le client envoie deux requêtes coup sur coup, les réponses arrivent comme des messages séparés et le client ne peut pas dire quelle réponse correspond à quelle requête.
Le nouveau protocole ajoute des ID de requête :
Nous ne pouvions pas casser les clients existants en supprimant le format plat. Donc le serveur gère les deux :
Le WebSocketClient de l'app iOS fait la même chose côté client — essaie de décoder les messages entrants comme le nouveau protocole Frame d'abord, se replie sur le ServerMessage legacy en cas d'échec.
Heartbeat
Les connexions WebSocket mobiles sont fragiles. Le téléphone bascule entre WiFi et cellulaire, se met en veille, entre dans des zones mortes. Nous devons détecter rapidement les connexions mortes sans tuer les connexions simplement interrompues temporairement.
Intervalle de 45 secondes, tolérance à 2 échecs. Un client peut être non réactif pendant 90 secondes avant d'être terminé. C'est calibré pour le mobile — un heartbeat WebSocket typique est de 15-30 secondes, mais les clients mobiles traversent fréquemment des transitions réseau de 30-60 secondes en changeant d'antenne ou en se réassociant au WiFi.
Nous envoyons à la fois un ping au niveau WebSocket (que la bibliothèque ws gère automatiquement) et un message {"type": "ping"} au niveau application. Le ping applicatif est pour les clients qui ne peuvent pas accéder directement aux trames WebSocket de bas niveau (une autre contrainte de URLSessionWebSocketTask sur iOS).
Ce que nous n'avons pas
Soyons honnêtes sur ce que cette architecture n'a pas :
Pas de clustering. Le serveur est mono-processus, mono-machine. Il ne peut pas scaler horizontalement. Il n'en a pas besoin — il sert un utilisateur sur un Mac.
Pas de persistance. Si le serveur redémarre, l'état du rate limiter et le registre des clients sont perdus. Les sessions tmux survivent (ce sont des processus indépendants), mais l'état en mémoire du serveur non. Pour notre cas d'usage, c'est acceptable — le client se reconnecte et rétablit l'état en quelques secondes.
Pas d'orchestration d'arrêt gracieux. Quand le serveur s'arrête, les connexions WebSocket sont terminées, pas drainées. Encore une fois, acceptable pour un serveur mono-utilisateur — le client gère la reconnexion.
Pas de logging de requêtes ni d'APM. Nous logons vers stdout. Si vous avez besoin de plus, redirigez vers un fichier. Il n'y a pas de bibliothèque de logging structuré, pas d'intégration OpenTelemetry, pas d'agrégation de logs.
Ce sont toutes des fonctionnalités qui ont du sens pour les services cloud multi-tenants. Elles n'en ont pas pour un serveur qui tourne sur votre bureau et sert votre téléphone.
La réalité de la maintenance
Depuis le lancement, notre maintenance de dépendances a été :
- Mettre à jour
wsquand il y a un correctif de sécurité - C'est tout
Pas d'alertes npm audit de dépendances transitives. Pas de PRs Dependabot pour des bibliothèques que nous n'utilisons pas directement. Pas de changements cassants de bumps de version majeure de frameworks middleware.
Quand la bibliothèque ws a une vulnérabilité (c'est arrivé deux fois depuis le début), nous mettons à jour un package. Le changement est petit, le risque est contenu, et nous pouvons auditer le diff en cinq minutes.
Comparez avec une stack typique Express + passport + helmet + compression + cors + body-parser. Chacun de ceux-ci tire son propre arbre de dépendances. Un seul npm audit peut signaler des dizaines de vulnérabilités transitives, la plupart théoriques mais toutes nécessitant une investigation.
Quand cette approche ne fonctionne pas
Pour être clair : ce n'est pas une recommandation générale. Les serveurs à une seule dépendance fonctionnent quand :
- Vous servez un seul utilisateur ou un petit ensemble connu de clients
- Votre surface HTTP est minuscule (1-5 routes)
- Vous n'avez pas besoin d'accès base de données, de templating ou de routage complexe
- La sécurité est mieux servie par la simplicité que par l'empilement de bibliothèques
- Votre équipe est à l'aise avec les API natives de Node.js
Si vous construisez un SaaS avec des comptes utilisateurs, OAuth, des modèles de base de données et une API REST, utilisez un framework. Express ou Fastify avec un ORM approprié vous fera gagner des mois de travail. L'approche « une dépendance » est pour les systèmes où le coût de la dépendance dépasse le coût de l'implémentation.
Pour le serveur de Tactic Remote — un relais WebSocket mono-utilisateur, mono-usage entre tmux et un iPhone — une dépendance est le bon nombre.
Pour voir comment ce serveur s'intègre dans l'architecture globale, consultez Exécuter Claude Code à distance depuis votre téléphone. Pour la référence complète du protocole WebSocket, consultez le guide de démarrage.
Essayez Tactic Remote
Pilotez vos agents de programmation depuis votre téléphone
Connectez-vous à Claude Code, Codex et aux autres agents sur votre Mac, PC Windows ou Linux. Consultez leur progression et envoyez la prochaine instruction depuis votre iPhone ou iPad.