From 633f8b8cfb2a86133cec66de6f2497efaa926324 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ramirez=20Vargas=2C=20Jos=C3=A9=20Pablo?= Date: Mon, 16 Oct 2023 23:28:59 -0600 Subject: [PATCH] feat: Multiple import maps --- README.md | 74 +++++++++++++++++++++++++------ src/package-lock.json | 4 +- src/package.json | 2 +- src/plugin-factory.ts | 73 +++++++++++++++++------------- src/vite-plugin-single-spa.d.ts | 10 ++--- tests/plugin-factory.test.ts | 78 ++++++++++++++++++++++++++++++--- 6 files changed, 184 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 7b60b7e..84da72e 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,9 @@ 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 ci` 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). +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: @@ -48,8 +48,8 @@ export default defineConfig({ 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 (or js, jsx, tsx)` must be created. This file becomes -the main export of the project and should export the `single-spa` lifecycle functions. +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. ## single-spa Root Projects @@ -69,8 +69,8 @@ export type SingleSpaRootPluginOptions = { type: 'root'; importMaps?: { type?: 'importmap' | 'overridable-importmap' | 'systemjs-importmap' | 'importmap-shim'; - dev?: string; - build?: string; + dev?: string | string[]; + build?: string | string[]; }; imo?: boolean | string | (() => string); imoUi?: boolean | 'full' | 'popup' | 'list' | { @@ -88,10 +88,10 @@ the import maps non-functional, at least for the native `importmap` type. The s the import map script and the `import-map-overrides` package as first children of the `` HTML element. 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 the **JSDelivr**. However, production deployments should never let unknown +`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.0**. +`import-map-overrides` is **v3.1.0** (but always check for yourself). ```typescript vitePluginSingleSpa({ @@ -110,7 +110,7 @@ returns the package's URL. ```typescript vitePluginSingleSpa({ -type: 'root', + type: 'root', imo: () => `https://my.cdn.example.com/import-map-overrides@3.1.0` }) ``` @@ -123,8 +123,8 @@ By default, the user interface will be configured to appear in the bottom right 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 file names and the import map -type. The default behavior is to automatically import maps from the file `src/importMap.dev.json` whenever Vite runs +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`. @@ -190,9 +190,49 @@ This is no longer the case as seen in the [caniuse](https://caniuse.com/?search= If you're confused about all this import map type thing, read all about this import map topic in the [import-map-overrides](https://github.com/single-spa/import-map-overrides) website. +#### Using More Import Map Files + +> 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: + +```json +{ + "imports": { + "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js", + "react": "https://cdn.jsdelivr.net/npm/react@18.2.0/+esm", + "react-dom": "https://cdn.jsdelivr.net/npm/react-dom@18.2.0/+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: + +```typescript +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. + ## single-spa Micro-Frontend Projects -A micro-frontend project in `single-spa` is referred as a micro-frontend or a *parcel*. Micro-frontends are the +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. ### Micro-Frontend Project Options @@ -223,6 +263,8 @@ the `single-spa`'s lifecycle functions (`bootstrap`, `mount` and `unmount`). If differs, use this property to specify it. Note that if your project is using a different file extension, you'll have to specify this property just to change the file extension. +> Since **v0.2.0** + At the bottom we see `projectId`. This is necessary for CSS tracking. 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 @@ -234,6 +276,8 @@ this property. ## Mounting and Unmounting CSS +> Since **v0.2.0** + Vite comes with magic that inserts a micro-frontend's CSS in the root project's index page when it is mounted. One more thing to love about Vite, for sure. However, this is lost when the project is built. @@ -269,6 +313,8 @@ React. ## Vite Environment Information +> Since **v0.2.0** + The same extension module that provides CSS lifecycle functions also provides basic information about the Vite environment. Especifically, it exports the `viteEnv` object which is described as: @@ -302,7 +348,7 @@ understand how this plug-in works and the reasons behind its behavior. ## Roadmap +- [x] Multiple import map files per mode (to support shared dependencies marked `external` in Vite) - [ ] Multiple `single-spa` entry points - [ ] Option to set development entry point -- [ ] Multiple import map files per mode (to support shared dependencies marked `external` in Vite) - [ ] SvelteKit? diff --git a/src/package-lock.json b/src/package-lock.json index 6e32f78..136070e 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "vite-plugin-single-spa", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "vite-plugin-single-spa", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "peerDependencies": { "single-spa": "^5.9.5", diff --git a/src/package.json b/src/package.json index 8673fc6..1c64d3d 100644 --- a/src/package.json +++ b/src/package.json @@ -1,6 +1,6 @@ { "name": "vite-plugin-single-spa", - "version": "0.2.0", + "version": "0.3.0", "description": "Vite plugin to convert Vite-based projects to single-spa root or micro-frontend applications.", "type": "module", "main": "index.js", diff --git a/src/plugin-factory.ts b/src/plugin-factory.ts index 74a8049..e14eea9 100644 --- a/src/plugin-factory.ts +++ b/src/plugin-factory.ts @@ -77,22 +77,34 @@ export function pluginFactory(readFileFn?: (path: string, options: any) => Promi } /** - * Loads the import map file (JSON files) that is pertinent to the occasion. + * Loads the import map files (JSON files) that are pertinent to the occasion. * @param command Vite command (serve or build). - * @returns A promise that resolves with the file's text content; if the file doesn't exist then null is returned. + * @returns An array of string values, where each value is the content of one import map file. */ - function loadImportMap(command: string) { + async function loadImportMaps(command: ConfigEnv['command']) { const cfg = config as SingleSpaRootPluginOptions; + let fileCfg = command === 'serve' ? cfg.importMaps?.dev : cfg.importMaps?.build; const defaultFile = fileExists('src/importMap.dev.json') ? 'src/importMap.dev.json' : 'src/importMap.json'; - const mapFile = command === 'serve' ? - (cfg.importMaps?.dev ?? defaultFile) : - (cfg.importMaps?.build ?? 'src/importMap.json'); - if (!fileExists(mapFile)) { - return null; + if (fileCfg === undefined || typeof fileCfg === 'string') { + const mapFile = command === 'serve' ? + (fileCfg ?? defaultFile) : + (fileCfg ?? 'src/importMap.json'); + if (!fileExists(mapFile)) { + return null; + } + const contents = await readFile(mapFile, { + encoding: 'utf8' + }) as string; + return [contents]; + } + else { + const fileContents: string[] = []; + for (let f of fileCfg) { + const contents = await readFile(f, { encoding: 'utf8' }) as string; + fileContents.push(contents); + } + return fileContents; } - return readFile(mapFile, { - encoding: 'utf8' - }); } /** @@ -104,22 +116,23 @@ export function pluginFactory(readFileFn?: (path: string, options: any) => Promi { imports: {}, scopes: {} }, ...maps, ); - return { - imports: { - ...oriImportMap.imports, - ...Object.keys(oriImportMap.imports).reduce( - (acc, imp) => ({ - ...acc, - // [`${prefix}${imp}`]: oriImportMap.imports[imp], - [`${imp}`]: oriImportMap.imports[imp], - }), - {}, - ), - }, - scopes: { - ...oriImportMap.scopes, - }, - }; + return oriImportMap; + // return { + // imports: { + // ...oriImportMap.imports, + // ...Object.keys(oriImportMap.imports).reduce( + // (acc, imp) => ({ + // ...acc, + // // [`${prefix}${imp}`]: oriImportMap.imports[imp], + // [`${imp}`]: oriImportMap.imports[imp], + // }), + // {}, + // ), + // }, + // scopes: { + // ...oriImportMap.scopes, + // }, + // }; } /** @@ -182,10 +195,10 @@ export function pluginFactory(readFileFn?: (path: string, options: any) => Promi */ async function rootIndexTransform(html: string) { const cfg = config as SingleSpaRootPluginOptions; - const importMapText = await loadImportMap(viteEnv.command) as string; + const importMapContents = await loadImportMaps(viteEnv.command); let importMap: Required | undefined = undefined; - if (importMapText) { - importMap = buildImportMap([JSON.parse(importMapText)]); + if (importMapContents) { + importMap = buildImportMap(importMapContents.map(t => JSON.parse(t))); } const tags: HtmlTagDescriptor[] = []; if (importMap) { diff --git a/src/vite-plugin-single-spa.d.ts b/src/vite-plugin-single-spa.d.ts index 3d116b2..0cd3a6d 100644 --- a/src/vite-plugin-single-spa.d.ts +++ b/src/vite-plugin-single-spa.d.ts @@ -12,7 +12,7 @@ declare module "vite-plugin-single-spa" { */ export type ImportMap = { imports?: Record; - scopes?: Record; + scopes?: Record>; }; /** @@ -50,13 +50,13 @@ declare module "vite-plugin-single-spa" { */ type?: 'importmap' | 'overridable-importmap' | 'systemjs-importmap' | 'importmap-shim'; /** - * File name of the import map to be used while developing. + * File name or array of file names of the import map or maps to be used while developing. */ - dev?: string; + dev?: string | string[]; /** - * File name of the import map to be used while building. + * File name or array of file names of the import map or maps to be used while building. */ - build?: string; + build?: string | string[]; }; /** diff --git a/tests/plugin-factory.test.ts b/tests/plugin-factory.test.ts index 9422464..dee80ca 100644 --- a/tests/plugin-factory.test.ts +++ b/tests/plugin-factory.test.ts @@ -3,7 +3,7 @@ import { expect } from 'chai'; import { describe, it } from 'mocha'; import { pluginFactory } from '../src/plugin-factory.js'; -import type { SingleSpaRootPluginOptions, SingleSpaMifePluginOptions, ImportMapsOption, ImoUiVariant, ImoUiOption } from "vite-plugin-single-spa"; +import type { SingleSpaRootPluginOptions, SingleSpaMifePluginOptions, ImportMapsOption, ImoUiVariant, ImoUiOption, ImportMap } from "vite-plugin-single-spa"; import type { ConfigEnv, HtmlTagDescriptor, IndexHtmlTransformHook, UserConfig } from 'vite'; import type { PreserveEntrySignaturesOption, OutputOptions } from 'rollup'; import { extensionModuleName } from '../src/ex-defs.js'; @@ -643,7 +643,7 @@ describe('vite-plugin-single-spa', () => { for (let tc of defaultImportMapTestData) { it(`Should pick the contents of the default file "${tc.fileName}" if the file exists on ${tc.viteCmd} as the contents of the import map script.`, () => defaultImportMapTest(tc.fileName, tc.viteCmd)); } - const importMapTest = async (propertyName: string, viteCmd: ConfigEnv['command']) => { + const importMapTest = async (propertyName: Exclude, viteCmd: ConfigEnv['command']) => { // Arrange. const fileName = 'customImportMap.json'; const fileExists = (x: string) => x === fileName; @@ -667,8 +667,7 @@ describe('vite-plugin-single-spa', () => { return Promise.resolve(JSON.stringify(importMap)); } const pluginOptions: SingleSpaRootPluginOptions = { type: 'root', importMaps: {} }; - // @ts-ignore - pluginOptions.importMaps[propertyName] = fileName; + pluginOptions.importMaps![propertyName] = fileName; const plugin = pluginFactory(readFile, fileExists)(pluginOptions); const env: ConfigEnv = { command: viteCmd, mode: 'development' }; await (plugin.config as ConfigHandler)({}, env); @@ -693,7 +692,7 @@ describe('vite-plugin-single-spa', () => { throw new Error('TypeScript narrowing suddenly routed the test elsewhere!'); } }; - const importMapTestData: { propertyName: string, viteCmd: ConfigEnv['command'] }[] = [ + const importMapTestData: { propertyName: Exclude, viteCmd: ConfigEnv['command'] }[] = [ { propertyName: 'dev', viteCmd: 'serve' @@ -706,6 +705,75 @@ describe('vite-plugin-single-spa', () => { for (let tc of importMapTestData) { it(`Should pick the contents of the specified file in the "importMaps.${tc.propertyName}" configuration property on ${tc.viteCmd}.`, () => importMapTest(tc.propertyName, tc.viteCmd)); } + const importMapTestMultiple = async (propertyName: Exclude, viteCmd: ConfigEnv['command']) => { + // Arrange. + const fileNames = ['A.json', 'B.json']; + const fileExists = (x: string) => fileNames.includes(x); + const importMapA = { + imports: { + '@a/b': 'cd' + }, + scopes: { + pickyModule: { + '@a/b': 'ef' + } + } + }; + const importMapB = { + imports: { + '@b/c': 'de' + }, + scopes: { + pickyModule: { + '@b/c': 'fg' + } + } + }; + const importMaps: Record = { + 'A.json': importMapA, + 'B.json': importMapB + }; + let fileRead: Record = {}; + let fileReadCount = 0; + const readFile = (x: string, _opts: any) => { + if (fileNames.includes(x)) { + fileRead[x] = true; + } + ++fileReadCount; + return Promise.resolve(JSON.stringify(importMaps[x])); + } + const pluginOptions: SingleSpaRootPluginOptions = { type: 'root', importMaps: {} }; + pluginOptions.importMaps![propertyName] = fileNames; + const plugin = pluginFactory(readFile, fileExists)(pluginOptions); + const env: ConfigEnv = { command: viteCmd, mode: 'development' }; + await (plugin.config as ConfigHandler)({}, env); + const ctx = { path: '', filename: '' }; + + // Act. + const xForm = await (plugin.transformIndexHtml as { order: any, handler: IndexHtmlTransformHook }).handler('', ctx); + + // Assert. + expect(Object.keys(fileRead).length).to.equal(2); + expect(fileReadCount).to.equal(2); + expect(xForm).to.not.equal(null); + expect(xForm).to.not.equal(undefined); + if (xForm && typeof xForm !== 'string' && !Array.isArray(xForm)) { + const firstTag = xForm.tags[0]; + expect(firstTag).to.not.equal(undefined); + expect(firstTag.tag).to.equal('script'); + const parsedImportMap = JSON.parse(firstTag.children as string); + expect(parsedImportMap).to.be.deep.equal({ + ...importMapA, + ...importMapB + }); + } + else { + throw new Error('TypeScript narrowing suddenly routed the test elsewhere!'); + } + }; + for (let tc of importMapTestData) { + it(`Should pick the contents of all import maps specified in the "importMaps.${tc.propertyName}" configuration property on ${tc.viteCmd}.`, () => importMapTestMultiple(tc.propertyName, tc.viteCmd)); + } const importMapTypeTest = async (importMapType: ImportMapsOption['type'], viteCmd: ConfigEnv['command']) => { const fileExists = (_x: string) => true; const importMap = {