1 | import qs from "qs";
|
2 | import urlJoin from "url-join";
|
3 | import asyncReduce from "./async-reduce";
|
4 | import { setupAbort, teardownAbort } from "./abort";
|
5 |
|
6 | if (typeof fetch === "undefined") {
|
7 | throw new Error(
|
8 | "You need a fetch implementation. Try npm install cross-fetch"
|
9 | );
|
10 | }
|
11 |
|
12 | if (typeof AbortController === "undefined") {
|
13 | throw new Error(
|
14 | "You're missing an AbortController implementation. Try npm install abortcontroller-polyfill"
|
15 | );
|
16 | }
|
17 |
|
18 | const METHODS = ["GET", "POST", "PUT", "HEAD", "OPTIONS", "DELETE", "PATCH"];
|
19 |
|
20 | const doFetch = (method, context, path, options) => {
|
21 |
|
22 | const { abortToken, ...rest } = options;
|
23 |
|
24 | const opts = {
|
25 | ...rest,
|
26 | method,
|
27 | headers: {
|
28 | ...(context.headers || {}),
|
29 | ...options.headers
|
30 | }
|
31 | };
|
32 |
|
33 |
|
34 | opts.headers = Object.keys(opts.headers).reduce((carry, key) => {
|
35 | const value = opts.headers[key];
|
36 | return value ? { [key]: value, ...carry } : carry;
|
37 | }, {});
|
38 |
|
39 | if (!opts.body && method === "POST") {
|
40 | opts.body = "";
|
41 | }
|
42 |
|
43 | if (method === "GET" && opts.body) {
|
44 | path += `?${qs.stringify(opts.body, { arrayFormat: context.arrayFormat })}`;
|
45 | delete opts.body;
|
46 | }
|
47 |
|
48 | if (opts.body && typeof opts.body === "object") {
|
49 | opts.body = JSON.stringify(opts.body);
|
50 | }
|
51 |
|
52 | const fullUri = context.baseURI ? urlJoin(context.baseURI, path) : path;
|
53 | return fetch(fullUri, opts);
|
54 | };
|
55 |
|
56 | const call = async (
|
57 | method,
|
58 | context,
|
59 | { path, options },
|
60 | extra,
|
61 | retryCount = 0
|
62 | ) => {
|
63 |
|
64 |
|
65 |
|
66 | const signal = setupAbort(
|
67 | options,
|
68 | context.abortController,
|
69 | context.abortTokenMap
|
70 | );
|
71 | const originalOptions = { ...options };
|
72 | const originalExtra = { ...extra };
|
73 | const originalPath = path;
|
74 |
|
75 | const returnedFromInterceptors = [];
|
76 | const interceptors = Array.from(context.interceptors);
|
77 | const req = asyncReduce(
|
78 | interceptors,
|
79 | Promise.resolve([path, options, {...extra}, retryCount]),
|
80 | "request",
|
81 | "requestError",
|
82 |
|
83 | args => {
|
84 | const [path,options] = args.slice(0, 2);
|
85 | returnedFromInterceptors.push([path,{...options}]);
|
86 | return [path,options].concat([{ ...extra }, retryCount]);
|
87 | }
|
88 | );
|
89 |
|
90 | const res = asyncReduce(
|
91 | interceptors.reverse(),
|
92 | (async () => {
|
93 |
|
94 | const [path, options] = await req;
|
95 | returnedFromInterceptors.push([path,{...options}]);
|
96 | const res = await doFetch(method, context, path, { ...options, signal });
|
97 | res.flighty = {
|
98 | method,
|
99 | retryCount,
|
100 |
|
101 | call: {
|
102 | path: originalPath,
|
103 | options: originalOptions,
|
104 | extra: originalExtra
|
105 | },
|
106 |
|
107 | intercepted:returnedFromInterceptors,
|
108 |
|
109 | retry: async () => {
|
110 | retryCount++;
|
111 | return await call(
|
112 | method,
|
113 | context,
|
114 | { path: originalPath, options: originalOptions },
|
115 | originalExtra,
|
116 | retryCount
|
117 | );
|
118 | }
|
119 | };
|
120 |
|
121 |
|
122 |
|
123 | if (res) {
|
124 | try {
|
125 | res.flighty.json = await res.clone().json();
|
126 | } catch (e) {}
|
127 | try {
|
128 | res.flighty.text = await res.clone().text();
|
129 | } catch (e) {}
|
130 | }
|
131 |
|
132 | return res;
|
133 | })(),
|
134 | "response",
|
135 | "responseError"
|
136 | );
|
137 |
|
138 | try {
|
139 | await res;
|
140 | } catch (e) {}
|
141 |
|
142 | teardownAbort(options.abortToken, context.abortTokenMap);
|
143 |
|
144 | return res;
|
145 | };
|
146 |
|
147 | export default class Flighty {
|
148 | constructor(options = {}) {
|
149 |
|
150 | METHODS.forEach(
|
151 | method =>
|
152 | (this[method.toLowerCase()] = (path = "/", options = {}, extra = {}) =>
|
153 | call(method, this, { path, options }, extra))
|
154 | );
|
155 |
|
156 | let localAbortController;
|
157 | const interceptors = new Set();
|
158 | const abortTokenMap = new Map();
|
159 | Object.defineProperties(this, {
|
160 | headers: {
|
161 | get() {
|
162 | return options.headers;
|
163 | },
|
164 | set(headers = {}) {
|
165 | options = {
|
166 | ...options,
|
167 | headers
|
168 | };
|
169 | }
|
170 | },
|
171 | arrayFormat: {
|
172 | get() {
|
173 | return options.arrayFormat || "indicies";
|
174 | }
|
175 | },
|
176 | baseURI: {
|
177 | get() {
|
178 | return options.baseURI;
|
179 | },
|
180 | set(baseURI) {
|
181 | options = {
|
182 | ...options,
|
183 | baseURI
|
184 | };
|
185 | }
|
186 | },
|
187 | interceptors: {
|
188 | get() {
|
189 | return interceptors;
|
190 | }
|
191 | },
|
192 |
|
193 | interceptor: {
|
194 | get() {
|
195 | return {
|
196 | register: interceptor => this.registerInterceptor(interceptor),
|
197 | unregister: interceptor => this.removeInterceptor(interceptor),
|
198 | clear: () => this.clearInterceptors()
|
199 | };
|
200 | }
|
201 | },
|
202 |
|
203 | abortController: {
|
204 | get() {
|
205 | if (!localAbortController) {
|
206 | localAbortController = new AbortController();
|
207 | localAbortController.signal.addEventListener("abort", () => {
|
208 |
|
209 |
|
210 | localAbortController = null;
|
211 | });
|
212 | }
|
213 | return localAbortController;
|
214 | }
|
215 | },
|
216 |
|
217 | abortTokenMap: {
|
218 | get() {
|
219 | return abortTokenMap;
|
220 | }
|
221 | }
|
222 | });
|
223 | }
|
224 |
|
225 | abort(token) {
|
226 | const val = this.abortTokenMap.get(token);
|
227 | return val && val.controller.abort();
|
228 | }
|
229 |
|
230 | abortAll() {
|
231 | this.abortController.abort();
|
232 | }
|
233 |
|
234 | registerInterceptor(interceptor) {
|
235 | if (!interceptor) {
|
236 | throw new Error("cannot register a null interceptor");
|
237 | }
|
238 |
|
239 | this.interceptors.add(interceptor);
|
240 | return () => this.interceptors.delete(interceptor);
|
241 | }
|
242 |
|
243 | clearInterceptors() {
|
244 | this.interceptors.clear();
|
245 | }
|
246 |
|
247 | removeInterceptor(interceptor) {
|
248 | this.interceptors.delete(interceptor);
|
249 | }
|
250 |
|
251 | jwt(token) {
|
252 | this.headers = {
|
253 | ...this.headers,
|
254 | Authorization: token ? `Bearer ${token}` : null
|
255 | };
|
256 | return this;
|
257 | }
|
258 |
|
259 | }
|