@vobs/http 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +57 -0
- package/package.json +21 -0
- package/src/debug.ts +64 -0
- package/src/index.test.ts +499 -0
- package/src/index.ts +847 -0
- package/src/stream.ts +128 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 vobs contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# @vobs/http
|
|
2
|
+
|
|
3
|
+
HTTP client for vobs with retry, timeout, in-flight dedupe, interceptors, pluggable transports, and signal-based cancellation.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @vobs/http
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { createHTTPClient, HTTPError } from '@vobs/http'
|
|
15
|
+
|
|
16
|
+
const client = createHTTPClient({
|
|
17
|
+
baseURL: '/api',
|
|
18
|
+
timeout: 10_000,
|
|
19
|
+
retry: 2,
|
|
20
|
+
retryDelay: attempt => attempt * 500
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const { data, status } = await client.get<{ id: number }>('/users/1', {
|
|
24
|
+
params: { expand: 'profile' }
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
await client.post('/users', { name: 'Ada' })
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
await client.get('/private')
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (error instanceof HTTPError) console.log(error.status, error.data)
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Non-2xx responses throw `HTTPError` carrying `status`, `data`, and the request `config`. Requests exceeding `timeout` throw `TimeoutError` (code `ETIMEDOUT`) and are not retried. Other failures retry up to `retry` times when `shouldRetry` allows (default: 429 and 5xx), waiting `retryDelay` between attempts. Pass `signal` to cancel a request; `dedupe: true` shares one in-flight GET or HEAD per URL.
|
|
37
|
+
|
|
38
|
+
## API
|
|
39
|
+
|
|
40
|
+
| Signature | Description |
|
|
41
|
+
| --- | --- |
|
|
42
|
+
| `createHTTPClient(options?: HTTPClientOptions): HTTPClient` | Defaults for `baseURL`, `headers`, `timeout`, `retry`, `adapter`, `concurrency`, and `dedupe`. |
|
|
43
|
+
| `client.get / delete / head(url, options?)` | Body-less methods returning `HTTPResponse<T>`. |
|
|
44
|
+
| `client.post / put / patch(url, body?, options?)` | FormData, Blob, and string bodies pass through; other values are JSON-encoded. |
|
|
45
|
+
| `client.request(options)` | Full `RequestOptions` control, including `params`, `responseType`, and progress handlers. |
|
|
46
|
+
| `client.interceptors.request / response` | `use / eject / clear`; request interceptors run in order, response interceptors in reverse. |
|
|
47
|
+
| `createConcurrencyLimiter(limit): ConcurrencyLimiter` | Queue tasks behind a maximum parallel count. |
|
|
48
|
+
| `createFetchAdapter() / createXHRAdapter() / createAxiosAdapter(request?) / createMockAdapter(handler)` | Swap the transport; the XHR adapter adds upload and download progress events. |
|
|
49
|
+
| `toResourceFetcher(request)` | Adapt an HTTP request to the `ResourceFetcher` contract of @vobs/resource. |
|
|
50
|
+
| `httpPlugin(options?)` | Provide the client as `HTTP_KEY` in a vobs app. |
|
|
51
|
+
| `createSSE(url, options?)` | EventSource wrapper exposing `source` and `close()`. |
|
|
52
|
+
| `createWebSocket(url, options?)` | WebSocket client with typed `on()` events and optional auto-reconnect. |
|
|
53
|
+
| `subscribeHTTPDebug(listener) / setHTTPDebugHooks(hooks) / getHTTPDebugHooks()` | Observe request traces for DevTools; sensitive headers are redacted. |
|
|
54
|
+
|
|
55
|
+
## Types
|
|
56
|
+
|
|
57
|
+
HTTPMethod, HTTPResponseType, HTTPHeaders, RetryDelay, HTTPProgress, HTTPProgressHandler, RequestOptions, RequestConfig, HTTPResponse, HTTPResourceRequest, HTTPAdapter, AxiosRequest, AxiosRequestConfigLike, AxiosResponseLike, HTTPClientOptions, HTTPClient, HTTPInterceptors, InterceptorManager, ConcurrencyLimiter, HTTPPluginOptions, HTTPDebugRequest, HTTPDebugStatus, HTTPDebugContext, HTTPDebugHooks, HTTPDebugCacheStatus, SSEClient, SSEConstructor, SSEOptions, WebSocketClient, WebSocketConstructor, WebSocketEventListener, WebSocketEventName, WebSocketOptions, WebSocketState
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"license": "MIT",
|
|
3
|
+
"files": [
|
|
4
|
+
"src",
|
|
5
|
+
"README.md",
|
|
6
|
+
"LICENSE"
|
|
7
|
+
],
|
|
8
|
+
"name": "@vobs/http",
|
|
9
|
+
"version": "1.0.0",
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "src/index.ts",
|
|
12
|
+
"types": "src/index.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./src/index.ts"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"axios": "^1.20.0",
|
|
18
|
+
"@vobs/runtime": "1.0.0",
|
|
19
|
+
"@vobs/vobs": "1.0.0"
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/debug.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { HTTPMethod, HTTPHeaders } from './index'
|
|
2
|
+
import type { RuntimeDebugContext } from '@vobs/runtime'
|
|
3
|
+
|
|
4
|
+
export type HTTPDebugStatus = 'loading' | 'retrying' | 'success' | 'error' | 'cancelled'
|
|
5
|
+
|
|
6
|
+
export type HTTPDebugCacheStatus = 'hit' | 'miss' | 'revalidated'
|
|
7
|
+
|
|
8
|
+
export interface HTTPDebugContext extends RuntimeDebugContext {
|
|
9
|
+
readonly cacheStatus?: HTTPDebugCacheStatus
|
|
10
|
+
/** Marks a request created from the DevTools request tester. */
|
|
11
|
+
readonly test?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface HTTPDebugRequest {
|
|
15
|
+
readonly id: number
|
|
16
|
+
readonly phase: 'start' | 'retry' | 'end'
|
|
17
|
+
readonly status: HTTPDebugStatus
|
|
18
|
+
readonly url: string
|
|
19
|
+
readonly method: HTTPMethod
|
|
20
|
+
readonly headers: HTTPHeaders
|
|
21
|
+
readonly requestBody?: unknown
|
|
22
|
+
readonly startedAt: number
|
|
23
|
+
readonly endedAt?: number
|
|
24
|
+
readonly duration?: number
|
|
25
|
+
readonly attempt: number
|
|
26
|
+
readonly retries: number
|
|
27
|
+
readonly responseStatus?: number
|
|
28
|
+
readonly responseBody?: unknown
|
|
29
|
+
readonly error?: { readonly name: string; readonly message: string }
|
|
30
|
+
readonly context?: HTTPDebugContext
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface HTTPDebugHooks {
|
|
34
|
+
request?(event: HTTPDebugRequest): void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let activeHTTPDebugHooks: HTTPDebugHooks | null = null
|
|
38
|
+
const httpDebugListeners = new Set<NonNullable<HTTPDebugHooks['request']>>()
|
|
39
|
+
|
|
40
|
+
export function setHTTPDebugHooks(hooks: HTTPDebugHooks | null): HTTPDebugHooks | null {
|
|
41
|
+
const previous = activeHTTPDebugHooks
|
|
42
|
+
activeHTTPDebugHooks = hooks
|
|
43
|
+
return previous
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getHTTPDebugHooks(): HTTPDebugHooks | null {
|
|
47
|
+
return activeHTTPDebugHooks
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function subscribeHTTPDebug(listener: NonNullable<HTTPDebugHooks['request']>): () => void {
|
|
51
|
+
httpDebugListeners.add(listener)
|
|
52
|
+
return () => httpDebugListeners.delete(listener)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function emitHTTPDebug(event: HTTPDebugRequest): void {
|
|
56
|
+
try {
|
|
57
|
+
activeHTTPDebugHooks?.request?.(event)
|
|
58
|
+
} catch {
|
|
59
|
+
// Debug tooling must never change request behavior.
|
|
60
|
+
}
|
|
61
|
+
for (const listener of [...httpDebugListeners]) {
|
|
62
|
+
try { listener(event) } catch { /* Debug tooling must never change request behavior. */ }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
createHTTPClient,
|
|
4
|
+
createConcurrencyLimiter,
|
|
5
|
+
createAxiosAdapter,
|
|
6
|
+
createFetchAdapter,
|
|
7
|
+
createMockAdapter,
|
|
8
|
+
createSSE,
|
|
9
|
+
createWebSocket,
|
|
10
|
+
createXHRAdapter,
|
|
11
|
+
HTTPError,
|
|
12
|
+
HTTP_KEY,
|
|
13
|
+
httpPlugin,
|
|
14
|
+
TimeoutError,
|
|
15
|
+
toResourceFetcher,
|
|
16
|
+
subscribeHTTPDebug,
|
|
17
|
+
type HTTPClient,
|
|
18
|
+
type HTTPResponse,
|
|
19
|
+
type AxiosRequest
|
|
20
|
+
} from './index'
|
|
21
|
+
import { createText, createVobs } from '@vobs/vobs'
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.unstubAllGlobals()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('@vobs/http', () => {
|
|
28
|
+
it('构造 URL、合并 headers、序列化 JSON body 并解析响应', async () => {
|
|
29
|
+
const fetcher = vi.fn(async (_url: string, _init?: RequestInit) => new Response(
|
|
30
|
+
JSON.stringify({ id: 1 }),
|
|
31
|
+
{ status: 201, headers: { 'content-type': 'application/json' } }
|
|
32
|
+
))
|
|
33
|
+
vi.stubGlobal('fetch', fetcher)
|
|
34
|
+
const client = createHTTPClient({
|
|
35
|
+
adapter: createFetchAdapter(),
|
|
36
|
+
baseURL: '/api',
|
|
37
|
+
headers: { 'X-Base': 'base' }
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const response = await client.post<{ id: number }>('/users', { name: 'Ada' }, {
|
|
41
|
+
params: { page: 1, tags: ['a', 'b'] },
|
|
42
|
+
headers: { 'X-Request': 'request' }
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
expect(response.data).toEqual({ id: 1 })
|
|
46
|
+
expect(response.status).toBe(201)
|
|
47
|
+
expect(fetcher).toHaveBeenCalledWith('/api/users?page=1&tags=a&tags=b', expect.objectContaining({
|
|
48
|
+
method: 'POST',
|
|
49
|
+
body: JSON.stringify({ name: 'Ada' })
|
|
50
|
+
}))
|
|
51
|
+
const init = fetcher.mock.calls[0]?.[1]
|
|
52
|
+
if (!init) throw new Error('missing RequestInit')
|
|
53
|
+
expect(new Headers(init.headers).get('x-base')).toBe('base')
|
|
54
|
+
expect(new Headers(init.headers).get('x-request')).toBe('request')
|
|
55
|
+
expect(new Headers(init.headers).get('content-type')).toBe('application/json')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('toResourceFetcher 转发 AbortSignal 并解包 HTTPResponse.data', async () => {
|
|
59
|
+
const controller = new AbortController()
|
|
60
|
+
const response: HTTPResponse<{ id: number }> = {
|
|
61
|
+
data: { id: 1 },
|
|
62
|
+
status: 200,
|
|
63
|
+
statusText: 'OK',
|
|
64
|
+
headers: new Headers(),
|
|
65
|
+
config: {
|
|
66
|
+
url: '/users/1',
|
|
67
|
+
method: 'GET',
|
|
68
|
+
headers: {},
|
|
69
|
+
signal: controller.signal
|
|
70
|
+
},
|
|
71
|
+
raw: null
|
|
72
|
+
}
|
|
73
|
+
const request = vi.fn(() => Promise.resolve(response))
|
|
74
|
+
|
|
75
|
+
await expect(toResourceFetcher(request)(controller.signal)).resolves.toEqual({ id: 1 })
|
|
76
|
+
expect(request).toHaveBeenCalledWith(controller.signal)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('请求拦截器正序执行,响应拦截器逆序执行', async () => {
|
|
80
|
+
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', {
|
|
81
|
+
headers: { 'content-type': 'application/json' }
|
|
82
|
+
})))
|
|
83
|
+
const calls: string[] = []
|
|
84
|
+
const client = createHTTPClient({ adapter: createFetchAdapter() })
|
|
85
|
+
client.interceptors.request.use(config => {
|
|
86
|
+
calls.push('request-a')
|
|
87
|
+
return config
|
|
88
|
+
})
|
|
89
|
+
client.interceptors.request.use(config => {
|
|
90
|
+
calls.push('request-b')
|
|
91
|
+
return config
|
|
92
|
+
})
|
|
93
|
+
client.interceptors.response.use(response => {
|
|
94
|
+
calls.push('response-a')
|
|
95
|
+
return response
|
|
96
|
+
})
|
|
97
|
+
client.interceptors.response.use(response => {
|
|
98
|
+
calls.push('response-b')
|
|
99
|
+
return response
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
await client.get('/users')
|
|
103
|
+
expect(calls).toEqual(['request-a', 'request-b', 'response-b', 'response-a'])
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('非 2xx 响应抛出 HTTPError,并可由响应错误拦截器观察', async () => {
|
|
107
|
+
vi.stubGlobal('fetch', vi.fn(async () => new Response(
|
|
108
|
+
JSON.stringify({ message: 'denied' }),
|
|
109
|
+
{ status: 403, statusText: 'Forbidden', headers: { 'content-type': 'application/json' } }
|
|
110
|
+
)))
|
|
111
|
+
const errors: unknown[] = []
|
|
112
|
+
const client = createHTTPClient({ adapter: createFetchAdapter() })
|
|
113
|
+
client.interceptors.response.use(undefined, error => {
|
|
114
|
+
errors.push(error)
|
|
115
|
+
return Promise.reject(error)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
await expect(client.get('/private')).rejects.toBeInstanceOf(HTTPError)
|
|
119
|
+
expect(errors[0]).toBeInstanceOf(HTTPError)
|
|
120
|
+
expect((errors[0] as HTTPError).status).toBe(403)
|
|
121
|
+
expect((errors[0] as HTTPError).data).toEqual({ message: 'denied' })
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('按条件重试服务端错误,并尊重 AbortController 取消', async () => {
|
|
125
|
+
let attempts = 0
|
|
126
|
+
const fetcher = vi.fn(async () => {
|
|
127
|
+
attempts++
|
|
128
|
+
if (attempts < 3) return new Response('{}', { status: 503 })
|
|
129
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
130
|
+
headers: { 'content-type': 'application/json' }
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
vi.stubGlobal('fetch', fetcher)
|
|
134
|
+
const client = createHTTPClient({ adapter: createFetchAdapter(), retry: 2, retryDelay: 0 })
|
|
135
|
+
await expect(client.get('/retry')).resolves.toMatchObject({ data: { ok: true } })
|
|
136
|
+
expect(attempts).toBe(3)
|
|
137
|
+
|
|
138
|
+
const controller = new AbortController()
|
|
139
|
+
vi.stubGlobal('fetch', vi.fn((_url: string, init?: RequestInit) => new Promise<Response>((_, reject) => {
|
|
140
|
+
const abort = () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))
|
|
141
|
+
if (init?.signal?.aborted) abort()
|
|
142
|
+
else init?.signal?.addEventListener('abort', abort)
|
|
143
|
+
})))
|
|
144
|
+
const request = client.get('/cancel', { signal: controller.signal })
|
|
145
|
+
controller.abort()
|
|
146
|
+
await expect(request).rejects.toMatchObject({ name: 'AbortError' })
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('超时会终止 adapter 并抛出 TimeoutError', async () => {
|
|
150
|
+
vi.stubGlobal('fetch', vi.fn((_url: string, init?: RequestInit) => new Promise<Response>((_, reject) => {
|
|
151
|
+
const abort = () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))
|
|
152
|
+
if (init?.signal?.aborted) abort()
|
|
153
|
+
else init?.signal?.addEventListener('abort', abort)
|
|
154
|
+
})))
|
|
155
|
+
|
|
156
|
+
await expect(createHTTPClient({ adapter: createFetchAdapter(), timeout: 5 }).get('/slow')).rejects.toBeInstanceOf(TimeoutError)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('可选请求去重只执行一次 adapter,并共享响应', async () => {
|
|
160
|
+
let resolve!: (response: Response) => void
|
|
161
|
+
const adapter = vi.fn(() => new Promise<Response>(done => { resolve = done }))
|
|
162
|
+
const client = createHTTPClient({ adapter })
|
|
163
|
+
const first = client.get<{ ok: boolean }>('/same', { dedupe: true })
|
|
164
|
+
const second = client.get<{ ok: boolean }>('/same', { dedupe: true })
|
|
165
|
+
await vi.waitFor(() => expect(adapter).toHaveBeenCalledTimes(1))
|
|
166
|
+
resolve(new Response(JSON.stringify({ ok: true }), {
|
|
167
|
+
headers: { 'content-type': 'application/json' }
|
|
168
|
+
}))
|
|
169
|
+
|
|
170
|
+
await expect(Promise.all([first, second])).resolves.toEqual([
|
|
171
|
+
expect.objectContaining({ data: { ok: true } }),
|
|
172
|
+
expect.objectContaining({ data: { ok: true } })
|
|
173
|
+
])
|
|
174
|
+
expect(adapter).toHaveBeenCalledTimes(1)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('Axios adapter 将 response.data 保持为 HTTPResponse<T>.data', async () => {
|
|
178
|
+
const axiosRequest = vi.fn(async (config: { url: string; method: string; data?: unknown }) => ({
|
|
179
|
+
data: { id: 1 },
|
|
180
|
+
status: 200,
|
|
181
|
+
statusText: 'OK',
|
|
182
|
+
headers: { 'content-type': 'application/json' },
|
|
183
|
+
config
|
|
184
|
+
})) as unknown as AxiosRequest
|
|
185
|
+
const client = createHTTPClient({ adapter: createAxiosAdapter(axiosRequest) })
|
|
186
|
+
const response = await client.post<{ id: number }>('/users', { name: 'Ada' })
|
|
187
|
+
|
|
188
|
+
expect(response.data.id).toBe(1)
|
|
189
|
+
expect(axiosRequest).toHaveBeenCalledWith(expect.objectContaining({
|
|
190
|
+
url: '/users',
|
|
191
|
+
method: 'POST',
|
|
192
|
+
data: { name: 'Ada' }
|
|
193
|
+
}))
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('自定义 Axios adapter 不会把已序列化的 params 再传给 Axios', async () => {
|
|
197
|
+
const requestMock = vi.fn(async (_config: { url: string; params?: unknown }) => ({
|
|
198
|
+
data: { ok: true },
|
|
199
|
+
status: 200,
|
|
200
|
+
headers: {}
|
|
201
|
+
}))
|
|
202
|
+
const axiosRequest = requestMock as unknown as AxiosRequest
|
|
203
|
+
const client = createHTTPClient({ adapter: createAxiosAdapter(axiosRequest) })
|
|
204
|
+
|
|
205
|
+
await client.get('/users', { params: { page: 1 } })
|
|
206
|
+
|
|
207
|
+
expect(requestMock).toHaveBeenCalledWith(expect.objectContaining({ url: '/users?page=1' }))
|
|
208
|
+
expect(requestMock.mock.calls[0]?.[0]).not.toHaveProperty('params')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('默认客户端使用内置 Axios transport 并保留 Network debug 事件', async () => {
|
|
212
|
+
const fetcher = vi.fn(async () => new Response(JSON.stringify({ ok: true }), {
|
|
213
|
+
status: 200,
|
|
214
|
+
headers: { 'content-type': 'application/json' }
|
|
215
|
+
}))
|
|
216
|
+
vi.stubGlobal('fetch', fetcher)
|
|
217
|
+
const events: import('./debug').HTTPDebugRequest[] = []
|
|
218
|
+
const stop = subscribeHTTPDebug(event => events.push(event))
|
|
219
|
+
|
|
220
|
+
const response = await createHTTPClient().get<{ ok: boolean }>('https://example.test/health')
|
|
221
|
+
|
|
222
|
+
stop()
|
|
223
|
+
expect(response.data).toEqual({ ok: true })
|
|
224
|
+
expect(fetcher).toHaveBeenCalled()
|
|
225
|
+
expect(events.map(event => event.status)).toEqual(['loading', 'success'])
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('内置 Axios transport 不会重复拼接 params,并遵守 GET 无 body', async () => {
|
|
229
|
+
let seenURL = ''
|
|
230
|
+
let seenMethod = ''
|
|
231
|
+
let seenBody: unknown
|
|
232
|
+
const fetcher = vi.fn(async (input: unknown, init?: RequestInit) => {
|
|
233
|
+
const request = typeof Request !== 'undefined' && input instanceof Request ? input : undefined
|
|
234
|
+
seenURL = request?.url ?? String(input)
|
|
235
|
+
seenMethod = request?.method ?? init?.method ?? ''
|
|
236
|
+
seenBody = request?.body ?? init?.body
|
|
237
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
238
|
+
status: 200,
|
|
239
|
+
headers: { 'content-type': 'application/json' }
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
vi.stubGlobal('fetch', fetcher)
|
|
243
|
+
|
|
244
|
+
const response = await createHTTPClient().request<{ ok: boolean }>({
|
|
245
|
+
url: 'https://example.test/users',
|
|
246
|
+
method: 'GET',
|
|
247
|
+
params: { page: 1, tags: ['a', 'b'] },
|
|
248
|
+
body: { ignored: true }
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
expect(response.data).toEqual({ ok: true })
|
|
252
|
+
expect(seenURL).toBe('https://example.test/users?page=1&tags=a&tags=b')
|
|
253
|
+
expect(seenMethod).toBe('GET')
|
|
254
|
+
expect(seenBody == null).toBe(true)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('Mock adapter 将普通返回值转换为成功 200 payload', async () => {
|
|
258
|
+
const client = createHTTPClient({
|
|
259
|
+
adapter: createMockAdapter(config => ({ method: config.method, url: config.url }))
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
await expect(client.get('/mock-users')).resolves.toMatchObject({
|
|
263
|
+
status: 200,
|
|
264
|
+
data: { method: 'GET', url: '/mock-users' }
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('Mock adapter 保留字符串和 undefined payload,不强制 JSON 解析', async () => {
|
|
269
|
+
const stringClient = createHTTPClient({ adapter: createMockAdapter(() => 'mocked') })
|
|
270
|
+
const emptyClient = createHTTPClient({ adapter: createMockAdapter(() => undefined) })
|
|
271
|
+
|
|
272
|
+
await expect(stringClient.get('/mock-text')).resolves.toMatchObject({ status: 200, data: 'mocked' })
|
|
273
|
+
await expect(emptyClient.get('/mock-empty')).resolves.toMatchObject({ status: 200, data: undefined })
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('默认 Axios transport 的 responseType response 返回原始 Response', async () => {
|
|
277
|
+
vi.stubGlobal('fetch', vi.fn(async () => new Response('raw body', {
|
|
278
|
+
status: 201,
|
|
279
|
+
statusText: 'Created',
|
|
280
|
+
headers: { 'content-type': 'text/plain' }
|
|
281
|
+
})))
|
|
282
|
+
|
|
283
|
+
const response = await createHTTPClient().get<Response>('https://example.test/raw', {
|
|
284
|
+
responseType: 'response'
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
expect(response.status).toBe(201)
|
|
288
|
+
expect(response.data).toBeInstanceOf(Response)
|
|
289
|
+
expect(response.raw).toBe(response.data)
|
|
290
|
+
await expect((response.data as Response).text()).resolves.toBe('raw body')
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('并发上限控制 adapter 同时执行数量', async () => {
|
|
294
|
+
const limiter = createConcurrencyLimiter(2)
|
|
295
|
+
let active = 0
|
|
296
|
+
let maximum = 0
|
|
297
|
+
const tasks = Array.from({ length: 5 }, (_, index) => limiter(async () => {
|
|
298
|
+
active++
|
|
299
|
+
maximum = Math.max(maximum, active)
|
|
300
|
+
await Promise.resolve()
|
|
301
|
+
active--
|
|
302
|
+
return index
|
|
303
|
+
}))
|
|
304
|
+
|
|
305
|
+
await expect(Promise.all(tasks)).resolves.toEqual([0, 1, 2, 3, 4])
|
|
306
|
+
expect(maximum).toBe(2)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('SSE adapter 转发事件回调并支持关闭', () => {
|
|
310
|
+
class FakeEventSource {
|
|
311
|
+
closed = false
|
|
312
|
+
listeners = new Map<string, EventListener[]>()
|
|
313
|
+
constructor(readonly url: string, readonly options?: EventSourceInit) {}
|
|
314
|
+
addEventListener(type: string, listener: EventListener): void {
|
|
315
|
+
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener])
|
|
316
|
+
}
|
|
317
|
+
close(): void { this.closed = true }
|
|
318
|
+
emit(type: string): void {
|
|
319
|
+
for (const listener of this.listeners.get(type) ?? []) listener(new Event(type))
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const opened = vi.fn()
|
|
323
|
+
const client = createSSE('/events', {
|
|
324
|
+
eventSource: FakeEventSource as unknown as import('./stream').SSEConstructor,
|
|
325
|
+
onOpen: opened
|
|
326
|
+
})
|
|
327
|
+
;(client.source as unknown as FakeEventSource).emit('open')
|
|
328
|
+
client.close()
|
|
329
|
+
expect(opened).toHaveBeenCalledTimes(1)
|
|
330
|
+
expect((client.source as unknown as FakeEventSource).closed).toBe(true)
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('WebSocket adapter 支持连接、发送、事件监听和关闭', () => {
|
|
334
|
+
class FakeWebSocket {
|
|
335
|
+
readyState = 0
|
|
336
|
+
sent: unknown[] = []
|
|
337
|
+
listeners = new Map<string, EventListener[]>()
|
|
338
|
+
constructor(readonly url: string) {}
|
|
339
|
+
addEventListener(type: string, listener: EventListener): void {
|
|
340
|
+
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener])
|
|
341
|
+
}
|
|
342
|
+
send(data: unknown): void { this.sent.push(data) }
|
|
343
|
+
close(): void {
|
|
344
|
+
this.readyState = 3
|
|
345
|
+
for (const listener of this.listeners.get('close') ?? []) listener(new CloseEvent('close'))
|
|
346
|
+
}
|
|
347
|
+
open(): void {
|
|
348
|
+
this.readyState = 1
|
|
349
|
+
for (const listener of this.listeners.get('open') ?? []) listener(new Event('open'))
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const opened = vi.fn()
|
|
353
|
+
const client = createWebSocket('/socket', {
|
|
354
|
+
webSocket: FakeWebSocket as unknown as import('./stream').WebSocketConstructor
|
|
355
|
+
})
|
|
356
|
+
client.on('open', opened)
|
|
357
|
+
const socket = client.socket as unknown as FakeWebSocket
|
|
358
|
+
socket.open()
|
|
359
|
+
client.send('hello')
|
|
360
|
+
client.close()
|
|
361
|
+
expect(opened).toHaveBeenCalledTimes(1)
|
|
362
|
+
expect(socket.sent).toEqual(['hello'])
|
|
363
|
+
expect(client.state).toBe('closed')
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
it('fetch adapter 报告可读响应流的下载进度', async () => {
|
|
367
|
+
const encoder = new TextEncoder()
|
|
368
|
+
const chunks = [encoder.encode('{"ok":'), encoder.encode('true}')]
|
|
369
|
+
let index = 0
|
|
370
|
+
vi.stubGlobal('fetch', vi.fn(async () => new Response(new ReadableStream({
|
|
371
|
+
pull(controller) {
|
|
372
|
+
if (index < chunks.length) controller.enqueue(chunks[index++])
|
|
373
|
+
else controller.close()
|
|
374
|
+
}
|
|
375
|
+
}), {
|
|
376
|
+
headers: { 'content-type': 'application/json', 'content-length': '9' }
|
|
377
|
+
})))
|
|
378
|
+
const progress: number[] = []
|
|
379
|
+
const response = await createHTTPClient({ adapter: createFetchAdapter() }).get<{ ok: boolean }>('/stream', {
|
|
380
|
+
onDownloadProgress: event => progress.push(event.loaded)
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
expect(response.data).toEqual({ ok: true })
|
|
384
|
+
expect(progress).toEqual([6, 11])
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it('下载进度与 text 响应共用一次流读取', async () => {
|
|
388
|
+
const encoder = new TextEncoder()
|
|
389
|
+
const chunks = [encoder.encode('hel'), encoder.encode('lo')]
|
|
390
|
+
let index = 0
|
|
391
|
+
vi.stubGlobal('fetch', vi.fn(async () => new Response(new ReadableStream({
|
|
392
|
+
pull(controller) {
|
|
393
|
+
if (index < chunks.length) controller.enqueue(chunks[index++])
|
|
394
|
+
else controller.close()
|
|
395
|
+
}
|
|
396
|
+
}), { headers: { 'content-length': '5' } })))
|
|
397
|
+
const progress: number[] = []
|
|
398
|
+
|
|
399
|
+
const response = await createHTTPClient({ adapter: createFetchAdapter() }).get('/text', {
|
|
400
|
+
responseType: 'text',
|
|
401
|
+
onDownloadProgress: event => progress.push(event.loaded)
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
expect(response.data).toBe('hello')
|
|
405
|
+
expect(progress).toEqual([3, 5])
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it('XHR adapter 报告上传和下载进度', async () => {
|
|
409
|
+
class FakeXHR {
|
|
410
|
+
static instance: FakeXHR
|
|
411
|
+
readonly upload = { onprogress: (_event: ProgressEvent) => undefined }
|
|
412
|
+
onprogress = (_event: ProgressEvent): void => undefined
|
|
413
|
+
onload = (): void => undefined
|
|
414
|
+
onerror = (): void => undefined
|
|
415
|
+
onabort = (): void => undefined
|
|
416
|
+
responseText = '{"ok":true}'
|
|
417
|
+
status = 200
|
|
418
|
+
statusText = 'OK'
|
|
419
|
+
responseType = ''
|
|
420
|
+
constructor() { FakeXHR.instance = this }
|
|
421
|
+
open(): void {}
|
|
422
|
+
setRequestHeader(): void {}
|
|
423
|
+
getAllResponseHeaders(): string { return 'content-type: application/json' }
|
|
424
|
+
send(): void {
|
|
425
|
+
this.upload.onprogress(new ProgressEvent('progress', { lengthComputable: true, loaded: 5, total: 10 }))
|
|
426
|
+
this.onprogress(new ProgressEvent('progress', { lengthComputable: true, loaded: 10, total: 10 }))
|
|
427
|
+
this.onload()
|
|
428
|
+
}
|
|
429
|
+
abort(): void { this.onabort() }
|
|
430
|
+
}
|
|
431
|
+
vi.stubGlobal('XMLHttpRequest', FakeXHR)
|
|
432
|
+
const uploads: number[] = []
|
|
433
|
+
const downloads: number[] = []
|
|
434
|
+
const response = await createHTTPClient({ adapter: createXHRAdapter() }).post<{ ok: boolean }>('/upload', { file: 'data' }, {
|
|
435
|
+
onUploadProgress: event => uploads.push(event.percent ?? -1),
|
|
436
|
+
onDownloadProgress: event => downloads.push(event.percent ?? -1)
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
expect(response.data).toEqual({ ok: true })
|
|
440
|
+
expect(uploads).toEqual([50])
|
|
441
|
+
expect(downloads).toEqual([100])
|
|
442
|
+
expect(FakeXHR.instance).toBeDefined()
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
it('httpPlugin 将 HTTPClient 注入应用', () => {
|
|
446
|
+
const client = createHTTPClient()
|
|
447
|
+
let injected: HTTPClient | undefined
|
|
448
|
+
const app = createVobs({
|
|
449
|
+
render: () => createText('http'),
|
|
450
|
+
plugins: [
|
|
451
|
+
httpPlugin({ client }),
|
|
452
|
+
{
|
|
453
|
+
name: 'consumer',
|
|
454
|
+
install(context) {
|
|
455
|
+
injected = context.inject(HTTP_KEY)
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
]
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
app.destroy()
|
|
462
|
+
expect(injected).toBe(client)
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
it('请求诊断事件保留显式路由和导航上下文', async () => {
|
|
466
|
+
const events: import('./debug').HTTPDebugRequest[] = []
|
|
467
|
+
const stop = subscribeHTTPDebug(event => events.push(event))
|
|
468
|
+
const client = createHTTPClient({
|
|
469
|
+
adapter: async () => new Response(JSON.stringify({ ok: true }), {
|
|
470
|
+
headers: { 'content-type': 'application/json' }
|
|
471
|
+
})
|
|
472
|
+
})
|
|
473
|
+
await client.get('/users', {
|
|
474
|
+
debugContext: { route: '/users', navigationId: 7, environment: 'client' }
|
|
475
|
+
})
|
|
476
|
+
stop()
|
|
477
|
+
expect(events).toHaveLength(2)
|
|
478
|
+
expect(events[0]).toMatchObject({ phase: 'start', context: { route: '/users', navigationId: 7 } })
|
|
479
|
+
expect(events[1]).toMatchObject({ phase: 'end', status: 'success', context: { environment: 'client' } })
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
it('重试诊断事件继承显式请求上下文', async () => {
|
|
483
|
+
let attempts = 0
|
|
484
|
+
const events: import('./debug').HTTPDebugRequest[] = []
|
|
485
|
+
const stop = subscribeHTTPDebug(event => events.push(event))
|
|
486
|
+
const client = createHTTPClient({
|
|
487
|
+
retry: 1,
|
|
488
|
+
adapter: async () => {
|
|
489
|
+
attempts++
|
|
490
|
+
return new Response('{}', { status: attempts === 1 ? 503 : 200 })
|
|
491
|
+
}
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
await client.get('/retry-context', { debugContext: { route: '/retry-context', navigationId: 9 } })
|
|
495
|
+
stop()
|
|
496
|
+
expect(events.map(event => event.status)).toEqual(['loading', 'retrying', 'success'])
|
|
497
|
+
expect(events[1]).toMatchObject({ phase: 'retry', context: { route: '/retry-context', navigationId: 9 } })
|
|
498
|
+
})
|
|
499
|
+
})
|