@esmx/core
Version:
A high-performance microfrontend framework supporting Vue, React, Preact, Solid, and Svelte with SSR and Module Federation capabilities.
886 lines (885 loc) • 26.6 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 { getImportMap } 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 {
// 基础属性和构造函数
_options;
_readied = null;
_importmapHash = null;
get readied() {
if (this._readied) {
return this._readied;
}
throw new NotReadyError();
}
/**
* 获取模块名称
* @returns {string} 当前模块的名称,来源于模块配置
* @throws {NotReadyError} 在框架实例未初始化时抛出错误
*/
get name() {
return this.moduleConfig.name;
}
/**
* 获取模块变量名
* @returns {string} 基于模块名称生成的合法 JavaScript 变量名
* @throws {NotReadyError} 在框架实例未初始化时抛出错误
*/
get varName() {
return "__" + this.name.replace(/[^a-zA-Z]/g, "_") + "__";
}
/**
* 获取项目根目录的绝对路径
* @returns {string} 项目根目录的绝对路径
* 如果配置的 root 为相对路径,则基于当前工作目录解析为绝对路径
*/
get root() {
const { root = cwd() } = this._options;
if (path.isAbsolute(root)) {
return root;
}
return path.resolve(cwd(), root);
}
/**
* 判断当前是否为生产环境
* @returns {boolean} 环境标识
* 优先使用配置项中的 isProd,若未配置则根据 process.env.NODE_ENV 判断
*/
get isProd() {
return this._options?.isProd ?? process.env.NODE_ENV === "production";
}
/**
* 获取模块的基础路径
* @returns {string} 以斜杠开头和结尾的模块基础路径
* 用于构建模块资源的访问路径
*/
get basePath() {
return `/${this.name}/`;
}
/**
* 获取基础路径占位符
* @returns {string} 基础路径占位符或空字符串
* 用于运行时动态替换模块的基础路径,可通过配置禁用
*/
get basePathPlaceholder() {
const varName = this._options.basePathPlaceholder;
if (varName === false) {
return "";
}
return varName ?? "[[[___GEZ_DYNAMIC_BASE___]]]";
}
/**
* 获取当前执行的命令
* @returns {COMMAND} 当前正在执行的命令枚举值
* @throws {NotReadyError} 在框架实例未初始化时调用此方法会抛出错误
*/
get command() {
return this.readied.command;
}
/**
* 获取命令枚举类型
* @returns {typeof COMMAND} 命令枚举类型定义
*/
get COMMAND() {
return COMMAND;
}
/**
* 获取模块配置信息
* @returns {ParsedModuleConfig} 当前模块的完整配置信息
*/
get moduleConfig() {
return this.readied.moduleConfig;
}
/**
* 获取打包配置信息
* @returns {ParsedPackConfig} 当前模块的打包相关配置
*/
get packConfig() {
return this.readied.packConfig;
}
/**
* 获取应用程序的静态资源处理中间件。
*
* 该中间件负责处理应用程序的静态资源请求,根据运行环境提供不同的实现:
* - 开发环境:支持源码的实时编译、热更新,使用 no-cache 缓存策略
* - 生产环境:处理构建后的静态资源,支持不可变文件的长期缓存
*
* @returns {Middleware} 返回静态资源处理中间件函数
* @throws {NotReadyError} 在框架实例未初始化时调用此方法会抛出错误
*
* @example
* ```ts
* const server = http.createServer((req, res) => {
* // 使用中间件处理静态资源请求
* esmx.middleware(req, res, async () => {
* const rc = await esmx.render({ url: req.url });
* res.end(rc.html);
* });
* });
* ```
*/
get middleware() {
return this.readied.app.middleware;
}
/**
* 获取应用程序的服务端渲染函数。
*
* 该函数负责执行服务端渲染,根据运行环境提供不同的实现:
* - 开发环境:加载源码中的服务端入口文件,支持热更新和实时预览
* - 生产环境:加载构建后的服务端入口文件,提供优化的渲染性能
*
* @returns {(options?: RenderContextOptions) => Promise<RenderContext>} 返回服务端渲染函数
* @throws {NotReadyError} 在框架实例未初始化时调用此方法会抛出错误
*
* @example
* ```ts
* // 基本用法
* const rc = await esmx.render({
* params: { url: req.url }
* });
* res.end(rc.html);
*
* // 高级配置
* const rc = await esmx.render({
* base: '', // 设置基础路径
* importmapMode: 'inline', // 设置导入映射模式
* entryName: 'default', // 指定渲染入口
* params: {
* url: req.url,
* state: { user: 'admin' }
* }
* });
* ```
*/
get render() {
return this.readied.app.render;
}
constructor(options = {}) {
this._options = options;
}
/**
* 初始化 Esmx 框架实例。
*
* 该方法执行以下核心初始化流程:
* 1. 解析项目配置(package.json、模块配置、打包配置等)
* 2. 创建应用实例(开发环境或生产环境)
* 3. 根据命令执行相应的生命周期方法
*
* @param command - 框架运行命令
* - dev: 启动开发服务器,支持热更新
* - build: 构建生产环境产物
* - preview: 预览构建产物
* - start: 启动生产环境服务器
*
* @returns 初始化成功返回 true
* @throws {Error} 重复初始化时抛出错误
*
* @example
* ```ts
* // entry.node.ts
* import type { EsmxOptions } from '@esmx/core';
*
* export default {
* // 开发环境配置
* async devApp(esmx) {
* return import('@esmx/rspack').then((m) =>
* m.createRspackHtmlApp(esmx, {
* config(context) {
* // 自定义 Rspack 配置
* }
* })
* );
* },
*
* // HTTP 服务器配置
* async server(esmx) {
* const server = http.createServer((req, res) => {
* // 静态文件处理
* esmx.middleware(req, res, async () => {
* // 传入渲染的参数
* const render = await esmx.render({
* params: { url: req.url }
* });
* // 响应 HTML 内容
* res.end(render.html);
* });
* });
*
* // 监听端口
* 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;
}
/**
* 销毁 Esmx 框架实例,执行资源清理和连接关闭等操作。
*
* 该方法主要用于开发环境下的资源清理,包括:
* - 关闭开发服务器(如 Rspack Dev Server)
* - 清理临时文件和缓存
* - 释放系统资源
*
* 注意:一般情况下,框架会自动处理资源的释放,用户无需手动调用此方法。
* 仅在需要自定义资源清理逻辑时才需要使用。
*
* @returns 返回一个 Promise,resolve 为 boolean 值
* - true: 清理成功或无需清理
* - false: 清理失败
*
* @example
* ```ts
* // 在需要自定义清理逻辑时使用
* process.once('SIGTERM', async () => {
* await esmx.destroy(); // 清理资源
* process.exit(0);
* });
* ```
*/
async destroy() {
const { readied } = this;
if (readied.app?.destroy) {
return readied.app.destroy();
}
return true;
}
/**
* 执行应用程序的构建流程。
*
* 该方法负责执行整个应用的构建过程,包括:
* - 编译源代码
* - 生成生产环境的构建产物
* - 优化和压缩代码
* - 生成资源清单
*
* 构建过程会打印开始和结束时间,以及总耗时等信息。
*
* @returns 返回一个 Promise,resolve 为 boolean 值
* - true: 构建成功或构建方法未实现
* - false: 构建失败
*
* @throws {NotReadyError} 在框架实例未初始化时调用此方法会抛出错误
*
* @example
* ```ts
* // entry.node.ts
* import type { EsmxOptions } from '@esmx/core';
*
* export default {
* // 开发环境配置
* async devApp(esmx) {
* return import('@esmx/rspack').then((m) =>
* m.createRspackHtmlApp(esmx, {
* config(context) {
* // 自定义 Rspack 配置
* }
* })
* );
* },
*
* // 构建后处理
* async postBuild(esmx) {
* // 构建完成后生成静态 HTML
* 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;
}
/**
* 启动 HTTP 服务器并配置服务器实例。
*
* 该方法在框架的以下生命周期中被调用:
* - 开发环境(dev):启动开发服务器,提供热更新等功能
* - 生产环境(start):启动生产服务器,提供生产级性能
*
* 服务器的具体实现由用户通过 EsmxOptions 的 server 配置函数提供。
* 该函数负责:
* - 创建 HTTP 服务器实例
* - 配置中间件和路由
* - 处理请求和响应
* - 启动服务器监听
*
* @returns 返回一个 Promise,在服务器启动完成后 resolve
* @throws {NotReadyError} 在框架实例未初始化时调用此方法会抛出错误
*
* @example
* ```ts
* // entry.node.ts
* import http from 'node:http';
* import type { EsmxOptions } from '@esmx/core';
*
* export default {
* // 服务器配置
* async server(esmx) {
* const server = http.createServer((req, res) => {
* // 处理静态资源
* esmx.middleware(req, res, async () => {
* // 服务端渲染
* const render = await esmx.render({
* params: { url: req.url }
* });
* res.end(render.html);
* });
* });
*
* // 启动服务器
* server.listen(3000, () => {
* console.log('Server running at http://localhost:3000');
* });
* }
* } satisfies EsmxOptions;
* ```
*/
async server() {
await this._options?.server?.(this);
}
/**
* 执行构建后的处理逻辑。
*
* 该方法在应用构建完成后被调用,用于执行额外的资源处理,如:
* - 生成静态 HTML 文件
* - 处理构建产物
* - 执行部署任务
* - 发送构建通知
*
* 方法会自动捕获并处理执行过程中的异常,确保不会影响主构建流程。
*
* @returns 返回一个 Promise,resolve 为 boolean 值
* - true: 后处理成功或无需处理
* - false: 后处理失败
*
* @example
* ```ts
* // entry.node.ts
* import type { EsmxOptions } from '@esmx/core';
*
* export default {
* // 构建后处理
* async postBuild(esmx) {
* // 生成多个页面的静态 HTML
* const pages = ['/', '/about', '/404'];
*
* for (const url of pages) {
* const render = await esmx.render({
* params: { url }
* });
*
* // 写入静态 HTML 文件
* 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;
}
}
/**
* 解析项目相对路径为绝对路径
*
* @param projectPath - 项目路径类型,如 'dist/client'、'dist/server' 等
* @param args - 需要拼接的路径片段
* @returns 解析后的绝对路径
*
* @example
* ```ts
* // 在 entry.node.ts 中使用
* async postBuild(esmx) {
* const outputPath = esmx.resolvePath('dist/client', 'index.html');
* // 输出: /project/root/dist/client/index.html
* }
* ```
*/
resolvePath(projectPath, ...args) {
return resolvePath(this.root, projectPath, ...args);
}
/**
* 同步写入文件内容
*
* @param filepath - 文件的绝对路径
* @param data - 要写入的数据,可以是字符串、Buffer 或对象
* @returns 写入是否成功
*
* @example
* ```ts
* // 在 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;
}
}
/**
* 异步写入文件内容
*
* @param filepath - 文件的绝对路径
* @param data - 要写入的数据,可以是字符串、Buffer 或对象
* @returns Promise<boolean> 写入是否成功
*
* @example
* ```ts
* // 在 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;
}
}
/**
* 同步读取并解析 JSON 文件
*
* @template T - 期望返回的JSON对象类型
* @param filename - JSON 文件的绝对路径
* @returns {T} 解析后的 JSON 对象
* @throws 当文件不存在或 JSON 格式错误时抛出异常
*
* @example
* ```ts
* // 在 entry.node.ts 中使用
* async server(esmx) {
* const manifest = esmx.readJsonSync<Manifest>(esmx.resolvePath('dist/client', 'manifest.json'));
* // 使用 manifest 对象
* }
* ```
*/
readJsonSync(filename) {
return JSON.parse(fs.readFileSync(filename, "utf-8"));
}
/**
* 异步读取并解析 JSON 文件
*
* @template T - 期望返回的JSON对象类型
* @param filename - JSON 文件的绝对路径
* @returns {Promise<T>} 解析后的 JSON 对象
* @throws 当文件不存在或 JSON 格式错误时抛出异常
*
* @example
* ```ts
* // 在 entry.node.ts 中使用
* async server(esmx) {
* const manifest = await esmx.readJson<Manifest>(esmx.resolvePath('dist/client', 'manifest.json'));
* // 使用 manifest 对象
* }
* ```
*/
async readJson(filename) {
return JSON.parse(await fsp.readFile(filename, "utf-8"));
}
/**
* 获取构建清单列表
*
* @description
* 该方法用于获取指定目标环境的构建清单列表,包含以下功能:
* 1. **缓存管理**
* - 使用内部缓存机制避免重复加载
* - 返回不可变的清单列表
*
* 2. **环境适配**
* - 支持客户端和服务端两种环境
* - 根据目标环境返回对应的清单信息
*
* 3. **模块映射**
* - 包含模块导出信息
* - 记录资源依赖关系
*
* @param env - 目标环境类型
* - 'client': 客户端环境
* - 'server': 服务端环境
* @returns 返回只读的构建清单列表
* @throws {NotReadyError} 在框架实例未初始化时调用此方法会抛出错误
*
* @example
* ```ts
* // 在 entry.node.ts 中使用
* async server(esmx) {
* // 获取客户端构建清单
* const manifests = await esmx.getManifestList('client');
*
* // 查找特定模块的构建信息
* 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))
);
}
/**
* 获取导入映射对象
*
* @description
* 该方法用于生成 ES 模块导入映射(Import Map),具有以下特点:
* 1. **模块解析**
* - 基于构建清单生成模块映射
* - 支持客户端和服务端两种环境
* - 自动处理模块路径解析
*
* 2. **缓存优化**
* - 使用内部缓存机制
* - 返回不可变的映射对象
*
* 3. **路径处理**
* - 自动处理模块路径
* - 支持动态基础路径
*
* @param env - 目标环境类型
* - 'client': 生成浏览器环境的导入映射
* - 'server': 生成服务端环境的导入映射
* @returns 返回只读的导入映射对象
* @throws {NotReadyError} 在框架实例未初始化时调用此方法会抛出错误
*
* @example
* ```ts
* // 在 entry.node.ts 中使用
* async server(esmx) {
* // 获取客户端导入映射
* const importmap = await esmx.getImportMap('client');
*
* // 自定义 HTML 模板
* const html = `
* <!DOCTYPE html>
* <html>
* <head>
* <script type="importmap">
* ${JSON.stringify(importmap)}
* <\/script>
* </head>
* <body>
* <!-- 页面内容 -->
* </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 = getImportMap({
manifests,
getScope(name, scope) {
return `/${name}${scope}`;
},
getFile(name, file) {
return `/${name}/${file}`;
}
});
break;
case "server":
json = getImportMap({
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);
});
}
/**
* 获取客户端导入映射信息
*
* @description
* 该方法用于生成客户端环境的导入映射代码,支持两种模式:
* 1. **内联模式 (inline)**
* - 将导入映射直接内联到 HTML 中
* - 减少额外的网络请求
* - 适合导入映射较小的场景
*
* 2. **JS 文件模式 (js)**
* - 生成独立的 JS 文件
* - 支持浏览器缓存
* - 适合导入映射较大的场景
*
* 核心功能:
* - 自动处理动态基础路径
* - 支持模块路径运行时替换
* - 优化缓存策略
* - 确保模块加载顺序
*
* @param mode - 导入映射模式
* - 'inline': 内联模式,返回 HTML script 标签
* - 'js': JS 文件模式,返回带有文件路径的信息
* @returns 返回导入映射的相关信息
* - src: JS 文件的 URL(仅在 js 模式下)
* - filepath: JS 文件的本地路径(仅在 js 模式下)
* - code: HTML script 标签内容
* @throws {NotReadyError} 在框架实例未初始化时调用此方法会抛出错误
*
* @example
* ```ts
* // 在 entry.node.ts 中使用
* async server(esmx) {
* const server = express();
* server.use(esmx.middleware);
*
* server.get('*', async (req, res) => {
* // 使用 JS 文件模式
* const result = await esmx.render({
* importmapMode: 'js',
* params: { url: req.url }
* });
* res.send(result.html);
* });
*
* // 或者使用内联模式
* 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 })}<\/script>`
};
}
);
}
/**
* 获取模块的静态导入路径列表。
*
* @param env - 构建目标('client' | 'server')
* @param specifier - 模块标识符
* @returns 返回静态导入路径列表,如果未找到则返回 null
* @throws {NotReadyError} 在框架实例未初始化时调用此方法会抛出错误
*
* @example
* ```ts
* // 获取客户端入口模块的静态导入路径
* 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));
}
);
}
}
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);
}