one
Version:
One is a new React Framework that makes Vite serve both native and web.
419 lines (361 loc) • 14.8 kB
text/typescript
import type { Hono, MiddlewareHandler } from 'hono'
import type { BlankEnv } from 'hono/types'
import { readFile } from 'node:fs/promises'
import { extname, join, resolve } from 'node:path'
import {
CSS_PRELOAD_JS_POSTFIX,
LOADER_JS_POSTFIX_UNCACHED,
PRELOAD_JS_POSTFIX,
} from '../constants'
import {
compileManifest,
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 { getFetchStaticHtml } from './staticHtmlFetcher'
const debugRouter = process.env.ONE_DEBUG_ROUTER
/**
* 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 { resolveAPIRoute, resolveLoaderRoute, resolvePageRoute } = await import(
'../createHandleRequest'
)
const { isResponse } = await import('../utils/isResponse')
const { isStatusRedirect } = await import('../utils/isStatus')
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
const serverOptions = {
...oneOptions,
root: '.',
}
const apiCJS = oneOptions.build?.api?.outputFormat === 'cjs'
// useRolldown is determined at build time and stored in buildInfo
const useRolldown = buildInfo.useRolldown ?? false
// Lazy load server entry only when needed for SSR
let render: ((props: RenderAppProps) => any) | null = null
async function getRender() {
if (!render) {
// Use lazy import if available (workers), otherwise dynamic import (Node.js)
const entry = options?.lazyRoutes?.serverEntry
? await options.lazyRoutes.serverEntry()
: await import(
resolve(
process.cwd(),
`${serverOptions.root}/dist/server/_virtual_one-entry.${typeof oneOptions.build?.server === 'object' && oneOptions.build.server.outputFormat === 'cjs' ? 'c' : ''}js`
)
)
render = entry.default.render as (props: RenderAppProps) => any
}
return render
}
const requestHandlers: RequestHandlers = {
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]()
}
const fileName = useRolldown
? route.page.slice(1)
: route.page.slice(1).replace(/\[/g, '_').replace(/\]/g, '_')
const apiFile = join(process.cwd(), 'dist', '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 }) {
// Use lazy import if available (workers), otherwise dynamic import (Node.js)
// For workers, look up by routeFile (original file path like "./dynamic/[id]+ssr.tsx")
// For Node.js, use route.file which may be loaderServerPath
const routeFile = (route as any).routeFile || route.file
const exports = options?.lazyRoutes?.pages?.[routeFile]
? await options.lazyRoutes.pages[routeFile]()
: await import(toAbsolute(join('./', 'dist/server', route.file)))
const { loader } = exports
if (!loader) {
console.warn(`No loader found in exports`, route.file)
return null
}
const json = await loader(loaderProps)
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 {
// Use lazy import if available (workers), otherwise dynamic import (Node.js)
const exported = options?.lazyRoutes?.pages?.[route.file]
? await options.lazyRoutes.pages[route.file]()
: await import(toAbsolute(buildInfo.serverJsPath))
const loaderData = await exported.loader?.(loaderProps)
const headers = new Headers()
headers.set('content-type', 'text/html')
const rendered = await (await getRender())({
mode: route.type,
loaderData,
loaderProps,
path: loaderProps?.path || '/',
// Use separated preloads for optimal loading
preloads: buildInfo.criticalPreloads || buildInfo.preloads,
deferredPreloads: buildInfo.deferredPreloads,
css: buildInfo.css,
cssContents: buildInfo.cssContents,
})
return new Response(rendered, {
headers,
status: route.isNotFound ? 404 : 200,
})
} catch (err) {
console.error(`[one] Error rendering SSR route ${route.file}
${err?.['stack'] ?? err}
url: ${url}`)
}
} else {
const htmlPath = routeMap[url.pathname] || routeMap[buildInfo?.cleanPath]
if (htmlPath) {
// Try Worker ASSETS binding first (for Cloudflare Workers), fall back to filesystem
const fetchStaticHtml = getFetchStaticHtml()
let html: string | null = null
if (fetchStaticHtml) {
html = await fetchStaticHtml(htmlPath)
}
if (!html) {
// Fall back to filesystem (Node.js)
try {
html = await readFile(join('dist/client', htmlPath), 'utf-8')
} catch {
// File not found
}
}
if (html) {
const headers = new Headers()
headers.set('content-type', 'text/html')
return new Response(html, { headers, status: route.isNotFound ? 404 : 200 })
}
}
}
},
}
function createHonoHandler(route: RouteInfoCompiled): MiddlewareHandler<BlankEnv, never, {}> {
return async (context, next) => {
try {
const request = context.req.raw
if (route.page.endsWith('/+not-found') || Reflect.ownKeys(route.routeKeys).length > 0) {
// 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 we want to serve our js files directly, as they can match a route on accident
// middleware my want to handle this eventually as well but for now this is a fine balance
if (extname(request.url) === '.js' || extname(request.url) === '.css') {
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)
const finalUrl = new URL(originalUrl, url.origin)
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') || ''}`
response.headers.forEach((value, key) => {
context.header(key, value)
})
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
)
}
}
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 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)
const cleanedRequest = new Request(finalUrl, request)
try {
const resolved = await resolveLoaderRoute(
requestHandlers,
cleanedRequest,
finalUrl,
loaderRoute
)
return resolved
} catch (err) {
console.error(`Error running loader: ${err}`)
return next()
}
}
}
return next()
})
}