UNPKG

@ordojs/core

Version:

Core compiler and runtime for OrdoJS framework

590 lines (576 loc) 21.4 kB
/** * @fileoverview OrdoJS Code Splitter - Implements code splitting and lazy loading */ import {} from '../types/index.js'; import {} from './fs-router.js'; /** * Default code splitting configuration */ const DEFAULT_CONFIG = { enabled: true, chunkSizeThreshold: 50000, // 50KB routeBasedSplitting: true, componentBasedSplitting: true, maxChunks: 10, alwaysIncludeInMain: [] }; /** * Code splitter for automatic code splitting and lazy loading */ export class CodeSplitter { config; errors = []; warnings = []; componentRegistry = new Map(); routeRegistry = new Map(); dependencyGraph = new Map(); chunks = []; constructor(config = {}) { this.config = { ...DEFAULT_CONFIG, ...config }; } /** * Register a component for code splitting analysis */ registerComponent(component) { this.componentRegistry.set(component.component.name, component); } /** * Register multiple components */ registerComponents(components) { for (const component of components) { this.registerComponent(component); } } /** * Register routes for route-based code splitting */ registerRoutes(routes) { for (const route of routes) { this.routeRegistry.set(route.path, route); } } /** * Analyze dependencies and create chunks */ analyze() { this.reset(); try { // Step 1: Build dependency graph this.buildDependencyGraph(); // Step 2: Create initial chunks based on routes if enabled if (this.config.routeBasedSplitting) { this.createRouteBasedChunks(); } // Step 3: Create component-based chunks if enabled if (this.config.componentBasedSplitting) { this.createComponentBasedChunks(); } // Step 4: Optimize chunks (merge small chunks, split large ones) this.optimizeChunks(); // Step 5: Generate import map and lazy loading code const importMap = this.generateImportMap(); const lazyLoadingCode = this.generateLazyLoadingCode(); return { chunks: this.chunks, importMap, lazyLoadingCode, errors: this.errors, warnings: this.warnings }; } catch (error) { if (error instanceof Error) { this.errors.push({ message: `Code splitting error: ${error.message}`, position: { line: 0, column: 0, offset: 0 }, range: { start: { line: 0, column: 0, offset: 0 }, end: { line: 0, column: 0, offset: 0 } }, getErrorCode: () => 'OPT002', getSeverity: () => 'ERROR', getSuggestions: () => ['Check component dependencies', 'Verify route configuration'], toUserFriendlyMessage: () => `Code splitting error: ${error instanceof Error ? error.message : String(error)}` }); } return { chunks: [], importMap: {}, lazyLoadingCode: '', errors: this.errors, warnings: this.warnings }; } } /** * Get errors encountered during code splitting */ getErrors() { return this.errors; } /** * Get warnings encountered during code splitting */ getWarnings() { return this.warnings; } /** * Reset code splitter state */ reset() { this.errors = []; this.warnings = []; this.dependencyGraph = new Map(); this.chunks = []; } /** * Build dependency graph between components */ buildDependencyGraph() { // Initialize dependency graph for (const [componentName] of this.componentRegistry) { this.dependencyGraph.set(componentName, new Set()); } // Analyze component dependencies for (const [componentName, ast] of this.componentRegistry) { const dependencies = this.extractComponentDependencies(ast); // Filter out dependencies that don't exist in our registry const validDependencies = dependencies.filter(dep => this.componentRegistry.has(dep)); this.dependencyGraph.set(componentName, new Set(validDependencies)); } } /** * Extract component dependencies from AST */ extractComponentDependencies(ast) { // This is a simplified implementation // In a real implementation, we would analyze the markup for component usage return ast.dependencies || []; } /** * Create route-based chunks */ createRouteBasedChunks() { // Group routes by directory structure const routeGroups = new Map(); for (const route of this.routeRegistry.values()) { const routeDir = this.getRouteDirectory(route.path); if (!routeGroups.has(routeDir)) { routeGroups.set(routeDir, []); } routeGroups.get(routeDir).push(route); } // Create a chunk for each route group for (const [routeDir, routes] of routeGroups) { const chunkName = this.sanitizeChunkName(routeDir); const components = routes.map(route => route.componentName); // Skip empty chunks if (components.length === 0) continue; // Create chunk this.chunks.push({ id: `chunk_${this.chunks.length}`, name: chunkName, components, routes: routes.map(route => route.path), dependencies: this.getChunkDependencies(components), estimatedSize: this.estimateChunkSize(components), isMain: routeDir === '/' || routeDir === '' }); } } /** * Create component-based chunks */ createComponentBasedChunks() { // Find components not already in chunks const chunkedComponents = new Set(); for (const chunk of this.chunks) { for (const component of chunk.components) { chunkedComponents.add(component); } } const remainingComponents = Array.from(this.componentRegistry.keys()) .filter(component => !chunkedComponents.has(component)); // Group components by their dependencies const componentGroups = this.groupComponentsByDependencies(remainingComponents); // Create a chunk for each component group for (const [groupName, components] of componentGroups) { // Skip empty groups if (components.length === 0) continue; // Skip groups that are too small (will be merged into main chunk later) if (components.length === 1 && this.estimateChunkSize(components) < this.config.chunkSizeThreshold) { continue; } // Create chunk this.chunks.push({ id: `chunk_${this.chunks.length}`, name: groupName, components, routes: [], dependencies: this.getChunkDependencies(components), estimatedSize: this.estimateChunkSize(components), isMain: false }); } } /** * Group components by their dependencies */ groupComponentsByDependencies(components) { const groups = new Map(); // Simple grouping strategy: group by first-level dependencies for (const component of components) { const dependencies = this.dependencyGraph.get(component) || new Set(); if (dependencies.size === 0) { // Components with no dependencies go to "standalone" group const groupName = 'standalone'; if (!groups.has(groupName)) { groups.set(groupName, []); } groups.get(groupName).push(component); } else { // Use first dependency as group name const firstDep = Array.from(dependencies)[0]; const groupName = `group_${firstDep}`; if (!groups.has(groupName)) { groups.set(groupName, []); } groups.get(groupName).push(component); } } return groups; } /** * Optimize chunks by merging small chunks and splitting large ones */ optimizeChunks() { // Find the main chunk const mainChunkIndex = this.chunks.findIndex(chunk => chunk.isMain); let mainChunk; if (mainChunkIndex === -1) { // Create main chunk if it doesn't exist mainChunk = { id: 'chunk_main', name: 'main', components: [], routes: [], dependencies: [], estimatedSize: 0, isMain: true }; this.chunks.push(mainChunk); } else { mainChunk = this.chunks[mainChunkIndex]; } // Merge small chunks into main const smallChunks = this.chunks.filter(chunk => !chunk.isMain && chunk.estimatedSize < this.config.chunkSizeThreshold); for (const smallChunk of smallChunks) { // Add components to main chunk mainChunk.components.push(...smallChunk.components); mainChunk.routes.push(...smallChunk.routes); // Update main chunk size mainChunk.estimatedSize += smallChunk.estimatedSize; // Remove small chunk const index = this.chunks.findIndex(chunk => chunk.id === smallChunk.id); if (index !== -1) { this.chunks.splice(index, 1); } } // Split large chunks if needed const largeChunks = this.chunks.filter(chunk => chunk.estimatedSize > this.config.chunkSizeThreshold * 2); for (const largeChunk of largeChunks) { // Skip main chunk if (largeChunk.isMain) continue; // Split chunk if it has enough components if (largeChunk.components.length > 2) { const midpoint = Math.floor(largeChunk.components.length / 2); const firstHalf = largeChunk.components.slice(0, midpoint); const secondHalf = largeChunk.components.slice(midpoint); // Create two new chunks const chunk1 = { id: `${largeChunk.id}_1`, name: `${largeChunk.name}_1`, components: firstHalf, routes: largeChunk.routes.filter(route => { const componentName = this.getRouteComponentName(route); return firstHalf.includes(componentName); }), dependencies: this.getChunkDependencies(firstHalf), estimatedSize: this.estimateChunkSize(firstHalf), isMain: false }; const chunk2 = { id: `${largeChunk.id}_2`, name: `${largeChunk.name}_2`, components: secondHalf, routes: largeChunk.routes.filter(route => { const componentName = this.getRouteComponentName(route); return secondHalf.includes(componentName); }), dependencies: this.getChunkDependencies(secondHalf), estimatedSize: this.estimateChunkSize(secondHalf), isMain: false }; // Replace large chunk with two smaller chunks const index = this.chunks.findIndex(chunk => chunk.id === largeChunk.id); if (index !== -1) { this.chunks.splice(index, 1, chunk1, chunk2); } } } // Ensure we don't exceed max chunks if (this.chunks.length > this.config.maxChunks) { // Sort non-main chunks by size (ascending) const nonMainChunks = this.chunks .filter(chunk => !chunk.isMain) .sort((a, b) => a.estimatedSize - b.estimatedSize); // Merge smallest chunks until we're under the limit while (this.chunks.length > this.config.maxChunks) { if (nonMainChunks.length < 2) break; const smallest1 = nonMainChunks.shift(); const smallest2 = nonMainChunks.shift(); // Create merged chunk const mergedChunk = { id: `merged_${smallest1.id}_${smallest2.id}`, name: `merged_${smallest1.name}_${smallest2.name}`, components: [...smallest1.components, ...smallest2.components], routes: [...smallest1.routes, ...smallest2.routes], dependencies: [...new Set([ ...this.getChunkDependencies(smallest1.components), ...this.getChunkDependencies(smallest2.components) ])], estimatedSize: smallest1.estimatedSize + smallest2.estimatedSize, isMain: false }; // Remove original chunks this.chunks = this.chunks.filter(chunk => chunk.id !== smallest1.id && chunk.id !== smallest2.id); // Add merged chunk this.chunks.push(mergedChunk); // Add merged chunk back to sorted list nonMainChunks.push(mergedChunk); nonMainChunks.sort((a, b) => a.estimatedSize - b.estimatedSize); } } // Assign entry points to chunks for (const chunk of this.chunks) { if (chunk.routes.length > 0) { // Use first route component as entry point chunk.entryPoint = this.getRouteComponentName(chunk.routes[0]); } else if (chunk.components.length > 0) { // Use first component as entry point chunk.entryPoint = chunk.components[0]; } } } /** * Generate import map for dynamic imports */ generateImportMap() { const importMap = {}; for (const chunk of this.chunks) { if (chunk.isMain) continue; // Skip main chunk for (const component of chunk.components) { importMap[component] = `chunks/${chunk.name}.js`; } } return importMap; } /** * Generate lazy loading code */ generateLazyLoadingCode() { return ` /** * Generated lazy loading utilities */ // Import map for dynamic imports export const IMPORT_MAP = ${JSON.stringify(this.generateImportMap(), null, 2)}; // Chunk dependency map export const CHUNK_DEPENDENCIES = ${JSON.stringify(this.chunks.reduce((map, chunk) => { map[chunk.name] = chunk.dependencies; return map; }, {}), null, 2)}; // Loaded chunks cache const loadedChunks = new Set(); /** * Lazy load a component */ export async function lazyLoad(componentName) { const chunkPath = IMPORT_MAP[componentName]; if (!chunkPath) { // Component is in the main bundle return Promise.resolve(); } if (loadedChunks.has(chunkPath)) { // Chunk already loaded return Promise.resolve(); } try { // Load the chunk await import(/* webpackChunkName: "[request]" */ chunkPath); loadedChunks.add(chunkPath); return Promise.resolve(); } catch (error) { console.error(\`Failed to load component \${componentName}: \${error instanceof Error ? error.message : String(error)}\`); return Promise.reject(error); } } /** * Preload a component */ export function preload(componentName) { const chunkPath = IMPORT_MAP[componentName]; if (!chunkPath || loadedChunks.has(chunkPath)) { return Promise.resolve(); } // Use link preload for modern browsers const link = document.createElement('link'); link.rel = 'preload'; link.as = 'script'; link.href = chunkPath; document.head.appendChild(link); return Promise.resolve(); } /** * Preload all components for a route */ export function preloadRoute(routePath) { const routeComponents = ${JSON.stringify(Array.from(this.routeRegistry.entries()).reduce((map, [path, route]) => { map[path] = route.componentName; return map; }, {}), null, 2)}; const componentName = routeComponents[routePath]; if (componentName) { return preload(componentName); } return Promise.resolve(); } `; } /** * Get dependencies for a chunk */ getChunkDependencies(components) { const dependencies = new Set(); for (const component of components) { const componentDeps = this.dependencyGraph.get(component) || new Set(); for (const dep of componentDeps) { // Only add dependencies that are not in this chunk if (!components.includes(dep)) { dependencies.add(dep); } } } return Array.from(dependencies); } /** * Estimate chunk size based on component ASTs */ estimateChunkSize(components) { let size = 0; for (const component of components) { const ast = this.componentRegistry.get(component); if (ast) { // This is a very rough estimate based on AST node count size += this.countASTNodes(ast.component); } } // Convert node count to approximate byte size return size * 20; // Rough estimate: each node is about 20 bytes of JS } /** * Count AST nodes in a component */ countASTNodes(component) { let count = 1; // Count the component itself // Count client block nodes if (component.clientBlock) { count += 1; // The block itself count += component.clientBlock.reactiveVariables.length; count += component.clientBlock.computedValues.length; count += component.clientBlock.eventHandlers.length; count += component.clientBlock.functions.length; count += component.clientBlock.lifecycle.length; } // Count server block nodes if (component.serverBlock) { count += 1; // The block itself count += component.serverBlock.functions.length; count += component.serverBlock.middleware.length; count += component.serverBlock.dataFetchers.length; count += component.serverBlock.imports.length; } // Count markup block nodes if (component.markupBlock) { count += 1; // The block itself count += component.markupBlock.elements.length; count += component.markupBlock.textNodes.length; count += component.markupBlock.interpolations.length; // Count HTML elements recursively for (const element of component.markupBlock.elements) { count += this.countHTMLElementNodes(element); } } return count; } /** * Count nodes in an HTML element recursively */ countHTMLElementNodes(element) { let count = 1; // Count the element itself // Count attributes count += element.attributes.length; // Count children recursively for (const child of element.children) { if (child.type === 'HTMLElement') { count += this.countHTMLElementNodes(child); } else { count += 1; // Text or interpolation node } } return count; } /** * Get route directory from path */ getRouteDirectory(routePath) { // Extract directory structure from route path const parts = routePath.split('/').filter(Boolean); if (parts.length === 0) { return '/'; } // Remove dynamic parameters const staticParts = parts.filter(part => !part.startsWith(':') && !part.startsWith('*')); if (staticParts.length === 0) { return '/'; } return staticParts[0]; } /** * Get component name for a route */ getRouteComponentName(routePath) { const route = this.routeRegistry.get(routePath); return route ? route.componentName : ''; } /** * Sanitize chunk name for file system */ sanitizeChunkName(name) { return name .replace(/^\/+/, '') // Remove leading slashes .replace(/[^a-zA-Z0-9_-]/g, '_') // Replace invalid chars .toLowerCase() || 'index'; } } //# sourceMappingURL=code-splitter.js.map