mirror of
https://github.com/langgenius/dify.git
synced 2026-05-10 15:01:36 -04:00
feat(dev-proxy): init package (#35852)
This commit is contained in:
158
packages/dev-proxy/src/cli.spec.ts
Normal file
158
packages/dev-proxy/src/cli.spec.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import type { ChildProcessByStdio } from 'node:child_process'
|
||||
import type { Readable } from 'node:stream'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { once } from 'node:events'
|
||||
import fs from 'node:fs/promises'
|
||||
import net from 'node:net'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const tempDirs: string[] = []
|
||||
type DevProxyCliProcess = ChildProcessByStdio<null, Readable, Readable>
|
||||
|
||||
const childProcesses: DevProxyCliProcess[] = []
|
||||
const binPath = fileURLToPath(new URL('../bin/dev-proxy.js', import.meta.url))
|
||||
|
||||
const createTempDir = async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dev-proxy-cli-test-'))
|
||||
tempDirs.push(tempDir)
|
||||
return tempDir
|
||||
}
|
||||
|
||||
const getFreePort = async () => {
|
||||
const server = net.createServer()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string')
|
||||
throw new Error('Failed to allocate a test port.')
|
||||
|
||||
const { port } = address
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error)
|
||||
reject(error)
|
||||
else
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
return port
|
||||
}
|
||||
|
||||
const waitForOutput = (
|
||||
child: DevProxyCliProcess,
|
||||
output: () => string,
|
||||
expectedOutput: string,
|
||||
) => new Promise<void>((resolve, reject) => {
|
||||
let timeout: ReturnType<typeof setTimeout>
|
||||
|
||||
function cleanup() {
|
||||
clearTimeout(timeout)
|
||||
child.stdout.off('data', onData)
|
||||
child.stderr.off('data', onData)
|
||||
child.off('exit', onExit)
|
||||
}
|
||||
|
||||
function onData() {
|
||||
if (!output().includes(expectedOutput))
|
||||
return
|
||||
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
|
||||
function onExit(code: number | null, signal: NodeJS.Signals | null) {
|
||||
cleanup()
|
||||
reject(new Error(`dev-proxy exited before writing "${expectedOutput}" with code ${code} and signal ${signal}. Output:\n${output()}`))
|
||||
}
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
cleanup()
|
||||
reject(new Error(`Timed out waiting for "${expectedOutput}". Output:\n${output()}`))
|
||||
}, 3000)
|
||||
|
||||
child.stdout.on('data', onData)
|
||||
child.stderr.on('data', onData)
|
||||
child.once('exit', onExit)
|
||||
onData()
|
||||
})
|
||||
|
||||
const spawnCli = (args: readonly string[], cwd: string) => {
|
||||
const child = spawn(process.execPath, [binPath, ...args], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
FORCE_COLOR: '0',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
childProcesses.push(child)
|
||||
return child
|
||||
}
|
||||
|
||||
const stopChildProcess = async (child: DevProxyCliProcess) => {
|
||||
if (child.exitCode !== null || child.signalCode !== null)
|
||||
return
|
||||
|
||||
child.kill('SIGTERM')
|
||||
await once(child, 'exit')
|
||||
}
|
||||
|
||||
describe('dev proxy CLI', () => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(childProcesses.splice(0).map(stopChildProcess))
|
||||
await Promise.all(tempDirs.splice(0).map(tempDir => fs.rm(tempDir, {
|
||||
force: true,
|
||||
recursive: true,
|
||||
})))
|
||||
})
|
||||
|
||||
// Scenario: help output should still be a normal short-lived command.
|
||||
it('should print help and exit', async () => {
|
||||
// Arrange
|
||||
const tempDir = await createTempDir()
|
||||
const child = spawnCli(['--help'], tempDir)
|
||||
|
||||
// Act
|
||||
const [code] = await once(child, 'exit')
|
||||
|
||||
// Assert
|
||||
expect(code).toBe(0)
|
||||
})
|
||||
|
||||
// Scenario: successful server startup should keep the CLI process alive.
|
||||
it('should keep running after starting the proxy server', async () => {
|
||||
// Arrange
|
||||
const tempDir = await createTempDir()
|
||||
const port = await getFreePort()
|
||||
await fs.writeFile(path.join(tempDir, 'dev-proxy.config.ts'), `
|
||||
export default {
|
||||
routes: [{ paths: '/api', target: 'https://api.example.com' }],
|
||||
}
|
||||
`)
|
||||
|
||||
let output = ''
|
||||
const child = spawnCli(['--config', './dev-proxy.config.ts', '--host', '127.0.0.1', '--port', String(port)], tempDir)
|
||||
child.stdout.on('data', chunk => output += chunk.toString())
|
||||
child.stderr.on('data', chunk => output += chunk.toString())
|
||||
|
||||
// Act
|
||||
await waitForOutput(child, () => output, `[dev-proxy] listening on http://127.0.0.1:${port}`)
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
const response = await fetch(`http://127.0.0.1:${port}/not-proxied`)
|
||||
|
||||
// Assert
|
||||
expect(child.exitCode).toBeNull()
|
||||
expect(child.signalCode).toBeNull()
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
})
|
||||
56
packages/dev-proxy/src/cli.ts
Normal file
56
packages/dev-proxy/src/cli.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import process from 'node:process'
|
||||
import { serve } from '@hono/node-server'
|
||||
import { loadDevProxyConfig, parseDevProxyCliArgs, resolveDevProxyServerOptions } from './config'
|
||||
import { createDevProxyApp } from './server'
|
||||
|
||||
function printUsage() {
|
||||
console.log(`Usage:
|
||||
dev-proxy --config <path> [options]
|
||||
|
||||
Options:
|
||||
--config, -c <path> Path to a dev proxy config file. Defaults to dev-proxy.config.ts.
|
||||
--env-file <path> Load environment variables before evaluating the config file.
|
||||
--host <host> Override the configured host.
|
||||
--port <port> Override the configured port.
|
||||
--help, -h Show this help message.`)
|
||||
}
|
||||
|
||||
async function flushStandardStreams() {
|
||||
await Promise.all([
|
||||
new Promise<void>(resolve => process.stdout.write('', () => resolve())),
|
||||
new Promise<void>(resolve => process.stderr.write('', () => resolve())),
|
||||
])
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const cliOptions = parseDevProxyCliArgs(process.argv.slice(2))
|
||||
|
||||
if (cliOptions.help) {
|
||||
printUsage()
|
||||
return
|
||||
}
|
||||
|
||||
const config = await loadDevProxyConfig(cliOptions.config, process.cwd(), {
|
||||
envFile: cliOptions.envFile,
|
||||
})
|
||||
const { host, port } = resolveDevProxyServerOptions(config.server, cliOptions)
|
||||
const app = createDevProxyApp(config)
|
||||
|
||||
serve({
|
||||
fetch: app.fetch,
|
||||
hostname: host,
|
||||
port,
|
||||
})
|
||||
|
||||
console.log(`[dev-proxy] listening on http://${host}:${port}`)
|
||||
}
|
||||
|
||||
try {
|
||||
await main()
|
||||
await flushStandardStreams()
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
await flushStandardStreams()
|
||||
process.exit(1)
|
||||
}
|
||||
145
packages/dev-proxy/src/config.spec.ts
Normal file
145
packages/dev-proxy/src/config.spec.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import fs from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { loadDevProxyConfig, parseDevProxyCliArgs, resolveDevProxyServerOptions } from './config'
|
||||
|
||||
const tempDirs: string[] = []
|
||||
|
||||
const createTempDir = async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dev-proxy-test-'))
|
||||
tempDirs.push(tempDir)
|
||||
return tempDir
|
||||
}
|
||||
|
||||
describe('dev proxy config', () => {
|
||||
afterEach(async () => {
|
||||
delete process.env.DEV_PROXY_TEST_PORT
|
||||
delete process.env.DEV_PROXY_TEST_TARGET
|
||||
|
||||
await Promise.all(tempDirs.splice(0).map(tempDir => fs.rm(tempDir, {
|
||||
force: true,
|
||||
recursive: true,
|
||||
})))
|
||||
})
|
||||
|
||||
// Scenario: CLI options should support both inline and separated values.
|
||||
it('should parse proxy CLI options', () => {
|
||||
// Act
|
||||
const options = parseDevProxyCliArgs([
|
||||
'--config=./dev-proxy.config.ts',
|
||||
'--env-file',
|
||||
'./.env.proxy',
|
||||
'--host',
|
||||
'0.0.0.0',
|
||||
'--port',
|
||||
'8083',
|
||||
])
|
||||
|
||||
// Assert
|
||||
expect(options).toEqual({
|
||||
config: './dev-proxy.config.ts',
|
||||
envFile: './.env.proxy',
|
||||
host: '0.0.0.0',
|
||||
port: '8083',
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: removed target shortcuts should fail instead of silently doing the wrong thing.
|
||||
it('should reject unsupported target shortcuts', () => {
|
||||
// Assert
|
||||
expect(() => parseDevProxyCliArgs(['--target', 'enterprise'])).toThrow('Unsupported dev proxy option')
|
||||
})
|
||||
|
||||
// Scenario: package manager argument separators should not be treated as proxy options.
|
||||
it('should ignore package manager argument separators', () => {
|
||||
// Act
|
||||
const options = parseDevProxyCliArgs(['--config', './dev-proxy.config.ts', '--', '--help'])
|
||||
|
||||
// Assert
|
||||
expect(options).toEqual({
|
||||
config: './dev-proxy.config.ts',
|
||||
help: true,
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: CLI host and port should override config defaults.
|
||||
it('should resolve server options with CLI overrides', () => {
|
||||
// Act
|
||||
const options = resolveDevProxyServerOptions({
|
||||
host: '127.0.0.1',
|
||||
port: 5001,
|
||||
}, {
|
||||
host: '0.0.0.0',
|
||||
port: '9002',
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(options).toEqual({
|
||||
host: '0.0.0.0',
|
||||
port: 9002,
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: TS config files should load through c12.
|
||||
it('should load a TypeScript config file', async () => {
|
||||
// Arrange
|
||||
const tempDir = await createTempDir()
|
||||
await fs.writeFile(path.join(tempDir, 'dev-proxy.config.ts'), `
|
||||
export default {
|
||||
server: { host: '127.0.0.1', port: 7777 },
|
||||
routes: [{ paths: ['/api', '/files'], target: 'https://api.example.com' }],
|
||||
}
|
||||
`)
|
||||
|
||||
// Act
|
||||
const config = await loadDevProxyConfig('dev-proxy.config.ts', tempDir)
|
||||
|
||||
// Assert
|
||||
expect(config.server).toEqual({
|
||||
host: '127.0.0.1',
|
||||
port: 7777,
|
||||
})
|
||||
expect(config.routes).toEqual([
|
||||
{
|
||||
paths: ['/api', '/files'],
|
||||
target: 'https://api.example.com',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
// Scenario: env files should be loaded before the TypeScript config is evaluated.
|
||||
it('should load a TypeScript config file with env file values', async () => {
|
||||
// Arrange
|
||||
const tempDir = await createTempDir()
|
||||
await fs.writeFile(path.join(tempDir, '.env.proxy'), [
|
||||
'DEV_PROXY_TEST_PORT=7788',
|
||||
'DEV_PROXY_TEST_TARGET=https://env.example.com',
|
||||
].join('\n'))
|
||||
await fs.writeFile(path.join(tempDir, 'dev-proxy.config.ts'), `
|
||||
export default {
|
||||
server: { port: Number(process.env.DEV_PROXY_TEST_PORT) },
|
||||
routes: [{ paths: '/api', target: process.env.DEV_PROXY_TEST_TARGET }],
|
||||
}
|
||||
`)
|
||||
|
||||
// Act
|
||||
const config = await loadDevProxyConfig('dev-proxy.config.ts', tempDir, {
|
||||
envFile: '.env.proxy',
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(config.server).toEqual({
|
||||
port: 7788,
|
||||
})
|
||||
expect(config.routes).toEqual([
|
||||
{
|
||||
paths: '/api',
|
||||
target: 'https://env.example.com',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
129
packages/dev-proxy/src/config.ts
Normal file
129
packages/dev-proxy/src/config.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { DotenvOptions } from 'c12'
|
||||
import type { DevProxyCliOptions, DevProxyConfig, DevProxyConfigLoadOptions, DevProxyServerConfig, ResolvedDevProxyServerOptions } from './types'
|
||||
import path from 'node:path'
|
||||
import { loadConfig } from 'c12'
|
||||
|
||||
const DEFAULT_CONFIG_FILE = 'dev-proxy.config.ts'
|
||||
const DEFAULT_PROXY_HOST = '127.0.0.1'
|
||||
const DEFAULT_PROXY_PORT = 5001
|
||||
|
||||
const OPTION_NAME_TO_KEY = {
|
||||
'--config': 'config',
|
||||
'-c': 'config',
|
||||
'--env-file': 'envFile',
|
||||
'--host': 'host',
|
||||
'--port': 'port',
|
||||
} as const
|
||||
|
||||
type OptionName = keyof typeof OPTION_NAME_TO_KEY
|
||||
|
||||
const isOptionName = (value: string): value is OptionName => value in OPTION_NAME_TO_KEY
|
||||
|
||||
const requireOptionValue = (name: string, value?: string) => {
|
||||
if (!value || value.startsWith('-'))
|
||||
throw new Error(`Missing value for ${name}.`)
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
export const parseDevProxyCliArgs = (argv: readonly string[]): DevProxyCliOptions => {
|
||||
const options: DevProxyCliOptions = {}
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index]!
|
||||
|
||||
if (arg === '--')
|
||||
continue
|
||||
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
options.help = true
|
||||
continue
|
||||
}
|
||||
|
||||
const [rawName, inlineValue] = arg.split('=', 2)
|
||||
const name = rawName ?? ''
|
||||
|
||||
if (!name.startsWith('-'))
|
||||
continue
|
||||
|
||||
if (!isOptionName(name))
|
||||
throw new Error(`Unsupported dev proxy option "${name}".`)
|
||||
|
||||
const key = OPTION_NAME_TO_KEY[name]
|
||||
options[key] = inlineValue ?? requireOptionValue(name, argv[index + 1])
|
||||
|
||||
if (inlineValue === undefined)
|
||||
index += 1
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
const resolvePort = (rawPort: string | number) => {
|
||||
const port = Number(rawPort)
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
||||
throw new Error(`Invalid proxy port "${rawPort}". Expected an integer between 1 and 65535.`)
|
||||
|
||||
return port
|
||||
}
|
||||
|
||||
export const resolveDevProxyServerOptions = (
|
||||
serverConfig: DevProxyServerConfig = {},
|
||||
cliOptions: DevProxyCliOptions = {},
|
||||
): ResolvedDevProxyServerOptions => {
|
||||
const configuredPort = cliOptions.port ?? serverConfig.port ?? DEFAULT_PROXY_PORT
|
||||
|
||||
return {
|
||||
host: cliOptions.host || serverConfig.host || DEFAULT_PROXY_HOST,
|
||||
port: resolvePort(configuredPort),
|
||||
}
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null
|
||||
|
||||
export function assertDevProxyConfig(config: unknown): asserts config is DevProxyConfig {
|
||||
if (!isRecord(config))
|
||||
throw new Error('Dev proxy config must export an object.')
|
||||
|
||||
if (!Array.isArray(config.routes))
|
||||
throw new Error('Dev proxy config must include a routes array.')
|
||||
}
|
||||
|
||||
const resolveDotenvOptions = (
|
||||
envFile: DevProxyConfigLoadOptions['envFile'],
|
||||
cwd: string,
|
||||
): DotenvOptions | false => {
|
||||
if (!envFile)
|
||||
return false
|
||||
|
||||
const resolvedEnvFilePath = path.resolve(cwd, envFile)
|
||||
return {
|
||||
cwd: path.dirname(resolvedEnvFilePath),
|
||||
fileName: path.basename(resolvedEnvFilePath),
|
||||
interpolate: true,
|
||||
}
|
||||
}
|
||||
|
||||
export const loadDevProxyConfig = async (
|
||||
configPath = DEFAULT_CONFIG_FILE,
|
||||
cwd = process.cwd(),
|
||||
options: DevProxyConfigLoadOptions = {},
|
||||
): Promise<DevProxyConfig> => {
|
||||
const resolvedConfigPath = path.resolve(cwd, configPath)
|
||||
const parsedPath = path.parse(resolvedConfigPath)
|
||||
const { config: loadedConfig } = await loadConfig({
|
||||
configFile: parsedPath.name,
|
||||
cwd: parsedPath.dir,
|
||||
dotenv: resolveDotenvOptions(options.envFile, cwd),
|
||||
envName: false,
|
||||
globalRc: false,
|
||||
packageJson: false,
|
||||
rcFile: false,
|
||||
})
|
||||
|
||||
assertDevProxyConfig(loadedConfig)
|
||||
return loadedConfig
|
||||
}
|
||||
|
||||
export const defineDevProxyConfig = (config: DevProxyConfig) => config
|
||||
44
packages/dev-proxy/src/cookies.spec.ts
Normal file
44
packages/dev-proxy/src/cookies.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { rewriteCookieHeaderForUpstream, rewriteSetCookieHeadersForLocal } from './cookies'
|
||||
|
||||
describe('dev proxy cookies', () => {
|
||||
// Scenario: cookie names should only receive secure host prefixes when configured.
|
||||
it('should rewrite configured cookie names for HTTPS upstream requests', () => {
|
||||
// Act
|
||||
const cookieHeader = rewriteCookieHeaderForUpstream('access_token=abc; theme=dark; passport-app=def', {
|
||||
hostPrefixCookies: ['access_token', /^passport-/],
|
||||
useHostPrefix: true,
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(cookieHeader).toBe('__Host-access_token=abc; theme=dark; __Host-passport-app=def')
|
||||
})
|
||||
|
||||
// Scenario: HTTP upstreams should keep local cookie names even when rewrite config exists.
|
||||
it('should keep local cookie names for HTTP upstream requests', () => {
|
||||
// Act
|
||||
const cookieHeader = rewriteCookieHeaderForUpstream('access_token=abc; refresh_token=def', {
|
||||
hostPrefixCookies: ['access_token', 'refresh_token'],
|
||||
useHostPrefix: false,
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(cookieHeader).toBe('access_token=abc; refresh_token=def')
|
||||
})
|
||||
|
||||
// Scenario: upstream set-cookie headers should be converted into localhost-safe cookies.
|
||||
it('should rewrite upstream set-cookie headers for local development', () => {
|
||||
// Act
|
||||
const cookies = rewriteSetCookieHeadersForLocal([
|
||||
'__Host-access_token=abc; Path=/console/api; Domain=cloud.example.com; Secure; SameSite=None; Partitioned',
|
||||
])
|
||||
|
||||
// Assert
|
||||
expect(cookies).toEqual([
|
||||
'access_token=abc; Path=/; SameSite=Lax',
|
||||
])
|
||||
})
|
||||
})
|
||||
96
packages/dev-proxy/src/cookies.ts
Normal file
96
packages/dev-proxy/src/cookies.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { CookieRewriteOptions } from './types'
|
||||
|
||||
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 stripSecureCookiePrefix = (cookieName: string) => cookieName.replace(SECURE_COOKIE_PREFIX_PATTERN, '')
|
||||
|
||||
const matchesCookieName = (cookieName: string, matcher: string | RegExp) =>
|
||||
typeof matcher === 'string'
|
||||
? matcher === cookieName
|
||||
: matcher.test(cookieName)
|
||||
|
||||
const shouldUseHostPrefix = (cookieName: string, options: CookieRewriteOptions) => {
|
||||
const normalizedCookieName = stripSecureCookiePrefix(cookieName)
|
||||
|
||||
return options.hostPrefixCookies?.some(matcher => matchesCookieName(normalizedCookieName, matcher)) || false
|
||||
}
|
||||
|
||||
const toUpstreamCookieName = (cookieName: string, options: CookieRewriteOptions) => {
|
||||
if (cookieName.startsWith('__Host-'))
|
||||
return cookieName
|
||||
|
||||
if (cookieName.startsWith('__Secure-'))
|
||||
return `__Host-${stripSecureCookiePrefix(cookieName)}`
|
||||
|
||||
if (!shouldUseHostPrefix(cookieName, options))
|
||||
return cookieName
|
||||
|
||||
return `__Host-${cookieName}`
|
||||
}
|
||||
|
||||
export const toLocalCookieName = (cookieName: string) => stripSecureCookiePrefix(cookieName)
|
||||
|
||||
export const rewriteCookieHeaderForUpstream = (
|
||||
cookieHeader: string | undefined,
|
||||
options: CookieRewriteOptions & { useHostPrefix?: boolean },
|
||||
) => {
|
||||
if (!cookieHeader)
|
||||
return cookieHeader
|
||||
|
||||
const { useHostPrefix = true } = options
|
||||
|
||||
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)
|
||||
const upstreamCookieName = useHostPrefix
|
||||
? toUpstreamCookieName(cookieName, options)
|
||||
: cookieName
|
||||
|
||||
return `${upstreamCookieName}=${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: readonly string[]) =>
|
||||
setCookieHeaders.map(rewriteSetCookieValueForLocal)
|
||||
22
packages/dev-proxy/src/index.ts
Normal file
22
packages/dev-proxy/src/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export {
|
||||
assertDevProxyConfig,
|
||||
defineDevProxyConfig,
|
||||
loadDevProxyConfig,
|
||||
parseDevProxyCliArgs,
|
||||
resolveDevProxyServerOptions,
|
||||
} from './config'
|
||||
export { rewriteCookieHeaderForUpstream, rewriteSetCookieHeadersForLocal, toLocalCookieName } from './cookies'
|
||||
export { buildUpstreamUrl, createDevProxyApp, isAllowedDevOrigin, isAllowedLocalDevOrigin } from './server'
|
||||
export type {
|
||||
CookieNameMatcher,
|
||||
CookieRewriteOptions,
|
||||
CreateDevProxyAppOptions,
|
||||
DevProxyCliOptions,
|
||||
DevProxyConfig,
|
||||
DevProxyConfigLoadOptions,
|
||||
DevProxyCorsAllowedOrigins,
|
||||
DevProxyCorsConfig,
|
||||
DevProxyRoute,
|
||||
DevProxyServerConfig,
|
||||
ResolvedDevProxyServerOptions,
|
||||
} from './types'
|
||||
242
packages/dev-proxy/src/server.spec.ts
Normal file
242
packages/dev-proxy/src/server.spec.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildUpstreamUrl, createDevProxyApp, isAllowedDevOrigin } from './server'
|
||||
|
||||
describe('dev proxy server', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// 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 by default.
|
||||
it('should only allow local development origins by default', () => {
|
||||
// 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: explicit CORS origins should support non-local development hosts.
|
||||
it('should allow explicitly configured origins', () => {
|
||||
// Assert
|
||||
expect(isAllowedDevOrigin('https://app.example.com', ['https://app.example.com'])).toBe(true)
|
||||
expect(isAllowedDevOrigin('https://other.example.com', ['https://app.example.com'])).toBe(false)
|
||||
})
|
||||
|
||||
// Scenario: proxy requests should rewrite cookies and surface credentialed CORS headers when configured.
|
||||
it('should proxy api requests with configured 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.example.com; Secure; SameSite=None'],
|
||||
['transfer-encoding', 'chunked'],
|
||||
],
|
||||
}))
|
||||
const app = createDevProxyApp({
|
||||
routes: [
|
||||
{
|
||||
paths: '/console/api',
|
||||
target: 'https://cloud.example.com',
|
||||
cookieRewrite: {
|
||||
hostPrefixCookies: ['access_token'],
|
||||
},
|
||||
},
|
||||
],
|
||||
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; theme=dark',
|
||||
'Accept-Encoding': 'zstd, br, gzip',
|
||||
},
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
new URL('https://cloud.example.com/console/api/apps?page=1'),
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: expect.any(Headers),
|
||||
}),
|
||||
)
|
||||
|
||||
const requestHeaders = fetchImpl.mock.calls[0]?.[1]?.headers
|
||||
if (!(requestHeaders instanceof Headers))
|
||||
throw new Error('Expected proxy request headers to be Headers')
|
||||
|
||||
expect(requestHeaders.get('cookie')).toBe('__Host-access_token=abc; theme=dark')
|
||||
expect(requestHeaders.get('origin')).toBe('https://cloud.example.com')
|
||||
expect(requestHeaders.get('accept-encoding')).toBe('identity')
|
||||
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: generic proxy routes should not know Dify cookie names by default.
|
||||
it('should not rewrite cookie names when cookie rewriting is not configured', async () => {
|
||||
// Arrange
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response('ok'))
|
||||
const app = createDevProxyApp({
|
||||
routes: [
|
||||
{
|
||||
paths: '/api',
|
||||
target: 'https://api.example.com',
|
||||
},
|
||||
],
|
||||
fetchImpl,
|
||||
})
|
||||
|
||||
// Act
|
||||
await app.request('http://127.0.0.1:5001/api/messages', {
|
||||
headers: {
|
||||
Cookie: 'access_token=abc; refresh_token=def',
|
||||
},
|
||||
})
|
||||
|
||||
// Assert
|
||||
const requestHeaders = fetchImpl.mock.calls[0]?.[1]?.headers
|
||||
if (!(requestHeaders instanceof Headers))
|
||||
throw new Error('Expected proxy request headers to be Headers')
|
||||
|
||||
expect(requestHeaders.get('cookie')).toBe('access_token=abc; refresh_token=def')
|
||||
})
|
||||
|
||||
// Scenario: local HTTP upstreams expect local cookie names even when cookie rewriting is configured.
|
||||
it('should keep local cookie names for HTTP upstream targets', async () => {
|
||||
// Arrange
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response('ok'))
|
||||
const app = createDevProxyApp({
|
||||
routes: [
|
||||
{
|
||||
paths: '/console/api',
|
||||
target: 'http://127.0.0.1:5001',
|
||||
cookieRewrite: {
|
||||
hostPrefixCookies: ['access_token', 'refresh_token'],
|
||||
},
|
||||
},
|
||||
],
|
||||
fetchImpl,
|
||||
})
|
||||
|
||||
// Act
|
||||
await app.request('http://127.0.0.1:5010/console/api/account/profile', {
|
||||
headers: {
|
||||
Cookie: 'access_token=abc; refresh_token=def',
|
||||
},
|
||||
})
|
||||
|
||||
// Assert
|
||||
const requestHeaders = fetchImpl.mock.calls[0]?.[1]?.headers
|
||||
if (!(requestHeaders instanceof Headers))
|
||||
throw new Error('Expected proxy request headers to be Headers')
|
||||
|
||||
expect(requestHeaders.get('cookie')).toBe('access_token=abc; refresh_token=def')
|
||||
})
|
||||
|
||||
// Scenario: custom route paths should support independent upstream targets.
|
||||
it('should proxy custom route paths to their configured targets', async () => {
|
||||
// Arrange
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response('ok'))
|
||||
const app = createDevProxyApp({
|
||||
routes: [
|
||||
{
|
||||
paths: '/api',
|
||||
target: 'https://api.example.com',
|
||||
},
|
||||
{
|
||||
paths: '/files',
|
||||
target: 'https://files.example.com/assets',
|
||||
},
|
||||
],
|
||||
fetchImpl,
|
||||
})
|
||||
|
||||
// Act
|
||||
await app.request('http://127.0.0.1:5001/api/messages')
|
||||
await app.request('http://127.0.0.1:5001/files/logo.png?size=small')
|
||||
|
||||
// Assert
|
||||
expect(fetchImpl.mock.calls.map(([url]) => url.toString())).toEqual([
|
||||
'https://api.example.com/api/messages',
|
||||
'https://files.example.com/assets/files/logo.png?size=small',
|
||||
])
|
||||
})
|
||||
|
||||
// Scenario: routes are matched in config order so callers can put specific routes first.
|
||||
it('should prefer earlier route entries', async () => {
|
||||
// Arrange
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response('ok'))
|
||||
const app = createDevProxyApp({
|
||||
routes: [
|
||||
{
|
||||
paths: '/api/enterprise',
|
||||
target: 'https://enterprise.example.com',
|
||||
},
|
||||
{
|
||||
paths: '/api',
|
||||
target: 'https://api.example.com',
|
||||
},
|
||||
],
|
||||
fetchImpl,
|
||||
})
|
||||
|
||||
// Act
|
||||
await app.request('http://127.0.0.1:5001/api/enterprise/sso/login')
|
||||
|
||||
// Assert
|
||||
expect(fetchImpl.mock.calls.map(([url]) => url.toString())).toEqual([
|
||||
'https://enterprise.example.com/api/enterprise/sso/login',
|
||||
])
|
||||
})
|
||||
|
||||
// Scenario: preflight requests should advertise allowed headers for credentialed cross-origin calls.
|
||||
it('should answer CORS preflight requests', async () => {
|
||||
// Arrange
|
||||
const app = createDevProxyApp({
|
||||
routes: [
|
||||
{
|
||||
paths: '/api',
|
||||
target: 'https://api.example.com',
|
||||
},
|
||||
],
|
||||
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')
|
||||
})
|
||||
})
|
||||
254
packages/dev-proxy/src/server.ts
Normal file
254
packages/dev-proxy/src/server.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import type { Context, Hono } from 'hono'
|
||||
import type { CookieRewriteOptions, CreateDevProxyAppOptions, DevProxyCorsAllowedOrigins, DevProxyRoute } from './types'
|
||||
import { Hono as HonoApp } from 'hono'
|
||||
import { rewriteCookieHeaderForUpstream, rewriteSetCookieHeadersForLocal } 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 rewrittenCookieHeader = rewriteCookieHeaderForUpstream(headers.get('cookie') || undefined, {
|
||||
...cookieRewrite,
|
||||
useHostPrefix: targetUrl.protocol === 'https:',
|
||||
})
|
||||
if (rewrittenCookieHeader)
|
||||
headers.set('cookie', rewrittenCookieHeader)
|
||||
}
|
||||
|
||||
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,
|
||||
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 setCookieHeaders = getSetCookieHeaders(response.headers)
|
||||
const responseSetCookieHeaders = cookieRewrite
|
||||
? rewriteSetCookieHeadersForLocal(setCookieHeaders)
|
||||
: 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,
|
||||
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
|
||||
}
|
||||
50
packages/dev-proxy/src/types.ts
Normal file
50
packages/dev-proxy/src/types.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export type DevProxyServerConfig = {
|
||||
host?: string
|
||||
port?: number
|
||||
}
|
||||
|
||||
export type DevProxyCorsAllowedOrigins = 'local' | readonly string[]
|
||||
|
||||
export type DevProxyCorsConfig = {
|
||||
allowedOrigins?: DevProxyCorsAllowedOrigins
|
||||
}
|
||||
|
||||
export type CookieNameMatcher = string | RegExp
|
||||
|
||||
export type CookieRewriteOptions = {
|
||||
hostPrefixCookies?: readonly CookieNameMatcher[]
|
||||
}
|
||||
|
||||
export type DevProxyRoute = {
|
||||
paths: string | readonly string[]
|
||||
target: string
|
||||
cookieRewrite?: CookieRewriteOptions | false
|
||||
}
|
||||
|
||||
export type DevProxyConfig = {
|
||||
server?: DevProxyServerConfig
|
||||
routes: readonly DevProxyRoute[]
|
||||
cors?: DevProxyCorsConfig
|
||||
}
|
||||
|
||||
export type DevProxyCliOptions = {
|
||||
config?: string
|
||||
envFile?: string
|
||||
host?: string
|
||||
port?: string
|
||||
help?: boolean
|
||||
}
|
||||
|
||||
export type DevProxyConfigLoadOptions = {
|
||||
envFile?: string | false
|
||||
}
|
||||
|
||||
export type ResolvedDevProxyServerOptions = {
|
||||
host: string
|
||||
port: number
|
||||
}
|
||||
|
||||
export type CreateDevProxyAppOptions = Pick<DevProxyConfig, 'routes' | 'cors'> & {
|
||||
fetchImpl?: typeof globalThis.fetch
|
||||
logger?: Pick<Console, 'error'>
|
||||
}
|
||||
Reference in New Issue
Block a user