@vobase/core 0.10.0 → 0.11.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.
Files changed (188) hide show
  1. package/package.json +7 -9
  2. package/src/__tests__/drizzle-introspection.test.ts +77 -0
  3. package/src/__tests__/e2e.test.ts +225 -0
  4. package/src/__tests__/permissions.test.ts +157 -0
  5. package/src/__tests__/rpc-types.test.ts +92 -0
  6. package/src/app.test.ts +99 -0
  7. package/src/app.ts +178 -0
  8. package/src/audit.test.ts +126 -0
  9. package/src/auth.test.ts +74 -0
  10. package/src/contracts/auth.ts +37 -0
  11. package/{dist/contracts/module.d.ts → src/contracts/module.ts} +6 -6
  12. package/src/contracts/notify.ts +47 -0
  13. package/src/contracts/permissions.ts +10 -0
  14. package/src/contracts/storage.ts +61 -0
  15. package/src/ctx.test.ts +162 -0
  16. package/src/ctx.ts +64 -0
  17. package/src/db/client.test.ts +75 -0
  18. package/src/db/client.ts +15 -0
  19. package/src/db/helpers.test.ts +147 -0
  20. package/src/db/helpers.ts +51 -0
  21. package/src/db/index.ts +8 -0
  22. package/{dist/index.d.ts → src/index.ts} +103 -6
  23. package/src/infra/circuit-breaker.test.ts +74 -0
  24. package/src/infra/circuit-breaker.ts +57 -0
  25. package/src/infra/errors.test.ts +175 -0
  26. package/src/infra/errors.ts +64 -0
  27. package/src/infra/http-client.test.ts +482 -0
  28. package/src/infra/http-client.ts +221 -0
  29. package/src/infra/index.ts +35 -0
  30. package/src/infra/job.test.ts +85 -0
  31. package/src/infra/job.ts +94 -0
  32. package/src/infra/logger.test.ts +65 -0
  33. package/src/infra/logger.ts +18 -0
  34. package/src/infra/queue.test.ts +46 -0
  35. package/src/infra/queue.ts +147 -0
  36. package/src/infra/throw-proxy.test.ts +34 -0
  37. package/src/infra/throw-proxy.ts +17 -0
  38. package/src/infra/webhooks-schema.ts +17 -0
  39. package/src/infra/webhooks.test.ts +364 -0
  40. package/src/infra/webhooks.ts +146 -0
  41. package/src/mcp/auth.test.ts +129 -0
  42. package/src/mcp/crud.test.ts +128 -0
  43. package/src/mcp/crud.ts +171 -0
  44. package/{dist/mcp/index.d.ts → src/mcp/index.ts} +0 -1
  45. package/src/mcp/server.test.ts +153 -0
  46. package/src/mcp/server.ts +178 -0
  47. package/src/middleware/audit.test.ts +169 -0
  48. package/src/module-registry.ts +18 -0
  49. package/src/module.test.ts +168 -0
  50. package/src/module.ts +111 -0
  51. package/src/modules/audit/index.ts +18 -0
  52. package/src/modules/audit/middleware.ts +33 -0
  53. package/src/modules/audit/schema.ts +35 -0
  54. package/src/modules/audit/track-changes.ts +70 -0
  55. package/src/modules/auth/audit-hooks.ts +74 -0
  56. package/src/modules/auth/index.ts +101 -0
  57. package/src/modules/auth/middleware.ts +51 -0
  58. package/src/modules/auth/permissions.ts +46 -0
  59. package/src/modules/auth/schema.ts +184 -0
  60. package/src/modules/credentials/encrypt.ts +95 -0
  61. package/src/modules/credentials/index.ts +15 -0
  62. package/src/modules/credentials/schema.ts +10 -0
  63. package/src/modules/notify/index.ts +90 -0
  64. package/src/modules/notify/notify.test.ts +145 -0
  65. package/src/modules/notify/providers/resend.ts +47 -0
  66. package/src/modules/notify/providers/smtp.ts +117 -0
  67. package/src/modules/notify/providers/waba.ts +82 -0
  68. package/src/modules/notify/schema.ts +27 -0
  69. package/src/modules/notify/service.ts +93 -0
  70. package/src/modules/sequences/index.ts +15 -0
  71. package/src/modules/sequences/next-sequence.ts +48 -0
  72. package/src/modules/sequences/schema.ts +12 -0
  73. package/src/modules/storage/index.ts +44 -0
  74. package/src/modules/storage/providers/local.ts +124 -0
  75. package/src/modules/storage/providers/s3.ts +83 -0
  76. package/src/modules/storage/routes.ts +76 -0
  77. package/src/modules/storage/schema.ts +26 -0
  78. package/src/modules/storage/service.ts +202 -0
  79. package/src/modules/storage/storage.test.ts +225 -0
  80. package/src/schemas.test.ts +44 -0
  81. package/src/schemas.ts +63 -0
  82. package/src/sequence.test.ts +56 -0
  83. package/dist/app.d.ts +0 -37
  84. package/dist/app.d.ts.map +0 -1
  85. package/dist/contracts/auth.d.ts +0 -35
  86. package/dist/contracts/auth.d.ts.map +0 -1
  87. package/dist/contracts/module.d.ts.map +0 -1
  88. package/dist/contracts/notify.d.ts +0 -46
  89. package/dist/contracts/notify.d.ts.map +0 -1
  90. package/dist/contracts/permissions.d.ts +0 -10
  91. package/dist/contracts/permissions.d.ts.map +0 -1
  92. package/dist/contracts/storage.d.ts +0 -54
  93. package/dist/contracts/storage.d.ts.map +0 -1
  94. package/dist/ctx.d.ts +0 -40
  95. package/dist/ctx.d.ts.map +0 -1
  96. package/dist/db/client.d.ts +0 -4
  97. package/dist/db/client.d.ts.map +0 -1
  98. package/dist/db/helpers.d.ts +0 -26
  99. package/dist/db/helpers.d.ts.map +0 -1
  100. package/dist/db/index.d.ts +0 -3
  101. package/dist/db/index.d.ts.map +0 -1
  102. package/dist/index.d.ts.map +0 -1
  103. package/dist/index.js +0 -98611
  104. package/dist/infra/circuit-breaker.d.ts +0 -17
  105. package/dist/infra/circuit-breaker.d.ts.map +0 -1
  106. package/dist/infra/errors.d.ts +0 -26
  107. package/dist/infra/errors.d.ts.map +0 -1
  108. package/dist/infra/http-client.d.ts +0 -31
  109. package/dist/infra/http-client.d.ts.map +0 -1
  110. package/dist/infra/index.d.ts +0 -11
  111. package/dist/infra/index.d.ts.map +0 -1
  112. package/dist/infra/job.d.ts +0 -14
  113. package/dist/infra/job.d.ts.map +0 -1
  114. package/dist/infra/logger.d.ts +0 -7
  115. package/dist/infra/logger.d.ts.map +0 -1
  116. package/dist/infra/queue.d.ts +0 -18
  117. package/dist/infra/queue.d.ts.map +0 -1
  118. package/dist/infra/throw-proxy.d.ts +0 -7
  119. package/dist/infra/throw-proxy.d.ts.map +0 -1
  120. package/dist/infra/webhooks-schema.d.ts +0 -60
  121. package/dist/infra/webhooks-schema.d.ts.map +0 -1
  122. package/dist/infra/webhooks.d.ts +0 -46
  123. package/dist/infra/webhooks.d.ts.map +0 -1
  124. package/dist/mcp/crud.d.ts +0 -12
  125. package/dist/mcp/crud.d.ts.map +0 -1
  126. package/dist/mcp/index.d.ts.map +0 -1
  127. package/dist/mcp/server.d.ts +0 -16
  128. package/dist/mcp/server.d.ts.map +0 -1
  129. package/dist/module-registry.d.ts +0 -3
  130. package/dist/module-registry.d.ts.map +0 -1
  131. package/dist/module.d.ts +0 -29
  132. package/dist/module.d.ts.map +0 -1
  133. package/dist/modules/audit/index.d.ts +0 -5
  134. package/dist/modules/audit/index.d.ts.map +0 -1
  135. package/dist/modules/audit/middleware.d.ts +0 -3
  136. package/dist/modules/audit/middleware.d.ts.map +0 -1
  137. package/dist/modules/audit/schema.d.ts +0 -247
  138. package/dist/modules/audit/schema.d.ts.map +0 -1
  139. package/dist/modules/audit/track-changes.d.ts +0 -3
  140. package/dist/modules/audit/track-changes.d.ts.map +0 -1
  141. package/dist/modules/auth/audit-hooks.d.ts +0 -6
  142. package/dist/modules/auth/audit-hooks.d.ts.map +0 -1
  143. package/dist/modules/auth/index.d.ts +0 -25
  144. package/dist/modules/auth/index.d.ts.map +0 -1
  145. package/dist/modules/auth/middleware.d.ts +0 -15
  146. package/dist/modules/auth/middleware.d.ts.map +0 -1
  147. package/dist/modules/auth/permissions.d.ts +0 -5
  148. package/dist/modules/auth/permissions.d.ts.map +0 -1
  149. package/dist/modules/auth/schema.d.ts +0 -2519
  150. package/dist/modules/auth/schema.d.ts.map +0 -1
  151. package/dist/modules/credentials/encrypt.d.ts +0 -12
  152. package/dist/modules/credentials/encrypt.d.ts.map +0 -1
  153. package/dist/modules/credentials/index.d.ts +0 -4
  154. package/dist/modules/credentials/index.d.ts.map +0 -1
  155. package/dist/modules/credentials/schema.d.ts +0 -56
  156. package/dist/modules/credentials/schema.d.ts.map +0 -1
  157. package/dist/modules/notify/index.d.ts +0 -36
  158. package/dist/modules/notify/index.d.ts.map +0 -1
  159. package/dist/modules/notify/providers/resend.d.ts +0 -7
  160. package/dist/modules/notify/providers/resend.d.ts.map +0 -1
  161. package/dist/modules/notify/providers/smtp.d.ts +0 -18
  162. package/dist/modules/notify/providers/smtp.d.ts.map +0 -1
  163. package/dist/modules/notify/providers/waba.d.ts +0 -12
  164. package/dist/modules/notify/providers/waba.d.ts.map +0 -1
  165. package/dist/modules/notify/schema.d.ts +0 -337
  166. package/dist/modules/notify/schema.d.ts.map +0 -1
  167. package/dist/modules/notify/service.d.ts +0 -22
  168. package/dist/modules/notify/service.d.ts.map +0 -1
  169. package/dist/modules/sequences/index.d.ts +0 -4
  170. package/dist/modules/sequences/index.d.ts.map +0 -1
  171. package/dist/modules/sequences/next-sequence.d.ts +0 -8
  172. package/dist/modules/sequences/next-sequence.d.ts.map +0 -1
  173. package/dist/modules/sequences/schema.d.ts +0 -72
  174. package/dist/modules/sequences/schema.d.ts.map +0 -1
  175. package/dist/modules/storage/index.d.ts +0 -24
  176. package/dist/modules/storage/index.d.ts.map +0 -1
  177. package/dist/modules/storage/providers/local.d.ts +0 -3
  178. package/dist/modules/storage/providers/local.d.ts.map +0 -1
  179. package/dist/modules/storage/providers/s3.d.ts +0 -3
  180. package/dist/modules/storage/providers/s3.d.ts.map +0 -1
  181. package/dist/modules/storage/routes.d.ts +0 -4
  182. package/dist/modules/storage/routes.d.ts.map +0 -1
  183. package/dist/modules/storage/schema.d.ts +0 -273
  184. package/dist/modules/storage/schema.d.ts.map +0 -1
  185. package/dist/modules/storage/service.d.ts +0 -35
  186. package/dist/modules/storage/service.d.ts.map +0 -1
  187. package/dist/schemas.d.ts +0 -19
  188. package/dist/schemas.d.ts.map +0 -1
@@ -0,0 +1,482 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
2
+ import { createHttpClient } from './http-client';
3
+
4
+ let server: ReturnType<typeof Bun.serve>;
5
+ let baseUrl: string;
6
+
7
+ beforeAll(() => {
8
+ server = Bun.serve({
9
+ port: 0,
10
+ async fetch(req) {
11
+ const url = new URL(req.url);
12
+
13
+ if (url.pathname === '/json') {
14
+ return Response.json({ message: 'hello' });
15
+ }
16
+
17
+ if (url.pathname === '/text') {
18
+ return new Response('plain text', {
19
+ headers: { 'content-type': 'text/plain' },
20
+ });
21
+ }
22
+
23
+ if (url.pathname === '/no-content') {
24
+ return new Response(null, { status: 204 });
25
+ }
26
+
27
+ if (url.pathname === '/echo') {
28
+ return req.json().then((body) =>
29
+ Response.json({
30
+ method: req.method,
31
+ body,
32
+ contentType: req.headers.get('content-type'),
33
+ }),
34
+ );
35
+ }
36
+
37
+ if (url.pathname === '/echo-raw') {
38
+ const text = await req.text();
39
+ return Response.json({
40
+ method: req.method,
41
+ body: text,
42
+ contentType: req.headers.get('content-type'),
43
+ });
44
+ }
45
+
46
+ if (url.pathname === '/headers') {
47
+ return Response.json({
48
+ custom: req.headers.get('x-custom'),
49
+ });
50
+ }
51
+
52
+ if (url.pathname === '/slow') {
53
+ return new Promise((resolve) =>
54
+ setTimeout(() => resolve(Response.json({ done: true })), 5000),
55
+ );
56
+ }
57
+
58
+ if (url.pathname === '/status/404') {
59
+ return Response.json({ error: 'not found' }, { status: 404 });
60
+ }
61
+
62
+ if (url.pathname === '/status/500') {
63
+ return Response.json({ error: 'server error' }, { status: 500 });
64
+ }
65
+
66
+ return new Response('not found', { status: 404 });
67
+ },
68
+ });
69
+
70
+ baseUrl = `http://localhost:${server.port}`;
71
+ });
72
+
73
+ afterAll(() => {
74
+ server.stop(true);
75
+ });
76
+
77
+ /** Helper: create a local server with a custom handler, returns { baseUrl, stop } */
78
+ function createTestServer(handler: (req: Request) => Response | Promise<Response>) {
79
+ const s = Bun.serve({ port: 0, fetch: handler });
80
+ return { baseUrl: `http://localhost:${s.port}`, stop: () => s.stop(true) };
81
+ }
82
+
83
+ describe('createHttpClient', () => {
84
+ test('returns an object with fetch, get, post, put, delete methods', () => {
85
+ const client = createHttpClient();
86
+ expect(typeof client.fetch).toBe('function');
87
+ expect(typeof client.get).toBe('function');
88
+ expect(typeof client.post).toBe('function');
89
+ expect(typeof client.put).toBe('function');
90
+ expect(typeof client.delete).toBe('function');
91
+ });
92
+ });
93
+
94
+ describe('HttpClient.fetch', () => {
95
+ test('makes a GET request and parses JSON response', async () => {
96
+ const client = createHttpClient({ baseUrl });
97
+ const res = await client.fetch('/json');
98
+ expect(res.ok).toBe(true);
99
+ expect(res.status).toBe(200);
100
+ expect(res.data).toEqual({ message: 'hello' });
101
+ expect(res.raw).toBeInstanceOf(Response);
102
+ expect(res.headers).toBeInstanceOf(Headers);
103
+ });
104
+
105
+ test('parses text response when content-type is not JSON', async () => {
106
+ const client = createHttpClient({ baseUrl });
107
+ const res = await client.fetch('/text');
108
+ expect(res.ok).toBe(true);
109
+ expect(res.data).toBe('plain text');
110
+ });
111
+
112
+ test('handles empty body (204 no content)', async () => {
113
+ const client = createHttpClient({ baseUrl });
114
+ const res = await client.fetch('/no-content');
115
+ expect(res.ok).toBe(true);
116
+ expect(res.status).toBe(204);
117
+ expect(res.data).toBeNull();
118
+ });
119
+
120
+ test('sends JSON body with POST method', async () => {
121
+ const client = createHttpClient({ baseUrl });
122
+ const res = await client.fetch<{
123
+ method: string;
124
+ body: unknown;
125
+ contentType: string;
126
+ }>('/echo', {
127
+ method: 'POST',
128
+ body: { foo: 'bar' },
129
+ });
130
+ expect(res.data.method).toBe('POST');
131
+ expect(res.data.body).toEqual({ foo: 'bar' });
132
+ expect(res.data.contentType).toBe('application/json');
133
+ });
134
+
135
+ test('sends custom headers', async () => {
136
+ const client = createHttpClient({ baseUrl });
137
+ const res = await client.fetch<{ custom: string }>('/headers', {
138
+ headers: { 'x-custom': 'test-value' },
139
+ });
140
+ expect(res.data.custom).toBe('test-value');
141
+ });
142
+
143
+ test('returns ok=false for non-2xx status', async () => {
144
+ const client = createHttpClient({ baseUrl });
145
+ const res = await client.fetch('/status/404');
146
+ expect(res.ok).toBe(false);
147
+ expect(res.status).toBe(404);
148
+ expect(res.data).toEqual({ error: 'not found' });
149
+ });
150
+
151
+ test('works with absolute URL ignoring baseUrl', async () => {
152
+ const client = createHttpClient({ baseUrl: 'http://wrong-host:9999' });
153
+ const res = await client.fetch(`${baseUrl}/json`);
154
+ expect(res.ok).toBe(true);
155
+ expect(res.data).toEqual({ message: 'hello' });
156
+ });
157
+ });
158
+
159
+ describe('HttpClient.get', () => {
160
+ test('makes a GET request', async () => {
161
+ const client = createHttpClient({ baseUrl });
162
+ const res = await client.get('/json');
163
+ expect(res.ok).toBe(true);
164
+ expect(res.data).toEqual({ message: 'hello' });
165
+ });
166
+
167
+ test('passes custom headers', async () => {
168
+ const client = createHttpClient({ baseUrl });
169
+ const res = await client.get<{ custom: string }>('/headers', {
170
+ headers: { 'x-custom': 'from-get' },
171
+ });
172
+ expect(res.data.custom).toBe('from-get');
173
+ });
174
+ });
175
+
176
+ describe('HttpClient.post', () => {
177
+ test('sends POST with JSON body', async () => {
178
+ const client = createHttpClient({ baseUrl });
179
+ const res = await client.post<{ method: string; body: unknown }>(
180
+ '/echo',
181
+ { key: 'value' },
182
+ );
183
+ expect(res.data.method).toBe('POST');
184
+ expect(res.data.body).toEqual({ key: 'value' });
185
+ });
186
+ });
187
+
188
+ describe('HttpClient.put', () => {
189
+ test('sends PUT with JSON body', async () => {
190
+ const client = createHttpClient({ baseUrl });
191
+ const res = await client.put<{ method: string; body: unknown }>(
192
+ '/echo',
193
+ { updated: true },
194
+ );
195
+ expect(res.data.method).toBe('PUT');
196
+ expect(res.data.body).toEqual({ updated: true });
197
+ });
198
+ });
199
+
200
+ describe('HttpClient.delete', () => {
201
+ test('sends DELETE request', async () => {
202
+ const client = createHttpClient({ baseUrl });
203
+ const res = await client.delete('/json');
204
+ expect(res.ok).toBe(true);
205
+ });
206
+ });
207
+
208
+ describe('timeout', () => {
209
+ test('aborts request after timeout', async () => {
210
+ const client = createHttpClient({ baseUrl, timeout: 100 });
211
+ await expect(client.get('/slow')).rejects.toThrow();
212
+ });
213
+
214
+ test('per-request timeout overrides default', async () => {
215
+ const client = createHttpClient({ baseUrl, timeout: 30_000 });
216
+ await expect(client.get('/slow', { timeout: 100 })).rejects.toThrow();
217
+ });
218
+ });
219
+
220
+ describe('baseUrl', () => {
221
+ test('prepends baseUrl to relative paths', async () => {
222
+ const client = createHttpClient({ baseUrl });
223
+ const res = await client.get('/json');
224
+ expect(res.ok).toBe(true);
225
+ expect(res.data).toEqual({ message: 'hello' });
226
+ });
227
+
228
+ test('works without baseUrl using full URL', async () => {
229
+ const client = createHttpClient();
230
+ const res = await client.get(`${baseUrl}/json`);
231
+ expect(res.ok).toBe(true);
232
+ expect(res.data).toEqual({ message: 'hello' });
233
+ });
234
+ });
235
+
236
+ describe('retry logic', () => {
237
+ test('retries GET on 5xx and eventually succeeds', async () => {
238
+ let attempts = 0;
239
+ const ts = createTestServer(() => {
240
+ attempts++;
241
+ if (attempts < 3) {
242
+ return Response.json({ error: 'fail' }, { status: 500 });
243
+ }
244
+ return Response.json({ ok: true });
245
+ });
246
+
247
+ try {
248
+ const client = createHttpClient({ baseUrl: ts.baseUrl, retries: 3, retryDelay: 10 });
249
+ const res = await client.get('/test');
250
+ expect(res.ok).toBe(true);
251
+ expect(res.data).toEqual({ ok: true });
252
+ expect(attempts).toBe(3);
253
+ } finally {
254
+ ts.stop();
255
+ }
256
+ });
257
+
258
+ test('returns last 5xx response after exhausting retries for GET', async () => {
259
+ let attempts = 0;
260
+ const ts = createTestServer(() => {
261
+ attempts++;
262
+ return Response.json({ error: `fail-${attempts}` }, { status: 503 });
263
+ });
264
+
265
+ try {
266
+ const client = createHttpClient({ baseUrl: ts.baseUrl, retries: 2, retryDelay: 10 });
267
+ const res = await client.get('/test');
268
+ expect(res.ok).toBe(false);
269
+ expect(res.status).toBe(503);
270
+ expect(attempts).toBe(3); // 1 initial + 2 retries
271
+ } finally {
272
+ ts.stop();
273
+ }
274
+ });
275
+
276
+ test('retries POST on network error and eventually succeeds', async () => {
277
+ // First, grab a port by starting then stopping a server
278
+ const tempServer = Bun.serve({ port: 0, fetch: () => new Response('') });
279
+ const port = tempServer.port;
280
+ tempServer.stop(true);
281
+
282
+ // Client will hit a closed port (network error) on first attempts
283
+ const client = createHttpClient({
284
+ baseUrl: `http://localhost:${port}`,
285
+ retries: 3,
286
+ retryDelay: 10,
287
+ timeout: 500,
288
+ });
289
+
290
+ // Start the real server on the same port after a short delay
291
+ let realServer: ReturnType<typeof Bun.serve> | undefined;
292
+ setTimeout(() => {
293
+ realServer = Bun.serve({
294
+ port,
295
+ fetch: (req) => Response.json({ method: req.method, posted: true }),
296
+ });
297
+ }, 30);
298
+
299
+ try {
300
+ const res = await client.post('/test', { data: 1 });
301
+ expect(res.ok).toBe(true);
302
+ expect(res.data).toEqual({ method: 'POST', posted: true });
303
+ } finally {
304
+ realServer?.stop(true);
305
+ }
306
+ });
307
+
308
+ test('throws last network error after exhausting retries', async () => {
309
+ // Use a port with nothing listening
310
+ const tempServer = Bun.serve({ port: 0, fetch: () => new Response('') });
311
+ const port = tempServer.port;
312
+ tempServer.stop(true);
313
+
314
+ const client = createHttpClient({
315
+ baseUrl: `http://localhost:${port}`,
316
+ retries: 2,
317
+ retryDelay: 10,
318
+ timeout: 500,
319
+ });
320
+
321
+ await expect(client.post('/test', { data: 1 })).rejects.toThrow();
322
+ });
323
+
324
+ test('does NOT retry POST on 5xx (returns immediately)', async () => {
325
+ let attempts = 0;
326
+ const ts = createTestServer(() => {
327
+ attempts++;
328
+ return Response.json({ error: 'server error' }, { status: 500 });
329
+ });
330
+
331
+ try {
332
+ const client = createHttpClient({ baseUrl: ts.baseUrl, retries: 3, retryDelay: 10 });
333
+ const res = await client.post('/test', { data: 1 });
334
+ expect(res.ok).toBe(false);
335
+ expect(res.status).toBe(500);
336
+ expect(attempts).toBe(1); // No retries
337
+ } finally {
338
+ ts.stop();
339
+ }
340
+ });
341
+
342
+ test('per-request retries override works', async () => {
343
+ let attempts = 0;
344
+ const ts = createTestServer(() => {
345
+ attempts++;
346
+ if (attempts < 4) {
347
+ return Response.json({ error: 'fail' }, { status: 500 });
348
+ }
349
+ return Response.json({ ok: true });
350
+ });
351
+
352
+ try {
353
+ // Client default is 0 retries, but per-request overrides to 3
354
+ const client = createHttpClient({ baseUrl: ts.baseUrl, retries: 0, retryDelay: 10 });
355
+ const res = await client.get('/test', { retries: 3 });
356
+ expect(res.ok).toBe(true);
357
+ expect(res.data).toEqual({ ok: true });
358
+ expect(attempts).toBe(4);
359
+ } finally {
360
+ ts.stop();
361
+ }
362
+ });
363
+
364
+ test('no retries when retries=0 (default)', async () => {
365
+ let attempts = 0;
366
+ const ts = createTestServer(() => {
367
+ attempts++;
368
+ return Response.json({ error: 'fail' }, { status: 500 });
369
+ });
370
+
371
+ try {
372
+ const client = createHttpClient({ baseUrl: ts.baseUrl, retryDelay: 10 });
373
+ const res = await client.get('/test');
374
+ expect(res.ok).toBe(false);
375
+ expect(res.status).toBe(500);
376
+ expect(attempts).toBe(1);
377
+ } finally {
378
+ ts.stop();
379
+ }
380
+ });
381
+ });
382
+
383
+ describe('circuit breaker integration', () => {
384
+ test('HttpClient with circuit breaker opens after threshold failures', async () => {
385
+ const ts = createTestServer(() => {
386
+ return Response.json({ error: 'fail' }, { status: 500 });
387
+ });
388
+
389
+ try {
390
+ const client = createHttpClient({
391
+ baseUrl: ts.baseUrl,
392
+ circuitBreaker: { threshold: 3, resetTimeout: 5000 },
393
+ });
394
+
395
+ // 3 failures should open the circuit
396
+ await client.get('/test');
397
+ await client.get('/test');
398
+ await client.get('/test');
399
+
400
+ // Now the circuit should be open
401
+ await expect(client.get('/test')).rejects.toThrow('Circuit breaker is open');
402
+ } finally {
403
+ ts.stop();
404
+ }
405
+ });
406
+
407
+ test('HttpClient throws when circuit is open', async () => {
408
+ const ts = createTestServer(() => {
409
+ return Response.json({ error: 'fail' }, { status: 500 });
410
+ });
411
+
412
+ try {
413
+ const client = createHttpClient({
414
+ baseUrl: ts.baseUrl,
415
+ circuitBreaker: { threshold: 2, resetTimeout: 5000 },
416
+ });
417
+
418
+ // Trip the breaker
419
+ await client.post('/test', { data: 1 });
420
+ await client.post('/test', { data: 2 });
421
+
422
+ // All methods should be rejected
423
+ await expect(client.get('/test')).rejects.toThrow('Circuit breaker is open');
424
+ await expect(client.post('/test', {})).rejects.toThrow('Circuit breaker is open');
425
+ await expect(client.put('/test', {})).rejects.toThrow('Circuit breaker is open');
426
+ await expect(client.delete('/test')).rejects.toThrow('Circuit breaker is open');
427
+ } finally {
428
+ ts.stop();
429
+ }
430
+ });
431
+ });
432
+
433
+ describe('body serialization', () => {
434
+ test('passes string body through without JSON.stringify', async () => {
435
+ const client = createHttpClient({ baseUrl });
436
+ const res = await client.post<{ body: string; contentType: string | null }>(
437
+ '/echo-raw',
438
+ 'raw string body',
439
+ );
440
+ expect(res.data.body).toBe('raw string body');
441
+ expect(res.data.contentType).not.toBe('application/json');
442
+ });
443
+
444
+ test('passes Blob body through without JSON.stringify', async () => {
445
+ const client = createHttpClient({ baseUrl });
446
+ const blob = new Blob(['blob content'], { type: 'text/plain' });
447
+ const res = await client.post<{ body: string; contentType: string | null }>(
448
+ '/echo-raw',
449
+ blob,
450
+ );
451
+ expect(res.data.body).toBe('blob content');
452
+ });
453
+
454
+ test('passes URLSearchParams body through without JSON.stringify', async () => {
455
+ const client = createHttpClient({ baseUrl });
456
+ const params = new URLSearchParams({ key: 'value' });
457
+ const res = await client.post<{ body: string; contentType: string | null }>(
458
+ '/echo-raw',
459
+ params,
460
+ );
461
+ expect(res.data.body).toBe('key=value');
462
+ expect(res.data.contentType).not.toBe('application/json');
463
+ });
464
+
465
+ test('JSON.stringifies plain objects and sets content-type', async () => {
466
+ const client = createHttpClient({ baseUrl });
467
+ const res = await client.post<{
468
+ body: unknown;
469
+ contentType: string;
470
+ }>('/echo', { key: 'value' });
471
+ expect(res.data.body).toEqual({ key: 'value' });
472
+ expect(res.data.contentType).toBe('application/json');
473
+ });
474
+
475
+ test('post with no body does not set content-type', async () => {
476
+ const client = createHttpClient({ baseUrl });
477
+ const res = await client.post<{ contentType: string | null }>(
478
+ '/echo-raw',
479
+ );
480
+ expect(res.data.contentType).toBeNull();
481
+ });
482
+ });
@@ -0,0 +1,221 @@
1
+ import { CircuitBreaker, type CircuitBreakerOptions } from './circuit-breaker';
2
+
3
+ export interface HttpClientOptions {
4
+ baseUrl?: string;
5
+ timeout?: number;
6
+ retries?: number;
7
+ retryDelay?: number;
8
+ circuitBreaker?: CircuitBreakerOptions;
9
+ }
10
+
11
+ export interface RequestOptions {
12
+ method?: string;
13
+ headers?: Record<string, string>;
14
+ body?: unknown;
15
+ timeout?: number;
16
+ retries?: number;
17
+ }
18
+
19
+ export interface HttpResponse<T = unknown> {
20
+ ok: boolean;
21
+ status: number;
22
+ headers: Headers;
23
+ data: T;
24
+ raw: Response;
25
+ }
26
+
27
+ export interface HttpClient {
28
+ fetch<T = unknown>(
29
+ url: string,
30
+ options?: RequestOptions,
31
+ ): Promise<HttpResponse<T>>;
32
+ get<T = unknown>(
33
+ url: string,
34
+ options?: Omit<RequestOptions, 'method' | 'body'>,
35
+ ): Promise<HttpResponse<T>>;
36
+ post<T = unknown>(
37
+ url: string,
38
+ body?: unknown,
39
+ options?: Omit<RequestOptions, 'method' | 'body'>,
40
+ ): Promise<HttpResponse<T>>;
41
+ put<T = unknown>(
42
+ url: string,
43
+ body?: unknown,
44
+ options?: Omit<RequestOptions, 'method' | 'body'>,
45
+ ): Promise<HttpResponse<T>>;
46
+ delete<T = unknown>(
47
+ url: string,
48
+ options?: Omit<RequestOptions, 'method' | 'body'>,
49
+ ): Promise<HttpResponse<T>>;
50
+ }
51
+
52
+ const DEFAULT_TIMEOUT = 30_000;
53
+
54
+ function isAbsoluteUrl(url: string): boolean {
55
+ return url.startsWith('http://') || url.startsWith('https://');
56
+ }
57
+
58
+ async function parseResponseData(response: Response): Promise<unknown> {
59
+ const contentType = response.headers.get('content-type') ?? '';
60
+ const contentLength = response.headers.get('content-length');
61
+
62
+ if (
63
+ response.status === 204 ||
64
+ contentLength === '0' ||
65
+ response.body === null
66
+ ) {
67
+ return null;
68
+ }
69
+
70
+ if (contentType.includes('application/json')) {
71
+ return response.json();
72
+ }
73
+
74
+ const text = await response.text();
75
+ if (!text) {
76
+ return null;
77
+ }
78
+
79
+ return text;
80
+ }
81
+
82
+ const DEFAULT_RETRIES = 0;
83
+ const DEFAULT_RETRY_DELAY = 1000;
84
+
85
+ function sleep(ms: number): Promise<void> {
86
+ return new Promise((resolve) => setTimeout(resolve, ms));
87
+ }
88
+
89
+ function isRetryableMethod(method: string): boolean {
90
+ return method === 'GET';
91
+ }
92
+
93
+ export function createHttpClient(defaults?: HttpClientOptions): HttpClient {
94
+ const baseUrl = defaults?.baseUrl ?? '';
95
+ const defaultTimeout = defaults?.timeout ?? DEFAULT_TIMEOUT;
96
+ const defaultRetries = defaults?.retries ?? DEFAULT_RETRIES;
97
+ const defaultRetryDelay = defaults?.retryDelay ?? DEFAULT_RETRY_DELAY;
98
+ const breaker = defaults?.circuitBreaker
99
+ ? new CircuitBreaker(defaults.circuitBreaker)
100
+ : undefined;
101
+
102
+ async function doFetch<T = unknown>(
103
+ url: string,
104
+ options?: RequestOptions,
105
+ ): Promise<HttpResponse<T>> {
106
+ if (breaker?.isOpen()) {
107
+ throw new Error('Circuit breaker is open');
108
+ }
109
+ const resolvedUrl = isAbsoluteUrl(url) ? url : `${baseUrl}${url}`;
110
+ const timeout = options?.timeout ?? defaultTimeout;
111
+ const maxRetries = options?.retries ?? defaultRetries;
112
+ const method = options?.method ?? 'GET';
113
+
114
+ const headers: Record<string, string> = { ...options?.headers };
115
+ let body: string | Blob | FormData | ArrayBuffer | URLSearchParams | ReadableStream | undefined;
116
+
117
+ if (options?.body !== undefined) {
118
+ if (
119
+ typeof options.body === 'string' ||
120
+ options.body instanceof Blob ||
121
+ options.body instanceof FormData ||
122
+ options.body instanceof ArrayBuffer ||
123
+ options.body instanceof URLSearchParams ||
124
+ options.body instanceof ReadableStream
125
+ ) {
126
+ body = options.body;
127
+ } else {
128
+ headers['content-type'] =
129
+ headers['content-type'] ?? 'application/json';
130
+ body = JSON.stringify(options.body);
131
+ }
132
+ }
133
+
134
+ let lastError: unknown;
135
+ let lastResponse: HttpResponse<T> | undefined;
136
+
137
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
138
+ if (attempt > 0) {
139
+ await sleep(defaultRetryDelay * 2 ** (attempt - 1));
140
+ }
141
+
142
+ try {
143
+ const response = await fetch(resolvedUrl, {
144
+ method,
145
+ headers,
146
+ body,
147
+ signal: AbortSignal.timeout(timeout),
148
+ });
149
+
150
+ const data = (await parseResponseData(response)) as T;
151
+
152
+ lastResponse = {
153
+ ok: response.ok,
154
+ status: response.status,
155
+ headers: response.headers,
156
+ data,
157
+ raw: response,
158
+ };
159
+
160
+ // Retry on 5xx only for GET requests
161
+ if (response.status >= 500 && isRetryableMethod(method) && attempt < maxRetries) {
162
+ breaker?.recordFailure();
163
+ continue;
164
+ }
165
+
166
+ if (response.status >= 500) {
167
+ breaker?.recordFailure();
168
+ } else {
169
+ breaker?.recordSuccess();
170
+ }
171
+
172
+ return lastResponse;
173
+ } catch (error) {
174
+ lastError = error;
175
+ breaker?.recordFailure();
176
+ }
177
+ }
178
+
179
+ // If we have a last response (5xx after exhausting retries), return it
180
+ if (lastResponse) {
181
+ return lastResponse;
182
+ }
183
+
184
+ // Otherwise throw the last network error
185
+ throw lastError;
186
+ }
187
+
188
+ return {
189
+ fetch: doFetch,
190
+
191
+ get<T = unknown>(
192
+ url: string,
193
+ options?: Omit<RequestOptions, 'method' | 'body'>,
194
+ ) {
195
+ return doFetch<T>(url, { ...options, method: 'GET' });
196
+ },
197
+
198
+ post<T = unknown>(
199
+ url: string,
200
+ body?: unknown,
201
+ options?: Omit<RequestOptions, 'method' | 'body'>,
202
+ ) {
203
+ return doFetch<T>(url, { ...options, method: 'POST', body });
204
+ },
205
+
206
+ put<T = unknown>(
207
+ url: string,
208
+ body?: unknown,
209
+ options?: Omit<RequestOptions, 'method' | 'body'>,
210
+ ) {
211
+ return doFetch<T>(url, { ...options, method: 'PUT', body });
212
+ },
213
+
214
+ delete<T = unknown>(
215
+ url: string,
216
+ options?: Omit<RequestOptions, 'method' | 'body'>,
217
+ ) {
218
+ return doFetch<T>(url, { ...options, method: 'DELETE' });
219
+ },
220
+ };
221
+ }