vite-plugin-single-spa: Vite Projects Made single-spa Compatible with One Plug-In

Hello, everyone. In this article, I will cover how to use vite-plugin-single-spa to create Vite-based single-spa projects, both root projects and microservice projects.

I will be explaining how the plug-in works, why it works the way it does, and what to expect in the future.

Preface

If you have been following the series, we have learned that we can have complete freedom in how we create our Vite-based front-end projects because we don't need to use create-single-spa because we don't like it. Why don't we like it? Because it is very limited in the options that it provides. My number one concern is the lack of Vite. Furthermore, we concluded that it is wasteful to create a UI-less root and that it should be fine to have root projects powered by both single-spa and our framework of choice (React, Vue, Svelte, SolidJS, etc.). Furthermore, projects created with create-single-spa can only be run as micro-frontends. If one wanted to preview or develop it outside the context of the root project, several hoops had to be jumped in order to make the project be executable as a standalone.

To resolve all of the above, we developed a relatively simple Vite configuration that gave us all that we wanted:

  1. Can be used in any Vite project.

  2. Provides simultaneous behavior: Micro-frontend and standalone.

If all the problems had stopped there, I would have been a happy, happy man and I would probably have had this series closed by now. But as the god of programming will have it, problems did not stop here. This recap is found in the previous article of the series, so I won't be discussing it again. Let's see how the plug-in works.

Plug-In Fundamentals

IMPORTANT: This article explains the inners of the vite-plugin-single-spa package in its current (at time of writing) v0.0.3 iteration. Always refer to the package's documentation for the latest information.

The plug-in does 2 mutually-exclusive jobs:

  1. Make your Vite project a single-spa root project.

  2. Make your Vite project a single-spa micro-frontend (or parcel) project.

Root Projects

For single-spa root projects, the plug-in will:

  • Ensure import maps are present in the project's index page, if they have been defined by the developer.

  • Include the package import-map-overrides in the project's index page, using the information provided by the developer.

The developer must adhere to the following options definition:

    export type SingleSpaRootPluginOptions = {
        type: 'root';
        importMaps?: {
            type?: 'importmap' | 'overridable-importmap' | 'systemjs-importmap' | 'importmap-shim';
            dev?: string;
            build?: string;
        };
        imo?: boolean | string | (() => string);
    };

The type property must be set to the string 'root'; this is mandatory.

The importMaps.type property is optional, and if not specified, it is assumed to be overridable-importmap. Specify the type of import map you are working with. The single-spa team recommends the systemjs-importmap type; I recommend the default one. This type has everything to do with import-map-overrides, so read all about it if you haven't done so.

The importMaps.dev and importMaps.build properties serve the same purpose but for different Vite commands: The former applies when serving (npm run dev); the latter when building (npm run build). Use these properties to specify the file name of the import map. If not specified and while running in serve mode, it is assumed to be 'src/importMap.dev.json', or 'src/importMap.json' if the former doesn't exist. For build mode, the default is always 'src/importMap.json'.

If the resolved import map file name does not exist as an actual file, then no import maps (and no import-map-overrides package) are included.

The imo property configures the import-map-overrides package. Set it to false to not include import-map-overrides; the property's default value is true. Not specifying the value will pull the latest version of import-map-overrides, and this is not desirable in production environments. For production environments, the recommendation is to always specify the desired version. This is done simply by assigning the version number as a string. Set imo to, for example, '2.4.2' to install v2.4.2 of import-map-overrides.

NOTE: At the time of this writing, the latest version of import-map-overrides is v3.0.0, which doesn't work. Careful.

On all above cases where the package does get inserted, it is inserted by referencing it from the JSDelivr network. If this is not desirable, then specify imo as a function that returns a string. The returned string must be the URL of the import-map-overrides package from your CDN of choice. You are also responsible for making sure you get the correct/desired version of the package. The URL can be a relative URL if your deployed application serves the package from the same server as your application.

Micro-Frontend Projects

For single-spa micro-frontend projects, the plug-in will:

  1. Set the server's port number.

  2. Set asset and entry file names to be generated without a hash.

  3. Set rollup's input to be the file defining the single-spa lifecycle functions on build, or 'index.html' on serve.

  4. Set the target JavaScript version to 'es2022'.

  5. Request manifest file creation.

Yes, the plug-in is quite opinionated, but hopefully all for the better. For instance, it is nearly impossible to work with single-spa projects if the server port is not specified for each project (it is needed when configuring the import map). Also, import maps will become a complex thing to manage if we allow Vite (rollup) to add hashes to the generated file names. What about the target JavaScript version? Well, nowadays all browsers are quite up to date in terms of standards. The es2022 version has a lot of very nice features, most notably, top-level awaits.

The developer must adhere to the following options definition:

    export type SingleSpaMifePluginOptions = {
        type?: 'mife';
        serverPort: number;
        deployedBase?: string;
        spaEntryPoint?: string;
    };

The type property is optional, but if specified, it must be the string 'mife'.

The serverPort property is required. It is the port number Vite will use whenever serving development or preview versions of the project (npm run dev or npm run preview). As explained above, it is mandatory because, in the context of import maps, one must know the server's port number. Imagine the nightmare it would be if we allowed this value to be selected at random by Vite.

The deployedBase property is only used to set Vite's base property on build operations. This feature, seemingly unnecessary in this plug-in, is in reality necessary because the plug-in will set a base equal to http://localhost:<server port> if there is no specification of this property to allow you to test the micro-frontend using preview mode, which is currently the only mode that will properly serve micro-frontend assets.

This discussion brings us back to the interesting discovery explained in the previous article of this series: Vite's base property will be trimmed down to a relative URL on serve mode. This makes it impossible to serve assets in the single-spa world using serve mode. If you would like to see Vite enhanced so that assets are properly served while in serve mode, please visit this GitHub discussion and upvote it.

The spaEntryPoint property is used to tell the plug-in the file name of the code file that exports the single-spa lifecycle functions. It is optional and if not specified it defaults to 'src/spa.ts'.

NOTE: If your project is not in TypeScript, then you must always use spaEntryPoint, even if it is just to change the file extension.

Creating a Root Project

Ok, now that we know what the plug-in does and how to configure it, let's create a single-spa root project.

For this example, let's create a Vite + Vue project using npm create vite@latest:

npm create vite@latest
Need to install the following packages:
  create-vite@latest
Ok to proceed? (y) y
√ Project name: ... mySspaRoot
√ Package name: ... myssparoot
√ Select a framework: » Vue
√ Select a variant: » TypeScript

Scaffolding project in C:\Users\webJo\src\mySspaRoot...

Done. Now run:

  cd mySspaRoot
  npm install
  npm run dev

Great. Now we have the root project mySspaRoot in Vue. Let's install the plug-in:

npm i -D vite-plugin-single-spa

Finally, let's add single-spa and configure Vite (in file vite.config.ts):

npm i single-spa
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vitePluginSingleSpa from 'vite-plugin-single-spa';

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue(), vitePluginSingleSpa({
    type: 'root',
    imo: '2.4.2'
  })],
})

With this, we are finished with the root project, for the time being. Now the project is a single-spa root! At this point, we may proceed to create some simple markup and then start working on a micro-frontend. I won't be doing it in this article, but please, by all means, do it if you would like to.

Creating a Micro-Frontend Project

This one is started just like the root: We create a Vite + <Favorite Framework> project. Let's do React, since it is my interest to use React projects as micro-frontends.

My favorite framework is Svelte, FYI.

Just like we did for the root, we run npm create vite@latest:

npm create vite@latest
√ Project name: ... myReactMife
√ Package name: ... myreactmife
√ Select a framework: » React
√ Select a variant: » TypeScript

Scaffolding project in C:\Users\webJo\src\myReactMife...

Done. Now run:

  cd myReactMife
  npm install
  npm run dev

Micro-frontends need to export the single-spa lifecycle functions, and this is not provided for us. Let's do it.

The first step is to install single-spa-react:

npm i single-spa-react

The second step is to use single-spa-react. We will add this to file src/spa.tsx:

import React from 'react';
import ReactDOMClient from 'react-dom/client';
// @ts-ignore
import singleSpaReact from 'single-spa-react';
import App from './App';

const lc = singleSpaReact({
    React,
    ReactDOMClient,
    rootComponent: App,
    errorBoundary(err: any, _info: any, _props: any) {
        return <div>Error: {err}</div>
    }
});

export const { bootstrap, mount, unmount } = lc;

Ok, I believe we can now proceed with the Vite plug-in.

Install the single-spa plug-in for Vite:

npm i -D vite-plugin-single-spa

Version 0.0.3 of vite-plugin-single-spa has been programmed to set a base equal to http://localhost:<port number> as base if no deployedBase is given while building. Let's give this a try. These would be the contents of vite.config.ts:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import vitePluginSingleSpa from 'vite-plugin-single-spa';

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react(), vitePluginSingleSpa(
    {
      type: 'mife',
      serverPort: 4101,
      spaEntryPoint: 'src/spa.tsx'
    }
  )],
})

This project is not a root project, so we don't need to install single-spa.

Remember that, currently, Vite cannot be used with single-spa in serve mode because asset URL's are not being re-written, again, while serving. This is only happening properly while building.

This would be all that is needed in our test React micro-frontend. Let's configure the root project.

Joining Root and Micro-Frontend

As per the single-spa's documentation, we will be using an import map to define the entry point of our React micro-frontend.

Add the file src/importMap.json with the following content to the mySspaRoot project:

{
    "imports": {
        "@learnSspa/mifeA": "http://localhost:4101/spa.js"
    }
}

The vite-plugin-single-spa package will pick this import map automatically because of its filename, so we don't need to modify its configuration.

Determining the Import Map Value

Here's a tip for you, in case you don't know how to identify the value in the right hand side of the import map: Build your Vite project using npm run build. You'll see something similar to this:

Config passed to plugin: { type: 'mife', serverPort: 4101, spaEntryPoint: 'src/spa.tsx' }
Configuration resolved.  Base:  http://localhost:4101/
vite v4.4.8 building for production...
✓ 32 modules transformed.
dist/manifest.json       0.36 kB │ gzip:  0.15 kB
dist/assets/react.svg    4.13 kB │ gzip:  2.14 kB
dist/assets/spa.css      0.48 kB │ gzip:  0.31 kB
dist/spa.js            149.94 kB │ gzip: 47.85 kB
✓ built in 649ms

Do you see the last entry named spa.js? That's what we want. Because it resides directly in the dist folder, it means that, when served, it will be in the root path. Therefore, the import map needs to be <schema>://<host name>:<port>/spa.js.


Now that we have an alias for the micro-frontend (the alias is the @learnSspa/mifeA part), we can now do the needful in code.

For this simple set of projects, we will just modify src/main.ts like this:

import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { registerApplication, start } from 'single-spa'

const mifeAModule = '@learnSspa/mifeA';

registerApplication({
    name: 'mifeA',
    app: () => import(/* @vite-ignore */ mifeAModule),
    activeWhen: '/mifea'
});
start();

createApp(App).mount('#app')

We have imported registerApplication() and start() from single-spa, and we have registered a micro-frontend which we have named mifeA. The module to be imported when this micro-frontend is loaded will be our import map alias: @learnSspa/mifeA.

Now, you might be wondering why we are using an extra variable (mifeAModule) to store the alias instead of just putting the alias directly in the import() call. This has to be made like this because Vite will throw a runtime error because it tries to resolve the module specified inside the import() call, and such resolution fails because it is driven by the import map. The import map only comes into play in the browser.

Testing Root and Micro-Frontend Together

All single-spa and vite-plugin-single-spa requirements have been met.

To test these, serve the root project (mySspaRoot) using npm run dev, and then preview the react micro-frontend project (myReactMife) by first running npm run build and then npm run preview.

Now open a browser and navigate to the root project's home page. You should see the Vite + Vue interface that comes by default with new projects.

Now the real test: Add /mifea to the URL and press ENTER. Voilá! Now you see both the Vite + Vue interface and the Vite + React interface, with assets loaded. If you want to verify, right-click the React logo and select Inspect. You should see that its source reads http://localhost:4101/assets/react.svg, meaning it is in fact being served from the React micro-frontend.

The stying, however, doesn't look exactly right. Styling will be a future topic in this series, so stay tuned.

How About Running the Micro-Frontend as Standalone

Ah, yes! This is an important promise, good catch. If you were to visit http://localhost:4101 right now, you'd get the text "cannot get /". Why? Because we are previewing, and previewing serves the result of npm run build, and said result has been achieved by completely ignoring the project's index.html file. There is no homepage in the resulting build.

The simultaneous dual behavior exists only when serving (npm run dev). Go ahead and try it. After stopping the preview and re-starting in serve mode, re-visit http://localhost:4101. Now it works.

Now refresh your root project's page (http://localhost:<some port>/mifea). No React! So what is happening? Open the console log and see:

GET http://localhost:4101/spa.js net::ERR_ABORTED 404 (Not Found)

Ha! Now the value in the import map is not correct. To fix this, let's create the file src/importMap.dev.json in the root project with the following content:

{
    "imports": {
        "@learnSspa/mifeA": "http://localhost:4101/src/spa.tsx"
    }
}

Make sure the root's server is restarted. It should show the new import maps in the console, like this:

loadImportMap --- command: serve
Import map text: {
    "imports": {
        "@learnSspa/mifeA": "http://localhost:4101/src/spa.tsx"
    }
}
IM ready: {
  imports: { '@learnSspa/mifeA': 'http://localhost:4101/src/spa.tsx' },
  scope: {}
}

Now, reload the browser's tab for your root project. Bonkers! A mysterious error appears in the console log:

Uncaught Error: application 'mifeA' died in status LOADING_SOURCE_CODE: @vitejs/plugin-react can't detect preamble. Something is wrong. See https://github.com/vitejs/vite-plugin-react/pull/11#discussion_r430879201
    at App.tsx:10:5

The error has a URL that brings us to an already-closed discussion in GitHub. It seems that this known error should not be happening anymore. What is happening? The standalone version works fine. Only the single-spa version is failing.

By digging into the plug-in's code, I have found that the preamble is some custom code that pertains to fast refresh (HMR), and said code must be installed as an injected script element in the HTML page when serving. This, however, doesn't seem possible in the single-spa scenario we have: Our root project is the one that provides the HTML page, and Vue doesn't do this preamble thing; React seems to be the only one needing it. Since the HTML page being used to load the React component wasn't processed by the @vitejs/plugin-react plug-in, no preamble exists, hence the error.

I examined the code a bit more and discovered that by disabling HMR for the React micro-frontend server, then this preamble is no longer a requirement. So let's do that. Open the file vite.config.ts in the myReactMife project and modify it as follows:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import vitePluginSingleSpa from 'vite-plugin-single-spa';

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react(), vitePluginSingleSpa(
    {
      type: 'mife',
      serverPort: 4101,
      spaEntryPoint: 'src/spa.tsx'
    }
  )],
  server: {
    hmr: false
  }
})

Now make sure the Vite server has restarted (and if not, restart yourself). Finally, reload the root project's home page. Voilá! There's the React component, showing up next to the Vue component.

Wow! That was a lot to deal with, but we finally got ourselves a React project working dually in a simultaneous way.

NOTE: This has left us without HMR for the React micro-frontend. I don't see any other way around it as of now. It seems that, if the host HTML page doesn't come from another Vite + React project, HMR is not a possiblity. This also means that all this is probably a non-issue if your root project is a Vite + React project. None of this affects the built result, so weigh your interest: This is exclusively a developer issue.


We have learned how to use the vite-plugin-single-spa plug-in to quickly configure both root and micro-frontend projects with very little input on our part. While not ideal, we were able to get assets loaded properly by previewing the micro-frontend. We also managed to get a Vite + React micro-frontend project that works simultaneously for single-spa or as a regular standalone project, albeit in single-spa mode we don't get the assets loaded.

We have also experienced first hand the perils our journey poses in front of us. I am proud to say, however, that so far, no showstoppers have emerged: The journey continues.

If you would like to see Vite supporting single-spa while serving, not just previewing, please upvote this discussion in GitHub.

This is it for now. I trust that if anyone out there reading has any questions or useful information, will post them in the comments section. Happy coding!

11
Subscribe to my newsletter

Read articles from José Pablo Ramírez Vargas directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

José Pablo Ramírez Vargas
José Pablo Ramírez Vargas