@lobehub/lobehub 2.0.0-next.126 → 2.0.0-next.128

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.env.example +23 -3
  2. package/.env.example.development +5 -0
  3. package/CHANGELOG.md +50 -0
  4. package/changelog/v1.json +18 -0
  5. package/docker-compose/local/docker-compose.yml +24 -1
  6. package/docker-compose/local/logto/docker-compose.yml +25 -2
  7. package/docker-compose.development.yml +6 -0
  8. package/locales/ar/auth.json +114 -1
  9. package/locales/bg-BG/auth.json +114 -1
  10. package/locales/de-DE/auth.json +114 -1
  11. package/locales/en-US/auth.json +42 -22
  12. package/locales/es-ES/auth.json +114 -1
  13. package/locales/fa-IR/auth.json +114 -1
  14. package/locales/fr-FR/auth.json +114 -1
  15. package/locales/it-IT/auth.json +114 -1
  16. package/locales/ja-JP/auth.json +114 -1
  17. package/locales/ko-KR/auth.json +114 -1
  18. package/locales/nl-NL/auth.json +114 -1
  19. package/locales/pl-PL/auth.json +114 -1
  20. package/locales/pt-BR/auth.json +114 -1
  21. package/locales/ru-RU/auth.json +114 -1
  22. package/locales/tr-TR/auth.json +114 -1
  23. package/locales/vi-VN/auth.json +114 -1
  24. package/locales/zh-CN/auth.json +36 -29
  25. package/locales/zh-TW/auth.json +114 -1
  26. package/package.json +4 -1
  27. package/packages/database/src/client/db.ts +21 -21
  28. package/packages/database/src/repositories/dataImporter/deprecated/index.ts +5 -5
  29. package/packages/database/src/repositories/dataImporter/index.ts +59 -59
  30. package/packages/database/src/schemas/generation.ts +16 -16
  31. package/packages/database/src/schemas/oidc.ts +36 -36
  32. package/packages/model-runtime/src/providers/newapi/index.ts +61 -18
  33. package/packages/model-runtime/src/runtimeMap.ts +1 -0
  34. package/src/app/[variants]/(main)/settings/provider/features/ProviderConfig/UpdateProviderInfo/SettingModal.tsx +10 -6
  35. package/src/envs/auth.test.ts +60 -0
  36. package/src/envs/auth.ts +3 -3
  37. package/src/envs/redis.ts +106 -0
  38. package/src/libs/redis/index.ts +5 -0
  39. package/src/libs/redis/manager.test.ts +107 -0
  40. package/src/libs/redis/manager.ts +56 -0
  41. package/src/libs/redis/redis.test.ts +158 -0
  42. package/src/libs/redis/redis.ts +117 -0
  43. package/src/libs/redis/types.ts +71 -0
  44. package/src/libs/redis/upstash.test.ts +154 -0
  45. package/src/libs/redis/upstash.ts +109 -0
  46. package/src/libs/redis/utils.test.ts +46 -0
  47. package/src/libs/redis/utils.ts +53 -0
  48. package/.github/workflows/check-console-log.yml +0 -117
@@ -10,7 +10,7 @@ import { files } from './file';
10
10
  import { users } from './user';
11
11
 
12
12
  /**
13
- * 生成主题表 - 用于组织和管理 AI 生成内容的主题
13
+ * Generation topics table - Used to organize and manage AI-generated content topics
14
14
  */
15
15
  export const generationTopics = pgTable('generation_topics', {
16
16
  id: text('id')
@@ -22,10 +22,10 @@ export const generationTopics = pgTable('generation_topics', {
22
22
  .references(() => users.id, { onDelete: 'cascade' })
23
23
  .notNull(),
24
24
 
25
- /** 简要描述主题内容,由 LLM 生成 */
25
+ /** Brief description of topic content, generated by LLM */
26
26
  title: text('title'),
27
27
 
28
- /** 主题封面图片 URL */
28
+ /** Topic cover image URL */
29
29
  coverUrl: text('cover_url'),
30
30
 
31
31
  ...timestamps,
@@ -37,7 +37,7 @@ export type NewGenerationTopic = typeof generationTopics.$inferInsert;
37
37
  export type GenerationTopicItem = typeof generationTopics.$inferSelect;
38
38
 
39
39
  /**
40
- * 生成批次表 - 存储一次生成请求的配置信息
40
+ * Generation batches table - Stores configuration information for a single generation request
41
41
  */
42
42
  export const generationBatches = pgTable('generation_batches', {
43
43
  id: text('id')
@@ -53,25 +53,25 @@ export const generationBatches = pgTable('generation_batches', {
53
53
  .notNull()
54
54
  .references(() => generationTopics.id, { onDelete: 'cascade' }),
55
55
 
56
- /** 服务商名称 */
56
+ /** Provider name */
57
57
  provider: text('provider').notNull(),
58
58
 
59
- /** 模型名称 */
59
+ /** Model name */
60
60
  model: text('model').notNull(),
61
61
 
62
- /** 生成提示词 */
62
+ /** Generation prompt */
63
63
  prompt: text('prompt').notNull(),
64
64
 
65
- /** 图片宽度 */
65
+ /** Image width */
66
66
  width: integer('width'),
67
67
 
68
- /** 图片高度 */
68
+ /** Image height */
69
69
  height: integer('height'),
70
70
 
71
- /** 图片比例 */
71
+ /** Image aspect ratio */
72
72
  ratio: varchar('ratio', { length: 64 }),
73
73
 
74
- /** 存储生成批次的配置,存放不需要建立索引的公共配置 */
74
+ /** Stores generation batch configuration for common settings that don't need indexing */
75
75
  config: jsonb('config'),
76
76
 
77
77
  ...timestamps,
@@ -86,7 +86,7 @@ export type GenerationBatchWithGenerations = GenerationBatchItem & {
86
86
  };
87
87
 
88
88
  /**
89
- * 存储单个 AI 生成信息
89
+ * Stores individual AI generation information
90
90
  */
91
91
  export const generations = pgTable('generations', {
92
92
  id: text('id')
@@ -102,18 +102,18 @@ export const generations = pgTable('generations', {
102
102
  .notNull()
103
103
  .references(() => generationBatches.id, { onDelete: 'cascade' }),
104
104
 
105
- /** 关联的异步任务 ID */
105
+ /** Associated async task ID */
106
106
  asyncTaskId: uuid('async_task_id').references(() => asyncTasks.id, {
107
107
  onDelete: 'set null',
108
108
  }),
109
109
 
110
- /** 关联的生成文件 ID,删除文件时连带删除生成记录 */
110
+ /** Associated generated file ID, deletes generation record when file is deleted */
111
111
  fileId: text('file_id').references(() => files.id, { onDelete: 'cascade' }),
112
112
 
113
- /** 生成种子值 */
113
+ /** Generation seed value */
114
114
  seed: integer('seed'),
115
115
 
116
- /** 生成的资源信息,包含存储在 s3 上的 key, 图片实际宽高,缩略图 key */
116
+ /** Generated asset information, including S3 storage key, actual image dimensions, thumbnail key, etc. */
117
117
  asset: jsonb('asset').$type<ImageGenerationAsset>(),
118
118
 
119
119
  ...timestamps,
@@ -6,8 +6,8 @@ import { timestamps, timestamptz } from './_helpers';
6
6
  import { users } from './user';
7
7
 
8
8
  /**
9
- * OIDC 授权码
10
- * oidc-provider 需要持久化的模型之一
9
+ * OIDC authorization code
10
+ * One of the models that oidc-provider needs to persist
11
11
  */
12
12
  export const oidcAuthorizationCodes = pgTable('oidc_authorization_codes', {
13
13
  id: varchar('id', { length: 255 }).primaryKey(),
@@ -23,8 +23,8 @@ export const oidcAuthorizationCodes = pgTable('oidc_authorization_codes', {
23
23
  });
24
24
 
25
25
  /**
26
- * OIDC 访问令牌
27
- * oidc-provider 需要持久化的模型之一
26
+ * OIDC access token
27
+ * One of the models that oidc-provider needs to persist
28
28
  */
29
29
  export const oidcAccessTokens = pgTable('oidc_access_tokens', {
30
30
  id: varchar('id', { length: 255 }).primaryKey(),
@@ -40,8 +40,8 @@ export const oidcAccessTokens = pgTable('oidc_access_tokens', {
40
40
  });
41
41
 
42
42
  /**
43
- * OIDC 刷新令牌
44
- * oidc-provider 需要持久化的模型之一
43
+ * OIDC refresh token
44
+ * One of the models that oidc-provider needs to persist
45
45
  */
46
46
  export const oidcRefreshTokens = pgTable('oidc_refresh_tokens', {
47
47
  id: varchar('id', { length: 255 }).primaryKey(),
@@ -57,8 +57,8 @@ export const oidcRefreshTokens = pgTable('oidc_refresh_tokens', {
57
57
  });
58
58
 
59
59
  /**
60
- * OIDC 设备代码
61
- * oidc-provider 需要持久化的模型之一
60
+ * OIDC device code
61
+ * One of the models that oidc-provider needs to persist
62
62
  */
63
63
  export const oidcDeviceCodes = pgTable('oidc_device_codes', {
64
64
  id: varchar('id', { length: 255 }).primaryKey(),
@@ -73,8 +73,8 @@ export const oidcDeviceCodes = pgTable('oidc_device_codes', {
73
73
  });
74
74
 
75
75
  /**
76
- * OIDC 交互会话
77
- * oidc-provider 需要持久化的模型之一
76
+ * OIDC interaction session
77
+ * One of the models that oidc-provider needs to persist
78
78
  */
79
79
  export const oidcInteractions = pgTable('oidc_interactions', {
80
80
  id: varchar('id', { length: 255 }).primaryKey(),
@@ -84,8 +84,8 @@ export const oidcInteractions = pgTable('oidc_interactions', {
84
84
  });
85
85
 
86
86
  /**
87
- * OIDC 授权记录
88
- * oidc-provider 需要持久化的模型之一
87
+ * OIDC grant record
88
+ * One of the models that oidc-provider needs to persist
89
89
  */
90
90
  export const oidcGrants = pgTable('oidc_grants', {
91
91
  id: varchar('id', { length: 255 }).primaryKey(),
@@ -100,14 +100,14 @@ export const oidcGrants = pgTable('oidc_grants', {
100
100
  });
101
101
 
102
102
  /**
103
- * OIDC 客户端配置
104
- * 存储 OIDC 客户端配置信息
103
+ * OIDC client configuration
104
+ * Stores OIDC client configuration information
105
105
  */
106
106
  export const oidcClients = pgTable('oidc_clients', {
107
107
  id: varchar('id', { length: 255 }).primaryKey(), // client_id
108
108
  name: text('name').notNull(),
109
109
  description: text('description'),
110
- clientSecret: varchar('client_secret', { length: 255 }), // 公共客户端可为 null
110
+ clientSecret: varchar('client_secret', { length: 255 }), // Can be null for public clients
111
111
  redirectUris: text('redirect_uris').array().notNull(),
112
112
  grants: text('grants').array().notNull(),
113
113
  responseTypes: text('response_types').array().notNull(),
@@ -123,8 +123,8 @@ export const oidcClients = pgTable('oidc_clients', {
123
123
  });
124
124
 
125
125
  /**
126
- * OIDC 会话
127
- * oidc-provider 需要持久化的模型之一
126
+ * OIDC session
127
+ * One of the models that oidc-provider needs to persist
128
128
  */
129
129
  export const oidcSessions = pgTable('oidc_sessions', {
130
130
  id: varchar('id', { length: 255 }).primaryKey(),
@@ -137,8 +137,8 @@ export const oidcSessions = pgTable('oidc_sessions', {
137
137
  });
138
138
 
139
139
  /**
140
- * OIDC 授权同意记录
141
- * 记录用户对客户端的授权同意历史
140
+ * OIDC authorization consent record
141
+ * Records user authorization consent history for clients
142
142
  */
143
143
  export const oidcConsents = pgTable(
144
144
  'oidc_consents',
@@ -159,39 +159,39 @@ export const oidcConsents = pgTable(
159
159
  );
160
160
 
161
161
  /**
162
- * 通用认证凭证传递表
163
- * 用于在不同客户端(桌面端、浏览器插件、移动端等)之间安全传递认证凭证
162
+ * Generic authentication credential handoff table
163
+ * Used to securely pass authentication credentials between different clients (desktop, browser extension, mobile, etc.)
164
164
  *
165
- * 工作流程:
166
- * 1. 客户端生成唯一的 handoff ID
167
- * 2. handoff ID 作为参数附加到 OAuth redirect_uri
168
- * 3. 认证成功后,中间页将凭证存储到此表
169
- * 4. 客户端轮询此表获取凭证
170
- * 5. 成功获取后立即删除记录
165
+ * Workflow:
166
+ * 1. Client generates a unique handoff ID
167
+ * 2. Appends handoff ID as a parameter to OAuth redirect_uri
168
+ * 3. After successful authentication, intermediate page stores credentials in this table
169
+ * 4. Client polls this table to retrieve credentials
170
+ * 5. Record is immediately deleted after successful retrieval
171
171
  */
172
172
  export const oauthHandoffs = pgTable('oauth_handoffs', {
173
173
  /**
174
- * 由客户端生成的一次性唯一标识符
175
- * 用于客户端轮询时认领自己的凭证
174
+ * One-time unique identifier generated by the client
175
+ * Used for client polling to claim its own credentials
176
176
  */
177
177
  id: text('id').primaryKey(),
178
178
 
179
179
  /**
180
- * 客户端类型标识
181
- * 如: 'desktop', 'browser-extension', 'mobile-app'
180
+ * Client type identifier
181
+ * Examples: 'desktop', 'browser-extension', 'mobile-app', etc.
182
182
  */
183
183
  client: varchar('client', { length: 50 }).notNull(),
184
184
 
185
185
  /**
186
- * 凭证数据的 JSON 载荷
187
- * 灵活存储不同认证流程所需的各种数据
188
- * 当前主要包含: { code: string; state: string }
186
+ * JSON payload for credential data
187
+ * Flexible storage for various data required by different authentication flows
188
+ * Currently mainly contains: { code: string; state: string }
189
189
  */
190
190
  payload: jsonb('payload').$type<Record<string, unknown>>().notNull(),
191
191
 
192
192
  /**
193
- * 时间戳字段,用于 TTL 控制
194
- * 凭证应在创建后 5 分钟内被消费,否则视为过期
193
+ * Timestamp fields for TTL control
194
+ * Credentials should be consumed within 5 minutes of creation, otherwise considered expired
195
195
  */
196
196
  ...timestamps,
197
197
  });
@@ -25,6 +25,62 @@ export interface NewAPIPricing {
25
25
  supported_endpoint_types?: string[];
26
26
  }
27
27
 
28
+ /**
29
+ * Detect if running in browser environment
30
+ */
31
+ const isBrowser = () => typeof window !== 'undefined' && typeof document !== 'undefined';
32
+
33
+ /**
34
+ * Parse a pricing API HTTP response into a `NewAPIPricing[] | null`.
35
+ * Shared between browser and server branches to avoid duplicated logic.
36
+ */
37
+ const parsePricingResponse = async (res: Response): Promise<NewAPIPricing[] | null> => {
38
+ if (!res.ok) {
39
+ return null;
40
+ }
41
+
42
+ try {
43
+ const body = await res.json();
44
+ return body?.success && body?.data ? (body.data as NewAPIPricing[]) : null;
45
+ } catch {
46
+ return null;
47
+ }
48
+ };
49
+
50
+ /**
51
+ * Fetch pricing information with CORS bypass for client-side requests
52
+ * In browser environment, use /webapi/proxy to avoid CORS errors
53
+ */
54
+ const fetchPricing = async (
55
+ pricingUrl: string,
56
+ apiKey: string,
57
+ ): Promise<NewAPIPricing[] | null> => {
58
+ try {
59
+ if (isBrowser()) {
60
+ // In browser environment, use the proxy endpoint to avoid CORS
61
+ // The proxy endpoint expects the URL as the request body
62
+ const proxyResponse = await fetch('/webapi/proxy', {
63
+ body: pricingUrl,
64
+ method: 'POST',
65
+ });
66
+
67
+ return await parsePricingResponse(proxyResponse);
68
+ } else {
69
+ // In server environment, fetch directly
70
+ const pricingResponse = await fetch(pricingUrl, {
71
+ headers: {
72
+ Authorization: `Bearer ${apiKey}`,
73
+ },
74
+ });
75
+
76
+ return await parsePricingResponse(pricingResponse);
77
+ }
78
+ } catch (error) {
79
+ console.debug('Failed to fetch NewAPI pricing info:', error);
80
+ return null;
81
+ }
82
+ };
83
+
28
84
  export const params = {
29
85
  debug: {
30
86
  chatCompletion: () => process.env.DEBUG_NEWAPI_CHAT_COMPLETION === '1',
@@ -42,25 +98,12 @@ export const params = {
42
98
 
43
99
  // Try to get pricing information to enrich model details
44
100
  let pricingMap: Map<string, NewAPIPricing> = new Map();
45
- try {
46
- // Use saved baseURL
47
- const pricingResponse = await fetch(`${baseURL}/api/pricing`, {
48
- headers: {
49
- Authorization: `Bearer ${openAIClient.apiKey}`,
50
- },
51
- });
52
101
 
53
- if (pricingResponse.ok) {
54
- const pricingData = await pricingResponse.json();
55
- if (pricingData.success && pricingData.data) {
56
- (pricingData.data as NewAPIPricing[]).forEach((pricing) => {
57
- pricingMap.set(pricing.model_name, pricing);
58
- });
59
- }
60
- }
61
- } catch (error) {
62
- // If fetching pricing information fails, continue using the basic model information
63
- console.debug('Failed to fetch NewAPI pricing info:', error);
102
+ const pricingList = await fetchPricing(`${baseURL}/api/pricing`, openAIClient.apiKey || '');
103
+ if (pricingList) {
104
+ pricingList.forEach((pricing) => {
105
+ pricingMap.set(pricing.model_name, pricing);
106
+ });
64
107
  }
65
108
 
66
109
  // Process the model list: determine the provider for each model based on priority rules
@@ -112,6 +112,7 @@ export const providerRuntimeMap = {
112
112
  ppio: LobePPIOAI,
113
113
  qiniu: LobeQiniuAI,
114
114
  qwen: LobeQwenAI,
115
+ router: LobeNewAPIAI,
115
116
  sambanova: LobeSambaNovaAI,
116
117
  search1api: LobeSearch1API,
117
118
  sensenova: LobeSenseNovaAI,
@@ -84,12 +84,16 @@ const CreateNewProvider = memo<CreateNewProviderProps>(({ onClose, open, initial
84
84
  {
85
85
  children: (
86
86
  <Select
87
- optionRender={({ label, value }) => (
88
- <Flexbox align={'center'} gap={8} horizontal>
89
- <ProviderIcon provider={value as string} size={18} />
90
- {label}
91
- </Flexbox>
92
- )}
87
+ optionRender={({ label, value }) => {
88
+ // Map 'router' to 'newapi' for displaying the correct icon
89
+ const iconProvider = value === 'router' ? 'newapi' : (value as string);
90
+ return (
91
+ <Flexbox align={'center'} gap={8} horizontal>
92
+ <ProviderIcon provider={iconProvider} size={18} />
93
+ {label}
94
+ </Flexbox>
95
+ );
96
+ }}
93
97
  options={CUSTOM_PROVIDER_SDK_OPTIONS}
94
98
  placeholder={t('createNewAiProvider.sdkType.placeholder')}
95
99
  variant={'filled'}
@@ -0,0 +1,60 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+
3
+ import { getAuthConfig } from './auth';
4
+
5
+ const ORIGINAL_ENV = { ...process.env };
6
+ const ORIGINAL_WINDOW = globalThis.window;
7
+
8
+ describe('getAuthConfig fallbacks', () => {
9
+ beforeEach(() => {
10
+ // reset env to a clean clone before each test
11
+ process.env = { ...ORIGINAL_ENV };
12
+ globalThis.window = ORIGINAL_WINDOW;
13
+ });
14
+
15
+ afterEach(() => {
16
+ process.env = { ...ORIGINAL_ENV };
17
+ globalThis.window = ORIGINAL_WINDOW;
18
+ });
19
+
20
+ it('should fall back to NEXT_AUTH_SSO_PROVIDERS when AUTH_SSO_PROVIDERS is empty string', () => {
21
+ process.env.AUTH_SSO_PROVIDERS = '';
22
+ process.env.NEXT_AUTH_SSO_PROVIDERS = 'logto,github';
23
+
24
+ // Simulate server runtime so @t3-oss/env treats this as server-side access
25
+ // (happy-dom sets window by default in Vitest)
26
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
27
+ // @ts-expect-error - allow overriding for test
28
+ globalThis.window = undefined;
29
+
30
+ const config = getAuthConfig();
31
+
32
+ expect(config.AUTH_SSO_PROVIDERS).toBe('logto,github');
33
+ });
34
+
35
+ it('should fall back to NEXT_AUTH_SECRET when AUTH_SECRET is empty string', () => {
36
+ process.env.AUTH_SECRET = '';
37
+ process.env.NEXT_AUTH_SECRET = 'nextauth-secret';
38
+
39
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
40
+ // @ts-expect-error - allow overriding for test
41
+ globalThis.window = undefined;
42
+
43
+ const config = getAuthConfig();
44
+
45
+ expect(config.AUTH_SECRET).toBe('nextauth-secret');
46
+ });
47
+
48
+ it('should fall back to NEXTAUTH_URL origin when NEXT_PUBLIC_AUTH_URL is empty string', () => {
49
+ process.env.NEXT_PUBLIC_AUTH_URL = '';
50
+ process.env.NEXTAUTH_URL = 'https://example.com/api/auth';
51
+
52
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
53
+ // @ts-expect-error - allow overriding for test
54
+ globalThis.window = undefined;
55
+
56
+ const config = getAuthConfig();
57
+
58
+ expect(config.NEXT_PUBLIC_AUTH_URL).toBe('https://example.com');
59
+ });
60
+ });
package/src/envs/auth.ts CHANGED
@@ -237,14 +237,14 @@ export const getAuthConfig = () => {
237
237
  NEXT_PUBLIC_ENABLE_BETTER_AUTH: process.env.NEXT_PUBLIC_ENABLE_BETTER_AUTH === '1',
238
238
  // Fallback to NEXTAUTH_URL origin for seamless migration from next-auth
239
239
  NEXT_PUBLIC_AUTH_URL:
240
- process.env.NEXT_PUBLIC_AUTH_URL ??
240
+ process.env.NEXT_PUBLIC_AUTH_URL ||
241
241
  (process.env.NEXTAUTH_URL ? new URL(process.env.NEXTAUTH_URL).origin : undefined),
242
242
  NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION: process.env.NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION === '1',
243
243
  NEXT_PUBLIC_ENABLE_MAGIC_LINK: process.env.NEXT_PUBLIC_ENABLE_MAGIC_LINK === '1',
244
244
  // Fallback to NEXT_AUTH_SECRET for seamless migration from next-auth
245
- AUTH_SECRET: process.env.AUTH_SECRET ?? process.env.NEXT_AUTH_SECRET,
245
+ AUTH_SECRET: process.env.AUTH_SECRET || process.env.NEXT_AUTH_SECRET,
246
246
  // Fallback to NEXT_AUTH_SSO_PROVIDERS for seamless migration from next-auth
247
- AUTH_SSO_PROVIDERS: process.env.AUTH_SSO_PROVIDERS ?? process.env.NEXT_AUTH_SSO_PROVIDERS,
247
+ AUTH_SSO_PROVIDERS: process.env.AUTH_SSO_PROVIDERS || process.env.NEXT_AUTH_SSO_PROVIDERS,
248
248
 
249
249
  // better-auth env for Cognito provider is different from next-auth's one
250
250
  AUTH_COGNITO_DOMAIN: process.env.AUTH_COGNITO_DOMAIN,
@@ -0,0 +1,106 @@
1
+ /* eslint-disable sort-keys-fix/sort-keys-fix */
2
+ import { createEnv } from '@t3-oss/env-nextjs';
3
+ import { z } from 'zod';
4
+
5
+ import type { RedisConfig } from '@/libs/redis';
6
+
7
+ type UpstashRedisConfig = { token: string; url: string };
8
+
9
+ const parseNumber = (value?: string) => {
10
+ const parsed = Number.parseInt(value ?? '', 10);
11
+
12
+ return Number.isInteger(parsed) ? parsed : undefined;
13
+ };
14
+
15
+ const parseRedisTls = (value?: string) => {
16
+ if (!value) {
17
+ return false
18
+ }
19
+
20
+ const normalized = value.trim().toLowerCase();
21
+ return normalized === 'true' || normalized === '1';
22
+ };
23
+
24
+ export const getRedisEnv = () => {
25
+ return createEnv({
26
+ runtimeEnv: {
27
+ REDIS_DATABASE: parseNumber(process.env.REDIS_DATABASE),
28
+ REDIS_PASSWORD: process.env.REDIS_PASSWORD,
29
+ REDIS_PREFIX: process.env.REDIS_PREFIX || 'lobechat',
30
+ REDIS_TLS: parseRedisTls(process.env.REDIS_TLS),
31
+ REDIS_URL: process.env.REDIS_URL,
32
+ REDIS_USERNAME: process.env.REDIS_USERNAME,
33
+ UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN,
34
+ UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL,
35
+ },
36
+ server: {
37
+ REDIS_DATABASE: z.number().int().optional(),
38
+ REDIS_PASSWORD: z.string().optional(),
39
+ REDIS_PREFIX: z.string(),
40
+ REDIS_TLS: z.boolean().default(false),
41
+ REDIS_URL: z.string().url().optional(),
42
+ REDIS_USERNAME: z.string().optional(),
43
+ UPSTASH_REDIS_REST_TOKEN: z.string().optional(),
44
+ UPSTASH_REDIS_REST_URL: z.string().url().optional(),
45
+ },
46
+ });
47
+ };
48
+
49
+ export const redisEnv = getRedisEnv();
50
+
51
+ export const getUpstashRedisConfig = (): UpstashRedisConfig | null => {
52
+ const upstashConfigSchema = z.union([
53
+ z.object({
54
+ token: z.string(),
55
+ url: z.string().url(),
56
+ }),
57
+ z.object({
58
+ token: z.undefined().optional(),
59
+ url: z.undefined().optional(),
60
+ }),
61
+ ]);
62
+
63
+ const parsed = upstashConfigSchema.safeParse({
64
+ token: redisEnv.UPSTASH_REDIS_REST_TOKEN,
65
+ url: redisEnv.UPSTASH_REDIS_REST_URL,
66
+ });
67
+
68
+ if (!parsed.success) throw parsed.error;
69
+ if (!parsed.data.token || !parsed.data.url) return null;
70
+
71
+ return parsed.data;
72
+ };
73
+
74
+ export const getRedisConfig = (): RedisConfig => {
75
+ const prefix = redisEnv.REDIS_PREFIX;
76
+
77
+ if (redisEnv.REDIS_URL) {
78
+ return {
79
+ database: redisEnv.REDIS_DATABASE,
80
+ enabled: true,
81
+ password: redisEnv.REDIS_PASSWORD,
82
+ prefix,
83
+ provider: 'redis',
84
+ tls: redisEnv.REDIS_TLS,
85
+ url: redisEnv.REDIS_URL,
86
+ username: redisEnv.REDIS_USERNAME,
87
+ };
88
+ }
89
+
90
+ const upstashConfig = getUpstashRedisConfig();
91
+ if (upstashConfig) {
92
+ return {
93
+ enabled: true,
94
+ prefix,
95
+ provider: 'upstash',
96
+ token: upstashConfig.token,
97
+ url: upstashConfig.url,
98
+ };
99
+ }
100
+
101
+ return {
102
+ enabled: false,
103
+ prefix,
104
+ provider: false,
105
+ };
106
+ };
@@ -0,0 +1,5 @@
1
+ export * from './manager';
2
+ export * from './redis';
3
+ export * from './types';
4
+ export * from './upstash';
5
+ export * from './utils';
@@ -0,0 +1,107 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { RedisManager, initializeRedis, resetRedisClient } from './manager';
4
+ import { DisabledRedisConfig } from './types';
5
+
6
+ const {
7
+ mockIoRedisInitialize,
8
+ mockIoRedisDisconnect,
9
+ mockUpstashInitialize,
10
+ mockUpstashDisconnect,
11
+ } = vi.hoisted(() => ({
12
+ mockIoRedisInitialize: vi.fn().mockResolvedValue(undefined),
13
+ mockIoRedisDisconnect: vi.fn().mockResolvedValue(undefined),
14
+ mockUpstashInitialize: vi.fn().mockResolvedValue(undefined),
15
+ mockUpstashDisconnect: vi.fn().mockResolvedValue(undefined),
16
+ }));
17
+
18
+ vi.mock('./redis', () => {
19
+ const IoRedisRedisProvider = vi.fn().mockImplementation((config) => ({
20
+ provider: 'redis' as const,
21
+ config,
22
+ initialize: mockIoRedisInitialize,
23
+ disconnect: mockIoRedisDisconnect,
24
+ }));
25
+
26
+ return { IoRedisRedisProvider };
27
+ });
28
+
29
+ vi.mock('./upstash', () => {
30
+ const UpstashRedisProvider = vi.fn().mockImplementation((config) => ({
31
+ provider: 'upstash' as const,
32
+ config,
33
+ initialize: mockUpstashInitialize,
34
+ disconnect: mockUpstashDisconnect,
35
+ }));
36
+
37
+ return { UpstashRedisProvider };
38
+ });
39
+
40
+ afterEach(async () => {
41
+ vi.clearAllMocks();
42
+ await RedisManager.reset();
43
+ });
44
+
45
+ describe('RedisManager', () => {
46
+ it('returns null when redis is disabled', async () => {
47
+ const config = {
48
+ enabled: false,
49
+ prefix: 'test',
50
+ provider: false,
51
+ } satisfies DisabledRedisConfig;
52
+
53
+ const instance = await initializeRedis(config);
54
+
55
+ expect(instance).toBeNull();
56
+ expect(mockIoRedisInitialize).not.toHaveBeenCalled();
57
+ expect(mockUpstashInitialize).not.toHaveBeenCalled();
58
+ });
59
+
60
+ it('initializes ioredis provider once and memoizes the instance', async () => {
61
+ const config = {
62
+ database: 0,
63
+ enabled: true,
64
+ password: 'pwd',
65
+ prefix: 'test',
66
+ provider: 'redis' as const,
67
+ tls: false,
68
+ url: 'redis://localhost:6379',
69
+ username: 'user',
70
+ };
71
+ const [first, second] = await Promise.all([initializeRedis(config), initializeRedis(config)]);
72
+
73
+ expect(first).toBe(second);
74
+ expect(mockIoRedisInitialize).toHaveBeenCalledTimes(1);
75
+ expect(mockUpstashInitialize).not.toHaveBeenCalled();
76
+ });
77
+
78
+ it('initializes upstash provider when configured', async () => {
79
+ const config = {
80
+ enabled: true,
81
+ prefix: 'test',
82
+ provider: 'upstash' as const,
83
+ token: 'token',
84
+ url: 'https://example.upstash.io',
85
+ };
86
+ const instance = await initializeRedis(config);
87
+
88
+ expect(instance?.provider).toBe('upstash');
89
+ expect(mockUpstashInitialize).toHaveBeenCalledTimes(1);
90
+ expect(mockIoRedisInitialize).not.toHaveBeenCalled();
91
+ });
92
+
93
+ it('disconnects existing provider on reset', async () => {
94
+ const config = {
95
+ enabled: true,
96
+ prefix: 'test',
97
+ provider: 'redis' as const,
98
+ tls: false,
99
+ url: 'redis://localhost:6379',
100
+ };
101
+
102
+ await initializeRedis(config);
103
+ await resetRedisClient();
104
+
105
+ expect(mockIoRedisDisconnect).toHaveBeenCalledTimes(1);
106
+ });
107
+ });