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:
Can be used in any Vite project.
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:
Make your Vite project a
single-spa
root project.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:
Set the server's port number.
Set asset and entry file names to be generated without a hash.
Set rollup's input to be the file defining the
single-spa
lifecycle functions on build, or'index.html'
on serve.Set the target JavaScript version to
'es2022'
.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!