diff --git a/cli/package.json b/cli/package.json index 90f1f31eb1..a582f6e010 100644 --- a/cli/package.json +++ b/cli/package.json @@ -49,12 +49,12 @@ "cli-table3": "catalog:", "eventsource-parser": "catalog:", "js-yaml": "catalog:", - "ky": "catalog:", "lockfile": "catalog:", "open": "catalog:", "ora": "catalog:", "picocolors": "catalog:", "std-semver": "catalog:", + "undici": "catalog:", "zod": "catalog:" }, "devDependencies": { diff --git a/cli/src/api/account-sessions.test.ts b/cli/src/api/account-sessions.test.ts new file mode 100644 index 0000000000..6f5796a4b0 --- /dev/null +++ b/cli/src/api/account-sessions.test.ts @@ -0,0 +1,84 @@ +import type { StubServer } from '@test/fixtures/stub-server' +import { testHttpClient } from '@test/fixtures/http-client' +import { jsonResponder, startStubServer } from '@test/fixtures/stub-server' +import { afterEach, describe, expect, it } from 'vitest' +import { isBaseError } from '@/errors/base' +import { AccountSessionsClient } from './account-sessions.js' + +const LIST_BODY = { page: 1, limit: 100, total: 0, has_more: false, data: [] } + +function makeClient(host: string): AccountSessionsClient { + return new AccountSessionsClient(testHttpClient(host, 'dfoa_test')) +} + +describe('AccountSessionsClient.list', () => { + let stub: StubServer + + afterEach(async () => { + await stub?.stop() + }) + + it('GETs account/sessions with no query when paging is unset', async () => { + stub = await startStubServer(cap => jsonResponder(200, LIST_BODY, cap)) + + await makeClient(stub.url).list() + + expect(stub.captured.method).toBe('GET') + // page/limit are undefined → dropped, so no trailing query string at all. + expect(stub.captured.url).toBe('/openapi/v1/account/sessions') + }) + + it('forwards page/limit when supplied', async () => { + stub = await startStubServer(cap => jsonResponder(200, LIST_BODY, cap)) + + await makeClient(stub.url).list({ page: 2, limit: 25 }) + + const q = new URL(stub.captured.url ?? '', 'http://x').searchParams + expect(q.get('page')).toBe('2') + expect(q.get('limit')).toBe('25') + }) +}) + +describe('AccountSessionsClient.revoke', () => { + let stub: StubServer + + afterEach(async () => { + await stub?.stop() + }) + + it('DELETEs the session by id and discards the JSON body without throwing', async () => { + // The server replies 200 + {status:"revoked"}; revoke() returns void but the + // typed client still parses the body — this guards against a regression where + // a non-empty 200 body trips JSON handling. + stub = await startStubServer(cap => jsonResponder(200, { status: 'revoked' }, cap)) + + await expect(makeClient(stub.url).revoke('sess-1')).resolves.toBeUndefined() + expect(stub.captured.method).toBe('DELETE') + expect(stub.captured.url).toBe('/openapi/v1/account/sessions/sess-1') + }) + + it('URL-encodes the session id', async () => { + stub = await startStubServer(cap => jsonResponder(200, { status: 'revoked' }, cap)) + + await makeClient(stub.url).revoke('sess/1 2') + + expect(stub.captured.url).toBe('/openapi/v1/account/sessions/sess%2F1%202') + }) + + it('propagates 404 as a classified BaseError', async () => { + stub = await startStubServer(cap => + jsonResponder(404, { error: { code: 'not_found', message: 'session not found' } }, cap)) + + await expect(makeClient(stub.url).revoke('missing')).rejects.toSatisfy( + err => isBaseError(err) && err.httpStatus === 404, + ) + }) + + it('revokeSelf DELETEs the self subresource', async () => { + stub = await startStubServer(cap => jsonResponder(200, { status: 'revoked' }, cap)) + + await expect(makeClient(stub.url).revokeSelf()).resolves.toBeUndefined() + expect(stub.captured.method).toBe('DELETE') + expect(stub.captured.url).toBe('/openapi/v1/account/sessions/self') + }) +}) diff --git a/cli/src/api/account-sessions.ts b/cli/src/api/account-sessions.ts index 83950c9bde..4055ca1dd2 100644 --- a/cli/src/api/account-sessions.ts +++ b/cli/src/api/account-sessions.ts @@ -1,22 +1,17 @@ import type { SessionListResponse } from '@dify/contracts/api/openapi/types.gen' -import type { KyInstance } from 'ky' +import type { HttpClient } from '@/http/types' export class AccountSessionsClient { - private readonly http: KyInstance + private readonly http: HttpClient - constructor(http: KyInstance) { + constructor(http: HttpClient) { this.http = http } async list(q?: { page?: number, limit?: number }): Promise { - const params = new URLSearchParams() - if (q?.page !== undefined) - params.set('page', String(q.page)) - if (q?.limit !== undefined) - params.set('limit', String(q.limit)) - const hasParams = Array.from(params.keys()).length > 0 - const opts = hasParams ? { searchParams: params } : undefined - return this.http.get('account/sessions', opts).json() + return this.http.get('account/sessions', { + searchParams: { page: q?.page, limit: q?.limit }, + }) } async revoke(sessionId: string): Promise { diff --git a/cli/src/api/account.test.ts b/cli/src/api/account.test.ts new file mode 100644 index 0000000000..9dc114db9f --- /dev/null +++ b/cli/src/api/account.test.ts @@ -0,0 +1,41 @@ +import type { StubServer } from '@test/fixtures/stub-server' +import { testHttpClient } from '@test/fixtures/http-client' +import { jsonResponder, startStubServer } from '@test/fixtures/stub-server' +import { afterEach, describe, expect, it } from 'vitest' +import { isBaseError } from '@/errors/base' +import { AccountClient } from './account.js' + +function makeClient(host: string): AccountClient { + return new AccountClient(testHttpClient(host, 'dfoa_test')) +} + +describe('AccountClient.get', () => { + let stub: StubServer + + afterEach(async () => { + await stub?.stop() + }) + + it('GETs account, sends the bearer, and returns the parsed payload', async () => { + stub = await startStubServer(cap => + jsonResponder(200, { + subject_type: 'account', + account: { id: 'acct-1', email: 'a@e.com', name: 'A' }, + }, cap)) + + const res = await makeClient(stub.url).get() + + expect(stub.captured.method).toBe('GET') + expect(stub.captured.url).toBe('/openapi/v1/account') + expect(stub.captured.headers?.authorization).toBe('Bearer dfoa_test') + expect(res.account?.email).toBe('a@e.com') + }) + + it('maps 401 to a classified BaseError', async () => { + stub = await startStubServer(cap => jsonResponder(401, { error: 'expired' }, cap)) + + await expect(makeClient(stub.url).get()).rejects.toSatisfy( + err => isBaseError(err) && err.httpStatus === 401, + ) + }) +}) diff --git a/cli/src/api/account.ts b/cli/src/api/account.ts index daea500eb4..9e45b7c68c 100644 --- a/cli/src/api/account.ts +++ b/cli/src/api/account.ts @@ -1,14 +1,14 @@ import type { AccountResponse } from '@dify/contracts/api/openapi/types.gen' -import type { KyInstance } from 'ky' +import type { HttpClient } from '@/http/types' export class AccountClient { - private readonly http: KyInstance + private readonly http: HttpClient - constructor(http: KyInstance) { + constructor(http: HttpClient) { this.http = http } async get(): Promise { - return this.http.get('account').json() + return this.http.get('account') } } diff --git a/cli/src/api/app-meta.test.ts b/cli/src/api/app-meta.test.ts index 455acb64f1..ff11741f05 100644 --- a/cli/src/api/app-meta.test.ts +++ b/cli/src/api/app-meta.test.ts @@ -3,14 +3,14 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { startMock } from '@test/fixtures/dify-mock/server' +import { testHttpClient } from '@test/fixtures/http-client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { loadAppInfoCache } from '@/cache/app-info' -import { createClient } from '@/http/client' import { ENV_CACHE_DIR } from '@/store/dir' import { CACHE_APP_INFO, getCache } from '@/store/manager' import { FieldInfo, FieldParameters } from '@/types/app-meta' -import { AppMetaClient } from './app-meta' -import { AppsClient } from './apps' +import { AppMetaClient } from './app-meta.js' +import { AppsClient } from './apps.js' describe('AppMetaClient', () => { let mock: DifyMock @@ -33,7 +33,7 @@ describe('AppMetaClient', () => { it('cache miss → fetch → populate; warm hit skips network', async () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - const apps = new AppsClient(createClient({ host: mock.url, bearer: 'dfoa_test' })) + const apps = new AppsClient(testHttpClient(mock.url, 'dfoa_test')) const spy = vi.spyOn(apps, 'describe') const client = new AppMetaClient({ apps, host: mock.url, cache }) @@ -48,7 +48,7 @@ describe('AppMetaClient', () => { it('slim hit + full request triggers fresh fetch + merges', async () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - const apps = new AppsClient(createClient({ host: mock.url, bearer: 'dfoa_test' })) + const apps = new AppsClient(testHttpClient(mock.url, 'dfoa_test')) const spy = vi.spyOn(apps, 'describe') const client = new AppMetaClient({ apps, host: mock.url, cache }) @@ -62,7 +62,7 @@ describe('AppMetaClient', () => { it('expired cache entry refetches', async () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO), ttlMs: 100, now: () => new Date('2026-05-09T00:00:00Z') }) - const apps = new AppsClient(createClient({ host: mock.url, bearer: 'dfoa_test' })) + const apps = new AppsClient(testHttpClient(mock.url, 'dfoa_test')) const spy = vi.spyOn(apps, 'describe') const client = new AppMetaClient({ apps, host: mock.url, cache, now: () => new Date('2026-05-09T00:00:00Z') }) @@ -76,7 +76,7 @@ describe('AppMetaClient', () => { it('invalidate forces next get to fetch', async () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) - const apps = new AppsClient(createClient({ host: mock.url, bearer: 'dfoa_test' })) + const apps = new AppsClient(testHttpClient(mock.url, 'dfoa_test')) const spy = vi.spyOn(apps, 'describe') const client = new AppMetaClient({ apps, host: mock.url, cache }) @@ -89,7 +89,7 @@ describe('AppMetaClient', () => { }) it('no cache: each call hits network', async () => { - const apps = new AppsClient(createClient({ host: mock.url, bearer: 'dfoa_test' })) + const apps = new AppsClient(testHttpClient(mock.url, 'dfoa_test')) const spy = vi.spyOn(apps, 'describe') const client = new AppMetaClient({ apps, host: mock.url }) diff --git a/cli/src/api/app-run.test.ts b/cli/src/api/app-run.test.ts index 8100ebc256..4d502224c1 100644 --- a/cli/src/api/app-run.test.ts +++ b/cli/src/api/app-run.test.ts @@ -1,8 +1,8 @@ import type { DifyMock } from '@test/fixtures/dify-mock/server' import { startMock } from '@test/fixtures/dify-mock/server' +import { testHttpClient } from '@test/fixtures/http-client' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { createClient } from '@/http/client' -import { AppRunClient, buildRunBody } from './app-run' +import { AppRunClient, buildRunBody } from './app-run.js' describe('buildRunBody', () => { it('does not include response_mode', () => { @@ -58,7 +58,7 @@ describe('AppRunClient.runStream', () => { }) it('yields events for chat app', async () => { - const c = new AppRunClient(createClient({ host: mock.url, bearer: 'dfoa_test' })) + const c = new AppRunClient(testHttpClient(mock.url, 'dfoa_test')) const iter = await c.runStream('app-1', buildRunBody({ message: 'hi' })) const dec = new TextDecoder() const names: string[] = [] @@ -74,7 +74,7 @@ describe('AppRunClient.runStream', () => { it('throws typed BaseError on non-2xx open', async () => { mock.setScenario('server-5xx') - const c = new AppRunClient(createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 })) + const c = new AppRunClient(testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 })) await expect( c.runStream('app-1', buildRunBody({ message: 'hi' })), ).rejects.toMatchObject({ code: 'server_5xx' }) @@ -82,7 +82,7 @@ describe('AppRunClient.runStream', () => { it('aborts when signal fires', async () => { expect.assertions(1) - const c = new AppRunClient(createClient({ host: mock.url, bearer: 'dfoa_test' })) + const c = new AppRunClient(testHttpClient(mock.url, 'dfoa_test')) const ctrl = new AbortController() const iter = await c.runStream('app-1', buildRunBody({ message: 'hi' }), { signal: ctrl.signal }) ctrl.abort() @@ -95,7 +95,7 @@ describe('AppRunClient.runStream', () => { }) it('derives event name from JSON event field when SSE event line absent', async () => { - const c = new AppRunClient(createClient({ host: mock.url, bearer: 'dfoa_test' })) + const c = new AppRunClient(testHttpClient(mock.url, 'dfoa_test')) const iter = await c.runStream('app-2', buildRunBody({ inputs: { x: '1' } })) const names: string[] = [] for await (const ev of iter) @@ -114,7 +114,7 @@ describe('AppRunClient.stopTask', () => { }) it('resolves without error for known app and task', async () => { - const c = new AppRunClient(createClient({ host: mock.url, bearer: 'dfoa_test' })) + const c = new AppRunClient(testHttpClient(mock.url, 'dfoa_test')) await expect(c.stopTask('app-1', 'task-42')).resolves.toBeUndefined() }) }) @@ -129,7 +129,7 @@ describe('AppRunClient.submitHumanInput', () => { }) it('resolves without error', async () => { - const c = new AppRunClient(createClient({ host: mock.url, bearer: 'dfoa_test' })) + const c = new AppRunClient(testHttpClient(mock.url, 'dfoa_test')) await expect( c.submitHumanInput('app-1', 'tok-abc', 'approve', { comment: 'looks good' }), ).resolves.toBeUndefined() diff --git a/cli/src/api/app-run.ts b/cli/src/api/app-run.ts index cf75ade9f8..5daa948b6b 100644 --- a/cli/src/api/app-run.ts +++ b/cli/src/api/app-run.ts @@ -1,5 +1,5 @@ -import type { KyInstance } from 'ky' import type { SseEvent } from '@/http/sse' +import type { HttpClient } from '@/http/types' import { parseSSE } from '@/http/sse' import { normalizeDifyStream } from '@/http/sse-dify' @@ -35,9 +35,9 @@ export type StreamOptions = { } export class AppRunClient { - private readonly http: KyInstance + private readonly http: HttpClient - constructor(http: KyInstance) { + constructor(http: HttpClient) { this.http = http } @@ -46,12 +46,12 @@ export class AppRunClient { body: Record, opts: StreamOptions = {}, ): Promise> { - const res = await this.http.post(`apps/${encodeURIComponent(appId)}/run`, { + const res = await this.http.stream(`apps/${encodeURIComponent(appId)}/run`, { + method: 'POST', json: body, headers: { Accept: 'text/event-stream' }, - retry: { limit: 0 }, - timeout: false, signal: opts.signal, + throwOnError: true, }) if (res.body === null) throw new Error('streaming response body missing') @@ -61,7 +61,7 @@ export class AppRunClient { async stopTask(appId: string, taskId: string): Promise { await this.http.post(`apps/${encodeURIComponent(appId)}/tasks/${encodeURIComponent(taskId)}/stop`, { json: {}, - timeout: 30_000, + timeoutMs: 30_000, }) } @@ -73,7 +73,7 @@ export class AppRunClient { ): Promise { await this.http.post( `apps/${encodeURIComponent(appId)}/form/human_input/${encodeURIComponent(formToken)}`, - { json: { action, inputs }, timeout: 30_000 }, + { json: { action, inputs }, timeoutMs: 30_000 }, ) } @@ -83,15 +83,14 @@ export class AppRunClient { opts: StreamOptions = {}, ): Promise> { const url = `apps/${encodeURIComponent(appId)}/tasks/${encodeURIComponent(workflowRunId)}/events` - const res = await this.http.get(url, { + const res = await this.http.stream(url, { searchParams: { include_state_snapshot: opts.includeStateSnapshot === true ? 'true' : 'false', continue_on_pause: 'false', }, headers: { Accept: 'text/event-stream' }, - retry: { limit: 0 }, - timeout: false, signal: opts.signal, + throwOnError: true, }) if (res.body === null) throw new Error('reconnect stream body missing') diff --git a/cli/src/api/apps.test.ts b/cli/src/api/apps.test.ts new file mode 100644 index 0000000000..26e5e3ce26 --- /dev/null +++ b/cli/src/api/apps.test.ts @@ -0,0 +1,116 @@ +import type { StubServer } from '@test/fixtures/stub-server' +import { testHttpClient } from '@test/fixtures/http-client' +import { jsonResponder, startStubServer } from '@test/fixtures/stub-server' +import { afterEach, describe, expect, it } from 'vitest' +import { isBaseError } from '@/errors/base' +import { AppsClient } from './apps.js' + +const LIST_BODY = { page: 1, limit: 20, total: 0, has_more: false, data: [] } +const DESCRIBE_BODY = { info: { id: 'app-1', name: 'Demo', mode: 'chat', service_api_enabled: true } } + +function makeClient(host: string): AppsClient { + return new AppsClient(testHttpClient(host, 'dfoa_test')) +} + +function queryOf(url: string | undefined): URLSearchParams { + return new URL(url ?? '', 'http://x').searchParams +} + +describe('AppsClient.list', () => { + let stub: StubServer + + afterEach(async () => { + await stub?.stop() + }) + + it('defaults page=1 & limit=20 and always sends workspace_id', async () => { + stub = await startStubServer(cap => jsonResponder(200, LIST_BODY, cap)) + + await makeClient(stub.url).list({ workspaceId: 'ws-1' }) + + const q = queryOf(stub.captured.url) + expect(stub.captured.method).toBe('GET') + expect(q.get('workspace_id')).toBe('ws-1') + expect(q.get('page')).toBe('1') + expect(q.get('limit')).toBe('20') + // Optional filters are omitted entirely when not supplied. + expect(q.has('mode')).toBe(false) + expect(q.has('name')).toBe(false) + expect(q.has('tag')).toBe(false) + }) + + it('forwards explicit pagination and filters', async () => { + stub = await startStubServer(cap => jsonResponder(200, LIST_BODY, cap)) + + await makeClient(stub.url).list({ + workspaceId: 'ws-1', + page: 3, + limit: 50, + mode: 'chat', + name: 'support bot', + tag: 'prod', + }) + + const q = queryOf(stub.captured.url) + expect(q.get('page')).toBe('3') + expect(q.get('limit')).toBe('50') + expect(q.get('mode')).toBe('chat') + expect(q.get('name')).toBe('support bot') + expect(q.get('tag')).toBe('prod') + }) + + it('treats empty-string filters as absent (not blank query params)', async () => { + stub = await startStubServer(cap => jsonResponder(200, LIST_BODY, cap)) + + await makeClient(stub.url).list({ workspaceId: 'ws-1', mode: '', name: '', tag: '' }) + + const q = queryOf(stub.captured.url) + expect(q.has('mode')).toBe(false) + expect(q.has('name')).toBe(false) + expect(q.has('tag')).toBe(false) + }) + + it('propagates server 403 as a classified BaseError', async () => { + stub = await startStubServer(cap => jsonResponder(403, { error: 'forbidden' }, cap)) + + await expect(makeClient(stub.url).list({ workspaceId: 'ws-1' })).rejects.toSatisfy( + err => isBaseError(err) && err.httpStatus === 403, + ) + }) +}) + +describe('AppsClient.describe', () => { + let stub: StubServer + + afterEach(async () => { + await stub?.stop() + }) + + it('hits /apps//describe, sends workspace_id, omits fields when none given', async () => { + stub = await startStubServer(cap => jsonResponder(200, DESCRIBE_BODY, cap)) + + const res = await makeClient(stub.url).describe('app-1', 'ws-1') + + expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app-1/describe') + const q = queryOf(stub.captured.url) + expect(q.get('workspace_id')).toBe('ws-1') + expect(q.has('fields')).toBe(false) + expect(res.info?.id).toBe('app-1') + }) + + it('joins fields with commas', async () => { + stub = await startStubServer(cap => jsonResponder(200, DESCRIBE_BODY, cap)) + + await makeClient(stub.url).describe('app-1', 'ws-1', ['parameters', 'input_schema']) + + expect(queryOf(stub.captured.url).get('fields')).toBe('parameters,input_schema') + }) + + it('URL-encodes the app id', async () => { + stub = await startStubServer(cap => jsonResponder(200, DESCRIBE_BODY, cap)) + + await makeClient(stub.url).describe('app/with space', 'ws-1') + + expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app%2Fwith%20space/describe') + }) +}) diff --git a/cli/src/api/apps.ts b/cli/src/api/apps.ts index fee146c244..06da5c72be 100644 --- a/cli/src/api/apps.ts +++ b/cli/src/api/apps.ts @@ -1,5 +1,5 @@ import type { AppDescribeResponse, AppListResponse } from '@dify/contracts/api/openapi/types.gen' -import type { KyInstance } from 'ky' +import type { HttpClient } from '@/http/types' export type ListQuery = { readonly workspaceId: string @@ -11,31 +11,31 @@ export type ListQuery = { } export class AppsClient { - private readonly http: KyInstance + private readonly http: HttpClient - constructor(http: KyInstance) { + constructor(http: HttpClient) { this.http = http } async list(q: ListQuery): Promise { - const params = new URLSearchParams() - params.set('workspace_id', q.workspaceId) - params.set('page', String(q.page ?? 1)) - params.set('limit', String(q.limit ?? 20)) - if (q.mode !== undefined && q.mode !== '') - params.set('mode', q.mode) - if (q.name !== undefined && q.name !== '') - params.set('name', q.name) - if (q.tag !== undefined && q.tag !== '') - params.set('tag', q.tag) - return this.http.get('apps', { searchParams: params }).json() + return this.http.get('apps', { + searchParams: { + workspace_id: q.workspaceId, + page: q.page ?? 1, + limit: q.limit ?? 20, + mode: q.mode !== undefined && q.mode !== '' ? q.mode : undefined, + name: q.name !== undefined && q.name !== '' ? q.name : undefined, + tag: q.tag !== undefined && q.tag !== '' ? q.tag : undefined, + }, + }) } async describe(appId: string, workspaceId: string, fields?: readonly string[]): Promise { - const params = new URLSearchParams() - params.set('workspace_id', workspaceId) - if (fields !== undefined && fields.length > 0) - params.set('fields', fields.join(',')) - return this.http.get(`apps/${encodeURIComponent(appId)}/describe`, { searchParams: params }).json() + return this.http.get(`apps/${encodeURIComponent(appId)}/describe`, { + searchParams: { + workspace_id: workspaceId, + fields: fields !== undefined && fields.length > 0 ? fields.join(',') : undefined, + }, + }) } } diff --git a/cli/src/api/device-flow.test.ts b/cli/src/api/device-flow.test.ts index 3b380cfdc0..e0118f4ae1 100644 --- a/cli/src/api/device-flow.test.ts +++ b/cli/src/api/device-flow.test.ts @@ -1,14 +1,14 @@ import type { DifyMock } from '@test/fixtures/dify-mock/server' import type { AddressInfo } from 'node:net' -import type { CodeResponse } from './oauth-device' +import type { CodeResponse } from './oauth-device.js' import { Buffer } from 'node:buffer' import * as http from 'node:http' import { startMock } from '@test/fixtures/dify-mock/server' +import { testHttpClient } from '@test/fixtures/http-client' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { isBaseError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' -import { createClient } from '@/http/client' -import { DEFAULT_CLIENT_ID, DeviceFlowApi } from './oauth-device' +import { DEFAULT_CLIENT_ID, DeviceFlowApi } from './oauth-device.js' type StubServer = { url: string @@ -38,7 +38,7 @@ function jsonStub(status: number, body: unknown): (req: http.IncomingMessage, re } function makeApi(mock: DifyMock): DeviceFlowApi { - return new DeviceFlowApi(createClient({ host: mock.url })) + return new DeviceFlowApi(testHttpClient(mock.url)) } describe('DeviceFlowApi.requestCode', () => { @@ -61,7 +61,7 @@ describe('DeviceFlowApi.requestCode', () => { }) it('strips trailing slash from host', async () => { - const api = new DeviceFlowApi(createClient({ host: `${mock.url}/` })) + const api = new DeviceFlowApi(testHttpClient(`${mock.url}/`)) const out = await api.requestCode({ device_label: 'l' }) expect(out.device_code).toBeDefined() }) @@ -70,7 +70,7 @@ describe('DeviceFlowApi.requestCode', () => { let stub: StubServer | undefined try { stub = await startStub(jsonStub(404, {})) - const api = new DeviceFlowApi(createClient({ host: stub.url })) + const api = new DeviceFlowApi(testHttpClient(stub.url)) let caught: unknown try { await api.requestCode({ device_label: 'l' }) @@ -116,7 +116,7 @@ describe('DeviceFlowApi.pollOnce', () => { let stub: StubServer | undefined try { stub = await startStub(jsonStub(400, { error: 'authorization_pending' })) - const api = new DeviceFlowApi(createClient({ host: stub.url })) + const api = new DeviceFlowApi(testHttpClient(stub.url)) const r = await api.pollOnce({ device_code: 'dc' }) expect(r.status).toBe('pending') } @@ -150,7 +150,7 @@ describe('DeviceFlowApi.pollOnce', () => { let stub: StubServer | undefined try { stub = await startStub(jsonStub(404, {})) - const api = new DeviceFlowApi(createClient({ host: stub.url })) + const api = new DeviceFlowApi(testHttpClient(stub.url)) await expect(api.pollOnce({ device_code: 'dc' })).rejects.toThrow(/device flow/i) } finally { @@ -169,7 +169,7 @@ describe('DeviceFlowApi.pollOnce', () => { let stub: StubServer | undefined try { stub = await startStub(jsonStub(200, {})) - const api = new DeviceFlowApi(createClient({ host: stub.url })) + const api = new DeviceFlowApi(testHttpClient(stub.url)) await expect(api.pollOnce({ device_code: 'dc' })).rejects.toThrow(/no OAuth envelope|token/i) } finally { @@ -181,7 +181,7 @@ describe('DeviceFlowApi.pollOnce', () => { let stub: StubServer | undefined try { stub = await startStub(jsonStub(400, { error: 'something_else' })) - const api = new DeviceFlowApi(createClient({ host: stub.url })) + const api = new DeviceFlowApi(testHttpClient(stub.url)) await expect(api.pollOnce({ device_code: 'dc' })).rejects.toThrow(/unknown poll error/) } finally { diff --git a/cli/src/api/file-upload.test.ts b/cli/src/api/file-upload.test.ts new file mode 100644 index 0000000000..ef19b78d9f --- /dev/null +++ b/cli/src/api/file-upload.test.ts @@ -0,0 +1,76 @@ +import type { StubServer } from '@test/fixtures/stub-server' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { testHttpClient } from '@test/fixtures/http-client' +import { jsonResponder, startStubServer } from '@test/fixtures/stub-server' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { isBaseError } from '@/errors/base' +import { FileUploadClient } from './file-upload.js' + +const UPLOADED = { + id: 'file-1', + name: 'hello.png', + size: 5, + extension: 'png', + mime_type: 'image/png', +} + +function makeClient(host: string): FileUploadClient { + return new FileUploadClient(testHttpClient(host, 'dfoa_test')) +} + +describe('FileUploadClient.upload', () => { + let stub: StubServer + let dir: string + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'difyctl-upload-')) + }) + + afterEach(async () => { + await stub?.stop() + await rm(dir, { recursive: true, force: true }) + }) + + it('POSTs multipart/form-data (boundary intact, no JSON content-type) and returns the parsed file', async () => { + const filePath = join(dir, 'hello.png') + await writeFile(filePath, 'hello') + stub = await startStubServer(cap => jsonResponder(200, UPLOADED, cap)) + + const result = await makeClient(stub.url).upload('app-1', filePath) + + expect(stub.captured.method).toBe('POST') + expect(stub.captured.url).toBe('/openapi/v1/apps/app-1/files/upload') + // The client must let fetch own the multipart Content-Type + boundary; it + // must NOT coerce this to application/json the way a json body would. + const contentType = stub.captured.headers?.['content-type'] ?? '' + expect(contentType).toMatch(/^multipart\/form-data; boundary=/) + // Bearer still applied on the upload path. + expect(stub.captured.headers?.authorization).toBe('Bearer dfoa_test') + // The multipart payload carries the filename and the file bytes. + expect(stub.captured.body).toContain('filename="hello.png"') + expect(stub.captured.body).toContain('hello') + expect(result.id).toBe('file-1') + }) + + it('encodes the app id in the path', async () => { + const filePath = join(dir, 'a.txt') + await writeFile(filePath, 'x') + stub = await startStubServer(cap => jsonResponder(200, UPLOADED, cap)) + + await makeClient(stub.url).upload('app/with space', filePath) + + expect(stub.captured.url).toBe('/openapi/v1/apps/app%2Fwith%20space/files/upload') + }) + + it('propagates a server 413 as a classified BaseError', async () => { + const filePath = join(dir, 'big.bin') + await writeFile(filePath, 'data') + stub = await startStubServer(cap => jsonResponder(413, { error: 'file too large' }, cap)) + + await expect(makeClient(stub.url).upload('app-1', filePath)).rejects.toSatisfy( + err => isBaseError(err) && err.httpStatus === 413, + ) + }) +}) diff --git a/cli/src/api/file-upload.ts b/cli/src/api/file-upload.ts index bf1cdb1201..7a032737a5 100644 --- a/cli/src/api/file-upload.ts +++ b/cli/src/api/file-upload.ts @@ -1,4 +1,4 @@ -import type { KyInstance } from 'ky' +import type { HttpClient } from '@/http/types' import { readFile } from 'node:fs/promises' import { basename, extname } from 'node:path' @@ -51,9 +51,9 @@ function mimeFromFilename(filename: string): string { } export class FileUploadClient { - private readonly http: KyInstance + private readonly http: HttpClient - constructor(http: KyInstance) { + constructor(http: HttpClient) { this.http = http } @@ -64,9 +64,9 @@ export class FileUploadClient { const form = new FormData() form.append('file', blob, filename) - return this.http.post( + return this.http.post( `apps/${encodeURIComponent(appId)}/files/upload`, - { body: form, timeout: 60_000 }, - ).json() + { body: form, timeoutMs: 60_000 }, + ) } } diff --git a/cli/src/api/members.test.ts b/cli/src/api/members.test.ts index 4dafa1ce3c..1d129bb44d 100644 --- a/cli/src/api/members.test.ts +++ b/cli/src/api/members.test.ts @@ -1,58 +1,13 @@ -import type { AddressInfo } from 'node:net' -import { Buffer } from 'node:buffer' -import * as http from 'node:http' +import type { StubServer } from '@test/fixtures/stub-server' +import { testHttpClient } from '@test/fixtures/http-client' +import { jsonResponder, startStubServer } from '@test/fixtures/stub-server' import { afterEach, describe, expect, it } from 'vitest' import { isBaseError } from '@/errors/base' -import { createClient } from '@/http/client' -import { MembersClient } from './members' - -type StubServer = { - url: string - lastRequest: { method?: string, url?: string, body?: string } - stop: () => Promise -} - -function jsonResponder( - status: number, - body: unknown, - captured: StubServer['lastRequest'], -): http.RequestListener { - return (req, res) => { - captured.method = req.method - captured.url = req.url - const chunks: Buffer[] = [] - req.on('data', c => chunks.push(c)) - req.on('end', () => { - captured.body = Buffer.concat(chunks).toString('utf8') - const payload = JSON.stringify(body) - res.writeHead(status, { - 'content-type': 'application/json', - 'content-length': Buffer.byteLength(payload), - }) - res.end(payload) - }) - } -} - -function startServer(handler: http.RequestListener): Promise { - const captured: StubServer['lastRequest'] = {} - return new Promise((resolve, reject) => { - const server = http.createServer((req, res) => handler(req, res)) - server.listen(0, '127.0.0.1', () => { - const addr = server.address() as AddressInfo - resolve({ - url: `http://127.0.0.1:${addr.port}`, - lastRequest: captured, - stop: () => - new Promise((res, rej) => server.close(err => (err ? rej(err) : res()))), - }) - }) - server.on('error', reject) - }) -} +import { MembersClient } from './members.js' +import { WorkspacesClient } from './workspaces.js' function makeClient(host: string): MembersClient { - return new MembersClient(createClient({ host, bearer: 'dfoa_test' })) + return new MembersClient(testHttpClient(host, 'dfoa_test')) } describe('MembersClient.list', () => { @@ -63,8 +18,7 @@ describe('MembersClient.list', () => { }) it('GETs /workspaces//members and returns parsed envelope', async () => { - const captured: StubServer['lastRequest'] = {} - stub = await startServer( + stub = await startStubServer(cap => jsonResponder( 200, { @@ -76,42 +30,36 @@ describe('MembersClient.list', () => { { id: 'm-1', name: 'Mia', email: 'mia@e.com', role: 'admin', status: 'active' }, ], }, - captured, - ), - ) - stub.lastRequest = captured + cap, + )) const result = await makeClient(stub.url).list('ws-1') - expect(captured.method).toBe('GET') - expect(captured.url).toBe('/openapi/v1/workspaces/ws-1/members') + + expect(stub.captured.method).toBe('GET') + expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/members') expect(result.data[0]?.email).toBe('mia@e.com') }) it('URL-encodes workspace id', async () => { - const captured: StubServer['lastRequest'] = {} - stub = await startServer( - jsonResponder(200, { page: 1, limit: 20, total: 0, has_more: false, data: [] }, captured), - ) - stub.lastRequest = captured + stub = await startStubServer(cap => + jsonResponder(200, { page: 1, limit: 20, total: 0, has_more: false, data: [] }, cap)) await makeClient(stub.url).list('ws with space') - expect(captured.url).toBe('/openapi/v1/workspaces/ws%20with%20space/members') + + expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws%20with%20space/members') }) it('forwards page/limit as query params', async () => { - const captured: StubServer['lastRequest'] = {} - stub = await startServer( - jsonResponder(200, { page: 2, limit: 50, total: 0, has_more: false, data: [] }, captured), - ) - stub.lastRequest = captured + stub = await startStubServer(cap => + jsonResponder(200, { page: 2, limit: 50, total: 0, has_more: false, data: [] }, cap)) await makeClient(stub.url).list('ws-1', { page: 2, limit: 50 }) - expect(captured.url).toBe('/openapi/v1/workspaces/ws-1/members?page=2&limit=50') + + expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/members?page=2&limit=50') }) - it('propagates server 403 as HTTPError', async () => { - const captured: StubServer['lastRequest'] = {} - stub = await startServer(jsonResponder(403, { error: 'forbidden' }, captured)) + it('propagates server 403 as classified BaseError', async () => { + stub = await startStubServer(cap => jsonResponder(403, { error: 'forbidden' }, cap)) await expect(makeClient(stub.url).list('ws-1')).rejects.toSatisfy( err => isBaseError(err) && err.httpStatus === 403, @@ -119,8 +67,7 @@ describe('MembersClient.list', () => { }) it('propagates 404 as classified BaseError', async () => { - const captured: StubServer['lastRequest'] = {} - stub = await startServer(jsonResponder(404, { error: 'not found' }, captured)) + stub = await startStubServer(cap => jsonResponder(404, { error: 'not found' }, cap)) await expect(makeClient(stub.url).list('ws-missing')).rejects.toSatisfy( err => isBaseError(err) && err.httpStatus === 404, @@ -136,8 +83,7 @@ describe('MembersClient.invite', () => { }) it('POSTs JSON body and returns parsed invite response', async () => { - const captured: StubServer['lastRequest'] = {} - stub = await startServer( + stub = await startStubServer(cap => jsonResponder( 201, { @@ -148,18 +94,17 @@ describe('MembersClient.invite', () => { invite_url: 'https://console.example.com/activate?email=new&token=tok', tenant_id: 'ws-1', }, - captured, - ), - ) - stub.lastRequest = captured + cap, + )) const result = await makeClient(stub.url).invite('ws-1', { email: 'new@e.com', role: 'normal', }) - expect(captured.method).toBe('POST') - expect(captured.url).toBe('/openapi/v1/workspaces/ws-1/members') - expect(JSON.parse(captured.body ?? '{}')).toEqual({ + + expect(stub.captured.method).toBe('POST') + expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/members') + expect(JSON.parse(stub.captured.body ?? '{}')).toEqual({ email: 'new@e.com', role: 'normal', }) @@ -167,9 +112,8 @@ describe('MembersClient.invite', () => { expect(result.invite_url).toContain('token=tok') }) - it('propagates 400 (already in tenant) as HTTPError', async () => { - const captured: StubServer['lastRequest'] = {} - stub = await startServer(jsonResponder(400, { error: 'already in tenant' }, captured)) + it('propagates 400 (already in tenant) as classified BaseError', async () => { + stub = await startStubServer(cap => jsonResponder(400, { error: 'already in tenant' }, cap)) await expect( makeClient(stub.url).invite('ws-1', { email: 'u@e.com', role: 'normal' }), @@ -185,19 +129,17 @@ describe('MembersClient.remove', () => { }) it('DELETEs member by id and returns success', async () => { - const captured: StubServer['lastRequest'] = {} - stub = await startServer(jsonResponder(200, { result: 'success' }, captured)) - stub.lastRequest = captured + stub = await startStubServer(cap => jsonResponder(200, { result: 'success' }, cap)) const result = await makeClient(stub.url).remove('ws-1', 'm-1') - expect(captured.method).toBe('DELETE') - expect(captured.url).toBe('/openapi/v1/workspaces/ws-1/members/m-1') + + expect(stub.captured.method).toBe('DELETE') + expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/members/m-1') expect(result.result).toBe('success') }) it('propagates 400 (cannot operate self / cannot remove owner)', async () => { - const captured: StubServer['lastRequest'] = {} - stub = await startServer(jsonResponder(400, { error: 'cannot operate self' }, captured)) + stub = await startStubServer(cap => jsonResponder(400, { error: 'cannot operate self' }, cap)) await expect(makeClient(stub.url).remove('ws-1', 'm-1')).rejects.toSatisfy( err => isBaseError(err) && err.httpStatus === 400, @@ -213,20 +155,18 @@ describe('MembersClient.updateRole', () => { }) it('PUTs role payload to /role subresource', async () => { - const captured: StubServer['lastRequest'] = {} - stub = await startServer(jsonResponder(200, { result: 'success' }, captured)) - stub.lastRequest = captured + stub = await startStubServer(cap => jsonResponder(200, { result: 'success' }, cap)) const result = await makeClient(stub.url).updateRole('ws-1', 'm-1', { role: 'admin' }) - expect(captured.method).toBe('PUT') - expect(captured.url).toBe('/openapi/v1/workspaces/ws-1/members/m-1/role') - expect(JSON.parse(captured.body ?? '{}')).toEqual({ role: 'admin' }) + + expect(stub.captured.method).toBe('PUT') + expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/members/m-1/role') + expect(JSON.parse(stub.captured.body ?? '{}')).toEqual({ role: 'admin' }) expect(result.result).toBe('success') }) it('propagates 400 (admin cannot demote owner)', async () => { - const captured: StubServer['lastRequest'] = {} - stub = await startServer(jsonResponder(400, { error: 'no permission' }, captured)) + stub = await startStubServer(cap => jsonResponder(400, { error: 'no permission' }, cap)) await expect( makeClient(stub.url).updateRole('ws-1', 'm-1', { role: 'admin' }), @@ -242,8 +182,7 @@ describe('WorkspacesClient.switch (integration with stub)', () => { }) it('POSTs /workspaces//switch and returns workspace detail', async () => { - const captured: StubServer['lastRequest'] = {} - stub = await startServer( + stub = await startStubServer(cap => jsonResponder( 200, { @@ -254,25 +193,21 @@ describe('WorkspacesClient.switch (integration with stub)', () => { current: true, created_at: '2026-05-18T00:00:00Z', }, - captured, - ), - ) - stub.lastRequest = captured + cap, + )) - const { WorkspacesClient } = await import('./workspaces') - const client = new WorkspacesClient(createClient({ host: stub.url, bearer: 'dfoa_test' })) + const client = new WorkspacesClient(testHttpClient(stub.url, 'dfoa_test')) const result = await client.switch('ws-1') - expect(captured.method).toBe('POST') - expect(captured.url).toBe('/openapi/v1/workspaces/ws-1/switch') + + expect(stub.captured.method).toBe('POST') + expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/switch') expect(result.current).toBe(true) }) it('propagates 404 (non-member)', async () => { - const captured: StubServer['lastRequest'] = {} - stub = await startServer(jsonResponder(404, { error: 'not found' }, captured)) + stub = await startStubServer(cap => jsonResponder(404, { error: 'not found' }, cap)) - const { WorkspacesClient } = await import('./workspaces') - const client = new WorkspacesClient(createClient({ host: stub.url, bearer: 'dfoa_test' })) + const client = new WorkspacesClient(testHttpClient(stub.url, 'dfoa_test')) await expect(client.switch('ws-x')).rejects.toSatisfy( err => isBaseError(err) && err.httpStatus === 404, ) diff --git a/cli/src/api/members.ts b/cli/src/api/members.ts index 1152ae1474..1ca97db618 100644 --- a/cli/src/api/members.ts +++ b/cli/src/api/members.ts @@ -5,45 +5,40 @@ import type { MemberListResponse, MemberRoleUpdatePayload, } from '@dify/contracts/api/openapi/types.gen' -import type { KyInstance } from 'ky' +import type { HttpClient } from '@/http/types' /** * Thin client for /openapi/v1/workspaces//members. * - * Errors are surfaced as ky HTTPErrors with the server's status code + * Errors are surfaced as BaseError via classifyResponse on non-2xx * (400/403/404/422). The CLI's AuthedCommand base layer maps those to * user-visible messages — clients never swallow status codes here. */ export class MembersClient { - private readonly http: KyInstance + private readonly http: HttpClient - constructor(http: KyInstance) { + constructor(http: HttpClient) { this.http = http } async list(workspaceId: string, q?: { page?: number, limit?: number }): Promise { - const params = new URLSearchParams() - if (q?.page !== undefined) - params.set('page', String(q.page)) - if (q?.limit !== undefined) - params.set('limit', String(q.limit)) - const hasParams = Array.from(params.keys()).length > 0 - const opts = hasParams ? { searchParams: params } : undefined - return this.http - .get(`workspaces/${encodeURIComponent(workspaceId)}/members`, opts) - .json() + return this.http.get( + `workspaces/${encodeURIComponent(workspaceId)}/members`, + { searchParams: { page: q?.page, limit: q?.limit } }, + ) } async invite(workspaceId: string, payload: MemberInvitePayload): Promise { - return this.http - .post(`workspaces/${encodeURIComponent(workspaceId)}/members`, { json: payload }) - .json() + return this.http.post( + `workspaces/${encodeURIComponent(workspaceId)}/members`, + { json: payload }, + ) } async remove(workspaceId: string, memberId: string): Promise { - return this.http - .delete(`workspaces/${encodeURIComponent(workspaceId)}/members/${encodeURIComponent(memberId)}`) - .json() + return this.http.delete( + `workspaces/${encodeURIComponent(workspaceId)}/members/${encodeURIComponent(memberId)}`, + ) } async updateRole( @@ -51,11 +46,9 @@ export class MembersClient { memberId: string, payload: MemberRoleUpdatePayload, ): Promise { - return this.http - .put( - `workspaces/${encodeURIComponent(workspaceId)}/members/${encodeURIComponent(memberId)}/role`, - { json: payload }, - ) - .json() + return this.http.put( + `workspaces/${encodeURIComponent(workspaceId)}/members/${encodeURIComponent(memberId)}/role`, + { json: payload }, + ) } } diff --git a/cli/src/api/meta.test.ts b/cli/src/api/meta.test.ts index 1b7fee0a79..c3e081a39f 100644 --- a/cli/src/api/meta.test.ts +++ b/cli/src/api/meta.test.ts @@ -1,8 +1,8 @@ import type { DifyMock } from '@test/fixtures/dify-mock/server' import { startMock } from '@test/fixtures/dify-mock/server' +import { testHttpClient } from '@test/fixtures/http-client' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { createClient } from '@/http/client' -import { MetaClient } from './meta' +import { MetaClient } from './meta.js' describe('MetaClient', () => { let mock: DifyMock @@ -15,7 +15,7 @@ describe('MetaClient', () => { }) it('fetches /openapi/v1/_version without a bearer token', async () => { - const client = new MetaClient(createClient({ host: mock.url })) + const client = new MetaClient(testHttpClient(mock.url)) const info = await client.serverVersion() expect(info.version).toBe('1.6.4') @@ -24,7 +24,7 @@ describe('MetaClient', () => { it('honors the auth-expired scenario by allowing the unauthed endpoint anyway', async () => { mock.setScenario('auth-expired') - const client = new MetaClient(createClient({ host: mock.url })) + const client = new MetaClient(testHttpClient(mock.url)) const info = await client.serverVersion() // The meta endpoint is exempt from auth middleware, so an auth-expired @@ -34,7 +34,7 @@ describe('MetaClient', () => { it('returns an empty version string when the server scenario forces it', async () => { mock.setScenario('server-version-empty') - const client = new MetaClient(createClient({ host: mock.url })) + const client = new MetaClient(testHttpClient(mock.url)) const info = await client.serverVersion() expect(info.version).toBe('') @@ -43,7 +43,7 @@ describe('MetaClient', () => { it('throws when the host has no Dify on it', async () => { // Closed port — connection refused. - const client = new MetaClient(createClient({ host: 'http://127.0.0.1:1' })) + const client = new MetaClient(testHttpClient('http://127.0.0.1:1')) await expect(client.serverVersion()).rejects.toBeDefined() }) }) diff --git a/cli/src/api/meta.ts b/cli/src/api/meta.ts index 1ddfdc4461..ed651f63df 100644 --- a/cli/src/api/meta.ts +++ b/cli/src/api/meta.ts @@ -1,19 +1,19 @@ import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen' -import type { KyInstance } from 'ky' +import type { HttpClient } from '@/http/types' // Used by every /_version probe call site (the version command and the -// per-command auto-nudge). Both must construct their ky client with this -// timeout + retry=0, otherwise the default 30s/3-retry budget kicks in. +// per-command auto-nudge). Both must construct their HTTP client with this +// timeout + retryAttempts: 0, otherwise the default 30s/3-retry budget kicks in. export const META_PROBE_TIMEOUT_MS = 2000 export class MetaClient { - private readonly http: KyInstance + private readonly http: HttpClient - constructor(http: KyInstance) { + constructor(http: HttpClient) { this.http = http } async serverVersion(): Promise { - return this.http.get('_version').json() + return this.http.get('_version') } } diff --git a/cli/src/api/oauth-device.ts b/cli/src/api/oauth-device.ts index dea88c82b6..b3d8bfbad6 100644 --- a/cli/src/api/oauth-device.ts +++ b/cli/src/api/oauth-device.ts @@ -1,4 +1,4 @@ -import type { KyInstance } from 'ky' +import type { HttpClient } from '@/http/types' import { BaseError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' @@ -62,9 +62,9 @@ const POLL_ERROR_TO_STATUS: Record = { } export class DeviceFlowApi { - private readonly http: KyInstance + private readonly http: HttpClient - constructor(http: KyInstance) { + constructor(http: HttpClient) { this.http = http } @@ -76,7 +76,7 @@ export class DeviceFlowApi { }) } const body = { client_id: req.client_id ?? DEFAULT_CLIENT_ID, device_label: req.device_label } - const res = await this.http.post('oauth/device/code', { json: body, throwHttpErrors: false, context: { skipClassify: true } }) + const res = await this.http.fetch('oauth/device/code', { method: 'POST', json: body }) if (res.status === 404) throw versionSkew() if (!res.ok) { @@ -97,7 +97,7 @@ export class DeviceFlowApi { }) } const body = { client_id: req.client_id ?? DEFAULT_CLIENT_ID, device_code: req.device_code } - const res = await this.http.post('oauth/device/token', { json: body, throwHttpErrors: false, context: { skipClassify: true } }) + const res = await this.http.fetch('oauth/device/token', { method: 'POST', json: body }) if (res.status === 404) throw versionSkew() if (res.status >= 500) diff --git a/cli/src/api/workspaces.test.ts b/cli/src/api/workspaces.test.ts new file mode 100644 index 0000000000..eefb9ccf08 --- /dev/null +++ b/cli/src/api/workspaces.test.ts @@ -0,0 +1,43 @@ +import type { StubServer } from '@test/fixtures/stub-server' +import { testHttpClient } from '@test/fixtures/http-client' +import { jsonResponder, startStubServer } from '@test/fixtures/stub-server' +import { afterEach, describe, expect, it } from 'vitest' +import { isBaseError } from '@/errors/base' +import { WorkspacesClient } from './workspaces.js' + +// WorkspacesClient.switch is covered in members.test.ts; this file covers list(). + +function makeClient(host: string): WorkspacesClient { + return new WorkspacesClient(testHttpClient(host, 'dfoa_test')) +} + +describe('WorkspacesClient.list', () => { + let stub: StubServer + + afterEach(async () => { + await stub?.stop() + }) + + it('GETs /workspaces and returns the parsed list', async () => { + stub = await startStubServer(cap => + jsonResponder(200, { + workspaces: [ + { id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: true }, + ], + }, cap)) + + const res = await makeClient(stub.url).list() + + expect(stub.captured.method).toBe('GET') + expect(stub.captured.url).toBe('/openapi/v1/workspaces') + expect(res.workspaces[0].id).toBe('ws-1') + }) + + it('maps 401 to a classified BaseError', async () => { + stub = await startStubServer(cap => jsonResponder(401, { error: 'expired' }, cap)) + + await expect(makeClient(stub.url).list()).rejects.toSatisfy( + err => isBaseError(err) && err.httpStatus === 401, + ) + }) +}) diff --git a/cli/src/api/workspaces.ts b/cli/src/api/workspaces.ts index f497ae25db..7325f8e2cb 100644 --- a/cli/src/api/workspaces.ts +++ b/cli/src/api/workspaces.ts @@ -1,15 +1,15 @@ import type { WorkspaceDetailResponse, WorkspaceListResponse } from '@dify/contracts/api/openapi/types.gen' -import type { KyInstance } from 'ky' +import type { HttpClient } from '@/http/types' export class WorkspacesClient { - private readonly http: KyInstance + private readonly http: HttpClient - constructor(http: KyInstance) { + constructor(http: HttpClient) { this.http = http } async list(): Promise { - return this.http.get('workspaces').json() + return this.http.get('workspaces') } /** @@ -22,8 +22,6 @@ export class WorkspacesClient { * server's state. */ async switch(workspaceId: string): Promise { - return this.http - .post(`workspaces/${encodeURIComponent(workspaceId)}/switch`) - .json() + return this.http.post(`workspaces/${encodeURIComponent(workspaceId)}/switch`) } } diff --git a/cli/src/commands/_shared/authed-command.ts b/cli/src/commands/_shared/authed-command.ts index 29710eacf5..1d3db5afc7 100644 --- a/cli/src/commands/_shared/authed-command.ts +++ b/cli/src/commands/_shared/authed-command.ts @@ -1,7 +1,7 @@ -import type { KyInstance } from 'ky' import type { HostsBundle } from '@/auth/hosts' import type { AppInfoCache } from '@/cache/app-info' import type { Command } from '@/framework/command' +import type { HttpClient } from '@/http/types' import type { IOStreams } from '@/sys/io/streams' import { META_PROBE_TIMEOUT_MS, MetaClient } from '@/api/meta' import { loadHosts } from '@/auth/hosts' @@ -11,16 +11,16 @@ import { getEnv } from '@/env/registry' import { BaseError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' import { formatErrorForCli } from '@/errors/format' -import { createClient } from '@/http/client' +import { createHttpClient } from '@/http/client' import { realStreams } from '@/sys/io/streams' -import { hostWithScheme } from '@/util/host' +import { hostWithScheme, openAPIBase } from '@/util/host' import { versionInfo } from '@/version/info' import { maybeNudgeCompat } from '@/version/nudge' -import { resolveRetryAttempts } from './global-flags' +import { resolveRetryAttempts } from './global-flags.js' export type AuthedContext = { readonly bundle: HostsBundle - readonly http: KyInstance + readonly http: HttpClient readonly host: string readonly io: IOStreams readonly cache?: AppInfoCache @@ -52,7 +52,7 @@ export async function buildAuthedContext( flag: opts.retryFlag, env: getEnv, }) - const http = createClient({ host, bearer: bundle.tokens.bearer, retryAttempts }) + const http = createHttpClient({ baseURL: openAPIBase(host), bearer: bundle.tokens.bearer, retryAttempts }) const cache = opts.withCache === true ? await loadAppInfoCache() : undefined @@ -72,7 +72,7 @@ async function runCompatNudge(opts: { await maybeNudgeCompat(opts.host, { store, probe: async (host) => { - const http = createClient({ host, timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 }) + const http = createHttpClient({ baseURL: openAPIBase(host), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 }) return new MetaClient(http).serverVersion() }, emit: line => opts.io.err.write(line), diff --git a/cli/src/commands/auth/devices/_shared/devices.test.ts b/cli/src/commands/auth/devices/_shared/devices.test.ts index 4675a79e22..bef574f26c 100644 --- a/cli/src/commands/auth/devices/_shared/devices.test.ts +++ b/cli/src/commands/auth/devices/_shared/devices.test.ts @@ -7,13 +7,13 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { startMock } from '@test/fixtures/dify-mock/server' +import { testHttpClient } from '@test/fixtures/http-client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { saveHosts } from '@/auth/hosts' -import { createClient } from '@/http/client' import { ENV_CONFIG_DIR, resolveConfigDir } from '@/store/dir' import { tokenKey } from '@/store/manager' import { bufferStreams } from '@/sys/io/streams' -import { listAllSessions, runDevicesList, runDevicesRevoke } from './devices' +import { listAllSessions, runDevicesList, runDevicesRevoke } from './devices.js' class MemStore implements Store { readonly entries = new Map() @@ -57,7 +57,7 @@ describe('runDevicesList', () => { it('table: marks current with *', async () => { const io = bufferStreams() - const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) + const http = testHttpClient(mock.url, 'dfoa_test') await runDevicesList({ io, bundle: bundleFor(mock.url, 'tok-1'), http }) const out = io.outBuf() expect(out).toContain('DEVICE') @@ -70,7 +70,7 @@ describe('runDevicesList', () => { it('json: emits PaginationEnvelope unchanged', async () => { const io = bufferStreams() - const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) + const http = testHttpClient(mock.url, 'dfoa_test') await runDevicesList({ io, bundle: bundleFor(mock.url), http, json: true }) const parsed = JSON.parse(io.outBuf()) as Record expect(parsed.page).toBe(1) @@ -80,7 +80,7 @@ describe('runDevicesList', () => { it('not-logged-in: throws NotLoggedIn', async () => { const io = bufferStreams() - const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) + const http = testHttpClient(mock.url, 'dfoa_test') await expect(runDevicesList({ io, bundle: undefined, http })) .rejects .toThrow(/not logged in/) @@ -112,7 +112,7 @@ describe('runDevicesRevoke', () => { const b = bundleFor(mock.url, 'tok-1') store.set(tokenKey(b.current_host, 'acct-1'), 'dfoa_test') saveHosts(b) - const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) + const http = testHttpClient(mock.url, 'dfoa_test') await runDevicesRevoke({ io, bundle: b, http, store, target: 'difyctl on desktop', all: false }) expect(io.outBuf()).toContain('Revoked 1 session(s)') @@ -123,7 +123,7 @@ describe('runDevicesRevoke', () => { const io = bufferStreams() const store = new MemStore() const b = bundleFor(mock.url, 'tok-1') - const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) + const http = testHttpClient(mock.url, 'dfoa_test') await runDevicesRevoke({ io, bundle: b, http, store, target: 'tok-2', all: false }) expect(io.outBuf()).toContain('Revoked 1 session(s)') @@ -133,7 +133,7 @@ describe('runDevicesRevoke', () => { const io = bufferStreams() const store = new MemStore() const b = bundleFor(mock.url, 'tok-1') - const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) + const http = testHttpClient(mock.url, 'dfoa_test') await runDevicesRevoke({ io, bundle: b, http, store, target: 'web', all: false }) expect(io.outBuf()).toContain('Revoked 1 session(s)') @@ -143,7 +143,7 @@ describe('runDevicesRevoke', () => { const io = bufferStreams() const store = new MemStore() const b = bundleFor(mock.url, 'tok-1') - const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) + const http = testHttpClient(mock.url, 'dfoa_test') await expect(runDevicesRevoke({ io, bundle: b, http, store, target: 'difyctl', all: false })) .rejects @@ -154,7 +154,7 @@ describe('runDevicesRevoke', () => { const io = bufferStreams() const store = new MemStore() const b = bundleFor(mock.url, 'tok-1') - const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) + const http = testHttpClient(mock.url, 'dfoa_test') await expect(runDevicesRevoke({ io, bundle: b, http, store, target: 'nonexistent', all: false })) .rejects @@ -165,7 +165,7 @@ describe('runDevicesRevoke', () => { const io = bufferStreams() const store = new MemStore() const b = bundleFor(mock.url, 'tok-1') - const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) + const http = testHttpClient(mock.url, 'dfoa_test') await runDevicesRevoke({ io, bundle: b, http, store, all: true }) expect(io.outBuf()).toContain('Revoked 2 session(s)') @@ -177,7 +177,7 @@ describe('runDevicesRevoke', () => { const b = bundleFor(mock.url, 'tok-1') store.set(tokenKey(b.current_host, 'acct-1'), 'dfoa_test') saveHosts(b) - const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) + const http = testHttpClient(mock.url, 'dfoa_test') await runDevicesRevoke({ io, bundle: b, http, store, target: 'tok-1', all: false }) expect(store.entries.size).toBe(0) @@ -187,7 +187,7 @@ describe('runDevicesRevoke', () => { it('no target + no --all: throws UsageMissingArg', async () => { const io = bufferStreams() const store = new MemStore() - const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) + const http = testHttpClient(mock.url, 'dfoa_test') await expect(runDevicesRevoke({ io, bundle: bundleFor(mock.url), http, store, all: false })) .rejects .toThrow(/specify a device label/) diff --git a/cli/src/commands/auth/devices/_shared/devices.ts b/cli/src/commands/auth/devices/_shared/devices.ts index 9daa44da99..9fc596c40c 100644 --- a/cli/src/commands/auth/devices/_shared/devices.ts +++ b/cli/src/commands/auth/devices/_shared/devices.ts @@ -1,6 +1,6 @@ import type { SessionRow } from '@dify/contracts/api/openapi/types.gen' -import type { KyInstance } from 'ky' import type { HostsBundle } from '@/auth/hosts' +import type { HttpClient } from '@/http/types' import type { Store } from '@/store/store' import type { IOStreams } from '@/sys/io/streams' import { AccountSessionsClient } from '@/api/account-sessions' @@ -15,7 +15,7 @@ import { runWithSpinner } from '@/sys/io/spinner' export type DevicesListOptions = { readonly io: IOStreams readonly bundle: HostsBundle | undefined - readonly http: KyInstance + readonly http: HttpClient readonly json?: boolean readonly page?: number readonly limitRaw?: string @@ -73,7 +73,7 @@ export async function listAllSessions(client: AccountSessionsClient): Promise { /* immediate */ }, @@ -65,7 +65,7 @@ describe('runLogin', () => { noBrowser: true, insecure: true, deviceLabel: 'difyctl on test', - api: new DeviceFlowApi(createClient({ host: mock.url })), + api: new DeviceFlowApi(testHttpClient(mock.url)), store: { store, mode: 'file' }, clock: noopClock, browserOpener: noopBrowser, @@ -97,7 +97,7 @@ describe('runLogin', () => { noBrowser: true, insecure: true, deviceLabel: 'difyctl on test', - api: new DeviceFlowApi(createClient({ host: mock.url })), + api: new DeviceFlowApi(testHttpClient(mock.url)), store: { store, mode: 'file' }, clock: noopClock, browserOpener: noopBrowser, @@ -106,7 +106,7 @@ describe('runLogin', () => { expect(bundle.account).toBeUndefined() expect(bundle.external_subject?.email).toBe('sso@dify.ai') expect(bundle.external_subject?.issuer).toBe('https://issuer.example') - const stored = await store.get(tokenKey(bundle.current_host, 'sso@dify.ai')) + const stored = store.get(tokenKey(bundle.current_host, 'sso@dify.ai')) expect(stored).toBe('dfoe_test') expect(io.outBuf()).toContain('external SSO') expect(io.outBuf()).toContain('sso@dify.ai') @@ -122,7 +122,7 @@ describe('runLogin', () => { noBrowser: true, insecure: true, deviceLabel: 'difyctl on test', - api: new DeviceFlowApi(createClient({ host: mock.url })), + api: new DeviceFlowApi(testHttpClient(mock.url)), store: { store, mode: 'file' }, clock: noopClock, browserOpener: noopBrowser, @@ -141,7 +141,7 @@ describe('runLogin', () => { noBrowser: true, insecure: true, deviceLabel: 'difyctl on test', - api: new DeviceFlowApi(createClient({ host: mock.url })), + api: new DeviceFlowApi(testHttpClient(mock.url)), store: { store, mode: 'file' }, clock: noopClock, browserOpener: noopBrowser, @@ -157,7 +157,7 @@ describe('runLogin', () => { noBrowser: true, insecure: false, deviceLabel: 'difyctl on test', - api: new DeviceFlowApi(createClient({ host: mock.url })), + api: new DeviceFlowApi(testHttpClient(mock.url)), store: { store, mode: 'file' }, clock: noopClock, browserOpener: noopBrowser, @@ -173,7 +173,7 @@ describe('runLogin', () => { noBrowser: true, insecure: true, deviceLabel: 'difyctl on test', - api: new DeviceFlowApi(createClient({ host: mock.url })), + api: new DeviceFlowApi(testHttpClient(mock.url)), store: { store, mode: 'file' }, clock: noopClock, browserOpener: noopBrowser, diff --git a/cli/src/commands/auth/login/login.ts b/cli/src/commands/auth/login/login.ts index 0048d89c06..74769ef468 100644 --- a/cli/src/commands/auth/login/login.ts +++ b/cli/src/commands/auth/login/login.ts @@ -1,4 +1,4 @@ -import type { Clock } from './device-flow' +import type { Clock } from './device-flow.js' import type { CodeResponse, PollSuccess } from '@/api/oauth-device' import type { HostsBundle, Workspace } from '@/auth/hosts' import type { StorageMode, Store } from '@/store/store' @@ -8,12 +8,12 @@ import * as os from 'node:os' import * as readline from 'node:readline' import { DeviceFlowApi } from '@/api/oauth-device' import { saveHosts } from '@/auth/hosts' -import { createClient } from '@/http/client' +import { createHttpClient } from '@/http/client' import { getTokenStore, tokenKey } from '@/store/manager' import { colorEnabled, colorScheme } from '@/sys/io/color' import { decideOpen, OpenDecision, openUrl, realEnv } from '@/util/browser' -import { bareHost, DEFAULT_HOST, resolveHost, validateVerificationURI } from '@/util/host' -import { awaitAuthorization, realClock } from './device-flow' +import { bareHost, DEFAULT_HOST, openAPIBase, resolveHost, validateVerificationURI } from '@/util/host' +import { awaitAuthorization, realClock } from './device-flow.js' export type LoginOptions = { readonly io: IOStreams @@ -35,7 +35,7 @@ export async function runLogin(opts: LoginOptions): Promise { const host = await resolveLoginHost(opts, insecure) const label = opts.deviceLabel ?? defaultDeviceLabel() - const api = opts.api ?? new DeviceFlowApi(createClient({ host })) + const api = opts.api ?? new DeviceFlowApi(createHttpClient({ baseURL: openAPIBase(host) })) const code = await api.requestCode({ device_label: label }) renderCodePrompt(opts.io.err, cs, code) diff --git a/cli/src/commands/auth/logout/index.ts b/cli/src/commands/auth/logout/index.ts index 9b65aff42b..809ac5d79f 100644 --- a/cli/src/commands/auth/logout/index.ts +++ b/cli/src/commands/auth/logout/index.ts @@ -1,11 +1,11 @@ -import type { KyInstance } from 'ky' +import type { HttpClient } from '@/http/types' import { loadHosts } from '@/auth/hosts' import { DifyCommand } from '@/commands/_shared/dify-command' -import { createClient } from '@/http/client' +import { createHttpClient } from '@/http/client' import { runWithSpinner } from '@/sys/io/spinner' import { realStreams } from '@/sys/io/streams' -import { hostWithScheme } from '@/util/host' -import { runLogout } from './logout' +import { hostWithScheme, openAPIBase } from '@/util/host' +import { runLogout } from './logout.js' export default class Logout extends DifyCommand { static override description = 'Log out of the active Dify host' @@ -18,10 +18,10 @@ export default class Logout extends DifyCommand { this.parse(Logout, argv) const bundle = loadHosts() - let http: KyInstance | undefined + let http: HttpClient | undefined if (bundle !== undefined && bundle.current_host !== '' && bundle.tokens?.bearer !== undefined && bundle.tokens.bearer !== '') { - http = createClient({ - host: hostWithScheme(bundle.current_host, bundle.scheme), + http = createHttpClient({ + baseURL: openAPIBase(hostWithScheme(bundle.current_host, bundle.scheme)), bearer: bundle.tokens.bearer, retryAttempts: 0, }) diff --git a/cli/src/commands/auth/logout/logout.test.ts b/cli/src/commands/auth/logout/logout.test.ts index fe0ca5aa3a..495a18f607 100644 --- a/cli/src/commands/auth/logout/logout.test.ts +++ b/cli/src/commands/auth/logout/logout.test.ts @@ -5,13 +5,13 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { startMock } from '@test/fixtures/dify-mock/server' +import { testHttpClient } from '@test/fixtures/http-client' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { saveHosts } from '@/auth/hosts' -import { createClient } from '@/http/client' import { ENV_CONFIG_DIR } from '@/store/dir' import { tokenKey } from '@/store/manager' import { bufferStreams } from '@/sys/io/streams' -import { runLogout } from './logout' +import { runLogout } from './logout.js' class MemStore implements Store { readonly entries = new Map() @@ -71,7 +71,7 @@ describe('runLogout', () => { const bundle = fixtureBundle(mock.url) store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfoa_test') saveHosts(bundle) - const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) + const http = testHttpClient(mock.url, 'dfoa_test') await runLogout({ io, bundle, http, store }) @@ -91,7 +91,7 @@ describe('runLogout', () => { const io = bufferStreams() const store = new MemStore() const bundle = fixtureBundle(mock.url) - const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) + const http = testHttpClient(mock.url, 'dfoa_test') await runLogout({ io, bundle, http, store }) @@ -105,7 +105,7 @@ describe('runLogout', () => { store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfoa_test') saveHosts(bundle) mock.setScenario('server-5xx') - const http = createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }) + const http = testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 }) await runLogout({ io, bundle, http, store }) @@ -121,7 +121,7 @@ describe('runLogout', () => { bundle.tokens = { bearer: 'dfp_personal_token' } store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfp_personal_token') saveHosts(bundle) - const http = createClient({ host: mock.url, bearer: 'dfp_personal_token' }) + const http = testHttpClient(mock.url, 'dfp_personal_token') await runLogout({ io, bundle, http, store }) @@ -135,7 +135,7 @@ describe('runLogout', () => { const bundle = fixtureBundle(mock.url) saveHosts(bundle) await writeFile(join(configDir, 'config.yml'), 'foo: bar\n', 'utf8') - const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) + const http = testHttpClient(mock.url, 'dfoa_test') await runLogout({ io, bundle, http, store }) diff --git a/cli/src/commands/auth/logout/logout.ts b/cli/src/commands/auth/logout/logout.ts index c2a1c9d32a..fc99d460d1 100644 --- a/cli/src/commands/auth/logout/logout.ts +++ b/cli/src/commands/auth/logout/logout.ts @@ -1,5 +1,5 @@ -import type { KyInstance } from 'ky' import type { HostsBundle } from '@/auth/hosts' +import type { HttpClient } from '@/http/types' import type { Store } from '@/store/store' import type { IOStreams } from '@/sys/io/streams' import { AccountSessionsClient } from '@/api/account-sessions' @@ -12,7 +12,7 @@ import { colorEnabled, colorScheme } from '@/sys/io/color' export type LogoutOptions = { readonly io: IOStreams readonly bundle: HostsBundle | undefined - readonly http?: KyInstance + readonly http?: HttpClient /** Optional override for tests; production code resolves via `getTokenStore`. */ readonly store?: Store } diff --git a/cli/src/commands/create/member/run.test.ts b/cli/src/commands/create/member/run.test.ts index 4ac72b0552..d796f507eb 100644 --- a/cli/src/commands/create/member/run.test.ts +++ b/cli/src/commands/create/member/run.test.ts @@ -1,8 +1,8 @@ -import type { KyInstance } from 'ky' import type { HostsBundle } from '@/auth/hosts' +import type { HttpClient } from '@/http/types' import { describe, expect, it, vi } from 'vitest' import { bufferStreams } from '@/sys/io/streams' -import { runCreateMember } from './run' +import { runCreateMember } from './run.js' function bundle(): HostsBundle { return { @@ -36,7 +36,7 @@ describe('runCreateMember', () => { { email: 'new@example.com', role: 'normal' }, { bundle: bundle(), - http: {} as KyInstance, + http: {} as HttpClient, io: bufferStreams(), membersFactory: () => client as never, }, @@ -61,7 +61,7 @@ describe('runCreateMember', () => { { email: 'new@example.com', role: 'owner' }, { bundle: bundle(), - http: {} as KyInstance, + http: {} as HttpClient, io: bufferStreams(), membersFactory: () => client as never, }, @@ -77,7 +77,7 @@ describe('runCreateMember', () => { { email: '', role: 'normal' }, { bundle: bundle(), - http: {} as KyInstance, + http: {} as HttpClient, io: bufferStreams(), membersFactory: () => client as never, }, @@ -92,7 +92,7 @@ describe('runCreateMember', () => { { email: 'new@example.com', role: 'admin', workspace: 'ws-9' }, { bundle: bundle(), - http: {} as KyInstance, + http: {} as HttpClient, io: bufferStreams(), membersFactory: () => client as never, }, diff --git a/cli/src/commands/create/member/run.ts b/cli/src/commands/create/member/run.ts index 1e4a9c3ab0..bc4c15ab9c 100644 --- a/cli/src/commands/create/member/run.ts +++ b/cli/src/commands/create/member/run.ts @@ -1,5 +1,5 @@ -import type { KyInstance } from 'ky' import type { HostsBundle } from '@/auth/hosts' +import type { HttpClient } from '@/http/types' import type { IOStreams } from '@/sys/io/streams' import { MembersClient } from '@/api/members' import { BaseError } from '@/errors/base' @@ -8,7 +8,7 @@ import { colorEnabled, colorScheme } from '@/sys/io/color' import { runWithSpinner } from '@/sys/io/spinner' import { nullStreams } from '@/sys/io/streams' import { resolveWorkspaceId } from '@/workspace/resolver' -import { InviteOutput } from './handlers' +import { InviteOutput } from './handlers.js' export type CreateMemberOptions = { readonly email: string @@ -19,10 +19,10 @@ export type CreateMemberOptions = { export type CreateMemberDeps = { readonly bundle: HostsBundle - readonly http: KyInstance + readonly http: HttpClient readonly io?: IOStreams readonly envLookup?: (k: string) => string | undefined - readonly membersFactory?: (http: KyInstance) => MembersClient + readonly membersFactory?: (http: HttpClient) => MembersClient } export type CreateMemberResult = { @@ -52,7 +52,7 @@ export async function runCreateMember( } const env = deps.envLookup ?? ((k: string) => process.env[k]) - const factory = deps.membersFactory ?? ((h: KyInstance) => new MembersClient(h)) + const factory = deps.membersFactory ?? ((h: HttpClient) => new MembersClient(h)) const io = deps.io ?? nullStreams() const cs = colorScheme(colorEnabled(io.isErrTTY)) diff --git a/cli/src/commands/delete/member/run.test.ts b/cli/src/commands/delete/member/run.test.ts index db4a3c0adc..e63329d140 100644 --- a/cli/src/commands/delete/member/run.test.ts +++ b/cli/src/commands/delete/member/run.test.ts @@ -1,8 +1,8 @@ -import type { KyInstance } from 'ky' import type { HostsBundle } from '@/auth/hosts' +import type { HttpClient } from '@/http/types' import { describe, expect, it, vi } from 'vitest' import { bufferStreams } from '@/sys/io/streams' -import { runDeleteMember } from './run' +import { runDeleteMember } from './run.js' function bundle(): HostsBundle { return { @@ -28,7 +28,7 @@ describe('runDeleteMember', () => { { memberId: 'acct-2' }, { bundle: bundle(), - http: {} as KyInstance, + http: {} as HttpClient, io: bufferStreams(), membersFactory: () => client as never, }, @@ -46,7 +46,7 @@ describe('runDeleteMember', () => { { memberId: 'acct-2', workspace: 'ws-9' }, { bundle: bundle(), - http: {} as KyInstance, + http: {} as HttpClient, io: bufferStreams(), membersFactory: () => client as never, }, @@ -61,7 +61,7 @@ describe('runDeleteMember', () => { { memberId: '' }, { bundle: bundle(), - http: {} as KyInstance, + http: {} as HttpClient, io: bufferStreams(), membersFactory: () => client as never, }, diff --git a/cli/src/commands/delete/member/run.ts b/cli/src/commands/delete/member/run.ts index 63fda52d54..0476524f1e 100644 --- a/cli/src/commands/delete/member/run.ts +++ b/cli/src/commands/delete/member/run.ts @@ -1,5 +1,5 @@ -import type { KyInstance } from 'ky' import type { HostsBundle } from '@/auth/hosts' +import type { HttpClient } from '@/http/types' import type { IOStreams } from '@/sys/io/streams' import * as readline from 'node:readline' import { MembersClient } from '@/api/members' @@ -9,7 +9,7 @@ import { colorEnabled, colorScheme } from '@/sys/io/color' import { runWithSpinner } from '@/sys/io/spinner' import { nullStreams } from '@/sys/io/streams' import { resolveWorkspaceId } from '@/workspace/resolver' -import { DeleteMemberOutput } from './handlers' +import { DeleteMemberOutput } from './handlers.js' export type DeleteMemberOptions = { readonly memberId: string @@ -20,10 +20,10 @@ export type DeleteMemberOptions = { export type DeleteMemberDeps = { readonly bundle: HostsBundle - readonly http: KyInstance + readonly http: HttpClient readonly io?: IOStreams readonly envLookup?: (k: string) => string | undefined - readonly membersFactory?: (http: KyInstance) => MembersClient + readonly membersFactory?: (http: HttpClient) => MembersClient } export type DeleteMemberResult = { @@ -44,7 +44,7 @@ export async function runDeleteMember( } const env = deps.envLookup ?? ((k: string) => process.env[k]) - const factory = deps.membersFactory ?? ((h: KyInstance) => new MembersClient(h)) + const factory = deps.membersFactory ?? ((h: HttpClient) => new MembersClient(h)) const io = deps.io ?? nullStreams() const cs = colorScheme(colorEnabled(io.isErrTTY)) diff --git a/cli/src/commands/describe/app/run.test.ts b/cli/src/commands/describe/app/run.test.ts index 65da6bfa4e..785b6f9a0b 100644 --- a/cli/src/commands/describe/app/run.test.ts +++ b/cli/src/commands/describe/app/run.test.ts @@ -4,13 +4,13 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { startMock } from '@test/fixtures/dify-mock/server' +import { testHttpClient } from '@test/fixtures/http-client' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { loadAppInfoCache } from '@/cache/app-info' import { formatted, stringifyOutput } from '@/framework/output' -import { createClient } from '@/http/client' import { ENV_CACHE_DIR } from '@/store/dir' import { CACHE_APP_INFO, getCache } from '@/store/manager' -import { runDescribeApp } from './run' +import { runDescribeApp } from './run.js' function bundle(): HostsBundle { return { @@ -49,7 +49,7 @@ describe('runDescribeApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) const data = await runDescribeApp( opts, - { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache }, + { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, cache }, ) return stringifyOutput(formatted({ format: opts.format ?? '', data })) } @@ -92,13 +92,13 @@ describe('runDescribeApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runDescribeApp( { appId: 'app-1' }, - { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache }, + { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, cache }, ) const before = cache.get(mock.url, 'app-1') expect(before).toBeDefined() await runDescribeApp( { appId: 'app-1', refresh: true }, - { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache }, + { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, cache }, ) const after = cache.get(mock.url, 'app-1') expect(after?.fetchedAt).not.toBe(before?.fetchedAt ?? '') @@ -113,7 +113,7 @@ describe('runDescribeApp', () => { { appId: 'nope' }, { bundle: bundle(), - http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }), + http: testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 }), host: mock.url, }, )).rejects.toThrow() diff --git a/cli/src/commands/describe/app/run.ts b/cli/src/commands/describe/app/run.ts index 5542fa5fd7..d68d04ba7f 100644 --- a/cli/src/commands/describe/app/run.ts +++ b/cli/src/commands/describe/app/run.ts @@ -1,6 +1,6 @@ -import type { KyInstance } from 'ky' import type { HostsBundle } from '@/auth/hosts' import type { AppInfoCache } from '@/cache/app-info' +import type { HttpClient } from '@/http/types' import type { IOStreams } from '@/sys/io/streams' import { AppMetaClient } from '@/api/app-meta' import { AppsClient } from '@/api/apps' @@ -9,7 +9,7 @@ import { runWithSpinner } from '@/sys/io/spinner' import { nullStreams } from '@/sys/io/streams' import { FieldInfo, FieldInputSchema, FieldParameters } from '@/types/app-meta' import { resolveWorkspaceId } from '@/workspace/resolver' -import { AppDescribeOutput } from './handlers' +import { AppDescribeOutput } from './handlers.js' export type DescribeAppOptions = { readonly appId: string @@ -20,7 +20,7 @@ export type DescribeAppOptions = { export type DescribeAppDeps = { readonly bundle: HostsBundle - readonly http: KyInstance + readonly http: HttpClient readonly host: string readonly io?: IOStreams readonly cache?: AppInfoCache diff --git a/cli/src/commands/get/app/run.test.ts b/cli/src/commands/get/app/run.test.ts index e9e4d52916..3682665962 100644 --- a/cli/src/commands/get/app/run.test.ts +++ b/cli/src/commands/get/app/run.test.ts @@ -1,11 +1,11 @@ import type { DifyMock } from '@test/fixtures/dify-mock/server' import type { HostsBundle } from '@/auth/hosts' import { startMock } from '@test/fixtures/dify-mock/server' +import { testHttpClient } from '@test/fixtures/http-client' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { stringifyOutput, table } from '@/framework/output' -import { createClient } from '@/http/client' -import { AppListOutput } from './handlers' -import { runGetApp } from './run' +import { AppListOutput } from './handlers.js' +import { runGetApp } from './run.js' const baseBundle: HostsBundle = { current_host: '127.0.0.1', @@ -32,7 +32,7 @@ describe('runGetApp', () => { }) function http() { - return createClient({ host: mock.url, bearer: 'dfoa_test' }) + return testHttpClient(mock.url, 'dfoa_test') } async function render(opts: Parameters[0] = {}): Promise { diff --git a/cli/src/commands/get/app/run.ts b/cli/src/commands/get/app/run.ts index 28ae0cba4a..3cd84f4ec8 100644 --- a/cli/src/commands/get/app/run.ts +++ b/cli/src/commands/get/app/run.ts @@ -1,6 +1,6 @@ import type { AppDescribeResponse, AppListResponse, AppMode } from '@dify/contracts/api/openapi/types.gen' -import type { KyInstance } from 'ky' import type { HostsBundle } from '@/auth/hosts' +import type { HttpClient } from '@/http/types' import type { IOStreams } from '@/sys/io/streams' import { AppsClient } from '@/api/apps' import { WorkspacesClient } from '@/api/workspaces' @@ -9,7 +9,7 @@ import { getEnv } from '@/sys/index' import { runWithSpinner } from '@/sys/io/spinner' import { nullStreams } from '@/sys/io/streams' import { resolveWorkspaceId } from '@/workspace/resolver' -import { AppListOutput, AppRow } from './handlers' +import { AppListOutput, AppRow } from './handlers.js' export type GetAppOptions = { readonly appId?: string @@ -25,11 +25,11 @@ export type GetAppOptions = { export type GetAppDeps = { readonly bundle: HostsBundle - readonly http: KyInstance + readonly http: HttpClient readonly io?: IOStreams readonly envLookup?: (k: string) => string | undefined - readonly appsFactory?: (http: KyInstance) => AppsClient - readonly workspacesFactory?: (http: KyInstance) => WorkspacesClient + readonly appsFactory?: (http: HttpClient) => AppsClient + readonly workspacesFactory?: (http: HttpClient) => WorkspacesClient } const ALL_WORKSPACES_CONCURRENCY = 4 @@ -40,8 +40,8 @@ export type GetAppResult = { export async function runGetApp(opts: GetAppOptions, deps: GetAppDeps): Promise { const env = deps.envLookup ?? getEnv - const appsFactory = deps.appsFactory ?? ((h: KyInstance) => new AppsClient(h)) - const wsFactory = deps.workspacesFactory ?? ((h: KyInstance) => new WorkspacesClient(h)) + const appsFactory = deps.appsFactory ?? ((h: HttpClient) => new AppsClient(h)) + const wsFactory = deps.workspacesFactory ?? ((h: HttpClient) => new WorkspacesClient(h)) const apps = appsFactory(deps.http) const pageSize = resolveLimit(opts.limitRaw, env) diff --git a/cli/src/commands/get/member/run.test.ts b/cli/src/commands/get/member/run.test.ts index 5e0af7fe2a..e40a1cd3eb 100644 --- a/cli/src/commands/get/member/run.test.ts +++ b/cli/src/commands/get/member/run.test.ts @@ -1,9 +1,9 @@ import type { MemberListResponse } from '@dify/contracts/api/openapi/types.gen' -import type { KyInstance } from 'ky' import type { HostsBundle } from '@/auth/hosts' +import type { HttpClient } from '@/http/types' import { describe, expect, it, vi } from 'vitest' import { bufferStreams } from '@/sys/io/streams' -import { runGetMember } from './run' +import { runGetMember } from './run.js' function bundle(): HostsBundle { return { @@ -38,7 +38,7 @@ describe('runGetMember', () => { {}, { bundle: bundle(), - http: {} as KyInstance, + http: {} as HttpClient, io: bufferStreams(), membersFactory: () => client as never, }, @@ -55,7 +55,7 @@ describe('runGetMember', () => { { workspace: 'ws-9' }, { bundle: bundle(), - http: {} as KyInstance, + http: {} as HttpClient, io: bufferStreams(), membersFactory: () => client as never, }, @@ -70,7 +70,7 @@ describe('runGetMember', () => { { page: 3, limitRaw: '50' }, { bundle: bundle(), - http: {} as KyInstance, + http: {} as HttpClient, io: bufferStreams(), membersFactory: () => client as never, }, @@ -86,7 +86,7 @@ describe('runGetMember', () => { {}, { bundle: b, - http: {} as KyInstance, + http: {} as HttpClient, io: bufferStreams(), membersFactory: () => client as never, }, @@ -106,7 +106,7 @@ describe('runGetMember', () => { tokens: { bearer: 'dfoa_test' }, account: { id: 'acct-1', email: '', name: '' }, }, - http: {} as KyInstance, + http: {} as HttpClient, io: bufferStreams(), envLookup: () => undefined, membersFactory: () => client as never, @@ -133,7 +133,7 @@ describe('MemberListOutput shape', () => { {}, { bundle: bundle(), - http: {} as KyInstance, + http: {} as HttpClient, io: bufferStreams(), membersFactory: () => client as never, }, diff --git a/cli/src/commands/get/member/run.ts b/cli/src/commands/get/member/run.ts index b6bf995dc1..b9c30cbdcb 100644 --- a/cli/src/commands/get/member/run.ts +++ b/cli/src/commands/get/member/run.ts @@ -1,12 +1,12 @@ -import type { KyInstance } from 'ky' import type { HostsBundle } from '@/auth/hosts' +import type { HttpClient } from '@/http/types' import type { IOStreams } from '@/sys/io/streams' import { MembersClient } from '@/api/members' import { LIMIT_DEFAULT, parseLimit } from '@/limit/limit' import { runWithSpinner } from '@/sys/io/spinner' import { nullStreams } from '@/sys/io/streams' import { resolveWorkspaceId } from '@/workspace/resolver' -import { MemberListOutput, MemberRow } from './handlers' +import { MemberListOutput, MemberRow } from './handlers.js' export type GetMemberOptions = { readonly workspace?: string @@ -17,10 +17,10 @@ export type GetMemberOptions = { export type GetMemberDeps = { readonly bundle: HostsBundle - readonly http: KyInstance + readonly http: HttpClient readonly io?: IOStreams readonly envLookup?: (k: string) => string | undefined - readonly membersFactory?: (http: KyInstance) => MembersClient + readonly membersFactory?: (http: HttpClient) => MembersClient } export type GetMemberResult = { @@ -33,7 +33,7 @@ export async function runGetMember( deps: GetMemberDeps, ): Promise { const env = deps.envLookup ?? ((k: string) => process.env[k]) - const factory = deps.membersFactory ?? ((h: KyInstance) => new MembersClient(h)) + const factory = deps.membersFactory ?? ((h: HttpClient) => new MembersClient(h)) const io = deps.io ?? nullStreams() const wsId = resolveWorkspaceId({ diff --git a/cli/src/commands/get/workspace/run.test.ts b/cli/src/commands/get/workspace/run.test.ts index df7d1bd207..65c8c4d755 100644 --- a/cli/src/commands/get/workspace/run.test.ts +++ b/cli/src/commands/get/workspace/run.test.ts @@ -1,11 +1,11 @@ import type { DifyMock } from '@test/fixtures/dify-mock/server' import type { HostsBundle } from '@/auth/hosts' import { startMock } from '@test/fixtures/dify-mock/server' +import { testHttpClient } from '@test/fixtures/http-client' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { stringifyOutput, table } from '@/framework/output' -import { createClient } from '@/http/client' -import { WorkspaceListOutput } from './handlers' -import { EMPTY_WORKSPACES_MESSAGE, runGetWorkspace } from './run' +import { WorkspaceListOutput } from './handlers.js' +import { EMPTY_WORKSPACES_MESSAGE, runGetWorkspace } from './run.js' const baseBundle: HostsBundle = { current_host: '127.0.0.1', @@ -32,7 +32,7 @@ describe('runGetWorkspace', () => { }) function http() { - return createClient({ host: mock.url, bearer: 'dfoa_test' }) + return testHttpClient(mock.url, 'dfoa_test') } async function render(format = '', bundle = baseBundle): Promise { diff --git a/cli/src/commands/get/workspace/run.ts b/cli/src/commands/get/workspace/run.ts index 9f35dd5906..847aed7856 100644 --- a/cli/src/commands/get/workspace/run.ts +++ b/cli/src/commands/get/workspace/run.ts @@ -1,10 +1,10 @@ -import type { KyInstance } from 'ky' import type { HostsBundle } from '@/auth/hosts' +import type { HttpClient } from '@/http/types' import type { IOStreams } from '@/sys/io/streams' import { WorkspacesClient } from '@/api/workspaces' import { runWithSpinner } from '@/sys/io/spinner' import { nullStreams } from '@/sys/io/streams' -import { WorkspaceListOutput, WorkspaceRow } from './handlers' +import { WorkspaceListOutput, WorkspaceRow } from './handlers.js' export const EMPTY_WORKSPACES_MESSAGE = 'No workspaces visible to this bearer (external-SSO subjects see empty data).\n' @@ -15,9 +15,9 @@ export type GetWorkspaceOptions = { export type GetWorkspaceDeps = { readonly bundle: HostsBundle - readonly http: KyInstance + readonly http: HttpClient readonly io?: IOStreams - readonly workspacesFactory?: (http: KyInstance) => WorkspacesClient + readonly workspacesFactory?: (http: HttpClient) => WorkspacesClient } export type GetWorkspaceResult @@ -25,7 +25,7 @@ export type GetWorkspaceResult | { readonly kind: 'output', readonly data: WorkspaceListOutput } export async function runGetWorkspace(opts: GetWorkspaceOptions, deps: GetWorkspaceDeps): Promise { - const wsFactory = deps.workspacesFactory ?? ((h: KyInstance) => new WorkspacesClient(h)) + const wsFactory = deps.workspacesFactory ?? ((h: HttpClient) => new WorkspacesClient(h)) const io = deps.io ?? nullStreams() const env = await runWithSpinner( { io, label: 'Fetching workspaces' }, diff --git a/cli/src/commands/resume/app/run.ts b/cli/src/commands/resume/app/run.ts index 972a6f1e8e..b850147f08 100644 --- a/cli/src/commands/resume/app/run.ts +++ b/cli/src/commands/resume/app/run.ts @@ -1,7 +1,7 @@ -import type { KyInstance } from 'ky' import type { HostsBundle } from '@/auth/hosts' import type { AppInfoCache } from '@/cache/app-info' import type { RunContext } from '@/commands/run/app/_strategies/index' +import type { HttpClient } from '@/http/types' import type { IOStreams } from '@/sys/io/streams' import { AppMetaClient } from '@/api/app-meta' import { AppRunClient } from '@/api/app-run' @@ -31,7 +31,7 @@ export type ResumeAppOptions = { export type ResumeAppDeps = { readonly bundle: HostsBundle - readonly http: KyInstance + readonly http: HttpClient readonly host: string readonly io: IOStreams readonly cache?: AppInfoCache @@ -90,9 +90,9 @@ export async function resumeApp(opts: ResumeAppOptions, deps: ResumeAppDeps): Pr let action = opts.action if (action === undefined) { - const formResp = await deps.http.get( + const formResp = await deps.http.get<{ user_actions: { id: string }[] }>( `apps/${encodeURIComponent(opts.appId)}/form/human_input/${encodeURIComponent(opts.formToken)}`, - ).json<{ user_actions: { id: string }[] }>() + ) if (formResp.user_actions.length === 1) { action = formResp.user_actions[0]?.id ?? '' } diff --git a/cli/src/commands/run/app/run.test.ts b/cli/src/commands/run/app/run.test.ts index a9bc41571a..e97a1244bc 100644 --- a/cli/src/commands/run/app/run.test.ts +++ b/cli/src/commands/run/app/run.test.ts @@ -4,14 +4,14 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { startMock } from '@test/fixtures/dify-mock/server' +import { testHttpClient } from '@test/fixtures/http-client' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { loadAppInfoCache } from '@/cache/app-info' import { resumeApp } from '@/commands/resume/app/run' -import { createClient } from '@/http/client' import { ENV_CACHE_DIR } from '@/store/dir' import { CACHE_APP_INFO, getCache } from '@/store/manager' import { bufferStreams } from '@/sys/io/streams' -import { runApp } from './run' +import { runApp } from './run.js' function bundle(): HostsBundle { return { @@ -51,7 +51,7 @@ describe('runApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-1', message: 'hi' }, - { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache }, + { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) expect(io.outBuf()).toBe('echo: hi\n') expect(io.errBuf()).toContain('--conversation conv-1') @@ -62,7 +62,7 @@ describe('runApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await expect(runApp( { appId: 'app-2', message: 'hi' }, - { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache }, + { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, )).rejects.toMatchObject({ code: 'usage_invalid_flag' }) }) @@ -71,7 +71,7 @@ describe('runApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-2', inputs: { x: '1' } }, - { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache }, + { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) expect(io.outBuf()).toBe('echo: \n') }) @@ -81,7 +81,7 @@ describe('runApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-1', message: 'hi', format: 'json' }, - { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache }, + { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) const parsed = JSON.parse(io.outBuf()) as { mode: string, answer: string } expect(parsed.mode).toBe('chat') @@ -92,7 +92,7 @@ describe('runApp', () => { const io = bufferStreams() await expect(runApp( { appId: 'app-1', format: 'bogus' }, - { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io }, + { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io }, )).rejects.toThrow(/not supported/) }) @@ -102,7 +102,7 @@ describe('runApp', () => { { appId: 'nope', message: 'hi' }, { bundle: bundle(), - http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }), + http: testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 }), host: mock.url, io, }, @@ -114,7 +114,7 @@ describe('runApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-1', message: 'hi', stream: true }, - { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache }, + { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) expect(io.outBuf()).toContain('echo: ') expect(io.outBuf()).toContain('hi') @@ -126,7 +126,7 @@ describe('runApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-1', message: 'hi', stream: true, format: 'json' }, - { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache }, + { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) const parsed = JSON.parse(io.outBuf()) as { mode: string, answer: string, conversation_id: string } expect(parsed.mode).toBe('chat') @@ -139,7 +139,7 @@ describe('runApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-4', workspace: 'ws-2', message: 'do research' }, - { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache }, + { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) expect(io.outBuf()).toContain('do research') expect(io.errBuf()).toContain('--conversation conv-1') @@ -150,7 +150,7 @@ describe('runApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-4', workspace: 'ws-2', message: 'go', stream: true }, - { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache }, + { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) expect(io.outBuf()).toContain('go') expect(io.errBuf()).toContain('thought:') @@ -161,7 +161,7 @@ describe('runApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-2', inputs: { x: '1' }, stream: true, format: 'json' }, - { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache }, + { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) const parsed = JSON.parse(io.outBuf()) as { mode: string, data: { status: string } } expect(parsed.mode).toBe('workflow') @@ -174,7 +174,7 @@ describe('runApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await expect(runApp( { appId: 'app-1', message: 'hi', stream: true }, - { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }), host: mock.url, io, cache }, + { bundle: bundle(), http: testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 }), host: mock.url, io, cache }, )).rejects.toMatchObject({ code: 'server_5xx' }) }) @@ -186,7 +186,7 @@ describe('runApp', () => { await writeFile(inputsFile, JSON.stringify({ x: 'from-file' })) await runApp( { appId: 'app-2', inputsFile }, - { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache }, + { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) expect(io.outBuf()).toBe('echo: \n') }) @@ -198,7 +198,7 @@ describe('runApp', () => { await writeFile(inputsFile, JSON.stringify([1, 2, 3])) await expect(runApp( { appId: 'app-2', inputsFile }, - { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io }, + { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io }, )).rejects.toThrow(/must be a JSON object/) }) @@ -207,7 +207,7 @@ describe('runApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-2', inputsJson: '{"x":"hello"}' }, - { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache }, + { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) expect(io.outBuf()).toBe('echo: \n') }) @@ -219,7 +219,7 @@ describe('runApp', () => { await writeFile(inputsFile, '{}') await expect(runApp( { appId: 'app-2', inputsJson: '{}', inputsFile }, - { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io }, + { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io }, )).rejects.toThrow(/mutually exclusive/) }) @@ -232,7 +232,7 @@ describe('runApp', () => { { appId: 'app-2', inputs: {} }, { bundle: bundle(), - http: createClient({ host: mock.url, bearer: 'dfoa_test' }), + http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache, @@ -261,7 +261,7 @@ describe('runApp', () => { { appId: 'app-2', inputs: {}, format: 'json' }, { bundle: bundle(), - http: createClient({ host: mock.url, bearer: 'dfoa_test' }), + http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache, @@ -284,7 +284,7 @@ describe('runApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await resumeApp( { appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, withHistory: false }, - { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache }, + { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) expect(io.outBuf()).toBe('echo: resumed\n') }) @@ -295,7 +295,7 @@ describe('runApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await resumeApp( { appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {} }, - { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache }, + { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) expect(io.outBuf()).toBe('echo: resumed\n') }) @@ -306,7 +306,7 @@ describe('runApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await resumeApp( { appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, stream: true }, - { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache }, + { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) // stream mode for workflow: node_started → "→ " on stderr expect(io.errBuf()).toContain('After Resume') @@ -317,7 +317,7 @@ describe('runApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-2', files: ['doc=https://example.com/report.pdf'] }, - { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache }, + { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) expect(io.outBuf()).toBe('echo: \n') expect(mock.uploadCallCount).toBe(0) @@ -338,7 +338,7 @@ describe('runApp', () => { await writeFile(filePath, 'fake pdf content') await runApp( { appId: 'app-2', files: [`doc=@${filePath}`] }, - { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache }, + { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) expect(io.outBuf()).toBe('echo: \n') expect(mock.uploadCallCount).toBe(1) @@ -355,7 +355,7 @@ describe('runApp', () => { const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) await runApp( { appId: 'app-2', inputs: { doc: 'old-value' }, files: ['doc=https://example.com/override.pdf'] }, - { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache }, + { bundle: bundle(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, io, cache }, ) expect(io.outBuf()).toBe('echo: \n') const runInputs = mock.lastRunBody?.inputs as Record<string, unknown> diff --git a/cli/src/commands/run/app/run.ts b/cli/src/commands/run/app/run.ts index ca529e0728..468eac826e 100644 --- a/cli/src/commands/run/app/run.ts +++ b/cli/src/commands/run/app/run.ts @@ -1,6 +1,6 @@ -import type { KyInstance } from 'ky' import type { HostsBundle } from '@/auth/hosts' import type { AppInfoCache } from '@/cache/app-info' +import type { HttpClient } from '@/http/types' import type { IOStreams } from '@/sys/io/streams' import { AppMetaClient } from '@/api/app-meta' import { AppRunClient } from '@/api/app-run' @@ -12,9 +12,9 @@ import { ErrorCode } from '@/errors/codes' import { getEnv, processExit } from '@/sys/index' import { FieldInfo } from '@/types/app-meta' import { resolveWorkspaceId } from '@/workspace/resolver' -import { resolveFileInputs } from './file-flags' -import { RUN_MODES } from './handlers' -import { AppRunPrintFlags } from './print-flags' +import { resolveFileInputs } from './file-flags.js' +import { RUN_MODES } from './handlers.js' +import { AppRunPrintFlags } from './print-flags.js' export type RunAppOptions = { readonly appId: string @@ -33,7 +33,7 @@ export type RunAppOptions = { export type RunAppDeps = { readonly bundle: HostsBundle - readonly http: KyInstance + readonly http: HttpClient readonly host: string readonly io: IOStreams readonly cache?: AppInfoCache diff --git a/cli/src/commands/set/member/run.test.ts b/cli/src/commands/set/member/run.test.ts index 4541fddb94..3f6b988fe2 100644 --- a/cli/src/commands/set/member/run.test.ts +++ b/cli/src/commands/set/member/run.test.ts @@ -1,8 +1,8 @@ -import type { KyInstance } from 'ky' import type { HostsBundle } from '@/auth/hosts' +import type { HttpClient } from '@/http/types' import { describe, expect, it, vi } from 'vitest' import { bufferStreams } from '@/sys/io/streams' -import { runSetMember } from './run' +import { runSetMember } from './run.js' function bundle(): HostsBundle { return { @@ -28,7 +28,7 @@ describe('runSetMember', () => { { memberId: 'acct-2', role: 'admin' }, { bundle: bundle(), - http: {} as KyInstance, + http: {} as HttpClient, io: bufferStreams(), membersFactory: () => client as never, }, @@ -47,7 +47,7 @@ describe('runSetMember', () => { { memberId: 'acct-2', role: 'owner' }, { bundle: bundle(), - http: {} as KyInstance, + http: {} as HttpClient, io: bufferStreams(), membersFactory: () => client as never, }, @@ -63,7 +63,7 @@ describe('runSetMember', () => { { memberId: '', role: 'admin' }, { bundle: bundle(), - http: {} as KyInstance, + http: {} as HttpClient, io: bufferStreams(), membersFactory: () => client as never, }, @@ -77,7 +77,7 @@ describe('runSetMember', () => { { memberId: 'acct-2', role: 'normal', workspace: 'ws-9' }, { bundle: bundle(), - http: {} as KyInstance, + http: {} as HttpClient, io: bufferStreams(), membersFactory: () => client as never, }, diff --git a/cli/src/commands/set/member/run.ts b/cli/src/commands/set/member/run.ts index 0236a1ea77..714fc5d3ad 100644 --- a/cli/src/commands/set/member/run.ts +++ b/cli/src/commands/set/member/run.ts @@ -1,5 +1,5 @@ -import type { KyInstance } from 'ky' import type { HostsBundle } from '@/auth/hosts' +import type { HttpClient } from '@/http/types' import type { IOStreams } from '@/sys/io/streams' import { MembersClient } from '@/api/members' import { BaseError } from '@/errors/base' @@ -8,7 +8,7 @@ import { colorEnabled, colorScheme } from '@/sys/io/color' import { runWithSpinner } from '@/sys/io/spinner' import { nullStreams } from '@/sys/io/streams' import { resolveWorkspaceId } from '@/workspace/resolver' -import { SetMemberOutput } from './handlers' +import { SetMemberOutput } from './handlers.js' export type SetMemberOptions = { readonly memberId: string @@ -19,10 +19,10 @@ export type SetMemberOptions = { export type SetMemberDeps = { readonly bundle: HostsBundle - readonly http: KyInstance + readonly http: HttpClient readonly io?: IOStreams readonly envLookup?: (k: string) => string | undefined - readonly membersFactory?: (http: KyInstance) => MembersClient + readonly membersFactory?: (http: HttpClient) => MembersClient } export type SetMemberResult = { @@ -52,7 +52,7 @@ export async function runSetMember( } const env = deps.envLookup ?? ((k: string) => process.env[k]) - const factory = deps.membersFactory ?? ((h: KyInstance) => new MembersClient(h)) + const factory = deps.membersFactory ?? ((h: HttpClient) => new MembersClient(h)) const io = deps.io ?? nullStreams() const cs = colorScheme(colorEnabled(io.isErrTTY)) diff --git a/cli/src/commands/use/workspace/use.test.ts b/cli/src/commands/use/workspace/use.test.ts index a26d1eba61..f8b177495e 100644 --- a/cli/src/commands/use/workspace/use.test.ts +++ b/cli/src/commands/use/workspace/use.test.ts @@ -2,8 +2,8 @@ import type { WorkspaceDetailResponse, WorkspaceListResponse, } from '@dify/contracts/api/openapi/types.gen' -import type { KyInstance } from 'ky' import type { HostsBundle } from '@/auth/hosts' +import type { HttpClient } from '@/http/types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -11,7 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { loadHosts, saveHosts } from '@/auth/hosts' import { ENV_CONFIG_DIR } from '@/store/dir' import { bufferStreams } from '@/sys/io/streams' -import { runUseWorkspace } from './use' +import { runUseWorkspace } from './use.js' function bundle(): HostsBundle { return { @@ -76,7 +76,7 @@ describe('runUseWorkspace', () => { { workspaceId: 'ws-2' }, { bundle: b, - http: {} as KyInstance, + http: {} as HttpClient, io, workspacesFactory: () => client as never, }, @@ -105,7 +105,7 @@ describe('runUseWorkspace', () => { await runUseWorkspace( { workspaceId: 'ws-2' }, - { bundle: b, http: {} as KyInstance, io, workspacesFactory: () => client as never }, + { bundle: b, http: {} as HttpClient, io, workspacesFactory: () => client as never }, ) const reloaded = loadHosts() @@ -128,7 +128,7 @@ describe('runUseWorkspace', () => { { workspaceId: 'ws-2' }, { bundle: b, - http: {} as KyInstance, + http: {} as HttpClient, io, workspacesFactory: () => client as never, }, @@ -156,7 +156,7 @@ describe('runUseWorkspace', () => { { workspaceId: 'ws-2' }, { bundle: b, - http: {} as KyInstance, + http: {} as HttpClient, io, workspacesFactory: () => client as never, }, @@ -193,7 +193,7 @@ describe('runUseWorkspace', () => { { workspaceId: 'ws-7' }, { bundle: b, - http: {} as KyInstance, + http: {} as HttpClient, io, workspacesFactory: () => client as never, }, diff --git a/cli/src/commands/use/workspace/use.ts b/cli/src/commands/use/workspace/use.ts index c847b71c71..ed979f80f1 100644 --- a/cli/src/commands/use/workspace/use.ts +++ b/cli/src/commands/use/workspace/use.ts @@ -1,5 +1,5 @@ -import type { KyInstance } from 'ky' import type { HostsBundle, Workspace } from '@/auth/hosts' +import type { HttpClient } from '@/http/types' import type { IOStreams } from '@/sys/io/streams' import { WorkspacesClient } from '@/api/workspaces' import { saveHosts } from '@/auth/hosts' @@ -14,9 +14,9 @@ export type UseWorkspaceOptions = { export type UseWorkspaceDeps = { readonly bundle: HostsBundle - readonly http: KyInstance + readonly http: HttpClient readonly io: IOStreams - readonly workspacesFactory?: (http: KyInstance) => WorkspacesClient + readonly workspacesFactory?: (http: HttpClient) => WorkspacesClient } /** @@ -38,7 +38,7 @@ export async function runUseWorkspace( deps: UseWorkspaceDeps, ): Promise<HostsBundle> { const cs = colorScheme(colorEnabled(deps.io.isErrTTY)) - const factory = deps.workspacesFactory ?? ((h: KyInstance) => new WorkspacesClient(h)) + const factory = deps.workspacesFactory ?? ((h: HttpClient) => new WorkspacesClient(h)) const client = factory(deps.http) const detail = await runWithSpinner( diff --git a/cli/src/http/body.test.ts b/cli/src/http/body.test.ts new file mode 100644 index 0000000000..e2f5c35dcf --- /dev/null +++ b/cli/src/http/body.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { buildBody, isJSONSerializable } from './body.js' + +describe('isJSONSerializable', () => { + it('rejects undefined', () => { + expect(isJSONSerializable(undefined)).toBe(false) + }) + + it('accepts null and primitives', () => { + expect(isJSONSerializable(null)).toBe(true) + expect(isJSONSerializable('')).toBe(true) + expect(isJSONSerializable(0)).toBe(true) + expect(isJSONSerializable(true)).toBe(true) + }) + + it('accepts plain objects and arrays', () => { + expect(isJSONSerializable({ a: 1 })).toBe(true) + expect(isJSONSerializable([])).toBe(true) + }) + + it('rejects FormData and URLSearchParams', () => { + expect(isJSONSerializable(new FormData())).toBe(false) + expect(isJSONSerializable(new URLSearchParams())).toBe(false) + }) + + it('accepts objects with a toJSON method', () => { + expect(isJSONSerializable({ toJSON: () => ({}) })).toBe(true) + }) + + it('rejects buffer-like objects', () => { + expect(isJSONSerializable({ buffer: new ArrayBuffer(1) })).toBe(false) + }) +}) + +describe('buildBody', () => { + it('returns no body for GET regardless of json/body input', () => { + expect(buildBody({ method: 'GET', json: { a: 1 } })).toEqual({ body: undefined, contentType: undefined }) + expect(buildBody({ method: 'GET', body: 'x' })).toEqual({ body: undefined, contentType: undefined }) + }) + + it('serializes json and sets Content-Type on payload methods', () => { + const { body, contentType } = buildBody({ method: 'POST', json: { a: 1 } }) + expect(body).toBe('{"a":1}') + expect(contentType).toBe('application/json') + }) + + it('passes raw body through without Content-Type injection', () => { + const form = new FormData() + const { body, contentType } = buildBody({ method: 'POST', body: form }) + expect(body).toBe(form) + expect(contentType).toBeUndefined() + }) + + it('prefers json over body when both are supplied', () => { + const { body, contentType } = buildBody({ method: 'POST', json: { a: 1 }, body: 'raw' }) + expect(body).toBe('{"a":1}') + expect(contentType).toBe('application/json') + }) + + it('returns null body when neither json nor body is supplied', () => { + expect(buildBody({ method: 'POST' })).toEqual({ body: undefined, contentType: undefined }) + }) +}) diff --git a/cli/src/http/body.ts b/cli/src/http/body.ts new file mode 100644 index 0000000000..485b88549b --- /dev/null +++ b/cli/src/http/body.ts @@ -0,0 +1,63 @@ +import type { BodyInit } from './types.js' + +// Reports whether a value should be JSON-stringified for the wire: primitives, +// plain objects, arrays, and anything with a `toJSON` method — but not typed +// arrays/buffers, FormData, or URLSearchParams, which are sent as-is. +export function isJSONSerializable(value: unknown): boolean { + if (value === undefined) + return false + if (value === null) + return true + const t = typeof value + if (t === 'string' || t === 'number' || t === 'boolean') + return true + if (t !== 'object') + return false + if (Array.isArray(value)) + return true + const obj = value as { buffer?: unknown, constructor?: { name?: string }, toJSON?: unknown } + if (obj.buffer !== undefined) + return false + if (value instanceof FormData || value instanceof URLSearchParams) + return false + if (obj.constructor?.name === 'Object') + return true + return typeof obj.toJSON === 'function' +} + +export type BuildBodyInput = { + readonly json?: unknown + readonly body?: BodyInit + readonly method: string +} + +export type BuildBodyResult = { + readonly body: BodyInit | undefined + readonly contentType: string | undefined +} + +// Decide the wire body and whether Content-Type should be injected. +// json wins over body when both are provided; tests assert single-source-of-truth via type system, +// but at runtime we still prefer json explicitly. +export function buildBody(input: BuildBodyInput): BuildBodyResult { + const { json, body, method } = input + const isPayloadMethod = method !== 'GET' && method !== 'HEAD' + + if (json !== undefined) { + if (!isPayloadMethod) + return { body: undefined, contentType: undefined } + if (isJSONSerializable(json)) + return { body: JSON.stringify(json), contentType: 'application/json' } + return { body: json as BodyInit, contentType: undefined } + } + + // A raw `body` is passed through untouched. This is replay-safe only for buffered + // payloads (string, Blob, FormData, typed arrays) — a single-shot ReadableStream + // would be consumed on the first attempt and replay empty on retry. Safe today + // because the only stream/multipart caller (file-upload) uses POST, which is not + // in RETRY_METHODS; revisit if a ReadableStream body is ever sent over GET/PUT/DELETE. + if (body !== undefined && isPayloadMethod) + return { body, contentType: undefined } + + return { body: undefined, contentType: undefined } +} diff --git a/cli/src/http/client.test.ts b/cli/src/http/client.test.ts index 6faa873a85..9d27d36578 100644 --- a/cli/src/http/client.test.ts +++ b/cli/src/http/client.test.ts @@ -1,9 +1,32 @@ import type { DifyMock } from '@test/fixtures/dify-mock/server' +import type { AddressInfo } from 'node:net' +import * as http from 'node:http' import { startMock } from '@test/fixtures/dify-mock/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { isBaseError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' -import { createClient } from './client' +import { openAPIBase } from '@/util/host' +import { createHttpClient } from './client.js' + +function base(mockUrl: string): string { + return openAPIBase(mockUrl) +} + +type Stub = { url: string, stop: () => Promise<void> } + +function startStub(handler: (req: http.IncomingMessage, res: http.ServerResponse) => void): Promise<Stub> { + return new Promise((resolve, reject) => { + const server = http.createServer(handler) + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as AddressInfo + resolve({ + url: `http://127.0.0.1:${addr.port}`, + stop: () => new Promise<void>((res, rej) => server.close(err => err ? rej(err) : res())), + }) + }) + server.on('error', reject) + }) +} describe('http client', () => { let mock: DifyMock @@ -16,27 +39,24 @@ describe('http client', () => { await mock.stop() }) - it('GET /workspaces returns parsed JSON when bearer is valid', async () => { - const client = createClient({ host: mock.url, bearer: 'dfoa_test' }) - const body = await client.get('workspaces').json<{ workspaces: unknown[] }>() + it('GET returns parsed JSON when bearer is valid', async () => { + const client = createHttpClient({ baseURL: base(mock.url), bearer: 'dfoa_test' }) + const body = await client.get<{ workspaces: unknown[] }>('workspaces') expect(body.workspaces).toHaveLength(2) }) it('omits Authorization header when bearer is undefined', async () => { let captured: string | null = null - const client = createClient({ - host: mock.url, + const client = createHttpClient({ + baseURL: base(mock.url), logger: () => undefined, bearer: undefined, + hooks: { + onRequest: ({ request }) => { captured = request.headers.get('authorization') }, + }, }) try { - await client.get('workspaces', { - hooks: { - beforeRequest: [ - ({ request }) => { captured = request.headers.get('authorization') }, - ], - }, - }).json() + await client.get('workspaces') } catch { // 401 expected because no bearer @@ -46,40 +66,54 @@ describe('http client', () => { it('sets Authorization header when bearer is provided', async () => { let captured: string | null = null - const client = createClient({ host: mock.url, bearer: 'dfoa_test' }) - await client.get('workspaces', { + const client = createHttpClient({ + baseURL: base(mock.url), + bearer: 'dfoa_test', hooks: { - beforeRequest: [ - ({ request }) => { captured = request.headers.get('authorization') }, - ], + onRequest: ({ request }) => { captured = request.headers.get('authorization') }, }, - }).json() + }) + await client.get('workspaces') expect(captured).toBe('Bearer dfoa_test') }) it('sets a User-Agent header in the difyctl format', async () => { let captured: string | null = null - const client = createClient({ - host: mock.url, + const client = createHttpClient({ + baseURL: base(mock.url), bearer: 'dfoa_test', userAgent: 'difyctl/0.0.0-test (test; arm64; dev)', - }) - await client.get('workspaces', { hooks: { - beforeRequest: [ - ({ request }) => { captured = request.headers.get('user-agent') }, - ], + onRequest: ({ request }) => { captured = request.headers.get('user-agent') }, }, - }).json() + }) + await client.get('workspaces') expect(captured).toBe('difyctl/0.0.0-test (test; arm64; dev)') }) + // Regression guard for F-2: every production createHttpClient call site omits + // `userAgent`, so the client itself must pin a difyctl-shaped default. Without + // it, requests leak out with Node's default UA and server-side telemetry / WAF + // rules lose the CLI version signal. + it('falls back to the difyctl default User-Agent when none is supplied', async () => { + let captured: string | null = null + const client = createHttpClient({ + baseURL: base(mock.url), + bearer: 'dfoa_test', + hooks: { + onRequest: ({ request }) => { captured = request.headers.get('user-agent') }, + }, + }) + await client.get('workspaces') + expect(captured).toMatch(/^difyctl\/\S+ \(.+; .+; .+\)$/) + }) + it('maps 401 to BaseError(auth_expired)', async () => { mock.setScenario('auth-expired') - const client = createClient({ host: mock.url, bearer: 'dfoa_test' }) + const client = createHttpClient({ baseURL: base(mock.url), bearer: 'dfoa_test' }) let caught: unknown try { - await client.get('workspaces').json() + await client.get('workspaces') } catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) @@ -93,15 +127,15 @@ describe('http client', () => { it('maps 5xx to BaseError(server_5xx) after retries', async () => { mock.setScenario('server-5xx') - const client = createClient({ - host: mock.url, + const client = createHttpClient({ + baseURL: base(mock.url), bearer: 'dfoa_test', retryAttempts: 1, timeoutMs: 5_000, }) let caught: unknown try { - await client.get('workspaces').json() + await client.get('workspaces') } catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) @@ -112,15 +146,15 @@ describe('http client', () => { }) it('maps DNS failure to BaseError(network_dns)', async () => { - const client = createClient({ - host: 'http://nonexistent-host-12345.invalid', + const client = createHttpClient({ + baseURL: base('http://nonexistent-host-12345.invalid'), bearer: 'dfoa_test', retryAttempts: 0, timeoutMs: 3_000, }) let caught: unknown try { - await client.get('workspaces').json() + await client.get('workspaces') } catch (err) { caught = err } expect(isBaseError(caught) || caught instanceof Error).toBe(true) @@ -128,29 +162,29 @@ describe('http client', () => { it('logger fires twice per successful request (request + response phases)', async () => { const events: { phase: string, status?: number }[] = [] - const client = createClient({ - host: mock.url, + const client = createHttpClient({ + baseURL: base(mock.url), bearer: 'dfoa_test', logger: e => events.push({ phase: e.phase, status: e.status }), }) - await client.get('workspaces').json() + await client.get('workspaces') expect(events).toHaveLength(2) expect(events[0]?.phase).toBe('request') expect(events[1]?.phase).toBe('response') expect(events[1]?.status).toBe(200) }) - it('respects insecure URL trim (no trailing slash collapses correctly)', async () => { - const client = createClient({ host: `${mock.url}/`, bearer: 'dfoa_test' }) - const body = await client.get('workspaces').json<{ workspaces: unknown[] }>() + it('respects insecure URL trim (trailing slash on baseURL is normalized)', async () => { + const client = createHttpClient({ baseURL: openAPIBase(`${mock.url}/`), bearer: 'dfoa_test' }) + const body = await client.get<{ workspaces: unknown[] }>('workspaces') expect(body.workspaces).toHaveLength(2) }) it('preserves error envelope hint when server returns one', async () => { - const client = createClient({ host: mock.url, bearer: 'dfoa_test' }) + const client = createHttpClient({ baseURL: base(mock.url), bearer: 'dfoa_test' }) let caught: unknown try { - await client.get('apps/nope/describe').json() + await client.get('apps/nope/describe') } catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) @@ -158,17 +192,17 @@ describe('http client', () => { expect(caught.code).toBe(ErrorCode.Server4xxOther) }) - it('handles 429 via retry status code list (eventual server-error class)', async () => { + it('handles 429 via retry status code list', async () => { mock.setScenario('rate-limited') - const client = createClient({ - host: mock.url, + const client = createHttpClient({ + baseURL: base(mock.url), bearer: 'dfoa_test', retryAttempts: 0, timeoutMs: 5_000, }) let caught: unknown try { - await client.get('workspaces').json() + await client.get('workspaces') } catch (err) { caught = err } expect(isBaseError(caught)).toBe(true) @@ -179,8 +213,8 @@ describe('http client', () => { it('does not retry POST on 503', async () => { mock.setScenario('server-5xx') let attempts = 0 - const client = createClient({ - host: mock.url, + const client = createHttpClient({ + baseURL: base(mock.url), bearer: 'dfoa_test', retryAttempts: 3, timeoutMs: 5_000, @@ -189,7 +223,7 @@ describe('http client', () => { attempts++ }, }) - await expect(client.post('apps/app-1/run', { json: { inputs: {}, response_mode: 'blocking' } }).json()) + await expect(client.post('apps/app-1/run', { json: { inputs: {}, response_mode: 'blocking' } })) .rejects .toBeDefined() expect(attempts).toBe(1) @@ -197,8 +231,8 @@ describe('http client', () => { it('does not retry POST on network error (method allowlist gates retry)', async () => { let attempts = 0 - const client = createClient({ - host: 'http://nonexistent-host-12345.invalid', + const client = createHttpClient({ + baseURL: base('http://nonexistent-host-12345.invalid'), bearer: 'dfoa_test', retryAttempts: 3, timeoutMs: 3_000, @@ -208,31 +242,38 @@ describe('http client', () => { }, }) await expect( - client.post('apps/app-1/run', { json: { inputs: {}, response_mode: 'blocking' } }).json(), + client.post('apps/app-1/run', { json: { inputs: {}, response_mode: 'blocking' } }), ).rejects.toBeDefined() expect(attempts).toBe(1) }) it('retries GET on network error up to retryAttempts', async () => { - let attempts = 0 - const client = createClient({ - host: 'http://nonexistent-host-12345.invalid', + // New semantics: every attempt emits a 'request' event (logRequest runs in onRequest + // on each recursive dispatch), plus one 'retry' event per retry decision. + // retryAttempts=2 => 3 attempts => 3 requests + 2 retries = 5 events. + let requests = 0 + let retries = 0 + const client = createHttpClient({ + baseURL: base('http://nonexistent-host-12345.invalid'), bearer: 'dfoa_test', retryAttempts: 2, timeoutMs: 3_000, logger: (e) => { - if (e.phase === 'request' || e.phase === 'retry') - attempts++ + if (e.phase === 'request') + requests++ + else if (e.phase === 'retry') + retries++ }, }) - await expect(client.get('workspaces').json()).rejects.toBeDefined() - expect(attempts).toBe(3) + await expect(client.get('workspaces')).rejects.toBeDefined() + expect(requests).toBe(3) + expect(retries).toBe(2) }, 30_000) it('does not retry PATCH on network error (method allowlist gates retry)', async () => { let attempts = 0 - const client = createClient({ - host: 'http://nonexistent-host-12345.invalid', + const client = createHttpClient({ + baseURL: base('http://nonexistent-host-12345.invalid'), bearer: 'dfoa_test', retryAttempts: 3, timeoutMs: 3_000, @@ -241,24 +282,50 @@ describe('http client', () => { attempts++ }, }) - await expect( - client.patch('workspaces', { json: {} }).json(), - ).rejects.toBeDefined() + await expect(client.patch('workspaces', { json: {} })).rejects.toBeDefined() expect(attempts).toBe(1) }) }) +describe('empty / No-Content bodies', () => { + it('204 response resolves to undefined instead of throwing', async () => { + const stub = await startStub((_req, res) => { + res.writeHead(204).end() + }) + try { + const client = createHttpClient({ baseURL: stub.url, bearer: 'dfoa_test' }) + await expect(client.delete('account/sessions/abc')).resolves.toBeUndefined() + } + finally { + await stub.stop() + } + }) + + it('empty 200 body resolves to undefined instead of throwing', async () => { + const stub = await startStub((_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }).end() + }) + try { + const client = createHttpClient({ baseURL: stub.url, bearer: 'dfoa_test' }) + await expect(client.post('apps/app-1/tasks/t-1/stop', { json: {} })).resolves.toBeUndefined() + } + finally { + await stub.stop() + } + }) +}) + describe('classifyResponse internals', () => { - it('strips Bearer from logged URLs (sanity check via vi.fn logger)', async () => { + it('strips Bearer from logged URLs', async () => { const mock = await startMock() try { const logger = vi.fn() - const client = createClient({ - host: mock.url, + const client = createHttpClient({ + baseURL: openAPIBase(mock.url), bearer: 'dfoa_should_not_log', logger, }) - await client.get('workspaces').json() + await client.get('workspaces') const calls = logger.mock.calls.map(c => c[0]) for (const event of calls) expect(JSON.stringify(event)).not.toContain('dfoa_should_not_log') @@ -268,3 +335,259 @@ describe('classifyResponse internals', () => { } }) }) + +describe('extend()', () => { + let mock: DifyMock + beforeEach(async () => { + mock = await startMock() + }) + afterEach(async () => { + await mock.stop() + }) + + it('inherits bearer, userAgent, and logger from parent', async () => { + const events: { phase: string }[] = [] + const parent = createHttpClient({ + baseURL: openAPIBase(mock.url), + bearer: 'dfoa_test', + userAgent: 'difyctl/parent', + logger: e => events.push({ phase: e.phase }), + }) + let captured: { auth?: string | null, ua?: string | null } = {} + const child = parent.extend({ + hooks: { + onRequest: ({ request }) => { + captured = { + auth: request.headers.get('authorization'), + ua: request.headers.get('user-agent'), + } + }, + }, + }) + await child.get('workspaces') + expect(captured.auth).toBe('Bearer dfoa_test') + expect(captured.ua).toBe('difyctl/parent') + expect(events.length).toBeGreaterThan(0) + }) + + it('drops log hooks when extended with logger: undefined', async () => { + const events: unknown[] = [] + const parent = createHttpClient({ + baseURL: openAPIBase(mock.url), + bearer: 'dfoa_test', + logger: e => events.push(e), + }) + const silent = parent.extend({ logger: undefined }) + await silent.get('workspaces') + expect(events).toHaveLength(0) + }) + + it('per-call timeoutMs override beats client default', async () => { + const parent = createHttpClient({ baseURL: openAPIBase(mock.url), bearer: 'dfoa_test', timeoutMs: 1 }) + // 1ms client default would always fail the mock GET; per-call override of 5s lets it succeed. + const body = await parent.get<{ workspaces: unknown[] }>('workspaces', { timeoutMs: 5_000 }) + expect(body.workspaces.length).toBeGreaterThan(0) + }) +}) + +describe('fetch() and stream()', () => { + let mock: DifyMock + beforeEach(async () => { + mock = await startMock() + }) + afterEach(async () => { + await mock.stop() + }) + + it('fetch() returns raw Response and does NOT throw on 4xx', async () => { + mock.setScenario('auth-expired') + const client = createHttpClient({ baseURL: openAPIBase(mock.url), bearer: 'dfoa_test' }) + const res = await client.fetch('workspaces') + expect(res.status).toBe(401) + expect(res.ok).toBe(false) + }) + + it('throwOnError: true on .fetch() opts in to classification', async () => { + mock.setScenario('auth-expired') + const client = createHttpClient({ baseURL: openAPIBase(mock.url), bearer: 'dfoa_test' }) + await expect(client.fetch('workspaces', { throwOnError: true })).rejects.toBeDefined() + }) + + it('stream() bypasses the client-default timeout so SSE bodies stay open', async () => { + let stub: Stub | undefined + try { + stub = await startStub((_req, res) => { + setTimeout(() => { + res.writeHead(200, { 'content-type': 'text/event-stream' }) + res.end('data: ok\n\n') + }, 200) + }) + const client = createHttpClient({ + baseURL: openAPIBase(stub.url), + bearer: 'dfoa_test', + timeoutMs: 50, // would abort .get(); stream() must ignore it + retryAttempts: 0, + }) + const res = await client.stream('apps/app-1/run', { method: 'POST', json: {} }) + expect(res.ok).toBe(true) + } + finally { + await stub?.stop() + } + }) + + it('stream() forces retryAttempts=0 even when client default would allow retries', async () => { + let attempts = 0 + const client = createHttpClient({ + baseURL: openAPIBase('http://nonexistent-host-12345.invalid'), + bearer: 'dfoa_test', + retryAttempts: 5, + timeoutMs: 0, + logger: (e) => { + if (e.phase === 'request' || e.phase === 'retry') + attempts++ + }, + }) + await expect(client.stream('workspaces')).rejects.toBeDefined() + expect(attempts).toBe(1) + }) +}) + +describe('timeout + abort retry policy', () => { + it('does not retry POST on timeout (method allowlist gates timeout retries)', async () => { + let attempts = 0 + let stub: Stub | undefined + try { + stub = await startStub(() => { + attempts++ + // Never respond — let the client timeout abort. + }) + const client = createHttpClient({ + baseURL: openAPIBase(stub.url), + bearer: 'dfoa_test', + retryAttempts: 3, + timeoutMs: 100, + }) + await expect(client.post('apps/app-1/run', { json: {} })).rejects.toBeDefined() + expect(attempts).toBe(1) + } + finally { + await stub?.stop() + } + }) + + it('retries GET on timeout up to retryAttempts', async () => { + let attempts = 0 + let stub: Stub | undefined + try { + stub = await startStub(() => { + attempts++ + // Never respond. + }) + const client = createHttpClient({ + baseURL: openAPIBase(stub.url), + bearer: 'dfoa_test', + retryAttempts: 2, + timeoutMs: 100, + }) + await expect(client.get('workspaces')).rejects.toBeDefined() + expect(attempts).toBe(3) // initial + 2 retries + } + finally { + await stub?.stop() + } + }) + + it('does not retry GET on user-initiated abort', async () => { + let attempts = 0 + let stub: Stub | undefined + try { + stub = await startStub(() => { + attempts++ + // Never respond — caller will abort. + }) + const ac = new AbortController() + const client = createHttpClient({ + baseURL: openAPIBase(stub.url), + bearer: 'dfoa_test', + retryAttempts: 3, + timeoutMs: 5_000, + }) + const pending = client.get('workspaces', { signal: ac.signal }) + setTimeout(() => ac.abort(), 50) + await expect(pending).rejects.toBeDefined() + expect(attempts).toBe(1) + } + finally { + await stub?.stop() + } + }) +}) + +describe('hook semantics', () => { + let mock: DifyMock + beforeEach(async () => { + mock = await startMock() + }) + afterEach(async () => { + await mock.stop() + }) + + it('onRequest hooks see each other\'s mutations via shared ctx.request', async () => { + let observed: string | null = null + const client = createHttpClient({ + baseURL: openAPIBase(mock.url), + bearer: 'dfoa_test', + hooks: { + onRequest: [ + ({ request }) => { + request.headers.set('x-trace', 'hooked') + }, + ({ request }) => { + observed = request.headers.get('x-trace') + }, + ], + }, + }) + await client.get('workspaces') + expect(observed).toBe('hooked') + }) + + it('onResponse hook throw propagates immediately and does NOT enter onResponseError', async () => { + let onResponseErrorRan = false + const client = createHttpClient({ + baseURL: openAPIBase(mock.url), + bearer: 'dfoa_test', + hooks: { + onResponse: () => { + throw new Error('hook boom') + }, + onResponseError: () => { + onResponseErrorRan = true + }, + }, + }) + await expect(client.get('workspaces')).rejects.toThrow('hook boom') + expect(onResponseErrorRan).toBe(false) + }) + + // Symmetric to the onResponse-throws test above: a throw inside an + // onResponseError hook (the !res.ok branch) must propagate out of dispatch + // verbatim, replacing the classified BaseError. dispatch has no try/catch + // around the hook chain; this pins that intent so a future "swallow hook + // errors" change can't slip through. + it('onResponseError hook throw propagates and replaces the classified BaseError', async () => { + mock.setScenario('auth-expired') + const client = createHttpClient({ + baseURL: openAPIBase(mock.url), + bearer: 'dfoa_test', + retryAttempts: 0, + hooks: { + onResponseError: () => { + throw new Error('hook boom on error') + }, + }, + }) + await expect(client.get('workspaces')).rejects.toThrow('hook boom on error') + }) +}) diff --git a/cli/src/http/client.ts b/cli/src/http/client.ts index 2637870b0a..99459ea7b0 100644 --- a/cli/src/http/client.ts +++ b/cli/src/http/client.ts @@ -1,63 +1,244 @@ -import type { AfterResponseHook, BeforeErrorHook, KyInstance } from 'ky' -import type { HttpFactoryOptions, HttpLogger } from './types' -import ky from 'ky' -import { BaseError } from '@/errors/base' -import { applyBearer } from '@/http/middleware/auth' -import { logBeforeRequest, logBeforeRetry } from '@/http/middleware/request-logger' -import { applyUserAgent } from '@/http/middleware/user-agent' +import type { + ClientOptions, + FetchContext, + HeadersInit, + Hook, + HttpClient, + HttpLogger, + HttpMethod, + RequestOptions, + ResolvedOptions, +} from './types.js' import { userAgent as defaultUserAgent } from '@/version/info' -import { classifyResponse, classifyTransportError } from './error-mapper' -import { redactBearer } from './sanitize' +import { buildBody } from './body.js' +import { classifyResponse } from './error-mapper.js' +import { classifyTransport, logRequest, logResponse, setBearer, setUserAgent } from './hooks.js' +import { proxyDispatcher } from './proxy.js' +import { backoffDelay, shouldRetry } from './retry.js' +import { redactBearer } from './sanitize.js' +import { appendSearchParams, joinURL } from './url.js' export const DEFAULT_TIMEOUT_MS = 30_000 export const DEFAULT_RETRY_ATTEMPTS = 3 -function trimSlash(s: string): string { - return s.endsWith('/') ? s.slice(0, -1) : s +type ResolvedHooks = { + readonly onRequest: Hook[] + readonly onResponse: Hook[] + readonly onRequestError: Hook[] + readonly onResponseError: Hook[] } -function logAndClassify(logger: HttpLogger | undefined): AfterResponseHook { - return async ({ request, response, options }) => { - if (logger !== undefined) { - logger({ - phase: 'response', - method: request.method, - url: redactBearer(request.url), - status: response.status, - }) - } - if (!response.ok && options.context?.skipClassify !== true) - throw await classifyResponse(request, response) - return response +type ClientState = { + readonly baseURL: string + readonly defaultTimeoutMs: number | undefined + readonly defaultRetryAttempts: number + readonly hooks: ResolvedHooks + readonly logger: HttpLogger | undefined + readonly originalOptions: ClientOptions + readonly dispatcher: ReturnType<typeof proxyDispatcher> +} + +function toArray<T>(value: T | T[] | undefined): T[] { + if (value === undefined) + return [] + return Array.isArray(value) ? value : [value] +} + +function compileState(opts: ClientOptions): ClientState { + const onRequest: Hook[] = [] + const onResponse: Hook[] = [] + const onRequestError: Hook[] = [classifyTransport] + const onResponseError: Hook[] = [] + + // Always pin a difyctl-shaped UA so server logs / WAF rules see the CLI's + // version + platform. Callers can override by passing `userAgent` explicitly. + onRequest.push(setUserAgent(opts.userAgent ?? defaultUserAgent())) + if (opts.bearer !== undefined && opts.bearer !== '') + onRequest.push(setBearer(opts.bearer)) + if (opts.logger !== undefined) { + onRequest.push(logRequest(opts.logger)) + onResponse.push(logResponse(opts.logger)) + } + + onRequest.push(...toArray(opts.hooks?.onRequest)) + onResponse.push(...toArray(opts.hooks?.onResponse)) + onRequestError.push(...toArray(opts.hooks?.onRequestError)) + onResponseError.push(...toArray(opts.hooks?.onResponseError)) + + return { + baseURL: opts.baseURL, + defaultTimeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, + defaultRetryAttempts: opts.retryAttempts ?? DEFAULT_RETRY_ATTEMPTS, + hooks: { onRequest, onResponse, onRequestError, onResponseError }, + logger: opts.logger, + originalOptions: opts, + dispatcher: proxyDispatcher(), } } -const mapTransportError: BeforeErrorHook = ({ error }) => { - if (error instanceof BaseError) - return error - return classifyTransportError(error) +async function runHooks(hooks: readonly Hook[], ctx: FetchContext): Promise<void> { + for (const hook of hooks) + await hook(ctx) } -export function createClient(opts: HttpFactoryOptions): KyInstance { - const ua = opts.userAgent ?? defaultUserAgent() - return ky.create({ - prefix: `${trimSlash(opts.host)}/openapi/v1/`, - timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, - retry: { - limit: opts.retryAttempts ?? DEFAULT_RETRY_ATTEMPTS, - methods: ['get', 'put', 'delete'], - statusCodes: [408, 413, 429, 500, 502, 503, 504], - }, - throwHttpErrors: false, - hooks: { - beforeRequest: [ - applyUserAgent(ua), - applyBearer(opts.bearer), - logBeforeRequest(opts.logger), - ], - afterResponse: [logAndClassify(opts.logger)], - beforeRetry: [logBeforeRetry(opts.logger)], - beforeError: [mapTransportError], - }, - }) +function buildSignal(opts: RequestOptions, effectiveTimeoutMs: number | undefined): AbortSignal | undefined { + const timeoutSignal = effectiveTimeoutMs !== undefined && effectiveTimeoutMs > 0 + ? AbortSignal.timeout(effectiveTimeoutMs) + : undefined + const userSignal = opts.signal + + if (timeoutSignal === undefined) + return userSignal + if (userSignal === undefined) + return timeoutSignal + return AbortSignal.any([timeoutSignal, userSignal]) +} + +function mergeHeaders(input: HeadersInit | undefined, contentType: string | undefined): Headers { + const headers = new Headers(input ?? {}) + if (contentType !== undefined && !headers.has('content-type')) + headers.set('content-type', contentType) + return headers +} + +async function dispatch(state: ClientState, path: string, opts: RequestOptions, attempt: number, throwOnErrorDefault: boolean): Promise<Response> { + const method: HttpMethod = opts.method ?? 'GET' + const effectiveTimeoutMs = opts.timeoutMs !== undefined + ? (opts.timeoutMs > 0 ? opts.timeoutMs : undefined) + : state.defaultTimeoutMs + const effectiveRetryAttempts = opts.retryAttempts ?? state.defaultRetryAttempts + const throwOnError = opts.throwOnError ?? throwOnErrorDefault + + const { body, contentType } = buildBody({ json: opts.json, body: opts.body, method }) + const headers = mergeHeaders(opts.headers, contentType) + const url = appendSearchParams(joinURL(state.baseURL, path), opts.searchParams) + + const signal = buildSignal(opts, effectiveTimeoutMs) + + const request = new Request(url, { method, headers, body, signal }) + const resolved: ResolvedOptions = { + method, + headers, + body, + timeoutMs: effectiveTimeoutMs, + retryAttempts: effectiveRetryAttempts, + throwOnError, + } + const ctx: FetchContext = { + request, + options: resolved, + attempt, + meta: new Map(), + } + + await runHooks(state.hooks.onRequest, ctx) + + // `dispatcher` is an undici extension to RequestInit, not in @types/node's fetch + // signature — hence the local type. Carries proxy routing when a proxy env var is set. + const init: RequestInit & { dispatcher?: unknown } = { signal } + if (state.dispatcher !== undefined) + init.dispatcher = state.dispatcher + + try { + ctx.response = await fetch(ctx.request, init) + } + catch (err) { + ctx.error = err + // Snapshot the abort cause before onRequestError hooks rewrite ctx.error into BaseError. + const userAborted = opts.signal?.aborted === true + await runHooks(state.hooks.onRequestError, ctx) + + // User aborts (ctrl+C) must never retry. Timeouts and other transport errors fall + // through to shouldRetry, which enforces the method allowlist. + if (!userAborted && attempt < effectiveRetryAttempts && shouldRetry(ctx.error, ctx)) { + state.logger?.({ phase: 'retry', method, url: redactBearer(request.url), attempt: attempt + 1 }) + const delay = backoffDelay(attempt + 1) + if (delay > 0) + await new Promise(resolve => setTimeout(resolve, delay)) + return dispatch(state, path, opts, attempt + 1, throwOnErrorDefault) + } + + const finalErr = ctx.error + if (finalErr instanceof Error && typeof Error.captureStackTrace === 'function') + Error.captureStackTrace(finalErr, dispatch) + throw finalErr + } + + await runHooks(state.hooks.onResponse, ctx) + + const res = ctx.response + if (!res.ok) { + if (attempt < effectiveRetryAttempts && shouldRetry(res, ctx)) { + state.logger?.({ phase: 'retry', method, url: redactBearer(request.url), attempt: attempt + 1 }) + // Drain the discarded error body so undici can release the socket back to its + // pool instead of holding the connection open until keep-alive timeout / GC. + await res.body?.cancel().catch(() => {}) + const delay = backoffDelay(attempt + 1) + if (delay > 0) + await new Promise(resolve => setTimeout(resolve, delay)) + return dispatch(state, path, opts, attempt + 1, throwOnErrorDefault) + } + + ctx.error = await classifyResponse(request, res) + await runHooks(state.hooks.onResponseError, ctx) + + if (throwOnError) { + const finalErr = ctx.error + if (finalErr instanceof Error && typeof Error.captureStackTrace === 'function') + Error.captureStackTrace(finalErr, dispatch) + throw finalErr + } + } + + return res +} + +// 204/205 and empty 2xx bodies carry no JSON. Resolve to `undefined` instead of +// letting `res.json()` throw an unclassified SyntaxError, so void-returning callers +// (revoke, stopTask, …) stay safe when a server replies with No Content. +async function parseJsonBody<T>(res: Response): Promise<T> { + if (res.status === 204 || res.status === 205) + return undefined as T + const text = await res.text() + return (text === '' ? undefined : JSON.parse(text)) as T +} + +export function createHttpClient(opts: ClientOptions): HttpClient { + const state = compileState(opts) + + const typedCall = async <T>(method: HttpMethod, path: string, callOpts?: RequestOptions): Promise<T> => { + const finalOpts: RequestOptions = { ...callOpts, method } + const res = await dispatch(state, path, finalOpts, 0, true) + return parseJsonBody<T>(res) + } + + const rawFetch = (path: string, callOpts?: RequestOptions): Promise<Response> => { + const finalOpts: RequestOptions = { ...callOpts, method: callOpts?.method ?? 'GET' } + return dispatch(state, path, finalOpts, 0, false) + } + + const streamFetch = (path: string, callOpts?: RequestOptions): Promise<Response> => { + // SSE bodies must not be aborted by a request-level timeout — `0` is the dispatch + // sentinel for "no timeout" and also overrides the client default. + const finalOpts: RequestOptions = { + ...callOpts, + method: callOpts?.method ?? 'GET', + retryAttempts: 0, + timeoutMs: 0, + } + return dispatch(state, path, finalOpts, 0, false) + } + + const extend = (overrides: Partial<ClientOptions>): HttpClient => createHttpClient({ ...state.originalOptions, ...overrides }) + + return { + get: <T>(p: string, o?: RequestOptions) => typedCall<T>('GET', p, o), + post: <T>(p: string, o?: RequestOptions) => typedCall<T>('POST', p, o), + put: <T>(p: string, o?: RequestOptions) => typedCall<T>('PUT', p, o), + patch: <T>(p: string, o?: RequestOptions) => typedCall<T>('PATCH', p, o), + delete: <T>(p: string, o?: RequestOptions) => typedCall<T>('DELETE', p, o), + fetch: rawFetch, + stream: streamFetch, + extend, + } } diff --git a/cli/src/http/hooks.ts b/cli/src/http/hooks.ts new file mode 100644 index 0000000000..d08d2c24b8 --- /dev/null +++ b/cli/src/http/hooks.ts @@ -0,0 +1,55 @@ +import type { Hook, HttpLogger } from './types.js' +import { BaseError } from '@/errors/base' +import { classifyTransportError } from './error-mapper.js' +import { redactBearer } from './sanitize.js' + +export const HTTP_START_SYM = Symbol('difyctl-http-start') + +export function setBearer(token: string): Hook { + return ({ request }) => { + if (!request.headers.has('authorization')) + request.headers.set('authorization', `Bearer ${token}`) + } +} + +export function setUserAgent(ua: string): Hook { + return ({ request }) => { + if (!request.headers.has('user-agent')) + request.headers.set('user-agent', ua) + } +} + +export function logRequest(logger: HttpLogger): Hook { + return ({ request, meta }) => { + meta.set(HTTP_START_SYM, performance.now()) + logger({ + phase: 'request', + method: request.method, + url: redactBearer(request.url), + }) + } +} + +export function logResponse(logger: HttpLogger): Hook { + return ({ request, response, meta }) => { + if (response === undefined) + return + const start = meta.get(HTTP_START_SYM) + const durationMs = typeof start === 'number' ? performance.now() - start : undefined + logger({ + phase: 'response', + method: request.method, + url: redactBearer(request.url), + status: response.status, + durationMs, + }) + } +} + +export const classifyTransport: Hook = (ctx) => { + if (ctx.error === undefined) + return + if (ctx.error instanceof BaseError) + return + ctx.error = classifyTransportError(ctx.error) +} diff --git a/cli/src/http/middleware/auth.ts b/cli/src/http/middleware/auth.ts deleted file mode 100644 index c9abace468..0000000000 --- a/cli/src/http/middleware/auth.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { BeforeRequestHook } from 'ky' - -export function applyBearer(token: string | undefined): BeforeRequestHook { - return ({ request }) => { - if (token === undefined || token === '') - return - if (!request.headers.has('authorization')) - request.headers.set('authorization', `Bearer ${token}`) - } -} diff --git a/cli/src/http/middleware/request-logger.ts b/cli/src/http/middleware/request-logger.ts deleted file mode 100644 index c7eb1c1991..0000000000 --- a/cli/src/http/middleware/request-logger.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { BeforeRequestHook, BeforeRetryHook } from 'ky' -import type { HttpLogger } from '@/http/types' -import { redactBearer } from '@/http/sanitize' - -const START_TIME = Symbol('difyctl-http-start') - -type Timed = { [START_TIME]?: number } - -export function logBeforeRequest(logger: HttpLogger | undefined): BeforeRequestHook { - if (logger === undefined) - return () => undefined - return ({ request }) => { - const safeUrl = redactBearer(request.url) - ;(request as unknown as Timed)[START_TIME] = performance.now() - logger({ phase: 'request', method: request.method, url: safeUrl }) - } -} - -export function logBeforeRetry(logger: HttpLogger | undefined): BeforeRetryHook { - if (logger === undefined) - return () => undefined - return ({ request, retryCount }) => { - logger({ - phase: 'retry', - method: request.method, - url: redactBearer(request.url), - attempt: retryCount, - }) - } -} diff --git a/cli/src/http/middleware/user-agent.ts b/cli/src/http/middleware/user-agent.ts deleted file mode 100644 index a6ab540924..0000000000 --- a/cli/src/http/middleware/user-agent.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { BeforeRequestHook } from 'ky' - -export function applyUserAgent(value: string): BeforeRequestHook { - return ({ request }) => { - if (!request.headers.has('user-agent')) - request.headers.set('user-agent', value) - } -} diff --git a/cli/src/http/proxy.test.ts b/cli/src/http/proxy.test.ts new file mode 100644 index 0000000000..80b36d0f29 --- /dev/null +++ b/cli/src/http/proxy.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const PROXY_KEYS = ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy', 'NO_PROXY', 'no_proxy'] as const + +describe('proxyDispatcher', () => { + const saved = new Map<string, string | undefined>() + + beforeEach(() => { + for (const k of PROXY_KEYS) { + saved.set(k, process.env[k]) + delete process.env[k] + } + // proxyDispatcher memoizes per module instance; reset so each case re-resolves. + vi.resetModules() + }) + + afterEach(() => { + for (const k of PROXY_KEYS) { + const v = saved.get(k) + if (v === undefined) + delete process.env[k] + else + process.env[k] = v + } + }) + + it('returns undefined and reports no proxy when env is clean', async () => { + const { proxyDispatcher, hasProxyEnv } = await import('./proxy.js') + expect(hasProxyEnv()).toBe(false) + expect(proxyDispatcher()).toBeUndefined() + }) + + it('builds an EnvHttpProxyAgent when HTTP_PROXY is set', async () => { + process.env.HTTP_PROXY = 'http://127.0.0.1:8888' + const { proxyDispatcher, hasProxyEnv } = await import('./proxy.js') + expect(hasProxyEnv()).toBe(true) + const d = proxyDispatcher() + expect(d?.constructor.name).toBe('EnvHttpProxyAgent') + await d?.close() + }) + + it('detects the lowercase https_proxy variant', async () => { + process.env.https_proxy = 'http://127.0.0.1:8888' + const { hasProxyEnv } = await import('./proxy.js') + expect(hasProxyEnv()).toBe(true) + }) + + it('memoizes the resolved dispatcher across calls', async () => { + process.env.HTTPS_PROXY = 'http://127.0.0.1:8888' + const { proxyDispatcher } = await import('./proxy.js') + const first = proxyDispatcher() + expect(proxyDispatcher()).toBe(first) + await first?.close() + }) +}) diff --git a/cli/src/http/proxy.ts b/cli/src/http/proxy.ts new file mode 100644 index 0000000000..5e58936f91 --- /dev/null +++ b/cli/src/http/proxy.ts @@ -0,0 +1,23 @@ +import { EnvHttpProxyAgent } from 'undici' + +const PROXY_ENV_KEYS = ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy'] as const + +export function hasProxyEnv(): boolean { + return PROXY_ENV_KEYS.some(k => (process.env[k] ?? '') !== '') +} + +let resolved = false +let agent: EnvHttpProxyAgent | undefined + +// Node's global fetch ignores HTTP_PROXY / HTTPS_PROXY / NO_PROXY. When a proxy +// var is set, route requests through an EnvHttpProxyAgent (it also reads the +// lowercase variants and honours NO_PROXY); when none is set, return undefined so +// fetch keeps Node's default global dispatcher untouched. Resolved once per +// process — proxy env vars are fixed for a single CLI invocation. +export function proxyDispatcher(): EnvHttpProxyAgent | undefined { + if (!resolved) { + agent = hasProxyEnv() ? new EnvHttpProxyAgent() : undefined + resolved = true + } + return agent +} diff --git a/cli/src/http/retry.test.ts b/cli/src/http/retry.test.ts new file mode 100644 index 0000000000..83e4d4c996 --- /dev/null +++ b/cli/src/http/retry.test.ts @@ -0,0 +1,69 @@ +import type { FetchContext, HttpMethod, ResolvedOptions } from './types.js' +import { describe, expect, it } from 'vitest' +import { backoffDelay, shouldRetry } from './retry.js' + +function ctxFor(method: HttpMethod): FetchContext { + const options: ResolvedOptions = { + method, + headers: new Headers(), + body: undefined, + timeoutMs: undefined, + retryAttempts: 0, + throwOnError: true, + } + return { + request: new Request('https://x/y', { method }), + options, + attempt: 0, + meta: new Map(), + } +} + +describe('shouldRetry', () => { + it('retries retryable status codes on GET', () => { + const res = new Response(null, { status: 503 }) + expect(shouldRetry(res, ctxFor('GET'))).toBe(true) + }) + + it('does not retry non-retryable status codes on GET', () => { + const res = new Response(null, { status: 404 }) + expect(shouldRetry(res, ctxFor('GET'))).toBe(false) + }) + + it('does not retry POST regardless of status', () => { + const res = new Response(null, { status: 503 }) + expect(shouldRetry(res, ctxFor('POST'))).toBe(false) + }) + + it('does not retry PATCH regardless of status', () => { + const res = new Response(null, { status: 503 }) + expect(shouldRetry(res, ctxFor('PATCH'))).toBe(false) + }) + + it('retries transport errors on retryable methods', () => { + expect(shouldRetry(new Error('econnreset'), ctxFor('GET'))).toBe(true) + expect(shouldRetry(new Error('econnreset'), ctxFor('PUT'))).toBe(true) + }) + + it('does not retry transport errors on non-retryable methods', () => { + expect(shouldRetry(new Error('econnreset'), ctxFor('POST'))).toBe(false) + }) +}) + +describe('backoffDelay', () => { + it('returns 0 for attempts <= 0', () => { + expect(backoffDelay(0)).toBe(0) + expect(backoffDelay(-1)).toBe(0) + }) + + it('grows exponentially from a 300 ms base', () => { + expect(backoffDelay(1)).toBe(300) + expect(backoffDelay(2)).toBe(600) + expect(backoffDelay(3)).toBe(1200) + expect(backoffDelay(4)).toBe(2400) + }) + + it('caps at 30 s', () => { + expect(backoffDelay(20)).toBe(30_000) + }) +}) diff --git a/cli/src/http/retry.ts b/cli/src/http/retry.ts new file mode 100644 index 0000000000..456bf32166 --- /dev/null +++ b/cli/src/http/retry.ts @@ -0,0 +1,29 @@ +import type { FetchContext } from './types.js' + +export const RETRY_METHODS = ['GET', 'PUT', 'DELETE'] as const +export const RETRY_STATUS_CODES = [408, 413, 429, 500, 502, 503, 504] as const + +const RETRY_METHODS_SET: ReadonlySet<string> = new Set(RETRY_METHODS) +const RETRY_STATUS_SET: ReadonlySet<number> = new Set(RETRY_STATUS_CODES) + +export function shouldRetry(target: Response | unknown, ctx: FetchContext): boolean { + if (!RETRY_METHODS_SET.has(ctx.options.method)) + return false + if (target instanceof Response) + return RETRY_STATUS_SET.has(target.status) + // Any other transport error on a retryable method retries. User aborts are filtered + // out earlier in dispatch (before this hook ever runs), so they never reach here. + return true +} + +// Exponential backoff: 300ms base, doubling each attempt, capped at 30s +// (300ms, 600ms, 1.2s, ...). +const BACKOFF_BASE_MS = 300 +const BACKOFF_CAP_MS = 30_000 + +export function backoffDelay(attempt: number): number { + if (attempt <= 0) + return 0 + const exp = BACKOFF_BASE_MS * 2 ** (attempt - 1) + return Math.min(exp, BACKOFF_CAP_MS) +} diff --git a/cli/src/http/types.ts b/cli/src/http/types.ts index c83749acb0..a702492d23 100644 --- a/cli/src/http/types.ts +++ b/cli/src/http/types.ts @@ -11,11 +11,74 @@ export type HttpLogEvent = { export type HttpLogger = (event: HttpLogEvent) => void -export type HttpFactoryOptions = { - readonly host: string - readonly bearer?: string +export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + +export type SearchParamValue = string | number | boolean | undefined + +// Local equivalents of the DOM-named union types — globals exist (Request, Response, Headers, Blob, …) +// but the union aliases BodyInit / HeadersInit are not exposed by @types/node. Matching the shapes +// the global Request/Response constructors accept; ArrayBufferView is omitted because @types/node's +// RequestInit is stricter and only accepts DataView, which `Uint8Array` covers for our byte-buffer +// callers. +export type HeadersInit = Headers | [string, string][] | Record<string, string> +export type BodyInit = string | Blob | ArrayBuffer | FormData | URLSearchParams | ReadableStream<Uint8Array> | Uint8Array + +export type FetchContext = { + request: Request + readonly options: ResolvedOptions + response?: Response + error?: unknown + attempt: number + readonly meta: Map<string | symbol, unknown> +} + +export type Hook = (ctx: FetchContext) => void | Promise<void> + +export type Hooks = { + readonly onRequest?: Hook | Hook[] + readonly onResponse?: Hook | Hook[] + readonly onRequestError?: Hook | Hook[] + readonly onResponseError?: Hook | Hook[] +} + +export type RequestOptions = { + readonly method?: HttpMethod + readonly headers?: HeadersInit + readonly json?: unknown + readonly body?: BodyInit + readonly searchParams?: Record<string, SearchParamValue> readonly timeoutMs?: number readonly retryAttempts?: number - readonly userAgent?: string - readonly logger?: HttpLogger + readonly signal?: AbortSignal + readonly throwOnError?: boolean +} + +export type ResolvedOptions = { + readonly method: HttpMethod + readonly headers: Headers + readonly body: BodyInit | undefined + readonly timeoutMs: number | undefined + readonly retryAttempts: number + readonly throwOnError: boolean +} + +export type ClientOptions = { + readonly baseURL: string + readonly bearer?: string + readonly userAgent?: string + readonly timeoutMs?: number + readonly retryAttempts?: number + readonly logger?: HttpLogger + readonly hooks?: Hooks +} + +export type HttpClient = { + readonly get: <T>(path: string, opts?: RequestOptions) => Promise<T> + readonly post: <T>(path: string, opts?: RequestOptions) => Promise<T> + readonly put: <T>(path: string, opts?: RequestOptions) => Promise<T> + readonly patch: <T>(path: string, opts?: RequestOptions) => Promise<T> + readonly delete: <T>(path: string, opts?: RequestOptions) => Promise<T> + readonly fetch: (path: string, opts?: RequestOptions) => Promise<Response> + readonly stream: (path: string, opts?: RequestOptions) => Promise<Response> + readonly extend: (overrides: Partial<ClientOptions>) => HttpClient } diff --git a/cli/src/http/url.test.ts b/cli/src/http/url.test.ts new file mode 100644 index 0000000000..1d340800c7 --- /dev/null +++ b/cli/src/http/url.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { appendSearchParams, joinURL } from './url.js' + +describe('joinURL', () => { + it('joins base and path with single slash', () => { + expect(joinURL('https://api.example.com/openapi/v1', 'workspaces')).toBe('https://api.example.com/openapi/v1/workspaces') + }) + + it('collapses double slash when base has trailing and path has leading', () => { + expect(joinURL('https://api.example.com/openapi/v1/', '/workspaces')).toBe('https://api.example.com/openapi/v1/workspaces') + }) + + it('inserts slash when neither side has one', () => { + expect(joinURL('https://api.example.com', 'workspaces')).toBe('https://api.example.com/workspaces') + }) + + it('preserves trailing-only or leading-only slash without adding another', () => { + expect(joinURL('https://api.example.com/', 'workspaces')).toBe('https://api.example.com/workspaces') + expect(joinURL('https://api.example.com', '/workspaces')).toBe('https://api.example.com/workspaces') + }) + + it('returns base when path is empty or root', () => { + expect(joinURL('https://api.example.com', '')).toBe('https://api.example.com') + expect(joinURL('https://api.example.com', '/')).toBe('https://api.example.com') + }) + + it('returns path when base is empty or root', () => { + expect(joinURL('', 'workspaces')).toBe('workspaces') + expect(joinURL('/', 'workspaces')).toBe('workspaces') + }) +}) + +describe('appendSearchParams', () => { + it('returns the URL unchanged when params is undefined', () => { + expect(appendSearchParams('https://x/y', undefined)).toBe('https://x/y') + }) + + it('returns the URL unchanged when every value is undefined', () => { + expect(appendSearchParams('https://x/y', { a: undefined, b: undefined })).toBe('https://x/y') + }) + + it('omits undefined values and coerces primitives', () => { + const url = appendSearchParams('https://x/y', { page: 2, active: true, name: 'foo', skip: undefined }) + expect(url).toBe('https://x/y?page=2&active=true&name=foo') + }) + + it('uses & when the URL already has a query string', () => { + expect(appendSearchParams('https://x/y?a=1', { b: 2 })).toBe('https://x/y?a=1&b=2') + }) + + // Pins the convention documented on `appendSearchParams`: callers that mean + // "absent" pass `undefined`; an explicit empty string travels as `?key=`. + // API-client callers collapse empties to `undefined` upstream — this is the + // backstop that catches a future "let's just skip empties here too" change. + it('keeps empty-string values on the wire (only undefined is skipped)', () => { + expect(appendSearchParams('https://x/y', { name: '', tag: undefined })).toBe('https://x/y?name=') + }) +}) diff --git a/cli/src/http/url.ts b/cli/src/http/url.ts new file mode 100644 index 0000000000..7ac0c11a8f --- /dev/null +++ b/cli/src/http/url.ts @@ -0,0 +1,41 @@ +import type { SearchParamValue } from './types.js' + +// Joins a base URL and a path, collapsing/inserting a single slash at the seam. +export function joinURL(base: string, path: string): string { + if (base === '' || base === '/') + return path === '' ? '/' : path + if (path === '' || path === '/') + return base + + const baseHasTrailing = base.endsWith('/') + const pathHasLeading = path.startsWith('/') + + if (baseHasTrailing && pathHasLeading) + return base + path.slice(1) + if (!baseHasTrailing && !pathHasLeading) + return `${base}/${path}` + return base + path +} + +// Only `undefined` is treated as "absent". Empty strings, 0, and false coerce +// through `String(...)` and land on the wire — e.g. `{ name: '' }` becomes +// `?name=`. API-client callers (see `apps.ts`, `account-sessions.ts`) collapse +// empty-string filters to `undefined` at their own layer; this helper does NOT +// do that for them, on purpose, so a caller that wants to send an explicit +// empty value still can. +export function appendSearchParams(url: string, params: Record<string, SearchParamValue> | undefined): string { + if (params === undefined) + return url + + const search = new URLSearchParams() + for (const [key, value] of Object.entries(params)) { + if (value === undefined) + continue + search.append(key, String(value)) + } + + const qs = search.toString() + if (qs === '') + return url + return url.includes('?') ? `${url}&${qs}` : `${url}?${qs}` +} diff --git a/cli/src/util/host.ts b/cli/src/util/host.ts index e58649aafb..1042b0875e 100644 --- a/cli/src/util/host.ts +++ b/cli/src/util/host.ts @@ -3,6 +3,10 @@ import { ErrorCode } from '@/errors/codes' export const DEFAULT_HOST = 'https://cloud.dify.ai' +export function openAPIBase(host: string): string { + return `${host.replace(/\/+$/, '')}/openapi/v1/` +} + export type ResolveHostOptions = { raw: string insecure: boolean diff --git a/cli/src/version/probe.ts b/cli/src/version/probe.ts index 64fff384a4..03ec018b8a 100644 --- a/cli/src/version/probe.ts +++ b/cli/src/version/probe.ts @@ -1,14 +1,14 @@ import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen' -import type { CompatVerdict } from './compat' -import type { Channel } from './info' +import type { CompatVerdict } from './compat.js' +import type { Channel } from './info.js' import type { HostsBundle } from '@/auth/hosts' import { META_PROBE_TIMEOUT_MS, MetaClient } from '@/api/meta' import { loadHosts } from '@/auth/hosts' -import { createClient } from '@/http/client' +import { createHttpClient } from '@/http/client' import { arch, platform } from '@/sys/index' -import { hostWithScheme } from '@/util/host' -import { difyCompat, evaluateCompat } from './compat' -import { versionInfo } from './info' +import { hostWithScheme, openAPIBase } from '@/util/host' +import { difyCompat, evaluateCompat } from './compat.js' +import { versionInfo } from './info.js' export type ClientBlock = { readonly version: string @@ -50,7 +50,7 @@ export type RunVersionProbeOptions = { const defaultLoadBundle = async (): Promise<HostsBundle | undefined> => loadHosts() const defaultProbe: MetaProbe = async (endpoint) => { - const http = createClient({ host: endpoint, timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 }) + const http = createHttpClient({ baseURL: openAPIBase(endpoint), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 }) return new MetaClient(http).serverVersion() } diff --git a/cli/test/fixtures/http-client.ts b/cli/test/fixtures/http-client.ts new file mode 100644 index 0000000000..fdfda7c1fe --- /dev/null +++ b/cli/test/fixtures/http-client.ts @@ -0,0 +1,14 @@ +import type { ClientOptions, HttpClient } from '../../src/http/types.js' +import { createHttpClient } from '../../src/http/client.js' +import { openAPIBase } from '../../src/util/host.js' + +type ClientOverrides = Omit<ClientOptions, 'baseURL'> + +// Wraps createHttpClient + openAPIBase for tests so call sites read at a glance. +// Accepts a bare bearer string for the common case, or an options object for everything else. +export function testHttpClient(host: string, bearerOrOpts?: string | ClientOverrides): HttpClient { + const opts: ClientOverrides = typeof bearerOrOpts === 'string' + ? { bearer: bearerOrOpts } + : (bearerOrOpts ?? {}) + return createHttpClient({ baseURL: openAPIBase(host), ...opts }) +} diff --git a/cli/test/fixtures/stub-server.ts b/cli/test/fixtures/stub-server.ts new file mode 100644 index 0000000000..947f867127 --- /dev/null +++ b/cli/test/fixtures/stub-server.ts @@ -0,0 +1,66 @@ +import type { AddressInfo } from 'node:net' +import { Buffer } from 'node:buffer' +import * as http from 'node:http' + +// Records what the client actually put on the wire so API-client tests can +// assert method / path / query / body / headers without mocking fetch. +export type CapturedRequest = { + method?: string + url?: string + body?: string + headers?: http.IncomingHttpHeaders +} + +export type StubServer = { + readonly url: string + readonly captured: CapturedRequest + readonly stop: () => Promise<void> +} + +// Buffers the request body, captures the request line + headers, then replies +// with a JSON payload and a matching Content-Length. +export function jsonResponder( + status: number, + body: unknown, + captured: CapturedRequest, +): http.RequestListener { + return (req, res) => { + captured.method = req.method + captured.url = req.url + captured.headers = req.headers + const chunks: Buffer[] = [] + req.on('data', c => chunks.push(c)) + req.on('end', () => { + captured.body = Buffer.concat(chunks).toString('utf8') + const payload = JSON.stringify(body) + res.writeHead(status, { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(payload), + }) + res.end(payload) + }) + } +} + +// Starts a throwaway loopback server. The handler is built from the same +// `captured` object the caller reads back via `stub.captured`, so there is no +// reassignment dance between the listener and the assertions. +export function startStubServer( + makeHandler: (captured: CapturedRequest) => http.RequestListener, +): Promise<StubServer> { + const captured: CapturedRequest = {} + const handler = makeHandler(captured) + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => handler(req, res)) + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as AddressInfo + resolve({ + url: `http://127.0.0.1:${addr.port}`, + captured, + stop: () => + new Promise<void>((res, rej) => server.close(err => (err ? rej(err) : res()))), + }) + }) + server.on('error', reject) + }) +} diff --git a/cli/tsconfig.json b/cli/tsconfig.json index a44a2225bd..d709023b13 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "@dify/tsconfig/node.json", "compilerOptions": { + "rootDir": "src", "moduleResolution": "bundler", "paths": { "@/*": [ @@ -17,5 +18,6 @@ "outDir": "dist", "sourceMap": true }, - "include": ["src/**/*.ts", "test/**/*.ts"] + "include": ["src/**/*.ts"], + "exclude": ["dist", "test", "node_modules", "**/*.test.ts"] } diff --git a/cli/vite.config.ts b/cli/vite.config.ts index ebc6c2fec4..28ba19f568 100644 --- a/cli/vite.config.ts +++ b/cli/vite.config.ts @@ -34,6 +34,7 @@ export default defineConfig({ environment: 'node', setupFiles: ['./test/setup.ts'], include: ['test/**/*.test.ts', 'src/**/*.test.ts', 'scripts/**/*.test.ts'], + exclude: ['**/node_modules/**', '**/dist/**'], coverage: { provider: 'v8', reporter: ['text', 'text-summary', 'json'], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 411b2a77cf..c839c987ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -546,6 +546,9 @@ catalogs: uglify-js: specifier: 3.19.3 version: 3.19.3 + undici: + specifier: 7.25.0 + version: 7.25.0 unist-util-visit: specifier: 5.1.0 version: 5.1.0 @@ -647,9 +650,6 @@ importers: js-yaml: specifier: 'catalog:' version: 4.1.1 - ky: - specifier: 'catalog:' - version: 2.0.2 lockfile: specifier: 'catalog:' version: 1.0.4 @@ -665,6 +665,9 @@ importers: std-semver: specifier: 'catalog:' version: 1.0.8 + undici: + specifier: 'catalog:' + version: 7.25.0 zod: specifier: 'catalog:' version: 4.4.3 @@ -17698,6 +17701,7 @@ time: tsx@4.22.3: '2026-05-19T09:53:00.670Z' typescript@6.0.3: '2026-04-16T23:38:27.905Z' uglify-js@3.19.3: '2024-08-29T13:49:01.316Z' + undici@7.25.0: '2026-04-13T13:27:40.320Z' unist-util-visit@5.1.0: '2026-01-22T19:02:58.977Z' use-context-selector@2.0.0: '2024-05-06T11:23:59.259Z' uuid@14.0.0: '2026-04-19T15:15:42.302Z' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index edac2dbfc5..150db15101 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -225,6 +225,7 @@ catalog: tsx: 4.22.3 typescript: 6.0.3 uglify-js: 3.19.3 + undici: 7.25.0 unist-util-visit: 5.1.0 use-context-selector: 2.0.0 uuid: 14.0.0