Using @monaco-editor/react in Electron without Internet Connection

#react #electron #monaco

The Monaco Editor is awesome. It’s what powers VSCode. I wanted to use it to power the query editor pane in Zui, the data exploration app I work on. I reached for @monaco-editor/react because it fit well in the app’s NextJS, React, and Electron tech stack. Loading the actual monaco-editor code seems to be a bit of a headache, so in an effort to make setup simple, @monaco-editor/react fetches the minified monaco-editor files from jsdelivr over the network.

This was a problem. Zui should work without needing to be connected to the internet.

My solution involved a configuration change in @monaco-editor/react, custom protocol handlers in electron, and disabling the webSecurity option in my browser window instances (only in development).

The first step was to add the monaco-editor package to my dependencies.

yarn add monaco-editor

Then I configured the @monaco-editor/react loader to fetch the files from my own made up url. This code should run in the browser window before react mounts up.

import { loader } from "@monaco-editor/react";

// This is how you change the source of the monaco files.
loader.config({
  paths: {
    vs: "app-asset://zui/node_modules/monaco-editor/min/vs",
  },
});

What’s the deal with that url? I just made it up. It can be anything because I will intercept requests to it in electron’s main process using the protocol module. Then I can fetch the appropriate file from the node_modules directory on disc and return it.

Here was my finished code. This should run in the main process during initialization before any browser windows are created.

import { app, protocol } from "electron";
import { AssetUrl } from "../protocols/asset-url";
import { AssetServer } from "../protocols/asset-server";

protocol.registerSchemesAsPrivileged([
  {
    scheme: "app-asset",
    privileges: {
      standard: true,
      supportFetchAPI: true,
      bypassCSP: true,
    },
  },
]);

const server = new AssetServer();

app.whenReady().then(() => {
  protocol.handle("app-asset", (request) => {
    const asset = new AssetUrl(request.url);

    if (asset.isNodeModule) {
      return server.fromNodeModules(asset.relativeUrl);
    } else {
      return server.fromPublic(asset.relativeUrl);
    }
  });
});

First we register the "app-asset" scheme with “standard” privileges, making it behavie like the "http" scheme. Read more about that here.

Then I new up my little AssetServer class which is responsible for finding the files on the file system to return to the requestor. That code is posted below.

Once the app fires the "ready" event, I intercept all requests to the "app-asset" scheme, using protocol.handle(). If the pathname starts with “/node_modules” I look it up using Node’s require.resolve and return it. Otherwise I look for that file in the public directory and return it.

Here’s the code for the AssetServer class.

import { app, net } from "electron";
import path from "node:path";
import { pathToFileURL } from "node:url";

export class AssetServer {
  fromNodeModules(relativePath: string) {
    const file = require.resolve(relativePath);
    const url = pathToFileURL(file).toString();
    return net.fetch(url, { bypassCustomProtocolHandlers: true });
  }

  fromPublic(relativeUrl: string) {
    const file = path.join(app.getAppPath(), "out", relativeUrl);
    const url = pathToFileURL(file).toString();
    return net.fetch(url, { bypassCustomProtocolHandlers: true });
  }
}

Notice the {bypassCustomProtocolHandlers: true} option. This is just in case I have other protocol handlers in my app for the "file://" scheme. If I don’t bypass, then my other custom protocol handlers will intercept this new request. Took me a whole day to figure that one out.

And the code for the AssetUrl class.

export class AssetUrl {
  url: URL;

  constructor(url: string) {
    this.url = new URL(url);
  }

  get isNodeModule() {
    return this.url.pathname.startsWith("/node_modules");
  }

  get relativeUrl() {
    if (this.isNodeModule) {
      return this.url.pathname.replace("/node_modules/", "");
    } else {
      return this.url.pathname.replace(/^\//, "");
    }
  }
}

Now depending on how you are serving your HTML file, you may see this error below in the dev tools.

Failed to construct 'Worker': Script at 'app-asset://node_modules/monaco-editor/min/vs/base/worker/workerMain.js#editorWorkerService' cannot be accessed from origin 'http://localhost:4567'.

I was serving my HTML files from the NextJS development server on port 4567. This is my code that loads the HTML in electron.

const url = env.isDevelopment
  ? `http://localhost:4567${this.path}?id=${this.id}&name=${this.name}`
  : `app-asset://zui${this.path}.html?id=${this.id}&name=${this.name}`;

browserWindow.loadURL(url);

The reason for the error is the “same-origin” policy enforced by the browser window. This means that JavaScript can’t load more JavaScript from a different site. In my setup, the monaco code was trying to fetch a file at the app-asset://zui origin, but it was running on the http://localhost:4567 origin.

As you can see above, in production, the html file is also loaded from the app-asset://zui origin so this error will not occur.

To fix it in development, I decided to disable the same-origin check on the browser window, by passing false to the webSecurity option in webPreferences.

const browserWindow = new BrowserWindow({
  webPreferences: {
    webSecurity: env.isProduction,
  },
});

That env object is a utility module I created to check for environment variables like process.NODE_ENV === "production" .

And that’s all folks. I am happy with the resulting code and now the app works offline. What a concept.

Thanks for Reading

Email me your thoughts at kerrto-prevent-spam@hto-prevent-spamey.comto-prevent-spam or give me a mention on Mastodon.