@lobehub/lobehub 2.0.0-next.292 → 2.0.0-next.294

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.
@@ -4,7 +4,7 @@ name: Release Desktop Beta
4
4
  # Beta/Nightly 频道发版工作流
5
5
  # ============================================
6
6
  # 触发条件: 发布包含 pre-release 标识的 release
7
- # 如: v2.0.0-beta.1, v2.0.0-alpha.1, v2.0.0-rc.1, v2.0.0-nightly.xxx
7
+ # 如: v2.0.0-beta.1, v2.0.0-alpha.1, v2.0.0-rc.1, v2.0.0-nightly.xxx, v2.0.0-next.292
8
8
  #
9
9
  # 注意: Stable 版本 (如 v2.0.0) 由 release-desktop-stable.yml 处理
10
10
  # ============================================
@@ -24,10 +24,10 @@ env:
24
24
 
25
25
  jobs:
26
26
  # ============================================
27
- # 检查是否为 Beta/Nightly 版本 (排除 Stable)
27
+ # 检查是否为 Beta/Nightly/Next 版本 (排除 Stable)
28
28
  # ============================================
29
29
  check-beta:
30
- name: Check if Beta/Nightly Release
30
+ name: Check if Beta/Nightly/Next Release
31
31
  runs-on: ubuntu-latest
32
32
  outputs:
33
33
  is_beta: ${{ steps.check.outputs.is_beta }}
@@ -40,10 +40,10 @@ jobs:
40
40
  version="${version#v}"
41
41
  echo "version=${version}" >> $GITHUB_OUTPUT
42
42
 
43
- # Beta/Nightly 版本包含 beta/alpha/rc/nightly
44
- if [[ "$version" == *"beta"* ]] || [[ "$version" == *"alpha"* ]] || [[ "$version" == *"rc"* ]] || [[ "$version" == *"nightly"* ]]; then
43
+ # Beta/Nightly/Next 版本包含 beta/alpha/rc/nightly/next
44
+ if [[ "$version" == *"beta"* ]] || [[ "$version" == *"alpha"* ]] || [[ "$version" == *"rc"* ]] || [[ "$version" == *"nightly"* ]] || [[ "$version" == *"next"* ]]; then
45
45
  echo "is_beta=true" >> $GITHUB_OUTPUT
46
- echo "✅ Beta/Nightly release detected: $version"
46
+ echo "✅ Beta/Nightly/Next release detected: $version"
47
47
  else
48
48
  echo "is_beta=false" >> $GITHUB_OUTPUT
49
49
  echo "⏭️ Skipping: $version is a stable release (handled by release-desktop-stable.yml)"
@@ -3,10 +3,10 @@ name: Release Desktop Stable
3
3
  # ============================================
4
4
  # Stable 频道发版工作流
5
5
  # ============================================
6
- # 触发条件: 发布不含 pre-release 标识的 release (如 v2.0.0)
6
+ # 触发条件: 发布不含 pre-release 后缀的 release (如 v2.0.0)
7
7
  #
8
8
  # 与 Beta 的区别:
9
- # 1. 仅响应 stable 版本 tag (不含 beta/alpha/rc/nightly)
9
+ # 1. 仅响应 stable 版本 tag (不含任何 '-' 后缀)
10
10
  # 2. 使用 STABLE 专用的 Umami 配置
11
11
  # 3. 额外上传到 S3 更新服务器
12
12
  # 4. 构建时注入 UPDATE_SERVER_URL 让客户端从 S3 检查更新
@@ -34,6 +34,11 @@ on:
34
34
  required: false
35
35
  type: boolean
36
36
  default: true
37
+ build_mac_intel:
38
+ description: 'Build macOS (Intel x64)'
39
+ required: false
40
+ type: boolean
41
+ default: true
37
42
  build_windows:
38
43
  description: 'Build Windows'
39
44
  required: false
@@ -84,8 +89,8 @@ jobs:
84
89
  if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
85
90
  # 手动触发: 使用输入的版本号
86
91
  version="${{ inputs.version }}"
92
+ version="${version#v}"
87
93
  echo "is_manual=true" >> $GITHUB_OUTPUT
88
- echo "is_stable=true" >> $GITHUB_OUTPUT
89
94
  echo "version=${version}" >> $GITHUB_OUTPUT
90
95
  echo "release_notes=" >> $GITHUB_OUTPUT
91
96
  echo "🔧 Manual trigger: version=${version}"
@@ -101,15 +106,15 @@ jobs:
101
106
  printf '%s\n' "$release_body"
102
107
  echo "EOF"
103
108
  } >> $GITHUB_OUTPUT
109
+ fi
104
110
 
105
- # 检查是否为 stable 版本 (不含 beta/alpha/rc/nightly)
106
- if [[ "$version" == *"beta"* ]] || [[ "$version" == *"alpha"* ]] || [[ "$version" == *"rc"* ]] || [[ "$version" == *"nightly"* ]]; then
107
- echo "is_stable=false" >> $GITHUB_OUTPUT
108
- echo "⏭️ Skipping: $version is not a stable release"
109
- else
110
- echo "is_stable=true" >> $GITHUB_OUTPUT
111
- echo "✅ Stable release detected: $version"
112
- fi
111
+ # 检查是否为 stable 版本 (不含任何 '-' 后缀)
112
+ if [[ "$version" == *"-"* ]]; then
113
+ echo "is_stable=false" >> $GITHUB_OUTPUT
114
+ echo "⏭️ Skipping: $version is not a stable release"
115
+ else
116
+ echo "is_stable=true" >> $GITHUB_OUTPUT
117
+ echo "✅ Stable release detected: $version"
113
118
  fi
114
119
 
115
120
  # ============================================
@@ -147,6 +152,12 @@ jobs:
147
152
  static_matrix=$(echo "$static_matrix" | jq -c --argjson entry "$arm_entry" '. + [$entry]')
148
153
  fi
149
154
 
155
+ if [[ "${{ github.event_name }}" != "workflow_dispatch" ]] || [[ "${{ inputs.build_mac_intel }}" == "true" ]]; then
156
+ echo "Using GitHub-Hosted Runner for macOS Intel x64"
157
+ intel_entry='{"os": "macos-15-intel", "name": "macos-intel"}'
158
+ static_matrix=$(echo "$static_matrix" | jq -c --argjson entry "$intel_entry" '. + [$entry]')
159
+ fi
160
+
150
161
  # 输出
151
162
  echo "matrix={\"include\":$static_matrix}" >> $GITHUB_OUTPUT
152
163
 
package/CHANGELOG.md CHANGED
@@ -2,6 +2,58 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ## [Version 2.0.0-next.294](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.293...v2.0.0-next.294)
6
+
7
+ <sup>Released on **2026-01-15**</sup>
8
+
9
+ #### 🐛 Bug Fixes
10
+
11
+ - **chat**: Reset activeTopicId when switching agent/group.
12
+ - **mcp**: Fix installation check hanging issue in desktop app.
13
+
14
+ <br/>
15
+
16
+ <details>
17
+ <summary><kbd>Improvements and Fixes</kbd></summary>
18
+
19
+ #### What's fixed
20
+
21
+ - **chat**: Reset activeTopicId when switching agent/group, closes [#11523](https://github.com/lobehub/lobe-chat/issues/11523) ([fde54b0](https://github.com/lobehub/lobe-chat/commit/fde54b0))
22
+ - **mcp**: Fix installation check hanging issue in desktop app, closes [#11524](https://github.com/lobehub/lobe-chat/issues/11524) ([b9341c3](https://github.com/lobehub/lobe-chat/commit/b9341c3))
23
+
24
+ </details>
25
+
26
+ <div align="right">
27
+
28
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
29
+
30
+ </div>
31
+
32
+ ## [Version 2.0.0-next.293](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.292...v2.0.0-next.293)
33
+
34
+ <sup>Released on **2026-01-15**</sup>
35
+
36
+ #### ✨ Features
37
+
38
+ - **desktop**: Add desktop release service and API endpoint.
39
+
40
+ <br/>
41
+
42
+ <details>
43
+ <summary><kbd>Improvements and Fixes</kbd></summary>
44
+
45
+ #### What's improved
46
+
47
+ - **desktop**: Add desktop release service and API endpoint, closes [#11520](https://github.com/lobehub/lobe-chat/issues/11520) ([e3dc5be](https://github.com/lobehub/lobe-chat/commit/e3dc5be))
48
+
49
+ </details>
50
+
51
+ <div align="right">
52
+
53
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
54
+
55
+ </div>
56
+
5
57
  ## [Version 2.0.0-next.292](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.291...v2.0.0-next.292)
6
58
 
7
59
  <sup>Released on **2026-01-15**</sup>
@@ -21,7 +21,6 @@ export default defineConfig({
21
21
  },
22
22
  sourcemap: isDev ? 'inline' : false,
23
23
  },
24
-
25
24
  define: {
26
25
  'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
27
26
  'process.env.UPDATE_CHANNEL': JSON.stringify(process.env.UPDATE_CHANNEL),
@@ -7,7 +7,7 @@ import superjson from 'superjson';
7
7
  import FileService from '@/services/fileSrv';
8
8
  import { createLogger } from '@/utils/logger';
9
9
 
10
- import { MCPClient } from '../libs/mcp/client';
10
+ import { MCPClient, MCPConnectionError } from '../libs/mcp/client';
11
11
  import type { MCPClientParams, ToolCallContent, ToolCallResult } from '../libs/mcp/types';
12
12
  import { ControllerModule, IpcMethod } from './index';
13
13
 
@@ -228,8 +228,9 @@ export default class McpCtr extends ControllerModule {
228
228
  type: 'stdio',
229
229
  };
230
230
 
231
- const client = await this.createClient(params);
231
+ let client: MCPClient | undefined;
232
232
  try {
233
+ client = await this.createClient(params);
233
234
  const manifest = await client.listManifests();
234
235
  const identifier = input.name;
235
236
 
@@ -257,8 +258,25 @@ export default class McpCtr extends ControllerModule {
257
258
  mcpParams: params,
258
259
  type: 'mcp' as any,
259
260
  });
261
+ } catch (error) {
262
+ // If it's an MCPConnectionError with stderr logs, enhance the error message
263
+ if (error instanceof MCPConnectionError && error.stderrLogs.length > 0) {
264
+ const stderrOutput = error.stderrLogs.join('\n');
265
+ const enhancedError = new Error(
266
+ `${error.message}\n\n--- STDIO Process Output ---\n${stderrOutput}`,
267
+ );
268
+ enhancedError.name = error.name;
269
+ logger.error('getStdioMcpServerManifest failed with STDIO logs:', {
270
+ message: error.message,
271
+ stderrLogs: error.stderrLogs,
272
+ });
273
+ throw enhancedError;
274
+ }
275
+ throw error;
260
276
  } finally {
261
- await client.disconnect();
277
+ if (client) {
278
+ await client.disconnect();
279
+ }
262
280
  }
263
281
  }
264
282
 
@@ -313,8 +331,9 @@ export default class McpCtr extends ControllerModule {
313
331
  type: 'stdio',
314
332
  };
315
333
 
316
- const client = await this.createClient(params);
334
+ let client: MCPClient | undefined;
317
335
  try {
336
+ client = await this.createClient(params);
318
337
  const args = safeParseToRecord(input.args);
319
338
 
320
339
  const raw = (await client.callTool(input.toolName, args)) as ToolCallResult;
@@ -328,10 +347,25 @@ export default class McpCtr extends ControllerModule {
328
347
  success: true,
329
348
  });
330
349
  } catch (error) {
350
+ // If it's an MCPConnectionError with stderr logs, enhance the error message
351
+ if (error instanceof MCPConnectionError && error.stderrLogs.length > 0) {
352
+ const stderrOutput = error.stderrLogs.join('\n');
353
+ const enhancedError = new Error(
354
+ `${error.message}\n\n--- STDIO Process Output ---\n${stderrOutput}`,
355
+ );
356
+ enhancedError.name = error.name;
357
+ logger.error('callTool failed with STDIO logs:', {
358
+ message: error.message,
359
+ stderrLogs: error.stderrLogs,
360
+ });
361
+ throw enhancedError;
362
+ }
331
363
  logger.error('callTool failed:', error);
332
364
  throw error;
333
365
  } finally {
334
- await client.disconnect();
366
+ if (client) {
367
+ await client.disconnect();
368
+ }
335
369
  }
336
370
  }
337
371
 
@@ -361,8 +395,9 @@ export default class McpCtr extends ControllerModule {
361
395
  }
362
396
 
363
397
  private async checkSystemDependency(dependency: any) {
398
+ const checkCommand = dependency.checkCommand || `${dependency.name} --version`;
399
+
364
400
  try {
365
- const checkCommand = dependency.checkCommand || `${dependency.name} --version`;
366
401
  const { stdout, stderr } = await execPromise(checkCommand);
367
402
 
368
403
  if (stderr && !stdout) {
@@ -444,22 +479,19 @@ export default class McpCtr extends ControllerModule {
444
479
  const packageName = details?.packageName;
445
480
  if (!packageName) return { installed: false };
446
481
 
482
+ // Only check global npm list - do NOT use npx as it may download packages
447
483
  try {
448
484
  const { stdout } = await execPromise(`npm list -g ${packageName} --depth=0`);
449
- if (!stdout.includes('(empty)') && stdout.includes(packageName)) return { installed: true };
485
+ if (!stdout.includes('(empty)') && stdout.includes(packageName)) {
486
+ return { installed: true };
487
+ }
450
488
  } catch {
451
- // ignore
489
+ // ignore - package not found in global list
452
490
  }
453
491
 
454
- try {
455
- await execPromise(`npx -y ${packageName} --version`);
456
- return { installed: true };
457
- } catch (error) {
458
- return {
459
- error: error instanceof Error ? error.message : 'Unknown error',
460
- installed: false,
461
- };
462
- }
492
+ // For npm packages, we don't require pre-installation
493
+ // npx will handle downloading and running on-demand during actual MCP connection
494
+ return { installed: false };
463
495
  }
464
496
 
465
497
  if (installationMethod === 'python') {
@@ -553,7 +585,7 @@ export default class McpCtr extends ControllerModule {
553
585
  const bestResult = recommendedResult || firstInstallableResult || results[0];
554
586
 
555
587
  const checkResult: CheckMcpInstallResult = {
556
- ...(bestResult || {}),
588
+ ...bestResult,
557
589
  allOptions: results as any,
558
590
  platform: process.platform,
559
591
  success: true,
@@ -6,15 +6,31 @@ import {
6
6
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
7
7
  import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
8
8
  import type { Progress } from '@modelcontextprotocol/sdk/types.js';
9
+ import type { Readable } from 'node:stream';
9
10
 
10
11
  import { getDesktopEnv } from '@/env';
11
12
 
12
13
  import type { MCPClientParams, McpPrompt, McpResource, McpTool, ToolCallResult } from './types';
13
14
 
15
+ /**
16
+ * Custom error class for MCP connection errors that includes STDIO logs
17
+ */
18
+ export class MCPConnectionError extends Error {
19
+ readonly stderrLogs: string[];
20
+
21
+ constructor(message: string, stderrLogs: string[] = []) {
22
+ super(message);
23
+ this.name = 'MCPConnectionError';
24
+ this.stderrLogs = stderrLogs;
25
+ }
26
+ }
27
+
14
28
  export class MCPClient {
15
29
  private readonly mcp: Client;
16
30
 
17
31
  private transport: Transport;
32
+ private stderrLogs: string[] = [];
33
+ private isStdio: boolean = false;
18
34
 
19
35
  constructor(params: MCPClientParams) {
20
36
  this.mcp = new Client({ name: 'lobehub-desktop-mcp-client', version: '1.0.0' });
@@ -40,14 +56,21 @@ export class MCPClient {
40
56
  }
41
57
 
42
58
  case 'stdio': {
43
- this.transport = new StdioClientTransport({
59
+ this.isStdio = true;
60
+ const stdioTransport = new StdioClientTransport({
44
61
  args: params.args,
45
62
  command: params.command,
46
63
  env: {
47
64
  ...getDefaultEnvironment(),
48
65
  ...params.env,
49
66
  },
67
+ stderr: 'pipe', // Capture stderr for better error messages
50
68
  });
69
+
70
+ // Listen to stderr stream to collect logs
71
+ this.setupStderrListener(stdioTransport);
72
+
73
+ this.transport = stdioTransport;
51
74
  break;
52
75
  }
53
76
 
@@ -60,16 +83,45 @@ export class MCPClient {
60
83
  }
61
84
  }
62
85
 
86
+ private setupStderrListener(transport: StdioClientTransport) {
87
+ const stderr = transport.stderr as Readable | null;
88
+ if (stderr) {
89
+ stderr.on('data', (chunk: Buffer) => {
90
+ const text = chunk.toString('utf8');
91
+ // Split by newlines and filter empty lines
92
+ const lines = text.split('\n').filter((line) => line.trim());
93
+ this.stderrLogs.push(...lines);
94
+ });
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Get collected stderr logs from the STDIO process
100
+ */
101
+ getStderrLogs(): string[] {
102
+ return this.stderrLogs;
103
+ }
104
+
63
105
  private isMethodNotFoundError(error: unknown) {
64
106
  const err = error as any;
65
107
  if (!err) return false;
108
+ // eslint-disable-next-line unicorn/numeric-separators-style
66
109
  if (err.code === -32601) return true;
67
110
  if (typeof err.message === 'string' && err.message.includes('Method not found')) return true;
68
111
  return false;
69
112
  }
70
113
 
71
114
  async initialize(options: { onProgress?: (progress: Progress) => void } = {}) {
72
- await this.mcp.connect(this.transport, { onprogress: options.onProgress });
115
+ try {
116
+ await this.mcp.connect(this.transport, { onprogress: options.onProgress });
117
+ } catch (error) {
118
+ // If this is a STDIO connection and we have stderr logs, enhance the error
119
+ if (this.isStdio && this.stderrLogs.length > 0) {
120
+ const originalMessage = error instanceof Error ? error.message : String(error);
121
+ throw new MCPConnectionError(originalMessage, this.stderrLogs);
122
+ }
123
+ throw error;
124
+ }
73
125
  }
74
126
 
75
127
  async disconnect() {
package/changelog/v1.json CHANGED
@@ -1,4 +1,14 @@
1
1
  [
2
+ {
3
+ "children": {},
4
+ "date": "2026-01-15",
5
+ "version": "2.0.0-next.294"
6
+ },
7
+ {
8
+ "children": {},
9
+ "date": "2026-01-15",
10
+ "version": "2.0.0-next.293"
11
+ },
2
12
  {
3
13
  "children": {
4
14
  "improvements": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/lobehub",
3
- "version": "2.0.0-next.292",
3
+ "version": "2.0.0-next.294",
4
4
  "description": "LobeHub - an open-source,comprehensive AI Agent framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
5
5
  "keywords": [
6
6
  "framework",
@@ -1,3 +1,4 @@
1
1
  export enum FetchCacheTag {
2
2
  Changelog = 'changelog',
3
+ DesktopRelease = 'desktop-release',
3
4
  }
@@ -0,0 +1,115 @@
1
+ import debug from 'debug';
2
+ import { NextResponse } from 'next/server';
3
+ import { z } from 'zod';
4
+
5
+ import { zodValidator } from '@/app/(backend)/middleware/validate';
6
+ import {
7
+ type DesktopDownloadType,
8
+ getLatestDesktopReleaseFromGithub,
9
+ getStableDesktopReleaseInfoFromUpdateServer,
10
+ resolveDesktopDownload,
11
+ resolveDesktopDownloadFromUrls,
12
+ } from '@/server/services/desktopRelease';
13
+
14
+ const log = debug('api-route:desktop:latest');
15
+
16
+ const SupportedTypes = ['mac-arm', 'mac-intel', 'windows', 'linux'] as const;
17
+
18
+ const truthyStringToBoolean = z.preprocess((value) => {
19
+ if (!value) return undefined;
20
+ if (typeof value === 'boolean') return value;
21
+ if (typeof value !== 'string') return undefined;
22
+
23
+ const v = value.trim().toLowerCase();
24
+ if (!v) return undefined;
25
+
26
+ return v === '1' || v === 'true' || v === 'yes' || v === 'y';
27
+ }, z.boolean());
28
+
29
+ const downloadTypeSchema = z.preprocess((value) => {
30
+ if (typeof value !== 'string') return value;
31
+ return value;
32
+ }, z.enum(SupportedTypes));
33
+
34
+ const querySchema = z
35
+ .object({
36
+ asJson: truthyStringToBoolean.optional(),
37
+ as_json: truthyStringToBoolean.optional(),
38
+ type: downloadTypeSchema.optional(),
39
+ })
40
+ .strip()
41
+ .transform((value) => ({
42
+ asJson: value.as_json ?? value.asJson ?? false,
43
+ type: value.type,
44
+ }))
45
+ .superRefine((value, ctx) => {
46
+ if (!value.asJson && !value.type) {
47
+ ctx.addIssue({
48
+ code: z.ZodIssueCode.custom,
49
+ message: '`type` is required when `as_json` is false',
50
+ path: ['type'],
51
+ });
52
+ }
53
+ });
54
+
55
+ export const GET = zodValidator(querySchema)(async (req, _context, query) => {
56
+ try {
57
+ const { asJson, type } = query;
58
+
59
+ const stableInfo = await getStableDesktopReleaseInfoFromUpdateServer();
60
+
61
+ if (!type) {
62
+ if (stableInfo) {
63
+ return NextResponse.json({
64
+ links: {
65
+ 'linux': resolveDesktopDownloadFromUrls({ ...stableInfo, type: 'linux' }),
66
+ 'mac-arm': resolveDesktopDownloadFromUrls({ ...stableInfo, type: 'mac-arm' }),
67
+ 'mac-intel': resolveDesktopDownloadFromUrls({ ...stableInfo, type: 'mac-intel' }),
68
+ 'windows': resolveDesktopDownloadFromUrls({ ...stableInfo, type: 'windows' }),
69
+ },
70
+ tag: stableInfo.tag,
71
+ version: stableInfo.version,
72
+ });
73
+ }
74
+
75
+ const release = await getLatestDesktopReleaseFromGithub();
76
+ const resolveOne = (t: DesktopDownloadType) => resolveDesktopDownload(release, t);
77
+
78
+ return NextResponse.json({
79
+ links: {
80
+ 'linux': resolveOne('linux'),
81
+ 'mac-arm': resolveOne('mac-arm'),
82
+ 'mac-intel': resolveOne('mac-intel'),
83
+ 'windows': resolveOne('windows'),
84
+ },
85
+ tag: release.tag_name,
86
+ version: release.tag_name.replace(/^v/i, ''),
87
+ });
88
+ }
89
+
90
+ const s3Resolved = stableInfo ? resolveDesktopDownloadFromUrls({ ...stableInfo, type }) : null;
91
+ if (s3Resolved) {
92
+ if (asJson) return NextResponse.json(s3Resolved);
93
+ return NextResponse.redirect(s3Resolved.url, { status: 302 });
94
+ }
95
+
96
+ const release = await getLatestDesktopReleaseFromGithub();
97
+ const resolved = resolveDesktopDownload(release, type);
98
+ if (!resolved) {
99
+ return NextResponse.json(
100
+ { error: 'No matched asset for type', supportedTypes: SupportedTypes, type },
101
+ { status: 404 },
102
+ );
103
+ }
104
+
105
+ if (asJson) return NextResponse.json(resolved);
106
+
107
+ return NextResponse.redirect(resolved.url, { status: 302 });
108
+ } catch (e) {
109
+ log('Failed to resolve latest desktop download: %O', e);
110
+ return NextResponse.json(
111
+ { error: 'Failed to resolve latest desktop download' },
112
+ { status: 500 },
113
+ );
114
+ }
115
+ });
@@ -0,0 +1,61 @@
1
+ import { NextRequest } from 'next/server';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { z } from 'zod';
4
+
5
+ import { createValidator } from './createValidator';
6
+
7
+ describe('createValidator', () => {
8
+ it('should validate query for GET and pass parsed data to handler', async () => {
9
+ const validate = createValidator({
10
+ errorStatus: 422,
11
+ stopOnFirstError: true,
12
+ omitNotShapeField: true,
13
+ });
14
+ const schema = z.object({ type: z.enum(['a', 'b']) });
15
+
16
+ const handler = validate(schema)(async (_req: Request, _ctx: unknown, data: any) => {
17
+ return new Response(JSON.stringify({ ok: true, data }), { status: 200 });
18
+ });
19
+
20
+ const res = await handler(new NextRequest('https://example.com/api?type=a'));
21
+ expect(res.status).toBe(200);
22
+ expect(await res.json()).toEqual({ ok: true, data: { type: 'a' } });
23
+ });
24
+
25
+ it('should return 422 with one issue when stopOnFirstError', async () => {
26
+ const validate = createValidator({
27
+ errorStatus: 422,
28
+ stopOnFirstError: true,
29
+ omitNotShapeField: true,
30
+ });
31
+ const schema = z.object({
32
+ foo: z.string().min(2),
33
+ type: z.enum(['a', 'b']),
34
+ });
35
+
36
+ const handler = validate(schema)(async () => new Response('ok'));
37
+ const res = await handler(new NextRequest('https://example.com/api?foo=x&type=c'));
38
+ expect(res.status).toBe(422);
39
+ const body = await res.json();
40
+ expect(body.error).toBe('Invalid request');
41
+ expect(Array.isArray(body.issues)).toBe(true);
42
+ expect(body.issues).toHaveLength(1);
43
+ });
44
+
45
+ it('should omit unknown fields when omitNotShapeField enabled', async () => {
46
+ const validate = createValidator({
47
+ errorStatus: 422,
48
+ stopOnFirstError: true,
49
+ omitNotShapeField: true,
50
+ });
51
+ const schema = z.object({ type: z.enum(['a', 'b']) });
52
+
53
+ const handler = validate(schema)(async (_req: Request, _ctx: unknown, data: any) => {
54
+ return new Response(JSON.stringify(data), { status: 200 });
55
+ });
56
+
57
+ const res = await handler(new NextRequest('https://example.com/api?type=a&extra=1'));
58
+ expect(res.status).toBe(200);
59
+ expect(await res.json()).toEqual({ type: 'a' });
60
+ });
61
+ });