‹div›RIOTS logo
browser-vite logo and Vite in the browser text

Vite in the browser


We made browser-vite - a patched version of Vite running in the browser with Workers.

How it works - in a nutshell

  • A Service Worker: replaces Vite’s HTTP server. Capturing the HTTP calls of an embedded iframe from example.
  • A Web Worker: Run browser-vite to process off the main thread.
  • Calls to the file system are replaced by an in-memory file system.
  • Import of files with special extensions (.ts, .tsx, .scss…) are transformed.

The challenges

No real File System

Vite does a lot with files. The files of the project but also config files, watchers, and globs. These are difficult to implement in the browser with a shimmed in-memory FS. We removed watchers, globs, and config file calls to limit the complexity and the surface API.

The project files stay in the in-memory FS that browser-vite and vite plugins can access normally.

No “node_modules”

Vite relies on the presence of node_modules to resolve dependencies. And it bundles them in a Dependencing Pre-Bundling optimization at startup.

We didn’t want to run a node_modules folder in the browser’s memory because we think it’s just too much data to download and store into the browser’s memory. So we carefully stripped out node resolvers and Dependencing Pre-Bundling from Vite.

Users of browser-vite have to create a Vite plugin to resolve bare module imports.

Our products: Backlight.dev, Components.studio and WebComponents.dev, are running a server-side bundler optimizer for the past 2 years now. We created a Vite plugin for browser-vite to resolve node dependencies automatically. As of the date of this post, this server-side bundler is not open-sourced.

Regex “lookbehind”

Some regexs in Vite are using lookbehind. This works great locally when executed by Node.js, but it’s not supported in Safari.

So we rewrote the regexs for more browser compatibility.

Hot Module Reload (HMR)

Vite uses WebSockets to communicate code changes from the server (node) to the client (browser).

In browser-vite, the server is the ServiceWorker + Vite worker and the client is the iframe. So we changed the communication from WebSockets to a post message to the iframe.

For this, the client side code of Vite in iframe has been replaced by a special browser version handling messages outside of WebSockets.

How to use it

As of the time of this writing, it’s not a plug and play process. There is a lot to figure out by reading Vite’s internal processing in order to use browser-vite.

Note: This post may become obsolete over time, so make sure you check browser-vite’s README for always up to date information on browser-vite’s usage.

Installation

Install the browser-vite npm package.

$ npm install --save browser-vite

or

$ npm install --save vite@npm:browser-vite

To channel “vite” imports to “browser-vite”.

iframe - window to browser-vite

You need an iframe that will show the pages served internally by browser-vite.

Service Worker - the in-browser web server

The Service Worker will capture certain URLs requests coming from the iframe.

Here is an example using workbox.

workbox.routing.registerRoute(
  /^https?:\/\/HOST/BASE_URL\/(\/.*)$/,
  async ({
    request,
    params,
    url,
  }: import('workbox-routing/types/RouteHandler').RouteHandlerCallbackContext): Promise<Response> => {
    const req = request?.url || url.toString();
    const [pathname] = params as string[];
    // send the request to vite worker
    const response = await postToViteWorker(pathname)
    return response;
  }
);

Mostly posting a message to the “Vite Worker” using postMessage or broadcast-channel.

Vite Worker - processing request

The Vite Worker is a Web Worker that will process requests captured by the Service Worker.

Example of creating a Vite Server:

import {
  transformWithEsbuild,
  ModuleGraph,
  transformRequest,
  createPluginContainer,
  createDevHtmlTransformFn,
  resolveConfig,
  generateCodeFrame,
  ssrTransform,
  ssrLoadModule,
  ViteDevServer,
  PluginOption
} from 'browser-vite';

export async function createServer(
  const config = await resolveConfig(
    {
      plugins: [
        // virtual plugin to provide vite client/env special entries (see below)
        viteClientPlugin,
        // virtual plugin to resolve NPM dependencies, e.g. using unpkg, skypack or another provider (browser-vite only handles project files)
        nodeResolvePlugin,
        // add vite plugins you need here (e.g. vue, react, astro ...)
      ]
      base: BASE_URL, // as hooked in service worker
      // not really used, but needs to be defined to enable dep optimizations
      cacheDir: 'browser',
      root: VFS_ROOT,
      // any other configuration (e.g. resolve alias)
    },
    'serve'
  );
  const plugins = config.plugins;
  const pluginContainer = await createPluginContainer(config);
  const moduleGraph = new ModuleGraph((url) => pluginContainer.resolveId(url));

  const watcher: any = {
    on(what: string, cb: any) {
      return watcher;
    },
    add() {},
  };
  const server: ViteDevServer = {
    config,
    pluginContainer,
    moduleGraph,
    transformWithEsbuild,
    transformRequest(url, options) {
      return transformRequest(url, server, options);
    },
    ssrTransform,
    printUrls() {},
    _globImporters: {},
    ws: {
      send(data) {
        // send HMR data to vite client in iframe however you want (post/broadcast-channel ...)
      },
      async close() {},
      on() {},
      off() {},
    },
    watcher,
    async ssrLoadModule(url) {
      return ssrLoadModule(url, server, loadModule);
    },
    ssrFixStacktrace() {},
    async close() {},
    async restart() {},
    _optimizeDepsMetadata: null,
    _isRunningOptimizer: false,
    _ssrExternals: [],
    _restartPromise: null,
    _forceOptimizeOnRestart: false,
    _pendingRequests: new Map(),
  };

  server.transformIndexHtml = createDevHtmlTransformFn(server);

  // apply server configuration hooks from plugins
  const postHooks: ((() => void) | void)[] = [];
  for (const plugin of plugins) {
    if (plugin.configureServer) {
      postHooks.push(await plugin.configureServer(server));
    }
  }

  // run post config hooks
  // This is applied before the html middleware so that user middleware can
  // serve custom content instead of index.html.
  postHooks.forEach((fn) => fn && fn());

  await pluginContainer.buildStart({});
  await runOptimize(server);

  return server;
}

Pseudo code to process requests via browser-vite

import {
  transformRequest,
  isCSSRequest,
  isDirectCSSRequest,
  injectQuery,
  removeImportQuery,
  unwrapId,
  handleFileAddUnlink,
  handleHMRUpdate,
} from 'vite/dist/browser';

...

async (req) => {
  let { url, accept } = req
  const html = accept?.includes('text/html');
  // strip ?import
  url = removeImportQuery(url);
  // Strip valid id prefix. This is prepended to resolved Ids that are
  // not valid browser import specifiers by the importAnalysis plugin.
  url = unwrapId(url);
  // for CSS, we need to differentiate between normal CSS requests and
  // imports
  if (isCSSRequest(url) && accept?.includes('text/css')) {
    url = injectQuery(url, 'direct');
  }
  let path: string | undefined = url;
  try {
    let code;
    path = url.slice(1);
    if (html) {
      code = await server.transformIndexHtml(`/${path}`, fs.readFileSync(path,'utf8'));
    } else {
      const ret = await transformRequest(url, server, { html });
      code = ret?.code;
    }
    // Return code reponse
  } catch (err: any) {
    // Return error response
  }
}

Check Vite’s internal middlewares for more details.

How does it compare to Stackblitz WebContainers

WebContainers: Run Node.js natively in your browser”

Stackblitz’s WebContainers can also run Vite in the browser. You can elegantly go to vite.new to have a working environment.

We are not experts in WebContainers but, in a nutshell, where browser-vite shims the FS and the HTTPS server at the Vite level, WebContainers shims the FS and a lot of other things at the Node.js level, and Vite runs on it with a few additional changes.

It goes as far as storing a node_modules in the WebContainer, in the browser. But it doesn’t run npm or yarn directly because it would take too much space (I guess). They aliased these commands to Turbo - their package manager.

WebContainers can run other frameworks too, like Remix, SvelteKit or Astro.

It’s magical ✨ It’s mind-blowing 🤯 We have massive respect for what the Stackblitz team has built here.

One downside of WebContainers is that it can only run on Chrome today but will probably run on Firefox soon. browser-vite works on Chrome, Firefox and Safari today.

In a nutshell, WebContainers operates at a lower level of abstraction to run Vite in the browser. browser-vite operates at a higher level of abstraction, very close to Vite itself.

Metaphorically, for the retro-gamers out there, browser-vite is a little bit like UltraHLE 🕹️😊

(*) gametechwiki.com: High/Low level emulation

What’s next?

browser-vite is at the heart of our solutions. We are progressively rolling it out to all our products:

Going forward, we will continue to invest in browser-vite and report back upstream. Last month, we also announced that we sponsored Vite via Evan You and Patak to support this wonderful project.

Want to know more?