@vida-global/core 1.4.4 → 1.4.5

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.
@@ -30,3 +30,13 @@ const body = {baz: 1, ban: 2};
30
30
  const response = await client.post("/foo/bar", { body });
31
31
  ```
32
32
 
33
+ ## Aborting requests
34
+ All request methods accept `timeout` (ms) and/or a caller-supplied `signal` (`AbortSignal`). When both are provided, they are composed via `AbortSignal.any` — whichever fires first aborts the request. If the request is aborted (by the timeout or the external signal), an `HttpAbortError` is thrown with `reason` set to either `'timed out'` or `'aborted'`.
35
+
36
+ ```
37
+ await client.post("/foo/bar", { body, timeout: 5000 });
38
+
39
+ const ctrl = new AbortController();
40
+ await client.get("/foo/bar", { signal: ctrl.signal });
41
+ ```
42
+
@@ -1,38 +1,67 @@
1
- const { logger } = require('../logger');
2
- const { HttpError } = require('./error');
1
+ const { logger } = require('../logger');
2
+ const { HttpError, HttpAbortError } = require('./error');
3
3
 
4
4
 
5
5
  class HttpClient {
6
- async get(endpoint, { requestParams, headers }={}) {
6
+ async get(endpoint, { requestParams, headers, timeout, signal }={}) {
7
7
  return await this.#makeRequest(endpoint, "GET", arguments[1]);
8
8
  }
9
9
 
10
10
 
11
- async post(endpoint, { body, headers }={}) {
11
+ async post(endpoint, { body, headers, timeout, signal }={}) {
12
12
  return await this.#makeRequest(endpoint, "POST", arguments[1]);
13
13
  }
14
14
 
15
15
 
16
- async put(endpoint, { body, headers }={}) {
16
+ async put(endpoint, { body, headers, timeout, signal }={}) {
17
17
  return await this.#makeRequest(endpoint, "PUT", arguments[1]);
18
18
  }
19
19
 
20
20
 
21
- async delete(endpoint, { requestParams, headers }={}) {
21
+ async delete(endpoint, { requestParams, headers, timeout, signal }={}) {
22
22
  return await this.#makeRequest(endpoint, "DELETE", arguments[1]);
23
23
  }
24
24
 
25
25
 
26
- async #makeRequest(endpoint, method, { requestParams, body, headers }={}) {
26
+ async #makeRequest(endpoint, method, { requestParams, body, headers, timeout, signal }={}) {
27
27
  this.#logRequest(method, endpoint);
28
28
 
29
29
  endpoint = `${this.urlRoot}${endpoint}`;
30
30
  const url = new URL(endpoint);
31
31
  const requestData = structuredClone(requestParams || body || {});
32
- const payload = this.#prepareRequestPayload(url, requestData, headers, method);
32
+ const payload = this.#prepareRequestPayload(url, requestData, headers, method, timeout, signal);
33
33
 
34
- const res = await fetch(url.toString(), payload);
34
+ try {
35
+ const res = await fetch(url.toString(), payload);
36
+ return await this.#handleRequestResponse(res, payload);
37
+ } catch (err) {
38
+ this.#handleRequestError(err, payload, timeout, signal);
39
+ }
40
+ }
41
+
42
+
43
+ #handleRequestError(err, payload, timeout, externalSignal) {
44
+ if (this.#isAbortError(err)) {
45
+ const msg = this.#classifyAbortReason(timeout, externalSignal)
46
+ throw new HttpAbortError(msg, payload);
47
+ }
48
+ throw err;
49
+ }
50
+
51
+
52
+ #isAbortError(err) {
53
+ return err instanceof DOMException && (err.name === 'AbortError' || err.name === 'TimeoutError');
54
+ }
35
55
 
56
+
57
+ #classifyAbortReason(timeout, externalSignal) {
58
+ if (externalSignal?.aborted) return 'aborted';
59
+ if (timeout) return 'timed out';
60
+ return 'aborted';
61
+ }
62
+
63
+
64
+ async #handleRequestResponse(res, payload) {
36
65
  if (res.status && res.status < 300) {
37
66
  const json = await res.json();
38
67
  return {data: json, status: res.status};
@@ -42,7 +71,7 @@ class HttpClient {
42
71
  }
43
72
 
44
73
 
45
- #prepareRequestPayload(url, requestData, headers, method) {
74
+ #prepareRequestPayload(url, requestData, headers, method, timeout, signal) {
46
75
  const payload = {};
47
76
 
48
77
  headers ||= {};
@@ -50,10 +79,23 @@ class HttpClient {
50
79
  payload.method = method;
51
80
  this.#addRequestData(payload, requestData, method, url, payload.headers);
52
81
 
82
+ const composedSignal = this.#composeSignal(signal, timeout);
83
+ if (composedSignal) payload.signal = composedSignal;
84
+
53
85
  return payload;
54
86
  }
55
87
 
56
88
 
89
+ #composeSignal(externalSignal, timeoutMs) {
90
+ const signals = [];
91
+ if (externalSignal) signals.push(externalSignal);
92
+ if (timeoutMs) signals.push(AbortSignal.timeout(timeoutMs));
93
+ if (signals.length === 0) return undefined;
94
+ if (signals.length === 1) return signals[0];
95
+ return AbortSignal.any(signals);
96
+ }
97
+
98
+
57
99
  #addRequestData(payload, requestData, method, url, headers) {
58
100
  if (method == "POST" || method == "PUT") {
59
101
  payload.body = JSON.stringify(requestData || {});
@@ -123,7 +165,8 @@ class HttpClient {
123
165
  }
124
166
 
125
167
 
126
- module.exports = {
168
+ module.exports = {
127
169
  HttpClient,
128
- HttpError
170
+ HttpError,
171
+ HttpAbortError
129
172
  }
package/lib/http/error.js CHANGED
@@ -29,6 +29,24 @@ class HttpError extends Error {
29
29
  }
30
30
 
31
31
 
32
+ class HttpAbortError extends Error {
33
+ #requestPayload;
34
+ #reason;
35
+
36
+
37
+ constructor(reason, requestPayload) {
38
+ super(`HTTP request ${reason}`);
39
+ this.#reason = reason;
40
+ this.#requestPayload = requestPayload;
41
+ }
42
+
43
+
44
+ get reason() { return this.#reason; }
45
+ get requestPayload() { return structuredClone(this.#requestPayload); }
46
+ }
47
+
48
+
32
49
  module.exports = {
33
- HttpError
50
+ HttpError,
51
+ HttpAbortError
34
52
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vida-global/core",
3
- "version": "1.4.4",
3
+ "version": "1.4.5",
4
4
  "description": "Core libraries for supporting Vida development",
5
5
  "author": "",
6
6
  "license": "ISC",
@@ -1,6 +1,6 @@
1
- const { HttpClient } = require('../../lib/http/client');
2
- const { logger } = require('../../lib/logger');
3
- const Helpers = require('./helpers/client');
1
+ const { HttpClient, HttpAbortError } = require('../../lib/http/client');
2
+ const { logger } = require('../../lib/logger');
3
+ const Helpers = require('./helpers/client');
4
4
 
5
5
 
6
6
  afterEach(() => {
@@ -90,4 +90,73 @@ describe('HttpClient', () => {
90
90
  });
91
91
  }
92
92
  });
93
+
94
+
95
+ describe('abort/timeout support', () => {
96
+ const client = new HttpClient();
97
+
98
+
99
+ it('passes a signal when timeout is provided', async () => {
100
+ await client.post(Helpers.apiUrl, { timeout: 5000 });
101
+
102
+ const callOpts = fetch.mock.calls[0][1];
103
+ expect(callOpts.signal).toBeInstanceOf(AbortSignal);
104
+ });
105
+
106
+
107
+ it('passes a signal when an external signal is provided', async () => {
108
+ const externalSignal = new AbortController().signal;
109
+
110
+ await client.post(Helpers.apiUrl, { signal: externalSignal });
111
+
112
+ const callOpts = fetch.mock.calls[0][1];
113
+ expect(callOpts.signal).toBe(externalSignal);
114
+ });
115
+
116
+
117
+ it('composes external signal with timeout', async () => {
118
+ const externalSignal = new AbortController().signal;
119
+
120
+ await client.post(Helpers.apiUrl, { signal: externalSignal, timeout: 5000 });
121
+
122
+ const callOpts = fetch.mock.calls[0][1];
123
+ expect(callOpts.signal).toBeInstanceOf(AbortSignal);
124
+ expect(callOpts.signal).not.toBe(externalSignal);
125
+ });
126
+
127
+
128
+ it('omits signal when neither timeout nor signal is provided', async () => {
129
+ await client.post(Helpers.apiUrl);
130
+
131
+ const callOpts = fetch.mock.calls[0][1];
132
+ expect(callOpts.signal).toBeUndefined();
133
+ });
134
+
135
+
136
+ it('throws HttpAbortError with reason "timed out" when fetch rejects with AbortError under a timeout', async () => {
137
+ global.fetch = jest.fn().mockRejectedValue(new DOMException('aborted', 'AbortError'));
138
+
139
+ await expect(client.post(Helpers.apiUrl, { timeout: 1 }))
140
+ .rejects.toBeInstanceOf(HttpAbortError);
141
+ await expect(client.post(Helpers.apiUrl, { timeout: 1 }))
142
+ .rejects.toMatchObject({ reason: 'timed out' });
143
+ });
144
+
145
+
146
+ it('throws HttpAbortError with reason "aborted" when caller-supplied signal aborts the fetch', async () => {
147
+ global.fetch = jest.fn().mockRejectedValue(new DOMException('aborted', 'AbortError'));
148
+ const externalSignal = new AbortController().signal;
149
+
150
+ await expect(client.post(Helpers.apiUrl, { signal: externalSignal }))
151
+ .rejects.toMatchObject({ reason: 'aborted' });
152
+ });
153
+
154
+
155
+ it('rethrows non-abort fetch errors unchanged', async () => {
156
+ const networkErr = new Error('connect ECONNREFUSED');
157
+ global.fetch = jest.fn().mockRejectedValue(networkErr);
158
+
159
+ await expect(client.post(Helpers.apiUrl)).rejects.toBe(networkErr);
160
+ });
161
+ });
93
162
  });