From dacba93e0094b02522f2feb2c3036fa2aa3a979a Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:31:47 +0800 Subject: [PATCH] chore: dev with http2 --- web/.env.example | 2 +- web/package.json | 1 + web/plugins/dev-proxy/cookies.ts | 31 ++++++++++++----- web/plugins/dev-proxy/protocol.ts | 21 ++++++++++++ web/plugins/dev-proxy/server.spec.ts | 51 +++++++++++++++++++++++++++- web/plugins/dev-proxy/server.ts | 20 +++++++++-- web/pnpm-lock.yaml | 13 +++++++ web/scripts/dev-hono-proxy.ts | 38 +++++++++++++++++---- web/vite.config.ts | 6 ++++ 9 files changed, 163 insertions(+), 20 deletions(-) create mode 100644 web/plugins/dev-proxy/protocol.ts diff --git a/web/.env.example b/web/.env.example index ed06ebe2c9..0ff35ff564 100644 --- a/web/.env.example +++ b/web/.env.example @@ -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= diff --git a/web/package.json b/web/package.json index d050e11a51..8ea23e1e93 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/plugins/dev-proxy/cookies.ts b/web/plugins/dev-proxy/cookies.ts index a744493892..acaaa1e388 100644 --- a/web/plugins/dev-proxy/cookies.ts +++ b/web/plugins/dev-proxy/cookies.ts @@ -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 } diff --git a/web/plugins/dev-proxy/protocol.ts b/web/plugins/dev-proxy/protocol.ts new file mode 100644 index 0000000000..58d702e01d --- /dev/null +++ b/web/plugins/dev-proxy/protocol.ts @@ -0,0 +1,21 @@ +export type DevProxyProtocolEnv = Partial> + +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) +} diff --git a/web/plugins/dev-proxy/server.spec.ts b/web/plugins/dev-proxy/server.spec.ts index 9c950abae0..aa4db354e2 100644 --- a/web/plugins/dev-proxy/server.spec.ts +++ b/web/plugins/dev-proxy/server.spec.ts @@ -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().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 diff --git a/web/plugins/dev-proxy/server.ts b/web/plugins/dev-proxy/server.ts index 3708dd746f..5fec4ae276 100644 --- a/web/plugins/dev-proxy/server.ts +++ b/web/plugins/dev-proxy/server.ts @@ -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< + | '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, diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index d29184e8b1..8fee078887 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -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 diff --git a/web/scripts/dev-hono-proxy.ts b/web/scripts/dev-hono-proxy.ts index f550d47ae8..6852b22bae 100644 --- a/web/scripts/dev-hono-proxy.ts +++ b/web/scripts/dev-hono-proxy.ts @@ -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}`) diff --git a/web/vite.config.ts b/web/vite.config.ts index de74154651..ae57757e23 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -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,