Vite plug-in to convert Vite-based projects to single-spa root or micro-frontend applications.
Important
We are dropping support for Vite v4. If this affects you, read this discussion.
This Vite plug-in is an opinionated way of making Vite-based front-end projects work with single-spa.
NOTE: This document assumes the use of TypeScript Vite projects. However, all of this applies to JavaScript projects as well. Just know that any file name with extension
.ts
probably exists as a.js
file.
Install the NPM package as a development dependency.
npm i -D vite-plugin-single-spa
In reality, what is installed as development dependency is a matter of how you build your final product. For example, if you want to use
npm
with--omit=dev
to build the project, then you'll need all packages used by Vite's build command to have been installed as regular dependencies. It is up to you how you end up installing the package (dev or regular).
Now, in vite.config.ts
, import the vitePluginSingleSpa
function from it. It is the default export but it is also
a named export:
// Either works.
import vitePluginSingleSpa from 'vite-plugin-single-spa';
import { vitePluginSingleSpa } from 'vite-plugin-single-spa';
This is the plug-in factory function. Create the plug-in by invoking the function. Pass the plug-in options as
argument to the function call. The following is the content of vite.config.ts
of a Vite + Svelte project
created with npm create vite@latest
.
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { defineConfig } from 'vite';
import vitePluginSingleSpa from 'vite-plugin-single-spa';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [svelte(), vitePluginSingleSpa({ /* options go here */})],
});
The options passed to the plug-in factory function determine the type of project (root or micro-frontend). For micro-frontend projects, the server port is required, while for root projects the type is required.
Additionally for micro-frontend projects, the file src/spa.ts/js/jsx/tsx
must be created. This file becomes the
main export of the project and should export the single-spa
lifecycle functions.
The single-spa
root project (referred to as root config within the single-spa
documentation) is the project
that loads all other micro-frontends and the one that has the single-spa
package installed, and the one that
typically calls registerApplication()
and start()
. The single-spa
developers advertise as a best practice to
make a root project that uses no framework. In other words, that your root project be devoided of all user interface
elements. This is a view I don't share, and this plug-in is capable of making a suitable Vite + XXX root project. In
the end, the choice is yours.
The plug-in options available to root projects are dictated by the following TypeScript type:
export type SingleSpaRootPluginOptions = {
type: 'root';
importMaps?: {
type?: 'importmap' | 'overridable-importmap' | 'systemjs-importmap' | 'importmap-shim';
dev?: string | string[];
build?: string | string[];
};
imo?: boolean | string | (() => string);
imoUi?: boolean | 'full' | 'popup' | 'list' | {
variant?: boolean | 'full' | 'popup' | 'list';
buttonPos?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
localStorageKey?: string;
};
};
The type
property is mandatory for root projects and must be the string 'root'
, as seen. The other options
pertain to the use of import maps and the import-map-overrides
package. Long story short: Vite, while in
development mode, inserts the script @vite/client
as first child in the HTML page's <head>
element, and this makes
the import maps non-functional, at least for the native importmap
type. The solution: Make this plug-in add both
the import map script and the import-map-overrides
package as first children of the <head>
HTML element. Why did
I tell the story? Just so you know that it is impossible to use import-map-overrides
with Vite "by hand" in serve
mode.
The imo
option is used to control the inclusion of import-map-overrides
. Set it to false
to exclude it; set to
true
to include its latest version from JSDelivr. However, production deployments should never let unknown
versions of packages to be loaded without prior testing, so it really isn't good practice to just say "include the
latest version". Instead, specify the desired package version as a string. The current recommended version of
import-map-overrides
is v3.1.1 (but always check for yourself).
vitePluginSingleSpa({
type: 'root',
imo: '3.1.1'
})
Just like the case of the latest version, this will also use the JSDelivr network.
IMPORTANT: Even if you request
import-map-overrides
to be included, it won't be included if no import maps are present.
If you wish to change the source of the package from JSDelivr to something else, then provide a function that returns the package's URL.
vitePluginSingleSpa({
type: 'root',
imo: () => `https://my.cdn.example.com/[email protected]`
})
The imoUi
property controls the inclusion of the import-map-overrides
user interface. The property's default
value is true
, which is equivalent to full
. The values full
, popup
and list
refer to the type of user
interface. Refer to the package's documentation in case this is confusing.
By default, the user interface will be configured to appear in the bottom right corner and become visible on the
presence of the imo-ui
local storage variable. If any of this is inconvenient, specify the value of imoUi
as an
object with the variant
, buttonPos
and localStorageKey
properties set to your liking.
We finally reach the importMaps
section of the options. Use this section to specify the import map type and file
names. The default behavior is to automatically import maps from the file src/importMap.dev.json
whenever Vite runs
in serve
mode (when you run the project with npm run dev
), or the file src/importMap.json
whenever vite runs in
build
mode (when you run npm run build
). Note, however, that if you have no need to have different import maps,
then you can omit src/importMap.dev.json
and just create src/importMap.json
.
Usually, the development import map would look like this:
{
"imports": {
"@learnSspa/spa01": "http://localhost:4101/src/spa.ts",
"@learnSspa/spa02": "http://localhost:4102/src/spa.ts"
}
}
This is because, while using npm run dev
, no bundling takes place, so we directly reference the module in the /src
folder.
Building the micro-frontend, on the other hand, produces a spa.js
bundle at the root of the dist
folder, so the
building import map would look like this:
{
"imports": {
"@learnSspa/spa01": "http://localhost:4101/spa.js",
"@learnSspa/spa02": "http://localhost:4102/spa.js"
}
}
Of course, that would be if you were building to test locally the build using npm run preview
. Whenever
building for deployment, it will be more like this:
{
"imports": {
"@learnSspa/spa01": "/spa01-prefix/spa.js",
"@learnSspa/spa02": "/spa02-prefix/spa.js"
}
}
The above is a popular Kubernetes deployment option: Set the K8s ingress up so that requests that start with the micro-frontend prefix are routed to the pod that serves that micro-frontend. In the end the final look of the import map is up to you and your deployment setup.
Now, what if you want or need to specify a different file name for your import maps? No problem. Use
importMaps.dev
to specify the serve-time import map file; use importMaps.buid
to specify the build-time import map
file.
As seen in the TypeScript definition, you can specify the type of import map you want. The four choices are the four
possible options for the import-map-overrides
package, and if not specified, it will default to
overridable-importmap
. Once again, I deviate from single-spa
's recommendation of using SystemJS
as the module
import solution. Long story short: Native import maps, except for one bug, seem to work just fine, and I am pro
minimizing package dependencies in projects. Furthermore, if you read the
Vite ecosystem page, you'll see that SystemJS
is recommended
because, and I quote:
since browser support for Import Maps is still pending
This is no longer the case as seen in the caniuse website.
If you're confused about all this import map type thing, read all about this import map topic in the import-map-overrides website.
Since v0.3.0
In single-spa
applications, it is common to need shared modules, and it so happens to be very practical to list them
as import map entries. For example, one could have something like this:
{
"imports": {
"vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js",
"react": "https://cdn.jsdelivr.net/npm/[email protected]/+esm",
"react-dom": "https://cdn.jsdelivr.net/npm/[email protected]/+esm",
"@learnSspa/mifeA": "http://localhost:4101/src/spa.tsx"
}
}
Because those shared entries (vue
, react
, react-dom
) need to be specified for both Vite modes (serve
and
build
), the most practical thing is to have a third import map file that is used in both scenarios. To support this
kind of import map construction, the properties importMaps.dev
and importMaps.build
can also accept an array of
string values to specify multiple file names. If you opt for this option, there is no "default file by omission" and
you must specify all your import map files explicitly.
Create 3 import map JSON files: src/importMap.json
, src/importMap.dev.json
and src/importMap.shared.json
. Now
specify the import map files as an array:
vitePluginSingleSpa({
type: 'root',
importMaps: {
dev: ['src/importMap.dev.json', 'src/importMap.shared.json'],
build: ['src/importMap.json', 'src/importMap.shared.json'],
}
})
This is of course a mere suggestion. Feel free to arrange your import maps any way you feel is best. Two, three or 50 import map files. Create import map files until your heart is content.
A micro-frontend project in single-spa
is referred to as a micro-frontend or a parcel. Micro-frontends are the
ultimate goal: Pieces of user interface living as entirely separate projects.
The plug-in options available to micro-frontend projects are dictated by the following TypeScript type:
export type SingleSpaMifePluginOptions = {
type?: 'mife';
serverPort: number;
spaEntryPoints?: string | string[];
projectId?: string;
assetFileNames?: string;
cssStrategy?: 'singleMife' | 'multiMife' | 'none';
};
The type
property may be omitted, but if specified, it must be the string 'mife'
.
The serverPort
property is relevant to the correct configuration of single-spa
and therefore, it is something this
package must know, or things will become harder to configure between the micro-frontends and the root project.
Specifically, micro-frontend projects are linked to the root project by means of its import map. During development,
the import map will usually point to the micro-frontend's entry file with a full URL similar to
http://localhost:4444/src/spa.ts
, where 4444
is the server's port number. It is very difficult to imagine a
single-spa
project that works with dynamic ports.
Since v0.4.0
The spaEntryPoints
property has a default value of src/spa.ts
and is used to specify all the files that export
single-spa
modules (modules that export bootstrap
, mount
and unmount
). Most of the time, there is only one
such file (the one that exports the micro-frontend), but if the micro-frontend exposes parcels, those are specified
here as well. If you need to specify more than one file, use an array of strings.
The default file name is certainly handy, but it is opinionated: It is a TypeScript file (the plug-in's author's
preference). If your file name differs, even if only by file extension, use spaEntryPoints
to specify it.
Since v0.6.0
The assetFileNames
option works as documented in Rollup's documentation,
with one exception: It can only be a string. Functions may not be passed at this time. If this is something that
people need, open an issue in the project's repository and request it.
projectId
: Since v0.2.0;cssStrategy
: Since v0.4.0
At the bottom we see projectId
and cssStrategy
. These are necessary for CSS mounting and unmounting. In a
single-spa
-enabled application, there will be (potentially) many micro-frontends coming and going in and out of the
application's page. The project ID is used to name the CSS bundles during the micro-frontend building process. Then,
at runtime, this identifier is used to discriminate among all the CSS link elements in the page to properly mount and
unmount them. If not provided, the plug-in will use the project's name from package.json
. Make sure the project
has a name, or provide a value for this property.
NOTE: The
projectId
value will be trimmed to 20 characters if a longer value is found/specified.
The cssStrategy
has to do with what you, the developer, want to do with the exported micro-frontend/parcel. Read
the following section to understand this fully.
Since v0.4.0
IMPORTANT: CSS lifecycle logic has been completely replaced by a new algorithm as of v0.4.0. The previous
cssLifecycle
object is no longer available, and in its stead you can import thecssLifecycleFactory
function.
This plug-in provides an extension module that provides ready-to-use single-spa
lifecycle functions for the bundled
CSS. It is very simple to use. The following is an example of the spa entry file src/spa.tsx
of a Vite + React
project created with npm create vite@latest
:
import React from 'react';
import ReactDOMClient from 'react-dom/client';
// @ts-expect-error
import singleSpaReact from 'single-spa-react';
import App from './App';
import { cssLifecycleFactory } from 'vite-plugin-single-spa/ex';
const lc = singleSpaReact({
React,
ReactDOMClient,
rootComponent: App,
errorBoundary(err: any, _info: any, _props: any) {
return <div>Error: {err}</div>
}
});
// IMPORTANT: Because the file is named spa.tsx, the string 'spa'
// must be passed to the call to cssLifecycleFactory.
const cssLc = cssLifecycleFactory('spa', /* optional factory options */);
export const bootstrap = [cssLc.bootstrap, lc.bootstrap];
export const mount = [cssLc.mount, lc.mount];
export const unmount = [cssLc.unmount, lc.unmount];
The lifecycle factory algorithm needs to know which entry point it should be creating the lifecycle object for, so it is very important that the name passed to the factory coincides exactly with the file name (minus the extension).
The object created by the factory (in the example, stored in the cssLc
variable), must be used for every
exported/created single-spa
lifecycle object that comes out of the same file (module).
NOTE: The optional factory options control the behavior of the FOUC-prevention feature and control over what is logged to the console.
Since v0.7.0
The option named logger
in the options parameter of the cssLifecycleFactory()
function can be used to control what
gets logged to the browser's console from the CSS mounting algorithms. Its default value is true
and means "log to
the console". If set to false
, then nothing is logged to the console.
There is a third possiblity: To provide a custom logger object. The logger only needs to implement the 4 console
methods debug()
, info()
, warn()
and error()
. Use this approach if you need fine-grained control over what
gets logged to the console, or to even do fancier logging, such as posting the console entries to a remote structured
log storage, like a Seq server.
The cssLifecycleFactory
function covers both CSS strategies (singleMife
and multiMife
).
This new CSS mounting algorithm supports multiple SPA entry points, with single or multiple exports per entry point,
and mixing micro-frontends with parcels in the same project should also be possible. If you intend to either mount
multiple instances of the same parcel or micro-frontend, or export more than one single-spa
lifecycle object, you
must set the options' cssStrategy
property to multiMife
.
If, however, your project simply exports a single micro-frontend or a single parcel that doesn't expect to be
instantiated more than once simultaneously, then cssStrategy
can be set to singleMife
, which is the default value
for this property.
Since v0.6.0
The CSS mounting algorithm in this package relies on some naming convention around bundled CSS files. If you plan to
roll out your own CSS mounting algorithm, you may set cssStrategy
to none
. This will effectively deactivate the
CSS bundle renaming that takes place during building. This also deactivates cssLifecycleFactory
.
Currently investigating if usage of
cssLifecycleFactory
can be detected in order to emit a warning.
Since v0.7.0
The object created with the cssLifecycleFactory()
function prevents FOUC (flash of unstyled content) by delaying the
mounting operation until all the CSS files that the plug-in handles (the CSS bundles created by Vite) are loaded and
ready to be used by the browser. This is implemented by subscribing to the LINK element's load
and error
events,
and only returning if the CSS loads, an error occurs, or the specified time elapses (a timeout event).
The following options, which are set when calling cssLifecycleFactory()
, control the behavior of this feature:
export type CssLifecycleFactoryOptions = {
logger?: boolean | ILogger;
loadTimeout?: number;
failOnTimeout?: boolean;
failOnError?: boolean;
};
Refer to Console Logging previously in this document to see about the
logger
option.
Set the loadTimeout
property to the amount of time to wait for the load
or error
events to fire before taking
action. Which action? That depends on the other two properties.
When set to true
, failOnTimeout
throws an error that aborts the micro-frontend mounting process, but when set to
false
, only a warning is logged to the console.
The last property, failOnError
, works identically to failOnTimeout
, except that it applies to the times where the
CSS fetching process fails.
The default values are, respectively, 1500ms, false
and false
.
single-spa
, by default, emits minified error message #31 if the mounting process takes 3000ms or more. Avoid settingloadTimeout
to 3000ms or higher.- The CSS mounting algorithm dismounts CSS by disabling CSS LINK elements, and then, if necessary, these are
re-enabled. Even though a network call is generated in the Network tab of the developer tools, a
load
event is never fired, meaning that FOUC prevention only works on the very first time the CSS is mounted.
While an incredible library, single-spa
was not designed to support the mounting of more than one instance of a
micro-frontend simultaneously, but some of us like this idea. This CSS mounting algorithm pretend to support as much
as possible this scenario, as well as the mounting of multiple instances of the same parcel. However, single-spa
is
not really prepared for this, so do this at your own risk.
Speaking of which, there is a bug in the singleSpaSvelte()
function exported by the single-spa-svelte
NPM package
that prevents exporting the way single-spa
recommends. Detailed information can be found in
this blog post or in the
logged issue at GitHub. The blog post and the issue were
written prior to the existence of vite-plugin-single-spa
v0.4.0, so make the necessary changes in the proposed
workaround.
While I haven't tested every helper for every framework/library, there is the possibility that the bug found in the
single-spa-svelte
package may exist in others, in which case the factory workaround may work for those too.
The new algorithm is robust and seems to work just fine under the conditions set by the singleMife
strategy, but
there is a price to pay: This new algorithm is incompatible with the idea of using single-spa
's unloadApplication
for the purposes of HMR. In order to allow people to keep the possibility of using unloadApplication
, the user can
choose the singleMife
strategy to keep this HMR ability.
Since v0.2.0
The same extension module that provides CSS lifecycle functions also provides basic information about the Vite
environment. Specifically, it exports the viteEnv
object which is described as:
export const viteEnv: {
serving: boolean,
built: boolean,
mode: string
};
The serving
property will be true if the project is being served by Vite in serve
mode. The built
property will
be true if the project is being run after being built. The mode
property is just the mode used when Vite was run,
and by default is the string development
for serve
mode, and production
for builds.
Unlike
cssLifecycleFactory
which is only useful to micro-frontend projects,viteEnv
is available to root projects as well.
To make use of it for whatever purpose, import it:
import { viteEnv } from 'vite-plugin-single-spa/ex';
This plug-in was born as the result of investigating single-spa
, Svelte and the general use of Vite-based
projects. This investigation was documented in the form of a blog
in hashnode. Feel free to read it in order to fully
understand how this plug-in works and the reasons behind its behavior.
- Multiple import map files per Vite command (to support shared dependencies marked
external
in Vite) - Single-SPA parcels
- Multiple
single-spa
entry points - Logging options
- Asset file name pattern
- CSS
load
event to prevent FOUC - Logger object for cssLifecycleFactory to allow full control of what renders in the console
- Input file name pattern?
- Specify import maps as objects instead of file names
- Possibly remove the need for CSS strategies (modify
multiMife
so it can re-bootstrap safely) - CSS
blocking="render"
attribute on injected LINK elements (experimental feature)? Instead ofload
event to prevent FOUC. - Allow media query specification for injected CSS LINK elements? (not sure if this is relevant to this plug-in)
- Option to set development entry point? (there might be a simpler solution)
- SvelteKit support for root projects?