UNPKG

@esmx/core

Version:

A high-performance microfrontend framework supporting Vue, React, Preact, Solid, and Svelte with SSR and Module Federation capabilities.

1,011 lines (1,010 loc) 30.7 kB
import path from "node:path"; import serialize from "serialize-javascript"; export class RenderContext { esmx; /** * Redirect address * @description * - Defaults to null, indicating no redirect * - When set, the server can perform HTTP redirection based on this value * - Commonly used for scenarios like login verification, permission control, etc. * * @example * ```ts * // 1. Login verification example * export default async (rc: RenderContext) => { * if (!isLoggedIn()) { * rc.redirect = '/login'; * rc.status = 302; * return; * } * // Continue rendering page... * }; * * // 2. Permission control example * export default async (rc: RenderContext) => { * if (!hasPermission()) { * rc.redirect = '/403'; * rc.status = 403; * return; * } * // Continue rendering page... * }; * * // 3. Server-side processing example * app.use(async (req, res) => { * const rc = await esmx.render({ * params: { * url: req.url * } * }); * * // Handle redirect * if (rc.redirect) { * res.statusCode = rc.status || 302; * res.setHeader('Location', rc.redirect); * res.end(); * return; * } * * // Set status code * if (rc.status) { * res.statusCode = rc.status; * } * * // Respond with HTML content * res.end(rc.html); * }); * ``` */ redirect = null; /** * HTTP response status code * @description * - Defaults to null, indicating use of 200 status code * - Can set any valid HTTP status code * - Commonly used for scenarios like error handling, redirection, etc. * - Usually used in conjunction with the redirect property * * @example * ```ts * // 1. 404 error handling example * export default async (rc: RenderContext) => { * const page = await findPage(rc.params.url); * if (!page) { * rc.status = 404; * // Render 404 page... * return; * } * // Continue rendering page... * }; * * // 2. Temporary redirect example * export default async (rc: RenderContext) => { * if (needMaintenance()) { * rc.redirect = '/maintenance'; * rc.status = 307; // Temporary redirect, keep request method unchanged * return; * } * // Continue rendering page... * }; * * // 3. Server-side processing example * app.use(async (req, res) => { * const rc = await esmx.render({ * params: { * url: req.url * } * }); * * // Handle redirect * if (rc.redirect) { * res.statusCode = rc.status || 302; * res.setHeader('Location', rc.redirect); * res.end(); * return; * } * * // Set status code * if (rc.status) { * res.statusCode = rc.status; * } * * // Respond with HTML content * res.end(rc.html); * }); * ``` */ status = null; _html = ""; /** * Base path for static assets * @description * The base property is used to control the loading path of static assets and is the core of Esmx framework's dynamic base path configuration: * * 1. **Build-time Processing** * - Static asset paths are marked with special placeholders: `[[[___ESMX_DYNAMIC_BASE___]]]/your-app-name/` * - Placeholders are injected into all static asset reference paths * - Supports various static assets like CSS, JavaScript, images, etc. * * 2. **Runtime Replacement** * - Set the actual base path through the `base` parameter of `esmx.render()` * - RenderContext automatically replaces placeholders in HTML with actual paths * * 3. **Technical Advantages** * - Deployment flexibility: The same set of build artifacts can be deployed to any path * - Performance optimization: Maintain the best caching strategy for static assets * - Development-friendly: Simplify multi-environment configuration management * * @example * ```ts * // 1. Basic usage * const rc = await esmx.render({ * base: '/esmx', // Set base path * params: { url: req.url } * }); * * // 2. Multi-language site example * const rc = await esmx.render({ * base: '/cn', // Chinese site * params: { lang: 'zh-CN' } * }); * * // 3. Micro-frontend application example * const rc = await esmx.render({ * base: '/app1', // Sub-application 1 * params: { appId: 1 } * }); * ``` */ base; /** * Server-side rendering entry function name * @description * The entryName property is used to specify the entry function used during server-side rendering: * * 1. **Basic Usage** * - Default value is 'default' * - Used to select the rendering function to use from entry.server.ts * - Supports scenarios where a module exports multiple rendering functions * * 2. **Use Cases** * - Multi-template rendering: Different pages use different rendering templates * - A/B testing: The same page uses different rendering logic * - Special rendering: Some pages need custom rendering processes * * @example * ```ts * // 1. Default entry function * // entry.server.ts * export default async (rc: RenderContext) => { * // Default rendering logic * }; * * // 2. Multiple entry functions * // entry.server.ts * export const mobile = async (rc: RenderContext) => { * // Mobile rendering logic * }; * * export const desktop = async (rc: RenderContext) => { * // Desktop rendering logic * }; * * // 3. Select entry function based on device type * const rc = await esmx.render({ * entryName: isMobile ? 'mobile' : 'desktop', * params: { url: req.url } * }); * ``` */ entryName; /** * Rendering parameters * @description * The params property is used to pass and access parameters during the server-side rendering process: * * 1. **Parameter Types** * - Supports key-value pairs of any type * - Defined through Record<string, any> type * - Remains unchanged throughout the entire rendering lifecycle * * 2. **Common Use Cases** * - Pass request information (URL, query parameters, etc.) * - Set page configuration (language, theme, etc.) * - Inject environment variables (API address, version number, etc.) * - Share server-side state (user information, permissions, etc.) * * 3. **Access Methods** * - Accessed through rc.params in server-side rendering functions * - Can destructure to get specific parameters * - Supports setting default values * * @example * ```ts * // 1. Basic usage - Pass URL and language settings * const rc = await esmx.render({ * params: { * url: req.url, * lang: 'zh-CN' * } * }); * * // 2. Page configuration - Set theme and layout * const rc = await esmx.render({ * params: { * theme: 'dark', * layout: 'sidebar' * } * }); * * // 3. Environment configuration - Inject API address * const rc = await esmx.render({ * params: { * apiBaseUrl: process.env.API_BASE_URL, * version: '1.0.0' * } * }); * * // 4. Use in rendering function * export default async (rc: RenderContext) => { * // Destructure to get parameters * const { url, lang = 'en' } = rc.params; * * // Execute different logic based on parameters * if (lang === 'zh-CN') { * // Chinese version processing... * } * * // Pass parameters to component * const html = await renderToString(createApp({ * props: { * currentUrl: url, * language: lang * } * })); * * // Set HTML * rc.html = ` * <!DOCTYPE html> * <html lang="${lang}"> * <body>${html}</body> * </html> * `; * }; * ``` */ params; /** * Module dependency collection set * @description * importMetaSet is the core of Esmx framework's intelligent dependency collection mechanism, used to track and record module dependencies during the server-side rendering process: * * 1. **On-demand Collection** * - Automatically tracks and records module dependencies during the actual component rendering process * - Only collects resources actually used during the current page rendering * - Precisely records the module dependency relationships of each component * * 2. **Performance Optimization** * - Avoids loading unused modules, significantly reducing first-screen loading time * - Precisely controls resource loading order, optimizing page rendering performance * - Automatically generates optimal import maps * * 3. **Usage** * - Passed to renderToString in the rendering function * - Framework automatically collects dependencies, no manual handling required * - Supports dependency collection for async components and dynamic imports * * @example * ```ts * // 1. Basic usage * const renderToString = (app: any, context: { importMetaSet: Set<ImportMeta> }) => { * // Automatically collect module dependencies during the rendering process * // Framework will automatically call context.importMetaSet.add(import.meta) during component rendering * // Developers do not need to manually handle dependency collection * return '<div id="app">Hello World</div>'; * }; * * // Usage example * const app = createApp(); * const html = await renderToString(app, { * importMetaSet: rc.importMetaSet * }); * * // 2. Commit dependencies * await rc.commit(); * * // 3. Generate HTML * rc.html = ` * <!DOCTYPE html> * <html> * <head> * <!-- Automatically inject resources based on collected dependencies --> * ${rc.preload()} * ${rc.css()} * </head> * <body> * ${html} * ${rc.importmap()} * ${rc.moduleEntry()} * ${rc.modulePreload()} * </body> * </html> * `; * ``` */ importMetaSet = /* @__PURE__ */ new Set(); /** * Resource file list * @description * The files property stores all static resource file paths collected during the server-side rendering process: * * 1. **Resource Types** * - js: List of JavaScript files, containing all scripts and modules * - css: List of stylesheet files * - modulepreload: List of ESM modules that need to be preloaded * - importmap: List of import map files * - resources: List of other resource files (images, fonts, etc.) * * 2. **Use Cases** * - Automatically collect and categorize resources in the commit() method * - Inject resources into HTML through methods like preload(), css(), etc. * - Supports base path configuration, implementing dynamic loading of resources * * @example * ```ts * // 1. Resource collection * await rc.commit(); * * // 2. Resource injection * rc.html = ` * <!DOCTYPE html> * <html> * <head> * <!-- Preload resources --> * ${rc.preload()} * <!-- Inject stylesheets --> * ${rc.css()} * </head> * <body> * ${html} * ${rc.importmap()} * ${rc.moduleEntry()} * ${rc.modulePreload()} * </body> * </html> * `; * ``` */ files = { js: [], css: [], modulepreload: [], resources: [] }; _importMap = { src: "", code: "" }; /** * Define the generation mode for importmap * * @description * ImportmapMode is used to control the generation method of importmap, supporting two modes: * - `inline`: Inline importmap content directly into HTML (default value), suitable for the following scenarios: * - Need to reduce the number of HTTP requests * - Importmap content is small * - High requirements for first-screen loading performance * - `js`: Generate importmap content as an independent JS file, suitable for the following scenarios: * - Importmap content is large * - Need to utilize browser caching mechanisms * - Multiple pages share the same importmap * * Reasons for choosing 'inline' as the default value: * 1. Simple and direct * - Reduce additional HTTP requests * - No additional resource management required * - Suitable for most application scenarios * 2. First-screen performance * - Avoid additional network requests * - Ensure import maps are immediately available * - Reduce page loading time * 3. Easy to debug * - Import maps are directly visible * - Facilitate problem diagnosis * - Simplify development process * * @example * ```ts * // Use inline mode (default) * const rc = await esmx.render({ * params: { url: req.url } * }); * * // Explicitly specify inline mode * const rc = await esmx.render({ * importmapMode: 'inline', * params: { url: req.url } * }); * * // Use JS file mode * const rc = await esmx.render({ * importmapMode: 'js', * params: { url: req.url } * }); * ``` */ importmapMode; /** * HTML content * @description * The html property is used to set and get the final generated HTML content: * * 1. **Base Path Replacement** * - Automatically handles base path placeholders when setting HTML * - Replaces `[[[___ESMX_DYNAMIC_BASE___]]]/your-app-name/` with the actual base path * - Ensures all static asset reference paths are correct * * 2. **Use Cases** * - Set HTML content generated by server-side rendering * - Support dynamic base path configuration * - Automatically handle static asset reference paths * * @example * ```ts * // 1. Basic usage * export default async (rc: RenderContext) => { * // Set HTML content * rc.html = ` * <!DOCTYPE html> * <html> * <head> * ${rc.preload()} * ${rc.css()} * </head> * <body> * <div id="app">Hello World</div> * ${rc.importmap()} * ${rc.moduleEntry()} * ${rc.modulePreload()} * </body> * </html> * `; * }; * * // 2. Dynamic base path * const rc = await esmx.render({ * base: '/app', // Set base path * params: { url: req.url } * }); * * // Placeholders in HTML will be automatically replaced: * // [[[___ESMX_DYNAMIC_BASE___]]]/your-app-name/css/style.css * // Replaced with: * // /app/your-app-name/css/style.css * ``` */ get html() { return this._html; } set html(html) { const varName = this.esmx.basePathPlaceholder; this._html = varName ? html.replaceAll(this.esmx.basePathPlaceholder, this.base) : html; } constructor(esmx, options = {}) { this.esmx = esmx; this.base = options.base ?? ""; this.params = options.params ?? {}; this.entryName = options.entryName ?? "default"; this.importmapMode = options.importmapMode ?? "inline"; } /** * Serialize JavaScript object to string * @description * The serialize method is used to serialize state data during the server-side rendering process for passing to the client: * * 1. **Main Uses** * - Serialize server-side state data * - Ensure data can be safely embedded in HTML * - Support complex data structures (such as Date, RegExp, etc.) * * 2. **Security Handling** * - Automatically escape special characters * - Prevent XSS attacks * - Maintain data type integrity * * @example * ```ts * // 1. Basic usage - Serialize state data * export default async (rc: RenderContext) => { * const state = { * user: { id: 1, name: 'Alice' }, * timestamp: new Date(), * regex: /\d+/ * }; * * rc.html = ` * <!DOCTYPE html> * <html> * <head> * <script> * // Inject serialized state into global variable * window.__INITIAL_STATE__ = ${rc.serialize(state)}; * <\/script> * </head> * <body>${html}</body> * </html> * `; * }; * * // 2. Custom serialization options * const state = { sensitive: 'data' }; * const serialized = rc.serialize(state, { * isJSON: true, // Use JSON compatible mode * unsafe: false // Disable unsafe serialization * }); * ``` * * @param {any} input - Input data to be serialized * @param {serialize.SerializeJSOptions} [options] - Serialization options * @returns {string} Serialized string */ serialize(input, options) { return serialize(input, options); } /** * Serialize state data and inject it into HTML * @description * The state method is used to serialize state data and inject it into HTML during server-side rendering, so that the client can restore these states when activating: * * 1. **Serialization Mechanism** * - Use safe serialization methods to process data * - Support complex data structures (objects, arrays, etc.) * - Automatically handle special characters and XSS protection * * 2. **Use Cases** * - Synchronize server-side state to client * - Initialize client application state * - Implement seamless server-side rendering to client activation * * @param varName Global variable name, used to access injected data on the client * @param data Data object that needs to be serialized * @returns Script tag string containing serialized data * * @example * ```ts * // 1. Basic usage - Inject user information * export default async (rc: RenderContext) => { * const userInfo = { * id: 1, * name: 'John', * roles: ['admin'] * }; * * rc.html = ` * <!DOCTYPE html> * <html> * <head> * ${rc.state('__USER__', userInfo)} * </head> * <body> * <div id="app"></div> * </body> * </html> * `; * }; * * // 2. Client-side usage * // Can directly access injected data on the client * const userInfo = window.__USER__; * console.log(userInfo.name); // Output: 'John' * * // 3. Complex data structures * export default async (rc: RenderContext) => { * const appState = { * user: { * id: 1, * preferences: { * theme: 'dark', * language: 'zh-CN' * } * }, * settings: { * notifications: true, * timezone: 'Asia/Shanghai' * } * }; * * rc.html = ` * <!DOCTYPE html> * <html> * <head> * ${rc.state('__APP_STATE__', appState)} * </head> * <body> * <div id="app"></div> * </body> * </html> * `; * }; * ``` */ state(varName, data) { return `<script>window[${serialize(varName)}] = ${serialize(data, { isJSON: true })};<\/script>`; } /** * Commit dependency collection and update resource list * @description * The commit method is the core of RenderContext's dependency collection mechanism, responsible for handling all collected module dependencies and updating the file resource list: * * 1. **Dependency Processing Flow** * - Collect all used modules from importMetaSet * - Parse specific resources for each module based on manifest files * - Handle different types of dependencies such as JS, CSS, resource files, etc. * - Automatically handle module preloading and import maps * * 2. **Resource Classification** * - js: JavaScript files, containing all scripts and modules * - css: Stylesheet files * - modulepreload: ESM modules that need to be preloaded * - importmap: Import map files * - resources: Other resource files (images, fonts, etc.) * * 3. **Path Processing** * - Automatically add base path prefix * - Ensure the correctness of resource paths * - Support resource isolation for multi-application scenarios * * @example * ```ts * // 1. Basic usage * export default async (rc: RenderContext) => { * // Render page and collect dependencies * const app = createApp(); * const html = await renderToString(app, { * importMetaSet: rc.importMetaSet * }); * * // Commit dependency collection * await rc.commit(); * * // Generate HTML * rc.html = ` * <!DOCTYPE html> * <html> * <head> * ${rc.preload()} * ${rc.css()} * </head> * <body> * ${html} * ${rc.importmap()} * ${rc.moduleEntry()} * ${rc.modulePreload()} * </body> * </html> * `; * }; * * // 2. Multi-application scenario * const rc = await esmx.render({ * base: '/app1', // Set base path * params: { appId: 1 } * }); * * // Render and commit dependencies * const html = await renderApp(rc); * await rc.commit(); * * // Resource paths will automatically add base path prefix * // For example: /app1/your-app-name/js/main.js * ``` */ async commit() { const { esmx } = this; const chunkSet = /* @__PURE__ */ new Set([`${esmx.name}@src/entry.client.ts`]); for (const item of this.importMetaSet) { if ("chunkName" in item && typeof item.chunkName === "string") { chunkSet.add(item.chunkName); } } const files = { js: /* @__PURE__ */ new Set(), modulepreload: /* @__PURE__ */ new Set(), css: /* @__PURE__ */ new Set(), resources: /* @__PURE__ */ new Set() }; const getUrlPath = (...paths2) => path.posix.join("/", this.base, ...paths2); const manifests = await this.esmx.getManifestList("client"); manifests.forEach((item) => { const addPath = (setName, filepath) => files[setName].add(getUrlPath(item.name, filepath)); const addPaths = (setName, filepaths) => filepaths.forEach((filepath) => addPath(setName, filepath)); Object.entries(item.chunks).forEach(([filepath, info]) => { if (chunkSet.has(filepath)) { addPath("js", info.js); addPaths("css", info.css); addPaths("resources", info.resources); } }); }); const paths = await esmx.getStaticImportPaths( "client", `${esmx.name}/src/entry.client` ); paths?.forEach( (filepath) => files.modulepreload.add(getUrlPath(filepath)) ); files.js = /* @__PURE__ */ new Set([...files.js, ...files.modulepreload]); Object.keys(files).forEach( (key) => this.files[key] = Array.from(files[key]) ); this._importMap = await esmx.getImportMapClientInfo(this.importmapMode); } /** * Generate resource preload tags * @description * The preload() method is used to generate resource preload tags, optimizing page performance by loading critical resources in advance: * * 1. **Resource Types** * - CSS files: Use `as="style"` to preload stylesheets * - JS files: Use `as="script"` to preload import map scripts * * 2. **Performance Optimization** * - Discover and load critical resources in advance * - Load in parallel with HTML parsing * - Optimize resource loading order * - Reduce page rendering blocking * * 3. **Best Practices** * - Use as early as possible in the head * - Only preload resources necessary for the current page * - Use in conjunction with other resource loading methods * * @returns Returns HTML string containing all preload tags * * @example * ```ts * // Use in HTML head * rc.html = ` * <!DOCTYPE html> * <html> * <head> * <!-- Preload critical resources --> * ${rc.preload()} * <!-- Inject stylesheets --> * ${rc.css()} * </head> * <body> * ${html} * ${rc.importmap()} * ${rc.moduleEntry()} * ${rc.modulePreload()} * </body> * </html> * `; * ``` */ preload() { const { files, _importMap } = this; const list = files.css.map((url) => { return `<link rel="preload" href="${url}" as="style">`; }); if (_importMap.src) { list.push( `<link rel="preload" href="${_importMap.src}" as="script">` ); } return list.join(""); } /** * Inject first-screen stylesheets * @description * The css() method is used to inject stylesheet resources required by the page: * * 1. **Injection Position** * - Must be injected in the head tag * - Avoid page flickering (FOUC) and reflow * - Ensure styles are in place when content is rendered * * 2. **Performance Optimization** * - Support critical CSS extraction * - Automatically handle style dependency relationships * - Utilize browser parallel loading capabilities * * 3. **Use Cases** * - Inject styles necessary for the first screen * - Handle component-level styles * - Support theme switching and dynamic styles * * @example * ```ts * // 1. Basic usage * rc.html = ` * <!DOCTYPE html> * <html> * <head> * ${rc.preload()} <!-- Preload resources --> * ${rc.css()} <!-- Inject stylesheets --> * </head> * <body> * <div id="app">Hello World</div> * </body> * </html> * `; * * // 2. Use in conjunction with other resources * rc.html = ` * <!DOCTYPE html> * <html> * <head> * ${rc.preload()} <!-- Preload resources --> * ${rc.css()} <!-- Inject stylesheets --> * </head> * <body> * ${html} * ${rc.importmap()} * ${rc.moduleEntry()} * ${rc.modulePreload()} * </body> * </html> * `; * ``` */ css() { return this.files.css.map((url) => `<link rel="stylesheet" href="${url}">`).join(""); } /** * Inject module import map * @description * The importmap() method is used to inject path resolution rules for ESM modules: * * 1. **Injection Position** * - Must be injected in the body * - Must be executed before moduleEntry * - Avoid blocking the first page render * * 2. **Import Map Modes** * - Inline mode (inline): * - Inline map content directly into HTML * - Suitable for scenarios with smaller map content * - Reduce the number of HTTP requests * - JS file mode (js): * - Generate independent JS files * - Suitable for scenarios with larger map content * - Can utilize browser caching mechanisms * * 3. **Technical Reasons** * - Define path resolution rules for ESM modules * - Client entry modules and their dependencies need to use these maps * - Ensure the map is correctly set before executing module code * * @example * ```ts * // 1. Basic usage - Inline mode * const rc = await esmx.render({ * importmapMode: 'inline' // Default mode * }); * * rc.html = ` * <!DOCTYPE html> * <html> * <head> * ${rc.preload()} * ${rc.css()} * </head> * <body> * ${html} * ${rc.importmap()} <!-- Inject import map --> * ${rc.moduleEntry()} <!-- Execute after import map --> * ${rc.modulePreload()} * </body> * </html> * `; * * // 2. JS file mode - Suitable for large applications * const rc = await esmx.render({ * importmapMode: 'js' // Use JS file mode * }); * ``` */ importmap() { return this._importMap.code; } /** * Inject client entry module * @description * The moduleEntry() method is used to inject the client's entry module: * 1. **Injection Position** * - Must be executed after importmap * - Ensure the import map is correctly set before executing module code * - Control the start timing of client activation (Hydration) * * 2. **Technical Reasons** * - Serve as the entry point for client code * - Need to wait for infrastructure (such as import maps) to be ready * - Ensure correct module path resolution * * 3. **Use Cases** * - Start the client application * - Execute client activation * - Initialize client state * * @example * ```ts * // 1. Basic usage * rc.html = ` * <!DOCTYPE html> * <html> * <head> * ${rc.preload()} * ${rc.css()} * </head> * <body> * ${html} * ${rc.importmap()} <!-- Inject import map first --> * ${rc.moduleEntry()} <!-- Then inject entry module --> * ${rc.modulePreload()} * </body> * </html> * `; * * // 2. Multiple entry configuration * const rc = await esmx.render({ * entryName: 'mobile', // Specify entry name * params: { device: 'mobile' } * }); * ``` */ moduleEntry() { return `<script type="module">import "${this.esmx.name}/src/entry.client";<\/script>`; } /** * Preload module dependencies * @description * The modulePreload() method is used to preload modules that may be needed later: * * 1. **Injection Position** * - Must be after importmap and moduleEntry * - Ensure the correct module path mapping is used * - Avoid competing with first-screen rendering for resources * * 2. **Performance Optimization** * - Preload modules that may be needed later * - Improve runtime performance * - Optimize on-demand loading experience * * 3. **Technical Reasons** * - Need correct path resolution rules * - Avoid duplicate loading * - Control loading priority * * @example * ```ts * // 1. Basic usage * rc.html = ` * <!DOCTYPE html> * <html> * <head> * ${rc.preload()} * ${rc.css()} * </head> * <body> * ${html} * ${rc.importmap()} * ${rc.moduleEntry()} * ${rc.modulePreload()} <!-- Preload module dependencies --> * </body> * </html> * `; * * // 2. Use with async components * const AsyncComponent = defineAsyncComponent(() => * import('./components/AsyncComponent.vue') * ); * // modulePreload will automatically collect and preload dependencies of async components * ``` */ modulePreload() { return this.files.modulepreload.map((url) => `<link rel="modulepreload" href="${url}">`).join(""); } }