@blueprint-ts/core 4.0.0 → 4.1.0-beta.2
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/CHANGELOG.md +26 -0
- package/docs/.vitepress/config.ts +1 -0
- package/docs/services/requests/drivers.md +68 -3
- package/docs/services/requests/events.md +22 -0
- package/docs/services/requests/file-uploads.md +168 -0
- package/docs/services/requests/request-bodies.md +30 -0
- package/package.json +1 -1
- package/src/requests/BaseRequest.ts +40 -2
- package/src/requests/RequestEvents.enum.ts +2 -1
- package/src/requests/bodies/BinaryBody.ts +31 -0
- package/src/requests/contracts/BodyContract.ts +3 -1
- package/src/requests/contracts/DriverConfigContract.ts +2 -0
- package/src/requests/contracts/HeadersContract.ts +4 -0
- package/src/requests/drivers/contracts/ResponseHandlerContract.ts +2 -2
- package/src/requests/drivers/fetch/FetchDriver.ts +2 -2
- package/src/requests/drivers/fetch/FetchResponse.ts +2 -2
- package/src/requests/drivers/xhr/XMLHttpRequestDriver.ts +138 -0
- package/src/requests/drivers/xhr/XMLHttpRequestResponse.ts +95 -0
- package/src/requests/factories/BinaryBodyFactory.ts +13 -0
- package/src/requests/index.ts +16 -4
- package/src/requests/responses/BaseResponse.ts +2 -2
- package/src/requests/types/RequestUploadProgress.ts +6 -0
- package/tests/service/requests/BaseRequest.test.ts +51 -0
- package/tests/service/requests/BodiesAndFactories.test.ts +24 -0
- package/tests/service/requests/Enums.test.ts +1 -0
- package/tests/service/requests/fetch/FetchDriver.test.ts +21 -3
- package/tests/service/requests/xhr/XMLHttpRequestDriver.test.ts +197 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,29 @@
|
|
|
1
|
+
## 4.1.0-beta.2 - 2026-03-25 (beta)
|
|
2
|
+
|
|
3
|
+
# [4.1.0-beta.2](/compare/4.1.0-beta.1...4.1.0-beta.2) (2026-03-25)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* Tighten response header typing f767572
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* Support Blob and ArrayBuffer bodies a265df3
|
|
14
|
+
## 4.1.0-beta.1 - 2026-03-21 (beta)
|
|
15
|
+
|
|
16
|
+
# [4.1.0-beta.1](/compare/v4.0.0...v4.1.0-beta.1) (2026-03-21)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Bug Fixes
|
|
20
|
+
|
|
21
|
+
* **requests:** export HeaderValue type 15e55c1
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Features
|
|
25
|
+
|
|
26
|
+
* **requests:** add XMLHttpRequest upload progress support 19123df
|
|
1
27
|
## v4.0.0 - 2026-03-01
|
|
2
28
|
|
|
3
29
|
# [4.0.0](/compare/v4.0.0-beta.10...v4.0.0) (2026-03-01)
|
|
@@ -24,6 +24,7 @@ export default defineConfig({
|
|
|
24
24
|
{ text: 'Drivers', link: '/services/requests/drivers' },
|
|
25
25
|
{ text: 'Responses', link: '/services/requests/responses' },
|
|
26
26
|
{ text: 'Request Bodies', link: '/services/requests/request-bodies' },
|
|
27
|
+
{ text: 'File Uploads', link: '/services/requests/file-uploads' },
|
|
27
28
|
{ text: 'Headers', link: '/services/requests/headers' },
|
|
28
29
|
{ text: 'Concurrency', link: '/services/requests/concurrency' },
|
|
29
30
|
{ text: 'Aborting Requests', link: '/services/requests/abort-requests' },
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# Drivers
|
|
2
2
|
|
|
3
|
-
Requests are executed by a request driver. The library includes a
|
|
4
|
-
own by implementing `RequestDriverContract`.
|
|
3
|
+
Requests are executed by a request driver. The library includes a `FetchDriver`, an `XMLHttpRequestDriver`, and also
|
|
4
|
+
lets you provide your own by implementing `RequestDriverContract`.
|
|
5
5
|
|
|
6
|
-
##
|
|
6
|
+
## Fetch Driver
|
|
7
7
|
|
|
8
8
|
```typescript
|
|
9
9
|
import { BaseRequest, FetchDriver } from '@blueprint-ts/core/requests'
|
|
@@ -17,6 +17,71 @@ The `FetchDriver` supports:
|
|
|
17
17
|
- `corsWithCredentials` configuration
|
|
18
18
|
- `AbortSignal` via request config
|
|
19
19
|
|
|
20
|
+
## XMLHttpRequest Driver
|
|
21
|
+
|
|
22
|
+
Use `XMLHttpRequestDriver` when you need upload progress events for file uploads:
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
import { BaseRequest, XMLHttpRequestDriver } from '@blueprint-ts/core/requests'
|
|
26
|
+
|
|
27
|
+
BaseRequest.setRequestDriver(new XMLHttpRequestDriver())
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
It supports the same configuration as `FetchDriver` and additionally forwards upload progress through
|
|
31
|
+
`RequestEvents.UPLOAD_PROGRESS`.
|
|
32
|
+
|
|
33
|
+
That includes:
|
|
34
|
+
|
|
35
|
+
- `corsWithCredentials`
|
|
36
|
+
- `headers`
|
|
37
|
+
- dynamic header callbacks such as `() => getCookie('XSRF-TOKEN')`
|
|
38
|
+
|
|
39
|
+
## Request-Defined Driver
|
|
40
|
+
|
|
41
|
+
If a specific request class should always use a different driver, define it inside the request:
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
import {
|
|
45
|
+
BaseRequest,
|
|
46
|
+
FetchDriver,
|
|
47
|
+
JsonResponse,
|
|
48
|
+
RequestMethodEnum,
|
|
49
|
+
XMLHttpRequestDriver
|
|
50
|
+
} from '@blueprint-ts/core/requests'
|
|
51
|
+
|
|
52
|
+
BaseRequest.setRequestDriver(new FetchDriver())
|
|
53
|
+
|
|
54
|
+
class UploadAvatarRequest extends BaseRequest<boolean, { message: string }, { ok: true }, JsonResponse<{ ok: true }>> {
|
|
55
|
+
public method(): RequestMethodEnum {
|
|
56
|
+
return RequestMethodEnum.POST
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
public url(): string {
|
|
60
|
+
return '/api/v1/avatar'
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
public getResponse(): JsonResponse<{ ok: true }> {
|
|
64
|
+
return new JsonResponse<{ ok: true }>()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
protected override getRequestDriver() {
|
|
68
|
+
return new XMLHttpRequestDriver({
|
|
69
|
+
corsWithCredentials: true,
|
|
70
|
+
headers: {
|
|
71
|
+
'X-XSRF-TOKEN': () => getCookie('XSRF-TOKEN')
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
This keeps the driver choice encapsulated inside the request class while still allowing the application to keep a
|
|
79
|
+
global default driver for everything else.
|
|
80
|
+
|
|
81
|
+
Important: request-defined drivers do not inherit configuration from the globally registered driver instance. If your
|
|
82
|
+
upload request needs credential support or shared headers, configure them on the `XMLHttpRequestDriver` you return from
|
|
83
|
+
`getRequestDriver()`.
|
|
84
|
+
|
|
20
85
|
## Custom Driver
|
|
21
86
|
|
|
22
87
|
To implement your own driver, implement `RequestDriverContract` and return a `ResponseHandlerContract`:
|
|
@@ -5,6 +5,7 @@ Requests can emit lifecycle events via `BaseRequest.on(...)`.
|
|
|
5
5
|
## Available Events
|
|
6
6
|
|
|
7
7
|
- `RequestEvents.LOADING`: Emits `true` when a request starts and `false` when it finishes.
|
|
8
|
+
- `RequestEvents.UPLOAD_PROGRESS`: Emits upload progress for drivers that support it, such as `XMLHttpRequestDriver`.
|
|
8
9
|
|
|
9
10
|
## Loading Event
|
|
10
11
|
|
|
@@ -29,3 +30,24 @@ request.on<boolean>(RequestEvents.LOADING, (isLoading) => {
|
|
|
29
30
|
// isLoading is typed as boolean
|
|
30
31
|
})
|
|
31
32
|
```
|
|
33
|
+
|
|
34
|
+
## Upload Progress Event
|
|
35
|
+
|
|
36
|
+
Use `RequestEvents.UPLOAD_PROGRESS` to drive file upload progress indicators:
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
import { RequestEvents, type RequestUploadProgress } from '@blueprint-ts/core/requests'
|
|
40
|
+
|
|
41
|
+
request.on<RequestUploadProgress>(RequestEvents.UPLOAD_PROGRESS, (progress) => {
|
|
42
|
+
console.log(progress.loaded, progress.total, progress.progress)
|
|
43
|
+
})
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The payload contains:
|
|
47
|
+
|
|
48
|
+
- `loaded`: Bytes uploaded so far.
|
|
49
|
+
- `total`: Total bytes when the browser can compute it.
|
|
50
|
+
- `lengthComputable`: Whether `total` is reliable.
|
|
51
|
+
- `progress`: A normalized value between `0` and `1` when `total` is known.
|
|
52
|
+
|
|
53
|
+
Note: The default `FetchDriver` does not emit upload progress. Use `XMLHttpRequestDriver` for upload progress support.
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# File Uploads
|
|
2
|
+
|
|
3
|
+
Use `FormDataFactory` for multipart uploads with fields, and use `BinaryBodyFactory` when the endpoint expects a raw
|
|
4
|
+
binary body such as a chunk `PUT`. Use `XMLHttpRequestDriver` when the consuming application needs upload progress for a
|
|
5
|
+
progress bar.
|
|
6
|
+
|
|
7
|
+
Choose the body factory based on the wire format your endpoint expects:
|
|
8
|
+
|
|
9
|
+
- Use `FormDataFactory` when the request includes normal fields plus one or more files.
|
|
10
|
+
- Use `BinaryBodyFactory` when the request body itself is the file or chunk.
|
|
11
|
+
- Use `FetchDriver` if you just need to send the upload.
|
|
12
|
+
- Use `XMLHttpRequestDriver` if you also need upload progress events.
|
|
13
|
+
|
|
14
|
+
## Request Definition
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
import {
|
|
18
|
+
BaseRequest,
|
|
19
|
+
FormDataFactory,
|
|
20
|
+
JsonResponse,
|
|
21
|
+
RequestMethodEnum,
|
|
22
|
+
XMLHttpRequestDriver
|
|
23
|
+
} from '@blueprint-ts/core/requests'
|
|
24
|
+
|
|
25
|
+
interface UploadAvatarPayload {
|
|
26
|
+
avatar: File
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface UploadAvatarResponse {
|
|
30
|
+
id: string
|
|
31
|
+
url: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
class UploadAvatarRequest extends BaseRequest<
|
|
35
|
+
boolean,
|
|
36
|
+
{ message: string },
|
|
37
|
+
UploadAvatarResponse,
|
|
38
|
+
JsonResponse<UploadAvatarResponse>,
|
|
39
|
+
UploadAvatarPayload
|
|
40
|
+
> {
|
|
41
|
+
public method(): RequestMethodEnum {
|
|
42
|
+
return RequestMethodEnum.POST
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
public url(): string {
|
|
46
|
+
return '/api/v1/avatar'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public getResponse(): JsonResponse<UploadAvatarResponse> {
|
|
50
|
+
return new JsonResponse<UploadAvatarResponse>()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
public override getRequestBodyFactory() {
|
|
54
|
+
return new FormDataFactory<UploadAvatarPayload>()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
protected override getRequestDriver() {
|
|
58
|
+
return new XMLHttpRequestDriver({
|
|
59
|
+
corsWithCredentials: true,
|
|
60
|
+
headers: {
|
|
61
|
+
'X-XSRF-TOKEN': () => getCookie('XSRF-TOKEN')
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Global Default Driver
|
|
69
|
+
|
|
70
|
+
You can keep `FetchDriver` as the application default. The upload request above will still use `XMLHttpRequestDriver`
|
|
71
|
+
because it defines its own driver internally:
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
import { BaseRequest, FetchDriver } from '@blueprint-ts/core/requests'
|
|
75
|
+
|
|
76
|
+
BaseRequest.setRequestDriver(new FetchDriver())
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Important: the upload request's `XMLHttpRequestDriver` does not inherit config from the global `FetchDriver`. If the
|
|
80
|
+
upload request needs credentials or shared headers, define them on the `XMLHttpRequestDriver` returned by
|
|
81
|
+
`getRequestDriver()`.
|
|
82
|
+
|
|
83
|
+
## Listening for Upload Progress
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
import { RequestEvents, type RequestUploadProgress } from '@blueprint-ts/core/requests'
|
|
87
|
+
|
|
88
|
+
const request = new UploadAvatarRequest()
|
|
89
|
+
|
|
90
|
+
request.on<RequestUploadProgress>(RequestEvents.UPLOAD_PROGRESS, (progress) => {
|
|
91
|
+
if (!progress.lengthComputable || progress.progress === undefined) {
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
progressBar.value = progress.progress * 100
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
await request.setBody({
|
|
99
|
+
avatar: fileInput.files![0],
|
|
100
|
+
}).send()
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Raw Chunk Uploads
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
import {
|
|
107
|
+
BaseRequest,
|
|
108
|
+
BinaryBodyFactory,
|
|
109
|
+
JsonResponse,
|
|
110
|
+
RequestMethodEnum,
|
|
111
|
+
XMLHttpRequestDriver
|
|
112
|
+
} from '@blueprint-ts/core/requests'
|
|
113
|
+
|
|
114
|
+
interface UploadPartResponse {
|
|
115
|
+
etag: string
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
class UploadPartRequest extends BaseRequest<
|
|
119
|
+
boolean,
|
|
120
|
+
{ message: string },
|
|
121
|
+
UploadPartResponse,
|
|
122
|
+
JsonResponse<UploadPartResponse>,
|
|
123
|
+
Uint8Array
|
|
124
|
+
> {
|
|
125
|
+
public method(): RequestMethodEnum {
|
|
126
|
+
return RequestMethodEnum.PUT
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
public url(): string {
|
|
130
|
+
return '/api/v1/uploads/part'
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
public getResponse(): JsonResponse<UploadPartResponse> {
|
|
134
|
+
return new JsonResponse<UploadPartResponse>()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
public override getRequestBodyFactory() {
|
|
138
|
+
return new BinaryBodyFactory<Uint8Array>('application/octet-stream')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
public override requestHeaders() {
|
|
142
|
+
return {
|
|
143
|
+
'X-Part-Number': '1'
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
protected override getRequestDriver() {
|
|
148
|
+
return new XMLHttpRequestDriver()
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
In this example, `setBody(...)` can receive a `Blob`, `ArrayBuffer`, `Uint8Array`, or another typed-array/data-view
|
|
154
|
+
payload supported by `BinaryBodyFactory`.
|
|
155
|
+
|
|
156
|
+
## Notes
|
|
157
|
+
|
|
158
|
+
- Upload progress requires `XMLHttpRequestDriver`. The default `FetchDriver` does not emit upload progress events.
|
|
159
|
+
- Define `XMLHttpRequestDriver` inside the upload request class when that request should always support progress.
|
|
160
|
+
- `BinaryBodyFactory` only sets `Content-Type` automatically when the body is a `Blob` with a non-empty `type`.
|
|
161
|
+
For `ArrayBuffer` and typed-array uploads, pass the expected content type explicitly.
|
|
162
|
+
- `XMLHttpRequestDriver` supports the same `corsWithCredentials` and `headers` options as `FetchDriver`, including
|
|
163
|
+
header callbacks.
|
|
164
|
+
- Request-defined drivers do not automatically inherit config from the globally registered driver.
|
|
165
|
+
- Some browsers cannot compute a reliable total size for every upload. Check `lengthComputable` before rendering a
|
|
166
|
+
percentage.
|
|
167
|
+
- Upload event listeners may cause CORS preflight requests on cross-origin uploads. Ensure the server is configured
|
|
168
|
+
accordingly.
|
|
@@ -54,6 +54,36 @@ public override getRequestBodyFactory() {
|
|
|
54
54
|
}
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
+
If you want to show upload progress for multipart file uploads, see [File Uploads](/services/requests/file-uploads).
|
|
58
|
+
|
|
59
|
+
## Raw Binary Bodies
|
|
60
|
+
|
|
61
|
+
Use `BinaryBodyFactory` when the request body should be sent as raw binary instead of multipart form data. This is a
|
|
62
|
+
better fit for chunk uploads, binary artifact pushes, and endpoints that expect the request body as-is:
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
import { BinaryBodyFactory } from '@blueprint-ts/core/requests'
|
|
66
|
+
|
|
67
|
+
public override getRequestBodyFactory() {
|
|
68
|
+
return new BinaryBodyFactory<ArrayBuffer>('application/octet-stream')
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`BinaryBodyFactory` supports:
|
|
73
|
+
|
|
74
|
+
- `Blob`
|
|
75
|
+
- `ArrayBuffer`
|
|
76
|
+
- typed-array and view values such as `Uint8Array` or `DataView`
|
|
77
|
+
|
|
78
|
+
`Content-Type` resolution works like this:
|
|
79
|
+
|
|
80
|
+
- If you pass a content type to `BinaryBodyFactory`, Blueprint sends that `Content-Type` header.
|
|
81
|
+
- Otherwise, if the body is a `Blob` with a non-empty `type`, Blueprint uses `Blob.type`.
|
|
82
|
+
- Otherwise, Blueprint does not add a `Content-Type` header for you.
|
|
83
|
+
|
|
84
|
+
`BinaryBodyFactory` works with both `FetchDriver` and `XMLHttpRequestDriver`. Choose `XMLHttpRequestDriver` only when
|
|
85
|
+
the consuming application needs upload progress events.
|
|
86
|
+
|
|
57
87
|
## Custom Body Factories
|
|
58
88
|
|
|
59
89
|
You can implement your own body factory by returning a `BodyContract` with custom headers and serialization logic.
|
package/package.json
CHANGED
|
@@ -7,6 +7,7 @@ import { ResponseException } from './exceptions/ResponseException'
|
|
|
7
7
|
import { StaleResponseException } from './exceptions/StaleResponseException'
|
|
8
8
|
import { type DriverConfigContract } from './contracts/DriverConfigContract'
|
|
9
9
|
import { type BodyFactoryContract } from './contracts/BodyFactoryContract'
|
|
10
|
+
import { type BodyContract } from './contracts/BodyContract'
|
|
10
11
|
import { type RequestLoaderContract } from './contracts/RequestLoaderContract'
|
|
11
12
|
import { type RequestDriverContract } from './contracts/RequestDriverContract'
|
|
12
13
|
import { type RequestLoaderFactoryContract } from './contracts/RequestLoaderFactoryContract'
|
|
@@ -15,6 +16,7 @@ import { type HeadersContract } from './contracts/HeadersContract'
|
|
|
15
16
|
import { type ResponseHandlerContract } from './drivers/contracts/ResponseHandlerContract'
|
|
16
17
|
import { type ResponseContract } from './contracts/ResponseContract'
|
|
17
18
|
import { type RequestConcurrencyOptions } from './types/RequestConcurrencyOptions'
|
|
19
|
+
import { type RequestUploadProgress } from './types/RequestUploadProgress'
|
|
18
20
|
import { RequestConcurrencyMode } from './RequestConcurrencyMode.enum'
|
|
19
21
|
import { mergeDeep } from '../support/helpers'
|
|
20
22
|
import { v4 as uuidv4 } from 'uuid'
|
|
@@ -164,8 +166,9 @@ export abstract class BaseRequest<
|
|
|
164
166
|
const responseSkeleton = this.getResponse()
|
|
165
167
|
|
|
166
168
|
const requestBody = this.requestBody === undefined ? undefined : this.getRequestBodyFactory()?.make(this.requestBody)
|
|
169
|
+
const requestConfig = this.buildRequestConfig(requestBody, concurrencyKey, sequence, useLatest)
|
|
167
170
|
|
|
168
|
-
return
|
|
171
|
+
return this.resolveRequestDriver()
|
|
169
172
|
.send(
|
|
170
173
|
this.buildUrl(),
|
|
171
174
|
this.method(),
|
|
@@ -174,7 +177,7 @@ export abstract class BaseRequest<
|
|
|
174
177
|
...this.requestHeaders()
|
|
175
178
|
},
|
|
176
179
|
requestBody,
|
|
177
|
-
|
|
180
|
+
requestConfig
|
|
178
181
|
)
|
|
179
182
|
.then(async (responseHandler: ResponseHandlerContract) => {
|
|
180
183
|
if (useLatest && !this.isLatestSequence(concurrencyKey, sequence)) {
|
|
@@ -275,9 +278,44 @@ export abstract class BaseRequest<
|
|
|
275
278
|
return undefined
|
|
276
279
|
}
|
|
277
280
|
|
|
281
|
+
protected buildRequestConfig(
|
|
282
|
+
requestBody: BodyContract | undefined,
|
|
283
|
+
concurrencyKey: string,
|
|
284
|
+
sequence: number,
|
|
285
|
+
useLatest: boolean
|
|
286
|
+
): DriverConfigContract {
|
|
287
|
+
const config = this.getConfig() ?? {}
|
|
288
|
+
const onUploadProgress = config.onUploadProgress
|
|
289
|
+
|
|
290
|
+
if (requestBody === undefined) {
|
|
291
|
+
return config
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
...config,
|
|
296
|
+
onUploadProgress: (progress: RequestUploadProgress) => {
|
|
297
|
+
onUploadProgress?.(progress)
|
|
298
|
+
|
|
299
|
+
if (useLatest && !this.isLatestSequence(concurrencyKey, sequence)) {
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
this.dispatch<RequestUploadProgress>(RequestEvents.UPLOAD_PROGRESS, progress)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
278
308
|
protected getConfig(): DriverConfigContract | undefined {
|
|
279
309
|
return {
|
|
280
310
|
abortSignal: this.abortSignal
|
|
281
311
|
}
|
|
282
312
|
}
|
|
313
|
+
|
|
314
|
+
protected resolveRequestDriver(): RequestDriverContract {
|
|
315
|
+
return this.getRequestDriver() ?? BaseRequest.requestDriver
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
protected getRequestDriver(): RequestDriverContract | undefined {
|
|
319
|
+
return undefined
|
|
320
|
+
}
|
|
283
321
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type BodyContent, type BodyContract } from '../contracts/BodyContract'
|
|
2
|
+
import { type HeadersContract } from '../contracts/HeadersContract'
|
|
3
|
+
|
|
4
|
+
export class BinaryBody<RequestBody extends Exclude<BodyContent, string | FormData>> implements BodyContract {
|
|
5
|
+
public constructor(
|
|
6
|
+
protected data: RequestBody,
|
|
7
|
+
protected contentType?: string
|
|
8
|
+
) {}
|
|
9
|
+
|
|
10
|
+
public getHeaders(): HeadersContract {
|
|
11
|
+
const contentType = this.resolveContentType()
|
|
12
|
+
|
|
13
|
+
return contentType === undefined ? {} : { 'Content-Type': contentType }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
public getContent(): RequestBody {
|
|
17
|
+
return this.data
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
protected resolveContentType(): string | undefined {
|
|
21
|
+
if (this.contentType !== undefined) {
|
|
22
|
+
return this.contentType
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (typeof Blob !== 'undefined' && this.data instanceof Blob && this.data.type !== '') {
|
|
26
|
+
return this.data.type
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return undefined
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { type HeadersContract } from './HeadersContract'
|
|
2
2
|
|
|
3
|
+
export type BodyContent = string | FormData | Blob | ArrayBuffer | ArrayBufferView<ArrayBuffer>
|
|
4
|
+
|
|
3
5
|
export interface BodyContract {
|
|
4
|
-
getContent():
|
|
6
|
+
getContent(): BodyContent
|
|
5
7
|
|
|
6
8
|
getHeaders(): HeadersContract
|
|
7
9
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { type HeadersContract } from './HeadersContract'
|
|
2
|
+
import { type RequestUploadProgress } from '../types/RequestUploadProgress'
|
|
2
3
|
|
|
3
4
|
export interface DriverConfigContract {
|
|
4
5
|
corsWithCredentials?: boolean | undefined
|
|
5
6
|
abortSignal?: AbortSignal | undefined
|
|
6
7
|
headers?: HeadersContract | undefined
|
|
8
|
+
onUploadProgress?: ((progress: RequestUploadProgress) => void) | undefined
|
|
7
9
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type ResolvedHeadersContract } from '../../contracts/HeadersContract'
|
|
2
2
|
|
|
3
3
|
export interface ResponseHandlerContract {
|
|
4
4
|
getStatusCode(): number | undefined
|
|
5
|
-
getHeaders():
|
|
5
|
+
getHeaders(): ResolvedHeadersContract
|
|
6
6
|
getRawResponse(): Response
|
|
7
7
|
json<ResponseBodyInterface>(): Promise<ResponseBodyInterface>
|
|
8
8
|
text(): Promise<string>
|
|
@@ -2,7 +2,7 @@ import { ResponseException } from '../../exceptions/ResponseException'
|
|
|
2
2
|
import { FetchResponse } from './FetchResponse'
|
|
3
3
|
import { RequestMethodEnum } from '../../RequestMethod.enum'
|
|
4
4
|
import { type HeadersContract, type HeaderValue } from '../../contracts/HeadersContract'
|
|
5
|
-
import { type BodyContract } from '../../contracts/BodyContract'
|
|
5
|
+
import { type BodyContent, type BodyContract } from '../../contracts/BodyContract'
|
|
6
6
|
import { type RequestDriverContract } from '../../contracts/RequestDriverContract'
|
|
7
7
|
import { type DriverConfigContract } from '../../contracts/DriverConfigContract'
|
|
8
8
|
import { type ResponseHandlerContract } from '../contracts/ResponseHandlerContract'
|
|
@@ -17,7 +17,7 @@ interface FetchDriverConfig {
|
|
|
17
17
|
headers: HeadersContract
|
|
18
18
|
credentials?: FetchDriverCredentialConfigEnum | undefined
|
|
19
19
|
signal?: AbortSignal | undefined
|
|
20
|
-
body?:
|
|
20
|
+
body?: BodyContent | URLSearchParams | undefined
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export class FetchDriver implements RequestDriverContract {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type ResolvedHeadersContract } from '../../contracts/HeadersContract'
|
|
2
2
|
import { type ResponseHandlerContract } from '../contracts/ResponseHandlerContract'
|
|
3
3
|
|
|
4
4
|
export class FetchResponse implements ResponseHandlerContract {
|
|
@@ -8,7 +8,7 @@ export class FetchResponse implements ResponseHandlerContract {
|
|
|
8
8
|
return this.response.status
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
public getHeaders():
|
|
11
|
+
public getHeaders(): ResolvedHeadersContract {
|
|
12
12
|
return Object.fromEntries(this.response.headers)
|
|
13
13
|
}
|
|
14
14
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { ResponseException } from '../../exceptions/ResponseException'
|
|
2
|
+
import { RequestMethodEnum } from '../../RequestMethod.enum'
|
|
3
|
+
import { type HeadersContract, type HeaderValue } from '../../contracts/HeadersContract'
|
|
4
|
+
import { type BodyContract } from '../../contracts/BodyContract'
|
|
5
|
+
import { type RequestDriverContract } from '../../contracts/RequestDriverContract'
|
|
6
|
+
import { type DriverConfigContract } from '../../contracts/DriverConfigContract'
|
|
7
|
+
import { type ResponseHandlerContract } from '../contracts/ResponseHandlerContract'
|
|
8
|
+
import { XMLHttpRequestResponse } from './XMLHttpRequestResponse'
|
|
9
|
+
|
|
10
|
+
export class XMLHttpRequestDriver implements RequestDriverContract {
|
|
11
|
+
public constructor(protected config?: DriverConfigContract) {}
|
|
12
|
+
|
|
13
|
+
public async send(
|
|
14
|
+
url: URL | string,
|
|
15
|
+
method: RequestMethodEnum,
|
|
16
|
+
headers: HeadersContract,
|
|
17
|
+
body?: BodyContract,
|
|
18
|
+
requestConfig?: DriverConfigContract
|
|
19
|
+
): Promise<ResponseHandlerContract> {
|
|
20
|
+
const mergedConfig: DriverConfigContract = {
|
|
21
|
+
...this.config,
|
|
22
|
+
...(requestConfig ?? {})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const mergedHeaders: HeadersContract = {
|
|
26
|
+
...this.config?.headers,
|
|
27
|
+
...headers,
|
|
28
|
+
...body?.getHeaders()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const resolvedHeaders = this.resolveHeaders(mergedHeaders)
|
|
32
|
+
|
|
33
|
+
return await new Promise<ResponseHandlerContract>((resolve, reject) => {
|
|
34
|
+
const request = new XMLHttpRequest()
|
|
35
|
+
const requestUrl = url instanceof URL ? url.toString() : url
|
|
36
|
+
const requestBody = [RequestMethodEnum.GET, RequestMethodEnum.HEAD].includes(method) ? undefined : body?.getContent()
|
|
37
|
+
const abortSignal = mergedConfig.abortSignal
|
|
38
|
+
const handleAbortSignal = () => request.abort()
|
|
39
|
+
|
|
40
|
+
const cleanup = () => {
|
|
41
|
+
request.onload = null
|
|
42
|
+
request.onerror = null
|
|
43
|
+
request.onabort = null
|
|
44
|
+
|
|
45
|
+
if (request.upload) {
|
|
46
|
+
request.upload.onprogress = null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
abortSignal?.removeEventListener('abort', handleAbortSignal)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
request.open(method, requestUrl, true)
|
|
53
|
+
request.responseType = 'blob'
|
|
54
|
+
request.withCredentials = this.getCorsWithCredentials(mergedConfig.corsWithCredentials)
|
|
55
|
+
|
|
56
|
+
for (const key in resolvedHeaders) {
|
|
57
|
+
request.setRequestHeader(key, resolvedHeaders[key] as string)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
request.onload = () => {
|
|
61
|
+
cleanup()
|
|
62
|
+
|
|
63
|
+
if (request.status === 0) {
|
|
64
|
+
reject(new Error('No response received.'))
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const response = new XMLHttpRequestResponse(request)
|
|
69
|
+
|
|
70
|
+
if (request.status < 200 || request.status >= 300) {
|
|
71
|
+
reject(new ResponseException(response))
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
resolve(response)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
request.onerror = () => {
|
|
79
|
+
cleanup()
|
|
80
|
+
reject(new Error('Network request failed.'))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
request.onabort = () => {
|
|
84
|
+
cleanup()
|
|
85
|
+
reject(new DOMException('The operation was aborted.', 'AbortError'))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (request.upload) {
|
|
89
|
+
request.upload.onprogress = (event: ProgressEvent<EventTarget>) => {
|
|
90
|
+
const total = event.lengthComputable ? event.total : undefined
|
|
91
|
+
|
|
92
|
+
mergedConfig.onUploadProgress?.({
|
|
93
|
+
loaded: event.loaded,
|
|
94
|
+
total: total,
|
|
95
|
+
lengthComputable: event.lengthComputable,
|
|
96
|
+
progress: total === undefined || total === 0 ? undefined : event.loaded / total
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (abortSignal?.aborted) {
|
|
102
|
+
handleAbortSignal()
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
abortSignal?.addEventListener('abort', handleAbortSignal, { once: true })
|
|
107
|
+
request.send(requestBody)
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
protected getCorsWithCredentials(corsWithCredentials: boolean | undefined): boolean {
|
|
112
|
+
if (corsWithCredentials === true) {
|
|
113
|
+
return true
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (corsWithCredentials === false) {
|
|
117
|
+
return false
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return this.config?.corsWithCredentials ?? false
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
protected resolveHeaders(headers: HeadersContract): HeadersContract {
|
|
124
|
+
const resolved: HeadersContract = {}
|
|
125
|
+
|
|
126
|
+
for (const key in headers) {
|
|
127
|
+
const value: HeaderValue | undefined = headers[key]
|
|
128
|
+
|
|
129
|
+
if (value === undefined) {
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
resolved[key] = typeof value === 'function' ? value() : value
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return resolved
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { type ResolvedHeadersContract } from '../../contracts/HeadersContract'
|
|
2
|
+
import { type ResponseHandlerContract } from '../contracts/ResponseHandlerContract'
|
|
3
|
+
|
|
4
|
+
export class XMLHttpRequestResponse implements ResponseHandlerContract {
|
|
5
|
+
protected response: Response
|
|
6
|
+
protected headers: ResolvedHeadersContract
|
|
7
|
+
|
|
8
|
+
public constructor(protected request: XMLHttpRequest) {
|
|
9
|
+
this.headers = this.parseHeaders(request.getAllResponseHeaders())
|
|
10
|
+
this.response = new Response(this.getResponseBody(), {
|
|
11
|
+
status: request.status,
|
|
12
|
+
statusText: request.statusText,
|
|
13
|
+
headers: Object.entries(this.headers).map(([key, value]) => [key, String(value)])
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
public getStatusCode(): number | undefined {
|
|
18
|
+
return this.request.status
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public getHeaders(): ResolvedHeadersContract {
|
|
22
|
+
return this.headers
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
public getRawResponse(): Response {
|
|
26
|
+
return this.response
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
public async json<ResponseBodyInterface>(): Promise<ResponseBodyInterface> {
|
|
30
|
+
return await this.response.json()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public async text(): Promise<string> {
|
|
34
|
+
return await this.response.text()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
public async blob(): Promise<Blob> {
|
|
38
|
+
return await this.response.blob()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
protected getResponseBody(): Blob | string | null {
|
|
42
|
+
if ([204, 205, 304].includes(this.request.status)) {
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (this.request.response === null || this.request.response === undefined) {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (this.isBlobLike(this.request.response) || typeof this.request.response === 'string') {
|
|
51
|
+
return this.request.response
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return new Blob([this.request.response])
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
protected isBlobLike(value: unknown): value is Blob {
|
|
58
|
+
return (
|
|
59
|
+
value instanceof Blob ||
|
|
60
|
+
(typeof value === 'object' &&
|
|
61
|
+
value !== null &&
|
|
62
|
+
typeof (value as Blob).arrayBuffer === 'function' &&
|
|
63
|
+
typeof (value as Blob).stream === 'function' &&
|
|
64
|
+
typeof (value as Blob).text === 'function')
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
protected parseHeaders(rawHeaders: string): ResolvedHeadersContract {
|
|
69
|
+
const headers: ResolvedHeadersContract = {}
|
|
70
|
+
const lines = rawHeaders.trim()
|
|
71
|
+
|
|
72
|
+
if (lines.length === 0) {
|
|
73
|
+
return headers
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const line of lines.split(/\r?\n/)) {
|
|
77
|
+
const separatorIndex = line.indexOf(':')
|
|
78
|
+
|
|
79
|
+
if (separatorIndex === -1) {
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const key = line.slice(0, separatorIndex).trim()
|
|
84
|
+
const value = line.slice(separatorIndex + 1).trim()
|
|
85
|
+
|
|
86
|
+
if (key.length === 0) {
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
headers[key] = key in headers ? `${String(headers[key])}, ${value}` : value
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return headers
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { BinaryBody } from '../bodies/BinaryBody'
|
|
2
|
+
import { type BodyFactoryContract } from '../contracts/BodyFactoryContract'
|
|
3
|
+
import { type BodyContent, type BodyContract } from '../contracts/BodyContract'
|
|
4
|
+
|
|
5
|
+
export type BinaryBodyContent = Exclude<BodyContent, string | FormData>
|
|
6
|
+
|
|
7
|
+
export class BinaryBodyFactory<RequestBodyInterface extends BinaryBodyContent> implements BodyFactoryContract<RequestBodyInterface> {
|
|
8
|
+
public constructor(protected contentType?: string) {}
|
|
9
|
+
|
|
10
|
+
public make(body: RequestBodyInterface): BodyContract {
|
|
11
|
+
return new BinaryBody<RequestBodyInterface>(body, this.contentType)
|
|
12
|
+
}
|
|
13
|
+
}
|
package/src/requests/index.ts
CHANGED
|
@@ -9,9 +9,11 @@ import { RequestErrorRouter } from './RequestErrorRouter'
|
|
|
9
9
|
import { RequestEvents } from './RequestEvents.enum'
|
|
10
10
|
import { RequestMethodEnum } from './RequestMethod.enum'
|
|
11
11
|
import { RequestConcurrencyMode } from './RequestConcurrencyMode.enum'
|
|
12
|
+
import { BinaryBody } from './bodies/BinaryBody'
|
|
12
13
|
import { JsonBodyFactory } from './factories/JsonBodyFactory'
|
|
14
|
+
import { BinaryBodyFactory, type BinaryBodyContent } from './factories/BinaryBodyFactory'
|
|
13
15
|
import { FormDataFactory } from './factories/FormDataFactory'
|
|
14
|
-
import { type BodyContract } from './contracts/BodyContract'
|
|
16
|
+
import { type BodyContent, type BodyContract } from './contracts/BodyContract'
|
|
15
17
|
import { type RequestLoaderContract } from './contracts/RequestLoaderContract'
|
|
16
18
|
import { type RequestDriverContract } from './contracts/RequestDriverContract'
|
|
17
19
|
import { type RequestLoaderFactoryContract } from './contracts/RequestLoaderFactoryContract'
|
|
@@ -21,8 +23,10 @@ import { type ResponseHandlerContract } from './drivers/contracts/ResponseHandle
|
|
|
21
23
|
import { type BaseRequestContract } from './contracts/BaseRequestContract'
|
|
22
24
|
import { ResponseException } from './exceptions/ResponseException'
|
|
23
25
|
import { StaleResponseException } from './exceptions/StaleResponseException'
|
|
24
|
-
import { type HeadersContract } from './contracts/HeadersContract'
|
|
26
|
+
import { type HeaderValue, type HeadersContract, type ResolvedHeadersContract } from './contracts/HeadersContract'
|
|
25
27
|
import { type RequestConcurrencyOptions } from './types/RequestConcurrencyOptions'
|
|
28
|
+
import { type RequestUploadProgress } from './types/RequestUploadProgress'
|
|
29
|
+
import { XMLHttpRequestDriver } from './drivers/xhr/XMLHttpRequestDriver'
|
|
26
30
|
|
|
27
31
|
export {
|
|
28
32
|
FetchDriver,
|
|
@@ -38,11 +42,16 @@ export {
|
|
|
38
42
|
RequestConcurrencyMode,
|
|
39
43
|
ResponseException,
|
|
40
44
|
StaleResponseException,
|
|
45
|
+
BinaryBody,
|
|
41
46
|
JsonBodyFactory,
|
|
42
|
-
|
|
47
|
+
BinaryBodyFactory,
|
|
48
|
+
FormDataFactory,
|
|
49
|
+
XMLHttpRequestDriver
|
|
43
50
|
}
|
|
44
51
|
|
|
45
52
|
export type {
|
|
53
|
+
BodyContent,
|
|
54
|
+
BinaryBodyContent,
|
|
46
55
|
RequestDriverContract,
|
|
47
56
|
RequestLoaderContract,
|
|
48
57
|
BodyContract,
|
|
@@ -51,6 +60,9 @@ export type {
|
|
|
51
60
|
BodyFactoryContract,
|
|
52
61
|
ResponseHandlerContract,
|
|
53
62
|
BaseRequestContract,
|
|
63
|
+
HeaderValue,
|
|
54
64
|
HeadersContract,
|
|
55
|
-
|
|
65
|
+
ResolvedHeadersContract,
|
|
66
|
+
RequestConcurrencyOptions,
|
|
67
|
+
RequestUploadProgress
|
|
56
68
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ResponseHandlerContract } from '../drivers/contracts/ResponseHandlerContract'
|
|
2
|
-
import { type
|
|
2
|
+
import { type ResolvedHeadersContract } from '../contracts/HeadersContract'
|
|
3
3
|
import { type ResponseContract } from '../contracts/ResponseContract'
|
|
4
4
|
|
|
5
5
|
export abstract class BaseResponse<ResponseInterface> implements ResponseContract<ResponseInterface> {
|
|
@@ -27,7 +27,7 @@ export abstract class BaseResponse<ResponseInterface> implements ResponseContrac
|
|
|
27
27
|
return this.response?.getStatusCode()
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
public getHeaders():
|
|
30
|
+
public getHeaders(): ResolvedHeadersContract | undefined {
|
|
31
31
|
return this.response?.getHeaders()
|
|
32
32
|
}
|
|
33
33
|
|
|
@@ -118,6 +118,33 @@ describe('BaseRequest', () => {
|
|
|
118
118
|
expect(body?.getContent()).toBe('{"name":"Ada"}')
|
|
119
119
|
})
|
|
120
120
|
|
|
121
|
+
it('dispatches upload progress events from the driver config callback', async () => {
|
|
122
|
+
const driver: RequestDriverContract = {
|
|
123
|
+
send: vi.fn().mockImplementation(async (_url, _method, _headers, _body, requestConfig) => {
|
|
124
|
+
requestConfig?.onUploadProgress?.({
|
|
125
|
+
loaded: 5,
|
|
126
|
+
total: 10,
|
|
127
|
+
lengthComputable: true,
|
|
128
|
+
progress: 0.5,
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
return createResponseHandler()
|
|
132
|
+
}),
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
BaseRequest.setRequestDriver(driver)
|
|
136
|
+
|
|
137
|
+
const request = new TestRequest()
|
|
138
|
+
const progressEvents: Array<number | undefined> = []
|
|
139
|
+
|
|
140
|
+
request.on(RequestEvents.UPLOAD_PROGRESS, (value: { progress?: number }) => progressEvents.push(value.progress))
|
|
141
|
+
request.setBody({ name: 'Ada' })
|
|
142
|
+
|
|
143
|
+
await request.send()
|
|
144
|
+
|
|
145
|
+
expect(progressEvents).toEqual([0.5])
|
|
146
|
+
})
|
|
147
|
+
|
|
121
148
|
it('throws when loading state is requested without a loader', () => {
|
|
122
149
|
const request = new TestRequest()
|
|
123
150
|
|
|
@@ -196,4 +223,28 @@ describe('BaseRequest', () => {
|
|
|
196
223
|
await expect(request.send()).rejects.toBe(responseException)
|
|
197
224
|
expect(handleSpy).toHaveBeenCalledTimes(1)
|
|
198
225
|
})
|
|
226
|
+
|
|
227
|
+
it('uses a request-defined driver when provided', async () => {
|
|
228
|
+
const globalDriver: RequestDriverContract = {
|
|
229
|
+
send: vi.fn().mockResolvedValue(createResponseHandler()),
|
|
230
|
+
}
|
|
231
|
+
const requestDriver: RequestDriverContract = {
|
|
232
|
+
send: vi.fn().mockResolvedValue(createResponseHandler()),
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
BaseRequest.setRequestDriver(globalDriver)
|
|
236
|
+
|
|
237
|
+
class DriverSpecificRequest extends TestRequest {
|
|
238
|
+
protected override getRequestDriver(): RequestDriverContract {
|
|
239
|
+
return requestDriver
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const request = new DriverSpecificRequest()
|
|
244
|
+
|
|
245
|
+
await request.send()
|
|
246
|
+
|
|
247
|
+
expect(requestDriver.send).toHaveBeenCalledTimes(1)
|
|
248
|
+
expect(globalDriver.send).not.toHaveBeenCalled()
|
|
249
|
+
})
|
|
199
250
|
})
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { BinaryBody } from '../../../src/requests/bodies/BinaryBody'
|
|
2
3
|
import { JsonBody } from '../../../src/requests/bodies/JsonBody'
|
|
4
|
+
import { BinaryBodyFactory } from '../../../src/requests/factories/BinaryBodyFactory'
|
|
3
5
|
import { JsonBodyFactory } from '../../../src/requests/factories/JsonBodyFactory'
|
|
4
6
|
import { FormDataFactory } from '../../../src/requests/factories/FormDataFactory'
|
|
5
7
|
import { FormDataBody } from '../../../src/requests/bodies/FormDataBody'
|
|
@@ -19,6 +21,28 @@ describe('Request bodies and factories', () => {
|
|
|
19
21
|
expect(body).toBeInstanceOf(JsonBody)
|
|
20
22
|
})
|
|
21
23
|
|
|
24
|
+
it('BinaryBody returns explicit content type and binary content', () => {
|
|
25
|
+
const body = new BinaryBody(new Uint8Array([1, 2, 3]), 'application/octet-stream')
|
|
26
|
+
|
|
27
|
+
expect(body.getHeaders()).toEqual({ 'Content-Type': 'application/octet-stream' })
|
|
28
|
+
expect(body.getContent()).toEqual(new Uint8Array([1, 2, 3]))
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('BinaryBody uses Blob mime type when no explicit content type is given', () => {
|
|
32
|
+
const blob = new Blob(['hello'], { type: 'application/custom-binary' })
|
|
33
|
+
const body = new BinaryBody(blob)
|
|
34
|
+
|
|
35
|
+
expect(body.getHeaders()).toEqual({ 'Content-Type': 'application/custom-binary' })
|
|
36
|
+
expect(body.getContent()).toBe(blob)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('BinaryBodyFactory returns BinaryBody', () => {
|
|
40
|
+
const factory = new BinaryBodyFactory<Uint8Array>('application/octet-stream')
|
|
41
|
+
const body = factory.make(new Uint8Array([4, 5, 6]))
|
|
42
|
+
|
|
43
|
+
expect(body).toBeInstanceOf(BinaryBody)
|
|
44
|
+
})
|
|
45
|
+
|
|
22
46
|
it('FormDataFactory returns FormDataBody', () => {
|
|
23
47
|
const factory = new FormDataFactory<{ name: string }>()
|
|
24
48
|
const body = factory.make({ name: 'alice' })
|
|
@@ -9,6 +9,7 @@ describe('Enums', () => {
|
|
|
9
9
|
it('exposes expected request enums', () => {
|
|
10
10
|
expect(RequestMethodEnum.GET).toBe('GET')
|
|
11
11
|
expect(RequestEvents.LOADING).toBe('loading')
|
|
12
|
+
expect(RequestEvents.UPLOAD_PROGRESS).toBe('upload-progress')
|
|
12
13
|
expect(RequestConcurrencyMode.REPLACE_LATEST).toBe('replace-latest')
|
|
13
14
|
})
|
|
14
15
|
|
|
@@ -3,10 +3,10 @@ import { FetchDriver } from '../../../../src/requests/drivers/fetch/FetchDriver'
|
|
|
3
3
|
import { FetchResponse } from '../../../../src/requests/drivers/fetch/FetchResponse'
|
|
4
4
|
import { RequestMethodEnum } from '../../../../src/requests/RequestMethod.enum'
|
|
5
5
|
import { ResponseException } from '../../../../src/requests/exceptions/ResponseException'
|
|
6
|
-
import type { BodyContract } from '../../../../src/requests/contracts/BodyContract'
|
|
6
|
+
import type { BodyContent, BodyContract } from '../../../../src/requests/contracts/BodyContract'
|
|
7
7
|
|
|
8
|
-
const createBody = (content: string): BodyContract => ({
|
|
9
|
-
getHeaders: () =>
|
|
8
|
+
const createBody = (content: BodyContent, headers: Record<string, string> = { 'Content-Type': 'application/json' }): BodyContract => ({
|
|
9
|
+
getHeaders: () => headers,
|
|
10
10
|
getContent: () => content,
|
|
11
11
|
})
|
|
12
12
|
|
|
@@ -65,6 +65,24 @@ describe('FetchDriver', () => {
|
|
|
65
65
|
expect(config.body).toBeUndefined()
|
|
66
66
|
})
|
|
67
67
|
|
|
68
|
+
it('passes Blob bodies through to fetch unchanged', async () => {
|
|
69
|
+
const response = new Response('ok', { status: 200 })
|
|
70
|
+
;(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(response)
|
|
71
|
+
|
|
72
|
+
const blob = new Blob(['chunk'], { type: 'application/octet-stream' })
|
|
73
|
+
const driver = new FetchDriver()
|
|
74
|
+
|
|
75
|
+
await driver.send(
|
|
76
|
+
'https://example.com',
|
|
77
|
+
RequestMethodEnum.PUT,
|
|
78
|
+
{},
|
|
79
|
+
createBody(blob, { 'Content-Type': 'application/octet-stream' })
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
const [, config] = (global.fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0]
|
|
83
|
+
expect(config.body).toBe(blob)
|
|
84
|
+
})
|
|
85
|
+
|
|
68
86
|
it('throws ResponseException when response is not ok', async () => {
|
|
69
87
|
const response = new Response('fail', { status: 500 })
|
|
70
88
|
;(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(response)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { XMLHttpRequestDriver } from '../../../../src/requests/drivers/xhr/XMLHttpRequestDriver'
|
|
3
|
+
import { XMLHttpRequestResponse } from '../../../../src/requests/drivers/xhr/XMLHttpRequestResponse'
|
|
4
|
+
import { RequestMethodEnum } from '../../../../src/requests/RequestMethod.enum'
|
|
5
|
+
import { ResponseException } from '../../../../src/requests/exceptions/ResponseException'
|
|
6
|
+
import type { BodyContent, BodyContract } from '../../../../src/requests/contracts/BodyContract'
|
|
7
|
+
|
|
8
|
+
const createBody = (content: BodyContent, headers: Record<string, string> = { 'Content-Type': 'application/json' }): BodyContract => ({
|
|
9
|
+
getHeaders: () => headers,
|
|
10
|
+
getContent: () => content,
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
class MockXMLHttpRequestUpload {
|
|
14
|
+
public onprogress: ((event: ProgressEvent<EventTarget>) => void) | null = null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
class MockXMLHttpRequest {
|
|
18
|
+
public static instances: MockXMLHttpRequest[] = []
|
|
19
|
+
|
|
20
|
+
public method?: string
|
|
21
|
+
public url?: string
|
|
22
|
+
public async?: boolean
|
|
23
|
+
public responseType: XMLHttpRequestResponseType = ''
|
|
24
|
+
public withCredentials = false
|
|
25
|
+
public status = 200
|
|
26
|
+
public statusText = 'OK'
|
|
27
|
+
public response: Blob | string | ArrayBuffer | null = '{"ok":true}'
|
|
28
|
+
public onload: (() => void) | null = null
|
|
29
|
+
public onerror: (() => void) | null = null
|
|
30
|
+
public onabort: (() => void) | null = null
|
|
31
|
+
public upload = new MockXMLHttpRequestUpload()
|
|
32
|
+
public headers: Record<string, string> = {}
|
|
33
|
+
public responseHeaders: Record<string, string> = {}
|
|
34
|
+
public sentBody: Document | XMLHttpRequestBodyInit | null | undefined = undefined
|
|
35
|
+
public aborted = false
|
|
36
|
+
|
|
37
|
+
public constructor() {
|
|
38
|
+
MockXMLHttpRequest.instances.push(this)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
public open(method: string, url: string, async: boolean): void {
|
|
42
|
+
this.method = method
|
|
43
|
+
this.url = url
|
|
44
|
+
this.async = async
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
public setRequestHeader(key: string, value: string): void {
|
|
48
|
+
this.headers[key] = value
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
public send(body?: Document | XMLHttpRequestBodyInit | null): void {
|
|
52
|
+
this.sentBody = body
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public abort(): void {
|
|
56
|
+
this.aborted = true
|
|
57
|
+
this.onabort?.()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
public getAllResponseHeaders(): string {
|
|
61
|
+
return Object.entries(this.responseHeaders)
|
|
62
|
+
.map(([key, value]) => `${key}: ${value}`)
|
|
63
|
+
.join('\r\n')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
public triggerLoad(): void {
|
|
67
|
+
this.onload?.()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
public triggerError(): void {
|
|
71
|
+
this.onerror?.()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
public triggerUploadProgress(loaded: number, total: number, lengthComputable: boolean = true): void {
|
|
75
|
+
this.upload.onprogress?.({
|
|
76
|
+
loaded,
|
|
77
|
+
total,
|
|
78
|
+
lengthComputable,
|
|
79
|
+
} as ProgressEvent<EventTarget>)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('XMLHttpRequestDriver', () => {
|
|
84
|
+
const originalXMLHttpRequest = global.XMLHttpRequest
|
|
85
|
+
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
MockXMLHttpRequest.instances = []
|
|
88
|
+
global.XMLHttpRequest = MockXMLHttpRequest as unknown as typeof XMLHttpRequest
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
afterEach(() => {
|
|
92
|
+
global.XMLHttpRequest = originalXMLHttpRequest
|
|
93
|
+
vi.restoreAllMocks()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('sends requests with merged headers, body, and upload progress callbacks', async () => {
|
|
97
|
+
const onUploadProgress = vi.fn()
|
|
98
|
+
const driver = new XMLHttpRequestDriver({ headers: { 'X-Global': 'a' }, corsWithCredentials: true })
|
|
99
|
+
|
|
100
|
+
const promise = driver.send(
|
|
101
|
+
'https://example.com',
|
|
102
|
+
RequestMethodEnum.POST,
|
|
103
|
+
{ 'X-Req': 'b', 'X-Fn': () => 'c', 'X-Ignore': undefined },
|
|
104
|
+
createBody('{"name":"test"}'),
|
|
105
|
+
{ onUploadProgress }
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
const request = MockXMLHttpRequest.instances[0]
|
|
109
|
+
request.status = 201
|
|
110
|
+
request.responseHeaders = { 'X-Response': 'yes' }
|
|
111
|
+
request.triggerUploadProgress(5, 10)
|
|
112
|
+
request.triggerLoad()
|
|
113
|
+
|
|
114
|
+
const result = await promise
|
|
115
|
+
|
|
116
|
+
expect(result).toBeInstanceOf(XMLHttpRequestResponse)
|
|
117
|
+
expect(request.method).toBe('POST')
|
|
118
|
+
expect(request.url).toBe('https://example.com')
|
|
119
|
+
expect(request.async).toBe(true)
|
|
120
|
+
expect(request.responseType).toBe('blob')
|
|
121
|
+
expect(request.withCredentials).toBe(true)
|
|
122
|
+
expect(request.headers).toEqual({
|
|
123
|
+
'X-Global': 'a',
|
|
124
|
+
'X-Req': 'b',
|
|
125
|
+
'X-Fn': 'c',
|
|
126
|
+
'Content-Type': 'application/json',
|
|
127
|
+
})
|
|
128
|
+
expect(request.sentBody).toBe('{"name":"test"}')
|
|
129
|
+
expect(onUploadProgress).toHaveBeenCalledWith({
|
|
130
|
+
loaded: 5,
|
|
131
|
+
total: 10,
|
|
132
|
+
lengthComputable: true,
|
|
133
|
+
progress: 0.5,
|
|
134
|
+
})
|
|
135
|
+
await expect(result.json()).resolves.toEqual({ ok: true })
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('omits body for GET and HEAD requests', async () => {
|
|
139
|
+
const driver = new XMLHttpRequestDriver()
|
|
140
|
+
|
|
141
|
+
const promise = driver.send('https://example.com', RequestMethodEnum.GET, {}, createBody('data'))
|
|
142
|
+
|
|
143
|
+
const request = MockXMLHttpRequest.instances[0]
|
|
144
|
+
request.triggerLoad()
|
|
145
|
+
|
|
146
|
+
await promise
|
|
147
|
+
|
|
148
|
+
expect(request.sentBody).toBeUndefined()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('passes typed array bodies through to xhr unchanged', async () => {
|
|
152
|
+
const driver = new XMLHttpRequestDriver()
|
|
153
|
+
const chunk = new Uint8Array([1, 2, 3, 4])
|
|
154
|
+
|
|
155
|
+
const promise = driver.send(
|
|
156
|
+
'https://example.com',
|
|
157
|
+
RequestMethodEnum.PUT,
|
|
158
|
+
{},
|
|
159
|
+
createBody(chunk, { 'Content-Type': 'application/octet-stream' })
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
const request = MockXMLHttpRequest.instances[0]
|
|
163
|
+
request.triggerLoad()
|
|
164
|
+
|
|
165
|
+
await promise
|
|
166
|
+
|
|
167
|
+
expect(request.sentBody).toBe(chunk)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('throws ResponseException when the response status is not ok', async () => {
|
|
171
|
+
const driver = new XMLHttpRequestDriver()
|
|
172
|
+
|
|
173
|
+
const promise = driver.send('https://example.com', RequestMethodEnum.GET, {})
|
|
174
|
+
|
|
175
|
+
const request = MockXMLHttpRequest.instances[0]
|
|
176
|
+
request.status = 500
|
|
177
|
+
request.response = 'fail'
|
|
178
|
+
request.triggerLoad()
|
|
179
|
+
|
|
180
|
+
await expect(promise).rejects.toBeInstanceOf(ResponseException)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('aborts requests when the AbortSignal is triggered', async () => {
|
|
184
|
+
const controller = new AbortController()
|
|
185
|
+
const driver = new XMLHttpRequestDriver()
|
|
186
|
+
|
|
187
|
+
const promise = driver.send('https://example.com', RequestMethodEnum.POST, {}, createBody('{"name":"test"}'), {
|
|
188
|
+
abortSignal: controller.signal,
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
const request = MockXMLHttpRequest.instances[0]
|
|
192
|
+
controller.abort()
|
|
193
|
+
|
|
194
|
+
await expect(promise).rejects.toMatchObject({ name: 'AbortError' })
|
|
195
|
+
expect(request.aborted).toBe(true)
|
|
196
|
+
})
|
|
197
|
+
})
|