one
Version:
One is a new React Framework that makes Vite serve both native and web.
558 lines (485 loc) • 17.8 kB
text/typescript
import { LOADER_JS_POSTFIX_UNCACHED } from './constants'
import type { Middleware, MiddlewareContext } from './createMiddleware'
import type { RouteNode } from './router/Route'
import type { RouteInfoCompiled } from './server/createRoutesManifest'
import type { LoaderProps } from './types'
import { getPathFromLoaderPath } from './utils/cleanUrl'
import { isResponse } from './utils/isResponse'
import { getManifest } from './vite/getManifest'
import { resolveAPIEndpoint, resolveResponse } from './vite/resolveResponse'
import type { RouteInfo } from './vite/types'
export type RequestHandlers = {
handlePage?: (props: RequestHandlerProps) => Promise<any>
handleLoader?: (props: RequestHandlerProps) => Promise<any>
handleAPI?: (props: RequestHandlerProps) => Promise<any>
handleStaticFile?: (path: string) => Promise<Response | null>
loadMiddleware?: (route: RouteNode) => Promise<any>
}
type RequestHandlerProps<RouteExtraProps extends object = {}> = {
request: Request
route: RouteInfo<string> & RouteExtraProps
url: URL
loaderProps?: LoaderProps
}
type RequestHandlerResponse = null | string | Response
const debugRouter = process.env.ONE_DEBUG_ROUTER
// ensure handler results are always a proper Response so middleware
// can safely use response.body / response.headers / new Response(response.body, ...)
function ensureResponse(value: any): Response {
// use isResponse (duck-type check) instead of instanceof — the Response
// constructor may differ across module realms (e.g. API handler vs middleware)
if (isResponse(value)) return value
if (typeof value === 'string') {
return new Response(value, {
headers: { 'Content-Type': 'text/html' },
})
}
if (value && typeof value === 'object') {
return Response.json(value)
}
return new Response(value)
}
export async function runMiddlewares(
handlers: RequestHandlers,
request: Request,
route: RouteInfo,
getResponse: () => Promise<Response>
): Promise<Response> {
const middlewares = route.middlewares
if (!middlewares?.length) {
return await getResponse()
}
if (!handlers.loadMiddleware) {
throw new Error(`No middleware handler configured`)
}
if (debugRouter) {
console.info(`[one] 🔗 middleware chain (${middlewares.length}) for ${route.page}`)
}
const context: MiddlewareContext = {}
async function dispatch(index: number): Promise<Response> {
const middlewareModule = middlewares![index]
// no more middlewares, finish
if (!middlewareModule) {
if (debugRouter) {
console.info(`[one] ✓ middleware chain complete`)
}
return ensureResponse(await getResponse())
}
if (debugRouter) {
console.info(`[one] → middleware[${index}]: ${middlewareModule.contextKey}`)
}
const exported = (await handlers.loadMiddleware!(middlewareModule))?.default as
| Middleware
| undefined
if (!exported) {
throw new Error(
`No valid export found in middleware: ${middlewareModule.contextKey}`
)
}
// go to next middleware
const next = async () => {
return dispatch(index + 1)
}
// run middlewares, if response returned, exit early
const response = await exported({ request, next, context })
if (response) {
if (debugRouter) {
console.info(
`[one] ← middleware[${index}] returned early (status: ${response.status})`
)
}
return response
}
// If the middleware returns null/void, keep going
return dispatch(index + 1)
}
// Start with the first middleware (index 0).
return dispatch(0)
}
export async function resolveAPIRoute(
handlers: RequestHandlers,
request: Request,
url: URL,
route: RouteInfoCompiled
) {
const { pathname } = url
const params = getRouteParams(pathname, route)
if (debugRouter) {
console.info(`[one] 📡 API ${request.method} ${pathname} → ${route.file}`, params)
}
return await runMiddlewares(handlers, request, route, async () => {
try {
return resolveAPIEndpoint(
() =>
handlers.handleAPI!({
request,
route,
url,
loaderProps: {
path: pathname,
search: url.search,
subdomain: getSubdomain(url),
params,
},
}),
request,
params || {}
)
} catch (err) {
if (isResponse(err)) {
return err
}
if (process.env.NODE_ENV === 'development') {
console.error(`\n [one] Error importing API route at ${pathname}:
${err}
If this is an import error, you can likely fix this by adding this dependency to
the "optimizeDeps.include" array in your vite.config.ts.
`)
}
throw err
}
})
}
export async function resolveLoaderRoute(
handlers: RequestHandlers,
request: Request,
url: URL,
route: RouteInfoCompiled
) {
if (debugRouter) {
console.info(`[one] 📦 loader ${url.pathname} → ${route.file}`)
}
const isNativeRequest =
url.searchParams.get('platform') === 'ios' ||
url.searchParams.get('platform') === 'android'
const response = await runMiddlewares(handlers, request, route, async () => {
return await resolveResponse(async () => {
const headers = new Headers()
headers.set('Content-Type', 'text/javascript')
try {
const loaderResponse = await handlers.handleLoader!({
request,
route,
url,
loaderProps: {
path: url.pathname,
search: url.search,
subdomain: getSubdomain(url),
request: route.type === 'ssr' ? request : undefined,
params: getLoaderParams(url, route),
},
})
// native needs CJS format for eval()
const body =
isNativeRequest && loaderResponse ? toCjsLoader(loaderResponse) : loaderResponse
return new Response(body, {
headers,
})
} catch (err) {
// allow throwing a response in a loader
if (isResponse(err)) {
return err
}
if ((err as any)?.code !== 'ERR_MODULE_NOT_FOUND') {
console.error(`Error running loader: ${err}`)
}
throw err
}
})
})
// transform redirect responses into js modules so the client can detect
// and handle them during client-side navigation (instead of the browser
// silently following the 302 and trying to parse HTML as javascript)
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location')
if (location) {
const redirectUrl = new URL(location, url.origin)
const redirectPath = redirectUrl.pathname + redirectUrl.search + redirectUrl.hash
const data = `{__oneRedirect:${JSON.stringify(redirectPath)},__oneRedirectStatus:${response.status}}`
const body = isNativeRequest
? `exports.loader=function(){return ${data}}`
: `export function loader(){return${data}}`
return new Response(body, {
headers: { 'Content-Type': 'text/javascript' },
})
}
}
// transform auth error responses (401/403) into js modules so the client
// gets a clean error signal instead of a parse failure
if (response.status === 401 || response.status === 403) {
const data = `{__oneError:${response.status},__oneErrorMessage:${JSON.stringify(response.statusText || 'Unauthorized')}}`
const body = isNativeRequest
? `exports.loader=function(){return ${data}}`
: `export function loader(){return${data}}`
return new Response(body, {
headers: { 'Content-Type': 'text/javascript' },
})
}
return response
}
/**
* convert an ESM loader response to CJS for native eval().
* extracts the JSON data from `export function loader() { return {...} }`
* and wraps it as `exports.loader = function() { return {...} }`
*/
function toCjsLoader(esmCode: string): string {
// already CJS (dev plugin pre-converts for native)
if (esmCode.startsWith('exports.')) {
return esmCode
}
// match: export function loader() { return DATA }
const match = esmCode.match(
/export\s+function\s+loader\s*\(\)\s*\{\s*return\s+([\s\S]+)\s*\}/
)
if (match) {
return `exports.loader=function(){return ${match[1]}}`
}
// fallback: wrap the whole thing
return `exports.loader=function(){return {}}`
}
export async function resolvePageRoute(
handlers: RequestHandlers,
request: Request,
url: URL,
route: RouteInfoCompiled
) {
const { pathname, search } = url
if (debugRouter) {
console.info(`[one] 📄 page ${pathname} → ${route.file} (${route.type})`)
}
const loaderProps = {
path: pathname,
search: search,
subdomain: getSubdomain(url),
request: route.type === 'ssr' ? request : undefined,
params: getLoaderParams(url, route),
}
// flatten the async chain for SSR: skip runMiddlewares wrapper when no middlewares
if (!route.middlewares?.length) {
return resolveResponse(() => {
return handlers.handlePage!({ request, route, url, loaderProps })
})
}
return resolveResponse(async () => {
return await runMiddlewares(handlers, request, route, async () => {
return await handlers.handlePage!({ request, route, url, loaderProps })
})
})
}
// weakmap cache to avoid re-parsing the same request URL
const _urlCache = new WeakMap<Request, URL>()
export function getURLfromRequestURL(request: Request) {
let url = _urlCache.get(request)
if (url) return url
const urlString = request.url || ''
url = new URL(
urlString || '',
request.headers.get('host') ? `http://${request.headers.get('host')}` : ''
)
_urlCache.set(request, url)
return url
}
export function getSubdomain(url: URL): string | undefined {
const host = url.hostname
// skip for IP addresses and localhost
if (!host || host === 'localhost' || /^\d+\.\d+\.\d+\.\d+$/.test(host)) {
return undefined
}
const parts = host.split('.')
// need at least 3 parts for a subdomain (sub.example.com)
if (parts.length < 3) {
return undefined
}
// return everything before the last two parts (domain.tld)
return parts.slice(0, -2).join('.')
}
function compileRouteRegex(route: RouteInfo): RouteInfoCompiled {
return {
...route,
compiledRegex: new RegExp(route.namedRegex),
}
}
export function compileManifest(manifest: {
pageRoutes: RouteInfo[]
apiRoutes: RouteInfo[]
}): {
pageRoutes: RouteInfoCompiled[]
apiRoutes: RouteInfoCompiled[]
} {
return {
pageRoutes: manifest.pageRoutes.map(compileRouteRegex),
apiRoutes: manifest.apiRoutes.map(compileRouteRegex),
}
}
// in dev mode we do it more simply:
export function createHandleRequest(
handlers: RequestHandlers,
{ routerRoot, ignoredRouteFiles }: { routerRoot: string; ignoredRouteFiles?: string[] }
) {
const manifest = getManifest({ routerRoot, ignoredRouteFiles })
if (!manifest) {
throw new Error(`No routes manifest`)
}
const compiledManifest = compileManifest(manifest)
return {
manifest,
handler: async function handleRequest(
request: Request
): Promise<RequestHandlerResponse> {
const url = getURLfromRequestURL(request)
const { pathname, search } = url
// skip paths handled by vite internals or react native dev middleware
if (
pathname === '/__vxrnhmr' ||
pathname.startsWith('/@vite/') ||
pathname.startsWith('/@fs/') ||
pathname.startsWith('/@id/') ||
pathname.startsWith('/node_modules/') ||
pathname.startsWith('/debugger-frontend') ||
pathname.startsWith('/inspector')
) {
return null
}
// check if path looks like a static file (extension 2-4 chars like .js, .png, .jpeg)
// excludes loader paths which end with _vxrn_loader.js
const looksLikeStaticFile =
!pathname.endsWith(LOADER_JS_POSTFIX_UNCACHED) &&
/\.[a-zA-Z0-9]{2,4}$/.test(pathname)
if (handlers.handleAPI) {
const apiRoute = compiledManifest.apiRoutes.find((route) => {
return route.compiledRegex.test(pathname)
})
if (apiRoute) {
if (debugRouter) {
console.info(`[one] ⚡ ${pathname} → matched API route: ${apiRoute.page}`)
}
return await resolveAPIRoute(handlers, request, url, apiRoute)
}
}
if (request.method !== 'GET') {
return null
}
if (handlers.handleLoader) {
const isClientRequestingNewRoute = pathname.endsWith(LOADER_JS_POSTFIX_UNCACHED)
if (isClientRequestingNewRoute) {
const platformParam = url.searchParams.get('platform')
const isNativePlatform =
platformParam === 'ios' ||
platformParam === 'android' ||
platformParam === 'native'
// for native requests, try serving the pre-built .native.js static file first
// (SSG/SPA routes generate standalone CJS loaders at build time)
if (isNativePlatform && handlers.handleStaticFile) {
const nativeLoaderPath = pathname.replace(/\.js$/, '.native.js')
const staticResponse = await handlers.handleStaticFile(nativeLoaderPath)
if (staticResponse) {
return staticResponse
}
}
const originalUrl = getPathFromLoaderPath(pathname)
for (const route of compiledManifest.pageRoutes) {
if (route.file === '') {
// ignore not found route
continue
}
const finalUrl = new URL(originalUrl, url.origin)
finalUrl.search = url.search
if (!route.compiledRegex.test(finalUrl.pathname)) {
continue
}
// route is known to export no loader → return empty module without
// importing the page bundle. on workerd/cloudflare, evaluating a
// no-loader SSG page's server bundle can crash when it pulls in
// RN/Tamagui modules that aren't compatible with the workers runtime.
if (route.hasLoader === false) {
const emptyBody = isNativePlatform
? 'exports.loader=function(){return undefined}'
: 'export function loader() { return undefined }'
return new Response(emptyBody, {
headers: { 'Content-Type': 'text/javascript' },
})
}
const cleanedRequest = new Request(finalUrl, request)
return resolveLoaderRoute(handlers, cleanedRequest, finalUrl, route)
}
// no matching route - return empty module so client handles gracefully
const emptyBody = isNativePlatform
? 'exports.loader=function(){return{}}'
: 'export {}'
return new Response(emptyBody, {
headers: { 'Content-Type': 'text/javascript' },
})
}
}
if (handlers.handlePage) {
for (const route of compiledManifest.pageRoutes) {
if (!route.compiledRegex.test(pathname)) {
continue
}
// for static-looking paths, skip dynamic routes (with route params)
// this prevents /favicon.ico from matching [slug] routes
// but allows explicit routes and not-found handlers to match
const isDynamicRoute = Object.keys(route.routeKeys).length > 0
const isNotFoundRoute = route.page.endsWith('/+not-found')
if (looksLikeStaticFile && isDynamicRoute && !isNotFoundRoute) {
if (debugRouter) {
console.info(
`[one] ⚡ ${pathname} → skipping dynamic route ${route.page} for static-looking path`
)
}
continue
}
// static-looking probes (sourcemaps, .well-known, favicons) that
// only match the auto-generated placeholder +not-found (route.file
// is '' — no user-defined +not-found page exists) should get a bare
// 404 rather than a full SSR render. browser devtools & crawlers
// want a status code, not an HTML shell, and rendering the layout
// tree for every probe is wasteful.
if (looksLikeStaticFile && route.file === '') {
if (debugRouter) {
console.info(
`[one] ⚡ ${pathname} → 404 for probe path (no +not-found defined)`
)
}
return new Response(null, {
status: 404,
headers: { 'Content-Type': 'text/plain' },
})
}
if (debugRouter) {
console.info(
`[one] ⚡ ${pathname} → matched page route: ${route.page} (${route.type})`
)
}
return resolvePageRoute(handlers, request, url, route)
}
}
return null
},
}
}
export function getLoaderParams(
url: URL,
config: { compiledRegex: RegExp; routeKeys: Record<string, string> }
) {
const params: Record<string, string> = {}
const match = config.compiledRegex.exec(url.pathname)
if (match?.groups) {
for (const [key, value] of Object.entries(match.groups)) {
const namedKey = config.routeKeys[key]
params[namedKey] = value as string
}
}
return params
}
// Add this helper function
function getRouteParams(pathname: string, route: RouteInfo<string>) {
const regex = new RegExp(route.namedRegex)
const match = regex.exec(pathname)
if (!match) return {}
return Object.fromEntries(
Object.entries(route.routeKeys).map(([key, value]) => {
return [value, (match.groups?.[key] || '') as string]
})
)
}