UNPKG

6.45 kBJavaScriptView Raw
1import qs from "qs";
2import urlJoin from "url-join";
3import asyncReduce from "./async-reduce";
4import { setupAbort, teardownAbort } from "./abort";
5
6if (typeof fetch === "undefined") {
7 throw new Error(
8 "You need a fetch implementation. Try npm install cross-fetch"
9 );
10}
11
12if (typeof AbortController === "undefined") {
13 throw new Error(
14 "You're missing an AbortController implementation. Try npm install abortcontroller-polyfill"
15 );
16}
17
18const METHODS = ["GET", "POST", "PUT", "HEAD", "OPTIONS", "DELETE", "PATCH"];
19
20const doFetch = (method, context, path, options) => {
21 // keep abortToken out of the fetch params
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 // remove any nil or blank headers
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
56const call = async (
57 method,
58 context,
59 { path, options },
60 extra,
61 retryCount = 0
62) => {
63 // don't let the interceptors modify the abort signal - it's the one
64 // attached to flighty's abortController so if they do, it will break our
65 // "abortAll" method
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 // don't let interceptors modify the extra or retryCount data
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 // stuff from the interceptors
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 // the values flighty was called with
101 call: {
102 path: originalPath,
103 options: originalOptions,
104 extra: originalExtra
105 },
106 // the values that were returned from each request interceptor - useful for debugging!
107 intercepted:returnedFromInterceptors,
108 // retry method
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 // add in the json and text responses to extra to make life easier
122 // for people - they can still await them if they want
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
147export default class Flighty {
148 constructor(options = {}) {
149 // add the methods
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 // when this is aborted, null out the localAbortController
209 // so we'll create a new one next time we need it
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}