UNPKG

@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
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); }