mirror of https://github.com/langgenius/dify.git
chore: add dev proxy server, update deps (#33371)
This commit is contained in:
parent
4717168fe2
commit
724eaee77e
|
|
@ -72,7 +72,7 @@ jobs:
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Merge reports
|
- name: Merge reports
|
||||||
run: pnpm vitest --merge-reports --coverage --silent=passed-only
|
run: pnpm vitest --merge-reports --reporter=json --reporter=agent --coverage
|
||||||
|
|
||||||
- name: Coverage Summary
|
- name: Coverage Summary
|
||||||
if: always()
|
if: always()
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,11 @@ NEXT_PUBLIC_API_PREFIX=http://localhost:5001/console/api
|
||||||
# console or api domain.
|
# console or api domain.
|
||||||
# example: http://udify.app/api
|
# example: http://udify.app/api
|
||||||
NEXT_PUBLIC_PUBLIC_API_PREFIX=http://localhost:5001/api
|
NEXT_PUBLIC_PUBLIC_API_PREFIX=http://localhost:5001/api
|
||||||
|
# Dev-only Hono proxy targets. The frontend keeps requesting http://localhost:5001 directly.
|
||||||
|
HONO_PROXY_HOST=127.0.0.1
|
||||||
|
HONO_PROXY_PORT=5001
|
||||||
|
HONO_CONSOLE_API_PROXY_TARGET=
|
||||||
|
HONO_PUBLIC_API_PROXY_TARGET=
|
||||||
# When the frontend and backend run on different subdomains, set NEXT_PUBLIC_COOKIE_DOMAIN=1.
|
# When the frontend and backend run on different subdomains, set NEXT_PUBLIC_COOKIE_DOMAIN=1.
|
||||||
NEXT_PUBLIC_COOKIE_DOMAIN=
|
NEXT_PUBLIC_COOKIE_DOMAIN=
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,10 @@ import { Avatar } from '../index'
|
||||||
|
|
||||||
describe('Avatar', () => {
|
describe('Avatar', () => {
|
||||||
describe('Rendering', () => {
|
describe('Rendering', () => {
|
||||||
it('should render img element when avatar URL is provided', () => {
|
it('should keep the fallback visible when avatar URL is provided before image load', () => {
|
||||||
render(<Avatar name="John Doe" avatar="https://example.com/avatar.jpg" />)
|
render(<Avatar name="John Doe" avatar="https://example.com/avatar.jpg" />)
|
||||||
|
|
||||||
const img = screen.getByRole('img', { name: 'John Doe' })
|
expect(screen.getByText('J')).toBeInTheDocument()
|
||||||
expect(img).toBeInTheDocument()
|
|
||||||
expect(img).toHaveAttribute('src', 'https://example.com/avatar.jpg')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render fallback with uppercase initial when avatar is null', () => {
|
it('should render fallback with uppercase initial when avatar is null', () => {
|
||||||
|
|
@ -18,10 +16,9 @@ describe('Avatar', () => {
|
||||||
expect(screen.getByText('A')).toBeInTheDocument()
|
expect(screen.getByText('A')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render both image and fallback when avatar is provided', () => {
|
it('should render the fallback when avatar is provided', () => {
|
||||||
render(<Avatar name="John" avatar="https://example.com/avatar.jpg" />)
|
render(<Avatar name="John" avatar="https://example.com/avatar.jpg" />)
|
||||||
|
|
||||||
expect(screen.getByRole('img')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('J')).toBeInTheDocument()
|
expect(screen.getByText('J')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -90,7 +87,7 @@ describe('Avatar', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('onLoadingStatusChange', () => {
|
describe('onLoadingStatusChange', () => {
|
||||||
it('should render image when avatar and onLoadingStatusChange are provided', () => {
|
it('should render the fallback when avatar and onLoadingStatusChange are provided', () => {
|
||||||
render(
|
render(
|
||||||
<Avatar
|
<Avatar
|
||||||
name="John"
|
name="John"
|
||||||
|
|
@ -99,7 +96,7 @@ describe('Avatar', () => {
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(screen.getByRole('img')).toBeInTheDocument()
|
expect(screen.getByText('J')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not render image when avatar is null even with onLoadingStatusChange', () => {
|
it('should not render image when avatar is null even with onLoadingStatusChange', () => {
|
||||||
|
|
|
||||||
|
|
@ -978,7 +978,7 @@ describe('ChatWrapper', () => {
|
||||||
expect(screen.getByAltText('answer icon')).toBeInTheDocument()
|
expect(screen.getByAltText('answer icon')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render question icon when user avatar is available', () => {
|
it('should render question icon fallback when user avatar is available', () => {
|
||||||
vi.mocked(useChatWithHistoryContext).mockReturnValue({
|
vi.mocked(useChatWithHistoryContext).mockReturnValue({
|
||||||
...defaultContextValue,
|
...defaultContextValue,
|
||||||
initUserVariables: {
|
initUserVariables: {
|
||||||
|
|
@ -992,12 +992,11 @@ describe('ChatWrapper', () => {
|
||||||
chatList: [{ id: 'q1', content: 'Question' }],
|
chatList: [{ id: 'q1', content: 'Question' }],
|
||||||
} as unknown as ChatHookReturn)
|
} as unknown as ChatHookReturn)
|
||||||
|
|
||||||
const { container } = render(<ChatWrapper />)
|
render(<ChatWrapper />)
|
||||||
const avatar = container.querySelector('img[alt="John Doe"]')
|
expect(screen.getByText('J')).toBeInTheDocument()
|
||||||
expect(avatar).toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should use fallback values for nullable appData, appMeta and user name', () => {
|
it('should use fallback values for nullable appData, appMeta and avatar name', () => {
|
||||||
vi.mocked(useChatWithHistoryContext).mockReturnValue({
|
vi.mocked(useChatWithHistoryContext).mockReturnValue({
|
||||||
...defaultContextValue,
|
...defaultContextValue,
|
||||||
appData: null as unknown as AppData,
|
appData: null as unknown as AppData,
|
||||||
|
|
@ -1014,7 +1013,7 @@ describe('ChatWrapper', () => {
|
||||||
|
|
||||||
render(<ChatWrapper />)
|
render(<ChatWrapper />)
|
||||||
expect(screen.getByText('Question with fallback avatar name')).toBeInTheDocument()
|
expect(screen.getByText('Question with fallback avatar name')).toBeInTheDocument()
|
||||||
expect(screen.getByAltText('user')).toBeInTheDocument()
|
expect(screen.getByText('U')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should set handleStop on currentChatInstanceRef', () => {
|
it('should set handleStop on currentChatInstanceRef', () => {
|
||||||
|
|
|
||||||
|
|
@ -327,7 +327,7 @@ describe('EmbeddedChatbot chat-wrapper', () => {
|
||||||
expect(screen.getByRole('button', { name: 'send message' })).toBeDisabled()
|
expect(screen.getByRole('button', { name: 'send message' })).toBeDisabled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should show the user name when avatar data is provided', () => {
|
it('should show the user avatar fallback when avatar data is provided', () => {
|
||||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
|
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
|
||||||
initUserVariables: {
|
initUserVariables: {
|
||||||
avatar_url: 'https://example.com/avatar.png',
|
avatar_url: 'https://example.com/avatar.png',
|
||||||
|
|
@ -337,7 +337,7 @@ describe('EmbeddedChatbot chat-wrapper', () => {
|
||||||
|
|
||||||
render(<ChatWrapper />)
|
render(<ChatWrapper />)
|
||||||
|
|
||||||
expect(screen.getByRole('img', { name: 'Alice' })).toBeInTheDocument()
|
expect(screen.getByText('A')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -639,128 +639,50 @@ describe('Mermaid Flowchart Component Module Isolation', () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should tolerate missing hidden container during classic render and cleanup', async () => {
|
it('should cancel a pending classic render on unmount', async () => {
|
||||||
vi.resetModules()
|
const { default: FlowchartFresh } = await import('../index')
|
||||||
let pendingContainerRef: unknown | null = null
|
|
||||||
let patchedContainerRef = false
|
|
||||||
let patchedTimeoutRef = false
|
|
||||||
let containerReadCount = 0
|
|
||||||
const virtualContainer = { innerHTML: 'seed' } as HTMLDivElement
|
|
||||||
|
|
||||||
vi.doMock('react', async () => {
|
|
||||||
const reactActual = await vi.importActual<typeof import('react')>('react')
|
|
||||||
const mockedUseRef = ((initialValue: unknown) => {
|
|
||||||
const ref = reactActual.useRef(initialValue as never)
|
|
||||||
if (!patchedContainerRef && initialValue === null)
|
|
||||||
pendingContainerRef = ref
|
|
||||||
|
|
||||||
if (!patchedContainerRef
|
|
||||||
&& pendingContainerRef
|
|
||||||
&& typeof initialValue === 'string'
|
|
||||||
&& initialValue.startsWith('mermaid-chart-')) {
|
|
||||||
Object.defineProperty(pendingContainerRef as { current: unknown }, 'current', {
|
|
||||||
configurable: true,
|
|
||||||
get() {
|
|
||||||
containerReadCount += 1
|
|
||||||
if (containerReadCount === 1)
|
|
||||||
return virtualContainer
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
set(_value: HTMLDivElement | null) { },
|
|
||||||
})
|
|
||||||
patchedContainerRef = true
|
|
||||||
pendingContainerRef = null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (patchedContainerRef && !patchedTimeoutRef && initialValue === undefined) {
|
|
||||||
patchedTimeoutRef = true
|
|
||||||
Object.defineProperty(ref, 'current', {
|
|
||||||
configurable: true,
|
|
||||||
get() {
|
|
||||||
return undefined
|
|
||||||
},
|
|
||||||
set(_value: NodeJS.Timeout | undefined) { },
|
|
||||||
})
|
|
||||||
return ref
|
|
||||||
}
|
|
||||||
|
|
||||||
return ref
|
|
||||||
}) as typeof reactActual.useRef
|
|
||||||
|
|
||||||
return {
|
|
||||||
...reactActual,
|
|
||||||
useRef: mockedUseRef,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { default: FlowchartFresh } = await import('../index')
|
|
||||||
const { unmount } = render(<FlowchartFresh PrimitiveCode={mockCode} />)
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('test-svg')).toBeInTheDocument()
|
|
||||||
}, { timeout: 3000 })
|
|
||||||
unmount()
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
vi.doUnmock('react')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should tolerate missing hidden container during handDrawn render', async () => {
|
|
||||||
vi.resetModules()
|
|
||||||
let pendingContainerRef: unknown | null = null
|
|
||||||
let patchedContainerRef = false
|
|
||||||
let containerReadCount = 0
|
|
||||||
const virtualContainer = { innerHTML: 'seed' } as HTMLDivElement
|
|
||||||
|
|
||||||
vi.doMock('react', async () => {
|
|
||||||
const reactActual = await vi.importActual<typeof import('react')>('react')
|
|
||||||
const mockedUseRef = ((initialValue: unknown) => {
|
|
||||||
const ref = reactActual.useRef(initialValue as never)
|
|
||||||
if (!patchedContainerRef && initialValue === null)
|
|
||||||
pendingContainerRef = ref
|
|
||||||
|
|
||||||
if (!patchedContainerRef
|
|
||||||
&& pendingContainerRef
|
|
||||||
&& typeof initialValue === 'string'
|
|
||||||
&& initialValue.startsWith('mermaid-chart-')) {
|
|
||||||
Object.defineProperty(pendingContainerRef as { current: unknown }, 'current', {
|
|
||||||
configurable: true,
|
|
||||||
get() {
|
|
||||||
containerReadCount += 1
|
|
||||||
if (containerReadCount === 1)
|
|
||||||
return virtualContainer
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
set(_value: HTMLDivElement | null) { },
|
|
||||||
})
|
|
||||||
patchedContainerRef = true
|
|
||||||
pendingContainerRef = null
|
|
||||||
}
|
|
||||||
return ref
|
|
||||||
}) as typeof reactActual.useRef
|
|
||||||
|
|
||||||
return {
|
|
||||||
...reactActual,
|
|
||||||
useRef: mockedUseRef,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
try {
|
try {
|
||||||
const { default: FlowchartFresh } = await import('../index')
|
const { unmount } = render(<FlowchartFresh PrimitiveCode={mockCode} />)
|
||||||
const { rerender } = render(<FlowchartFresh PrimitiveCode="graph" />)
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(screen.getByText(HAND_DRAWN_RE))
|
unmount()
|
||||||
rerender(<FlowchartFresh PrimitiveCode={mockCode} />)
|
|
||||||
await vi.advanceTimersByTimeAsync(350)
|
await vi.advanceTimersByTimeAsync(350)
|
||||||
})
|
})
|
||||||
await Promise.resolve()
|
|
||||||
expect(screen.getByText('test-svg-api')).toBeInTheDocument()
|
expect(vi.mocked(mermaidFresh.render)).not.toHaveBeenCalled()
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
vi.useRealTimers()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should cancel a pending handDrawn render on unmount', async () => {
|
||||||
|
const { default: FlowchartFresh } = await import('../index')
|
||||||
|
const { unmount } = render(<FlowchartFresh PrimitiveCode={mockCode} />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('test-svg')).toBeInTheDocument()
|
||||||
|
}, { timeout: 3000 })
|
||||||
|
|
||||||
|
const initialHandDrawnCalls = vi.mocked(mermaidFresh.mermaidAPI.render).mock.calls.length
|
||||||
|
|
||||||
|
vi.useFakeTimers()
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByText(HAND_DRAWN_RE))
|
||||||
|
})
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
unmount()
|
||||||
|
await vi.advanceTimersByTimeAsync(350)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(vi.mocked(mermaidFresh.mermaidAPI.render).mock.calls.length).toBe(initialHandDrawnCalls)
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
vi.doUnmock('react')
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { cva } from 'class-variance-authority'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { cn } from '@/utils/classnames'
|
import { cn } from '@/utils/classnames'
|
||||||
import Divider from '../divider'
|
import Divider from '../divider'
|
||||||
import './index.css'
|
|
||||||
|
|
||||||
type SegmentedControlOption<T> = {
|
type SegmentedControlOption<T> = {
|
||||||
value: T
|
value: T
|
||||||
|
|
@ -131,7 +130,7 @@ export const SegmentedControl = <T extends string | number | symbol>({
|
||||||
<div className={cn('inline-flex items-center gap-x-1', ItemTextWrapperVariants({ size }))}>
|
<div className={cn('inline-flex items-center gap-x-1', ItemTextWrapperVariants({ size }))}>
|
||||||
<span>{text}</span>
|
<span>{text}</span>
|
||||||
{!!(count && size === 'large') && (
|
{!!(count && size === 'large') && (
|
||||||
<div className="system-2xs-medium-uppercase inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] text-text-tertiary">
|
<div className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] text-text-tertiary system-2xs-medium-uppercase">
|
||||||
{count}
|
{count}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
@import "../components/base/button/index.css";
|
@import "../components/base/button/index.css";
|
||||||
@import "../components/base/modal/index.css";
|
@import "../components/base/modal/index.css";
|
||||||
@import "../components/base/premium-badge/index.css";
|
@import "../components/base/premium-badge/index.css";
|
||||||
|
@import "../components/base/segmented-control/index.css";
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
|
|
|
||||||
|
|
@ -2657,11 +2657,6 @@
|
||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"app/components/base/segmented-control/index.tsx": {
|
|
||||||
"tailwindcss/enforce-consistent-class-order": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"app/components/base/select/custom.tsx": {
|
"app/components/base/select/custom.tsx": {
|
||||||
"tailwindcss/enforce-consistent-class-order": {
|
"tailwindcss/enforce-consistent-class-order": {
|
||||||
"count": 2
|
"count": 2
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,10 @@ const config: KnipConfig = {
|
||||||
entry: [
|
entry: [
|
||||||
'scripts/**/*.{js,ts,mjs}',
|
'scripts/**/*.{js,ts,mjs}',
|
||||||
'bin/**/*.{js,ts,mjs}',
|
'bin/**/*.{js,ts,mjs}',
|
||||||
|
'taze.config.js',
|
||||||
|
'tsslint.config.ts',
|
||||||
],
|
],
|
||||||
ignore: [
|
ignore: [
|
||||||
'i18n/**',
|
|
||||||
'public/**',
|
'public/**',
|
||||||
],
|
],
|
||||||
ignoreBinaries: [
|
ignoreBinaries: [
|
||||||
|
|
@ -19,9 +20,6 @@ const config: KnipConfig = {
|
||||||
'@iconify-json/*',
|
'@iconify-json/*',
|
||||||
|
|
||||||
'@storybook/addon-onboarding',
|
'@storybook/addon-onboarding',
|
||||||
|
|
||||||
'@tsslint/compat-eslint',
|
|
||||||
'@tsslint/config',
|
|
||||||
],
|
],
|
||||||
rules: {
|
rules: {
|
||||||
files: 'warn',
|
files: 'warn',
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "1.13.0",
|
"version": "1.13.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.32.0",
|
"packageManager": "pnpm@10.32.1",
|
||||||
"imports": {
|
"imports": {
|
||||||
"#i18n": {
|
"#i18n": {
|
||||||
"react-server": "./i18n-config/lib.server.ts",
|
"react-server": "./i18n-config/lib.server.ts",
|
||||||
|
|
@ -32,6 +32,7 @@
|
||||||
"build:vinext": "vinext build",
|
"build:vinext": "vinext build",
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"dev:inspect": "next dev --inspect",
|
"dev:inspect": "next dev --inspect",
|
||||||
|
"dev:proxy": "tsx ./scripts/dev-hono-proxy.ts",
|
||||||
"dev:vinext": "vinext dev",
|
"dev:vinext": "vinext dev",
|
||||||
"gen-doc-paths": "tsx ./scripts/gen-doc-paths.ts",
|
"gen-doc-paths": "tsx ./scripts/gen-doc-paths.ts",
|
||||||
"gen-icons": "node ./scripts/gen-icons.mjs && eslint --fix app/components/base/icons/src/",
|
"gen-icons": "node ./scripts/gen-icons.mjs && eslint --fix app/components/base/icons/src/",
|
||||||
|
|
@ -50,7 +51,6 @@
|
||||||
"storybook": "storybook dev -p 6006",
|
"storybook": "storybook dev -p 6006",
|
||||||
"storybook:build": "storybook build",
|
"storybook:build": "storybook build",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:ci": "vitest run --coverage --silent=passed-only",
|
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:watch": "vitest --watch",
|
"test:watch": "vitest --watch",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
|
|
@ -58,14 +58,15 @@
|
||||||
"uglify-embed": "node ./bin/uglify-embed"
|
"uglify-embed": "node ./bin/uglify-embed"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@amplitude/analytics-browser": "2.36.3",
|
"@amplitude/analytics-browser": "2.36.4",
|
||||||
"@amplitude/plugin-session-replay-browser": "1.25.21",
|
"@amplitude/plugin-session-replay-browser": "1.26.1",
|
||||||
"@base-ui/react": "1.2.0",
|
"@base-ui/react": "1.3.0",
|
||||||
"@emoji-mart/data": "1.2.1",
|
"@emoji-mart/data": "1.2.1",
|
||||||
"@floating-ui/react": "0.27.19",
|
"@floating-ui/react": "0.27.19",
|
||||||
"@formatjs/intl-localematcher": "0.8.1",
|
"@formatjs/intl-localematcher": "0.8.1",
|
||||||
"@headlessui/react": "2.2.9",
|
"@headlessui/react": "2.2.9",
|
||||||
"@heroicons/react": "2.2.0",
|
"@heroicons/react": "2.2.0",
|
||||||
|
"@hono/node-server": "1.19.11",
|
||||||
"@lexical/code": "0.41.0",
|
"@lexical/code": "0.41.0",
|
||||||
"@lexical/link": "0.41.0",
|
"@lexical/link": "0.41.0",
|
||||||
"@lexical/list": "0.41.0",
|
"@lexical/list": "0.41.0",
|
||||||
|
|
@ -85,7 +86,7 @@
|
||||||
"@svgdotjs/svg.js": "3.2.5",
|
"@svgdotjs/svg.js": "3.2.5",
|
||||||
"@t3-oss/env-nextjs": "0.13.10",
|
"@t3-oss/env-nextjs": "0.13.10",
|
||||||
"@tailwindcss/typography": "0.5.19",
|
"@tailwindcss/typography": "0.5.19",
|
||||||
"@tanstack/react-form": "1.28.4",
|
"@tanstack/react-form": "1.28.5",
|
||||||
"@tanstack/react-query": "5.90.21",
|
"@tanstack/react-query": "5.90.21",
|
||||||
"abcjs": "6.6.2",
|
"abcjs": "6.6.2",
|
||||||
"ahooks": "3.9.6",
|
"ahooks": "3.9.6",
|
||||||
|
|
@ -94,9 +95,9 @@
|
||||||
"cmdk": "1.1.1",
|
"cmdk": "1.1.1",
|
||||||
"copy-to-clipboard": "3.3.3",
|
"copy-to-clipboard": "3.3.3",
|
||||||
"cron-parser": "5.5.0",
|
"cron-parser": "5.5.0",
|
||||||
"dayjs": "1.11.19",
|
"dayjs": "1.11.20",
|
||||||
"decimal.js": "10.6.0",
|
"decimal.js": "10.6.0",
|
||||||
"dompurify": "3.3.2",
|
"dompurify": "3.3.3",
|
||||||
"echarts": "6.0.0",
|
"echarts": "6.0.0",
|
||||||
"echarts-for-react": "3.0.6",
|
"echarts-for-react": "3.0.6",
|
||||||
"elkjs": "0.11.1",
|
"elkjs": "0.11.1",
|
||||||
|
|
@ -106,9 +107,10 @@
|
||||||
"es-toolkit": "1.45.1",
|
"es-toolkit": "1.45.1",
|
||||||
"fast-deep-equal": "3.1.3",
|
"fast-deep-equal": "3.1.3",
|
||||||
"foxact": "0.2.54",
|
"foxact": "0.2.54",
|
||||||
|
"hono": "4.12.7",
|
||||||
"html-entities": "2.6.0",
|
"html-entities": "2.6.0",
|
||||||
"html-to-image": "1.11.13",
|
"html-to-image": "1.11.13",
|
||||||
"i18next": "25.8.17",
|
"i18next": "25.8.18",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"immer": "11.1.4",
|
"immer": "11.1.4",
|
||||||
"jotai": "2.18.1",
|
"jotai": "2.18.1",
|
||||||
|
|
@ -136,7 +138,7 @@
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-easy-crop": "5.5.6",
|
"react-easy-crop": "5.5.6",
|
||||||
"react-hotkeys-hook": "5.2.4",
|
"react-hotkeys-hook": "5.2.4",
|
||||||
"react-i18next": "16.5.6",
|
"react-i18next": "16.5.8",
|
||||||
"react-multi-email": "1.0.25",
|
"react-multi-email": "1.0.25",
|
||||||
"react-papaparse": "4.4.0",
|
"react-papaparse": "4.4.0",
|
||||||
"react-pdf-highlighter": "8.0.0-rc.0",
|
"react-pdf-highlighter": "8.0.0-rc.0",
|
||||||
|
|
@ -164,7 +166,7 @@
|
||||||
"zustand": "5.0.11"
|
"zustand": "5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@antfu/eslint-config": "7.7.0",
|
"@antfu/eslint-config": "7.7.2",
|
||||||
"@chromatic-com/storybook": "5.0.1",
|
"@chromatic-com/storybook": "5.0.1",
|
||||||
"@egoist/tailwindcss-icons": "1.9.2",
|
"@egoist/tailwindcss-icons": "1.9.2",
|
||||||
"@eslint-react/eslint-plugin": "2.13.0",
|
"@eslint-react/eslint-plugin": "2.13.0",
|
||||||
|
|
@ -183,8 +185,8 @@
|
||||||
"@storybook/nextjs-vite": "10.2.17",
|
"@storybook/nextjs-vite": "10.2.17",
|
||||||
"@storybook/react": "10.2.17",
|
"@storybook/react": "10.2.17",
|
||||||
"@tanstack/eslint-plugin-query": "5.91.4",
|
"@tanstack/eslint-plugin-query": "5.91.4",
|
||||||
"@tanstack/react-devtools": "0.9.10",
|
"@tanstack/react-devtools": "0.9.13",
|
||||||
"@tanstack/react-form-devtools": "0.2.17",
|
"@tanstack/react-form-devtools": "0.2.18",
|
||||||
"@tanstack/react-query-devtools": "5.91.3",
|
"@tanstack/react-query-devtools": "5.91.3",
|
||||||
"@testing-library/dom": "10.4.1",
|
"@testing-library/dom": "10.4.1",
|
||||||
"@testing-library/jest-dom": "6.9.1",
|
"@testing-library/jest-dom": "6.9.1",
|
||||||
|
|
@ -196,7 +198,7 @@
|
||||||
"@types/js-cookie": "3.0.6",
|
"@types/js-cookie": "3.0.6",
|
||||||
"@types/js-yaml": "4.0.9",
|
"@types/js-yaml": "4.0.9",
|
||||||
"@types/negotiator": "0.6.4",
|
"@types/negotiator": "0.6.4",
|
||||||
"@types/node": "25.4.0",
|
"@types/node": "25.5.0",
|
||||||
"@types/postcss-js": "4.1.0",
|
"@types/postcss-js": "4.1.0",
|
||||||
"@types/qs": "6.15.0",
|
"@types/qs": "6.15.0",
|
||||||
"@types/react": "19.2.14",
|
"@types/react": "19.2.14",
|
||||||
|
|
@ -207,10 +209,10 @@
|
||||||
"@types/semver": "7.7.1",
|
"@types/semver": "7.7.1",
|
||||||
"@types/sortablejs": "1.15.9",
|
"@types/sortablejs": "1.15.9",
|
||||||
"@typescript-eslint/parser": "8.57.0",
|
"@typescript-eslint/parser": "8.57.0",
|
||||||
"@typescript/native-preview": "7.0.0-dev.20260310.1",
|
"@typescript/native-preview": "7.0.0-dev.20260312.1",
|
||||||
"@vitejs/plugin-react": "5.1.4",
|
"@vitejs/plugin-react": "6.0.0",
|
||||||
"@vitejs/plugin-rsc": "0.5.21",
|
"@vitejs/plugin-rsc": "0.5.21",
|
||||||
"@vitest/coverage-v8": "4.0.18",
|
"@vitest/coverage-v8": "4.1.0",
|
||||||
"agentation": "2.3.2",
|
"agentation": "2.3.2",
|
||||||
"autoprefixer": "10.4.27",
|
"autoprefixer": "10.4.27",
|
||||||
"code-inspector-plugin": "1.4.4",
|
"code-inspector-plugin": "1.4.4",
|
||||||
|
|
@ -231,17 +233,17 @@
|
||||||
"postcss": "8.5.8",
|
"postcss": "8.5.8",
|
||||||
"postcss-js": "5.1.0",
|
"postcss-js": "5.1.0",
|
||||||
"react-server-dom-webpack": "19.2.4",
|
"react-server-dom-webpack": "19.2.4",
|
||||||
"sass": "1.97.3",
|
"sass": "1.98.0",
|
||||||
"storybook": "10.2.17",
|
"storybook": "10.2.17",
|
||||||
"tailwindcss": "3.4.19",
|
"tailwindcss": "3.4.19",
|
||||||
|
"taze": "19.10.0",
|
||||||
"tsx": "4.21.0",
|
"tsx": "4.21.0",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"uglify-js": "3.19.3",
|
"uglify-js": "3.19.3",
|
||||||
"vinext": "0.0.29",
|
"vinext": "https://pkg.pr.new/vinext@18fe3ea",
|
||||||
"vite": "8.0.0-beta.18",
|
"vite": "8.0.0",
|
||||||
"vite-plugin-inspect": "11.3.3",
|
"vite-plugin-inspect": "11.3.3",
|
||||||
"vite-tsconfig-paths": "6.1.1",
|
"vitest": "4.1.0",
|
||||||
"vitest": "4.0.18",
|
|
||||||
"vitest-canvas-mock": "1.1.3"
|
"vitest-canvas-mock": "1.1.3"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
1007
web/pnpm-lock.yaml
1007
web/pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,21 @@
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { serve } from '@hono/node-server'
|
||||||
|
import { loadEnv } from 'vite'
|
||||||
|
import { createDevProxyApp, resolveDevProxyTargets } 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'
|
||||||
|
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))
|
||||||
|
|
||||||
|
serve({
|
||||||
|
fetch: app.fetch,
|
||||||
|
hostname: host,
|
||||||
|
port,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`[dev-hono-proxy] listening on http://${host}:${port}`)
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { defineConfig } from 'taze'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
exclude: [
|
||||||
|
// We are going to replace these
|
||||||
|
'react-syntax-highlighter',
|
||||||
|
'react-window',
|
||||||
|
'@types/react-window',
|
||||||
|
|
||||||
|
// We can not upgrade these yet
|
||||||
|
'tailwind-merge',
|
||||||
|
'tailwindcss',
|
||||||
|
],
|
||||||
|
|
||||||
|
write: true,
|
||||||
|
install: false,
|
||||||
|
recursive: true,
|
||||||
|
interactive: true,
|
||||||
|
})
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
|
/// <reference types="vitest/config" />
|
||||||
|
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import vinext from 'vinext'
|
import vinext from 'vinext'
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import Inspect from 'vite-plugin-inspect'
|
import Inspect from 'vite-plugin-inspect'
|
||||||
import tsconfigPaths from 'vite-tsconfig-paths'
|
|
||||||
import { createCodeInspectorPlugin, createForceInspectorClientInjectionPlugin } from './plugins/vite/code-inspector'
|
import { createCodeInspectorPlugin, createForceInspectorClientInjectionPlugin } from './plugins/vite/code-inspector'
|
||||||
import { customI18nHmrPlugin } from './plugins/vite/custom-i18n-hmr'
|
import { customI18nHmrPlugin } from './plugins/vite/custom-i18n-hmr'
|
||||||
|
|
||||||
|
|
@ -20,8 +21,6 @@ export default defineConfig(({ mode }) => {
|
||||||
return {
|
return {
|
||||||
plugins: isTest
|
plugins: isTest
|
||||||
? [
|
? [
|
||||||
// TODO: remove tsconfigPaths from test config after vitest supports it natively
|
|
||||||
tsconfigPaths(),
|
|
||||||
react(),
|
react(),
|
||||||
{
|
{
|
||||||
// Stub .mdx files so components importing them can be unit-tested
|
// Stub .mdx files so components importing them can be unit-tested
|
||||||
|
|
@ -46,7 +45,8 @@ export default defineConfig(({ mode }) => {
|
||||||
injectTarget: browserInitializerInjectTarget,
|
injectTarget: browserInitializerInjectTarget,
|
||||||
projectRoot,
|
projectRoot,
|
||||||
}),
|
}),
|
||||||
vinext(),
|
react(),
|
||||||
|
vinext({ react: false }),
|
||||||
customI18nHmrPlugin({ injectTarget: browserInitializerInjectTarget }),
|
customI18nHmrPlugin({ injectTarget: browserInitializerInjectTarget }),
|
||||||
// reactGrabOpenFilePlugin({
|
// reactGrabOpenFilePlugin({
|
||||||
// injectTarget: browserInitializerInjectTarget,
|
// injectTarget: browserInitializerInjectTarget,
|
||||||
|
|
@ -78,6 +78,7 @@ export default defineConfig(({ mode }) => {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
globals: true,
|
globals: true,
|
||||||
setupFiles: ['./vitest.setup.ts'],
|
setupFiles: ['./vitest.setup.ts'],
|
||||||
|
reporters: ['agent'],
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
reporter: isCI ? ['json', 'json-summary'] : ['text', 'json', 'json-summary'],
|
reporter: isCI ? ['json', 'json-summary'] : ['text', 'json', 'json-summary'],
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue