fetchbasequery 0.2.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,3 @@
1
- export { default as accessToken } from './accessToken.js'
2
- export { default as encodeBody } from './encodeBody.js'
3
- export { default as commonReply } from './commonReply.js'
4
- export { default as jsonReply } from './jsonReply.js'
1
+ export { muteErrors } from './muteErrors.js'
2
+ export { retryOnError } from './retryOnError.js'
3
+ export { retryOn429 } from './retryOn429.js'
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @typedef {import('../index.d.ts').Middleware} Middleware
3
+ */
4
+
5
+ /**
6
+ *
7
+ * @returns {Middleware}
8
+ */
9
+ export function muteErrors() {
10
+ return async (request, next) => {
11
+ try {
12
+ return await next(request)
13
+ } catch (e) {
14
+ return Response.error()
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,37 @@
1
+ import { sleep } from '../utils/sleep.js'
2
+
3
+ /**
4
+ * @typedef {import('./index.d.ts').RetryOn429Options} RetryOn429Options
5
+ */
6
+
7
+ /**
8
+ * @typedef {import('../index.d.ts').Middleware} Middleware
9
+ */
10
+
11
+ /**
12
+ *
13
+ * @param {RetryOn429Options} [options = {}]
14
+ * @returns {Middleware}
15
+ */
16
+ export function retryOn429({
17
+ maxNumAttempts = 3,
18
+ maxBackoffTime = Infinity,
19
+ backoffTime = (attempt, response) => {
20
+ const delayFromHeader = Number(response.headers.get('retry-after'))
21
+ return isNaN(delayFromHeader) ? attempt * 2000 : delayFromHeader * 1000
22
+ }
23
+ } = {}) {
24
+ return async (request, next) => {
25
+ let response
26
+ for (let attempt = 1; attempt <= maxNumAttempts && !request.signal.aborted; attempt ++) {
27
+ response = await next(request)
28
+ if (response.status !== 429)
29
+ break
30
+ const delay = backoffTime(attempt, response)
31
+ if (delay > maxBackoffTime)
32
+ break
33
+ await sleep(delay, request.signal)
34
+ }
35
+ return response || Response.error()
36
+ }
37
+ }
@@ -0,0 +1,32 @@
1
+ import { sleep } from '../utils/sleep.js'
2
+
3
+ /**
4
+ * @typedef {import('./index.d.ts').RetryOnErrorOptions} RetryOnErrorOptions
5
+ */
6
+
7
+ /**
8
+ * @typedef {import('../index.d.ts').Middleware} Middleware
9
+ */
10
+
11
+ /**
12
+ *
13
+ * @param {RetryOnErrorOptions} [options = {}]
14
+ * @returns {Middleware}
15
+ */
16
+ export function retryOnError({
17
+ maxNumAttempts = 3,
18
+ backoffTime = attempt => attempt * 2000
19
+ } = {}) {
20
+ return async (request, next) => {
21
+ let lastError
22
+ for (let attempt = 1; attempt <= maxNumAttempts && !request.signal.aborted; attempt ++) {
23
+ try {
24
+ return await next(request)
25
+ } catch (e) {
26
+ lastError = e
27
+ await sleep(backoffTime(attempt), request.signal)
28
+ }
29
+ }
30
+ throw lastError
31
+ }
32
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @param {number} delay Delay in milliseconds
3
+ * @param {AbortSignal} [signal=undefined]
4
+ * @returns
5
+ */
6
+ export function sleep(delay, signal = undefined) {
7
+ return new Promise((resolve, reject) => { // abortable sleep
8
+ const id = setTimeout(resolve, delay)
9
+ if (signal instanceof AbortSignal) {
10
+ const abortSignal = AbortSignal.any([ signal ])
11
+ abortSignal.onabort = () => {
12
+ clearInterval(id)
13
+ reject(abortSignal.reason)
14
+ }
15
+ }
16
+ })
17
+ }
@@ -0,0 +1,7 @@
1
+ <!doctype html>
2
+ <html>
3
+ <body>
4
+ <span>Please, look at console...</span>
5
+ <script src="./index.js" type="module" charset="utf-8"></script>
6
+ </body>
7
+ </html>
package/tests/index.js ADDED
@@ -0,0 +1,546 @@
1
+ import { fetchBaseQuery } from "../src/fetchBaseQuery.js"
2
+ import { muteErrors } from "../src/middlewares/muteErrors.js"
3
+ import { retryOnError } from "../src/middlewares/retryOnError.js"
4
+ import { retryOn429 } from "../src/middlewares/retryOn429.js"
5
+ import { json } from "../src/decorators/json.js"
6
+ import { sleep } from "../src/utils/sleep.js"
7
+
8
+ const createFakeFetch = () => {
9
+
10
+ const session = {
11
+ attempts: 0,
12
+ counter: 1
13
+ }
14
+
15
+ const connectionError = () => { throw new Error('Failed to load resource: net::ERR_CONNECTION_TIMED_OUT') }
16
+
17
+ /**
18
+ *
19
+ * @param {string | Request} resource
20
+ * @param {RequestInit} [options = {}]
21
+ * @returns {Promise<Response>}
22
+ */
23
+ return async (resource, options = {}) => {
24
+ const request = new Request(resource, options)
25
+ const url = new URL(request.url)
26
+ if (url.origin !== 'https://test.local')
27
+ connectionError()
28
+ switch (url.pathname) {
29
+ case '/counter': {
30
+ switch (request.method.toLowerCase()) {
31
+ case 'get': {
32
+ return new Response(JSON.stringify({
33
+ success: true,
34
+ counter: session.counter
35
+ }), {
36
+ status: 200,
37
+ statusText: 'OK',
38
+ headers: {
39
+ 'Content-Type': 'application/json; charset=utf-8'
40
+ }
41
+ })
42
+ }
43
+ case 'post': {
44
+ session.counter += 1
45
+ return new Response(JSON.stringify({
46
+ success: true,
47
+ counter: session.counter
48
+ }), {
49
+ status: 200,
50
+ statusText: 'OK',
51
+ headers: {
52
+ 'Content-Type': 'application/json; charset=utf-8'
53
+ }
54
+ })
55
+ }
56
+ case 'put': {
57
+ const { delta } = await request.json()
58
+ session.counter += delta
59
+ return new Response(JSON.stringify({
60
+ success: true,
61
+ counter: session.counter
62
+ }), {
63
+ status: 200,
64
+ statusText: 'OK',
65
+ headers: {
66
+ 'Content-Type': 'application/json; charset=utf-8'
67
+ }
68
+ })
69
+ }
70
+ }
71
+ } break
72
+ case '/long-task': {
73
+ switch (request.method.toLowerCase()) {
74
+ case 'post': {
75
+ await sleep(200)
76
+ return new Response(JSON.stringify({
77
+ success: true,
78
+ }), {
79
+ status: 200,
80
+ statusText: 'OK',
81
+ headers: {
82
+ 'Content-Type': 'application/json; charset=utf-8'
83
+ }
84
+ })
85
+ }
86
+ }
87
+ } break
88
+ case '/very-long-task': {
89
+ switch (request.method.toLowerCase()) {
90
+ case 'post': {
91
+ await sleep(500, request.signal)
92
+ if (request.signal.aborted)
93
+ connectionError()
94
+ return new Response(JSON.stringify({
95
+ success: true,
96
+ }), {
97
+ status: 200,
98
+ statusText: 'OK',
99
+ headers: {
100
+ 'Content-Type': 'application/json; charset=utf-8'
101
+ }
102
+ })
103
+ }
104
+ }
105
+ } break
106
+ case '/too-many-requests': {
107
+ switch (request.method.toLowerCase()) {
108
+ case 'get': {
109
+ const attempts = ++ session.attempts
110
+ if (attempts < 3) {
111
+ return new Response(null, {
112
+ status: 429,
113
+ statusText: 'Too Many Requests',
114
+ headers: {
115
+ 'Retry-After': '1'
116
+ }
117
+ })
118
+ }
119
+ return new Response(JSON.stringify({
120
+ success: true,
121
+ attempts
122
+ }), {
123
+ status: 200,
124
+ statusText: 'OK',
125
+ headers: {
126
+ 'Content-Type': 'application/json; charset=utf-8'
127
+ }
128
+ })
129
+ }
130
+ }
131
+ } break
132
+ case '/try-harder': {
133
+ switch (request.method.toLowerCase()) {
134
+ case 'get': {
135
+ const attempts = ++ session.attempts
136
+ if (attempts < 3)
137
+ connectionError()
138
+ return new Response(JSON.stringify({
139
+ success: true,
140
+ attempts
141
+ }), {
142
+ status: 200,
143
+ statusText: 'OK',
144
+ headers: {
145
+ 'Content-Type': 'application/json; charset=utf-8'
146
+ }
147
+ })
148
+ }
149
+ }
150
+ } break
151
+ case '/echo-content-type': {
152
+ switch (request.method.toLowerCase()) {
153
+ case 'post': {
154
+ return new Response(JSON.stringify({
155
+ success: true,
156
+ contentType: request.headers.get('content-type'),
157
+ }), {
158
+ status: 200,
159
+ statusText: 'OK',
160
+ headers: {
161
+ 'Content-Type': 'application/json; charset=utf-8'
162
+ }
163
+ })
164
+ }
165
+ }
166
+ } break
167
+ }
168
+ return new Response(null, {
169
+ status: 404,
170
+ statusText: 'Not Found'
171
+ })
172
+ }
173
+ }
174
+
175
+ async function test1() {
176
+ const query = fetchBaseQuery({
177
+ baseUrl: 'https://test.local',
178
+ fetch: createFakeFetch()
179
+ })
180
+ {
181
+ const response = await query('/counter')
182
+ if (response.status !== 200)
183
+ throw new Error('E01')
184
+ const data = await response.json()
185
+ if (data?.counter !== 1)
186
+ throw new Error('E02')
187
+ }
188
+ {
189
+ const response = await query('https://test.local/counter')
190
+ if (response.status !== 200)
191
+ throw new Error('E03')
192
+ const data = await response.json()
193
+ if (data?.counter !== 1)
194
+ throw new Error('E04')
195
+ }
196
+ {
197
+ try {
198
+ const response = await query('https://test.global/counter')
199
+ throw new Error('E05')
200
+ } catch (e) {
201
+ }
202
+ }
203
+ }
204
+
205
+ async function test2() {
206
+ const query = fetchBaseQuery({
207
+ baseUrl: 'https://test.local',
208
+ fetch: createFakeFetch()
209
+ })
210
+ {
211
+ const response = await query('/counter')
212
+ if (response.status !== 200)
213
+ throw new Error('E01')
214
+ const data = await response.json()
215
+ if (data?.counter !== 1)
216
+ throw new Error('E02')
217
+ }
218
+ {
219
+ const response = await query('/counter', { method: 'post' })
220
+ if (response.status !== 200)
221
+ throw new Error('E03')
222
+ const data = await response.json()
223
+ if (data?.counter !== 2)
224
+ throw new Error('E04')
225
+ }
226
+ {
227
+ const response = await query('/counter')
228
+ if (response.status !== 200)
229
+ throw new Error('E05')
230
+ const data = await response.json()
231
+ if (data?.counter !== 2)
232
+ throw new Error('E06')
233
+ }
234
+ {
235
+ const response = await query('/counter', { method: 'post' })
236
+ if (response.status !== 200)
237
+ throw new Error('E07')
238
+ const data = await response.json()
239
+ if (data?.counter !== 3)
240
+ throw new Error('E08')
241
+ }
242
+ }
243
+
244
+ async function test3() {
245
+ const query = fetchBaseQuery({
246
+ baseUrl: 'https://test.local',
247
+ fetch: createFakeFetch(),
248
+ middlewares: [
249
+ muteErrors()
250
+ ]
251
+ })
252
+ {
253
+ const response = await query('https://test.global/counter')
254
+ if (response.status !== 0)
255
+ throw new Error('E01')
256
+ }
257
+ {
258
+ const response = await query('/not-existed-path')
259
+ if (response.status !== 404)
260
+ throw new Error('E02')
261
+ }
262
+ }
263
+
264
+ async function test4() {
265
+ const query = fetchBaseQuery({
266
+ baseUrl: 'https://test.local',
267
+ fetch: createFakeFetch(),
268
+ })
269
+ {
270
+ const response = await query('/echo-content-type', {
271
+ method: 'post',
272
+ body: new URLSearchParams({ 'foo': 'bar' })
273
+ })
274
+ if (response.status !== 200)
275
+ throw new Error('E01')
276
+ const data = await response.json()
277
+ if (!/application\/x-www-form-urlencoded(\;| |$)/.test(data.contentType))
278
+ throw new Error('E02')
279
+ }
280
+ {
281
+ const body = new FormData()
282
+ body.append('foo', 'bat')
283
+ const response = await query('/echo-content-type', {
284
+ method: 'post',
285
+ body
286
+ })
287
+ if (response.status !== 200)
288
+ throw new Error('E03')
289
+ const data = await response.json()
290
+ if (!/multipart\/form-data(\;| |$)/.test(data.contentType))
291
+ throw new Error('E04')
292
+ }
293
+ }
294
+
295
+ async function test5() {
296
+ const query = fetchBaseQuery({
297
+ baseUrl: 'https://test.local',
298
+ timeout: 300,
299
+ fetch: createFakeFetch(),
300
+ middlewares: [
301
+ muteErrors()
302
+ ]
303
+ })
304
+ {
305
+ const response = await query('/long-task', { method: 'post' })
306
+ if (response.status !== 200)
307
+ throw new Error('E01')
308
+ }
309
+ {
310
+ const responsePromise = query('/very-long-task', { method: 'post' })
311
+ setTimeout(() => { responsePromise.abort() }, 100)
312
+ const response = await responsePromise
313
+ if (response.status !== 0)
314
+ throw new Error('E02')
315
+ }
316
+ {
317
+ const response = await query('/very-long-task', { method: 'post' })
318
+ if (response.status !== 0)
319
+ throw new Error('E03')
320
+ }
321
+ }
322
+
323
+ async function test6() {
324
+ {
325
+ const query = fetchBaseQuery({
326
+ baseUrl: 'https://test.local',
327
+ fetch: createFakeFetch(),
328
+ middlewares: [
329
+ retryOn429({
330
+ maxNumAttempts: 5
331
+ })
332
+ ]
333
+ })
334
+ const response = await query('/too-many-requests')
335
+ if (response.status !== 200)
336
+ throw new Error('E01')
337
+ const data = await response.json()
338
+ if (data.attempts !== 3)
339
+ throw new Error('E02')
340
+ }
341
+ {
342
+ const query = fetchBaseQuery({
343
+ baseUrl: 'https://test.local',
344
+ fetch: createFakeFetch(),
345
+ middlewares: [
346
+ retryOn429({
347
+ maxNumAttempts: 2
348
+ })
349
+ ]
350
+ })
351
+ const response = await query('/too-many-requests')
352
+ if (response.status !== 429)
353
+ throw new Error('E03')
354
+ }
355
+ {
356
+ const query = fetchBaseQuery({
357
+ baseUrl: 'https://test.local',
358
+ fetch: createFakeFetch(),
359
+ timeout: 300,
360
+ middlewares: [
361
+ muteErrors(),
362
+ retryOn429({
363
+ maxNumAttempts: 5
364
+ })
365
+ ]
366
+ })
367
+ const response = await query('/too-many-requests')
368
+ if (response.status !== 0)
369
+ throw new Error('E04')
370
+ }
371
+ }
372
+
373
+ async function test7() {
374
+ {
375
+ const query = fetchBaseQuery({
376
+ baseUrl: 'https://test.local',
377
+ fetch: createFakeFetch(),
378
+ middlewares: [
379
+ retryOnError({
380
+ maxNumAttempts: 5,
381
+ backoffTime: attempt => 200 + (attempt * 0)
382
+ })
383
+ ]
384
+ })
385
+ const response = await query('/try-harder')
386
+ if (response.status !== 200)
387
+ throw new Error('E01')
388
+ const data = await response.json()
389
+ if (data.attempts !== 3)
390
+ throw new Error('E02')
391
+ }
392
+ {
393
+ const query = fetchBaseQuery({
394
+ baseUrl: 'https://test.local',
395
+ fetch: createFakeFetch(),
396
+ middlewares: [
397
+ muteErrors(),
398
+ retryOnError({
399
+ maxNumAttempts: 2,
400
+ backoffTime: attempt => 200 + (attempt * 0)
401
+ })
402
+ ]
403
+ })
404
+ const response = await query('/try-harder')
405
+ if (response.status !== 0)
406
+ throw new Error('E03')
407
+ }
408
+ {
409
+ const query = fetchBaseQuery({
410
+ baseUrl: 'https://test.local',
411
+ fetch: createFakeFetch(),
412
+ timeout: 300,
413
+ middlewares: [
414
+ muteErrors(),
415
+ retryOnError({
416
+ maxNumAttempts: 5,
417
+ backoffTime: attempt => 200 + (attempt * 0)
418
+ })
419
+ ]
420
+ })
421
+ const response = await query('/try-harder')
422
+ if (response.status !== 0)
423
+ throw new Error('E04')
424
+ }
425
+ }
426
+
427
+ async function test8() {
428
+ {
429
+ const query = json(fetchBaseQuery({
430
+ baseUrl: 'https://test.local',
431
+ fetch: createFakeFetch(),
432
+ middlewares: [
433
+ retryOnError({
434
+ maxNumAttempts: 5,
435
+ backoffTime: attempt => 200 + (attempt * 0)
436
+ })
437
+ ]
438
+ }))
439
+ const response = await query('/try-harder')
440
+ if (response.status !== 200)
441
+ throw new Error('E01')
442
+ const { data } = response
443
+ if (data.attempts !== 3)
444
+ throw new Error('E02')
445
+ }
446
+ {
447
+ const query = json(fetchBaseQuery({
448
+ baseUrl: 'https://test.local',
449
+ fetch: createFakeFetch(),
450
+ }))
451
+ {
452
+ const response = await query('/counter')
453
+ if (response.status !== 200)
454
+ throw new Error('E03')
455
+ if (response.data?.counter !== 1)
456
+ throw new Error('E04')
457
+ }
458
+ {
459
+ const response = await query('/counter', { method: 'post' })
460
+ if (response.status !== 200)
461
+ throw new Error('E05')
462
+ if (response.data?.counter !== 2)
463
+ throw new Error('E06')
464
+ }
465
+ {
466
+ const response = await query('/counter', { method: 'put', body: { delta: 3 } })
467
+ if (response.status !== 200)
468
+ throw new Error('E05')
469
+ if (response.data?.counter !== 5)
470
+ throw new Error('E06')
471
+ }
472
+ {
473
+ const response = await query('/echo-content-type', {
474
+ method: 'post',
475
+ body: { 'foo': 'bar' }
476
+ })
477
+ if (response.status !== 200)
478
+ throw new Error('E07')
479
+ const { data } = response
480
+ if (data.contentType !== 'application/json; charset=utf-8')
481
+ throw new Error('E08')
482
+ }
483
+ {
484
+ const response = await query('/echo-content-type', {
485
+ method: 'post',
486
+ headers: { 'Content-Type': 'application/json' },
487
+ body: { 'foo': 'bar' }
488
+ })
489
+ if (response.status !== 200)
490
+ throw new Error('E09')
491
+ const { data } = response
492
+ if (data.contentType !== 'application/json')
493
+ throw new Error('E10')
494
+ }
495
+ }
496
+ }
497
+
498
+ async function test9() {
499
+ const query = fetchBaseQuery({
500
+ fetch: createFakeFetch(),
501
+ })
502
+ const promise = query('https://test.local/very-long-task', { method: 'post' })
503
+ let error
504
+ promise.catch(err => {
505
+ error = err
506
+ })
507
+ const reason = 'Unmounting...'
508
+ try {
509
+ setTimeout(() => promise.abort(reason), 50)
510
+ await promise
511
+ } catch (e) {
512
+ if (e !== reason)
513
+ throw new Error('E01')
514
+ } finally {
515
+ if (error !== reason)
516
+ throw new Error('E02')
517
+ }
518
+ }
519
+
520
+ async function runTests() {
521
+ const allTests = [
522
+ test1,
523
+ test2,
524
+ test3,
525
+ test4,
526
+ test5,
527
+ test6,
528
+ test7,
529
+ test8,
530
+ test9,
531
+ ]
532
+ for (const test of allTests) {
533
+ await test()
534
+ }
535
+ }
536
+
537
+ console.log('Running tests, wait...')
538
+ runTests()
539
+ .then(() => {
540
+ console.log('All tests done.')
541
+ globalThis?.process?.exit(0)
542
+ })
543
+ .catch(error => {
544
+ console.error('Error while testing:', error)
545
+ globalThis?.process?.exit(1)
546
+ })