diff --git a/docker-compose/local/.env.example b/docker-compose/local/.env.example index 21f2ad3dd5..82ca9c68ee 100644 --- a/docker-compose/local/.env.example +++ b/docker-compose/local/.env.example @@ -17,6 +17,9 @@ LOBE_PORT=3210 CASDOOR_PORT=8000 MINIO_PORT=9000 APP_URL=http://localhost:3210 +# INTERNAL_APP_URL is optional, used for server-to-server calls +# to bypass CDN/proxy. If not set, defaults to APP_URL. +# Example: INTERNAL_APP_URL=http://localhost:3210 AUTH_URL=http://localhost:3210/api/auth # Postgres related, which are the necessary environment variables for DB diff --git a/docs/self-hosting/server-database/docker-compose.mdx b/docs/self-hosting/server-database/docker-compose.mdx index 571d49221b..cab0178b69 100644 --- a/docs/self-hosting/server-database/docker-compose.mdx +++ b/docs/self-hosting/server-database/docker-compose.mdx @@ -675,6 +675,35 @@ You first need to access the WebUI for configuration: At this point, you have successfully deployed the LobeChat database version, and you can access your LobeChat service at `https://lobe.example.com`. +#### Configuring Internal Server Communication with `INTERNAL_APP_URL` + + + If you are deploying LobeChat behind a CDN (like Cloudflare) or reverse proxy, you may want to configure internal server-to-server communication to bypass the CDN/proxy layer for better performance. + + +You can configure the `INTERNAL_APP_URL` environment variable: + +```yaml +environment: + - 'APP_URL=https://lobe.example.com' # Public URL for browser access + - 'INTERNAL_APP_URL=http://localhost:3210' # Internal URL for server-to-server calls +``` + +**How it works:** +- `APP_URL`: Used for browser/client access, OAuth callbacks, webhooks, etc. (goes through CDN/proxy) +- `INTERNAL_APP_URL`: Used for internal server-to-server communication (bypasses CDN/proxy) + +If `INTERNAL_APP_URL` is not set, it defaults to `APP_URL`. + +**Configuration options:** +- `http://localhost:3210` - If using Docker with host network mode +- `http://lobe:3210` - If using Docker network with service name +- `http://127.0.0.1:3210` - Alternative localhost address + + + For Docker Compose deployments with `network_mode: 'service:network-service'`, use `http://localhost:3210` as the `INTERNAL_APP_URL`. + + #### Configuration Files For convenience, here is a summary of example configuration files required for the production deployment using the Casdoor authentication scheme: diff --git a/docs/self-hosting/server-database/docker-compose.zh-CN.mdx b/docs/self-hosting/server-database/docker-compose.zh-CN.mdx index e1ead3ff1f..d2e0c47db7 100644 --- a/docs/self-hosting/server-database/docker-compose.zh-CN.mdx +++ b/docs/self-hosting/server-database/docker-compose.zh-CN.mdx @@ -651,6 +651,35 @@ docker compose up -d # 重新启动 至此,你已经成功部署了 LobeChat 数据库版本,你可以通过 `https://lobe.example.com` 访问你的 LobeChat 服务。 +#### 使用 `INTERNAL_APP_URL` 配置内部服务器通信 + + + 如果你在 CDN(如 Cloudflare)或反向代理后部署 LobeChat,你可以配置内部服务器到服务器通信以绕过 CDN/代理层,以获得更好的性能。 + + +你可以配置 `INTERNAL_APP_URL` 环境变量: + +```yaml +environment: + - 'APP_URL=https://lobe.example.com' # 浏览器访问的公开 URL + - 'INTERNAL_APP_URL=http://localhost:3210' # 服务器到服务器调用的内部 URL +``` + +**工作原理:** +- `APP_URL`:用于浏览器/客户端访问、OAuth 回调、webhook 等(通过 CDN/代理) +- `INTERNAL_APP_URL`:用于内部服务器到服务器通信(绕过 CDN/代理) + +如果未设置 `INTERNAL_APP_URL`,它将默认为 `APP_URL`。 + +**配置选项:** +- `http://localhost:3210` - 如果使用 Docker 主机网络模式 +- `http://lobe:3210` - 如果使用 Docker 网络与服务名称 +- `http://127.0.0.1:3210` - 备用本地主机地址 + + + 对于使用 `network_mode: 'service:network-service'` 的 Docker Compose 部署,请使用 `http://localhost:3210` 作为 `INTERNAL_APP_URL`。 + + #### 配置文件 为方便一键复制,在此汇总基于 casdoor 鉴权方案的域名方式下生产部署配置服务端数据库所需要的示例配置文件。 diff --git a/src/envs/__tests__/app.test.ts b/src/envs/__tests__/app.test.ts index 3e64c2d0b1..b5b803cb93 100644 --- a/src/envs/__tests__/app.test.ts +++ b/src/envs/__tests__/app.test.ts @@ -1,18 +1,10 @@ // @vitest-environment node import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { getAppConfig } from '../app'; - -// Stub the global process object to safely mock environment variables -vi.stubGlobal('process', { - ...process, // Preserve the original process object - env: { ...process.env }, // Clone the environment variables object for modification -}); - describe('getServerConfig', () => { beforeEach(() => { - // Reset environment variables before each test case - vi.restoreAllMocks(); + // Reset modules to clear the cached config + vi.resetModules(); }); // it('correctly handles values for OPENAI_FUNCTION_REGIONS', () => { @@ -22,7 +14,8 @@ describe('getServerConfig', () => { // }); describe('index url', () => { - it('should return default URLs when no environment variables are set', () => { + it('should return default URLs when no environment variables are set', async () => { + const { getAppConfig } = await import('../app'); const config = getAppConfig(); expect(config.AGENTS_INDEX_URL).toBe( 'https://registry.npmmirror.com/@lobehub/agents-index/v1/files/public', @@ -32,18 +25,20 @@ describe('getServerConfig', () => { ); }); - it('should return custom URLs when environment variables are set', () => { + it('should return custom URLs when environment variables are set', async () => { process.env.AGENTS_INDEX_URL = 'https://custom-agents-url.com'; process.env.PLUGINS_INDEX_URL = 'https://custom-plugins-url.com'; + const { getAppConfig } = await import('../app'); const config = getAppConfig(); expect(config.AGENTS_INDEX_URL).toBe('https://custom-agents-url.com'); expect(config.PLUGINS_INDEX_URL).toBe('https://custom-plugins-url.com'); }); - it('should return default URLs when environment variables are empty string', () => { + it('should return default URLs when environment variables are empty string', async () => { process.env.AGENTS_INDEX_URL = ''; process.env.PLUGINS_INDEX_URL = ''; + const { getAppConfig } = await import('../app'); const config = getAppConfig(); expect(config.AGENTS_INDEX_URL).toBe( 'https://registry.npmmirror.com/@lobehub/agents-index/v1/files/public', @@ -53,4 +48,43 @@ describe('getServerConfig', () => { ); }); }); + + describe('INTERNAL_APP_URL', () => { + it('should default to APP_URL when INTERNAL_APP_URL is not set', async () => { + process.env.APP_URL = 'https://example.com'; + delete process.env.INTERNAL_APP_URL; + + const { getAppConfig } = await import('../app'); + const config = getAppConfig(); + expect(config.INTERNAL_APP_URL).toBe('https://example.com'); + }); + + it('should use INTERNAL_APP_URL when explicitly set', async () => { + process.env.APP_URL = 'https://public.example.com'; + process.env.INTERNAL_APP_URL = 'http://localhost:3210'; + + const { getAppConfig } = await import('../app'); + const config = getAppConfig(); + expect(config.INTERNAL_APP_URL).toBe('http://localhost:3210'); + }); + + it('should use INTERNAL_APP_URL over APP_URL when both are set', async () => { + process.env.APP_URL = 'https://public.example.com'; + process.env.INTERNAL_APP_URL = 'http://internal-service:3210'; + + const { getAppConfig } = await import('../app'); + const config = getAppConfig(); + expect(config.APP_URL).toBe('https://public.example.com'); + expect(config.INTERNAL_APP_URL).toBe('http://internal-service:3210'); + }); + + it('should handle localhost INTERNAL_APP_URL for bypassing CDN', async () => { + process.env.APP_URL = 'https://cloudflare-proxied.com'; + process.env.INTERNAL_APP_URL = 'http://127.0.0.1:3210'; + + const { getAppConfig } = await import('../app'); + const config = getAppConfig(); + expect(config.INTERNAL_APP_URL).toBe('http://127.0.0.1:3210'); + }); + }); }); diff --git a/src/envs/app.ts b/src/envs/app.ts index 6b94ccb98a..2b2b13a606 100644 --- a/src/envs/app.ts +++ b/src/envs/app.ts @@ -20,6 +20,10 @@ const APP_URL = process.env.APP_URL ? vercelUrl : 'http://localhost:3010'; +// INTERNAL_APP_URL is used for server-to-server calls to bypass CDN/proxy +// Falls back to APP_URL if not set +const INTERNAL_APP_URL = process.env.INTERNAL_APP_URL || APP_URL; + const ASSISTANT_INDEX_URL = 'https://registry.npmmirror.com/@lobehub/agents-index/v1/files/public'; const PLUGINS_INDEX_URL = 'https://registry.npmmirror.com/@lobehub/plugins-index/v1/files/public'; @@ -43,6 +47,7 @@ export const getAppConfig = () => { PLUGIN_SETTINGS: z.string().optional(), APP_URL: z.string().optional(), + INTERNAL_APP_URL: z.string().optional(), VERCEL_EDGE_CONFIG: z.string().optional(), MIDDLEWARE_REWRITE_THROUGH_LOCAL: z.boolean().optional(), ENABLE_AUTH_PROTECTION: z.boolean().optional(), @@ -77,6 +82,7 @@ export const getAppConfig = () => { VERCEL_EDGE_CONFIG: process.env.VERCEL_EDGE_CONFIG, APP_URL, + INTERNAL_APP_URL, MIDDLEWARE_REWRITE_THROUGH_LOCAL: process.env.MIDDLEWARE_REWRITE_THROUGH_LOCAL === '1', ENABLE_AUTH_PROTECTION: process.env.ENABLE_AUTH_PROTECTION === '1', diff --git a/src/server/routers/async/__tests__/caller.test.ts b/src/server/routers/async/__tests__/caller.test.ts new file mode 100644 index 0000000000..e288612931 --- /dev/null +++ b/src/server/routers/async/__tests__/caller.test.ts @@ -0,0 +1,333 @@ +// @vitest-environment node +// Import the module under test after mocks are set up +import { createTRPCClient, httpLink } from '@trpc/client'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt'; + +import { createAsyncServerClient } from '../caller'; + +// Create mockable appEnv - use object property to allow mutation +const mockAppEnv: { APP_URL?: string; INTERNAL_APP_URL?: string | null | undefined } = { + APP_URL: 'https://public.example.com', + INTERNAL_APP_URL: 'http://localhost:3210', +}; + +// Mock dependencies before importing the module under test +vi.mock('@trpc/client', () => ({ + createTRPCClient: vi.fn(), + httpLink: vi.fn((options) => options), +})); + +vi.mock('@/envs/app', () => ({ + get appEnv() { + return mockAppEnv; + }, +})); + +vi.mock('@/server/modules/KeyVaultsEncrypt', () => ({ + KeyVaultsGateKeeper: { + initWithEnvKey: vi.fn(), + }, +})); + +vi.mock('@/config/db', () => ({ + serverDBEnv: { + KEY_VAULTS_SECRET: 'test-secret-key', + }, +})); + +vi.mock('@/const/version', () => ({ + isDesktop: false, +})); + +describe('createAsyncServerClient - INTERNAL_APP_URL Tests', () => { + beforeEach(() => { + vi.clearAllMocks(); + + const mockEncrypt = vi.fn().mockResolvedValue('encrypted-payload-data'); + vi.mocked(KeyVaultsGateKeeper.initWithEnvKey).mockResolvedValue({ + encrypt: mockEncrypt, + } as any); + vi.mocked(createTRPCClient).mockReturnValue({ _mockClient: true } as any); + + // Reset to default values by mutating the object + mockAppEnv.APP_URL = 'https://public.example.com'; + mockAppEnv.INTERNAL_APP_URL = 'http://localhost:3210'; + }); + + describe('URL selection logic', () => { + it('should use INTERNAL_APP_URL when both APP_URL and INTERNAL_APP_URL are set', async () => { + mockAppEnv.APP_URL = 'https://public.example.com'; + mockAppEnv.INTERNAL_APP_URL = 'http://localhost:3210'; + + await createAsyncServerClient('user-123', { apiKey: 'test-key' }); + + const config = vi.mocked(createTRPCClient).mock.calls[0][0]; + const httpLinkOptions = config.links[0] as any; + + expect(httpLinkOptions.url).toBe('http://localhost:3210/trpc/async'); + expect(httpLinkOptions.url).not.toContain('public.example.com'); + }); + + it('should fall back to APP_URL when INTERNAL_APP_URL is not set in env', async () => { + // Simulating the result of getInternalAppUrl() when INTERNAL_APP_URL is not in env + // In this case, appEnv.INTERNAL_APP_URL would equal appEnv.APP_URL + mockAppEnv.APP_URL = 'https://fallback.example.com'; + mockAppEnv.INTERNAL_APP_URL = 'https://fallback.example.com'; // getInternalAppUrl() returns APP_URL + + await createAsyncServerClient('user-456', {}); + + const config = vi.mocked(createTRPCClient).mock.calls[0][0]; + const httpLinkOptions = config.links[0] as any; + + expect(httpLinkOptions.url).toBe('https://fallback.example.com/trpc/async'); + }); + + it('should use localhost to bypass CDN proxy', async () => { + mockAppEnv.APP_URL = 'https://cdn-proxied.example.com'; + mockAppEnv.INTERNAL_APP_URL = 'http://127.0.0.1:3210'; + + await createAsyncServerClient('user-789', {}); + + const config = vi.mocked(createTRPCClient).mock.calls[0][0]; + const httpLinkOptions = config.links[0] as any; + + expect(httpLinkOptions.url).toBe('http://127.0.0.1:3210/trpc/async'); + expect(httpLinkOptions.url).not.toContain('cdn-proxied'); + }); + + it('should use internal service name in Docker network', async () => { + mockAppEnv.APP_URL = 'https://public.example.com'; + mockAppEnv.INTERNAL_APP_URL = 'http://lobe-service:3210'; + + await createAsyncServerClient('user-docker', {}); + + const config = vi.mocked(createTRPCClient).mock.calls[0][0]; + const httpLinkOptions = config.links[0] as any; + + expect(httpLinkOptions.url).toBe('http://lobe-service:3210/trpc/async'); + }); + + it('should handle INTERNAL_APP_URL with trailing slash', async () => { + mockAppEnv.INTERNAL_APP_URL = 'http://localhost:3210/'; + + await createAsyncServerClient('user-trailing', {}); + + const config = vi.mocked(createTRPCClient).mock.calls[0][0]; + const httpLinkOptions = config.links[0] as any; + + // urlJoin should normalize the trailing slash + expect(httpLinkOptions.url).toBe('http://localhost:3210/trpc/async'); + }); + + it('should handle INTERNAL_APP_URL without trailing slash', async () => { + mockAppEnv.INTERNAL_APP_URL = 'https://example.com'; + + await createAsyncServerClient('user-no-trailing', {}); + + const config = vi.mocked(createTRPCClient).mock.calls[0][0]; + const httpLinkOptions = config.links[0] as any; + + expect(httpLinkOptions.url).toBe('https://example.com/trpc/async'); + }); + }); + + describe('authentication and headers', () => { + it('should include Authorization header with KEY_VAULTS_SECRET', async () => { + await createAsyncServerClient('user-auth', {}); + + const config = vi.mocked(createTRPCClient).mock.calls[0][0]; + const httpLinkOptions = config.links[0] as any; + + expect(httpLinkOptions.headers).toHaveProperty('Authorization'); + expect(httpLinkOptions.headers.Authorization).toBe('Bearer test-secret-key'); + }); + + it('should encrypt and include user payload in x-lobe-chat-auth header', async () => { + const testPayload = { apiKey: 'test-api-key-value', provider: 'openai' }; + const mockEncrypt = vi.fn().mockResolvedValue('test-encrypted-auth-data'); + vi.mocked(KeyVaultsGateKeeper.initWithEnvKey).mockResolvedValueOnce({ + encrypt: mockEncrypt, + } as any); + + await createAsyncServerClient('user-encrypt', testPayload); + + expect(KeyVaultsGateKeeper.initWithEnvKey).toHaveBeenCalled(); + expect(mockEncrypt).toHaveBeenCalledWith( + JSON.stringify({ payload: testPayload, userId: 'user-encrypt' }), + ); + + const config = vi.mocked(createTRPCClient).mock.calls[0][0]; + const httpLinkOptions = config.links[0] as any; + + // The header name is from LOBE_CHAT_AUTH_HEADER constant + expect(httpLinkOptions.headers).toHaveProperty('Authorization'); + // The X-lobe-chat-auth header should be present + expect(Object.keys(httpLinkOptions.headers)).toContain('X-lobe-chat-auth'); + expect(httpLinkOptions.headers['X-lobe-chat-auth']).toBe('test-encrypted-auth-data'); + }); + + it('should include Vercel bypass secret when available', async () => { + const originalEnv = process.env.VERCEL_AUTOMATION_BYPASS_SECRET; + process.env.VERCEL_AUTOMATION_BYPASS_SECRET = 'test-bypass-value'; + + await createAsyncServerClient('user-vercel', {}); + + const config = vi.mocked(createTRPCClient).mock.calls[0][0]; + const httpLinkOptions = config.links[0] as any; + + expect(httpLinkOptions.headers).toHaveProperty('x-vercel-protection-bypass'); + expect(httpLinkOptions.headers['x-vercel-protection-bypass']).toBe('test-bypass-value'); + + // Restore original env + if (originalEnv) { + process.env.VERCEL_AUTOMATION_BYPASS_SECRET = originalEnv; + } else { + delete process.env.VERCEL_AUTOMATION_BYPASS_SECRET; + } + }); + + it('should not include Vercel bypass secret when not available', async () => { + delete process.env.VERCEL_AUTOMATION_BYPASS_SECRET; + + await createAsyncServerClient('user-no-vercel', {}); + + const config = vi.mocked(createTRPCClient).mock.calls[0][0]; + const httpLinkOptions = config.links[0] as any; + + expect(httpLinkOptions.headers).not.toHaveProperty('x-vercel-protection-bypass'); + }); + }); + + describe('error handling', () => { + it('should handle encryption failure gracefully', async () => { + const mockEncrypt = vi.fn().mockRejectedValueOnce(new Error('Encryption failed')); + vi.mocked(KeyVaultsGateKeeper.initWithEnvKey).mockResolvedValueOnce({ + encrypt: mockEncrypt, + } as any); + + await expect(createAsyncServerClient('user-enc-fail', {})).rejects.toThrow( + 'Encryption failed', + ); + + expect(KeyVaultsGateKeeper.initWithEnvKey).toHaveBeenCalled(); + }); + + it('should handle missing INTERNAL_APP_URL by using APP_URL', async () => { + // When INTERNAL_APP_URL is not set in env, getInternalAppUrl() returns APP_URL + mockAppEnv.APP_URL = 'https://only-app-url.com'; + mockAppEnv.INTERNAL_APP_URL = 'https://only-app-url.com'; // Result of fallback + + await createAsyncServerClient('user-null', {}); + + const config = vi.mocked(createTRPCClient).mock.calls[0][0]; + const httpLinkOptions = config.links[0] as any; + + // Should use APP_URL when INTERNAL_APP_URL is not set in environment + expect(httpLinkOptions.url).toContain('only-app-url.com'); + }); + + it('should handle empty string INTERNAL_APP_URL', async () => { + mockAppEnv.APP_URL = 'https://fallback-from-empty.com'; + mockAppEnv.INTERNAL_APP_URL = ''; + + await createAsyncServerClient('user-empty', {}); + + const config = vi.mocked(createTRPCClient).mock.calls[0][0]; + const httpLinkOptions = config.links[0] as any; + + // Empty string is falsy, so urlJoin will use it but result may vary + expect(httpLinkOptions.url).toBeDefined(); + expect(httpLinkOptions.url).toContain('trpc/async'); + }); + + it('should handle malformed URL gracefully', async () => { + mockAppEnv.INTERNAL_APP_URL = 'not-a-valid-url'; + + await createAsyncServerClient('user-malformed', {}); + + const config = vi.mocked(createTRPCClient).mock.calls[0][0]; + const httpLinkOptions = config.links[0] as any; + + // urlJoin will still create a result, even if base is malformed + expect(httpLinkOptions.url).toBeDefined(); + expect(httpLinkOptions.url).toContain('trpc/async'); + }); + }); + + describe('TRPC client configuration', () => { + it('should configure httpLink with proper options', async () => { + await createAsyncServerClient('user-config', {}); + + expect(httpLink).toHaveBeenCalled(); + const httpLinkOptions = vi.mocked(httpLink).mock.calls[0][0]; + + expect(httpLinkOptions).toHaveProperty('url'); + expect(httpLinkOptions).toHaveProperty('headers'); + expect(httpLinkOptions).toHaveProperty('transformer'); + }); + + it('should pass httpLink result to createTRPCClient', async () => { + await createAsyncServerClient('user-link', {}); + + expect(createTRPCClient).toHaveBeenCalledWith({ + links: expect.arrayContaining([ + expect.objectContaining({ + url: expect.any(String), + headers: expect.any(Object), + }), + ]), + }); + }); + + it('should return the created TRPC client', async () => { + const client = await createAsyncServerClient('user-return', {}); + + expect(client).toBeDefined(); + expect(client).toHaveProperty('_mockClient'); + expect((client as any)._mockClient).toBe(true); + }); + }); + + describe('real-world scenarios', () => { + it('should handle production deployment behind Cloudflare', async () => { + mockAppEnv.APP_URL = 'https://lobechat.example.com'; + mockAppEnv.INTERNAL_APP_URL = 'http://localhost:3210'; + + await createAsyncServerClient('prod-user', { apiKey: 'test-key' }); + + const config = vi.mocked(createTRPCClient).mock.calls[0][0]; + const httpLinkOptions = config.links[0] as any; + + // Should use localhost to avoid CDN timeout + expect(httpLinkOptions.url).toBe('http://localhost:3210/trpc/async'); + }); + + it('should handle Docker Compose deployment with service names', async () => { + mockAppEnv.APP_URL = 'https://public.example.com'; + mockAppEnv.INTERNAL_APP_URL = 'http://lobe-chat-database:3210'; + + await createAsyncServerClient('docker-user', {}); + + const config = vi.mocked(createTRPCClient).mock.calls[0][0]; + const httpLinkOptions = config.links[0] as any; + + expect(httpLinkOptions.url).toBe('http://lobe-chat-database:3210/trpc/async'); + }); + + it('should handle deployment without CDN (INTERNAL_APP_URL not set)', async () => { + // When not using CDN, INTERNAL_APP_URL is not set, so it falls back to APP_URL + mockAppEnv.APP_URL = 'https://direct-access.example.com'; + mockAppEnv.INTERNAL_APP_URL = 'https://direct-access.example.com'; // Result of getInternalAppUrl() fallback + + await createAsyncServerClient('direct-user', {}); + + const config = vi.mocked(createTRPCClient).mock.calls[0][0]; + const httpLinkOptions = config.links[0] as any; + + // Should fallback to APP_URL + expect(httpLinkOptions.url).toBe('https://direct-access.example.com/trpc/async'); + }); + }); +}); diff --git a/src/server/routers/async/caller.ts b/src/server/routers/async/caller.ts index 864ef9aa0b..2b4a007d54 100644 --- a/src/server/routers/async/caller.ts +++ b/src/server/routers/async/caller.ts @@ -30,7 +30,8 @@ export const createAsyncServerClient = async (userId: string, payload: ClientSec httpLink({ headers, transformer: superjson, - url: urlJoin(appEnv.APP_URL!, '/trpc/async'), + // Use INTERNAL_APP_URL for server-to-server calls to bypass CDN/proxy + url: urlJoin(appEnv.INTERNAL_APP_URL!, '/trpc/async'), }), ], });