@ordojs/core
Version:
Core compiler and runtime for OrdoJS framework
799 lines (675 loc) • 21.4 kB
text/typescript
/**
* @fileoverview OrdoJS Code Splitter - Implements code splitting and lazy loading
*/
import {
type ComponentAST,
type ComponentNode,
type HTMLElementNode,
type OptimizationError
} from '../types/index.js';
import { type Route } from './fs-router.js';
/**
* Code splitting configuration
*/
export interface CodeSplittingConfig {
/**
* Whether to enable code splitting
*/
enabled: boolean;
/**
* Chunk size threshold in bytes
*/
chunkSizeThreshold: number;
/**
* Whether to enable route-based splitting
*/
routeBasedSplitting: boolean;
/**
* Whether to enable component-based splitting
*/
componentBasedSplitting: boolean;
/**
* Maximum number of chunks
*/
maxChunks: number;
/**
* Paths to always include in the main bundle
*/
alwaysIncludeInMain: string[];
}
/**
* Default code splitting configuration
*/
const DEFAULT_CONFIG: CodeSplittingConfig = {
enabled: true,
chunkSizeThreshold: 50000, // 50KB
routeBasedSplitting: true,
componentBasedSplitting: true,
maxChunks: 10,
alwaysIncludeInMain: []
};
/**
* Chunk information
*/
export interface ChunkInfo {
/**
* Chunk ID
*/
id: string;
/**
* Chunk name
*/
name: string;
/**
* Components in this chunk
*/
components: string[];
/**
* Routes in this chunk
*/
routes: string[];
/**
* Dependencies on other chunks
*/
dependencies: string[];
/**
* Estimated size in bytes
*/
estimatedSize: number;
/**
* Whether this is the main chunk
*/
isMain: boolean;
/**
* Entry point for this chunk
*/
entryPoint?: string;
}
/**
* Code splitting result
*/
export interface CodeSplittingResult {
/**
* Chunks generated
*/
chunks: ChunkInfo[];
/**
* Import map for dynamic imports
*/
importMap: Record<string, string>;
/**
* Lazy loading code
*/
lazyLoadingCode: string;
/**
* Errors encountered during code splitting
*/
errors: OptimizationError[];
/**
* Warnings encountered during code splitting
*/
warnings: OptimizationError[];
}
/**
* Code splitter for automatic code splitting and lazy loading
*/
export class CodeSplitter {
private config: CodeSplittingConfig;
private errors: OptimizationError[] = [];
private warnings: OptimizationError[] = [];
private componentRegistry: Map<string, ComponentAST> = new Map();
private routeRegistry: Map<string, Route> = new Map();
private dependencyGraph: Map<string, Set<string>> = new Map();
private chunks: ChunkInfo[] = [];
constructor(config: Partial<CodeSplittingConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
/**
* Register a component for code splitting analysis
*/
registerComponent(component: ComponentAST): void {
this.componentRegistry.set(component.component.name, component);
}
/**
* Register multiple components
*/
registerComponents(components: ComponentAST[]): void {
for (const component of components) {
this.registerComponent(component);
}
}
/**
* Register routes for route-based code splitting
*/
registerRoutes(routes: Route[]): void {
for (const route of routes) {
this.routeRegistry.set(route.path, route);
}
}
/**
* Analyze dependencies and create chunks
*/
analyze(): CodeSplittingResult {
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)}`
} as unknown as OptimizationError);
}
return {
chunks: [],
importMap: {},
lazyLoadingCode: '',
errors: this.errors,
warnings: this.warnings
};
}
}
/**
* Get errors encountered during code splitting
*/
getErrors(): OptimizationError[] {
return this.errors;
}
/**
* Get warnings encountered during code splitting
*/
getWarnings(): OptimizationError[] {
return this.warnings;
}
/**
* Reset code splitter state
*/
private reset(): void {
this.errors = [];
this.warnings = [];
this.dependencyGraph = new Map();
this.chunks = [];
}
/**
* Build dependency graph between components
*/
private buildDependencyGraph(): void {
// 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
*/
private extractComponentDependencies(ast: ComponentAST): string[] {
// This is a simplified implementation
// In a real implementation, we would analyze the markup for component usage
return ast.dependencies || [];
}
/**
* Create route-based chunks
*/
private createRouteBasedChunks(): void {
// Group routes by directory structure
const routeGroups = new Map<string, Route[]>();
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
*/
private createComponentBasedChunks(): void {
// Find components not already in chunks
const chunkedComponents = new Set<string>();
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
*/
private groupComponentsByDependencies(components: string[]): Map<string, string[]> {
const groups = new Map<string, string[]>();
// 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
*/
private optimizeChunks(): void {
// Find the main chunk
const mainChunkIndex = this.chunks.findIndex(chunk => chunk.isMain);
let mainChunk: ChunkInfo;
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: ChunkInfo = {
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: ChunkInfo = {
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: ChunkInfo = {
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
*/
private generateImportMap(): Record<string, string> {
const importMap: Record<string, string> = {};
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
*/
private generateLazyLoadingCode(): string {
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;
}, {} as Record<string, string[]>),
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;
}, {} as Record<string, string>),
null, 2
)};
const componentName = routeComponents[routePath];
if (componentName) {
return preload(componentName);
}
return Promise.resolve();
}
`;
}
/**
* Get dependencies for a chunk
*/
private getChunkDependencies(components: string[]): string[] {
const dependencies = new Set<string>();
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
*/
private estimateChunkSize(components: string[]): number {
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
*/
private countASTNodes(component: ComponentNode): number {
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
*/
private countHTMLElementNodes(element: HTMLElementNode): number {
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 as HTMLElementNode);
} else {
count += 1; // Text or interpolation node
}
}
return count;
}
/**
* Get route directory from path
*/
private getRouteDirectory(routePath: string): string {
// 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
*/
private getRouteComponentName(routePath: string): string {
const route = this.routeRegistry.get(routePath);
return route ? route.componentName : '';
}
/**
* Sanitize chunk name for file system
*/
private sanitizeChunkName(name: string): string {
return name
.replace(/^\/+/, '') // Remove leading slashes
.replace(/[^a-zA-Z0-9_-]/g, '_') // Replace invalid chars
.toLowerCase() || 'index';
}
}