chore: add dev proxy server, update deps (#33371)

This commit is contained in:
Stephen Zhou
2026-03-13 12:52:19 +08:00
committed by GitHub
parent 4717168fe2
commit 724eaee77e
18 changed files with 1075 additions and 638 deletions

View File

@ -0,0 +1,98 @@
const DEFAULT_PROXY_TARGET = 'https://cloud.dify.ai'
const SECURE_COOKIE_PREFIX_PATTERN = /^__(Host|Secure)-/
const SAME_SITE_NONE_PATTERN = /^samesite=none$/i
const COOKIE_PATH_PATTERN = /^path=/i
const COOKIE_DOMAIN_PATTERN = /^domain=/i
const COOKIE_SECURE_PATTERN = /^secure$/i
const COOKIE_PARTITIONED_PATTERN = /^partitioned$/i
const HOST_PREFIX_COOKIE_NAMES = new Set([
'access_token',
'csrf_token',
'refresh_token',
'webapp_access_token',
])
const isPassportCookie = (cookieName: string) => cookieName.startsWith('passport-')
const shouldUseHostPrefix = (cookieName: string) => {
const normalizedCookieName = cookieName.replace(SECURE_COOKIE_PREFIX_PATTERN, '')
return HOST_PREFIX_COOKIE_NAMES.has(normalizedCookieName) || isPassportCookie(normalizedCookieName)
}
const toUpstreamCookieName = (cookieName: string) => {
if (cookieName.startsWith('__Host-'))
return cookieName
if (cookieName.startsWith('__Secure-'))
return `__Host-${cookieName.replace(SECURE_COOKIE_PREFIX_PATTERN, '')}`
if (!shouldUseHostPrefix(cookieName))
return cookieName
return `__Host-${cookieName}`
}
const toLocalCookieName = (cookieName: string) => cookieName.replace(SECURE_COOKIE_PREFIX_PATTERN, '')
export const rewriteCookieHeaderForUpstream = (cookieHeader?: string) => {
if (!cookieHeader)
return cookieHeader
return cookieHeader
.split(/;\s*/)
.filter(Boolean)
.map((cookie) => {
const separatorIndex = cookie.indexOf('=')
if (separatorIndex === -1)
return cookie
const cookieName = cookie.slice(0, separatorIndex).trim()
const cookieValue = cookie.slice(separatorIndex + 1)
return `${toUpstreamCookieName(cookieName)}=${cookieValue}`
})
.join('; ')
}
const rewriteSetCookieValueForLocal = (setCookieValue: string) => {
const [rawCookiePair, ...rawAttributes] = setCookieValue.split(';')
const separatorIndex = rawCookiePair.indexOf('=')
if (separatorIndex === -1)
return setCookieValue
const cookieName = rawCookiePair.slice(0, separatorIndex).trim()
const cookieValue = rawCookiePair.slice(separatorIndex + 1)
const rewrittenAttributes = rawAttributes
.map(attribute => attribute.trim())
.filter(attribute =>
!COOKIE_DOMAIN_PATTERN.test(attribute)
&& !COOKIE_SECURE_PATTERN.test(attribute)
&& !COOKIE_PARTITIONED_PATTERN.test(attribute),
)
.map((attribute) => {
if (SAME_SITE_NONE_PATTERN.test(attribute))
return 'SameSite=Lax'
if (COOKIE_PATH_PATTERN.test(attribute))
return 'Path=/'
return attribute
})
return [`${toLocalCookieName(cookieName)}=${cookieValue}`, ...rewrittenAttributes].join('; ')
}
export const rewriteSetCookieHeadersForLocal = (setCookieHeaders?: string | string[]): string[] | undefined => {
if (!setCookieHeaders)
return undefined
const normalizedHeaders = Array.isArray(setCookieHeaders)
? setCookieHeaders
: [setCookieHeaders]
return normalizedHeaders.map(rewriteSetCookieValueForLocal)
}
export { DEFAULT_PROXY_TARGET }

View File

@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { buildUpstreamUrl, createDevProxyApp, isAllowedDevOrigin, resolveDevProxyTargets } from './server'
describe('dev proxy server', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Scenario: Hono proxy targets should be read directly from env.
it('should resolve Hono proxy targets from env', () => {
// Arrange
const targets = resolveDevProxyTargets({
HONO_CONSOLE_API_PROXY_TARGET: 'https://console.example.com',
HONO_PUBLIC_API_PROXY_TARGET: 'https://public.example.com',
})
// Assert
expect(targets.consoleApiTarget).toBe('https://console.example.com')
expect(targets.publicApiTarget).toBe('https://public.example.com')
})
// Scenario: target paths should not be duplicated when the incoming route already includes them.
it('should preserve prefixed targets when building upstream URLs', () => {
// Act
const url = buildUpstreamUrl('https://api.example.com/console/api', '/console/api/apps', '?page=1')
// Assert
expect(url.href).toBe('https://api.example.com/console/api/apps?page=1')
})
// Scenario: only localhost dev origins should be reflected for credentialed CORS.
it('should only allow local development origins', () => {
// Assert
expect(isAllowedDevOrigin('http://localhost:3000')).toBe(true)
expect(isAllowedDevOrigin('http://127.0.0.1:3000')).toBe(true)
expect(isAllowedDevOrigin('https://example.com')).toBe(false)
})
// Scenario: proxy requests should rewrite cookies and surface credentialed CORS headers.
it('should proxy api requests through Hono with local cookie rewriting', async () => {
// Arrange
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response('ok', {
status: 200,
headers: [
['content-encoding', 'br'],
['content-length', '123'],
['set-cookie', '__Host-access_token=abc; Path=/console/api; Domain=cloud.dify.ai; Secure; SameSite=None'],
['transfer-encoding', 'chunked'],
],
}))
const app = createDevProxyApp({
consoleApiTarget: 'https://cloud.dify.ai',
publicApiTarget: 'https://public.dify.ai',
fetchImpl,
})
// Act
const response = await app.request('http://127.0.0.1:5001/console/api/apps?page=1', {
headers: {
Origin: 'http://localhost:3000',
Cookie: 'access_token=abc',
},
})
// Assert
expect(fetchImpl).toHaveBeenCalledTimes(1)
expect(fetchImpl).toHaveBeenCalledWith(
new URL('https://cloud.dify.ai/console/api/apps?page=1'),
expect.objectContaining({
method: 'GET',
headers: expect.any(Headers),
}),
)
const [, requestInit] = fetchImpl.mock.calls[0]
const requestHeaders = requestInit?.headers as Headers
expect(requestHeaders.get('cookie')).toBe('__Host-access_token=abc')
expect(requestHeaders.get('origin')).toBe('https://cloud.dify.ai')
expect(response.headers.get('access-control-allow-origin')).toBe('http://localhost:3000')
expect(response.headers.get('access-control-allow-credentials')).toBe('true')
expect(response.headers.get('content-encoding')).toBeNull()
expect(response.headers.get('content-length')).toBeNull()
expect(response.headers.get('transfer-encoding')).toBeNull()
expect(response.headers.getSetCookie()).toEqual([
'access_token=abc; Path=/; SameSite=Lax',
])
})
// Scenario: preflight requests should advertise allowed headers for credentialed cross-origin calls.
it('should answer CORS preflight requests', async () => {
// Arrange
const app = createDevProxyApp({
consoleApiTarget: 'https://cloud.dify.ai',
publicApiTarget: 'https://public.dify.ai',
fetchImpl: vi.fn<typeof fetch>(),
})
// Act
const response = await app.request('http://127.0.0.1:5001/api/messages', {
method: 'OPTIONS',
headers: {
'Origin': 'http://localhost:3000',
'Access-Control-Request-Headers': 'authorization,content-type,x-csrf-token',
},
})
// Assert
expect(response.status).toBe(204)
expect(response.headers.get('access-control-allow-origin')).toBe('http://localhost:3000')
expect(response.headers.get('access-control-allow-credentials')).toBe('true')
expect(response.headers.get('access-control-allow-headers')).toBe('authorization,content-type,x-csrf-token')
})
})

View File

@ -0,0 +1,202 @@
import type { Context, Hono } from 'hono'
import { Hono as HonoApp } from 'hono'
import { DEFAULT_PROXY_TARGET, rewriteCookieHeaderForUpstream, rewriteSetCookieHeadersForLocal } from './cookies'
type DevProxyEnv = Partial<Record<
| 'HONO_CONSOLE_API_PROXY_TARGET'
| 'HONO_PUBLIC_API_PROXY_TARGET',
string
>>
export type DevProxyTargets = {
consoleApiTarget: string
publicApiTarget: string
}
type DevProxyAppOptions = DevProxyTargets & {
fetchImpl?: typeof globalThis.fetch
}
const LOCAL_DEV_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]'])
const ALLOW_METHODS = 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS'
const DEFAULT_ALLOW_HEADERS = 'Authorization, Content-Type, X-CSRF-Token'
const RESPONSE_HEADERS_TO_DROP = [
'connection',
'content-encoding',
'content-length',
'keep-alive',
'set-cookie',
'transfer-encoding',
] 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 isAllowedDevOrigin = (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 applyCorsHeaders = (headers: Headers, origin?: string | null) => {
if (!isAllowedDevOrigin(origin))
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) => {
const headers = new Headers(request.headers)
headers.delete('host')
if (headers.has('origin'))
headers.set('origin', targetUrl.origin)
const rewrittenCookieHeader = rewriteCookieHeaderForUpstream(headers.get('cookie') || undefined)
if (rewrittenCookieHeader)
headers.set('cookie', rewrittenCookieHeader)
return headers
}
const createUpstreamResponseHeaders = (response: Response, requestOrigin?: string | null) => {
const headers = new Headers(response.headers)
RESPONSE_HEADERS_TO_DROP.forEach(header => headers.delete(header))
const rewrittenSetCookies = rewriteSetCookieHeadersForLocal(response.headers.getSetCookie())
rewrittenSetCookies?.forEach((cookie) => {
headers.append('set-cookie', cookie)
})
applyCorsHeaders(headers, requestOrigin)
return headers
}
const proxyRequest = async (
context: Context,
target: string,
fetchImpl: typeof globalThis.fetch,
) => {
const requestUrl = new URL(context.req.url)
const targetUrl = buildUpstreamUrl(target, requestUrl.pathname, requestUrl.search)
const requestHeaders = createProxyRequestHeaders(context.req.raw, targetUrl)
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, context.req.header('origin'))
return new Response(upstreamResponse.body, {
status: upstreamResponse.status,
statusText: upstreamResponse.statusText,
headers: responseHeaders,
})
}
const registerProxyRoute = (
app: Hono,
path: '/console/api' | '/api',
target: string,
fetchImpl: typeof globalThis.fetch,
) => {
app.all(path, context => proxyRequest(context, target, fetchImpl))
app.all(`${path}/*`, context => proxyRequest(context, target, fetchImpl))
}
export const resolveDevProxyTargets = (env: DevProxyEnv = {}): DevProxyTargets => {
const consoleApiTarget = env.HONO_CONSOLE_API_PROXY_TARGET
|| DEFAULT_PROXY_TARGET
const publicApiTarget = env.HONO_PUBLIC_API_PROXY_TARGET
|| consoleApiTarget
return {
consoleApiTarget,
publicApiTarget,
}
}
export const createDevProxyApp = (options: DevProxyAppOptions) => {
const app = new HonoApp()
const fetchImpl = options.fetchImpl || globalThis.fetch
app.onError((error, context) => {
console.error('[dev-hono-proxy]', error)
const headers = new Headers()
applyCorsHeaders(headers, context.req.header('origin'))
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'))
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'))
})
registerProxyRoute(app, '/console/api', options.consoleApiTarget, fetchImpl)
registerProxyRoute(app, '/api', options.publicApiTarget, fetchImpl)
return app
}