Laravel et Vite : le cross-origin ruine l'histoire d'amour

Ce titre peut sembler dramatique, mais croyez-moi, cela vaut la peine d'être lu. Plongez-y !

C'est formidable de revenir sur mon blog. J'ai été absent en raison de nombreuses préparations pour des conférences et d'une mise à jour majeure de ce site web. Bien que ce ne soient pas les sujets du jour, je les aborderai certainement dans de futurs articles. De plus, c'est mon premier article sur Laravel, et ce ne sera pas le dernier !

Aujourd'hui, je souhaite partager une expérience que j'ai vécue avec Laravel et Vite en raison de la rigueur du Web Worker API.

L'intégration de Laravel et Vite

Avant de plonger dans le problème, il est crucial de comprendre comment Vite fonctionne dans un projet Laravel.

D'abord, qu'est-ce que Vite ? C'est un outil de construction frontend rapide qui propulse les applications web de nouvelle génération. Il vous permet de construire vos actifs frontend (JavaScript, CSS, etc.) efficacement, en particulier durant le développement. Le cœur de Vite est un pipeline de plugins qui transforme les fichiers source en sorties requises, un point que nous allons revisiter bientôt.

Et Laravel ? C'est un framework d'application web connu pour sa syntaxe expressive et élégante. Laravel facilite les tâches courantes comme l'authentification, le routage, les sessions et la mise en cache, visant à rendre le développement agréable sans compromettre la fonctionnalité. Et il excelle à ce jeu !

Lorsque vous adoptez Laravel et Vite, vous obtenez une pile redoutable pour le développement d'applications web.

Ça sonne bien, n'est-ce pas ? Mais comment cela fonctionne-t-il en pratique ? Permettez-moi d'illustrer.

Intégration de Laravel et Vite
Intégration de Laravel et Vite

Cette image offre un aperçu de haut niveau de l'architecture de Laravel et Vite. Pendant le développement, deux serveurs fonctionnent : un pour Laravel et un pour Vite.

  1. Un utilisateur demande une page au serveur Laravel.
  2. Le serveur Laravel envoie la page HTML à l'utilisateur. Avant d'envoyer la réponse, Laravel ajoute une balise lien au script d'entrée servi par le serveur Vite. Par exemple, vous pourriez trouver cette ligne dans le HTML retourné par le serveur Laravel :
html
<script type="module" src="http://localhost:5173/index.ts"></script>
  1. Le navigateur de l'utilisateur charge l'HTML et détecte la balise script pointant vers le serveur Vite, puis demande le script au serveur Vite.
  2. Le serveur Vite livre le script au navigateur de l'utilisateur.

Ce processus est simple et très efficace durant le développement. Il vous donne accès à la pleine puissance de Vite avec le pipeline de plugins et le remplacement à chaud des modules (HMR). De nombreux développeurs pourraient ne pas apprécier pleinement à quel point cette intégration fonctionne sans couture par rapport à d'autres outils.

Cependant, parfois, les choses se compliquent. Et c'est là que notre histoire commence.

Le Contexte

Pour un projet, j'ai dû utiliser le Monaco Editor, un éditeur de code basé sur le navigateur et le noyau de Visual Studio Code. L'objectif était d'afficher un JSON dans l'éditeur de code et de le valider par rapport à un schéma JSON. Les utilisateurs peuvent modifier ce JSON, et l'éditeur Monaco, avec la validation du schéma JSON, affichera en temps réel si le JSON est valide ou suggérera des corrections. Étonnamment efficace quand ça fonctionne ! Et cela s'est avéré beaucoup plus complexe que prévu.

Monaco Editor avec une interface familière si vous utilisez Visual Studio Code
Monaco Editor avec une interface familière si vous utilisez Visual Studio Code

Pour parvenir à cela, l'éditeur Monaco nécessite de démarrer quelques Web Workers. Pourquoi et qu'est-ce que les Web Workers ?

JavaScript, en particulier dans les environnements de navigateur, fonctionne sur un seul fil, ce qui signifie qu'il exécute une tâche à la fois. Donc, si une partie du code prend du temps à s'exécuter, le navigateur devient non réactif, ce qui dégrade l'expérience utilisateur. C'est pourquoi les développeurs web conseillent d'éviter de bloquer le fil principal.

Pour gérer les tâches longues sans affecter les performances, nous avons deux options :

  • JavaScript asynchrone : Cela implique de décomposer les tâches en morceaux plus petits et de les exécuter de manière asynchrone. De cette façon, le fil principal reste libre pour gérer d'autres tâches.

  • Web Workers : Pour des tâches plus complexes, en particulier celles nécessitant de lourdes calculs, nous utilisons des Web Workers. Un Web Worker est essentiellement un fichier JavaScript s'exécutant en arrière-plan, séparé des autres scripts, avec lequel il peut communiquer via des messages.

Voici un exemple simple d'un script de Web Worker :

ts
globThis.onmessage = function (e) {
  console.log('Message reçu du script principal')
  const workerResult = `Résultat : ${e.data[0] * e.data[1]}`
  console.log('Envoi du message de retour au script principal')
  postMessage(workerResult)
}

Avec son script principal :

ts
const worker = new Worker('worker.js')

worker.addEventListener('message', (e) => {
  console.log('Message reçu du worker')
  console.log(e.data)
})

worker.postMessage([2, 3])

Dans cet exemple, le script principal crée un nouveau worker et lui envoie un message en utilisant postMessage. Le worker reçoit et répond avec postMessage. En raison de la nature événementielle de JavaScript, le script principal écoute le message du worker en utilisant addEventListener.

Dans notre cas, l'éditeur Monaco exploite les Web Workers pour certaines fonctionnalités, y compris la validation du schéma JSON.

Note

L'utilisation d'un Web Worker permet à l'éditeur Monaco d'exécuter un serveur LSP en arrière-plan pour chaque langage, fournissant des fonctionnalités plus avancées sans impacter le fil principal et l'expérience utilisateur.

Le Problème

Mais voici le hic :

Le script passé au Web Worker doit être accessible depuis la même origine que le script principal. C'est là que le problème surgit.

Warning

C'est une mesure de sécurité pour prévenir les attaques d'origine croisée, impossible à contourner.

Une origine est définie par le schéma, l'hôte et le port d'une URL. Cela signifie que https://example.com et https://example.com:1024 sont considérés comme des origines différentes.

Imaginez que nous sommes sur la page https://example.com et que vous souhaitez créer un Web Worker. L'URL du script passée au Web Worker doit être accessible depuis https://example.com.

Cela signifie que cela fonctionne :

html
<script>
  const worker = new Worker('worker.js')
</script>

Mais cela ne fonctionne pas :

html
<script>
  const worker = new Worker('https://another-origin.example.com/worker.js')
</script>

Voyez-vous maintenant le problème ?

Notre serveur Laravel fonctionne sur http://localhost:8000, tandis que Vite est sur http://localhost:5173. http://localhost:8000 et http://localhost:5173 sont deux origines différentes. Cela signifie que le navigateur ne chargera pas le script Web Worker depuis le serveur Vite, entraînant un problème d'origine croisée dû à l'exécution de deux serveurs.

Dans le navigateur, nous pouvons facilement repérer cette erreur avec le message d'avertissement suivant :

sh
app.js:21 Échec de la construction de 'Worker' : le script à 'http://localhost:5173/node_modules/.pnpm/[email protected]/node_modules/monaco-editor/esm/vs/language/json/json.worker.js?worker_file&type=module' ne peut pas être accédé depuis l'origine 'http://localhost :'.

Cette erreur est un frein. L'éditeur Monaco ne peut pas fonctionner correctement sans le script Web Worker. Cherchons une solution.

Alerte spoiler, j'ai cherché haut et bas en ligne pour trouver une solution fonctionnelle. Malheureusement, je n'ai rien trouvé. J'ai donc essayé de m'attaquer au problème moi-même. Après de nombreuses recherches et essais, j'ai trouvé une solution que je partage avec vous dans cet article.

Le code problématique ressemble à cela :

ts
import * as monaco from 'monaco-editor'
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'

globalThis.MonacoEnvironment = {
  getWorker(workerId, label) {
    switch (label) {
      case 'json':
        return new JsonWorker()
      default:
        return new EditorWorker()
    }
  },
}

// Initialisation de l'éditeur Monaco et fonctionnalité de diagnostic

Lorsque Monaco est initialisé, il essaie de charger le script Web Worker à partir du serveur Vite. C'est là que surgit le problème d'origine croisée.

Note

Dans des frameworks comme Adonis—où Vite sert de middleware—ce problème ne se produit pas car Vite fonctionne au sein de la même application que le serveur. Mais dans Laravel, avec deux serveurs distincts, nous avions besoin d'un contournement.

Note

Ce problème n'existe pas si les actifs sont construits et chargés depuis /public/build, car tout est servi par le serveur Laravel. Cependant, cela impacte considérablement l'expérience du développeur, nécessitant la reconstruction des actifs et le rafraîchissement de la page pour chaque changement. Cela n'est pas en accord avec l'expérience de Vite ou sa philosophie à la demande.

La Solution

Nous avons maintenant une compréhension claire du problème et de l'origine de celui-ci. Plongeons dans la solution.

Tout d'abord, examinons l'URL du script worker :

monaco-editor/esm/vs/editor/editor.worker?worker

C'est un script worker - pas destiné à un chargement direct dans le navigateur. Remarquez le paramètre de requête ?worker, une indication de Vite pour encapsuler le script pour un usage facile. Nous pouvons inspecter la réponse pour mieux comprendre.

js
export default function WorkerWrapper(options) {
  return new Worker(
    'http://localhost:5173/node_modules/.pnpm/[email protected]/node_modules/monaco-editor/esm/vs/editor/editor.worker.js?worker_file&type=module',
    {
      type: 'module',
      name: options?.name
    }
  )
}

Grâce au paramètre de requête ?worker, Vite encapsule automatiquement le script dans une fonction qui retourne une nouvelle instance de Worker. Donc, lorsque nous importons le script, nous pouvons facilement invoquer la fonction pour démarrer le worker sans avoir à créer manuellement une nouvelle instance de Worker avec l'URL et les options correctes.

Le worker utilise le script suivant :

http://localhost:5173/node_modules/.pnpm/[email protected]/node_modules/monaco-editor/esm/vs/editor/editor.worker.js?worker_file&type=module

L'origine http://localhost:5173 appartient au serveur Vite et c'est la racine du problème.

Pour réussir, l'URL du script doit correspondre à l'origine de notre serveur Laravel :

http://localhost:8000/node_modules/.pnpm/[email protected]/node_modules/monaco-editor/esm/vs/editor/editor.worker.js?worker_file&type=module

Mais comment modifier l'URL dynamiquement pour qu'elle corresponde à l'origine du serveur Laravel ?

Avec un plugin Vite ! C'est le cœur de Vite de transformer les fichiers source à la volée. Avec un plugin, nous pourrions intercepter la demande de script worker et ajuster l'URL pour qu'elle corresponde à l'origine du serveur Laravel.

Dans notre vite.config.ts, nous pouvons définir un plugin pour réécrire l'URL du script worker :

ts
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: [
    (() => {
      return {
        name: 'monaco-editor:rewrite-worker',
        transform(code, id) {
          if (this.environment.mode !== 'dev') {
            return
          }

          if (id.includes('worker')) {
            return code.replace(
              '__laravel_vite_placeholder__.test',
              'localhost:8000',
            )
          }
        },
      }
    })(),
  ],
})

Note

Ici, nous remplaçons __laravel_vite_placeholder__.test au lieu de http://localhost:5173, car cette URL est généralement gérée par le plugin Laravel Vite.

Avec ce plugin, nous pouvons maintenant charger le script worker de l'éditeur Monaco puisque l'origine correspond à celle du serveur Laravel. En production, l'URL du script sera correcte et fonctionnera comme prévu, donc nous n'avons pas besoin de réécrire l'URL. Les fichiers de construction sont servis depuis la même origine à l'aide d'un seul serveur comme Nginx, Apache ou Caddy.

Cependant, cela pose un autre défi : le script worker est chargé depuis le serveur Laravel, mais seul Vite peut le servir - le serveur Laravel n'est pas au courant de ce script et la demande renverra simplement une erreur 404.

Mais nous avons un contrôle total sur le serveur Laravel. Nous pouvons créer une route pour proxy les demandes vers le serveur Vite, permettant au serveur Laravel de servir le script worker.

Cela semble viable, faisable et suffisamment élégant. Implémentons-le.

Nous pouvons créer une route Laravel, disponible uniquement en développement, pour proxy les demandes vers le serveur Vite. Créons un fichier proxy.dev.php dans le répertoire routes de Laravel et incluons-le dans routes/web.php uniquement durant le développement.

php
if (!app()->isProduction()) {
    require __DIR__ . '/proxy.dev.php';
}

Ensuite, définissons notre route proxy dans proxy.dev.php :

php
<?php

use Illuminate\Support\Facades\Route;

Route::get('/node_modules/{any}', function ($any) {
    $url = "http://localhost:5173/node_modules/{$any}";
    return response()->stream(function () use ($url) {
        // Diffuser directement le contenu distant sans le charger pleinement en mémoire
        readfile($url);
    }, 200, ['Content-Type' => 'text/javascript']);
})->where('any', '(.*)');

Note

Je fais de mon mieux pour optimiser le proxy afin de diffuser efficacement le contenu sans ajouter trop de surcharge. Si vous pensez à une meilleure façon de le faire, n'hésitez pas à la partager avec moi dans les commentaires.

Et voilà ! Le script worker se charge, et l'éditeur Monaco fonctionne parfaitement.

Monaco Editor avec une validation du schéma JSON fonctionnelle
Monaco Editor avec une validation du schéma JSON fonctionnelle

Un défi a été de trouver une solution qui fonctionne bien en développement, lors de la construction, et en production. Cette approche semble être un compromis élégant — simple, efficace et propre. Jusqu'à présent, cela fonctionne parfaitement.

Voici un aperçu de la solution :

Architecture de la solution
Architecture de la solution

Conclusion

Ce problème a effectivement été un défi. L'intégration de l'éditeur Monaco avec la validation du schéma JSON était essentielle pour notre projet. Malheureusement, le casse-tête des origines croisées et les ressources en ligne rares m'ont contraint à créer ma propre solution. Mais j'en ai trouvé une !

J'espère que cet article aidera ceux qui font face à des problèmes similaires. La clé à retenir : maîtriser vos outils est crucial. Bien que les plugins Vite puissent sembler intimidants, les comprendre supprime toutes les limites vous permettant de résoudre des problèmes complexes.

Pd

Merci de me lire ! Je m'appelle Estéban, et j'adore écrire sur le développement web.

Je code depuis plusieurs années maintenant, et j'apprends encore de nouvelles choses chaque jour. J'aime partager mes connaissances avec les autres, car j'aurais aimé avoir accès à des ressources aussi claires et complètes lorsque j'ai commencé à apprendre la programmation.

Si vous avez des questions ou souhaitez discuter, n'hésitez pas à commenter ci-dessous ou à me contacter sur Bluesky, X, et LinkedIn.

J'espère que vous avez apprécié cet article et appris quelque chose de nouveau. N'hésitez pas à le partager avec vos amis ou sur les réseaux sociaux, et laissez un commentaire ou une réaction ci-dessous—cela me ferait très plaisir ! Si vous souhaitez soutenir mon travail, vous pouvez me sponsoriser sur GitHub !

Réactions

Discussions

Ajouter un commentaire

Vous devez être connecté pour accéder à cette fonctionnalité.

Se connecter avec GitHub
Soutenez mon travail
Suivez-moi sur