Compare commits

...

1 Commits

Author SHA1 Message Date
Stephen Zhou
dacba93e00 chore: dev with http2 2026-03-17 16:31:47 +08:00
9 changed files with 163 additions and 20 deletions

View File

@@ -12,7 +12,7 @@ NEXT_PUBLIC_API_PREFIX=http://localhost:5001/console/api
# console or api domain.
# example: http://udify.app/api
NEXT_PUBLIC_PUBLIC_API_PREFIX=http://localhost:5001/api
# Dev-only Hono proxy targets. The frontend keeps requesting http://localhost:5001 directly.
# Dev-only Hono proxy targets. Set the api prefixes above to https://localhost:5001/... to start the proxy with HTTPS.
HONO_PROXY_HOST=127.0.0.1
HONO_PROXY_PORT=5001
HONO_CONSOLE_API_PROXY_TARGET=

View File

@@ -210,6 +210,7 @@
"@types/sortablejs": "1.15.9",
"@typescript-eslint/parser": "8.57.0",
"@typescript/native-preview": "7.0.0-dev.20260312.1",
"@vitejs/plugin-basic-ssl": "2.2.0",
"@vitejs/plugin-react": "6.0.0",
"@vitejs/plugin-rsc": "0.5.21",
"@vitest/coverage-v8": "4.1.0",

View File

@@ -34,7 +34,16 @@ const toUpstreamCookieName = (cookieName: string) => {
return `__Host-${cookieName}`
}
const toLocalCookieName = (cookieName: string) => cookieName.replace(SECURE_COOKIE_PREFIX_PATTERN, '')
const toLocalCookieName = (cookieName: string, options: LocalCookieRewriteOptions) => {
if (options.localSecure)
return cookieName
return cookieName.replace(SECURE_COOKIE_PREFIX_PATTERN, '')
}
type LocalCookieRewriteOptions = {
localSecure: boolean
}
export const rewriteCookieHeaderForUpstream = (cookieHeader?: string) => {
if (!cookieHeader)
@@ -55,7 +64,10 @@ export const rewriteCookieHeaderForUpstream = (cookieHeader?: string) => {
.join('; ')
}
const rewriteSetCookieValueForLocal = (setCookieValue: string) => {
const rewriteSetCookieValueForLocal = (
setCookieValue: string,
options: LocalCookieRewriteOptions,
) => {
const [rawCookiePair, ...rawAttributes] = setCookieValue.split(';')
const separatorIndex = rawCookiePair.indexOf('=')
@@ -68,11 +80,11 @@ const rewriteSetCookieValueForLocal = (setCookieValue: string) => {
.map(attribute => attribute.trim())
.filter(attribute =>
!COOKIE_DOMAIN_PATTERN.test(attribute)
&& !COOKIE_SECURE_PATTERN.test(attribute)
&& !COOKIE_PARTITIONED_PATTERN.test(attribute),
&& (options.localSecure || !COOKIE_SECURE_PATTERN.test(attribute))
&& (options.localSecure || !COOKIE_PARTITIONED_PATTERN.test(attribute)),
)
.map((attribute) => {
if (SAME_SITE_NONE_PATTERN.test(attribute))
if (!options.localSecure && SAME_SITE_NONE_PATTERN.test(attribute))
return 'SameSite=Lax'
if (COOKIE_PATH_PATTERN.test(attribute))
@@ -81,10 +93,13 @@ const rewriteSetCookieValueForLocal = (setCookieValue: string) => {
return attribute
})
return [`${toLocalCookieName(cookieName)}=${cookieValue}`, ...rewrittenAttributes].join('; ')
return [`${toLocalCookieName(cookieName, options)}=${cookieValue}`, ...rewrittenAttributes].join('; ')
}
export const rewriteSetCookieHeadersForLocal = (setCookieHeaders?: string | string[]): string[] | undefined => {
export const rewriteSetCookieHeadersForLocal = (
setCookieHeaders: string | string[] | undefined,
options: LocalCookieRewriteOptions,
): string[] | undefined => {
if (!setCookieHeaders)
return undefined
@@ -92,7 +107,7 @@ export const rewriteSetCookieHeadersForLocal = (setCookieHeaders?: string | stri
? setCookieHeaders
: [setCookieHeaders]
return normalizedHeaders.map(rewriteSetCookieValueForLocal)
return normalizedHeaders.map(setCookieValue => rewriteSetCookieValueForLocal(setCookieValue, options))
}
export { DEFAULT_PROXY_TARGET }

View File

@@ -0,0 +1,21 @@
export type DevProxyProtocolEnv = Partial<Record<
| 'NEXT_PUBLIC_API_PREFIX'
| 'NEXT_PUBLIC_PUBLIC_API_PREFIX',
string
>>
const isHttpsUrl = (value?: string) => {
if (!value)
return false
try {
return new URL(value).protocol === 'https:'
}
catch {
return false
}
}
export const shouldUseHttpsForDevProxy = (env: DevProxyProtocolEnv = {}) => {
return isHttpsUrl(env.NEXT_PUBLIC_API_PREFIX) || isHttpsUrl(env.NEXT_PUBLIC_PUBLIC_API_PREFIX)
}

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { buildUpstreamUrl, createDevProxyApp, isAllowedDevOrigin, resolveDevProxyTargets } from './server'
import { buildUpstreamUrl, createDevProxyApp, isAllowedDevOrigin, resolveDevProxyTargets, shouldUseHttpsForDevProxy } from './server'
describe('dev proxy server', () => {
beforeEach(() => {
@@ -19,6 +19,21 @@ describe('dev proxy server', () => {
expect(targets.publicApiTarget).toBe('https://public.example.com')
})
// Scenario: the local dev proxy should switch to https when api prefixes are configured with https.
it('should enable https for the local dev proxy when api prefixes use https', () => {
// Assert
expect(shouldUseHttpsForDevProxy({
NEXT_PUBLIC_API_PREFIX: 'https://localhost:5001/console/api',
})).toBe(true)
expect(shouldUseHttpsForDevProxy({
NEXT_PUBLIC_PUBLIC_API_PREFIX: 'https://localhost:5001/api',
})).toBe(true)
expect(shouldUseHttpsForDevProxy({
NEXT_PUBLIC_API_PREFIX: 'http://localhost:5001/console/api',
NEXT_PUBLIC_PUBLIC_API_PREFIX: 'http://localhost:5001/api',
})).toBe(false)
})
// Scenario: target paths should not be duplicated when the incoming route already includes them.
it('should preserve prefixed targets when building upstream URLs', () => {
// Act
@@ -32,6 +47,7 @@ describe('dev proxy server', () => {
it('should only allow local development origins', () => {
// Assert
expect(isAllowedDevOrigin('http://localhost:3000')).toBe(true)
expect(isAllowedDevOrigin('https://localhost:3000')).toBe(true)
expect(isAllowedDevOrigin('http://127.0.0.1:3000')).toBe(true)
expect(isAllowedDevOrigin('https://example.com')).toBe(false)
})
@@ -86,6 +102,39 @@ describe('dev proxy server', () => {
])
})
// Scenario: secure local proxy responses should keep secure cross-site cookie attributes intact.
it('should preserve secure cookie attributes when the local proxy is https', async () => {
// Arrange
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response('ok', {
status: 200,
headers: [
['set-cookie', '__Host-access_token=abc; Path=/console/api; Domain=cloud.dify.ai; Secure; SameSite=None; Partitioned'],
['set-cookie', '__Host-csrf_token=csrf; Path=/console/api; Domain=cloud.dify.ai; Secure; SameSite=None'],
],
}))
const app = createDevProxyApp({
consoleApiTarget: 'https://cloud.dify.ai',
publicApiTarget: 'https://public.dify.ai',
fetchImpl,
})
// Act
const response = await app.request('https://127.0.0.1:5001/console/api/apps?page=1', {
headers: {
Origin: 'https://localhost:3000',
Cookie: 'access_token=abc',
},
})
// Assert
expect(response.headers.getSetCookie()).toEqual([
'__Host-access_token=abc; Path=/; Secure; SameSite=None; Partitioned',
'__Host-csrf_token=csrf; Path=/; Secure; SameSite=None',
])
expect(response.headers.get('access-control-allow-origin')).toBe('https://localhost:3000')
expect(response.headers.get('access-control-allow-credentials')).toBe('true')
})
// Scenario: preflight requests should advertise allowed headers for credentialed cross-origin calls.
it('should answer CORS preflight requests', async () => {
// Arrange

View File

@@ -2,10 +2,16 @@ import type { Context, Hono } from 'hono'
import { Hono as HonoApp } from 'hono'
import { DEFAULT_PROXY_TARGET, rewriteCookieHeaderForUpstream, rewriteSetCookieHeadersForLocal } from './cookies'
export { shouldUseHttpsForDevProxy } from './protocol'
type DevProxyEnv = Partial<Record<
| 'HONO_CONSOLE_API_PROXY_TARGET'
| 'HONO_PUBLIC_API_PROXY_TARGET',
string
> & Record<
| 'NEXT_PUBLIC_API_PREFIX'
| 'NEXT_PUBLIC_PUBLIC_API_PREFIX',
string | undefined
>>
export type DevProxyTargets = {
@@ -93,11 +99,15 @@ const createProxyRequestHeaders = (request: Request, targetUrl: URL) => {
return headers
}
const createUpstreamResponseHeaders = (response: Response, requestOrigin?: string | null) => {
const createUpstreamResponseHeaders = (
response: Response,
requestOrigin: string | null | undefined,
localSecure: boolean,
) => {
const headers = new Headers(response.headers)
RESPONSE_HEADERS_TO_DROP.forEach(header => headers.delete(header))
const rewrittenSetCookies = rewriteSetCookieHeadersForLocal(response.headers.getSetCookie())
const rewrittenSetCookies = rewriteSetCookieHeadersForLocal(response.headers.getSetCookie(), { localSecure })
rewrittenSetCookies?.forEach((cookie) => {
headers.append('set-cookie', cookie)
})
@@ -126,7 +136,11 @@ const proxyRequest = async (
}
const upstreamResponse = await fetchImpl(targetUrl, requestInit)
const responseHeaders = createUpstreamResponseHeaders(upstreamResponse, context.req.header('origin'))
const responseHeaders = createUpstreamResponseHeaders(
upstreamResponse,
context.req.header('origin'),
requestUrl.protocol === 'https:',
)
return new Response(upstreamResponse.body, {
status: upstreamResponse.status,

13
web/pnpm-lock.yaml generated
View File

@@ -512,6 +512,9 @@ importers:
'@typescript/native-preview':
specifier: 7.0.0-dev.20260312.1
version: 7.0.0-dev.20260312.1
'@vitejs/plugin-basic-ssl':
specifier: 2.2.0
version: 2.2.0(@voidzero-dev/vite-plus-core@0.1.11(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))
'@vitejs/plugin-react':
specifier: 6.0.0
version: 6.0.0(@voidzero-dev/vite-plus-core@0.1.11(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))
@@ -3603,6 +3606,12 @@ packages:
resolution: {integrity: sha512-hBcWIOppZV14bi+eAmCZj8Elj8hVSUZJTpf1lgGBhVD85pervzQ1poM/qYfFUlPraYSZYP+ASg6To5BwYmUSGQ==}
engines: {node: '>=16'}
'@vitejs/plugin-basic-ssl@2.2.0':
resolution: {integrity: sha512-nmyQ1HGRkfUxjsv3jw0+hMhEdZdrtkvMTdkzRUaRWfiO6PCWw2V2Pz3gldCq96Tn9S8htcgdTxw/gmbLLEbfYw==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
peerDependencies:
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
'@vitejs/plugin-react@5.2.0':
resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -11030,6 +11039,10 @@ snapshots:
'@resvg/resvg-wasm': 2.4.0
satori: 0.16.0
'@vitejs/plugin-basic-ssl@2.2.0(@voidzero-dev/vite-plus-core@0.1.11(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))':
dependencies:
vite: '@voidzero-dev/vite-plus-core@0.1.11(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)'
'@vitejs/plugin-react@5.2.0(@voidzero-dev/vite-plus-core@0.1.11(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))':
dependencies:
'@babel/core': 7.29.0

View File

@@ -1,8 +1,10 @@
import { createSecureServer } from 'node:http2'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { serve } from '@hono/node-server'
import { getCertificate } from '@vitejs/plugin-basic-ssl'
import { loadEnv } from 'vite'
import { createDevProxyApp, resolveDevProxyTargets } from '../plugins/dev-proxy/server'
import { createDevProxyApp, resolveDevProxyTargets, shouldUseHttpsForDevProxy } from '../plugins/dev-proxy/server'
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const mode = process.env.MODE || process.env.NODE_ENV || 'development'
@@ -11,11 +13,33 @@ const env = loadEnv(mode, projectRoot, '')
const host = env.HONO_PROXY_HOST || '127.0.0.1'
const port = Number(env.HONO_PROXY_PORT || 5001)
const app = createDevProxyApp(resolveDevProxyTargets(env))
const useHttps = shouldUseHttpsForDevProxy(env)
serve({
fetch: app.fetch,
hostname: host,
port,
})
if (useHttps) {
const certificate = await getCertificate(
path.join(projectRoot, 'node_modules/.vite/basic-ssl'),
'localhost',
Array.from(new Set(['localhost', '127.0.0.1', host])),
)
console.log(`[dev-hono-proxy] listening on http://${host}:${port}`)
serve({
fetch: app.fetch,
hostname: host,
port,
createServer: createSecureServer,
serverOptions: {
allowHTTP1: true,
cert: certificate,
key: certificate,
},
})
}
else {
serve({
fetch: app.fetch,
hostname: host,
port,
})
}
console.log(`[dev-hono-proxy] listening on ${useHttps ? 'https' : 'http'}://${host}:${port}`)

View File

@@ -1,9 +1,12 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import basicSsl from '@vitejs/plugin-basic-ssl'
import react from '@vitejs/plugin-react'
import vinext from 'vinext'
import { loadEnv } from 'vite'
import Inspect from 'vite-plugin-inspect'
import { defineConfig } from 'vite-plus'
import { shouldUseHttpsForDevProxy } from './plugins/dev-proxy/protocol'
import { createCodeInspectorPlugin, createForceInspectorClientInjectionPlugin } from './plugins/vite/code-inspector'
import { customI18nHmrPlugin } from './plugins/vite/custom-i18n-hmr'
import { nextStaticImageTestPlugin } from './plugins/vite/next-static-image-test'
@@ -21,6 +24,8 @@ export default defineConfig(({ mode }) => {
const isTest = mode === 'test'
const isStorybook = process.env.STORYBOOK === 'true'
|| process.argv.some(arg => arg.toLowerCase().includes('storybook'))
const env = loadEnv(mode, projectRoot, '')
const useHttpsForDevServer = shouldUseHttpsForDevProxy(env)
const isAppComponentsCoverage = coverageScope === 'app-components'
const excludedComponentCoverageFiles = isAppComponentsCoverage
? collectComponentCoverageExcludedFiles(path.join(projectRoot, 'app/components'), { pathPrefix: 'app/components' })
@@ -57,6 +62,7 @@ export default defineConfig(({ mode }) => {
react(),
vinext({ react: false }),
customI18nHmrPlugin({ injectTarget: browserInitializerInjectTarget }),
...(useHttpsForDevServer ? [basicSsl()] : []),
// reactGrabOpenFilePlugin({
// injectTarget: browserInitializerInjectTarget,
// projectRoot,