👷 build: add INTERNAL_APP_URL for server-to-server calls (#9960)

*  feat: add INTERNAL_APP_URL for server-to-server calls

Add INTERNAL_APP_URL environment variable to bypass CDN/proxy for internal operations like embedding and file chunking. Falls back to APP_URL if not set.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* 📝 docs: add INTERNAL_APP_URL documentation

Add documentation for INTERNAL_APP_URL environment variable in:
- docker-compose .env.example
- Docker Compose deployment guide (English and Chinese)

Explains how to bypass CDN/proxy for server-to-server operations.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

*  test: add tests for INTERNAL_APP_URL feature

Add comprehensive test coverage for INTERNAL_APP_URL:
- Test fallback behavior to APP_URL when INTERNAL_APP_URL is not set
- Test explicit INTERNAL_APP_URL configuration
- Test localhost bypass for CDN/proxy
- Test createAsyncServerClient using INTERNAL_APP_URL
- Test authentication headers in async calls

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

---------

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
XYenon 2025-11-06 13:28:54 +08:00 committed by GitHub
parent 46ccddcd24
commit 7eb78c43e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 449 additions and 14 deletions

View file

@ -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

View file

@ -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`
<Callout type="info">
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.
</Callout>
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
<Callout type="tip">
For Docker Compose deployments with `network_mode: 'service:network-service'`, use `http://localhost:3210` as the `INTERNAL_APP_URL`.
</Callout>
#### Configuration Files
For convenience, here is a summary of example configuration files required for the production deployment using the Casdoor authentication scheme:

View file

@ -651,6 +651,35 @@ docker compose up -d # 重新启动
至此,你已经成功部署了 LobeChat 数据库版本,你可以通过 `https://lobe.example.com` 访问你的 LobeChat 服务。
#### 使用 `INTERNAL_APP_URL` 配置内部服务器通信
<Callout type="info">
如果你在 CDN如 Cloudflare或反向代理后部署 LobeChat你可以配置内部服务器到服务器通信以绕过 CDN/代理层,以获得更好的性能。
</Callout>
你可以配置 `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` - 备用本地主机地址
<Callout type="tip">
对于使用 `network_mode: 'service:network-service'` 的 Docker Compose 部署,请使用 `http://localhost:3210` 作为 `INTERNAL_APP_URL`。
</Callout>
#### 配置文件
为方便一键复制,在此汇总基于 casdoor 鉴权方案的域名方式下生产部署配置服务端数据库所需要的示例配置文件。

View file

@ -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');
});
});
});

View file

@ -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',

View file

@ -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');
});
});
});

View file

@ -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'),
}),
],
});