unfee 0.1.2 → 1.0.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.
package/README.md CHANGED
@@ -0,0 +1 @@
1
+ # unfee
package/dist/index.d.ts CHANGED
@@ -8,30 +8,41 @@ declare class HTTPError extends Error {
8
8
  constructor(response: Response, request: Request, options: FetchOptions);
9
9
  }
10
10
  //#endregion
11
+ //#region src/parse-error.d.ts
12
+ declare class ParseError extends Error {
13
+ readonly response: Response;
14
+ readonly request: Request;
15
+ readonly options: FetchOptions;
16
+ constructor(response: Response, request: Request, options: FetchOptions, cause?: unknown);
17
+ }
18
+ //#endregion
11
19
  //#region src/types.d.ts
12
20
  type Input = string | URL;
13
21
  interface Fetch {
14
22
  <T = any>(input: Input, options?: FetchOptions): Promise<FetchResponse<T>>;
15
23
  extend: (options: ExtendOptions) => Fetch;
16
24
  }
17
- interface FetchResponse<T> {
18
- headers: Headers;
19
- data: T;
20
- status: number;
21
- statusText: string;
22
- }
25
+ type FetchResponse<T> = [error: null, data: T, response: Response | null] | [error: HTTPError | ParseError | Error, data: null, response: Response | null];
23
26
  interface ExtendOptions {
24
27
  baseUrl?: string;
25
28
  headers?: HeadersInit;
26
29
  query?: Record<string, any>;
27
30
  hooks?: Hooks;
31
+ signal?: AbortSignal;
32
+ timeout?: number;
33
+ retry?: Retry;
28
34
  }
29
35
  interface FetchOptions {
30
36
  method?: RequestHttpVerbs;
31
37
  query?: Record<string, any>;
32
38
  headers?: HeadersInit;
33
39
  data?: string | FormData | URLSearchParams | object;
40
+ responseType?: keyof typeof ResponseType;
34
41
  hooks?: Hooks;
42
+ signal?: AbortSignal;
43
+ timeout?: number;
44
+ native?: Omit<RequestInit, "method" | "headers" | "body" | "signal">;
45
+ retry?: Retry;
35
46
  }
36
47
  interface Hooks {
37
48
  beforeRequest?: (options: Omit<FetchOptions, "hooks">) => void;
@@ -39,29 +50,43 @@ interface Hooks {
39
50
  onRequestError?: (error: unknown, request: Request, options: Readonly<FetchOptions>) => void;
40
51
  onResponseError?: (error: HTTPError, response: Response, request: Request, options: Readonly<FetchOptions>) => void;
41
52
  onResponseParseError?: (error: unknown, response: Response, request: Request, options: Readonly<FetchOptions>) => void;
53
+ onRequestRetry?: (retryCount: number, response: Response, request: Request, options: Readonly<FetchOptions>) => void;
54
+ }
55
+ interface Retry {
56
+ times?: number;
57
+ delay?: number;
58
+ statusCode?: Set<number>;
42
59
  }
43
60
  interface Context {
44
- method: string;
61
+ method: RequestHttpVerbs;
45
62
  url: string;
46
63
  headers: HeadersInit;
47
64
  query: URLSearchParams;
48
65
  data: BodyInit | undefined;
49
66
  options: FetchOptions;
67
+ responseType?: ResponseType;
68
+ signal?: AbortSignal;
69
+ timeout?: number;
50
70
  hooks: {
51
71
  beforeRequest: Hooks["beforeRequest"][];
52
72
  afterResponse: Hooks["afterResponse"][];
53
73
  onRequestError: Hooks["onRequestError"][];
54
74
  onResponseError: Hooks["onResponseError"][];
55
75
  onResponseParseError: Hooks["onResponseParseError"][];
76
+ onRequestRetry: Hooks["onRequestRetry"][];
56
77
  };
78
+ retry: Required<Retry>;
57
79
  }
58
80
  type RequestHttpVerbs = "get" | "post" | "put" | "patch" | "head" | "delete" | "options" | "trace";
59
81
  declare enum ResponseType {
60
82
  text = 0,
61
- json = 1
83
+ json = 1,
84
+ formdata = 2,
85
+ arraybuffer = 3,
86
+ blob = 4
62
87
  }
63
88
  //#endregion
64
89
  //#region src/index.d.ts
65
- declare const bfetch: Fetch;
90
+ declare const unfee: Fetch;
66
91
  //#endregion
67
- export { Context, ExtendOptions, Fetch, FetchOptions, FetchResponse, Hooks, Input, RequestHttpVerbs, ResponseType, bfetch };
92
+ export { Context, ExtendOptions, Fetch, FetchOptions, FetchResponse, Hooks, Input, RequestHttpVerbs, ResponseType, Retry, unfee };
package/dist/index.js CHANGED
@@ -13,11 +13,40 @@ var HTTPError = class extends Error {
13
13
  }
14
14
  };
15
15
 
16
+ //#endregion
17
+ //#region src/constants.ts
18
+ const RETRY_STATUS_CODES = new Set([
19
+ 408,
20
+ 409,
21
+ 425,
22
+ 429,
23
+ 500,
24
+ 502,
25
+ 503,
26
+ 504
27
+ ]);
28
+ const PAYLOAD_METHODS = new Set([
29
+ "post",
30
+ "put",
31
+ "patch",
32
+ "delete"
33
+ ]);
34
+ const TEXT_TYPES = new Set([
35
+ "image/svg",
36
+ "application/xml",
37
+ "application/xhtml",
38
+ "application/html"
39
+ ]);
40
+ const JSON_RESPONSE = /^application\/(?:[\w!#$%&*.^`~-]*\+)?json(?:;.+)?$/i;
41
+
16
42
  //#endregion
17
43
  //#region src/types.ts
18
44
  let ResponseType = /* @__PURE__ */ function(ResponseType) {
19
45
  ResponseType[ResponseType["text"] = 0] = "text";
20
46
  ResponseType[ResponseType["json"] = 1] = "json";
47
+ ResponseType[ResponseType["formdata"] = 2] = "formdata";
48
+ ResponseType[ResponseType["arraybuffer"] = 3] = "arraybuffer";
49
+ ResponseType[ResponseType["blob"] = 4] = "blob";
21
50
  return ResponseType;
22
51
  }({});
23
52
 
@@ -28,14 +57,18 @@ function constructRequest(ctx) {
28
57
  return new Request(ctx.url + (query ? `?${query}` : ""), {
29
58
  method: ctx.method,
30
59
  headers: ctx.headers,
31
- body: ctx.data
60
+ signal: ctx.signal,
61
+ body: ctx.data,
62
+ ...ctx.options.native
32
63
  });
33
64
  }
34
65
  function detectResponseType(responseTypeHeader) {
35
- const JSON_RESPONSE = /^application\/(?:[\w!#$%&*.^`~-]*\+)?json(?:;.+)?$/i;
36
66
  responseTypeHeader = responseTypeHeader.split(";").shift() || "";
37
67
  if (JSON_RESPONSE.test(responseTypeHeader)) return ResponseType.json;
38
- return ResponseType.text;
68
+ if (TEXT_TYPES.has(responseTypeHeader) || responseTypeHeader.startsWith("text/")) return ResponseType.text;
69
+ if (responseTypeHeader.includes("application/octet-stream")) return ResponseType.arraybuffer;
70
+ if (responseTypeHeader.includes("multipart/form-data")) return ResponseType.formdata;
71
+ return ResponseType.blob;
39
72
  }
40
73
 
41
74
  //#endregion
@@ -62,6 +95,27 @@ function mergeParams(...params) {
62
95
  });
63
96
  return result;
64
97
  }
98
+ function mergeRetry(...params) {
99
+ const result = {
100
+ times: 1,
101
+ delay: 0,
102
+ statusCode: RETRY_STATUS_CODES
103
+ };
104
+ for (const param of params) {
105
+ if (param.times !== void 0) result.times = param.times;
106
+ if (param.delay !== void 0) result.delay = param.delay;
107
+ if (param.statusCode !== void 0) result.statusCode = param.statusCode;
108
+ }
109
+ return result;
110
+ }
111
+ function mergeSignals(...signals) {
112
+ const controller = new AbortController();
113
+ signals.forEach((signal) => {
114
+ if (signal) if (signal.aborted) controller.abort();
115
+ else signal.addEventListener("abort", () => controller.abort());
116
+ });
117
+ return controller.signal;
118
+ }
65
119
 
66
120
  //#endregion
67
121
  //#region src/parse-error.ts
@@ -75,13 +129,18 @@ var ParseError = class extends Error {
75
129
  }
76
130
  };
77
131
 
132
+ //#endregion
133
+ //#region src/utils.ts
134
+ function isIdempotentRequest(method) {
135
+ return !PAYLOAD_METHODS.has(method);
136
+ }
137
+
78
138
  //#endregion
79
139
  //#region src/fetch.ts
80
- function createContext(input, options, extendOption) {
81
- let method = "get";
140
+ function createContext(input, options, extendOption, timeoutSignal) {
141
+ const method = options.method ?? "get";
82
142
  let data;
83
143
  let headers = { Accept: "application/json, text/plain, */*" };
84
- if (options.method) method = options.method;
85
144
  if (options.data instanceof URLSearchParams) {
86
145
  headers["Content-Type"] = "application/x-www-form-urlencoded";
87
146
  data = options.data.toString();
@@ -98,70 +157,93 @@ function createContext(input, options, extendOption) {
98
157
  ...options.headers ?? {}
99
158
  };
100
159
  return {
101
- method: method.toUpperCase(),
160
+ method,
102
161
  url: mergeURL(input, extendOption.baseUrl),
103
162
  query: mergeParams(extendOption.query ?? {}, options.query ?? {}),
163
+ retry: mergeRetry(extendOption.retry ?? {}, options.retry ?? {}),
164
+ responseType: options.responseType ? ResponseType[options.responseType] : void 0,
104
165
  headers,
105
166
  data,
106
167
  options,
168
+ timeout: options.timeout ?? extendOption.timeout,
169
+ signal: mergeSignals(extendOption.signal, options.signal, timeoutSignal),
107
170
  hooks: {
108
171
  onRequestError: [extendOption.hooks?.onRequestError, options.hooks?.onRequestError],
109
172
  onResponseError: [extendOption.hooks?.onResponseError, options.hooks?.onResponseError],
110
173
  beforeRequest: [extendOption.hooks?.beforeRequest, options.hooks?.beforeRequest],
111
174
  afterResponse: [extendOption.hooks?.afterResponse, options.hooks?.afterResponse],
112
- onResponseParseError: [extendOption.hooks?.onResponseParseError, options.hooks?.onResponseParseError]
175
+ onResponseParseError: [extendOption.hooks?.onResponseParseError, options.hooks?.onResponseParseError],
176
+ onRequestRetry: [extendOption.hooks?.onRequestRetry, options.hooks?.onRequestRetry]
113
177
  }
114
178
  };
115
179
  }
116
180
  async function createFetchResponse(response, request, ctx) {
117
- const contentType = detectResponseType(response.headers.get("content-type") ?? "application/json");
181
+ ctx.hooks.afterResponse[0]?.(request, response, ctx.options);
182
+ ctx.hooks.afterResponse[1]?.(request, response, ctx.options);
183
+ const contentType = ctx.responseType ?? detectResponseType(response.headers.get("content-type") ?? "application/json");
118
184
  let data;
119
185
  try {
120
186
  if (contentType === ResponseType.json) data = await response.json();
187
+ else if (contentType === ResponseType.formdata) data = await response.formData();
188
+ else if (contentType === ResponseType.arraybuffer) data = await response.arrayBuffer();
189
+ else if (contentType === ResponseType.blob) data = await response.blob();
121
190
  else data = await response.text();
122
191
  } catch (error) {
123
192
  throw new ParseError(response, request, ctx.options, error);
124
193
  }
125
- return {
126
- data,
127
- status: response.status,
128
- headers: response.headers,
129
- statusText: response.statusText
130
- };
194
+ return data;
195
+ }
196
+ async function handleFetchError(response, request, ctx, error, retryCount) {
197
+ if (error instanceof ParseError) {
198
+ ctx.hooks.onResponseParseError[0]?.(error, response, request, ctx.options);
199
+ ctx.hooks.onResponseParseError[1]?.(error, response, request, ctx.options);
200
+ } else if (error instanceof HTTPError) {
201
+ ctx.hooks.onResponseError[0]?.(error, response, request, ctx.options);
202
+ ctx.hooks.onResponseError[1]?.(error, response, request, ctx.options);
203
+ if (isIdempotentRequest(ctx.method) && ctx.retry.statusCode.has(response.status) && retryCount < ctx.retry.times) {
204
+ ctx.hooks.onRequestRetry[0]?.(retryCount, response, request, ctx.options);
205
+ ctx.hooks.onRequestRetry[1]?.(retryCount, response, request, ctx.options);
206
+ if (ctx.retry.delay > 0) await new Promise((resolve) => setTimeout(resolve, ctx.retry.delay));
207
+ }
208
+ } else {
209
+ ctx.hooks.onRequestError[0]?.(error, request, ctx.options);
210
+ ctx.hooks.onRequestError[1]?.(error, request, ctx.options);
211
+ }
131
212
  }
132
213
  function createFetch(extendOptions) {
133
214
  const $fetch = async function(input, options = {}) {
134
- extendOptions.hooks?.beforeRequest?.(options);
135
- options.hooks?.beforeRequest?.(options);
136
- const context = createContext(input, options, extendOptions);
137
- const request = constructRequest(context);
138
- let inflight;
139
- try {
140
- inflight = await fetch(request);
141
- if (!inflight.ok) throw new HTTPError(inflight, request, context.options);
142
- context.hooks.afterResponse[0]?.(request, inflight, options);
143
- context.hooks.afterResponse[1]?.(request, inflight, options);
144
- return await createFetchResponse(inflight, request, context);
145
- } catch (error) {
146
- if (error instanceof ParseError) {
147
- context.hooks.onResponseParseError[0]?.(error, inflight, request, options);
148
- context.hooks.onResponseParseError[1]?.(error, inflight, request, options);
149
- } else if (error instanceof HTTPError) {
150
- context.hooks.onResponseError[0]?.(error, inflight, request, options);
151
- context.hooks.onResponseError[1]?.(error, inflight, request, options);
152
- } else {
153
- context.hooks.onRequestError[0]?.(error, request, options);
154
- context.hooks.onRequestError[1]?.(error, request, options);
215
+ let context = null;
216
+ let maxRetries = 0;
217
+ for (let retryCount = 0; retryCount <= maxRetries; retryCount++) {
218
+ extendOptions.hooks?.beforeRequest?.(options);
219
+ options.hooks?.beforeRequest?.(options);
220
+ const timeoutController = new AbortController();
221
+ let timer;
222
+ context = createContext(input, options, extendOptions, timeoutController.signal);
223
+ if (context.timeout) timer = setTimeout(() => {
224
+ timeoutController.abort();
225
+ }, context.timeout);
226
+ maxRetries = context.retry.times;
227
+ const request = constructRequest(context);
228
+ let inflight;
229
+ try {
230
+ inflight = await fetch(request);
231
+ if (!inflight.ok) throw new HTTPError(inflight, request, context.options);
232
+ return await createFetchResponse(inflight, request, context);
233
+ } catch (error) {
234
+ await handleFetchError(inflight, request, context, error, retryCount);
235
+ } finally {
236
+ clearTimeout(timer);
155
237
  }
156
- throw error;
157
238
  }
239
+ throw new Error("Failed to fetch after retries");
158
240
  };
159
241
  return Object.assign($fetch, { extend: createFetch });
160
242
  }
161
243
 
162
244
  //#endregion
163
245
  //#region src/index.ts
164
- const bfetch = createFetch({});
246
+ const unfee = createFetch({});
165
247
 
166
248
  //#endregion
167
- export { ResponseType, bfetch };
249
+ export { ResponseType, unfee };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "unfee",
3
3
  "type": "module",
4
- "version": "0.1.2",
4
+ "version": "1.0.1",
5
5
  "description": "Fetch API with sensible defaults",
6
6
  "license": "MIT",
7
7
  "repository": "ayb-cha/unfee",