UNPKG

one

Version:

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

558 lines (485 loc) 17.8 kB
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] }) ) }