UNPKG

@ordojs/core

Version:

Core compiler and runtime for OrdoJS framework

374 lines (316 loc) 9.7 kB
/** * @fileoverview OrdoJS SSR Engine - Server-side rendering implementation */ import { type ComponentAST, type ComponentData, type HydrationData, type Props } from '../types/index.js'; import { OrdoJSCodeGenerator } from './code-generator-fixed.js'; /** * SSR Engine options */ export interface SSROptions { /** * Whether to include hydration markers in the generated HTML */ includeHydrationMarkers: boolean; /** * Whether to include component data for client-side hydration */ includeHydrationData: boolean; /** * Custom data fetching function */ dataFetcher?: (component: string, props: Props) => Promise<Record<string, any>>; /** * Route configuration for server-side routing */ routes?: RouteConfig[]; } /** * Route configuration for server-side routing */ export interface RouteConfig { /** * Route path pattern (e.g., '/users/:id') */ path: string; /** * Component name to render for this route */ component: string; /** * Data fetching function for this route */ dataFetcher?: (params: Record<string, string>, query: Record<string, string>) => Promise<Record<string, any>>; /** * Layout component to wrap the route component */ layout?: string; } /** * Default SSR options */ const DEFAULT_SSR_OPTIONS: SSROptions = { includeHydrationMarkers: true, includeHydrationData: true }; /** * OrdoJS SSR Engine * Handles server-side rendering of components */ export class OrdoJSSSR { options: SSROptions; private codeGenerator: OrdoJSCodeGenerator; private componentRegistry: Map<string, ComponentAST> = new Map(); constructor(options: Partial<SSROptions> = {}) { this.options = { ...DEFAULT_SSR_OPTIONS, ...options }; this.codeGenerator = new OrdoJSCodeGenerator({ minify: false, sourceMaps: false, target: 'production' }); } /** * Register a component for SSR */ registerComponent(ast: ComponentAST): void { this.componentRegistry.set(ast.component.name, ast); } /** * Register multiple components for SSR */ registerComponents(components: ComponentAST[]): void { for (const component of components) { this.registerComponent(component); } } /** * Render a component to HTML string */ async renderComponent(componentName: string, props: Props = {}): Promise<string> { const ast = this.componentRegistry.get(componentName); if (!ast) { throw new Error(`Component "${componentName}" not found in registry`); } // Fetch data if needed let componentData = {}; if (this.options.dataFetcher) { componentData = await this.options.dataFetcher(componentName, props); } // Generate HTML with hydration markers const html = this.codeGenerator.generateHTML(ast, { ...props, ...componentData }); // Generate hydration data if needed if (this.options.includeHydrationData) { const hydrationData = this.generateHydrationData(ast, { ...props, ...componentData }); return this.injectHydrationData(html, hydrationData); } return html; } /** * Generate hydration data for a component */ generateHydrationData(ast: ComponentAST, data: Record<string, any> = {}): HydrationData { return { componentName: ast.component.name, componentId: `ordojs_${ast.component.name}_${Date.now().toString(36)}`, props: data, initialState: this.extractInitialState(ast), version: '1.0' }; } /** * Extract initial state from component AST */ private extractInitialState(ast: ComponentAST): Record<string, any> { const initialState: Record<string, any> = {}; if (ast.component.clientBlock) { for (const variable of ast.component.clientBlock.reactiveVariables) { if (variable.initialValue && variable.initialValue.expressionType === 'LITERAL') { initialState[variable.name] = variable.initialValue.value; } else { // For non-literal initial values, we'll use null as a placeholder initialState[variable.name] = null; } } } return initialState; } /** * Inject hydration data into HTML */ private injectHydrationData(html: string, hydrationData: HydrationData): string { const hydrationScript = ` <script type="application/json" id="ordojs-hydration-data"> ${JSON.stringify(hydrationData, null, 2)} </script> `; // Insert before closing body tag if present, otherwise append if (html.includes('</body>')) { return html.replace('</body>', `${hydrationScript}</body>`); } return html + hydrationScript; } /** * Handle data fetching for SSR */ async handleDataFetching(componentName: string, props: Props = {}): Promise<ComponentData> { const ast = this.componentRegistry.get(componentName); if (!ast || !ast.component.serverBlock) { return { props }; } // Find data fetching functions in server block const dataFetchers = ast.component.serverBlock.functions.filter( f => f.name === 'getServerSideProps' || f.name === 'getStaticProps' ); if (dataFetchers.length === 0) { return { props }; } // Execute data fetcher function if available if (this.options.dataFetcher) { const data = await this.options.dataFetcher(componentName, props); return { props: { ...props, ...data } }; } return { props }; } /** * Render a route to HTML */ async renderRoute(url: string): Promise<string> { if (!this.options.routes || this.options.routes.length === 0) { throw new Error('No routes configured for SSR'); } // Parse URL const parsedUrl = new URL(url, 'http://localhost'); const pathname = parsedUrl.pathname; // Find matching route const route = this.findMatchingRoute(pathname); if (!route) { throw new Error(`No route found for path: ${pathname}`); } // Extract route params and query params const params = this.extractRouteParams(route.path, pathname); const query = this.extractQueryParams(parsedUrl.searchParams); // Fetch data for route let routeData = {}; if (route.dataFetcher) { routeData = await route.dataFetcher(params, query); } // Render component with data const componentHtml = await this.renderComponent(route.component, { ...routeData, routeParams: params, queryParams: query }); // Wrap with layout if specified if (route.layout) { return this.wrapWithLayout(componentHtml, route.layout, { ...routeData, routeParams: params, queryParams: query }); } return componentHtml; } /** * Find a matching route for a path */ private findMatchingRoute(pathname: string): RouteConfig | undefined { if (!this.options.routes) return undefined; return this.options.routes.find(route => { // Convert route path to regex const pattern = route.path .replace(/:[^/]+/g, '([^/]+)') .replace(/\*/g, '.*'); const regex = new RegExp(`^${pattern}$`); return regex.test(pathname); }); } /** * Extract route parameters from path */ private extractRouteParams(routePath: string, pathname: string): Record<string, string> { const params: Record<string, string> = {}; // Extract param names from route path const paramNames = (routePath.match(/:[^/]+/g) || []) .map(param => param.substring(1)); // Extract param values from pathname const pattern = routePath .replace(/:[^/]+/g, '([^/]+)') .replace(/\*/g, '.*'); const regex = new RegExp(`^${pattern}$`); const matches = pathname.match(regex); if (matches && matches.length > 1 && paramNames.length > 0) { // Skip the first match (full string) for (let i = 0; i < paramNames.length; i++) { if (i < matches.length - 1) { params[paramNames[i]] = matches[i + 1]; } } } return params; } /** * Extract query parameters from URL */ private extractQueryParams(searchParams: URLSearchParams): Record<string, string> { const query: Record<string, string> = {}; searchParams.forEach((value, key) => { query[key] = value; }); return query; } /** * Wrap component HTML with a layout */ private async wrapWithLayout( componentHtml: string, layoutName: string, data: Record<string, any> ): Promise<string> { const ast = this.componentRegistry.get(layoutName); if (!ast) { throw new Error(`Layout "${layoutName}" not found in registry`); } // Replace layout content placeholder with component HTML const layoutHtml = this.codeGenerator.generateHTML(ast, data); // Replace content placeholder with component HTML return layoutHtml.replace( /<div[^>]*data-content-placeholder[^>]*>.*?<\/div>/i, componentHtml ); } /** * Generate a complete HTML document with SSR content */ generateDocument( content: string, title: string = 'OrdoJS App', scripts: string[] = [], styles: string[] = [] ): string { const scriptTags = scripts .map(src => `<script src="${src}" defer></script>`) .join('\n '); const styleTags = styles .map(href => `<link rel="stylesheet" href="${href}">`) .join('\n '); return `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>${title}</title> ${styleTags} </head> <body> <div id="app">${content}</div> ${scriptTags} </body> </html>`; } }