UNPKG

one

Version:

One is a new React Framework that makes Vite serve both native and web.

550 lines (489 loc) • 18 kB
import { join } from 'node:path' import FSExtra from 'fs-extra' import * as constants from '../constants' import type { LoaderProps, RenderApp } from '../types' import { getLoaderPath, getPreloadCSSPath, getPreloadPath } from '../utils/cleanUrl' import { isResponse } from '../utils/isResponse' import { toAbsolute } from '../utils/toAbsolute' import { replaceLoader } from '../vite/replaceLoader' import type { One, RouteInfo } from '../vite/types' const { readFile, outputFile } = FSExtra // Convert URL path (with forward slashes) to filesystem path for cross-platform compatibility function urlPathToFilePath(urlPath: string): string { // Remove leading slash and split by forward slash (URL separator) const parts = urlPath.replace(/^\//, '').split('/') return join(...parts) } // timing helper for build profiling const buildTiming = process.env.ONE_BUILD_TIMING === '1' const timings: Record<string, number[]> = {} function recordTiming(label: string, ms: number) { if (!buildTiming) return ;(timings[label] ||= []).push(ms) } export function printBuildTimings() { if (!buildTiming) return console.info('\nšŸ“Š Build timing breakdown:') for (const [label, times] of Object.entries(timings)) { const avg = times.reduce((a, b) => a + b, 0) / times.length const total = times.reduce((a, b) => a + b, 0) console.info( ` ${label}: ${avg.toFixed(1)}ms avg, ${total.toFixed(0)}ms total (${times.length} calls)` ) } } export async function buildPage( serverEntry: string, path: string, relativeId: string, params: any, foundRoute: RouteInfo<string>, clientManifestEntry: any, staticDir: string, clientDir: string, builtMiddlewares: Record<string, string>, serverJsPath: string, preloads: string[], allCSS: string[], routePreloads: Record<string, string>, allCSSContents?: string[], criticalPreloads?: string[], deferredPreloads?: string[], useAfterLCP?: boolean, useAfterLCPAggressive?: boolean ): Promise<One.RouteBuildInfo> { let t0 = performance.now() const render = await getRender(serverEntry) recordTiming('getRender', performance.now() - t0) const htmlPath = `${path.endsWith('/') ? `${removeTrailingSlash(path)}/index` : path}.html` const clientJsPath = clientManifestEntry ? join(`dist/client`, clientManifestEntry.file) : '' const htmlOutPath = toAbsolute(join(staticDir, htmlPath)) const preloadPath = getPreloadPath(path) const cssPreloadPath = getPreloadCSSPath(path) let loaderPath = '' let loaderData = {} try { // generate preload file with route module registration const routeImports: string[] = [] const routeRegistrations: string[] = [] let routeIndex = 0 for (const [routeKey, bundlePath] of Object.entries(routePreloads)) { const varName = `_r${routeIndex++}` routeImports.push(`import * as ${varName} from "${bundlePath}"`) routeRegistrations.push(`registerPreloadedRoute("${routeKey}", ${varName})`) } // Use window global for registration since ES module exports get tree-shaken const registrationCalls = routeRegistrations.map((call) => call.replace('registerPreloadedRoute(', 'window.__oneRegisterPreloadedRoute(') ) const preloadContent = [ // import all route modules ...routeImports, // static imports for cache warming (original behavior) ...preloads.map((preload) => `import "${preload}"`), // register all route modules using window global ...registrationCalls, ].join('\n') t0 = performance.now() await FSExtra.writeFile( join(clientDir, urlPathToFilePath(preloadPath)), preloadContent ) recordTiming('writePreload', performance.now() - t0) // Generate CSS preload file with prefetch (on hover) and inject (on navigation) functions // Deduplicate CSS URLs to avoid loading the same file multiple times const uniqueCSS = [...new Set(allCSS)] const cssPreloadContent = ` const CSS_TIMEOUT = 1000 const cssUrls = ${JSON.stringify(uniqueCSS)} // Global cache for loaded CSS - avoids DOM queries and tracks across navigations const loaded = (window.__oneLoadedCSS ||= new Set()) // Prefetch CSS without applying - called on link hover export function prefetchCSS() { cssUrls.forEach(href => { if (loaded.has(href)) return if (document.querySelector(\`link[href="\${href}"]\`)) return const link = document.createElement('link') link.rel = 'prefetch' link.as = 'style' link.href = href document.head.appendChild(link) }) } // Inject CSS to apply styles - called on actual navigation export function injectCSS() { return Promise.all(cssUrls.map(href => { // Skip if already loaded if (loaded.has(href)) return Promise.resolve() // Remove any prefetch link for this href const prefetchLink = document.querySelector(\`link[rel="prefetch"][href="\${href}"]\`) if (prefetchLink) prefetchLink.remove() // Skip if stylesheet already exists in DOM if (document.querySelector(\`link[rel="stylesheet"][href="\${href}"]\`)) { loaded.add(href) return Promise.resolve() } return new Promise(resolve => { const link = document.createElement('link') link.rel = 'stylesheet' link.href = href const timeoutId = setTimeout(() => { console.warn('[one] CSS load timeout:', href) loaded.add(href) resolve() }, CSS_TIMEOUT) link.onload = link.onerror = () => { clearTimeout(timeoutId) loaded.add(href) resolve() } document.head.appendChild(link) }) })) } // For backwards compatibility, also prefetch on import prefetchCSS() ` t0 = performance.now() await FSExtra.writeFile( join(clientDir, urlPathToFilePath(cssPreloadPath)), cssPreloadContent ) recordTiming('writeCSSPreload', performance.now() - t0) t0 = performance.now() const exported = await import(toAbsolute(serverJsPath)) recordTiming('importServerModule', performance.now() - t0) const loaderProps: LoaderProps = { path, params } // Build matches array for useMatches() hook const matches: One.RouteMatch[] = [] // Run layout loaders in parallel t0 = performance.now() if (foundRoute.layouts?.length) { const layoutResults = await Promise.all( foundRoute.layouts.map(async (layout) => { try { const layoutServerPath = layout.loaderServerPath if (!layoutServerPath) { return { contextKey: layout.contextKey, loaderData: undefined } } const layoutExported = await import( toAbsolute(join('./', 'dist/server', layoutServerPath)) ) const layoutLoaderData = await layoutExported?.loader?.(loaderProps) return { contextKey: layout.contextKey, loaderData: layoutLoaderData } } catch (err) { if (isResponse(err)) { throw err } console.warn( `[one] Warning: layout loader failed for ${layout.contextKey}:`, err ) return { contextKey: layout.contextKey, loaderData: undefined } } }) ) for (const result of layoutResults) { matches.push({ routeId: result.contextKey, pathname: path, params: params || {}, loaderData: result.loaderData, }) } } recordTiming('layoutLoaders', performance.now() - t0) // Run page loader t0 = performance.now() let loaderRedirectInfo: { path: string; status: number } | null = null if (exported.loader) { try { loaderData = (await exported.loader?.(loaderProps)) ?? null } catch (err) { // handle thrown responses (e.g., throw redirect('/login')) // extract redirect info so we can generate a static redirect loader file if (isResponse(err)) { loaderRedirectInfo = extractRedirectInfo(err as Response) } else { throw err } } // handle returned redirect responses (e.g., return redirect('/login')) // check both isResponse and constructor name for cross-context compatibility if ( !loaderRedirectInfo && loaderData && (isResponse(loaderData) || loaderData instanceof Response || loaderData?.constructor?.name === 'Response') ) { loaderRedirectInfo = extractRedirectInfo(loaderData as Response) loaderData = {} } if (clientJsPath) { const loaderPartialPath = join(clientDir, urlPathToFilePath(getLoaderPath(path))) if (loaderRedirectInfo) { // generate a static redirect loader — the client detects __oneRedirect // and navigates before the protected page ever renders const redirectData = JSON.stringify({ __oneRedirect: loaderRedirectInfo.path, __oneRedirectStatus: loaderRedirectInfo.status, }) await outputFile( loaderPartialPath, `export function loader(){return ${redirectData}}` ) loaderPath = getLoaderPath(path) loaderData = {} } else { const code = await readFile(clientJsPath, 'utf-8') const withLoader = // super dirty to quickly make ssr loaders work until we have better ` if (typeof document === 'undefined') globalThis.document = {} ` + replaceLoader({ code, loaderData, }) await outputFile(loaderPartialPath, withLoader) loaderPath = getLoaderPath(path) } } } recordTiming('pageLoader', performance.now() - t0) // Add page match matches.push({ routeId: foundRoute.file, pathname: path, params: params || {}, loaderData, }) // ssr, we basically skip at build-time and just compile it the js we need if (foundRoute.type !== 'ssr') { // importing resetState causes issues :/ globalThis['__vxrnresetState']?.() if (foundRoute.type === 'ssg') { // Aggressive mode: only modulepreload critical scripts, skip deferred to prevent network saturation // Regular after-lcp mode: modulepreload all scripts for parallel downloads, defer execution // Default: all scripts load normally const renderPreloads = criticalPreloads || preloads const renderDeferredPreloads = useAfterLCPAggressive ? [] : deferredPreloads t0 = performance.now() let html = await render({ path, preloads: renderPreloads, deferredPreloads: renderDeferredPreloads, loaderProps, loaderData, css: allCSS, cssContents: allCSSContents, mode: 'ssg', routePreloads, matches, }) recordTiming('ssrRender', performance.now() - t0) // Apply after-LCP script loading if enabled // Load all preloads (not just critical) to ensure good TTI after first paint if (useAfterLCP) { html = applyAfterLCPScriptLoad(html, preloads) } t0 = performance.now() await outputFile(htmlOutPath, html) recordTiming('writeHTML', performance.now() - t0) } else if (foundRoute.type === 'spa') { // spa-shell: render if any parent layout has ssg/ssr render mode const needsSpaShell = foundRoute.layouts?.some( (layout) => layout.layoutRenderMode === 'ssg' || layout.layoutRenderMode === 'ssr' ) if (needsSpaShell) { // render root layout shell for SPA pages globalThis['__vxrnresetState']?.() const renderPreloads = criticalPreloads || preloads const renderDeferredPreloads = deferredPreloads || [] // for spa-shell, include layout matches (not page match) // matches array at this point has: [layout1, layout2, ..., page] // we want just the layouts for spa-shell const layoutMatches = matches.slice(0, -1) t0 = performance.now() let html = await render({ path, preloads: renderPreloads, deferredPreloads: renderDeferredPreloads, loaderProps, // don't pass loaderData for spa-shell - the page loader runs on client // passing {} here would make useLoaderState think data is preloaded loaderData: undefined, css: allCSS, cssContents: allCSSContents, mode: 'spa-shell', routePreloads, matches: layoutMatches, }) recordTiming('spaShellRender', performance.now() - t0) if (useAfterLCP) { html = applyAfterLCPScriptLoad(html, preloads) } t0 = performance.now() await outputFile(htmlOutPath, html) recordTiming('writeHTML', performance.now() - t0) } else { // Generate CSS - either inline styles or link tags const cssOutput = allCSSContents ? allCSSContents .filter(Boolean) .map((content) => ` <style>${content}</style>`) .join('\n') : allCSS .map((file) => ` <link rel="stylesheet" href=${file} />`) .join('\n') // Use separated preloads if available const criticalScripts = (criticalPreloads || preloads) .map((preload) => ` <script type="module" src="${preload}"></script>`) .join('\n') // Non-critical scripts as modulepreload hints only const deferredLinks = (deferredPreloads || []) .map( (preload) => ` <link rel="modulepreload" fetchPriority="low" href="${preload}"/>` ) .join('\n') await outputFile( htmlOutPath, `<!DOCTYPE html><html><head> ${constants.getSpaHeaderElements({ serverContext: { loaderProps, loaderData } })} ${criticalScripts} ${deferredLinks} ${cssOutput} </head><body></body></html>` ) } } } } catch (err) { const errMsg = err instanceof Error ? `${err.message}\n${err.stack}` : `${err}` console.error( `Error building static page at ${path} with id ${relativeId}: ${errMsg} loaderData:\n\n${JSON.stringify(loaderData || null, null, 2)} params:\n\n${JSON.stringify(params || null, null, 2)}` ) console.error(err) process.exit(1) } const middlewares = (foundRoute.middlewares || []).map( (x) => builtMiddlewares[x.contextKey] ) const cleanPath = path === '/' ? path : removeTrailingSlash(path) return { type: foundRoute.type, css: allCSS, cssContents: allCSSContents, routeFile: foundRoute.file, middlewares, cleanPath, preloadPath, cssPreloadPath, loaderPath, clientJsPath, serverJsPath, htmlPath, loaderData, params, path, preloads, criticalPreloads, deferredPreloads, } } async function getRender(serverEntry: string) { let render: RenderApp | null = null try { const serverImport = await import(serverEntry) render = serverImport.default.render || // for an unknown reason this is necessary serverImport.default.default?.render if (typeof render !== 'function') { console.error(`āŒ Error: didn't find render function in entry`, serverImport) process.exit(1) } } catch (err) { console.error(`āŒ Error importing the root entry:`) console.error(` This error happened in the built file: ${serverEntry}`) // @ts-expect-error console.error(err['stack']) process.exit(1) } return render } function removeTrailingSlash(path: string) { return path.endsWith('/') ? path.slice(0, path.length - 1) : path } // extract redirect target from a Response object (e.g., from redirect()) function extractRedirectInfo( response: Response ): { path: string; status: number } | null { if (response.status >= 300 && response.status < 400) { const location = response.headers.get('location') if (location) { try { const url = new URL(location) return { path: url.pathname + url.search + url.hash, status: response.status, } } catch { // relative URL return { path: location, status: response.status } } } } return null } /** * Transforms HTML to delay script execution until after first paint. * Keeps modulepreload links so critical scripts download in parallel. * Removes async script tags and adds a loader that executes scripts after paint. */ function applyAfterLCPScriptLoad(html: string, preloads: string[]): string { // Remove all <script type="module" ... async> tags (prevents immediate execution) // Keep modulepreload links so critical scripts download in parallel html = html.replace(/<script\s+type="module"[^>]*async[^>]*><\/script>/gi, '') // Create the loader script // Nested setTimeout yields to event loop multiple times, letting browser settle before loading scripts const loaderScript = ` <script> (function() { var scripts = ${JSON.stringify(preloads)}; function loadScripts() { scripts.forEach(function(src) { var script = document.createElement('script'); script.type = 'module'; script.src = src; document.head.appendChild(script); }); } function waitIdle(n) { if (n <= 0) { requestAnimationFrame(function() { requestAnimationFrame(loadScripts); }); return; } setTimeout(function() { setTimeout(function() { waitIdle(n - 1); }, 0); }, 0); } waitIdle(5); })(); </script>` // Insert the loader script before </head> html = html.replace('</head>', `${loaderScript}</head>`) return html }