├── .babelrc ├── .circleci └── config.yml ├── .eslintrc ├── .gitignore ├── .prettierignore ├── README.md ├── browser └── root-config.js ├── node-loader.config.js ├── package.json ├── server ├── app.js ├── index-html.js ├── server.js ├── static.js └── views │ └── index.html ├── webpack.config.cjs └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"], 3 | "plugins": [ 4 | [ 5 | "@babel/plugin-transform-runtime", 6 | { 7 | "useESModules": true, 8 | "regenerator": false 9 | } 10 | ] 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | setup: 4 | docker: 5 | - image: circleci/node:14.8.0 6 | steps: 7 | - checkout 8 | - restore_cache: 9 | key: dependency-cache-{{ checksum "yarn.lock" }} 10 | - run: 11 | name: Install 12 | command: yarn install --frozen-lockfile 13 | - save_cache: 14 | key: dependency-cache-{{ checksum "yarn.lock" }} 15 | paths: 16 | - node_modules 17 | build: 18 | docker: 19 | - image: circleci/node:14.8.0 20 | steps: 21 | - checkout 22 | - restore_cache: 23 | key: dependency-cache-{{ checksum "yarn.lock" }} 24 | - run: 25 | name: Build 26 | command: | 27 | yarn build 28 | ls dist 29 | - save_cache: 30 | key: build-output-{{ .Environment.CIRCLE_SHA1 }} 31 | paths: 32 | - dist 33 | - store_artifacts: 34 | path: dist 35 | destination: dist 36 | test: 37 | docker: 38 | - image: circleci/node:14.8.0 39 | steps: 40 | - checkout 41 | - restore_cache: 42 | key: dependency-cache-{{ checksum "yarn.lock" }} 43 | - run: 44 | name: Test and Lint 45 | command: yarn lint 46 | deploy: 47 | docker: 48 | - image: google/cloud-sdk 49 | environment: 50 | 51 | steps: 52 | - restore_cache: 53 | key: build-output-{{ .Environment.CIRCLE_SHA1 }} 54 | - run: 55 | name: Deploy 56 | command: | 57 | echo "Going to correct directory" 58 | cd /home/circleci/project/ 59 | ls dist 60 | echo "Creating google application credentials json file" 61 | echo $GOOGLE_APPLICATION_CREDENTIALS_JSON > google-application-credentials.json 62 | echo "Authenticating with Google" 63 | gcloud auth activate-service-account --key-file google-application-credentials.json 64 | echo "Commit sha is $CIRCLE_SHA1. This will be the directory name under $CIRCLE_PROJECT_REPONAME in Cloud Storage." 65 | echo "Uploading module" 66 | gsutil cp -rZ dist gs://$BUCKET_NAME/$CIRCLE_PROJECT_REPONAME/$CIRCLE_SHA1 67 | echo "Updating import map" 68 | echo '{ "service":"@isomorphic-mf/'"$CIRCLE_PROJECT_REPONAME"'","url":"https://'"$CF_PUBLIC_URL"'/'"$CIRCLE_PROJECT_REPONAME"'/'"$CIRCLE_SHA1"'/isomorphic-mf-'"$CIRCLE_PROJECT_REPONAME"'.js" }' 69 | curl -u $DEPLOYER_USERNAME:$DEPLOYER_PASSWORD -d '{ "service":"@isomorphic-mf/'"$CIRCLE_PROJECT_REPONAME"'","url":"https://'"$CF_PUBLIC_URL"'/'"$CIRCLE_PROJECT_REPONAME"'/'"$CIRCLE_SHA1"'/isomorphic-mf-'"$CIRCLE_PROJECT_REPONAME"'.js" }' -X PATCH http://$DEPLOYER_HOST/services\?env=$DEPLOYER_ENV -H "Accept:application/json" -H "Content-Type:application/json" --fail --insecure -i 70 | workflows: 71 | version: 2 72 | build_and_deploy: 73 | jobs: 74 | - setup 75 | - build: 76 | requires: 77 | - setup 78 | - test: 79 | requires: 80 | - setup 81 | - deploy: 82 | context: deploy-context 83 | requires: 84 | - build 85 | - test 86 | filters: 87 | branches: 88 | only: master 89 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["node-important-stuff", "plugin:prettier/recommended"], 3 | "parser": "babel-eslint", 4 | "parserOptions": { 5 | "ecmaVersion": 2020 6 | }, 7 | "rules": { 8 | "node/no-missing-import": "off", 9 | "node/no-unsupported-features/es-syntax": "off", 10 | "node/no-extraneous-import": "off" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | .DS_Store 3 | node_modules -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .prettierignore 3 | yarn.lock 4 | package-lock.json 5 | LICENSE 6 | *.ejs -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Isomorphic Microfrontends root config 2 | 3 | This example shows server-rendered microfrontends, using [single-spa](https://single-spa.js.org/), [single-spa-layout](https://single-spa.js.org/docs/layout-overview), [@node-loader/import-maps](https://github.com/node-loader/node-loader-import-maps), and [@node-loader/http](https://github.com/node-loader/node-loader-http). 4 | 5 | You can read more about how this works at https://single-spa.js.org/docs/ssr-overview. 6 | 7 | The current example references Pokemon APIs for the demo 8 | 9 | # Local Development 10 | 11 | This project requires a NodeJS version that supports the [`--experimental-loader` flag](https://nodejs.org/api/esm.html#esm_experimental_loaders). I'm not sure exactly when it was added, but Node 14 definitely has support for it. 12 | 13 | Additionally, this project may only work properly when the `yarn.lock` file is respected when installing dependencies. To do so, you may [install yarn](https://classic.yarnpkg.com/lang/en/) or use [npm@>=7](https://github.blog/2020-10-13-presenting-v7-0-0-of-the-npm-cli/) 14 | 15 | ```sh 16 | yarn install 17 | yarn develop 18 | open http://localhost:9000 19 | ``` 20 | -------------------------------------------------------------------------------- /browser/root-config.js: -------------------------------------------------------------------------------- 1 | import { registerApplication, start } from "single-spa"; 2 | import { 3 | constructRoutes, 4 | constructApplications, 5 | constructLayoutEngine, 6 | } from "single-spa-layout"; 7 | 8 | const routes = constructRoutes(document.querySelector("#single-spa-layout")); 9 | const applications = constructApplications({ 10 | routes, 11 | loadApp({ name }) { 12 | return System.import(name); 13 | }, 14 | }); 15 | const layoutEngine = constructLayoutEngine({ routes, applications }); 16 | 17 | applications.forEach(registerApplication); 18 | start(); 19 | -------------------------------------------------------------------------------- /node-loader.config.js: -------------------------------------------------------------------------------- 1 | import * as importMapLoader from "@node-loader/import-maps"; 2 | import * as httpLoader from "@node-loader/http"; 3 | 4 | export default { 5 | loaders: [httpLoader, importMapLoader], 6 | }; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "scripts": { 4 | "develop": "cross-env NODE_ENV='development' concurrently -n w: 'yarn:develop:*'", 5 | "develop:node": "nodemon -e js,ejs,html --experimental-loader @node-loader/core server/server.js", 6 | "develop:webpack": "webpack-dev-server --mode=development --port 9876 --env.isLocal=true --config webpack.config.cjs", 7 | "start:node": "node --experimental-loader @node-loader/core server/server.js", 8 | "lint": "eslint browser server", 9 | "test": "jest", 10 | "prettier": "prettier --write './**'", 11 | "build": "webpack --mode=production --config webpack.config.cjs" 12 | }, 13 | "husky": { 14 | "hooks": { 15 | "pre-commit": "pretty-quick --staged && eslint browser server" 16 | } 17 | }, 18 | "devDependencies": { 19 | "@babel/core": "^7.12.3", 20 | "@babel/plugin-transform-runtime": "^7.12.1", 21 | "@babel/preset-env": "^7.12.1", 22 | "@babel/runtime": "^7.12.1", 23 | "@types/systemjs": "^6.1.0", 24 | "babel-eslint": "^11.0.0-beta.2", 25 | "babel-loader": "^8.0.6", 26 | "clean-webpack-plugin": "^3.0.0", 27 | "concurrently": "^5.1.0", 28 | "cross-env": "^7.0.2", 29 | "eslint": "^7.11.0", 30 | "eslint-config-important-stuff": "^1.1.0", 31 | "eslint-config-node-important-stuff": "^1.0.0", 32 | "eslint-config-prettier": "^6.13.0", 33 | "eslint-plugin-prettier": "^3.1.1", 34 | "husky": "^4.2.3", 35 | "jest": "^26.6.0", 36 | "jest-cli": "^26.6.0", 37 | "nodemon": "^2.0.6", 38 | "prettier": "^2.0.2", 39 | "pretty-quick": "^3.1.0", 40 | "serve": "^11.2.0", 41 | "webpack": "^4.41.2", 42 | "webpack-cli": "^3.3.10", 43 | "webpack-dev-server": "^3.9.0" 44 | }, 45 | "dependencies": { 46 | "@node-loader/core": "^1.0.3", 47 | "@node-loader/http": "^1.0.1", 48 | "@node-loader/import-maps": "^1.0.3", 49 | "ejs": "^3.1.3", 50 | "express": "^4.17.1", 51 | "import-map-overrides": "^2.1.0", 52 | "merge2": "^1.4.1", 53 | "morgan": "^1.10.0", 54 | "node-fetch": "^2.6.0", 55 | "parse5": "^6.0.1", 56 | "react": "^16.14.0", 57 | "react-dom": "^16.14.0", 58 | "single-spa": "^5.8.0", 59 | "single-spa-layout": "^1.1.2", 60 | "single-spa-web-server-utils": "^1.17.0" 61 | }, 62 | "engines": { 63 | "node": ">= 14" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /server/app.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | 3 | export const app = express(); 4 | -------------------------------------------------------------------------------- /server/index-html.js: -------------------------------------------------------------------------------- 1 | import { app } from "./app.js"; 2 | import { 3 | constructServerLayout, 4 | sendLayoutHTTPResponse, 5 | } from "single-spa-layout/server"; 6 | import _ from "lodash"; 7 | import { getImportMaps } from "single-spa-web-server-utils"; 8 | 9 | const serverLayout = constructServerLayout({ 10 | filePath: "server/views/index.html", 11 | }); 12 | 13 | app.use("*", (req, res, next) => { 14 | const developmentMode = process.env.NODE_ENV === "development"; 15 | const importSuffix = developmentMode ? `?ts=${Date.now()}` : ""; 16 | 17 | const importMapsPromise = getImportMaps({ 18 | url: 19 | "https://storage.googleapis.com/isomorphic.microfrontends.app/importmap.json", 20 | nodeKeyFilter(importMapKey) { 21 | return importMapKey.startsWith("@isomorphic-mf"); 22 | }, 23 | req, 24 | allowOverrides: true, 25 | }).then(({ nodeImportMap, browserImportMap }) => { 26 | global.nodeLoader.setImportMapPromise(Promise.resolve(nodeImportMap)); 27 | if (developmentMode) { 28 | browserImportMap.imports["@isomorphic-mf/root-config"] = 29 | "http://localhost:9876/isomorphic-mf-root-config.js"; 30 | browserImportMap.imports["@isomorphic-mf/root-config/"] = 31 | "http://localhost:9876/"; 32 | } 33 | return { nodeImportMap, browserImportMap }; 34 | }); 35 | 36 | const props = { 37 | user: { 38 | id: 1, 39 | name: "Test User", 40 | }, 41 | }; 42 | 43 | const fragments = { 44 | importmap: async () => { 45 | const { browserImportMap } = await importMapsPromise; 46 | return ``; 51 | }, 52 | }; 53 | 54 | const renderFragment = (name) => fragments[name](); 55 | 56 | sendLayoutHTTPResponse({ 57 | serverLayout, 58 | urlPath: req.originalUrl, 59 | res, 60 | renderFragment, 61 | async renderApplication({ appName, propsPromise }) { 62 | await importMapsPromise; 63 | const [app, props] = await Promise.all([ 64 | import(appName + `/server.mjs${importSuffix}`), 65 | propsPromise, 66 | ]); 67 | return app.serverRender(props); 68 | }, 69 | async retrieveApplicationHeaders({ appName, propsPromise }) { 70 | await importMapsPromise; 71 | const [app, props] = await Promise.all([ 72 | import(appName + `/server.mjs${importSuffix}`), 73 | propsPromise, 74 | ]); 75 | return app.getResponseHeaders(props); 76 | }, 77 | async retrieveProp(propName) { 78 | return props[propName]; 79 | }, 80 | assembleFinalHeaders(allHeaders) { 81 | return Object.assign({}, Object.values(allHeaders)); 82 | }, 83 | }) 84 | .then(next) 85 | .catch((err) => { 86 | console.error(err); 87 | res.status(500).send("A server error occurred"); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import morgan from "morgan"; 3 | import { app } from "./app.js"; 4 | import "./static.js"; 5 | import "./index-html.js"; 6 | 7 | app.use(morgan("tiny")); 8 | app.set("view engine", "ejs"); 9 | app.set("views", path.resolve(process.cwd(), "./server/views")); 10 | 11 | if (!process.env.PORT) { 12 | console.log(`App is hosted at http://localhost:9000/`); 13 | } 14 | 15 | const port = process.env.PORT || 9000; 16 | app.listen(port); 17 | -------------------------------------------------------------------------------- /server/static.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { app } from "./app.js"; 3 | 4 | app.use(express.static("static")); 5 | -------------------------------------------------------------------------------- /server/views/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Isomorphic Microfrontends 8 | 14 | 15 | 16 | 17 | 18 | 22 | 23 | 24 | 25 | 36 | 37 | 40 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /webpack.config.cjs: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const { CleanWebpackPlugin } = require("clean-webpack-plugin"); 3 | 4 | module.exports = (env) => ({ 5 | entry: path.resolve(__dirname, "browser/root-config"), 6 | output: { 7 | filename: "isomorphic-mf-root-config.js", 8 | libraryTarget: "system", 9 | path: path.resolve(__dirname, "dist"), 10 | }, 11 | devtool: "sourcemap", 12 | module: { 13 | rules: [ 14 | { parser: { system: false } }, 15 | { 16 | test: /\.js$/, 17 | exclude: /node_modules/, 18 | use: [{ loader: "babel-loader" }], 19 | }, 20 | ], 21 | }, 22 | devServer: { 23 | historyApiFallback: true, 24 | headers: { 25 | "Access-Control-Allow-Origin": "*", 26 | }, 27 | disableHostCheck: true, 28 | }, 29 | plugins: [new CleanWebpackPlugin()], 30 | externals: ["single-spa", /^@isomorphic-mf\/.+$/], 31 | }); 32 | --------------------------------------------------------------------------------