UNPKG

one

Version:

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

1,027 lines (910 loc) 37.5 kB
import type { Hono, MiddlewareHandler } from 'hono' import type { BlankEnv } from 'hono/types' import { readFile } from 'node:fs/promises' import { join, resolve } from 'node:path' import { CSS_PRELOAD_JS_POSTFIX, LOADER_JS_POSTFIX_UNCACHED, PRELOAD_JS_POSTFIX, } from '../constants' import { compileManifest, getSubdomain, getURLfromRequestURL, type RequestHandlers, runMiddlewares, } from '../createHandleRequest' import type { RenderAppProps } from '../types' import { getPathFromLoaderPath } from '../utils/cleanUrl' import { toAbsolute } from '../utils/toAbsolute' import type { One } from '../vite/types' import type { RouteInfoCompiled } from './createRoutesManifest' import { setSSRLoaderData } from './ssrLoaderData' import { getFetchStaticHtml } from './staticHtmlFetcher' const debugRouter = process.env.ONE_DEBUG_ROUTER // forwards response headers to a hono context, preserving individual // set-cookie values (Headers.forEach joins them into one unparseable string) function forwardHeaders(response: Response, context: { header: Function }) { const setCookies = (response.headers as any).getSetCookie?.() as string[] | undefined if (setCookies?.length) { for (const cookie of setCookies) { context.header('set-cookie', cookie, { append: true }) } } response.headers.forEach((value: string, key: string) => { if (key === 'set-cookie') return context.header(key, value) }) } async function readStaticHtml(htmlPath: string, outDir = 'dist'): Promise<string | null> { const fetchStaticHtml = getFetchStaticHtml() if (fetchStaticHtml) { const html = await fetchStaticHtml(htmlPath) if (html) return html } try { return await readFile(join(`${outDir}/client`, htmlPath), 'utf-8') } catch { return null } } /** * Lazy import functions for route modules. * Modules are loaded on-demand when a route is matched, not all upfront. */ type LazyRoutes = { serverEntry: () => Promise<{ default: { render: (props: any) => any } }> pages: Record<string, () => Promise<any>> api: Record<string, () => Promise<any>> middlewares: Record<string, () => Promise<any>> } export async function oneServe( oneOptions: One.PluginOptions, buildInfo: One.BuildInfo, app: Hono, options?: { serveStaticAssets?: (ctx: { context: any }) => Promise<Response | undefined> lazyRoutes?: LazyRoutes } ) { const outDir = buildInfo.outDir || 'dist' const { resolveAPIRoute, resolveLoaderRoute, resolvePageRoute } = await import('../createHandleRequest') const { isResponse } = await import('../utils/isResponse') const { isStatusRedirect } = await import('../utils/isStatus') const { resolveResponse } = await import('../vite/resolveResponse') const isAPIRequest = new WeakMap<any, boolean>() // add redirects const redirects = oneOptions.web?.redirects if (redirects) { for (const redirect of redirects) { app.get(redirect.source, (context) => { const destinationUrl = redirect.destination.replace(/:\w+/g, (param) => { const paramName = param.substring(1) return context.req.param(paramName) || '' }) if (debugRouter) { console.info(`[one] ↪ redirect ${context.req.path}${destinationUrl}`) } return context.redirect(destinationUrl, redirect.permanent ? 301 : 302) }) } } if (!buildInfo) { throw new Error(`No build info found, have you run build?`) } const { routeToBuildInfo, routeMap } = buildInfo as One.BuildInfo // find nearest +not-found path by walking up from a url path function findNearestNotFoundPath(urlPath: string): string { let cur = urlPath while (cur) { const parent = cur.lastIndexOf('/') > 0 ? cur.slice(0, cur.lastIndexOf('/')) : '' if (routeMap[`${parent}/+not-found`]) { return `${parent}/+not-found` } if (!parent) break cur = parent } return '/+not-found' } // generate a 404 loader response that triggers client-side not-found navigation function make404LoaderJs(path: string, logReason?: string): string { const nfPath = findNearestNotFoundPath(path) if (logReason) { console.error(`[one] 404 loader for ${path}: ${logReason}`) } return `export function loader(){return{__oneError:404,__oneErrorMessage:'Not Found',__oneNotFoundPath:${JSON.stringify(nfPath)}}}` } const serverOptions = { ...oneOptions, root: '.', } const apiCJS = oneOptions.build?.api?.outputFormat === 'cjs' // pre-computed constants to avoid per-request overhead const useStreaming = !process.env.ONE_BUFFERED_SSR const htmlHeaders = { 'content-type': 'text/html' } // SSR responses get no-cache by default — include it in headers to avoid per-response mutation const ssrHtmlHeaders = { 'content-type': 'text/html', 'cache-control': 'no-cache' } // cache resolved loader functions directly (not just modules) const loaderCache = new Map<string, Function | null>() const moduleImportCache = new Map<string, any>() // loader coalescing via static loaderCache export // when a route exports loaderCache, concurrent requests with the same key share one execution const loaderCacheFnMap = new Map<string, Function | null>() const pendingLoaderResults = new Map< string, { promise: Promise<any>; expires: number } >() // resolve a route module's loader - sync on cache hit, async on cold start function resolveLoaderSync( serverPath: string | undefined, lazyKey: string | undefined ): Function | null | Promise<Function | null> { const cacheKey = lazyKey || serverPath || '' const cached = loaderCache.get(cacheKey) if (cached !== undefined) return cached // sync! // cold path - async import return (async () => { const pathToResolve = serverPath || lazyKey || '' const resolvedPath = pathToResolve.includes(`${outDir}/server`) ? pathToResolve : join('./', `${outDir}/server`, pathToResolve) let routeExported: any if (moduleImportCache.has(cacheKey)) { routeExported = moduleImportCache.get(cacheKey) } else { routeExported = lazyKey ? options?.lazyRoutes?.pages?.[lazyKey] ? await options.lazyRoutes.pages[lazyKey]() : await import(toAbsolute(resolvedPath)) : await import(toAbsolute(serverPath!)) moduleImportCache.set(cacheKey, routeExported) } const loader = routeExported?.loader || null loaderCache.set(cacheKey, loader) // also cache loaderCache export for coalescing const loaderCacheFn = routeExported?.loaderCache ?? null loaderCacheFnMap.set(cacheKey, loaderCacheFn) return loader })() } // shared helper to import a route module and run its loader async function importAndRunLoader( routeId: string, serverPath: string | undefined, lazyKey: string | undefined, loaderProps: any ): Promise<{ loaderData: unknown; routeId: string; isEnoent?: boolean }> { if (!serverPath && !lazyKey) { return { loaderData: undefined, routeId } } // check loaderCache BEFORE resolving the loader (fast path for coalesced requests) const cacheMapKey = lazyKey || serverPath || '' const loaderCacheFn = loaderCacheFnMap.get(cacheMapKey) let coalFullKey: string | undefined let coalTtl = 0 if (loaderCacheFn) { const cacheResult = loaderCacheFn(loaderProps?.params, loaderProps?.request) const cacheKey = typeof cacheResult === 'string' ? cacheResult : cacheResult?.key coalTtl = typeof cacheResult === 'string' ? 0 : (cacheResult?.ttl ?? 0) if (cacheKey != null) { coalFullKey = routeId + '\0' + cacheKey const existing = pendingLoaderResults.get(coalFullKey) // expires=0 means pending or no-TTL (coalesce-only), so !0 is true if (existing && (!existing.expires || Date.now() < existing.expires)) { // coalesce: reuse pending/cached result (never even resolves the loader fn) const loaderData = await existing.promise return { loaderData, routeId } } } } try { const loaderOrPromise = resolveLoaderSync(serverPath, lazyKey) const loader = loaderOrPromise instanceof Promise ? await loaderOrPromise : loaderOrPromise if (!loader) { return { loaderData: undefined, routeId } } // first caller with loaderCache: execute and register for coalescing if (coalFullKey) { const promise = loader(loaderProps) const entry = { promise, expires: 0 } pendingLoaderResults.set(coalFullKey, entry) promise.then( () => { entry.expires = coalTtl > 0 ? Date.now() + coalTtl : 0 if (coalTtl <= 0) { Promise.resolve().then(() => pendingLoaderResults.delete(coalFullKey!)) } }, () => { pendingLoaderResults.delete(coalFullKey!) } ) const loaderData = await promise return { loaderData, routeId } } // no coalescing: run loader directly const loaderData = await loader(loaderProps) return { loaderData, routeId } } catch (err) { if (isResponse(err)) { throw err } if ((err as any)?.code === 'ENOENT') { return { loaderData: undefined, routeId, isEnoent: true } } console.error(`[one] Error running loader for ${routeId}:`, err) return { loaderData: undefined, routeId } } } // lazy load server entry - sync on cache hit let render: ((props: RenderAppProps) => any) | null = null let renderStream: ((props: RenderAppProps) => Promise<ReadableStream>) | null = null let renderLoading: Promise<void> | null = null function ensureRenderLoaded(): void | Promise<void> { if (render) return // sync! if (renderLoading) return renderLoading renderLoading = (async () => { const entry = options?.lazyRoutes?.serverEntry ? await options.lazyRoutes.serverEntry() : await import( resolve( process.cwd(), `${serverOptions.root}/${outDir}/server/_virtual_one-entry.${typeof oneOptions.build?.server === 'object' && oneOptions.build.server.outputFormat === 'cjs' ? 'c' : ''}js` ) ) render = entry.default.render as (props: RenderAppProps) => any renderStream = entry.default.renderStream as | ((props: RenderAppProps) => Promise<ReadableStream>) | null })() return renderLoading } const clientDir = join(process.cwd(), outDir, 'client') const requestHandlers: RequestHandlers = { async handleStaticFile(filePath: string) { try { // filePath is like /assets/index_123_vxrn_loader.native.js const fullPath = join(clientDir, filePath) const content = await readFile(fullPath, 'utf-8') return new Response(content, { headers: { 'Content-Type': 'text/javascript' }, }) } catch { return null } }, async handleAPI({ route }) { // Use lazy import if available (workers), otherwise dynamic import (Node.js) if (options?.lazyRoutes?.api?.[route.page]) { return await options.lazyRoutes.api[route.page]() } // both vite and rolldown-vite replace brackets with underscores in output filenames const fileName = route.page.slice(1).replace(/\[/g, '_').replace(/\]/g, '_') const apiFile = join( process.cwd(), outDir, 'api', fileName + (apiCJS ? '.cjs' : '.js') ) return await import(apiFile) }, async loadMiddleware(route) { // Use lazy import if available (workers), otherwise dynamic import (Node.js) if (options?.lazyRoutes?.middlewares?.[route.contextKey]) { return await options.lazyRoutes.middlewares[route.contextKey]() } return await import(toAbsolute(route.contextKey)) }, async handleLoader({ route, loaderProps }) { const routeFile = (route as any).routeFile || route.file const serverPath = route.file.includes(`${outDir}/server`) ? route.file : join('./', `${outDir}/server`, route.file) let loader: Function | null try { const loaderResult = resolveLoaderSync(serverPath, routeFile) loader = loaderResult instanceof Promise ? await loaderResult : loaderResult } catch (err) { if ((err as any)?.code === 'ERR_MODULE_NOT_FOUND') { return null } throw err } if (!loader) { return null } let json try { json = await loader(loaderProps) } catch (err) { // for file-not-found errors (e.g., missing MDX for non-existent slug), // return a 404 signal so the client navigates to +not-found if ((err as any)?.code === 'ENOENT') { return make404LoaderJs( loaderProps?.path || '/', `ENOENT ${(err as any)?.path || err}` ) } throw err } // if the loader returned a Response (e.g. redirect()), throw it // so it bubbles up through resolveResponse and can be transformed // into a JS redirect module for client-side navigation if (isResponse(json)) { throw json } return `export function loader() { return ${JSON.stringify(json)} }` }, async handlePage({ route, url, loaderProps }) { const buildInfo = routeToBuildInfo[route.file] if (route.type === 'ssr') { if (!buildInfo) { console.error(`Error in route`, route) throw new Error( `No buildinfo found for ${url}, route: ${route.file}, in keys:\n ${Object.keys(routeToBuildInfo).join('\n ')}` ) } try { // collect layout loaders to run in parallel const layoutRoutes = route.layouts || [] // fast path: check which layouts actually have loaders (sync on cache hit) // skip importAndRunLoader entirely for layouts with no loader const layoutLoaderPromises: Array<ReturnType<typeof importAndRunLoader>> = [] const noLoaderResults: Array<{ loaderData: unknown; routeId: string }> = [] for (const layout of layoutRoutes) { const serverPath = layout.loaderServerPath || layout.contextKey const cacheKey = layout.contextKey || serverPath || '' const cachedLoader = loaderCache.get(cacheKey) if (cachedLoader === null) { // loader already resolved to null - skip the async call entirely noLoaderResults.push({ loaderData: undefined, routeId: layout.contextKey }) } else { layoutLoaderPromises.push( importAndRunLoader( layout.contextKey, serverPath, layout.contextKey, loaderProps ) ) } } // run page loader const pageLoaderPromise = importAndRunLoader( route.file, buildInfo.serverJsPath, route.file, loaderProps ) // wait for all loaders in parallel let layoutResults: Array<{ loaderData: unknown routeId: string isEnoent?: boolean }> let pageResult: { loaderData: unknown; routeId: string; isEnoent?: boolean } try { if (layoutLoaderPromises.length === 0) { // fast path: all layout loaders are null or no layouts layoutResults = noLoaderResults pageResult = await pageLoaderPromise } else { const [asyncLayoutResults, pr] = await Promise.all([ Promise.all(layoutLoaderPromises), pageLoaderPromise, ]) layoutResults = [...noLoaderResults, ...asyncLayoutResults] pageResult = pr } } catch (err) { // Handle thrown responses (e.g., redirect) from any loader if (isResponse(err)) { return err } throw err } // if loader threw ENOENT, serve the nearest +not-found page if (pageResult.isEnoent) { const nfPath = findNearestNotFoundPath(loaderProps?.path || '/') const nfHtml = routeMap[nfPath] if (nfHtml) { try { const html = await readFile( join(process.cwd(), `${outDir}/client`, nfHtml), 'utf-8' ) return new Response(html, { headers: { 'content-type': 'text/html' }, status: 404, }) } catch {} } return new Response('404 Not Found', { status: 404 }) } // build matches array (layouts + page) const matchPathname = loaderProps?.path || '/' const matchParams = loaderProps?.params || {} const matches: One.RouteMatch[] = new Array(layoutResults.length + 1) for (let i = 0; i < layoutResults.length; i++) { const result = layoutResults[i] matches[i] = { routeId: result.routeId, pathname: matchPathname, params: matchParams, loaderData: result.loaderData, } } matches[layoutResults.length] = { routeId: pageResult.routeId, pathname: matchPathname, params: matchParams, loaderData: pageResult.loaderData, } // for backwards compat, loaderData is still the page's loader data const loaderData = pageResult.loaderData // populate per-loader WeakMap so layout useLoader gets correct data for (const layout of layoutRoutes) { const key = layout.contextKey const loaderFn = loaderCache.get(key) if (loaderFn) { const result = layoutResults.find((r) => r.routeId === key) if (result) { setSSRLoaderData(loaderFn, result.loaderData) } } } const pageLoaderFn = loaderCache.get(route.file) if (pageLoaderFn) { setSSRLoaderData(pageLoaderFn, pageResult.loaderData) } // prepare router for this SSR render (lightweight version bump) globalThis['__vxrnresetState']?.() const renderProps = { mode: route.type, loaderData, loaderProps, path: loaderProps?.path || '/', preloads: buildInfo.criticalPreloads || buildInfo.preloads, deferredPreloads: buildInfo.deferredPreloads, css: buildInfo.css, cssContents: buildInfo.cssContents, matches, } const _rl = ensureRenderLoaded() if (_rl) await _rl const status = route.isNotFound ? 404 : 200 // use ssrHtmlHeaders (includes cache-control: no-cache) to avoid // per-response header mutation in the Hono handler const responseHeaders = route.isNotFound ? htmlHeaders : ssrHtmlHeaders // streaming SSR by default, fall back to buffered with ONE_BUFFERED_SSR=1 if (useStreaming) { const stream = await renderStream!(renderProps) return new Response(stream, { headers: responseHeaders, status, }) } // render is guaranteed loaded after ensureRenderLoaded above const rendered = await render!(renderProps) return new Response(rendered, { headers: responseHeaders, status, }) } catch (err) { // Handle thrown responses (e.g., redirect) that weren't caught above if (isResponse(err)) { return err } console.error(`[one] Error rendering SSR route ${route.file} ${err?.['stack'] ?? err} url: ${url}`) } } else { // for SPA routes only, check if we need to SSR the root layout shell // spa-shell: render if any parent layout has ssg/ssr render mode const layoutRoutes = route.layouts || [] const needsSpaShell = route.type === 'spa' && layoutRoutes.some( (layout: any) => layout.layoutRenderMode === 'ssg' || layout.layoutRenderMode === 'ssr' ) if (needsSpaShell) { try { // run layout loaders only (page content is client-rendered) const layoutResults = await Promise.all( layoutRoutes.map((layout: any) => { const serverPath = layout.loaderServerPath || layout.contextKey return importAndRunLoader( layout.contextKey, serverPath, layout.contextKey, loaderProps ) }) ) const matches: One.RouteMatch[] = layoutResults.map((result) => ({ routeId: result.routeId, pathname: loaderProps?.path || '/', params: loaderProps?.params || {}, loaderData: result.loaderData, })) globalThis['__vxrnresetState']?.() const _rl3 = ensureRenderLoaded() if (_rl3) await _rl3 const rendered = await render!({ mode: 'spa-shell', // don't pass loaderData for spa-shell - the page loader runs on client // passing {} here would make useLoaderState think data is preloaded loaderData: undefined, loaderProps, path: loaderProps?.path || '/', preloads: buildInfo?.criticalPreloads || buildInfo?.preloads, deferredPreloads: buildInfo?.deferredPreloads, css: buildInfo?.css, cssContents: buildInfo?.cssContents, matches, }) return new Response(rendered, { headers: htmlHeaders, status: route.isNotFound ? 404 : 200, }) } catch (err) { if (isResponse(err)) { return err } console.error( `[one] Error rendering spa-shell for ${route.file}\n${err?.['stack'] ?? err}\nurl: ${url}` ) } } // for SPA routes (not SSR), look up the HTML file const isDynamicRoute = Object.keys(route.routeKeys).length > 0 // for dynamic SPA routes, use the parameterized path to look up the single HTML file // (e.g., /home/feed/post/:feedId -> /home/feed/post/:feedId.html) // urlCleanPath has ? after optional params (e.g. /:id?), strip all of them to match routeMap keys const routeCleanPath = route.urlCleanPath.replace(/\?/g, '') // for +not-found routes, derive the routeMap key from the route's page field // route.page is like "/case8/[param1]/+not-found", routeMap key is "/case8/:param1/+not-found" const notFoundKey = route.isNotFound ? route.page.replace(/\[([^\]]+)\]/g, ':$1') : null const htmlPath = notFoundKey ? routeMap[notFoundKey] : isDynamicRoute ? routeMap[routeCleanPath] || routeMap[url.pathname] : routeMap[url.pathname] || routeMap[buildInfo?.cleanPath] if (htmlPath) { const html = await readStaticHtml(htmlPath, outDir) if (html) { const headers = new Headers() headers.set('content-type', 'text/html') return new Response(html, { headers, status: route.isNotFound ? 404 : 200, }) } } // dynamic route matched but no static HTML exists for this path // (slug wasn't in generateStaticParams) - return 404 if (isDynamicRoute) { const notFoundRoute = findNearestNotFoundPath(url.pathname) const notFoundHtmlPath = routeMap[notFoundRoute] if (notFoundHtmlPath) { const notFoundHtml = await readStaticHtml(notFoundHtmlPath, outDir) if (notFoundHtml) { // inject 404 marker so client knows this is a 404 response // this prevents hydration mismatch when the URL matches a dynamic route const notFoundMarker = `<script>window.__one404=${JSON.stringify({ originalPath: url.pathname, notFoundPath: notFoundRoute })}</script>` // inject before </head> or at start of <body> const injectedHtml = notFoundHtml.includes('</head>') ? notFoundHtml.replace('</head>', `${notFoundMarker}</head>`) : notFoundHtml.replace('<body', `${notFoundMarker}<body`) const headers = new Headers() headers.set('content-type', 'text/html') return new Response(injectedHtml, { headers, status: 404, }) } } // no +not-found.html found, return basic 404 return new Response('404 Not Found', { status: 404 }) } } }, } function createHonoHandler( route: RouteInfoCompiled ): MiddlewareHandler<BlankEnv, never, {}> { // pre-compute per-route checks (constant for the lifetime of the handler) const isDynamicOrNotFound = route.page.endsWith('/+not-found') || Object.keys(route.routeKeys).length > 0 return async (context, next) => { try { const request = context.req.raw if (isDynamicOrNotFound) { // Static assets should have the highest priority - which is the behavior of the dev server. // But if we handle every matching static asset here, it seems to break some of the static routes. // So we only handle it if there's a matching not-found or dynamic route, to prevent One from taking over the static asset. // If there's no matching not-found or dynamic route, it's very likely that One won't handle it and will fallback to VxRN serving the static asset so it will also work. // Note: serveStaticAssets is optional - workers handle static assets via platform config if (options?.serveStaticAssets) { const staticAssetResponse = await options.serveStaticAssets({ context, }) if (staticAssetResponse) { return await runMiddlewares( requestHandlers, request, route, async () => staticAssetResponse ) } } } // for js/css we want to serve our files directly, as they can match a route on accident // use the hono-parsed path to avoid parsing the full URL string const reqPath = context.req.path if (reqPath.endsWith('.js') || reqPath.endsWith('.css')) { return next() } // fast path for SSR pages without middleware: // skip URL parsing, resolvePageRoute, and resolveResponse entirely. // use hono's pre-parsed path and compute params inline. if ( route.type === 'ssr' && !route.middlewares?.length && !reqPath.endsWith(LOADER_JS_POSTFIX_UNCACHED) ) { if (debugRouter) { console.info(`[one] ⚡ ${reqPath} → matched page route: ${route.page} (ssr)`) } const pathname = reqPath // extract search from raw URL (after ?) const rawUrl = request.url const qIdx = rawUrl.indexOf('?') const search = qIdx >= 0 ? rawUrl.slice(qIdx) : '' // compute params from compiled regex using pathname const params: Record<string, string> = {} const match = route.compiledRegex.exec(pathname) if (match?.groups) { for (const [key, value] of Object.entries(match.groups)) { const namedKey = route.routeKeys[key] params[namedKey] = value as string } } const loaderProps = { path: pathname, search, subdomain: getSubdomain(getURLfromRequestURL(request)), request, params, } // lazy-create URL only when needed (error paths, non-SSR branches) const url = getURLfromRequestURL(request) const response = await resolveResponse(async () => { try { return await requestHandlers.handlePage!({ request, route, url, loaderProps, }) } catch (err) { if (isResponse(err)) { return err as Response } throw err } }) if (response) { if (isResponse(response)) { if (isStatusRedirect(response.status)) { const location = `${response.headers.get('location') || ''}` forwardHeaders(response, context) return context.redirect(location, response.status) } // cache-control is already set in ssrHtmlHeaders for SSR responses return response as Response } return next() } return next() } const url = getURLfromRequestURL(request) const response = await (() => { // this handles all loader refetches or fetches due to navigation if (url.pathname.endsWith(LOADER_JS_POSTFIX_UNCACHED)) { const originalUrl = getPathFromLoaderPath(url.pathname) // for ssg routes with dynamic params, check if this path was statically generated // if not in routeMap, the slug wasn't in generateStaticParams - return 404 if (route.type === 'ssg' && Object.keys(route.routeKeys).length > 0) { if (!routeMap[originalUrl]) { return new Response( make404LoaderJs(originalUrl, 'ssg route not in routeMap'), { headers: { 'Content-Type': 'text/javascript' }, } ) } } const finalUrl = new URL(originalUrl, url.origin) // preserve query params (platform=ios, etc.) for native CJS conversion finalUrl.search = url.search const cleanedRequest = new Request(finalUrl, request) return resolveLoaderRoute(requestHandlers, cleanedRequest, finalUrl, route) } switch (route.type) { case 'api': { if (debugRouter) { console.info( `[one] ⚡ ${url.pathname} → matched API route: ${route.page}` ) } return resolveAPIRoute(requestHandlers, request, url, route) } case 'ssg': case 'spa': case 'ssr': { if (debugRouter) { console.info( `[one] ⚡ ${url.pathname} → matched page route: ${route.page} (${route.type})` ) } return resolvePageRoute(requestHandlers, request, url, route) } } })() if (response) { if (isResponse(response)) { // const cloned = response.clone() if (isStatusRedirect(response.status)) { const location = `${response.headers.get('location') || ''}` forwardHeaders(response, context) return context.redirect(location, response.status) } if (isAPIRequest.get(request)) { try { if ( !response.headers.has('cache-control') && !response.headers.has('Cache-Control') ) { // don't cache api requests by default response.headers.set('cache-control', 'no-store') } return response } catch (err) { console.info( `Error updating cache header on api route "${ context.req.path }" to no-store, it is ${response.headers.get('cache-control')}, continue`, err ) } } // set cache headers for page responses // ssg/spa: enable CDN edge caching (skew protection handles stale assets) // ssr: no-cache since content is dynamic per request if ( !response.headers.has('cache-control') && !response.headers.has('Cache-Control') ) { if (route.type === 'ssg' || route.type === 'spa') { response.headers.set( 'cache-control', 'public, s-maxage=60, stale-while-revalidate=120' ) } else { response.headers.set('cache-control', 'no-cache') } } return response as Response } return next() } } catch (err) { console.error(` [one] Error handling request: ${(err as any)['stack']}`) } return next() } } const compiledManifest = compileManifest(buildInfo.manifest) for (const route of compiledManifest.apiRoutes) { app.get(route.urlPath, createHonoHandler(route)) app.put(route.urlPath, createHonoHandler(route)) app.post(route.urlPath, createHonoHandler(route)) app.delete(route.urlPath, createHonoHandler(route)) app.patch(route.urlPath, createHonoHandler(route)) if (route.urlPath !== route.urlCleanPath) { app.get(route.urlCleanPath, createHonoHandler(route)) app.put(route.urlCleanPath, createHonoHandler(route)) app.post(route.urlCleanPath, createHonoHandler(route)) app.delete(route.urlCleanPath, createHonoHandler(route)) app.patch(route.urlCleanPath, createHonoHandler(route)) } } for (const route of compiledManifest.pageRoutes) { app.get(route.urlPath, createHonoHandler(route)) if (route.urlPath !== route.urlCleanPath) { app.get(route.urlCleanPath, createHonoHandler(route)) } } const { preloads, cssPreloads } = buildInfo // TODO make this inside each page, need to make loader urls just be REGULAR_URL + loaderpostfix app.get('*', async (c, next) => { if (c.req.path.endsWith(PRELOAD_JS_POSTFIX)) { // TODO handle dynamic segments (i think the below loader has some logic for this) if (!preloads[c.req.path]) { // no preload exists 200 gracefully c.header('Content-Type', 'text/javascript') c.status(200) return c.body(``) } } if (c.req.path.endsWith(CSS_PRELOAD_JS_POSTFIX)) { // Return empty resolved promise if no CSS preload exists for this route if (!cssPreloads?.[c.req.path]) { c.header('Content-Type', 'text/javascript') c.status(200) return c.body(`export default Promise.resolve()`) } } if (c.req.path.endsWith(LOADER_JS_POSTFIX_UNCACHED)) { const request = c.req.raw const url = getURLfromRequestURL(request) const originalUrl = getPathFromLoaderPath(c.req.path) for (const route of compiledManifest.pageRoutes) { if (route.file === '') { // ignore not found route continue } if (!route.compiledRegex.test(originalUrl)) { continue } // for ssg routes with dynamic params, check if this path was statically generated if ( route.type === 'ssg' && Object.keys(route.routeKeys).length > 0 && !routeMap[originalUrl] ) { c.header('Content-Type', 'text/javascript') c.status(200) return c.body(make404LoaderJs(originalUrl, 'ssg route not in routeMap')) } // for now just change this const loaderRoute = { ...route, routeFile: route.file, // preserve original for lazy route lookup file: route.loaderServerPath || c.req.path, } const finalUrl = new URL(originalUrl, url.origin) // preserve query params (platform=ios, etc.) for native CJS conversion finalUrl.search = url.search const cleanedRequest = new Request(finalUrl, request) try { const resolved = await resolveLoaderRoute( requestHandlers, cleanedRequest, finalUrl, loaderRoute ) return resolved } catch (err) { if ((err as any)?.code === 'ERR_MODULE_NOT_FOUND') { // module doesn't exist (e.g., dynamic route with slug not in generateStaticParams) // return empty loader so client doesn't get import error c.header('Content-Type', 'text/javascript') c.status(200) return c.body(`export function loader() { return undefined }`) } console.error(`Error running loader: ${err}`) return next() } } } return next() }) }