UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

741 lines (653 loc) 24.1 kB
import { join } from "node:path"; import { $atom, $env, $hook, $inject, $state, Alepha, type Middleware, OPTIONS, PipelineHandler, type Static, z, } from "alepha"; import { $logger } from "alepha/logger"; import { ServerHeadProvider } from "alepha/react/head"; import { type ServerHandler, ServerRouterProvider } from "alepha/server"; import { ServerLinksProvider } from "alepha/server/links"; import { ServerStaticProvider } from "alepha/server/static"; import { FileSystemProvider } from "alepha/system"; import { renderToReadableStream } from "react-dom/server"; import { Redirection } from "../errors/Redirection.ts"; import { $page, type PagePrimitiveRenderOptions, type PagePrimitiveRenderResult, } from "../primitives/$page.ts"; import { type PageRoute, ReactPageProvider, type ReactRouterState, reactPageOptions, } from "./ReactPageProvider.ts"; import { ReactServerTemplateProvider } from "./ReactServerTemplateProvider.ts"; import { RouterLocaleProvider } from "./RouterLocaleProvider.ts"; import { SSRManifestProvider } from "./SSRManifestProvider.ts"; /** * React server provider responsible for SSR and static file serving. * * Coordinates between: * - ReactPageProvider: Page routing and layer resolution * - ReactServerTemplateProvider: HTML template parsing and streaming * - ServerHeadProvider: Head content management * - SSRManifestProvider: Module preload link collection * * Uses `react-dom/server` under the hood. */ export class ReactServerProvider { /** * SSR response headers - pre-allocated to avoid object creation per request. */ protected readonly SSR_HEADERS = { "content-type": "text/html", "cache-control": "no-store, no-cache, must-revalidate, proxy-revalidate", pragma: "no-cache", expires: "0", } as const; protected readonly fs = $inject(FileSystemProvider); protected readonly log = $logger(); protected readonly alepha = $inject(Alepha); protected readonly env = $env(envSchema); protected readonly pageApi = $inject(ReactPageProvider); protected readonly templateProvider = $inject(ReactServerTemplateProvider); protected readonly serverHeadProvider = $inject(ServerHeadProvider); protected readonly serverStaticProvider = $inject(ServerStaticProvider); protected readonly serverRouterProvider = $inject(ServerRouterProvider); protected readonly ssrManifestProvider = $inject(SSRManifestProvider); protected readonly localeProvider = $inject(RouterLocaleProvider); /** * Cached check for ServerLinksProvider - avoids has() lookup per request. */ protected hasServerLinksProvider = false; protected readonly options = $state(reactServerOptions); protected readonly pageOptions = $state(reactPageOptions); /** * Configure the React server provider. */ public readonly onConfigure = $hook({ on: "configure", handler: async () => { const pages = this.alepha.primitives($page); const ssrEnabled = pages.length > 0 && this.env.REACT_SSR_ENABLED !== false; this.alepha.store.set("alepha.react.server.ssr", ssrEnabled); // production mode let root = ""; // non-serverless mode only -> serve static files if (!this.alepha.isServerless() && !this.alepha.isViteDev()) { root = await this.getPublicDirectory(); if (!root) { this.log.warn( "Missing static files, static file server will be disabled", ); } else { this.log.debug(`Using static files from: ${root}`); await this.configureStaticServer(root); } } if (ssrEnabled) { this.registerPages(); this.log.info("SSR OK"); return; } // no SSR enabled, serve a minimal fallback this.log.info("SSR is disabled"); }, }); /** * Register all pages as server routes. */ protected registerPages(): void { // Set up early head content (entry assets) this.setupEarlyHeadContent(); // Cache ServerLinksProvider check at startup this.hasServerLinksProvider = this.alepha.has(ServerLinksProvider); for (const page of this.pageApi.getPages()) { if (page.component || page.lazy || page.redirect) { this.log.debug(`+ ${page.match} -> ${page.name}`); // Collect middleware from the entire parent chain + own page. // Parent middleware runs first (outermost → innermost). const allMiddleware = this.collectMiddleware(page); // Separate $cache from server-level middleware. // $cache is applied inside createHandler around the render function, // not around the entire server handler (which works via side effects). const cacheMiddleware = allMiddleware.filter( (m) => m[OPTIONS]?.name === "$cache", ); const serverMiddleware = allMiddleware.filter( (m) => m[OPTIONS]?.name !== "$cache", ); const rawHandler = this.createHandler(page, cacheMiddleware); const handler = serverMiddleware.length ? new PipelineHandler(rawHandler, serverMiddleware) : rawHandler; // Canonical (default-locale) route, served unprefixed. this.serverRouterProvider.createRoute({ ...page, schema: undefined, // schema is handled by the page primitive provider // A page's `static` is page-shaped (`{ entries }`) and handled by the // page prerender pass — it must not leak into the route's boolean // `static` snapshot flag. static: undefined, method: "GET", path: page.match, handler, }); // Locale-prefixed variants (`/fr/about`, …) point at the SAME handler. // The handler reads the active locale back out of the request URL, so // no extra route param is needed and matching stays native. if (this.localeProvider.enabled) { for (const locale of this.localeProvider.prefixedLocales) { const prefixedPath = this.localeProvider.withPrefix( page.match, locale, ); this.log.debug(`+ ${prefixedPath} -> ${page.name} (${locale})`); this.serverRouterProvider.createRoute({ ...page, schema: undefined, static: undefined, method: "GET", path: prefixedPath, handler, }); } } } } } /** * Set up early head content with entry assets. * * This content is sent immediately when streaming starts, before page loaders run, * allowing the browser to start downloading entry.js and CSS files early. */ protected setupEarlyHeadContent(): void { const globalHead = this.serverHeadProvider.resolveGlobalHead(); const manifest = this.ssrManifestProvider.getManifest(); const faviconTag = this.buildFaviconTag(manifest.favicon); // Dev mode: use pre-transformed head content from Vite if (manifest.devHead) { const devContent = faviconTag ? `${faviconTag}\n${manifest.devHead}\n` : `${manifest.devHead}\n`; this.templateProvider.setEarlyHeadContent(devContent, globalHead); this.log.debug("Early head content set (dev mode)"); return; } // Production: build from SSR manifest entry assets const parts: string[] = []; if (faviconTag) { parts.push(faviconTag); } const assets = this.ssrManifestProvider.getEntryAssets(); if (assets) { for (const css of assets.css) { parts.push(`<link rel="stylesheet" href="${css}">`); } if (assets.js) { parts.push( `<script type="module" crossorigin="" src="${assets.js}"></script>`, ); } } this.templateProvider.setEarlyHeadContent( parts.length > 0 ? `${parts.join("\n")}\n` : "", globalHead, ); this.log.debug("Early head content set", { parts: parts.length, }); } /** * Build a favicon link tag from the manifest favicon value. * Format is "mimeType:/path" (e.g., "image/svg+xml:/favicon.svg"). */ protected buildFaviconTag(favicon: string | undefined): string | undefined { if (!favicon) { return undefined; } const colonIndex = favicon.indexOf(":"); if (colonIndex === -1) { return undefined; } const type = favicon.slice(0, colonIndex); const href = favicon.slice(colonIndex + 1); return `<link rel="icon" type="${type}" href="${href}">`; } /** * Get the public directory path where static files are located. */ protected async getPublicDirectory(): Promise<string> { const maybe = [ join(process.cwd(), `dist/${this.options.publicDir}`), join(process.cwd(), this.options.publicDir), ]; for (const it of maybe) { if (await this.fs.exists(it)) { return it; } } return ""; } /** * Configure the static file server to serve files from the given root directory. */ protected async configureStaticServer(root: string) { await this.serverStaticProvider.createStaticServer({ root, cacheControl: { maxAge: 3600, immutable: true, }, ...this.options.staticServer, }); } /** * Resolve the static file pattern from page options. * Returns a compiled RegExp, or `false` if disabled (empty string). */ protected resolveStaticFilePattern(): RegExp | false { const pattern = this.pageOptions.staticFilePattern; if (!pattern) return false; return new RegExp(pattern); } /** * Collect middleware from the entire parent chain + the page itself. * Parent middleware runs first (outermost → innermost → page). */ protected collectMiddleware(page: PageRoute): Middleware[] { const chain: Middleware[][] = []; let current: PageRoute | undefined = page; while (current) { if (current.use?.length) { chain.unshift(current.use); } current = current.parent; } return chain.flat(); } /** * Create the request handler for a page route. * * When cacheMiddleware is provided, uses a non-streaming path that renders * to a string so the result can be cached. Otherwise uses early HTML streaming. */ protected createHandler( route: PageRoute, cacheMiddleware: Middleware[] = [], ): ServerHandler { const hasCache = cacheMiddleware.length > 0; const isCatchAll = route.match === "/*"; const staticFilePattern = isCatchAll ? this.resolveStaticFilePattern() : false; return async (serverRequest) => { const { url, reply, query, params } = serverRequest; // Skip SSR for file-like URLs hitting the catch-all wildcard. // Bots and crawlers often probe paths like /hello.txt, /wp-login.php, etc. // Rendering a full React page for these is wasteful — return a plain 404 instead. // biome-ignore lint/complexity/useOptionalChain: staticFilePattern is `false | RegExp`; optional chaining doesn't narrow `false` if (staticFilePattern && staticFilePattern.test(url.pathname)) { reply.status = 404; reply.headers["content-type"] = "text/plain"; return "Not Found"; } this.log.trace("Rendering page", { name: route.name }); // Locale-prefix mode: the URL is the source of truth for language. // Record the active locale so links built during SSR (`pathname()`) // carry the prefix, and so hreflang alternates can be emitted. if (this.localeProvider.enabled) { this.localeProvider.current = this.localeProvider.detect( url.pathname, ).locale; } // Initialize router state const state: ReactRouterState = { url, params, query, name: route.name, onError: () => null, layers: [], meta: {}, head: {}, }; // Set up API links if available if (this.hasServerLinksProvider) { this.alepha.store.set( "alepha.server.request.apiLinks", await this.alepha.inject(ServerLinksProvider).getUserApiLinks({ user: (serverRequest as any).user, // TODO: fix type authorization: serverRequest.headers.authorization, }), ); } // Route access control is enforced by `use: [$secure(...)]` middleware // in the loader pipeline (below). `$page.can` is a UI-only nav gate // (sidebar visibility / disabled state) and is intentionally NOT consulted // here — it has no server-side permission context to evaluate against. await this.alepha.events.emit("react:server:render:begin", { request: serverRequest, state, }); // Apply SSR headers early Object.assign(reply.headers, this.SSR_HEADERS); if (hasCache) { // When $cache middleware is present, render to string so the result // is serializable. Streaming is not compatible with $cache since // ReadableStream cannot be serialized/deserialized. const renderFn = async ( _url: string, ): Promise<{ html: string; redirect?: string }> => { const { redirect, reactStream } = await this.renderPage(route, state); if (redirect) { return { redirect, html: "" }; } const htmlStream = this.templateProvider.createHtmlStream( reactStream!, state, { hydration: true }, ); return { html: await this.streamToString(htmlStream) }; }; const result = await new PipelineHandler(renderFn, cacheMiddleware).run( url.href, ); if (result.redirect) { reply.status = 302; reply.headers.location = result.redirect; return; } route.onServerResponse?.(serverRequest); reply.body = result.html; return; } // Resolve global head for early streaming (htmlAttributes only) let globalHead = this.serverHeadProvider.resolveGlobalHead(); // In locale-prefix mode the language is known from the URL before any // rendering, so stamp the correct `<html lang>` onto the early head // (per-request copy — never mutate the shared global head). if (this.localeProvider.enabled) { globalHead = { ...globalHead, htmlAttributes: { ...globalHead?.htmlAttributes, lang: this.localeProvider.current, }, }; } // Create optimized HTML stream with early head const htmlStream = this.templateProvider.createEarlyHtmlStream( globalHead, async () => { // === ASYNC WORK (runs while early head is being sent) === const result = await this.renderPage(route, state); if (result.redirect) { // Return redirect URL - template provider will inject meta refresh // since HTTP headers have already been sent return { redirect: result.redirect }; } return { state, reactStream: result.reactStream! }; }, { hydration: true, state, onError: (error) => { if (error instanceof Redirection) { this.log.debug("Streaming resulted in redirection", { redirect: error.redirect, }); // Can't do redirect after streaming started - already handled above } else { // disable logging here, it's noisy and duplicate // this.log.error("HTML stream error", error); } }, }, ); this.log.trace("Page streaming started (early head optimization)"); route.onServerResponse?.(serverRequest); reply.body = htmlStream.pipeThrough( new TransformStream({ flush: () => { this.log.info("Page streaming completed", { name: route.name }); }, }), ); }; } // --------------------------------------------------------------------------- // Core rendering logic - shared between SSR handler and static prerendering // --------------------------------------------------------------------------- /** * Core page rendering logic shared between SSR handler and static prerendering. * * Handles: * - Layer resolution (loaders) * - Redirect detection * - Head content filling * - Preload link collection * - React stream rendering * * @param route - The page route to render * @param state - The router state * @returns Render result with redirect or React stream */ /** * Inject SEO `hreflang` alternate links for the current route when * locale-prefix routing is enabled. Each registered locale gets an absolute * alternate URL (the default locale stays unprefixed), plus an `x-default` * pointing at the unprefixed URL — this is what lets crawlers index every * language. Also sets a best-effort `<html lang>` for the non-streaming / * prerender path; the streamed path's `<html lang>` is finalized on the * client (the early HTML head is flushed before the route is known). * * No-op unless `routing: "prefix"` is enabled on the i18n module. */ protected injectLocaleHead(state: ReactRouterState): void { if (!this.localeProvider.enabled) { return; } const { origin, search } = state.url; const canonical = this.localeProvider.detect(state.url.pathname).pathname; const links = this.localeProvider.locales.map((locale) => ({ rel: "alternate", hreflang: locale, href: `${origin}${this.localeProvider.withPrefix(canonical, locale)}${search}`, })); links.push({ rel: "alternate", hreflang: "x-default", href: `${origin}${canonical}${search}`, }); state.head ??= {}; state.head.link = [...(state.head.link ?? []), ...links]; state.head.htmlAttributes = { ...state.head.htmlAttributes, lang: this.localeProvider.current, }; } protected async renderPage( route: PageRoute, state: ReactRouterState, ): Promise<{ redirect?: string; reactStream?: ReadableStream<Uint8Array> }> { // Resolve page layers (loaders) const { redirect } = await this.pageApi.createLayers(route, state); if (redirect) { this.log.debug("Resolver resulted in redirection", { redirect }); return { redirect }; } // Fill head from route config this.serverHeadProvider.fillHead(state); // Collect and inject modulepreload links for page-specific chunks const preloadLinks = this.ssrManifestProvider.collectPreloadLinks(route); if (preloadLinks.length > 0) { state.head ??= {}; state.head.link = [...(state.head.link ?? []), ...preloadLinks]; } // Inject SEO hreflang alternates for locale-prefix routing this.injectLocaleHead(state); // Render React to stream const element = this.pageApi.root(state); this.alepha.store.set("alepha.react.router.state", state); const reactStream = await renderToReadableStream(element, { onError: (error: unknown) => { if (error instanceof Redirection) { this.log.warn("Redirect during streaming ignored", { redirect: error.redirect, }); } else { // disable logging here, it's noisy and duplicate // this.log.error("Streaming render error", error); } }, }); return { reactStream }; } // --------------------------------------------------------------------------- // Testing utilities - kept for backwards compatibility with tests // --------------------------------------------------------------------------- /** * For testing purposes, renders a page to HTML string. * Uses the same streaming code path as production, then collects to string. * * @param name - Page name to render * @param options - Render options (params, query, html, hydration) */ public async render( name: string, options: PagePrimitiveRenderOptions = {}, ): Promise<PagePrimitiveRenderResult> { const page = this.pageApi.page(name); const url = new URL(this.pageApi.url(name, options)); const state: ReactRouterState = { url, params: options.params ?? {}, query: options.query ?? {}, onError: () => null, layers: [], meta: {}, head: {}, }; this.log.trace("Rendering", { url }); await this.alepha.events.emit("react:server:render:begin", { state }); // Render page and collect the stream into a serializable result. // This must happen inside the middleware pipeline so that $cache // wraps a function returning { html, redirect? } — not a ReadableStream. // The URL string is passed as argument so middleware like $cache can // derive a unique cache key per URL (params + query). const renderFn = async ( _url: string, ): Promise<{ html: string; redirect?: string; }> => { const { redirect, reactStream } = await this.renderPage(page, state); if (redirect) { return { redirect, html: "" }; } if (!options.html) { return { html: await this.streamToString(reactStream!) }; } const htmlStream = this.templateProvider.createHtmlStream( reactStream!, state, { hydration: options.hydration ?? true }, ); return { html: await this.streamToString(htmlStream) }; }; const allMiddleware = this.collectMiddleware(page); const result = allMiddleware.length ? await new PipelineHandler(renderFn, allMiddleware).run(url.href) : await renderFn(url.href); if (result.redirect) { return { state, html: "", redirect: result.redirect }; } await this.alepha.events.emit("react:server:render:end", { state, html: result.html, }); return { state, html: result.html }; } /** * Collect a ReadableStream into a string. */ protected async streamToString( stream: ReadableStream<Uint8Array>, ): Promise<string> { const reader = stream.getReader(); const decoder = new TextDecoder(); const chunks: string[] = []; try { while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(decoder.decode(value, { stream: true })); } chunks.push(decoder.decode()); // Flush remaining } finally { reader.releaseLock(); } return chunks.join(""); } } // --------------------------------------------------------------------------------------------------------------------- const envSchema = z.object({ REACT_SSR_ENABLED: z .boolean() .describe( "Enable or disable server-side rendering (SSR) for React pages. When set to false, pages are rendered client-side only.", ) .optional(), }); declare module "alepha" { interface Env extends Partial<Static<typeof envSchema>> {} interface State { "alepha.react.server.ssr"?: boolean; } } /** * React server provider configuration atom */ /** * Default pattern matching file-like URLs (e.g. /hello.txt, /wp-login.php). * Matches paths whose last segment contains a dot followed by 1-10 alphanumeric characters. */ export const DEFAULT_STATIC_FILE_PATTERN = "\\.[a-zA-Z0-9]{1,10}$"; export const reactServerOptions = $atom({ name: "alepha.react.server.options", schema: z.object({ publicDir: z.string(), staticServer: z.object({ disabled: z.boolean(), path: z.string().describe("URL path where static files will be served."), }), }), default: { publicDir: "public", staticServer: { disabled: false, path: "/", }, }, }); export type ReactServerProviderOptions = Static< typeof reactServerOptions.schema >; declare module "alepha" { interface State { [reactServerOptions.key]: ReactServerProviderOptions; } }