mirror of
https://github.com/langgenius/dify.git
synced 2026-06-03 08:16:37 +08:00
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:
+1
-1
@@ -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": {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 ?? ''
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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=')
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
Vendored
+14
@@ -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 })
|
||||
}
|
||||
Vendored
+66
@@ -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
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Generated
+7
-3
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user