@ordojs/core
Version:
Core compiler and runtime for OrdoJS framework
590 lines (576 loc) • 21.4 kB
JavaScript
/**
* @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