@blueprint-ts/core 4.0.0-beta.9 → 4.1.0-beta.1

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/docs/.vitepress/config.ts +1 -0
  3. package/docs/services/pagination/index.md +1 -1
  4. package/docs/services/pagination/updating-rows.md +16 -0
  5. package/docs/services/requests/drivers.md +68 -3
  6. package/docs/services/requests/events.md +22 -0
  7. package/docs/services/requests/file-uploads.md +105 -0
  8. package/docs/services/requests/request-bodies.md +2 -0
  9. package/package.json +1 -1
  10. package/release-tool.json +1 -1
  11. package/src/pagination/BasePaginator.ts +19 -0
  12. package/src/requests/BaseRequest.ts +42 -3
  13. package/src/requests/RequestEvents.enum.ts +2 -1
  14. package/src/requests/contracts/DriverConfigContract.ts +2 -0
  15. package/src/requests/drivers/xhr/XMLHttpRequestDriver.ts +138 -0
  16. package/src/requests/drivers/xhr/XMLHttpRequestResponse.ts +95 -0
  17. package/src/requests/index.ts +8 -3
  18. package/src/requests/types/RequestUploadProgress.ts +6 -0
  19. package/src/vue/forms/validation/rules/EmailRule.ts +1 -1
  20. package/tests/service/bulkRequests/BulkRequestSender.test.ts +76 -0
  21. package/tests/service/bulkRequests/BulkRequestWrapper.test.ts +51 -0
  22. package/tests/service/pagination/BasePaginator.test.ts +100 -0
  23. package/tests/service/pagination/InfiniteScroller.test.ts +101 -0
  24. package/tests/service/pagination/PageAwarePaginator.test.ts +133 -0
  25. package/tests/service/pagination/StatePaginator.test.ts +76 -0
  26. package/tests/service/pagination/VueViewDrivers.test.ts +46 -0
  27. package/tests/service/pagination/dtos/StatePaginationDataDto.test.ts +14 -0
  28. package/tests/service/persistenceDrivers/PersistenceDrivers.test.ts +56 -0
  29. package/tests/service/requests/BaseRequest.test.ts +250 -0
  30. package/tests/service/requests/BodiesAndFactories.test.ts +28 -0
  31. package/tests/service/requests/Enums.test.ts +20 -0
  32. package/tests/service/requests/ErrorHandler.test.ts +45 -1
  33. package/tests/service/requests/RequestErrorRouter.test.ts +44 -0
  34. package/tests/service/requests/Responses.test.ts +83 -0
  35. package/tests/service/requests/exceptions/Exceptions.test.ts +43 -0
  36. package/tests/service/requests/fetch/FetchDriver.test.ts +76 -0
  37. package/tests/service/requests/fetch/FetchResponse.test.ts +21 -0
  38. package/tests/service/requests/xhr/XMLHttpRequestDriver.test.ts +178 -0
  39. package/tests/service/support/DeferredPromise.test.ts +40 -0
  40. package/tests/service/support/helpers.test.ts +37 -0
  41. package/tests/vue/composables/useConfirmDialog.test.ts +77 -0
  42. package/tests/vue/composables/useGlobalCheckbox.test.ts +126 -0
  43. package/tests/vue/composables/useIsEmpty.test.ts +18 -0
  44. package/tests/vue/composables/useIsOpen.test.ts +25 -0
  45. package/tests/vue/composables/useIsOpenFromVar.test.ts +22 -0
  46. package/tests/vue/composables/useModelWrapper.test.ts +30 -0
  47. package/tests/vue/composables/useOnOpen.test.ts +26 -0
  48. package/tests/vue/forms/PropertyAwareArray.test.ts +30 -0
  49. package/tests/vue/forms/validation/ValidationRules.test.ts +79 -0
  50. package/tests/vue/requests/VueRequestLoaders.test.ts +48 -0
  51. package/tests/vue/router/routeResourceBinding/RouteResourceUtils.test.ts +70 -0
  52. package/tests/vue/state/State.test.ts +151 -0
  53. package/vitest.config.ts +10 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,27 @@
1
+ ## 4.1.0-beta.1 - 2026-03-21 (beta)
2
+
3
+ # [4.1.0-beta.1](/compare/v4.0.0...v4.1.0-beta.1) (2026-03-21)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **requests:** export HeaderValue type 15e55c1
9
+
10
+
11
+ ### Features
12
+
13
+ * **requests:** add XMLHttpRequest upload progress support 19123df
14
+ ## v4.0.0 - 2026-03-01
15
+
16
+ # [4.0.0](/compare/v4.0.0-beta.10...v4.0.0) (2026-03-01)
17
+ ## v4.0.0-beta.10 - 2026-02-28 (beta)
18
+
19
+ # [4.0.0-beta.10](/compare/v4.0.0-beta.9...v4.0.0-beta.10) (2026-02-28)
20
+
21
+
22
+ ### Features
23
+
24
+ * **pagination:** support local row removal without reload 3a66689
1
25
  ## v4.0.0-beta.9 - 2026-02-28 (beta)
2
26
 
3
27
  # [4.0.0-beta.9](/compare/v4.0.0-beta.8...v4.0.0-beta.9) (2026-02-28)
@@ -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' },
@@ -38,7 +38,7 @@ If you are using Vue, the library provides `VuePaginationDriverFactory` and `Vue
38
38
  - `flush`: clears existing data before applying the next page
39
39
  - `replace`: replaces existing data instead of appending (useful for infinite scroll)
40
40
 
41
- `updateRows` is available on all paginators. See [Updating Rows](./updating-rows).
41
+ `updateRows` and `removeRows` are available on all paginators. See [Updating Rows](./updating-rows).
42
42
 
43
43
  ## Using Laravel Pagination
44
44
 
@@ -34,3 +34,19 @@ const updated = paginator.updateRows(
34
34
  (row) => ({ ...row, updatedAt: new Date().toISOString() })
35
35
  )
36
36
  ```
37
+
38
+ ## Removing Rows
39
+
40
+ Use `removeRows` to delete items from the current page data without reloading. By default it also decrements `total`
41
+ by the number of removed rows. Set `adjustTotal: false` to skip that behavior.
42
+
43
+ ```typescript
44
+ // Remove a single item
45
+ const removed = paginator.removeRows((row) => row.id === targetId)
46
+
47
+ // Remove all drafts without adjusting total
48
+ const removedDrafts = paginator.removeRows(
49
+ (row) => row.status === 'draft',
50
+ { adjustTotal: false }
51
+ )
52
+ ```
@@ -1,9 +1,9 @@
1
1
  # Drivers
2
2
 
3
- Requests are executed by a request driver. The library includes a default fetch-based driver and lets you provide your
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
- ## Default Fetch Driver
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,105 @@
1
+ # File Uploads
2
+
3
+ Use `FormDataFactory` to build multipart payloads, and use `XMLHttpRequestDriver` when the consuming application needs
4
+ upload progress for a progress bar.
5
+
6
+ ## Request Definition
7
+
8
+ ```typescript
9
+ import {
10
+ BaseRequest,
11
+ FormDataFactory,
12
+ JsonResponse,
13
+ RequestMethodEnum,
14
+ XMLHttpRequestDriver
15
+ } from '@blueprint-ts/core/requests'
16
+
17
+ interface UploadAvatarPayload {
18
+ avatar: File
19
+ }
20
+
21
+ interface UploadAvatarResponse {
22
+ id: string
23
+ url: string
24
+ }
25
+
26
+ class UploadAvatarRequest extends BaseRequest<
27
+ boolean,
28
+ { message: string },
29
+ UploadAvatarResponse,
30
+ JsonResponse<UploadAvatarResponse>,
31
+ UploadAvatarPayload
32
+ > {
33
+ public method(): RequestMethodEnum {
34
+ return RequestMethodEnum.POST
35
+ }
36
+
37
+ public url(): string {
38
+ return '/api/v1/avatar'
39
+ }
40
+
41
+ public getResponse(): JsonResponse<UploadAvatarResponse> {
42
+ return new JsonResponse<UploadAvatarResponse>()
43
+ }
44
+
45
+ public override getRequestBodyFactory() {
46
+ return new FormDataFactory<UploadAvatarPayload>()
47
+ }
48
+
49
+ protected override getRequestDriver() {
50
+ return new XMLHttpRequestDriver({
51
+ corsWithCredentials: true,
52
+ headers: {
53
+ 'X-XSRF-TOKEN': () => getCookie('XSRF-TOKEN')
54
+ }
55
+ })
56
+ }
57
+ }
58
+ ```
59
+
60
+ ## Global Default Driver
61
+
62
+ You can keep `FetchDriver` as the application default. The upload request above will still use `XMLHttpRequestDriver`
63
+ because it defines its own driver internally:
64
+
65
+ ```typescript
66
+ import { BaseRequest, FetchDriver } from '@blueprint-ts/core/requests'
67
+
68
+ BaseRequest.setRequestDriver(new FetchDriver())
69
+ ```
70
+
71
+ Important: the upload request's `XMLHttpRequestDriver` does not inherit config from the global `FetchDriver`. If the
72
+ upload request needs credentials or shared headers, define them on the `XMLHttpRequestDriver` returned by
73
+ `getRequestDriver()`.
74
+
75
+ ## Listening for Upload Progress
76
+
77
+ ```typescript
78
+ import { RequestEvents, type RequestUploadProgress } from '@blueprint-ts/core/requests'
79
+
80
+ const request = new UploadAvatarRequest()
81
+
82
+ request.on<RequestUploadProgress>(RequestEvents.UPLOAD_PROGRESS, (progress) => {
83
+ if (!progress.lengthComputable || progress.progress === undefined) {
84
+ return
85
+ }
86
+
87
+ progressBar.value = progress.progress * 100
88
+ })
89
+
90
+ await request.setBody({
91
+ avatar: fileInput.files![0],
92
+ }).send()
93
+ ```
94
+
95
+ ## Notes
96
+
97
+ - Upload progress requires `XMLHttpRequestDriver`. The default `FetchDriver` does not emit upload progress events.
98
+ - Define `XMLHttpRequestDriver` inside the upload request class when that request should always support progress.
99
+ - `XMLHttpRequestDriver` supports the same `corsWithCredentials` and `headers` options as `FetchDriver`, including
100
+ header callbacks.
101
+ - Request-defined drivers do not automatically inherit config from the globally registered driver.
102
+ - Some browsers cannot compute a reliable total size for every upload. Check `lengthComputable` before rendering a
103
+ percentage.
104
+ - Upload event listeners may cause CORS preflight requests on cross-origin uploads. Ensure the server is configured
105
+ accordingly.
@@ -54,6 +54,8 @@ 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
+
57
59
  ## Custom Body Factories
58
60
 
59
61
  You can implement your own body factory by returning a `BodyContract` with custom headers and serialization logic.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blueprint-ts/core",
3
- "version": "4.0.0-beta.9",
3
+ "version": "4.1.0-beta.1",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"
package/release-tool.json CHANGED
@@ -20,7 +20,7 @@
20
20
  "subdir": "",
21
21
  "commit_message": "docs: publish {tag}",
22
22
  "add_nojekyll": true,
23
- "force_push": false
23
+ "force_push": true
24
24
  }
25
25
  }
26
26
  }
@@ -55,6 +55,25 @@ export abstract class BasePaginator<ResourceInterface, ViewDriver extends BaseVi
55
55
  return updated
56
56
  }
57
57
 
58
+ public removeRows(
59
+ predicate: (row: ResourceInterface, index: number, data: ResourceInterface[]) => boolean,
60
+ options?: { adjustTotal?: boolean }
61
+ ): number {
62
+ const data = this.viewDriver.getData()
63
+ const next = data.filter((row, index) => !predicate(row, index, data))
64
+ const removed = data.length - next.length
65
+
66
+ if (removed > 0) {
67
+ this.viewDriver.setData(next)
68
+
69
+ if (options?.adjustTotal ?? true) {
70
+ this.viewDriver.setTotal(Math.max(0, this.viewDriver.getTotal() - removed))
71
+ }
72
+ }
73
+
74
+ return removed
75
+ }
76
+
58
77
  public getTotal(): number {
59
78
  return this.viewDriver.getTotal()
60
79
  }
@@ -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'
@@ -113,7 +115,8 @@ export abstract class BaseRequest<
113
115
  }
114
116
 
115
117
  public buildUrl(): URL {
116
- const url = this.params !== undefined && Object.keys(this.params).length === 0 ? this.url() : this.url() + '?' + qs.stringify(this.params)
118
+ const hasParams = this.params !== undefined && Object.keys(this.params).length > 0
119
+ const url = hasParams ? this.url() + '?' + qs.stringify(this.params) : this.url()
117
120
 
118
121
  return new URL(url, this.baseUrl() ?? BaseRequest.defaultBaseUrl)
119
122
  }
@@ -163,8 +166,9 @@ export abstract class BaseRequest<
163
166
  const responseSkeleton = this.getResponse()
164
167
 
165
168
  const requestBody = this.requestBody === undefined ? undefined : this.getRequestBodyFactory()?.make(this.requestBody)
169
+ const requestConfig = this.buildRequestConfig(requestBody, concurrencyKey, sequence, useLatest)
166
170
 
167
- return BaseRequest.requestDriver
171
+ return this.resolveRequestDriver()
168
172
  .send(
169
173
  this.buildUrl(),
170
174
  this.method(),
@@ -173,7 +177,7 @@ export abstract class BaseRequest<
173
177
  ...this.requestHeaders()
174
178
  },
175
179
  requestBody,
176
- this.getConfig()
180
+ requestConfig
177
181
  )
178
182
  .then(async (responseHandler: ResponseHandlerContract) => {
179
183
  if (useLatest && !this.isLatestSequence(concurrencyKey, sequence)) {
@@ -274,9 +278,44 @@ export abstract class BaseRequest<
274
278
  return undefined
275
279
  }
276
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
+
277
308
  protected getConfig(): DriverConfigContract | undefined {
278
309
  return {
279
310
  abortSignal: this.abortSignal
280
311
  }
281
312
  }
313
+
314
+ protected resolveRequestDriver(): RequestDriverContract {
315
+ return this.getRequestDriver() ?? BaseRequest.requestDriver
316
+ }
317
+
318
+ protected getRequestDriver(): RequestDriverContract | undefined {
319
+ return undefined
320
+ }
282
321
  }
@@ -1,3 +1,4 @@
1
1
  export enum RequestEvents {
2
- LOADING = 'loading'
2
+ LOADING = 'loading',
3
+ UPLOAD_PROGRESS = 'upload-progress'
3
4
  }
@@ -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
  }
@@ -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 HeadersContract } from '../../contracts/HeadersContract'
2
+ import { type ResponseHandlerContract } from '../contracts/ResponseHandlerContract'
3
+
4
+ export class XMLHttpRequestResponse implements ResponseHandlerContract {
5
+ protected response: Response
6
+ protected headers: HeadersContract
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(): HeadersContract {
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): HeadersContract {
69
+ const headers: HeadersContract = {}
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
+ }