import type { Context, Hono } from 'hono' import type { CookieRewriteOptions, CreateDevProxyAppOptions, DevProxyCorsAllowedOrigins, DevProxyRoute } from './types' import { Hono as HonoApp } from 'hono' import { getCookieHeaderValue, resolveCookieRewriteLocalScopeKey, rewriteCookieHeaderForUpstream, rewriteSetCookieHeadersForLocal, toScopedLocalCookieName, } from './cookies' const LOCAL_DEV_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']) const ALLOW_METHODS = 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS' const DEFAULT_ALLOW_HEADERS = 'Authorization, Content-Type, X-CSRF-Token' const UPSTREAM_ACCEPT_ENCODING = 'identity' const RESPONSE_HEADERS_TO_DROP = [ 'connection', 'content-encoding', 'content-length', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', ] as const const appendHeaderValue = (headers: Headers, name: string, value: string) => { const currentValue = headers.get(name) if (!currentValue) { headers.set(name, value) return } if (currentValue.split(',').map(item => item.trim()).includes(value)) return headers.set(name, `${currentValue}, ${value}`) } export const isAllowedLocalDevOrigin = (origin?: string | null) => { if (!origin) return false try { const url = new URL(origin) return LOCAL_DEV_HOSTS.has(url.hostname) } catch { return false } } export const isAllowedDevOrigin = ( origin?: string | null, allowedOrigins: DevProxyCorsAllowedOrigins = 'local', ) => { if (!origin) return false if (allowedOrigins === 'local') return isAllowedLocalDevOrigin(origin) return allowedOrigins.includes(origin) } const applyCorsHeaders = ( headers: Headers, origin: string | undefined | null, allowedOrigins: DevProxyCorsAllowedOrigins = 'local', ) => { if (!isAllowedDevOrigin(origin, allowedOrigins)) return headers.set('Access-Control-Allow-Origin', origin!) headers.set('Access-Control-Allow-Credentials', 'true') appendHeaderValue(headers, 'Vary', 'Origin') } export const buildUpstreamUrl = (target: string, requestPath: string, search = '') => { const targetUrl = new URL(target) const normalizedTargetPath = targetUrl.pathname === '/' ? '' : targetUrl.pathname.replace(/\/$/, '') const normalizedRequestPath = requestPath.startsWith('/') ? requestPath : `/${requestPath}` const hasTargetPrefix = normalizedTargetPath && (normalizedRequestPath === normalizedTargetPath || normalizedRequestPath.startsWith(`${normalizedTargetPath}/`)) targetUrl.pathname = hasTargetPrefix ? normalizedRequestPath : `${normalizedTargetPath}${normalizedRequestPath}` targetUrl.search = search return targetUrl } const createProxyRequestHeaders = ( request: Request, targetUrl: URL, cookieRewrite: CookieRewriteOptions | false | undefined, ) => { const headers = new Headers(request.headers) headers.delete('host') headers.set('accept-encoding', UPSTREAM_ACCEPT_ENCODING) if (headers.has('origin')) headers.set('origin', targetUrl.origin) if (cookieRewrite) { const originalCookieHeader = headers.get('cookie') || undefined const localScopeKey = resolveCookieRewriteLocalScopeKey(cookieRewrite, targetUrl) const rewrittenCookieHeader = rewriteCookieHeaderForUpstream(headers.get('cookie') || undefined, { ...cookieRewrite, localScopeKey, useHostPrefix: targetUrl.protocol === 'https:', }) if (rewrittenCookieHeader) headers.set('cookie', rewrittenCookieHeader) else headers.delete('cookie') if (localScopeKey && cookieRewrite.csrfHeader) { const scopedCsrfCookieName = toScopedLocalCookieName(cookieRewrite.csrfHeader.cookieName, localScopeKey) const scopedCsrfToken = getCookieHeaderValue(originalCookieHeader, scopedCsrfCookieName) if (scopedCsrfToken) headers.set(cookieRewrite.csrfHeader.headerName, scopedCsrfToken) else headers.delete(cookieRewrite.csrfHeader.headerName) } } return headers } const getSetCookieHeaders = (headers: Headers) => { const headersWithGetSetCookie = headers as Headers & { getSetCookie?: () => string[] } const setCookieHeaders = headersWithGetSetCookie.getSetCookie?.() if (setCookieHeaders?.length) return setCookieHeaders const setCookie = headers.get('set-cookie') return setCookie ? [setCookie] : [] } const createUpstreamResponseHeaders = ( response: Response, targetUrl: URL, requestOrigin: string | undefined | null, allowedOrigins: DevProxyCorsAllowedOrigins, cookieRewrite: CookieRewriteOptions | false | undefined, ) => { const headers = new Headers(response.headers) RESPONSE_HEADERS_TO_DROP.forEach(header => headers.delete(header)) headers.delete('set-cookie') const localScopeKey = cookieRewrite ? resolveCookieRewriteLocalScopeKey(cookieRewrite, targetUrl) : undefined const setCookieHeaders = getSetCookieHeaders(response.headers) const responseSetCookieHeaders = cookieRewrite ? rewriteSetCookieHeadersForLocal(setCookieHeaders, { ...cookieRewrite, localScopeKey, }) : setCookieHeaders responseSetCookieHeaders.forEach((cookie) => { headers.append('set-cookie', cookie) }) applyCorsHeaders(headers, requestOrigin, allowedOrigins) return headers } const proxyRequest = async ( context: Context, route: DevProxyRoute, fetchImpl: typeof globalThis.fetch, allowedOrigins: DevProxyCorsAllowedOrigins, ) => { const requestUrl = new URL(context.req.url) const targetUrl = buildUpstreamUrl(route.target, requestUrl.pathname, requestUrl.search) const requestHeaders = createProxyRequestHeaders(context.req.raw, targetUrl, route.cookieRewrite) const requestInit: RequestInit & { duplex?: 'half' } = { method: context.req.method, headers: requestHeaders, redirect: 'manual', } if (context.req.method !== 'GET' && context.req.method !== 'HEAD') { requestInit.body = context.req.raw.body requestInit.duplex = 'half' } const upstreamResponse = await fetchImpl(targetUrl, requestInit) const responseHeaders = createUpstreamResponseHeaders( upstreamResponse, targetUrl, context.req.header('origin'), allowedOrigins, route.cookieRewrite, ) return new Response(upstreamResponse.body, { status: upstreamResponse.status, statusText: upstreamResponse.statusText, headers: responseHeaders, }) } const normalizeRoutePaths = (paths: DevProxyRoute['paths']) => Array.isArray(paths) ? paths : [paths] const registerProxyRoute = ( app: Hono, route: DevProxyRoute, path: string, fetchImpl: typeof globalThis.fetch, allowedOrigins: DevProxyCorsAllowedOrigins, ) => { if (!path.startsWith('/')) throw new Error(`Invalid dev proxy route path "${path}". Paths must start with "/".`) app.all(path, context => proxyRequest(context, route, fetchImpl, allowedOrigins)) app.all(`${path}/*`, context => proxyRequest(context, route, fetchImpl, allowedOrigins)) } const registerProxyRoutes = ( app: Hono, routes: readonly DevProxyRoute[], fetchImpl: typeof globalThis.fetch, allowedOrigins: DevProxyCorsAllowedOrigins, ) => { routes.forEach((route) => { normalizeRoutePaths(route.paths).forEach((path) => { registerProxyRoute(app, route, path, fetchImpl, allowedOrigins) }) }) } export const createDevProxyApp = (options: CreateDevProxyAppOptions) => { const app = new HonoApp() const fetchImpl = options.fetchImpl || globalThis.fetch const logger = options.logger || console const allowedOrigins = options.cors?.allowedOrigins || 'local' app.onError((error, context) => { logger.error('[dev-proxy]', error) const headers = new Headers() applyCorsHeaders(headers, context.req.header('origin'), allowedOrigins) return new Response('Upstream proxy request failed.', { status: 502, headers, }) }) app.use('*', async (context, next) => { if (context.req.method === 'OPTIONS') { const headers = new Headers() applyCorsHeaders(headers, context.req.header('origin'), allowedOrigins) headers.set('Access-Control-Allow-Methods', ALLOW_METHODS) headers.set( 'Access-Control-Allow-Headers', context.req.header('Access-Control-Request-Headers') || DEFAULT_ALLOW_HEADERS, ) if (context.req.header('Access-Control-Request-Private-Network') === 'true') headers.set('Access-Control-Allow-Private-Network', 'true') return new Response(null, { status: 204, headers, }) } await next() applyCorsHeaders(context.res.headers, context.req.header('origin'), allowedOrigins) }) registerProxyRoutes(app, options.routes, fetchImpl, allowedOrigins) return app }