@esmx/core
Version:
A high-performance microfrontend framework supporting Vue, React, Preact, Solid, and Svelte with SSR and Module Linking capabilities.
908 lines (907 loc) • 29.1 kB
JavaScript
import crypto from "node:crypto";
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { cwd } from "node:process";
import { pathToFileURL } from "node:url";
import serialize from "serialize-javascript";
import { createApp } from "./app.mjs";
import { getManifestList } from "./manifest-json.mjs";
import {
parseModuleConfig
} from "./module-config.mjs";
import {
parsePackConfig
} from "./pack-config.mjs";
import { createCache } from "./utils/cache.mjs";
import { generateSizeReport } from "./utils/file-size-stats.mjs";
import { createClientImportMap, createImportMap } from "./utils/import-map.mjs";
import { resolvePath } from "./utils/resolve-path.mjs";
import { getImportPreloadInfo as getStaticImportPaths } from "./utils/static-import-lexer.mjs";
export var COMMAND = /* @__PURE__ */ ((COMMAND2) => {
COMMAND2["dev"] = "dev";
COMMAND2["build"] = "build";
COMMAND2["preview"] = "preview";
COMMAND2["start"] = "start";
return COMMAND2;
})(COMMAND || {});
export class Esmx {
// Basic properties and constructor
_options;
_readied = null;
_importmapHash = null;
get readied() {
if (this._readied) {
return this._readied;
}
throw new NotReadyError();
}
/**
* Get module name
* @returns {string} The name of the current module, sourced from module configuration
* @throws {NotReadyError} Throws error when framework instance is not initialized
*/
get name() {
return this.moduleConfig.name;
}
/**
* Get module variable name
* @returns {string} A valid JavaScript variable name generated based on the module name
* @throws {NotReadyError} Throws error when framework instance is not initialized
*/
get varName() {
return "__" + this.name.replace(/[^a-zA-Z]/g, "_") + "__";
}
/**
* Get the absolute path of the project root directory
* @returns {string} The absolute path of the project root directory
* If the configured root is a relative path, it is resolved to an absolute path based on the current working directory
*/
get root() {
const { root = cwd() } = this._options;
if (path.isAbsolute(root)) {
return root;
}
return path.resolve(cwd(), root);
}
/**
* Determine if currently in production environment
* @returns {boolean} Environment flag
* Prioritizes the isProd in configuration, if not configured, judges based on process.env.NODE_ENV
*/
get isProd() {
return this._options?.isProd ?? process.env.NODE_ENV === "production";
}
/**
* Get the base path of the module
* @returns {string} The base path of the module starting and ending with a slash
* Used to construct the access path for module assets
*/
get basePath() {
return `/${this.name}/`;
}
/**
* Get the base path placeholder
* @returns {string} Base path placeholder or empty string
* Used for dynamically replacing the base path of the module at runtime, can be disabled through configuration
*/
get basePathPlaceholder() {
const varName = this._options.basePathPlaceholder;
if (varName === false) {
return "";
}
return varName ?? "[[[___ESMX_DYNAMIC_BASE___]]]";
}
/**
* Get the currently executing command
* @returns {COMMAND} The command enumeration value currently being executed
* @throws {NotReadyError} Throws error when calling this method if the framework instance is not initialized
*/
get command() {
return this.readied.command;
}
/**
* Get the command enumeration type
* @returns {typeof COMMAND} Command enumeration type definition
*/
get COMMAND() {
return COMMAND;
}
/**
* Get module configuration information
* @returns {ParsedModuleConfig} Complete configuration information of the current module
*/
get moduleConfig() {
return this.readied.moduleConfig;
}
/**
* Get package configuration information
* @returns {ParsedPackConfig} Package-related configuration of the current module
*/
get packConfig() {
return this.readied.packConfig;
}
/**
* Get the static asset processing middleware for the application.
*
* This middleware is responsible for handling static asset requests for the application,
* providing different implementations based on the runtime environment:
* - Development environment: Supports real-time compilation and hot reloading of source code, uses no-cache strategy
* - Production environment: Handles built static assets, supports long-term caching for immutable files
*
* @returns {Middleware} Returns the static asset processing middleware function
* @throws {NotReadyError} Throws error when calling this method if the framework instance is not initialized
*
* @example
* ```ts
* const server = http.createServer((req, res) => {
* // Use middleware to handle static asset requests
* esmx.middleware(req, res, async () => {
* const rc = await esmx.render({ url: req.url });
* res.end(rc.html);
* });
* });
* ```
*/
get middleware() {
return this.readied.app.middleware;
}
/**
* Get the server-side rendering function for the application.
*
* This function is responsible for executing server-side rendering,
* providing different implementations based on the runtime environment:
* - Development environment: Loads server entry file from source code, supports hot reloading and real-time preview
* - Production environment: Loads built server entry file, provides optimized rendering performance
*
* @returns {(options?: RenderContextOptions) => Promise<RenderContext>} Returns the server-side rendering function
* @throws {NotReadyError} Throws error when calling this method if the framework instance is not initialized
*
* @example
* ```ts
* // Basic usage
* const rc = await esmx.render({
* params: { url: req.url }
* });
* res.end(rc.html);
*
* // Advanced configuration
* const rc = await esmx.render({
* base: '', // Set base path
* importmapMode: 'inline', // Set import map mode
* entryName: 'default', // Specify render entry
* params: {
* url: req.url,
* state: { user: 'admin' }
* }
* });
* ```
*/
get render() {
return this.readied.app.render;
}
constructor(options = {}) {
this._options = options;
}
/**
* Initialize the Esmx framework instance.
*
* This method executes the following core initialization process:
* 1. Parse project configuration (package.json, module configuration, package configuration, etc.)
* 2. Create application instance (development or production environment)
* 3. Execute corresponding lifecycle methods based on the command
*
* @param command - Framework running command
* - dev: Start development server with hot reload support
* - build: Build production artifacts
* - preview: Preview build artifacts
* - start: Start production environment server
*
* @returns Returns true for successful initialization
* @throws {Error} Throws error when initializing repeatedly
*
* @example
* ```ts
* // entry.node.ts
* import type { EsmxOptions } from '@esmx/core';
*
* export default {
* // Development environment configuration
* async devApp(esmx) {
* return import('@esmx/rspack').then((m) =>
* m.createRspackHtmlApp(esmx, {
* config(context) {
* // Custom Rspack configuration
* }
* })
* );
* },
*
* // HTTP server configuration
* async server(esmx) {
* const server = http.createServer((req, res) => {
* // Static file handling
* esmx.middleware(req, res, async () => {
* // Pass rendering parameters
* const render = await esmx.render({
* params: { url: req.url }
* });
* // Respond with HTML content
* res.end(render.html);
* });
* });
*
* // Listen to port
* server.listen(3000, () => {
* console.log('http://localhost:3000');
* });
* }
* } satisfies EsmxOptions;
* ```
*/
async init(command) {
if (this._readied) {
throw new Error("Cannot be initialized repeatedly");
}
const { name } = await this.readJson(
path.resolve(this.root, "package.json")
);
const moduleConfig = parseModuleConfig(
name,
this.root,
this._options.modules
);
const packConfig = parsePackConfig(this._options.packs);
this._readied = {
command,
app: {
middleware() {
throw new NotReadyError();
},
async render() {
throw new NotReadyError();
}
},
moduleConfig,
packConfig,
cache: createCache(this.isProd)
};
const devApp = this._options.devApp || defaultDevApp;
const app = ["dev" /* dev */, "build" /* build */].includes(command) ? await devApp(this) : await createApp(this, command);
this.readied.app = app;
switch (command) {
case "dev" /* dev */:
case "start" /* start */:
await this.server();
break;
case "build" /* build */:
return this.build();
case "preview" /* preview */:
break;
}
return true;
}
/**
* Destroy the Esmx framework instance, performing resource cleanup and connection closing operations.
*
* This method is mainly used for resource cleanup in development environment, including:
* - Closing development servers (such as Rspack Dev Server)
* - Cleaning up temporary files and cache
* - Releasing system resources
*
* Note: In general, the framework automatically handles resource release, users do not need to manually call this method.
* Only use it when custom resource cleanup logic is needed.
*
* @returns Returns a Promise that resolves to a boolean value
* - true: Cleanup successful or no cleanup needed
* - false: Cleanup failed
*
* @example
* ```ts
* // Use when custom cleanup logic is needed
* process.once('SIGTERM', async () => {
* await esmx.destroy(); // Clean up resources
* process.exit(0);
* });
* ```
*/
async destroy() {
const { readied } = this;
if (readied.app?.destroy) {
return readied.app.destroy();
}
return true;
}
/**
* Execute the application's build process.
*
* This method is responsible for executing the entire application build process, including:
* - Compiling source code
* - Generating production build artifacts
* - Optimizing and compressing code
* - Generating asset manifests
*
* The build process prints start and end times, as well as total duration and other information.
*
* @returns Returns a Promise that resolves to a boolean value
* - true: Build successful or build method not implemented
* - false: Build failed
*
* @throws {NotReadyError} Throws error when calling this method if the framework instance is not initialized
*
* @example
* ```ts
* // entry.node.ts
* import type { EsmxOptions } from '@esmx/core';
*
* export default {
* // Development environment configuration
* async devApp(esmx) {
* return import('@esmx/rspack').then((m) =>
* m.createRspackHtmlApp(esmx, {
* config(context) {
* // Custom Rspack configuration
* }
* })
* );
* },
*
* // Post-build processing
* async postBuild(esmx) {
* // Generate static HTML after build completion
* const render = await esmx.render({
* params: { url: '/' }
* });
* esmx.writeSync(
* esmx.resolvePath('dist/client', 'index.html'),
* render.html
* );
* }
* } satisfies EsmxOptions;
* ```
*/
async build() {
const startTime = Date.now();
const successful = await this.readied.app.build?.();
const endTime = Date.now();
const duration = endTime - startTime;
const status = successful ? "\x1B[32m\u2713\x1B[0m".padEnd(3) : "\x1B[31m\u2717\x1B[0m".padEnd(3);
console.log(
`${status.padEnd(2)} Build ${successful ? "completed" : "failed"} in ${duration}ms`
);
return successful ?? true;
}
/**
* Start HTTP server and configure server instance.
*
* This method is called in the following lifecycle of the framework:
* - Development environment (dev): Start development server, providing features like hot reload
* - Production environment (start): Start production server, providing production-grade performance
*
* The specific implementation of the server is provided by the user through the server configuration function in EsmxOptions.
* This function is responsible for:
* - Creating HTTP server instance
* - Configuring middleware and routes
* - Handling requests and responses
* - Starting server listening
*
* @returns Returns a Promise that resolves when the server startup is complete
* @throws {NotReadyError} Throws error when calling this method if the framework instance is not initialized
*
* @example
* ```ts
* // entry.node.ts
* import http from 'node:http';
* import type { EsmxOptions } from '@esmx/core';
*
* export default {
* // Server configuration
* async server(esmx) {
* const server = http.createServer((req, res) => {
* // Handle static assets
* esmx.middleware(req, res, async () => {
* // Server-side rendering
* const render = await esmx.render({
* params: { url: req.url }
* });
* res.end(render.html);
* });
* });
*
* // Start server
* server.listen(3000, () => {
* console.log('Server running at http://localhost:3000');
* });
* }
* } satisfies EsmxOptions;
* ```
*/
async server() {
await this._options?.server?.(this);
}
/**
* Execute post-build processing logic.
*
* This method is called after the application build is completed, used to perform additional resource processing, such as:
* - Generating static HTML files
* - Processing build artifacts
* - Executing deployment tasks
* - Sending build notifications
*
* The method automatically captures and handles exceptions during execution, ensuring it does not affect the main build process.
*
* @returns Returns a Promise that resolves to a boolean value
* - true: Post-processing successful or no processing needed
* - false: Post-processing failed
*
* @example
* ```ts
* // entry.node.ts
* import type { EsmxOptions } from '@esmx/core';
*
* export default {
* // Post-build processing
* async postBuild(esmx) {
* // Generate static HTML for multiple pages
* const pages = ['/', '/about', '/404'];
*
* for (const url of pages) {
* const render = await esmx.render({
* params: { url }
* });
*
* // Write static HTML file
* esmx.writeSync(
* esmx.resolvePath('dist/client', url.substring(1), 'index.html'),
* render.html
* );
* }
* }
* } satisfies EsmxOptions;
* ```
*/
async postBuild() {
try {
await this._options.postBuild?.(this);
return true;
} catch (e) {
console.error(e);
return false;
}
}
/**
* Resolve project relative path to absolute path
*
* @param projectPath - Project path type, such as 'dist/client', 'dist/server', etc.
* @param args - Path segments to be concatenated
* @returns Resolved absolute path
*
* @example
* ```ts
* // Used in entry.node.ts
* async postBuild(esmx) {
* const outputPath = esmx.resolvePath('dist/client', 'index.html');
* // Output: /project/root/dist/client/index.html
* }
* ```
*/
resolvePath(projectPath, ...args) {
return resolvePath(this.root, projectPath, ...args);
}
/**
* Write file content synchronously
*
* @param filepath - Absolute path of the file
* @param data - Data to be written, can be string, Buffer or object
* @returns Whether the write was successful
*
* @example
* ```ts
* // Used in entry.node.ts
* async postBuild(esmx) {
* const htmlPath = esmx.resolvePath('dist/client', 'index.html');
* const success = esmx.writeSync(htmlPath, '<html>...</html>');
* }
* ```
*/
writeSync(filepath, data) {
try {
fs.mkdirSync(path.dirname(filepath), { recursive: true });
fs.writeFileSync(filepath, data);
return true;
} catch {
return false;
}
}
/**
* Write file content asynchronously
*
* @param filepath - Absolute path of the file
* @param data - Data to be written, can be string, Buffer or object
* @returns Promise<boolean> Whether the write was successful
*
* @example
* ```ts
* // Used in entry.node.ts
* async postBuild(esmx) {
* const htmlPath = esmx.resolvePath('dist/client', 'index.html');
* const success = await esmx.write(htmlPath, '<html>...</html>');
* }
* ```
*/
async write(filepath, data) {
try {
await fsp.mkdir(path.dirname(filepath), { recursive: true });
await fsp.writeFile(filepath, data);
return true;
} catch {
return false;
}
}
/**
* Read and parse JSON file synchronously
*
* @template T - Expected JSON object type to return
* @param filename - Absolute path of the JSON file
* @returns {T} Parsed JSON object
* @throws Throws exception when file does not exist or JSON format is incorrect
*
* @example
* ```ts
* // Used in entry.node.ts
* async server(esmx) {
* const manifest = esmx.readJsonSync<Manifest>(esmx.resolvePath('dist/client', 'manifest.json'));
* // Use manifest object
* }
* ```
*/
readJsonSync(filename) {
return JSON.parse(fs.readFileSync(filename, "utf-8"));
}
/**
* Read and parse JSON file asynchronously
*
* @template T - Expected JSON object type to return
* @param filename - Absolute path of the JSON file
* @returns {Promise<T>} Parsed JSON object
* @throws Throws exception when file does not exist or JSON format is incorrect
*
* @example
* ```ts
* // Used in entry.node.ts
* async server(esmx) {
* const manifest = await esmx.readJson<Manifest>(esmx.resolvePath('dist/client', 'manifest.json'));
* // Use manifest object
* }
* ```
*/
async readJson(filename) {
return JSON.parse(await fsp.readFile(filename, "utf-8"));
}
/**
* Get build manifest list
*
* @description
* This method is used to get the build manifest list for the specified target environment, including the following features:
* 1. **Cache Management**
* - Uses internal caching mechanism to avoid repeated loading
* - Returns immutable manifest list
*
* 2. **Environment Adaptation**
* - Supports both client and server environments
* - Returns corresponding manifest information based on the target environment
*
* 3. **Module Mapping**
* - Contains module export information
* - Records resource dependency relationships
*
* @param env - Target environment type
* - 'client': Client environment
* - 'server': Server environment
* @returns Returns read-only build manifest list
* @throws {NotReadyError} Throws error when calling this method if the framework instance is not initialized
*
* @example
* ```ts
* // Used in entry.node.ts
* async server(esmx) {
* // Get client build manifest
* const manifests = await esmx.getManifestList('client');
*
* // Find build information for a specific module
* const appModule = manifests.find(m => m.name === 'my-app');
* if (appModule) {
* console.log('App exports:', appModule.exports);
* console.log('App chunks:', appModule.chunks);
* }
* }
* ```
*/
async getManifestList(env) {
return this.readied.cache(
`getManifestList-${env}`,
async () => Object.freeze(await getManifestList(env, this.moduleConfig))
);
}
/**
* Get import map object
*
* @description
* This method is used to generate ES module import maps with the following features:
* 1. **Module Resolution**
* - Generate module mappings based on build manifests
* - Support both client and server environments
* - Automatically handle module path resolution
*
* 2. **Cache Optimization**
* - Use internal caching mechanism
* - Return immutable mapping objects
*
* 3. **Path Handling**
* - Automatically handle module paths
* - Support dynamic base paths
*
* @param env - Target environment type
* - 'client': Generate import map for browser environment
* - 'server': Generate import map for server environment
* @returns Returns read-only import map object
* @throws {NotReadyError} Throws error when calling this method if the framework instance is not initialized
*
* @example
* ```ts
* // Used in entry.node.ts
* async server(esmx) {
* // Get client import map
* const importmap = await esmx.getImportMap('client');
*
* // Custom HTML template
* const html = `
* <!DOCTYPE html>
* <html>
* <head>
* <script type="importmap">
* ${JSON.stringify(importmap)}
* <\/script>
* </head>
* <body>
* <!-- Page content -->
* </body>
* </html>
* `;
* }
* ```
*/
async getImportMap(env) {
return this.readied.cache(`getImportMap-${env}`, async () => {
const { moduleConfig } = this.readied;
const manifests = await this.getManifestList(env);
let json = {};
switch (env) {
case "client": {
json = createClientImportMap({
manifests,
getScope(name, scope) {
return `/${name}${scope}`;
},
getFile(name, file) {
return `/${name}/${file}`;
}
});
break;
}
case "server":
json = createImportMap({
manifests,
getScope: (name, scope) => {
const linkPath = moduleConfig.links[name].server;
const realPath = fs.realpathSync(linkPath);
return pathToFileURL(path.join(realPath, scope)).href;
},
getFile: (name, file) => {
const linkPath = moduleConfig.links[name].server;
const realPath = fs.realpathSync(linkPath);
return pathToFileURL(path.resolve(realPath, file)).href;
}
});
break;
}
return Object.freeze(json);
});
}
/**
* Get client import map information
*
* @description
* This method is used to generate import map code for client environment, supporting two modes:
* 1. **Inline Mode (inline)**
* - Inline import map directly into HTML
* - Reduce additional network requests
* - Suitable for scenarios with smaller import maps
*
* 2. **JS File Mode (js)**
* - Generate standalone JS file
* - Support browser caching
* - Suitable for scenarios with larger import maps
*
* Core Features:
* - Automatically handle dynamic base paths
* - Support module path runtime replacement
* - Optimize caching strategy
* - Ensure module loading order
*
* @param mode - Import map mode
* - 'inline': Inline mode, returns HTML script tag
* - 'js': JS file mode, returns information with file path
* @returns Returns import map related information
* - src: URL of the JS file (only in js mode)
* - filepath: Local path of the JS file (only in js mode)
* - code: HTML script tag content
* @throws {NotReadyError} Throws error when calling this method if the framework instance is not initialized
*
* @example
* ```ts
* // Used in entry.node.ts
* async server(esmx) {
* const server = express();
* server.use(esmx.middleware);
*
* server.get('*', async (req, res) => {
* // Use JS file mode
* const result = await esmx.render({
* importmapMode: 'js',
* params: { url: req.url }
* });
* res.send(result.html);
* });
*
* // Or use inline mode
* server.get('/inline', async (req, res) => {
* const result = await esmx.render({
* importmapMode: 'inline',
* params: { url: req.url }
* });
* res.send(result.html);
* });
* }
* ```
*/
async getImportMapClientInfo(mode) {
return this.readied.cache(
`getImportMap-${mode}`,
async () => {
const importmap = await this.getImportMap("client");
const { basePathPlaceholder } = this;
let filepath = null;
if (this._importmapHash === null) {
let wrote = false;
const code = `(() => {
const base = document.currentScript.getAttribute("data-base");
const importmap = ${serialize(importmap, { isJSON: true })};
const set = (data) => {
if (!data) return;
Object.entries(data).forEach(([k, v]) => {
data[k] = base + v;
});
};
set(importmap.imports);
if (importmap.scopes) {
Object.values(importmap.scopes).forEach(set);
}
const script = document.createElement("script");
script.type = "importmap";
script.innerText = JSON.stringify(importmap);
document.head.appendChild(script);
})();`;
const hash = contentHash(code);
filepath = this.resolvePath(
"dist/client/importmap",
`${hash}.final.mjs`
);
try {
const existingContent = await fsp.readFile(
filepath,
"utf-8"
);
if (existingContent === code) {
wrote = true;
} else {
wrote = await this.write(filepath, code);
}
} catch {
wrote = await this.write(filepath, code);
}
this._importmapHash = wrote ? hash : "";
}
if (mode === "js" && this._importmapHash) {
const src = `${basePathPlaceholder}${this.basePath}importmap/${this._importmapHash}.final.mjs`;
return {
src,
filepath,
code: `<script data-base="${basePathPlaceholder}" src="${src}"><\/script>`
};
}
if (basePathPlaceholder) {
const set = (data) => {
if (!data) return;
Object.entries(data).forEach(([k, v]) => {
data[k] = basePathPlaceholder + v;
});
};
set(importmap.imports);
if (importmap.scopes) {
Object.values(importmap.scopes).forEach(set);
}
}
return {
src: null,
filepath: null,
code: `<script type="importmap">${serialize(importmap, { isJSON: true, unsafe: true })}<\/script>`
};
}
);
}
/**
* Get the list of static import paths for a module.
*
* @param env - Build target ('client' | 'server')
* @param specifier - Module specifier
* @returns Returns the list of static import paths, returns null if not found
* @throws {NotReadyError} Throws error when calling this method if the framework instance is not initialized
*
* @example
* ```ts
* // Get static import paths for client entry module
* const paths = await esmx.getStaticImportPaths(
* 'client',
* `your-app-name/src/entry.client`
* );
* ```
*/
async getStaticImportPaths(env, specifier) {
return this.readied.cache(
`getStaticImportPaths-${env}-${specifier}`,
async () => {
const result = await getStaticImportPaths(
specifier,
await this.getImportMap(env),
this.moduleConfig
);
if (!result) {
return null;
}
return Object.freeze(Object.values(result));
}
);
}
/**
* Generate bundle size analysis report for the build artifacts.
*
* @returns {{ text: string, json: object }} Report with text and JSON formats
*
* @example
* ```ts
* const report = esmx.generateSizeReport();
* console.log(report.text);
* console.log(`Total files: ${report.json.totalFiles}`);
* ```
*/
generateSizeReport() {
return generateSizeReport(
this.resolvePath("dist"),
"{client,server,node}/**/!(.*)"
);
}
}
async function defaultDevApp() {
throw new Error("'devApp' function not set");
}
class NotReadyError extends Error {
constructor() {
super(`The Esmx has not been initialized yet`);
}
}
function contentHash(text) {
const hash = crypto.createHash("sha256");
hash.update(text);
return hash.digest("hex").substring(0, 12);
}