@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 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
+ })