vite-plugin-single-spa
Version:
Vite plugin to convert Vite-based projects to single-spa root or micro-frontend applications.
401 lines (397 loc) • 16.8 kB
JavaScript
import { promises as fs, existsSync } from 'fs';
import { fileURLToPath } from 'url';
import path from 'path';
import { cssHelpersModuleName, extensionModuleName } from './ex-defs.js';
import { closeLog, formatData, markdownCodeBlock, openLog, writeToLog } from './debug.js';
/*
NOTE:
-----
Import map logic mostly taken from vite-plugin-import-maps (https://github.com/pakholeung37/vite-plugin-import-maps).
It's been modified to suit single-spa.
LEGAL NOTICE
------------
vite-plugin-import-maps was under the MIT license at the time this project borrowed from it.
*/
/**
* Determines if the provided configuration options object is for a root project or not.
* @param config Plugin configuration options.
* @returns True if the options are for a root project; false otherwise.
*/
function isRootConfig(config) {
return config.type === 'root';
}
/**
* Factory function that produces the vite-plugin-single-spa plugin factory. Yes, a factory of a factory.
*
* This indirection exists to allow for unit testing.
* @param readFileFn Function used to read files.
* @param fileExistsFn Function used to determine if a particular file name represents an existing file.
* @returns The plug-in factory function.
*/
export function pluginFactory(readFileFn, fileExistsFn) {
const readFile = readFileFn ?? fs.readFile;
const fileExists = fileExistsFn ?? existsSync;
return (config) => {
const lg = config.logging;
if (lg?.chunks || lg?.config || lg?.incomingConfig) {
openLog(lg?.fileName);
}
let configFn = mifeConfig;
let htmlXformFn = () => { return; };
/**
* Set in config() and is used to preserve Vite command information.
*/
let viteEnv;
/**
* Base module path used to locate plug-in files.
*/
const baseModulePath = path.dirname(fileURLToPath(import.meta.url));
/**
* Module file name to use depending on the chosen CSS strategy and Vite command.
*/
let cssModuleFileName;
/**
* Used to cache the built /Ex module.
*/
let exModule;
/**
* Project ID to use when CSS strategy is not set to 'none'.
*/
let projectId;
/**
* Map of CSS files for CSS mounting
*/
const cssMap = {};
/**
* Control variable used just for logging chunks to a log file. When true, the title has already been written.
*/
let chunkInfoTitleWrittenToLog = false;
config.type = config.type ?? 'mife';
if (isRootConfig(config)) {
configFn = undefined;
htmlXformFn = rootIndexTransform;
}
/**
* Builds a full path using the provided file name and this module's file location.
* @param fileName Module file name (just name and extension).
* @returns The full path of the module.
*/
function buildPeerModulePath(fileName) {
return path.resolve(path.join(baseModulePath), fileName);
}
/**
* Builds the Ex dynamic module.
* @returns The finalized contents of the "vite-plugin-single-spa/ex" module.
*/
async function buildExModule() {
return (await readFile(buildPeerModulePath('vite-env.js'), { encoding: 'utf8' }))
.replace("'{serving}'", `${viteEnv.command === 'serve'}`)
.replace("'{built}'", `${viteEnv.command === 'build'}`)
.replace('{mode}', viteEnv.mode)
+ '\n' + (await readFile(buildPeerModulePath(cssModuleFileName), { encoding: 'utf8' }));
}
/**
* Loads the import map files (JSON files) that are pertinent to the occasion.
* @param command Vite command (serve or build).
* @returns An array of string values, where each value is the content of one import map file.
*/
async function loadImportMaps(command) {
const cfg = config;
let fileCfg = command === 'serve' ? cfg.importMaps?.dev : cfg.importMaps?.build;
const defaultFile = fileExists('src/importMap.dev.json') ? 'src/importMap.dev.json' : 'src/importMap.json';
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'
});
return [contents];
}
else {
const fileContents = [];
for (let f of fileCfg) {
const contents = await readFile(f, { encoding: 'utf8' });
fileContents.push(contents);
}
return fileContents;
}
}
/**
* Builds and returns the final import map using as input the provided input maps.
* @param maps Array of import maps that are merged together as a single map.
*/
function buildImportMap(maps) {
const importMap = { imports: {}, scopes: {} };
for (let map of maps) {
for (let key of Object.keys(map.imports)) {
importMap.imports[key] = map.imports[key];
}
if (map.scopes) {
for (let key of Object.keys(map.scopes)) {
importMap.scopes[key] = {
...importMap.scopes[key],
...map.scopes[key]
};
}
}
}
return importMap;
}
/**
* Builds the configuration required for single-spa micro-frontends.
* @param viteOpts Vite options.
* @returns An object with the necessary Vite options for single-spa micro-frontends.
*/
async function mifeConfig(viteOpts) {
const plugInConfig = config;
const cfg = {};
if (!config) {
return cfg;
}
projectId = plugInConfig.projectId ??
(JSON.parse(await readFile('./package.json', { encoding: 'utf8' }))).name;
projectId = projectId.substring(0, 20);
cfg.server = {
port: plugInConfig.serverPort,
origin: `http://localhost:${plugInConfig.serverPort}`
};
cfg.preview = {
port: plugInConfig.serverPort
};
const entryFileNames = '[name].js';
const input = {};
let preserveEntrySignatures;
if (viteOpts.command === 'build') {
let entryPoints = plugInConfig?.spaEntryPoints ?? 'src/spa.ts';
if (typeof entryPoints === 'string') {
entryPoints = [entryPoints];
}
for (let ep of entryPoints) {
input[path.parse(ep).name] = ep;
}
preserveEntrySignatures = 'exports-only';
}
else {
input['index'] = 'index.html';
preserveEntrySignatures = false;
}
const assetFileNames = plugInConfig.assetFileNames ?? 'assets/[name]-[hash][extname]';
const fileInfo = path.parse(assetFileNames);
const cssFileNames = path.join(fileInfo.dir, `vpss(${projectId})${fileInfo.name}`);
cfg.build = {
rollupOptions: {
input,
preserveEntrySignatures,
output: {
exports: 'auto',
assetFileNames: plugInConfig.cssStrategy !== 'none' ? ai => {
if (ai.name?.endsWith('.css')) {
return cssFileNames;
}
return assetFileNames;
} : assetFileNames,
entryFileNames
}
}
};
if (lg?.config) {
await writeToLog('# Plug-In Configuration\n\n');
await writeToLog(markdownCodeBlock(formatData("%o", cfg)));
}
return cfg;
}
/**
* Transforms the HTML file of single-spa root projects by injecting import maps and the import-map-overrides
* script.
* @param html HTML file content in string format.
* @returns An IndexHtmlTransformResult object that includes the necessary transformation in root projects.
*/
async function rootIndexTransform(html) {
const cfg = config;
const importMapContents = await loadImportMaps(viteEnv.command);
let importMap = undefined;
if (importMapContents) {
importMap = buildImportMap(importMapContents.map(t => JSON.parse(t)));
}
const tags = [];
if (importMap) {
tags.push({
tag: 'script',
attrs: {
type: cfg.importMaps?.type ?? 'overridable-importmap',
},
children: JSON.stringify(importMap, null, 2),
injectTo: 'head-prepend',
});
}
if (cfg.imo !== false && importMap) {
let imoVersion = 'latest';
if (typeof cfg.imo === 'string') {
imoVersion = cfg.imo;
}
const imoUrl = typeof cfg.imo === 'function' ? cfg.imo() : `https://cdn.jsdelivr.net/npm/import-map-overrides@${imoVersion}/dist/import-map-overrides.js`;
tags.push({
tag: 'script',
attrs: {
type: 'text/javascript',
src: imoUrl
},
injectTo: 'head-prepend'
});
}
let imoUiCfg = {
buttonPos: 'bottom-right',
localStorageKey: 'imo-ui',
variant: 'full'
};
if (typeof cfg.imoUi === 'object') {
imoUiCfg = {
...imoUiCfg,
...cfg.imoUi
};
}
else if (cfg.imoUi !== undefined) {
imoUiCfg.variant = cfg.imoUi;
}
if (imoUiCfg.variant && importMap) {
imoUiCfg.variant = imoUiCfg.variant === true ? 'full' : imoUiCfg.variant;
let attrs = undefined;
if (imoUiCfg.variant === 'full') {
attrs = {
'trigger-position': imoUiCfg.buttonPos,
'show-when-local-storage': imoUiCfg.localStorageKey
};
}
tags.push({
tag: `import-map-overrides-${imoUiCfg.variant}`,
attrs,
injectTo: 'body'
});
}
return {
html,
tags
};
}
return {
name: 'vite-plugin-single-spa',
async config(cfg, opts) {
viteEnv = opts;
cssModuleFileName = viteEnv.command !== 'build' || config.cssStrategy === 'none' ?
'no-css.js' :
`${config.cssStrategy ?? 'singleMife'}-css.js`;
if (lg?.incomingConfig) {
await writeToLog('# Incoming Configuration\n\n');
await writeToLog(markdownCodeBlock(formatData("%o", cfg)));
}
if (configFn) {
return await configFn(opts);
}
if (viteEnv.command === 'serve') {
await closeLog();
}
return {};
},
resolveId: {
order: 'pre',
handler(source, _importer, _options) {
if (source === extensionModuleName || source === cssHelpersModuleName) {
return source;
}
return null;
}
},
async load(id, _options) {
if (id === extensionModuleName) {
return exModule = exModule ?? (await buildExModule());
}
else if (id === cssHelpersModuleName) {
return await readFile(buildPeerModulePath(id), { encoding: 'utf8' });
}
},
renderChunk: {
order: 'post',
async handler(_code, chunk, options, meta) {
let errorOccurred = false;
// Even if renderChunk is documented as "sequential", it is run in parallel for each chunk.
// This makes log entries mix with each other. Solution: Build the chunk log entry data built
// and then written to the log in one call.
let logData = '';
try {
if (lg?.chunks) {
if (!chunkInfoTitleWrittenToLog) {
chunkInfoTitleWrittenToLog = true;
logData += formatData("# Chunk Information\n");
}
logData += formatData("## %s", chunk.fileName);
logData += markdownCodeBlock(formatData("%o", chunk));
logData += markdownCodeBlock(formatData("options: %o", options));
logData += markdownCodeBlock(formatData("meta: %o", meta));
}
if (chunk.isEntry && !isRootConfig(config) && config.cssStrategy !== 'none') {
// Recursively collect all CSS files that this entry point might need.
const cssFiles = new Set();
const processedImports = new Set();
const collectCssFiles = (curChunk) => {
if (!curChunk) {
return;
}
curChunk.viteMetadata?.importedCss.forEach(css => cssFiles.add(css));
for (let imp of curChunk.imports) {
if (processedImports.has(imp)) {
continue;
}
processedImports.add(imp);
collectCssFiles(meta.chunks[imp]);
}
};
collectCssFiles(chunk);
cssMap[chunk.name] = [];
for (let css of cssFiles.values()) {
cssMap[chunk.name].push(css);
}
}
}
catch (error) {
errorOccurred = true;
throw error;
}
finally {
await writeToLog(logData);
if (errorOccurred) {
await closeLog();
}
}
},
},
async generateBundle(_options, bundle, _isWrite) {
if (viteEnv.command === 'build') {
await closeLog();
}
if (!isRootConfig(config) && config.cssStrategy !== 'none') {
const stringifiedCssMap = JSON.stringify(JSON.stringify(cssMap));
for (let x in bundle) {
const entry = bundle[x];
if (entry.type === 'chunk') {
entry.code = entry.code
?.replace('{vpss:PROJECT_ID}', projectId)
.replace('"{vpss:CSS_MAP}"', stringifiedCssMap);
}
}
}
},
transformIndexHtml: {
order: 'post',
handler(html) {
return htmlXformFn(html);
},
},
};
};
}
;
//# sourceMappingURL=plugin-factory.js.map