# 【NOTE】single-spa
> [name=呂芯萍 Aka] [time=2025 05 27]
> Mail: NT96153@cathaybk.com.tw
:::spoiler 學習歷程
* 2023/11/22
> create-single-spa root-config
* 2023/11/27
> create-single-spa application
* 2024/03/07
> props
* 2024/05/12
> single-spa-angular
* 2024/07/05
> single-spa-react
* 2024/07/18
> import config from deploy
* 2025/02/11
> Module Federation in single-spa
* 2025/05/27
> Angular 18 init error
:::
:::spoiler 參考資料
* 主要
> [single-spa 官網文件](https://single-spa.js.org/docs/)
* 次要
> [single-spa: props](https://single-spa.js.org/docs/layout-definition/#props)
> [lifecycle function bootstrap did not return a promise](https://single-spa.js.org/error/?code=15&arg=application&arg=&arg=bootstrap&arg=1)
> [Single SPA with React](https://mrbytebuster.medium.com/single-spa-with-react-85491b437759)
> [single-spa-login-example-with-npm-packages | Github](https://github.com/jualoppaz/single-spa-login-example-with-npm-packages)
> [single-spa import Module Federation | single-spa](https://single-spa.js.org/docs/recommended-setup/#module-federation)
:::
## 成為 Single-spa 要注意的地方
* Add single-spa
* Change api Interceptor (call apiUrl from config.json)
* Auth call share function
* Add route parent (base url)
* Unsubscribe all oberservable on ngOnDestroy
* Image url call deployUrl from config.json
## 環境建置
### 下載 single-spa CLI
* 執行下列任一項即可
* 全域下載
```npm install --global create-single-spa```
* 該目錄下下載(執行下列任一項即可)
```npm init single-spa```
```npx create-single-spa```
:::danger
* 使用 ```npm init single-spa``` 且 single-spa 6.0.3 建立時,會無法將 Application 連結進 root-config,建議使用 ```npx create-single-spa```
* 2025.05.29 測試
:::
### 建立 root config
* 執行 ```create-single-spa --moduleType root-config```

* 替專案目錄命名
* 選擇使用的套件管理器(建議使用 npm)
* 選擇是否使用 Typescript(建議選用)
* 選擇是否使用 single-spa的 Layout 框架(建議選用)
* 替專案命名(會自動加上尾綴「root-config」)

* 執行 ```npm run start```,至瀏覽器輸入預設網址「[http://localhost:9000/](http://localhost:9000/)」可以看是否有初始成功

### 建立 Application(Angular)
* 執行 create-single-spa
* 方法一:執行 ```create-single-spa --moduleType app-parcel```


* 替專案目錄命名(建議為空,目錄才不會變兩層)
* 選擇使用的前端框架
* 替專案命名
* 其他該框架的詢問(Angular 會詢問是否使用 Routing、使用的 Style、是否下載相關套件)
* 專案 Port(預設 4200)

* 方法二:執行 ```create-single-spa --framework angular```
:::info
* 如出現「Package "single-spa-angular" was found but does not support schematics.」為版本不符
* 如為 angular 14
```ng add single-spa-angular@7```
```npm i```
* [single-spa-angular 對應版本](https://single-spa.js.org/docs/ecosystem-angular/)
:::
* 更改執行指令
```json
// package.json
{
// ...
"scripts": {
// ...
"start": "npm run serve:single-spa:single-spa-demo-main",
// ...
},
}
```
* 執行 ```npm run start```,至瀏覽器輸入預設網址「[http://localhost:4200/](http://localhost:4200/)」可以看是否有初始成功

:::info
* 如建立 Angular14+ 版本,需手動調整成 Standalone 方式
* 確定 AppComponent 為 Standalone
```typescript
@Component({
standalone: true,
// ...
})
export class AppComponent {}
```
* 修改 main.single-spa.ts,使用 bootstrapApplication() 而非 platformBrowserDynamic().bootstrapModule()
```typescript
import { AppComponent } from './app/app.component';
import { bootstrapApplication } from '@angular/platform-browser';
import { routes } from './app/app.routes';
const lifecycles = singleSpaAngular({
bootstrapFunction: () =>
bootstrapApplication(AppComponent, {
providers: [provideRouter(routes)], // if has routes
}),
template: '<app-root />',
NgZone,
});
export const bootstrap = lifecycles.bootstrap;
export const mount = lifecycles.mount;
export const unmount = lifecycles.unmount;
```
* 移除 angular.json 中的 browser
```json
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
//"browser": "src/main.single-spa.ts", // remove
"main": "src/main.single-spa.ts",
// ...
},
```
:::
:::info
* 如出現以下錯誤,執行 ```npm i``` 下載 @angular-builders 等套件

* 如出現以下錯誤,自行添加 environments/environment.ts


* 如出現以下錯誤,於檔案「tsconfig.json」添加「"skipLibCheck": true」


* 如出現以下錯誤,為 angular.json 有多餘項目,移除即可(此範例為多了 browser 項目)
* Error: Schema validation failed with the following errors: Data path "" must NOT have additional properties(browser).
:::
* 連結進 root config
* 加入 importmap
```html
<!-- single-spa-demo-root-config/src/index.ejs -->
<script type="systemjs-importmap">
{
"imports": {
...
"@single-spa-demo/main": "//localhost:4200/main.js"
}
}
</script>
```
* 加入 zone
```html
<!-- single-spa-demo-root-config/src/index.ejs -->
<script src="https://cdn.jsdelivr.net/npm/zone.js@0.11.3/dist/zone.min.js"></script>
```
* 加入 router
```html
<!-- single-spa-demo-root-config/src/microfrontend-layout.html -->
<single-spa-router>
<main>
<route default>
<application name="@single-spa-demo/main"></application>
</route>
</main>
</single-spa-router>
```
:::info
* 如有 APP_BASE_HREF error,加上 APP_BASE_HREF
```javascript
// single-spa-demo-main\src\app\app-routing.module.ts
@NgModule({
//...
providers: [{ provide: APP_BASE_HREF, useValue: '/' }],
})
export class AppRoutingModule { }
```
:::
### 建立 Application(React)
* 匯入套件 single-spa、single-spa-react、react-router-dom
```npm i -D single-spa single-spa-react react-router-dom```
* 重構進入點
* 調整 src/index.tsx
```typescript
import React from "react";
import App from "./App";
import { Provider } from "react-redux";
import { createStore } from "redux";
import rootReducer from "./reducer/root.reducer";
import { BrowserRouter } from "react-router-dom";
import "./index.css";
import "./styles.scss";
const store = createStore(rootReducer);
export default function Root() {
return (
<BrowserRouter basename="/app-react">
<Provider store={store}>
<App />
</Provider>
</BrowserRouter>
);
}
```
* 建立檔案 src/singleSpaEntry.tsx
```javascript
import React from 'react';
import ReactDOM from 'react-dom';
import singleSpaReact from 'single-spa-react';
import Root from './index';
const reactLifecycles = singleSpaReact({
React,
ReactDOM,
rootComponent: Root,
});
export const { bootstrap, mount, unmount } = reactLifecycles;
```
* 調整執行 script
* 匯入套件 craco
```npm i --save-dev @craco/craco```
* 建立檔案 src/craco.config.js
```javascript
const path = require("path");
module.exports = {
webpack: {
configure: (config, { paths }) => {
config.output.libraryTarget = 'umd';
config.output.filename = 'static/js/[name].js';
config.output.library = 'single-spa-demo-app-react';
config.output.publicPath = '/';
config.entry= ['./src/singleSpaEntry.tsx'];
paths.appBuild = config.output.path = path.resolve("dist", "single-spa-demo-app-react"); // 統一輸出成 dist
// 打包 style
const oneOfRule = webpackConfig.module.rules.find(rule => Array.isArray(rule.oneOf)).oneOf;
oneOfRule.forEach(rule => {
if (rule.test && rule.test.toString().includes('scss')) {
rule.use = [
'style-loader',
{
loader: 'css-loader',
options: {
importLoaders: 1,
}
},
'sass-loader'
]
}
})
return config
}
}
}
```
* 更改 script
```json
// package.json
{
"scripts": {
"start": "cross-env PORT=4201 craco start",
"build": "craco build",
// ...
}
}
```
* 執行 ```npm run start```,至瀏覽器輸入網址「[http://localhost:4201/static/js/main.js](http://localhost:4201/static/js/main.js)」可以看是否有初始成功
* 連結進 root config
* 加入 importmap
```html
<!-- single-spa-demo-root-config/src/index.ejs -->
<script type="systemjs-importmap">
{
"imports": {
...
"@single-spa-demo/app-react": "//localhost:4201/static/js/main.js"
}
}
</script>
```
* 加入 zone
```html
<!-- single-spa-demo-root-config/src/index.ejs -->
<script src="https://cdn.jsdelivr.net/npm/zone.js@0.11.3/dist/zone.min.js"></script>
```
* 加入 router
```html
<!-- single-spa-demo-root-config/src/microfrontend-layout.html -->
<single-spa-router>
<main>
<route path="app-react">
<application name="@single-spa-demo/app-react"></application>
</route>
</main>
</single-spa-router>
```
* 至瀏覽器輸入網址「[http://localhost:9000/app-react](http://localhost:9000/app-react)」可以看是否有初始成功
### 建立 in-browser utility module
* 執行 init single-spa
```npm init single-spa```
* 替專案目錄命名
* 選擇使用 in-browser utility module
* 使用框架選擇(建議使用 none)
* 套件管理器選擇(建議使用 npm)
* 是否使用 Typescript(建議使用)
* 使用打包方式選擇(建議使用 esm)
* 主專案名稱(single-spa-demo)
* 子專案名稱(auth)
* 設定 port=9001
```json
// pakage.json
{
// ...
"scripts": {
// ...
"start": "webpack serve --port=9001",
}
}
```
* 執行 ```npm run start```,至瀏覽器輸入預設網址「[http://localhost:9001/single-spa-demo-auth.js](http://localhost:9001/single-spa-demo-auth.js)」可以看是否有初始成功
* 連結進 root config
* 加入 importmap
```html
<!-- single-spa-demo-root-config/src/index.ejs -->
<script type="systemjs-importmap">
{
"imports": {
...
"@single-spa-demo/auth": "//localhost:9001/single-spa-demo-auth.js"
}
}
</script>
```
* Application Import (Angular)
* 新增 declarations.d.ts
```javascript
declare module '@single-spa-demo/auth';
```
* 設定 import
```javascript
// extra-webpack.config.js
module.exports = (config, options) => {
const singleSpaWebpackConfig = singleSpaAngularWebpack(config, options);
singleSpaWebpackConfig.externals = [/^@single-spa-demo\/auth$/];
return singleSpaWebpackConfig;
};
```
* 測試 Export/Import
```javascript
// single-spa-demo-auth\src\single-spa-demo-auth.ts
export const SS_DEMO_USERINFO_KEY = "SS-DEMO-USER-INFO";
```
```javascript
// single-spa-demo-main\src\app\app.componet.ts
import { SS_DEMO_USERINFO_KEY } from '@single-spa-demo/auth';
console.log(SS_DEMO_USERINFO_KEY);
```
* Application Import (React)
* 新增 declarations.d.ts
```javascript
declare module '@single-spa-demo/auth';
```
* 設定 import
```javascript
// craco.config.js
module.exports = {
webpack: {
configure: (config) => {
// ...
config.externals= ["@single-spa-demo/auth"];
return config;
},
},
```
* 測試 Export/Import
```javascript
// single-spa-demo-auth\src\single-spa-demo-auth.ts
export const SS_DEMO_USERINFO_KEY = "SS-DEMO-USER-INFO";
```
```javascript
// App.tsx
import { SS_DEMO_USERINFO_KEY } from '@single-spa-demo/auth';
console.log(SS_DEMO_USERINFO_KEY);
```
## Import config.json/importmap.json from deploy
### Import and Export importmap.json (root config、in-browser utility module)
* root config
* 建立 importmap.json
```json
// src/assets/config/importmap.json
{
"imports": {
"@single-spa-demo/root-config": "/root-config.bundle.js",
"@single-spa-demo/auth": "//localhost:9001/single-spa-demo-auth.js",
"@single-spa-demo/main": "//localhost:4200/main.js",
"@single-spa-demo/app-react": "//localhost:4201/static/js/main.js"
}
}
```
* 匯入檔案
```html
<!--index.ejs-->
<script type="systemjs-importmap" src="/assets/config/importmap.json"></script>
```
* in-browser utility module (export deployUrls_init$ and deployUrls$)
* 建立 config.ts
```typescript
import { BehaviorSubject, Subject, Observable } from "rxjs";
const deployUrlsBehaviorSubject$ = new BehaviorSubject<any[]>(undefined);
const deployUrlsSubject$ = new Subject<any[]>();
export const deployUrls_init$: Observable<any[]> = deployUrlsBehaviorSubject$.asObservable();
export const deployUrls$: Observable<any[]> = deployUrlsSubject$.asObservable(); // for browser reload
export function loadDeployUrls() {
return fetch(`${window.location.origin}/assets/config/importmap.json`)
.then(async (response) => {
if (!response.ok) {
throw {
status: response.status,
body: await response.json(),
};
}
return await response.json();
})
.then((importmap) => {
let deployUrls;
Object.keys(importmap.imports).forEach((key) => {
const url = importmap.imports[key];
const a = document.createElement("a");
a.href = url;
deployUrls = {
...deployUrls,
[key]: a.origin,
};
});
deployUrlsBehaviorSubject$.next(deployUrls);
deployUrlsSubject$.next(deployUrls);
});
}
```
* 呼叫方法
```typescript
// single-spa-demo-auth.ts
import * as config from "./config";
export const DEMO_CONFIG_SERVICE = config; // 匯出給其他 Application 使用
DEMO_CONFIG_SERVICE.loadDeployUrls(); // 讀取所有系統網址
```
### Import config.json (Application)
* Angular
* 新增 declarations.d.ts
```javascript
declare module '@single-spa-demo/auth';
```
* 設定 import
```javascript
// extra-webpack.config.js
module.exports = (config, options) => {
const singleSpaWebpackConfig = singleSpaAngularWebpack(config, options);
singleSpaWebpackConfig.externals = [/^@single-spa-demo\/auth$/];
return singleSpaWebpackConfig;
};
```
* 建立 config.json
```json
// src\assets\config\config.json
{
"apiUrl": "...",
"deployUrl": "..."
}
```
* 建立 app-config.service.ts
```typescript
// src\app\core\services\app-config.service.ts
import { Injectable } from '@angular/core';
import { DEMO_CONFIG_SERVICE } from '@single-spa-demo/auth';
import { Observable, of } from 'rxjs';
import { first, switchMap } from 'rxjs/operators';
@Injectable({
providedIn: 'root',
})
export class AppConfigService {
config: any;
constructor() {}
load(): Observable<any> {
return DEMO_CONFIG_SERVICE.deployUrls_init$.pipe(
first(),
switchMap((deployUrls: any) => {
if (!deployUrls) {
// for browser reload
return DEMO_CONFIG_SERVICE.deployUrls$.pipe(first());
}
return of(deployUrls);
}),
switchMap((deployUrls: any) => {
return fetch(`${deployUrls['@single-spa-demo/main']}/assets/config/config.json`)
.then((res) => res.json())
.then((data) => {
this.config = data;
});
})
);
}
}
```
* 設定 APP_INITIALIZER
```typescript
// src\app\app.module.ts
import { APP_INITIALIZER, NgModule } from '@angular/core';
import { AppConfigService } from './core/services/app-config.service';
export function initializeApp(AppConfigService: AppConfigService) {
return () => AppConfigService.load();
}
@NgModule({
declarations: [
//...
],
imports: [
//...
],
providers: [
{
provide: APP_INITIALIZER,
useFactory: initializeApp,
deps: [AppConfigService],
multi: true,
},
//...
],
bootstrap: [AppComponent],
})
export class AppModule {}
```
* 加入方法 assetUrl 取代讀取圖片路徑
* 建立 Service
```typescript
// src\app\core\services\image.service.ts
import { Injectable } from '@angular/core';
import { AppConfigService } from '../services/app-config.service';
import { DEMO_ICON } from '../constants/icon';
@Injectable({
providedIn: 'root',
})
export class ImageService {
demoIcon = this.assetUrl(DEMO_ICON);
constructor(private appConfigService: AppConfigService) {}
assetUrl(url: string): string {
const publicPath =
// @ts-ignore
this.appConfigService.config.deployUrl || __webpack_public_path__;
const publicPathSuffix = publicPath.endsWith('/') ? '' : '/';
const urlPrefix = url.startsWith('/') ? '' : '/';
return `${publicPath}${publicPathSuffix}assets${urlPrefix}${url}`;
}
}
```
* 圖片路徑皆需經過 assetUrl 方法
```typescript
// example
import { ImageService } from 'src/app/core/services/image.service';
constructor(private imageService: ImageService) {}
demoIcon = this.imageService.demoIcon;
```
* React
* 加入全域變數 config、方法 assetUrl 取代讀取圖片路徑
* 建立檔案 src/assets/config/config.json
```json
{
"deployUrl": "http://localhost:4201/"
}
```
* 建立檔案 src/single-spa/config-service.ts
```typescript
import { DEMO_CONFIG_SERVICE } from "@single-spa-demo/auth";
import { of } from "rxjs";
import { first, switchMap } from "rxjs/operators";
const config = {}
export default config;
// 讀取 ConfigMap 檔
export async function load() {
return await DEMO_CONFIG_SERVICE.deployUrls_init$
.pipe(
first(),
switchMap((deployUrls: any) => {
if (!deployUrls) {
// for browser reload
return DEMO_CONFIG_SERVICE.deployUrls$.pipe(first());
}
return of(deployUrls);
}),
switchMap((deployUrls: any) => {
return fetch(
`${deployUrls[`@single-spa-demo/app-react`]}/assets/config/config.json`
)
.then((res) => res.json())
.then((data) => {
config = data;
});
})
)
.subscribe();
}
// 使用 config 中路徑讀取圖片
export function assetUrl(url: string): string {
// @ts-ignore
const publicPath = config.deployUrl || __webpack_public_path__;
const publicPathSuffix = publicPath.endsWith('/') ? '' : '/';
const urlPrefix = url.startsWith('/') ? '' : '/';
return `${publicPath}${publicPathSuffix}assets${urlPrefix}${url}`;
}
```
* 進入點需呼叫讀取 config 檔
```typescript
// index.tsx
import { load as loadConfig } from './single-spa/config-service';
function Root() {
// 確認 config fetch 完成後再渲染 App.tsx
const [doRen, setDoRen] = useState<boolean>(false);
useEffect(() => {
let doCon = loadConfig().subscribe({
next: () => {
if(!!config){setDoRen(true);}
}
});
return () =>{
setDoRen(false);
doCon.unsubscribe();
}
}, [])
// ...
{doRen ? <App /> : null}
// ...
}
```
* 取代讀取圖片路徑
* 圖片路徑皆需經過 assetUrl 方法
```typescript
// example
import NoticeImg from './assets/images/Img_Notice_Horizontal.svg';
import { assetUrl } from './single-spa/config-service';
function Test() {
return (
<div>
<img src={assetUrl(NoticeImg)} />
</div>
);
}
```
## Import Local Folders
* 安裝套件 [copy-webpack-plugin](https://www.npmjs.com/package/copy-webpack-plugin)、[clean-webpack-plugin](https://www.npmjs.com/package/clean-webpack-plugin)
```npm install copy-webpack-plugin --save-dev```
```npm install clean-webpack-plugin --save-dev```
* 設置 patterns
```javascript
plugins: [
//...
new CopyPlugin({
patterns: [
{
from: "assets/js",
to: "assets/js",
},
{
from: "assets",
to: "assets",
},
],
}),
new CleanWebpackPlugin({
verbose: true,
}),
],
```
## Import packge styles
* 一般
```css
// style.css
@import "node_modules/<package>";
```
* 省略 node_modules
```css
// src/style.css
@import "<package>";
```
```json
// angular.json
"build": {
//...
"options": {
"stylePreprocessorOptions": {
"includePaths": ["node_modules"]
},
//...
}
}
```
## Use one style file when builded
* 安裝套件 [mini-css-extract-plugin](https://awdr74100.github.io/2020-03-02-webpack-minicssextractplugin/)
```npm install mini-css-extract-plugin --save-dev```
* 設置 rules
```javascript
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
module.exports = (webpackConfigEnv, argv) => {
//...
return merge(defaultConfig, {
plugins: [
new MiniCssExtractPlugin(),
//...
],
module: {
rules: [
{
test: /\.s[ac]ss$/i,
use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"],
},
],
},
});
};
```
* Import main.css
```html
<!--index.ejs-->
<head>
<!--...-->
<link rel="stylesheet" href="/main.css">
<!--...-->
</head>
```
## Change output file name in webpack
* webpack 預設 build 完的靜態檔案(圖片、字型檔)檔名為 hash 值
* 方法一:webpack
* 更改檔案檔名
```javascript
module.exports = (webpackConfigEnv, argv) => {
// ...
return merge(defaultConfig, {
// ...
output: {
// 入口檔案檔名
filename: "[name].js",
// 靜態檔案檔名
assetModuleFilename: "[path][name][ext]",
},
});
};
```
* 方法二:使用套件 craco(React)
* 匯入套件 craco
```npm i --save-dev @craco/craco```
* 建立檔案 src/craco.config.js
```javascript
module.exports = {
webpack: {
configure: (config) => {
config.output.libraryTarget = 'umd';
config.output.filename = 'static/js/[name].js';
config.output.library = 'single-spa-demo-app-react';
config.output.publicPath = '/';
return config
}
}
}
```
* 更改 script
```json
// package.json
{
"scripts": {
"start": "cross-env PORT=4201 craco start",
"build": "craco build",
// ...
}
}
```
## Prop
* defind

* use

* get
* not work...
## Import Module Federation
> [【NOTE】Module Federation](/-LHmfJBrTqi3H1hgBN-WbQ)
### Application(Angular)
* 使用 webpackMerge,修改檔案 extra-webpack.config.ts
```typescript
const singleSpaAngularWebpack =
require("single-spa-angular/lib/webpack").default;
const webpackMerge = require("webpack-merge");
const { ModuleFederationPlugin } = require("webpack").container;
const { shareAll } = require("@angular-architects/module-federation/webpack");
module.exports = (config, options) => {
const singleSpaWebpackConfig = singleSpaAngularWebpack(config, options);
return webpackMerge.default(singleSpaWebpackConfig, {
plugins: [
new ModuleFederationPlugin({
name: "angular-app-name",
filename: "remoteEntry.js",
remotes: {
modal: "layout@http://localhost:4201/remoteEntry.js",
},
shared: {
...shareAll({
singleton: true,
strictVersion: true,
requiredVersion: "auto",
eager: true, // 如 angular 版本太舊,需開啟預先載入
}),
},
}),
],
});
};
```
:::info
* References for 'eager'
* [Webpack Troubleshooting: Shared module is not available for eager consumption](https://webpack.js.org/concepts/module-federation/#troubleshooting)
* [babel-loader version is too old](https://alexocallaghan.com/module-not-available-for-eager-consumption)
* 疑難排解可參考 [【NOTE】Module Federation - 疑難排解](/-LHmfJBrTqi3H1hgBN-WbQ#疑難排解)
:::
* 需使用動態載入,於欲使用 Module Federation 的元件中加入
```html
<div #header></div>
```
```typescript
import { loadRemoteModule } from '@angular-architects/module-federation';
@ViewChild('header', { read: ViewContainerRef })
viewContainer!: ViewContainerRef;
async loadHeader() {
const m = await loadRemoteModule({
type: 'module',
remoteEntry: 'http://localhost:4201/remoteEntry.js',
exposedModule: './Component/Header'
});
const ref = this.viewContainer.createComponent(m.HeaderComponent);
// const compInstance = ref.instance;
// compInstance.ngOnInit()
}
```