Skip to main content
Glama
ViteReactBundlerService.ts28.8 kB
/** * ViteReactBundlerService * * DESIGN PATTERNS: * - Concrete implementation of BaseBundlerService * - Singleton pattern for dev server management * - Programmatic Vite build for React component SSR * * CODING STANDARDS: * - Use async/await for asynchronous operations * - Throw descriptive errors for error cases * - Keep methods focused and well-named * - Document complex logic with comments * * AVOID: * - Mixing concerns (keep focused on Vite + React) * - Direct tool implementation (services should be tool-agnostic) */ import { promises as fs } from 'node:fs'; import type { IncomingMessage, ServerResponse } from 'node:http'; import path from 'node:path'; import { log, TemplatesManagerService } from '@agiflowai/aicode-utils'; import tailwindcss from '@tailwindcss/vite'; import type { Connect, Plugin, ViteDevServer } from 'vite'; import { viteSingleFile } from 'vite-plugin-singlefile'; import { BaseBundlerService } from './BaseBundlerService'; import type { BuildOptions, BundlerServiceConfig, DevServerResult, PrerenderResult, RenderOptions, ServeComponentResult, } from './types'; /** * Maximum age for story configs in milliseconds (5 minutes). * Configs older than this are cleaned up to prevent memory leaks. */ const STORY_CONFIG_MAX_AGE_MS = 5 * 60 * 1000; /** * Maximum number of story configs to keep in memory. * Oldest configs are removed when this limit is exceeded. */ const STORY_CONFIG_MAX_COUNT = 100; /** * Maximum size for serialized args in bytes (1MB). * Prevents memory issues with extremely large payloads. */ const MAX_ARGS_SIZE_BYTES = 1024 * 1024; /** * Valid pattern for story names. * Allows alphanumeric characters, underscores, hyphens, and spaces. */ const VALID_STORY_NAME_PATTERN = /^[a-zA-Z0-9_\- ]+$/; /** * Valid pattern for component paths. * Must end with .stories.tsx, .stories.ts, .stories.jsx, or .stories.js */ const VALID_COMPONENT_PATH_PATTERN = /\.stories\.(tsx?|jsx?)$/; /** * Validates a story name to prevent code injection. * @param storyName - The story name to validate * @throws Error if the story name contains invalid characters */ function validateStoryName(storyName: string): void { if (!storyName || typeof storyName !== 'string') { throw new Error('Story name is required and must be a string'); } if (!VALID_STORY_NAME_PATTERN.test(storyName)) { throw new Error( `Story name "${storyName}" contains invalid characters. Only alphanumeric characters, underscores, hyphens, and spaces are allowed.`, ); } } /** * Validates a component path to prevent code injection. * @param componentPath - The component path to validate * @throws Error if the component path is invalid */ function validateComponentPath(componentPath: string): void { if (!componentPath || typeof componentPath !== 'string') { throw new Error('Component path is required and must be a string'); } if (!VALID_COMPONENT_PATH_PATTERN.test(componentPath)) { throw new Error( `Component path "${componentPath}" must be a valid Storybook story file (*.stories.tsx, *.stories.ts, *.stories.jsx, or *.stories.js)`, ); } // Prevent path traversal attacks if (componentPath.includes('..')) { throw new Error('Component path must not contain path traversal sequences (..)'); } } /** * Validates args object size to prevent memory issues. * @param args - The args object to validate * @throws Error if args exceed size limit */ function validateArgsSize(args: Record<string, unknown>): void { const serialized = JSON.stringify(args); const sizeBytes = Buffer.byteLength(serialized, 'utf-8'); if (sizeBytes > MAX_ARGS_SIZE_BYTES) { throw new Error( `Args payload too large: ${sizeBytes} bytes exceeds maximum of ${MAX_ARGS_SIZE_BYTES} bytes (1MB)`, ); } } /** * Validates CSS file paths to prevent path traversal attacks. * @param cssFiles - Array of CSS file paths to validate * @param workspaceRoot - The workspace root directory * @throws Error if any path is invalid or escapes workspace */ function validateCssFiles(cssFiles: string[], workspaceRoot: string): void { for (const cssFile of cssFiles) { if (typeof cssFile !== 'string') { throw new Error('CSS file path must be a string'); } // Check for path traversal sequences if (cssFile.includes('..')) { throw new Error(`CSS file path "${cssFile}" must not contain path traversal sequences (..)`); } // Check for absolute paths that escape workspace if (path.isAbsolute(cssFile)) { const normalizedPath = path.normalize(cssFile); if (!normalizedPath.startsWith(workspaceRoot)) { throw new Error(`CSS file path "${cssFile}" must be within workspace boundaries`); } } // Validate file extension const ext = path.extname(cssFile).toLowerCase(); if (!['.css', '.scss', '.sass', '.less', '.pcss', '.postcss'].includes(ext)) { throw new Error(`CSS file "${cssFile}" must have a valid CSS extension (.css, .scss, .sass, .less, .pcss)`); } } } /** * Helper to create a Vite plugin that serves story entry files from memory. */ function createStoryEntryPlugin( getStoryConfig: (id: string) => BuildOptions | undefined, generateCode: (opts: BuildOptions) => string, ): Plugin { // Virtual module pattern for serving story entry files from memory // The /@virtual:story-entry?id=xxx pattern is used in HTML script src return { name: 'vite-plugin-story-entry', enforce: 'pre', // Run before Vite's built-in plugins (especially vite:build-html) resolveId(id: string): string | undefined { // Match virtual module requests (with or without leading /) if (id.includes('virtual:story-entry')) { log.debug(`[vite-plugin-story-entry] resolveId: ${id}`); return `\0${id.replace(/^\//, '')}`; // Remove leading / if present } return undefined; }, load(id: string): string | undefined { if (id.includes('virtual:story-entry')) { log.debug(`[vite-plugin-story-entry] load: ${id}`); // Extract story ID from the virtual module id const idMatch = id.match(/id=([^&]+)/); const storyId = idMatch?.[1]; if (storyId) { const config = getStoryConfig(storyId); if (config) { return generateCode(config); } } throw new Error(`[Vite] Story config not found for id: ${storyId}`); } return undefined; }, }; } /** * ViteReactBundlerService provides Vite + React bundling for component rendering. * * This is the default implementation of BaseBundlerService that uses Vite * as the bundler and React as the framework for rendering components. * * @example * ```typescript * const service = ViteReactBundlerService.getInstance(); * await service.startDevServer('apps/my-app'); * const { url } = await service.serveComponent({ * componentPath: '/path/to/Button.stories.tsx', * storyName: 'Primary', * appPath: 'apps/my-app' * }); * ``` */ export class ViteReactBundlerService extends BaseBundlerService { private static instance: ViteReactBundlerService | null = null; private server: ViteDevServer | null = null; private monorepoRoot: string; private serverUrl: string | null = null; private serverPort: number | null = null; private currentAppPath: string | null = null; private storyConfigs = new Map<string, BuildOptions>(); /** Timestamps for when each story config was created, used for cleanup */ private storyConfigTimestamps = new Map<string, number>(); /** Promise that resolves when server startup completes, prevents race conditions */ private serverStartPromise: Promise<DevServerResult> | null = null; /** * Creates a new ViteReactBundlerService instance. * Use getInstance() for singleton access. * @param config - Service configuration options */ constructor(config: BundlerServiceConfig = {}) { super(config); this.monorepoRoot = TemplatesManagerService.getWorkspaceRootSync(); } /** * Get the singleton instance of ViteReactBundlerService. * Singleton pattern ensures only one dev server runs at a time, * preventing port conflicts and resource duplication. * @returns The singleton ViteReactBundlerService instance */ static getInstance(): ViteReactBundlerService { // Create instance only if it doesn't exist (lazy initialization) if (!ViteReactBundlerService.instance) { ViteReactBundlerService.instance = new ViteReactBundlerService(); } return ViteReactBundlerService.instance; } /** * Reset the singleton instance. * This is primarily used in testing to ensure a fresh instance. * @example * ```typescript * afterEach(() => { * ViteReactBundlerService.resetInstance(); * }); * ``` */ static resetInstance(): void { ViteReactBundlerService.instance = null; } /** * Get the bundler identifier. * @returns The bundler ID string ('vite') */ getBundlerId(): string { return 'vite'; } /** * Get the framework identifier. * @returns The framework ID string ('react') */ getFrameworkId(): string { return 'react'; } /** * Get the current server URL. * @returns Server URL or null if not running */ getServerUrl(): string | null { return this.serverUrl; } /** * Get the current server port. * @returns Server port or null if not running */ getServerPort(): number | null { return this.serverPort; } /** * Check if the dev server is running. * @returns True if server is running */ isServerRunning(): boolean { return this.server !== null && this.serverUrl !== null; } /** * Get the current app path being served. * @returns App path or null if not running */ getCurrentAppPath(): string | null { return this.currentAppPath; } /** * Start a Vite dev server for hot reload and caching. * Handles concurrent calls by returning the same promise if server is already starting. * @param appPath - Absolute or relative path to the app directory * @returns Promise resolving to server URL and port * @throws Error if server fails to start */ async startDevServer(appPath: string): Promise<DevServerResult> { const resolvedAppPath = path.isAbsolute(appPath) ? appPath : path.join(this.monorepoRoot, appPath); // If server is already running for the same app, return existing info if (this.isServerRunning() && this.currentAppPath === resolvedAppPath) { log.info(`[ViteReactBundlerService] Server already running for ${resolvedAppPath}`); return { url: this.serverUrl!, port: this.serverPort! }; } // If server is currently starting, wait for it to complete (prevents race condition) if (this.serverStartPromise) { log.info('[ViteReactBundlerService] Server start already in progress, waiting...'); return this.serverStartPromise; } // Start the server and store the promise to prevent concurrent starts this.serverStartPromise = this.doStartDevServer(resolvedAppPath); try { return await this.serverStartPromise; } finally { // Clear the promise after completion (success or failure) this.serverStartPromise = null; } } /** * Internal method that performs the actual server startup. * @param resolvedAppPath - Resolved absolute path to the app directory * @returns Promise resolving to server URL and port */ private async doStartDevServer(resolvedAppPath: string): Promise<DevServerResult> { const tmpDir = path.join(resolvedAppPath, '.tmp'); try { await fs.mkdir(tmpDir, { recursive: true }); const { createServer } = await import('vite'); this.server = await createServer({ root: tmpDir, base: '/', configFile: false, plugins: [ tailwindcss(), createStoryEntryPlugin( (id) => this.storyConfigs.get(id), (opts) => this.generateEntryFile(opts), ), ], resolve: { alias: { '@': path.join(resolvedAppPath, 'src'), }, }, esbuild: { jsx: 'automatic', jsxImportSource: 'react', }, server: { strictPort: false, open: false, }, }); // Add middleware to serve HTML from memory this.server.middlewares.use(async (req: IncomingMessage, res: ServerResponse, next: Connect.NextFunction) => { const url = req.url || '/'; // Match preview URLs like /preview/{storyId} - captures the storyId parameter const match = url.match(/^\/preview\/([^/?]+)/); if (match) { const storyId = match[1]; const config = this.storyConfigs.get(storyId); if (config) { try { const htmlTemplate = this.generateHtmlTemplate(`@virtual:story-entry?id=${storyId}`, config.darkMode); // Transform HTML using Vite (injects client scripts, HMR, etc.) const transformedHtml = await this.server!.transformIndexHtml(url, htmlTemplate); res.statusCode = 200; res.setHeader('Content-Type', 'text/html'); res.end(transformedHtml); return; } catch (e) { const err = e as Error; log.error(`[ViteMiddleware] Error serving preview: ${err.message}`); next(err); return; } } } next(); }); await this.server.listen(); // httpServer.address() returns AddressInfo object on success, string for pipe/socket, or null on error // We need the AddressInfo object to extract the port number const address = this.server.httpServer?.address(); if (!address || typeof address === 'string') { throw new Error( 'Failed to start Vite dev server. Ensure no other process is using the port.', ); } const port = address.port; const url = `http://localhost:${port}`; this.serverUrl = url; this.serverPort = port; this.currentAppPath = resolvedAppPath; log.info(`[ViteReactBundlerService] Vite dev server started at ${url}`); return { url, port }; } catch (error) { const err = new Error( `Failed to start dev server for ${resolvedAppPath}: ${error instanceof Error ? error.message : String(error)}`, ); err.cause = error; throw err; } } /** * Serve a component dynamically through the dev server. * @param options - Component rendering options * @returns Promise resolving to the component URL and HTML file path * @throws Error if dev server is not running or file operations fail */ async serveComponent(options: RenderOptions): Promise<ServeComponentResult> { if (!this.isServerRunning()) { throw new Error('Dev server is not running. Start it first using startDevServer().'); } const { componentPath, storyName, args = {}, darkMode = false, appPath, cssFiles = [], rootComponent } = options; // Validate inputs to prevent code injection, memory issues, and path traversal validateStoryName(storyName); validateComponentPath(componentPath); validateArgsSize(args); validateCssFiles(cssFiles, this.monorepoRoot); const resolvedAppPath = path.isAbsolute(appPath) ? appPath : path.join(this.monorepoRoot, appPath); if (this.currentAppPath !== resolvedAppPath) { throw new Error( `Dev server is running for ${this.currentAppPath} but requested ${resolvedAppPath}.`, ); } const tmpDir = path.join(resolvedAppPath, '.tmp'); try { // Clean up stale story configs to prevent memory leaks this.cleanupStaleStoryConfigs(); // Create tmpDir if it doesn't exist await fs.mkdir(tmpDir, { recursive: true }); // Write wrapper CSS with @source directive for Tailwind v4 content scanning const wrapperCssPath = path.join(tmpDir, 'tailwind-wrapper.css'); const wrapperCssContent = this.generateWrapperCss(resolvedAppPath, cssFiles); await fs.writeFile(wrapperCssPath, wrapperCssContent, 'utf-8'); const timestamp = Date.now(); const storyId = `${timestamp}-${Math.random().toString(36).slice(2)}`; // Store config in memory with timestamp for cleanup this.storyConfigs.set(storyId, { componentPath, storyName, args, appPath: resolvedAppPath, darkMode, cssFiles, rootComponent, tmpDir, }); this.storyConfigTimestamps.set(storyId, timestamp); const url = `${this.serverUrl}/preview/${storyId}`; const htmlContent = this.generateHtmlTemplate(`@virtual:story-entry?id=${storyId}`, darkMode); log.info(`[ViteReactBundlerService] Component served at: ${url}`); return { url, htmlContent, }; } catch (error) { const err = new Error( `Failed to serve component ${storyName}: ${error instanceof Error ? error.message : String(error)}`, ); err.cause = error; throw err; } } /** * Pre-render a component to a static HTML file. * @param options - Component rendering options * @returns Promise resolving to the HTML file path * @throws Error if build fails */ async prerenderComponent(options: RenderOptions): Promise<PrerenderResult> { const { componentPath, storyName, args = {}, darkMode = false, appPath, cssFiles = [], rootComponent } = options; // Validate inputs to prevent code injection, memory issues, and path traversal validateStoryName(storyName); validateComponentPath(componentPath); validateArgsSize(args); validateCssFiles(cssFiles, this.monorepoRoot); const resolvedAppPath = path.isAbsolute(appPath) ? appPath : path.join(this.monorepoRoot, appPath); const tmpDir = path.join(resolvedAppPath, '.tmp'); try { await fs.mkdir(tmpDir, { recursive: true }); const htmlFilePath = await this.buildComponent({ componentPath, storyName, args, appPath: resolvedAppPath, darkMode, cssFiles, rootComponent, tmpDir, }); return { htmlFilePath }; } catch (error) { const err = new Error( `Failed to prerender component ${storyName}: ${error instanceof Error ? error.message : String(error)}`, ); err.cause = error; throw err; } } /** * Clean up server resources and reset state. * Closes the Vite dev server if running. * * Note: Errors during server close are intentionally logged but not re-thrown. * This ensures cleanup always completes and state is reset, even if the server * is in an unexpected state. Callers should not depend on cleanup failure detection. */ async cleanup(): Promise<void> { if (this.server) { log.info('[ViteReactBundlerService] Closing Vite dev server...'); try { await this.server.close(); } catch (error) { // Intentionally suppressed - cleanup should always complete log.error(`[ViteReactBundlerService] Error closing server: ${error instanceof Error ? error.message : String(error)}`); } this.server = null; this.serverUrl = null; this.serverPort = null; this.currentAppPath = null; this.serverStartPromise = null; // Clear story configs to free memory this.storyConfigs.clear(); this.storyConfigTimestamps.clear(); log.info('[ViteReactBundlerService] Vite dev server closed'); } } /** * Clean up stale story configs to prevent memory leaks. * Removes configs that are older than STORY_CONFIG_MAX_AGE_MS or * when the number of configs exceeds STORY_CONFIG_MAX_COUNT. */ private cleanupStaleStoryConfigs(): void { const now = Date.now(); const entriesToDelete: string[] = []; // Find configs that are too old for (const [storyId, timestamp] of this.storyConfigTimestamps) { if (now - timestamp > STORY_CONFIG_MAX_AGE_MS) { entriesToDelete.push(storyId); } } // Delete stale entries for (const storyId of entriesToDelete) { this.storyConfigs.delete(storyId); this.storyConfigTimestamps.delete(storyId); } if (entriesToDelete.length > 0) { log.debug(`[ViteReactBundlerService] Cleaned up ${entriesToDelete.length} stale story configs`); } // If still over limit, remove oldest entries if (this.storyConfigs.size > STORY_CONFIG_MAX_COUNT) { const sortedEntries = Array.from(this.storyConfigTimestamps.entries()) .sort((a, b) => a[1] - b[1]); // Sort by timestamp ascending (oldest first) const toRemove = sortedEntries.slice(0, this.storyConfigs.size - STORY_CONFIG_MAX_COUNT); for (const [storyId] of toRemove) { this.storyConfigs.delete(storyId); this.storyConfigTimestamps.delete(storyId); } log.debug(`[ViteReactBundlerService] Removed ${toRemove.length} oldest story configs to stay under limit`); } } /** * Generate a wrapper CSS file with @source directive for Tailwind v4. * This tells Tailwind where to scan for class names when building from .tmp directory. * @param appPath - Absolute path to the app directory * @param cssFiles - Array of CSS file paths to import * @returns Generated CSS content with @source directive */ private generateWrapperCss(appPath: string, cssFiles: string[]): string { // Generate CSS imports based on path patterns const cssImportStatements = cssFiles .map((cssFile) => { if (cssFile.startsWith('@') || cssFile.startsWith('tailwindcss/')) { return `@import '${cssFile}';`; } if (cssFile.startsWith('packages/') || cssFile.startsWith('apps/')) { return `@import '${path.join(this.monorepoRoot, cssFile)}';`; } return `@import '${path.join(appPath, cssFile)}';`; }) .join('\n'); // Add @source directive to tell Tailwind v4 where to find component files // This is necessary because we're building from .tmp but components are in src/ return `/* Tailwind v4 source configuration for component scanning */ @source "${path.join(appPath, 'src')}"; ${cssImportStatements} `; } /** * Generate the React entry file content for rendering a story. * @param options - Build options including component path and story name * @returns Generated TypeScript/JSX entry file content */ private generateEntryFile(options: BuildOptions): string { const { componentPath, storyName, args, appPath, darkMode, rootComponent, tmpDir } = options; const argsJson = JSON.stringify(args, null, 2); // Use absolute path to wrapper CSS with @source directive for Tailwind v4 // Virtual modules don't have a real location, so relative paths don't work const wrapperCssPath = path.join(tmpDir!, 'tailwind-wrapper.css').replace(/\\/g, '/'); const cssImports = `import '${wrapperCssPath}';`; // Import root component wrapper if specified (e.g., theme provider, layout wrapper) const rootComponentImport = rootComponent ? `import { RootDocument } from '${path.join(appPath, rootComponent).replace(/\\/g, '/')}';` : ''; // Determine wrapper component - use RootDocument if provided, otherwise React.Fragment const wrapWithRoot = rootComponent ? 'RootDocument' : 'React.Fragment'; const wrapperProps = rootComponent ? `{ darkMode: ${darkMode} }` : '{}'; // Generate the entry file with the following structure: // 1. CSS imports for styling // 2. React and story imports // 3. Story resolution and args merging // 4. Element creation (supports both render function and component patterns) // 5. Wrapper application and DOM mounting return `${cssImports} import React from 'react'; import { createRoot } from 'react-dom/client'; import * as Stories from '${componentPath}'; ${rootComponentImport} // Extract story metadata and the specific story to render const meta = Stories.default; const Story = Stories['${storyName}']; const storyArgs = Story?.args || {}; // Merge story's default args with any custom args passed in const args = { ...storyArgs, ...${argsJson} }; // Create the story element - Storybook stories can define rendering in two ways: // 1. A render() function that receives args and context // 2. A component reference in meta.component let element; if (Story?.render) { // Story has custom render function - call it with args and minimal context const RenderComponent = () => Story.render(args, { loaded: {}, args }); element = React.createElement(RenderComponent); } else { // Story uses component from meta - render with merged args as props const Component = meta.component; element = React.createElement(Component, args); } // Wrap element with root component (theme provider, etc.) if specified const Wrapper = ${wrapWithRoot}; const wrappedElement = React.createElement(Wrapper, ${wrapperProps}, element); // Mount to DOM const rootEl = document.getElementById('root'); if (!rootEl) throw new Error('Root element not found'); const root = createRoot(rootEl); root.render(wrappedElement); `; } /** * Generate the HTML template for component preview. * @param entryFileName - Name of the entry file to include * @param darkMode - Whether to add dark mode class to HTML * @returns Generated HTML template string */ private generateHtmlTemplate(entryFileName: string, darkMode: boolean): string { return `<!DOCTYPE html> <html class="${darkMode ? 'dark' : ''}"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Component Preview</title> <style> body { margin: 0; padding: 0; } #root { display: inline-block; } </style> </head> <body> <div id="root"></div> <script type="module" src="/${entryFileName}"></script> </body> </html>`; } private async buildComponent(options: BuildOptions): Promise<string> { const { appPath, darkMode, tmpDir, cssFiles = [] } = options; const timestamp = Date.now(); try { // Write wrapper CSS with @source directive for Tailwind v4 content scanning const wrapperCssPath = path.join(tmpDir, 'tailwind-wrapper.css'); const wrapperCssContent = this.generateWrapperCss(appPath, cssFiles); await fs.writeFile(wrapperCssPath, wrapperCssContent, 'utf-8'); // Use a unique ID for the virtual module in this build const storyId = `build-${timestamp}`; const virtualModuleId = `@virtual:story-entry?id=${storyId}`; // Create a temporary HTML file that points to the virtual module // We still need a physical HTML file as the input for Vite's build.rollupOptions.input const htmlTemplate = this.generateHtmlTemplate(virtualModuleId, darkMode); const htmlTemplateFileName = `index-${timestamp}.html`; const htmlTemplatePath = path.join(tmpDir, htmlTemplateFileName); await fs.writeFile(htmlTemplatePath, htmlTemplate, 'utf-8'); const { build } = await import('vite'); const outDir = path.join(tmpDir, `dist-${timestamp}`); // Create a map for this specific build const buildStoryConfigs = new Map<string, BuildOptions>(); buildStoryConfigs.set(storyId, options); await build({ root: tmpDir, base: './', configFile: false, plugins: [ tailwindcss(), viteSingleFile(), createStoryEntryPlugin( (id) => buildStoryConfigs.get(id), (opts) => this.generateEntryFile(opts), ), ], resolve: { alias: { '@': path.join(appPath, 'src'), }, }, esbuild: { jsx: 'automatic', jsxImportSource: 'react', }, build: { outDir, emptyOutDir: false, cssCodeSplit: false, rollupOptions: { input: htmlTemplatePath, output: { inlineDynamicImports: true, }, }, }, }); const builtHtmlPath = path.join(outDir, htmlTemplateFileName); log.info(`[ViteReactBundlerService] Component built to: ${builtHtmlPath}`); // Clean up the input HTML file - non-critical, log debug on failure await fs.unlink(htmlTemplatePath).catch((err) => log.debug(`[ViteReactBundlerService] Failed to cleanup temp file: ${err.message}`)); return builtHtmlPath; } catch (error) { const err = new Error( `Failed to build component: ${error instanceof Error ? error.message : String(error)}`, ); err.cause = error; throw err; } } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/AgiFlow/aicode-toolkit'

If you have feedback or need assistance with the MCP directory API, please join our Discord server