mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 17:47:27 +00:00
👷 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:
parent
46ccddcd24
commit
7eb78c43e6
7 changed files with 449 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 鉴权方案的域名方式下生产部署配置服务端数据库所需要的示例配置文件。
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
333
src/server/routers/async/__tests__/caller.test.ts
Normal file
333
src/server/routers/async/__tests__/caller.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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'),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue