Artisan Numérique

/développement/css/inotify/ Recharger dynamiquement les feuilles de style d'une page en développement

Le truc bien pénible avec le développement web, c'est de passer sa vie à modifier une feuille de style, aller dans le navigateur, pressez F5, ah, c'est pas bon, revenir sur l'éditeur, changer deux merdouilles, re-navigateur, re-f5, etc, etc, ad nauseam...

Voyons donc comment provoquer un rechargement dynamique d'une feuille de style dés qu'elle a été modifiée en utilisant les petits outils magique de l'ami GNU/Linux.

Petite revue de l'existant

Avant de m'embarquer dans une solution custom, j'ai évidemment testé un "certain" nombre d'approches :

  • Le code javascript qui vérifie à intervalle de temps régulier qu'une des feuilles n'a pas bougé sur le se serveur. C'est trop lent. Moi je veux qu'en sauvant d'un côté, ça bouge de l'autre.
  • Le plugin pour FireFox, firefile en l'instance, qui permet de modifier dans firebug et de sauvegarder les modifications sur le serveur. J'ai utilisé cette approche un certain temps mais on est limité par ce que comprend FireFox en matière de directives CSS. Pas la peine d'y tenter de -webkit-whatever par exemple... Pour ceux que cela intéresse, j'avais forké le serveur associé à cette extension pour l'améliorer un peu.
  • L'automatisation du "navigateur/F5" au niveau X11 avec un outil comme xdotool. Mise au point assez hasardeuse, surtout lorsqu'il s'agit de changer de configuration d'écran.
    • La connexion direct à FireFox en passant par le très intéressant projet mozrepl. Ça a très bien fonctionné pendant un temps, puis un jour plus du tout. Sûrement un changement de version de trop de l'ami FireFox (grrr).
  • Le projet livereload mais qui à ma connaissance pas libre et ne fonctionne pas avec n'importe quel navigateur (ex. ie).
  • Le projet Browsync (aka Edge Inspect) de chez Adobe qui n'est pas libre non plus et demande à installer une application sur chaque device.

L'outil ultime..

Après tout ces essais, mon outil "ultime" serait donc assez "simple" :

  • Multi-navigateur : il faut que ça fonctionne partout, y compris sous IE (euh, surtout sous IE... ?).
  • Rien à installer sur les navigateurs ou devices cibles.
  • Instantané : je veux que lorsque je sauve, ça bouge à l'autre bout, sans lag, de sorte à ce que mon éditeur me paraisse aussi réactif que firebug.
  • Transparence réseau : Il m'arrive en effet souvent de travailler sur du code qui n'est pas hébergé en local et de visualiser cela sur un navigateur qui lui est local. En somme, je veux pouvoir débugger sur mon navigateur locale du code que je modifie production (c'est mal hein ;-)

Les requêtes longues pour un rendu instantané

Pour que le temps entre sauvegarde et mise à jour soit le plus court possible, il faudrait idéalement que ce soit le serveur qui indique au navigateur qu'un fichier a changé. Hors nous le savons, le web ne fonctionne pas dans ce sens. Pour s'en sortir il faudrait soit faire du pooling avec une requête toutes les secondes par exemple, soit utiliser des websockets. La première approche est trop lente et la seconde est encore loin d'être utilisable sur tous les navigateurs et en toute circonstance (version courte ;-). Il y a bien des projets qui permettent d'émuler cela de manière à peu prés robuste, quitte à se rabattre sur flash, mais l'objectif n'est pas de monter une usine à gaz s'il existe une solution simple;

Et fort heureusement, il existe une telle solution qui elle fonctionne à tout les coups : les requêtes longues. Et le principe est, vous allez le constater, bête comme chou.

  1. Une fois la page chargée, un code Javascript s'exécute.
  2. Le code appelle, en AJAX, une URL côté server.
  3. Le script serveur rattaché à l'URL reçoit la requête du script client et ne va jamais répondre.
  4. Le script serveur, du fond de sa boucle sans fin, constate qu'un fichier a été modifié.
  5. À ce moment là, il renvoie le nom du fichier altéré en réponse au client.

Voici donc le principe, le serveur est long à répondre au client, mais le script réagit rapidement à la modification du fichier. Et du coup, d'un point de vue utilisateur, la mise à jour du navigateur est instantanée.

Ici réside donc le paradoxe, cette requête longue va nous offrir notre effet instantané.

Analyse de la partie ajax du code client

Maintenant voyons donc à quoi ressemble le script côté client.

/**
  * "Démon" qui va lancer la requête longue et attendre sa réponse.
  */
  function watch() {
    // Lancement de la requêtes. Notez l'ajout de la date en paramètre dans le
    // but de ne pas faire jouer le cache navigateur. Cela permet entre autre
    // d'avoir plusieurs onglets ouvert sur notre projet et d'avoir une
    // mise à jour de tous d'un coup
    jQuery.getJSON('/modifications.php?now=' + (new Date()).getTime(), function(data) {
      for (var i=0; i<data.length; i++) {
        if (data[i].extention=='css') {
          // Si le fichier modifié est du CSS, on le recharge
          reloadStyleSheets(data[i].file);
        } else {
          // Dans tout autre cas on recharge la page entière
          window.location.reload();
          return;
        }
      }

      // Si l'on arrive ici c'est qu'un ou plusieur CSS ont été
      // rechargés. On relance donc maintenant l'écoute
      // avec un petit temps d'attente pour être sur que
      // tout est bien stable
      var self = this;
      window.setTimeout(function () { self.watch(); }, 100);
    });
  }

  // Lancement de l'écoute initiale
  jQuery(function() {
      watch();
  });

Dans ce code, voulu neuneu et basé sur jQuery pour plus de lisibilité, nous avons le principe de base. Au chargement de la page, on appelle la fonction watch qui va lancer une requête AJAX vers le script modifications.php, le fameux script qui ne répond pas. J'espère que les commentaires sont suffisamment explicites.

Code de rechargement des styles

Voici le code de rechargement de la feuille de style que j'avais fauché je ne sais plus où et largement modifié depuis :

/* Cette fonction permet de vérifier qu'une URL est locale au projet ou pas. */
function isLocal(url) {
  var
    loc = document.location,
    reg = new RegExp("^\\.|^\/(?!\/)|^[\\w]((?!://).)*$|" + loc.protocol + "//" + loc.host);
  return url.match(reg);
}

/* Fonction de rechargement d'une feuille de style. */
function reloadStyleSheets(key) {
  // On recherche un fichier css, logique...
  key='/'+key+'.css';
  var self=this;

  // On itère sur les <link> de la page
  jQuery('link').each(function() {
    var $link = jQuery(this);
    var rel = $link.attr('rel');
    var href = $link.attr('href');

    // Si on a ce css dans la liste et que c'est un css local, on poursuit
    if(href && (href.indexOf(key) != -1) && rel && rel.match(new RegExp("stylesheet", "i")) && isLocal(href)) {
      // On crée une url pour la nouvelle feuille de style avec une nouvelle
      // date pour déjouer les caches
      href = href.replace(/\?.*$/, '') + "?now=" + (new Date()).getTime();
      var $newLink = jQuery('<link type="text/css" rel="stylesheet" href="'+href+'">');

      // Partie importante, On va insérer notre nouvelle feuille à
      // la BONNE position dans la liste. Sinon, style du projet cassé ;-)
      var $next = $link.next();
      if ($next.length) {
        $newLink.insertBefore($next);
      } else {
        var $head = $link.parent();
        $newLink.appendTo($ĥead);
      }

      // Maintenant on supprimer l'ancienne feuille de style
      $link.remove();
    }
  });
}

Là aussi j'espère que le code est suffisamment documenté. Et sinon, oui, je sais, mettre des fonctions globales c'est moche. Mais c'est juste pour illustrer l'article avec plus de clarté. En vrai tout ceci se trouve dans un "joli" classe JS. Que je n'expose pas parce qu'en réalité, je l'ai codé en typescript ;-)

inotify à la rescousse

À ce stade, nous avons toute l'infrastructure pour la mise à jour dynamique. Il ne reste pas qu'à rédiger le fameux modifications.php.

<?php
$result = array();

// On vérifie qu'inotifywait est bien installé
if (file_exists('/usr/bin/inotifywait')) {
  // Lancement de la commande inotifywait en mode récursif (-r), CSV, en excluant les
  // trucs innutiles (git, fichiers binaires) et uniquement pour les évènements
  // close/write.
  $command = "/usr/bin/inotifywait --csv -r --exclude '(\.git|files)' -e close_write .";
  $output = '';
  exec($command, $output, $exit);
  $found = FALSE;

  // Récupération de la liste des fichiers et formattage en tableau PHP
  foreach ($output as $line) {
    if (preg_match('@^([^,]+),"[^"]+",(.+)\.(.+)$@', $line, $match)) {
      $result[] = array('path'=>trim($match[1],'/'), 'file'=>$match[2], 'extention'=>$match[3]);
    }
  }
}

// On renvoie la réponse au script client.
print json_encode($result);

Attention, tout le code présent dans cet article n'est qu'une illustration des principes présentés ici. Ceci n'est en aucun cas à utiliser sans un minimum de sécurisation (authentification, filtrage IP, etc.) permettant d'empêcher l'usage de modifications.php à n'importe qui.

Voilà donc la seconde astuce de cette histoire. On utilise ici la commande inotifywait que vous trouverez dans le paquet inotify-tools d'une debian de base. Cette commande utilise la système inotify qui permet de mettre en écoute les évènements du système de fichier.

Cette commande une fois exécutée va donc "bloquer" la requête tant qu'un fichier n'est pas sauvegardé. Dés que cela arrive, nous allons simplement parser le résultat de la commande (au format CSV grâce au paramètre --csv) pour extraire chemin, nom et extension et stocker le tout dans un tableau. La boucle n'est pas foncièrement utile car il n'y aura jamais qu'une ligne de résultat.

Dernière étape, encoder le tableau en JSON et l'imprimer dans la sortie standard. Le tableau sera récupéré instantanément par la requête AJAX et le JavaScript répercutera les actions à mener. C'est donc gagné.

Conclusion

Voilà donc un système relativement simple qui permet de mettre à jour une page dynamiquement à la moindre modification du code. Il répond à tous les critères énoncés plus haut. Cela fonctionne sur tous les navigateur (même sous IE6, c'est dire...) et c'est instantané. Et petite cerise sur le gâteau, si vous lancez deux navigateurs différents sur la même page, la mise à jour devrait se faire sur chacun d'entre-eux de manière synchrone. Pratique pour comparer les rendus.