refactor(cli/http): replace ky with a self-contained HTTP client (#36711)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
L1nSn0w
2026-06-01 17:04:42 +08:00
committed by GitHub
parent 055d9b9f0a
commit cfc1cf2b8c
70 changed files with 1949 additions and 602 deletions
+1 -1
View File
@@ -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": {
+84
View File
@@ -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')
})
})
+6 -11
View File
@@ -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<SessionListResponse> {
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<SessionListResponse>()
return this.http.get<SessionListResponse>('account/sessions', {
searchParams: { page: q?.page, limit: q?.limit },
})
}
async revoke(sessionId: string): Promise<void> {
+41
View File
@@ -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,
)
})
})
+4 -4
View File
@@ -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<AccountResponse> {
return this.http.get('account').json<AccountResponse>()
return this.http.get<AccountResponse>('account')
}
}
+8 -8
View File
@@ -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 })
+8 -8
View File
@@ -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()
+10 -11
View File
@@ -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<string, unknown>,
opts: StreamOptions = {},
): Promise<AsyncIterable<SseEvent>> {
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<void> {
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<void> {
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<AsyncIterable<SseEvent>> {
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')
+116
View File
@@ -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/<id>/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')
})
})
+19 -19
View File
@@ -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<AppListResponse> {
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<AppListResponse>()
return this.http.get<AppListResponse>('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<AppDescribeResponse> {
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<AppDescribeResponse>()
return this.http.get<AppDescribeResponse>(`apps/${encodeURIComponent(appId)}/describe`, {
searchParams: {
workspace_id: workspaceId,
fields: fields !== undefined && fields.length > 0 ? fields.join(',') : undefined,
},
})
}
}
+10 -10
View File
@@ -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 {
+76
View File
@@ -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,
)
})
})
+6 -6
View File
@@ -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<UploadedFile>(
`apps/${encodeURIComponent(appId)}/files/upload`,
{ body: form, timeout: 60_000 },
).json<UploadedFile>()
{ body: form, timeoutMs: 60_000 },
)
}
}
+52 -117
View File
@@ -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<void>
}
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<StubServer> {
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<void>((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/<id>/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/<id>/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,
)
+19 -26
View File
@@ -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/<id>/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<MemberListResponse> {
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<MemberListResponse>()
return this.http.get<MemberListResponse>(
`workspaces/${encodeURIComponent(workspaceId)}/members`,
{ searchParams: { page: q?.page, limit: q?.limit } },
)
}
async invite(workspaceId: string, payload: MemberInvitePayload): Promise<MemberInviteResponse> {
return this.http
.post(`workspaces/${encodeURIComponent(workspaceId)}/members`, { json: payload })
.json<MemberInviteResponse>()
return this.http.post<MemberInviteResponse>(
`workspaces/${encodeURIComponent(workspaceId)}/members`,
{ json: payload },
)
}
async remove(workspaceId: string, memberId: string): Promise<MemberActionResponse> {
return this.http
.delete(`workspaces/${encodeURIComponent(workspaceId)}/members/${encodeURIComponent(memberId)}`)
.json<MemberActionResponse>()
return this.http.delete<MemberActionResponse>(
`workspaces/${encodeURIComponent(workspaceId)}/members/${encodeURIComponent(memberId)}`,
)
}
async updateRole(
@@ -51,11 +46,9 @@ export class MembersClient {
memberId: string,
payload: MemberRoleUpdatePayload,
): Promise<MemberActionResponse> {
return this.http
.put(
`workspaces/${encodeURIComponent(workspaceId)}/members/${encodeURIComponent(memberId)}/role`,
{ json: payload },
)
.json<MemberActionResponse>()
return this.http.put<MemberActionResponse>(
`workspaces/${encodeURIComponent(workspaceId)}/members/${encodeURIComponent(memberId)}/role`,
{ json: payload },
)
}
}
+6 -6
View File
@@ -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()
})
})
+6 -6
View File
@@ -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<ServerVersionResponse> {
return this.http.get('_version').json<ServerVersionResponse>()
return this.http.get<ServerVersionResponse>('_version')
}
}
+5 -5
View File
@@ -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<string, PollResult['status']> = {
}
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)
+43
View File
@@ -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,
)
})
})
+5 -7
View File
@@ -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<WorkspaceListResponse> {
return this.http.get('workspaces').json<WorkspaceListResponse>()
return this.http.get<WorkspaceListResponse>('workspaces')
}
/**
@@ -22,8 +22,6 @@ export class WorkspacesClient {
* server's state.
*/
async switch(workspaceId: string): Promise<WorkspaceDetailResponse> {
return this.http
.post(`workspaces/${encodeURIComponent(workspaceId)}/switch`)
.json<WorkspaceDetailResponse>()
return this.http.post<WorkspaceDetailResponse>(`workspaces/${encodeURIComponent(workspaceId)}/switch`)
}
}
+7 -7
View File
@@ -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),
@@ -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<string, unknown>()
@@ -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<string, unknown>
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/)
@@ -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<re
export type DevicesRevokeOptions = {
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
readonly target?: string
+10 -10
View File
@@ -1,17 +1,17 @@
import type { DifyMock } from '@test/fixtures/dify-mock/server'
import type { Clock } from './device-flow'
import type { Clock } from './device-flow.js'
import type { Key, Store } from '@/store/store'
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 } from 'vitest'
import { DeviceFlowApi } from '@/api/oauth-device'
import { createClient } from '@/http/client'
import { ENV_CONFIG_DIR } from '@/store/dir'
import { tokenKey } from '@/store/manager'
import { bufferStreams } from '@/sys/io/streams'
import { runLogin } from './login'
import { runLogin } from './login.js'
const noopClock: Clock = {
sleepMs: async () => { /* 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,
+5 -5
View File
@@ -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<HostsBundle> {
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)
+7 -7
View File
@@ -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,
})
+7 -7
View File
@@ -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<string, unknown>()
@@ -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 })
+2 -2
View File
@@ -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
}
+6 -6
View File
@@ -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,
},
+5 -5
View File
@@ -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))
+5 -5
View File
@@ -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,
},
+5 -5
View File
@@ -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))
+6 -6
View File
@@ -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()
+3 -3
View File
@@ -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
+4 -4
View File
@@ -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<typeof runGetApp>[0] = {}): Promise<string> {
+7 -7
View File
@@ -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<GetAppResult> {
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)
+8 -8
View File
@@ -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,
},
+5 -5
View File
@@ -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<GetMemberResult> {
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({
+4 -4
View File
@@ -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<string> {
+5 -5
View File
@@ -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<GetWorkspaceResult> {
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' },
+4 -4
View File
@@ -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 ?? ''
}
+26 -26
View File
@@ -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 → "→ <title>" 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>
+5 -5
View File
@@ -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
+6 -6
View File
@@ -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,
},
+5 -5
View File
@@ -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))
+7 -7
View File
@@ -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,
},
+4 -4
View File
@@ -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(
+63
View File
@@ -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 })
})
})
+63
View File
@@ -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 }
}
+392 -69
View File
@@ -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')
})
})
+231 -50
View File
@@ -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,
}
}
+55
View File
@@ -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)
}
-10
View File
@@ -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}`)
}
}
-30
View File
@@ -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,
})
}
}
-8
View File
@@ -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)
}
}
+55
View File
@@ -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()
})
})
+23
View File
@@ -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
}
+69
View File
@@ -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)
})
})
+29
View File
@@ -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)
}
+68 -5
View File
@@ -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
}
+58
View File
@@ -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=')
})
})
+41
View File
@@ -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}`
}
+4
View File
@@ -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
+7 -7
View File
@@ -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()
}
+14
View File
@@ -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 })
}
+66
View File
@@ -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)
})
}
+3 -1
View File
@@ -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"]
}
+1
View File
@@ -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'],
+7 -3
View File
@@ -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'
+1
View File
@@ -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