one
Version:
One is a new React Framework that makes Vite serve both native and web.
1,027 lines (910 loc) • 37.5 kB
text/typescript
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()
})
}