vite-plugin-single-spa: Vite Projects Made single-spa Compatible with One Plug-In
Tags: vite, react, vue, webdev
Published: 2023-08-03 04:48:34

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:

```typescript
    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](https://github.com/single-spa/import-map-overrides) 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:

```typescript
    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**](https://github.com/vitejs/vite/discussions/13927) 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`:

```plaintext
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:

```bash
npm i -D vite-plugin-single-spa
```

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

```bash
npm i single-spa
```

```typescript
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 + &lt;Favorite Framework&gt;** 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`:

```plaintext
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`:

```bash
npm i single-spa-react
```

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

```typescript
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**:

```bash
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`:

```typescript
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:

```json
{
    "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:

```bash
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:

```typescript
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:

```plaintext
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:

```json
{
    "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:

```plaintext
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:

```plaintext
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:

```typescript
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](https://github.com/vitejs/vite/discussions/13927).

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!