Laravel and Vite: A Love Story Ruined with Cross-Origin

This title might sound dramatic, but trust me, it's worth your read. Dive in!

It's great to be back to my blog. I've been away due to numerous conference preparations and a major internal updates to this website. Although these aren't today's topics, I'll certainly cover them in future posts. Also, this is my first article on Laravel, and it won't be the last!

Today, I want to share an experience I had with Laravel and Vite because of the strictness of the Web Worker API.

The Laravel and Vite Integration

Before delving into the problem, it's crucial to understand how Vite operates within a Laravel project.

First, what is Vite? It's a fast frontend build tool that powers next-gen web applications. It allows you to construct your frontend assets (JavaScript, CSS, etc.) efficiently, especially during development. The core of Vite is a plugin pipeline that transforms source files into the required outputs—a point we'll revisit soon.

And Laravel? It's a web application framework known for its expressive, elegant syntax. Laravel eases common tasks like authentication, routing, sessions, and caching, aiming to make development enjoyable without compromising functionality. And it excels at it!

When you embrace Laravel and Vite, you get a formidable stack for web application development.

Sounds great, right? But how does it works practically? Let me illustrate.

Laravel and Vite integration
Laravel and Vite integration

This image offers a high-level overview of the Laravel and Vite architecture. During development, two servers run: one for Laravel and one for Vite.

  1. A user requests a page from the Laravel server.
  2. The Laravel server sends the HTML page to the user. Before sending the response, Laravel adds a link tag to the entry script served by the Vite server. For example, you might find this line in the HTML returned by the Laravel server:
html
<script type="module" src="http://localhost:5173/index.ts"></script>
  1. The user's browser loads the HTML and detects the script tag pointing to the Vite server, then requests the script from the Vite server.
  2. The Vite server delivers the script to the user's browser.

This process is straightforward and highly effective during development. It gives you access to Vite's full power with the plugin pipeline and hot module replacement (HMR). Many developers might not fully appreciate how seamlessly this integration works compared to other tools.

However, sometimes things get tricky. And that's where our story begins.

The Context

For a project, I had to use the Monaco Editor, a browser-based code editor and the core of Visual Studio Code. The aim was to display a JSON in the code editor and validate it against a JSON schema. Users can edit this JSON, and the Monaco Editor, with JSON schema validation, will show in real time whether the JSON is valid or suggest corrections. Amazingly efficient when it works! And it has been much more complexe than expected.

Monaco Editor with a familiar interface if you're using Visual Studio Code
Monaco Editor with a familiar interface if you're using Visual Studio Code

To achieve this, the Monaco Editor requires starting some Web Workers. Why and what are Web Workers?

JavaScript, especially in browser environments, operates on a single thread, meaning it executes one task at a time. So, if a piece of code takes long to execute, the browser becomes unresponsive which degrades the user experience. This is why web developers advise against blocking the main thread.

To handle long tasks without affecting performance, we have two options:

  • Asynchronous JavaScript: This involves breaking down tasks into smaller chunks and executing them asynchronously. This way, the main thread remains free to handle other tasks.

  • Web Workers: For more complex tasks, especially those requiring heavy computation, we use Web Workers. A Web Worker is essentially a JavaScript file running in the background, separate from other scripts, with which it can communicate via messages.

Here's a simple example of a Web Worker script:

ts
globThis.onmessage = function (e) {
  console.log('Message received from main script')
  const workerResult = `Result: ${e.data[0] * e.data[1]}`
  console.log('Posting message back to main script')
  postMessage(workerResult)
}

With its main script:

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

worker.addEventListener('message', (e) => {
  console.log('Message received from worker')
  console.log(e.data)
})

worker.postMessage([2, 3])

In this example, the main script creates a new worker and sends it a message using postMessage. The worker receives and responds with postMessage. Due to JavaScript's event-driven nature, the main script listens for the worker's message using addEventListener.

In our case, Monaco Editor leverages Web Workers for certain features, including JSON schema validation.

Note

Using a Web Worker allows the Monaco Editor to run a LSP server in the background for each language, providing more advanced features without impacting the main thread and the user experience.

The Issue

But here's the catch:

The script passed to the Web Worker must be accessible from the same origin as the main script. This is where the problem arises.

Warning

This is a security feature to prevent cross-origin attacks so it's impossible to bypass it.

An origin is defined by a URL's scheme, host, and port. This means https://example.com and https://example.com:1024 are considered different origins.

Imagine we're on the page https://example.com and you want to create a Web Worker. The script URL passed to the Web Worker must be accessible from https://example.com.

This means that this works:

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

But this doesn't:

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

Do you see the issue now?

Our Laravel server runs on http://localhost:8000, while Vite is on http://localhost:5173. http://localhost:8000 and http://localhost:5173 are two different origins. This means the browser won't load the Web Worker script from the Vite server, resulting in a cross-origin issue due to running dual servers.

In the browser, we can easily spot this error with the following warning:

sh
app.js:21 Failed to construct 'Worker': Script at 'http://localhost:5173/node_modules/.pnpm/[email protected]/node_modules/monaco-editor/esm/vs/language/json/json.worker.js?worker_file&type=module' cannot be accessed from origin 'http://localhost:'.

This error is a showstopper. The Monaco Editor can't function properly without the Web Worker script. Let's find a solution.

Spoiler alert, I searched high and low online for a working solution. Sadly, I couldn't find one. So I try to tackle the issue myself. After extensive research and trials, I found a solution that I'm sharing it with you in this article.

The problematic code looks like this:

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()
    }
  },
}

// Monaco Editor initialization and Diagnostic feature

When Monaco is initialized, it tries to load the Web Worker script from the Vite server. This is where the cross-origin issue arises.

Note

In frameworks like Adonis—where Vite serves as middleware—this issue doesn't occur because Vite runs within the same application as the server. But in Laravel, with two separate servers, we needed a workaround.

Note

This issue doesn't exist if assets are built and loaded from /public/build, as everything is served by the Laravel server. However, this significantly impacts the developer experience, requiring asset rebuilding and page refreshing for every change. This isn't aligned with Vite's experience or its on-demand philosophy.

The Solution

Now we have a clear understanding of the issue and the origin of the problem. Let's dive into the solution.

First, we can examine the worker script URL:

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

It's a worker script—not intended for direct browser loading. Notice the ?worker query parameter, a Vite hint to wrap the script for easy use. We can inspect the response to have a better understanding.

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
    }
  )
}

Thanks to the ?worker query parameter, Vite automatically wraps the script in a function that returns a new Worker instance. So when we import the script, we can easily invoke the function to start the worker without having to manually create a new Worker instance with the correct URL and options.

The worker use the following script:

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

The http://localhost:5173 origin belongs to the Vite server and it's the root of the issue.

To be successful, the script URL must match our Laravel server's origin:

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

But how to modify the URL dynamically to match the Laravel server origin?

With a Vite plugin! It's the core of Vite to transform source files on the fly. With a plugin, we could intercept the worker script request and adjust the URL to match the Laravel server origin.

In our vite.config.ts, we can define a plugin to rewrite the worker script URL:

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

Here, we're replacing __laravel_vite_placeholder__.test instead of http://localhost:5173, as this URL is typically managed by the Laravel Vite plugin.

With this plugin, we can now load the Monaco Editor worker script since the origin matches the Laravel server. In production, the script URL will be correct and working as expected so we don't need to rewrite the URL. Build files are served from the same origin using a single server like Nginx, Apache or Caddy.

However, this poses another challenge: the worker script loads from the Laravel server, but only Vite can serves it—the Laravel server isn't aware of this script and the request will just return a 404 error.

But, we have full control over the Laravel server. We can create a route to proxy requests to the Vite server, allowing the Laravel server to serve the worker script.

This seems viable, faisable and elegant enough. Let's implement it.

We can create a Laravel route, available only in development, to proxy requests to the Vite server. Let's craft a proxy.dev.php file within Laravel's routes directory and including it in routes/web.php just during development.

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

Next, define our proxy route in 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) {
        // Directly stream the remote content without loading it fully into memory
        readfile($url);
    }, 200, ['Content-Type' => 'text/javascript']);
})->where('any', '(.*)');

Note

I do my best to optimize the proxy to efficiently stream the content without adding too much overhead. If you think of a better way to do it, feel free to share it with me in the comments.

And there you have it! The worker script loads, and the Monaco Editor operates perfectly.

Monaco Editor with a working JSON schema validation
Monaco Editor with a working JSON schema validation

One challenge was finding a solution that works well in development, during build, and in production. This approach seems to be an elegant compromise—simple, effective, and clean. So far, it's working perfectly.

Here's an overview of the solution:

Architecture of the solution
Architecture of the solution

Conclusion

This issue was indeed a challenge. Integrating the Monaco Editor with JSON schema validation was essential for our project. Unfortunately, the cross-origin headache and scant online resources forced me to create my own solution. But I found one!

I hope this article assists those facing similar issues. The key takeaway: mastering your tools is crucial. Although Vite plugins might seem daunting, understanding them removes all limitations allowing you to solve complex problems.

PP

Thanks for reading! My name is Estéban, and I love to write about web development.

I've been coding for several years now, and I'm still learning new things every day. I enjoy sharing my knowledge with others, as I would have appreciated having access to such clear and complete resources when I first started learning programming.

If you have any questions or want to chat, feel free to comment below or reach out to me on Bluesky, X, and LinkedIn.

I hope you enjoyed this article and learned something new. Please consider sharing it with your friends or on social media, and feel free to leave a comment or a reaction below—it would mean a lot to me! If you'd like to support my work, you can sponsor me on GitHub!

Reactions

Discussions

Add a Comment

You need to be logged in to access this feature.

Login with GitHub
Support my work
Follow me on