1/*
2 * RapidContext <https://www.rapidcontext.com/>
3 * Copyright (c) 2022-2024 Per Cederberg. All rights reserved.
4 *
5 * This program is free software: you can redistribute it and/or
6 * modify it under the terms of the BSD license.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
11 *
12 * See the RapidContext LICENSE for more details.
13 */
14
15(function (window) {
16
17 function isFunction(value) {
18 return typeof(value) === "function";
19 }
20
21 /**
22 * Checks if an object conforms to the `Promise` API.
23 *
24 * @memberOf RapidContext.Async
25 * @param {Object} value the object to check
26 *
27 * @return {boolean} `true` if the object is a promise, or `false` otherwise
28 */
29 function isPromise(value) {
30 return !!value && isFunction(value.then) && isFunction(value.catch);
31 }
32
33 function isDeferred(value) {
34 return !isPromise(value) && value instanceof MochiKit.Async.Deferred;
35 }
36
37 function isCancellable(value) {
38 return isPromise(value) && isFunction(value.cancel);
39 }
40
41 /**
42 * Creates a new cancellable promise. The cancellation callback must be a
43 * no-op if the action is already performed.
44 *
45 * @constructor
46 * @param {Promise|Deferred|Function|Error|Object} promise the promise to wrap
47 * @param {function} [onCancelled] the cancellation callback function
48 *
49 * @name RapidContext.Async
50 * @class A cancellable Promise that is backwards-compatible with
51 * `MochiKit.Async.Deferred`. These promises can be used either as a deferred
52 * or as a normal promise (recommended).
53 *
54 * Instances of this class are `instanceof MochiKit.Async.Deferred` (and
55 * NOT `instanceof Promise`) due to backwards compatibility. Use
56 * `RapidContext.Async.isPromise` to check for a promise-compatible API.
57 */
58 function Async(promise, onCancelled) {
59 if (isPromise(promise)) {
60 this._promise = promise;
61 } else if (isDeferred(promise)) {
62 this._promise = new Promise(function (resolve, reject) {
63 promise.addBoth(function (res) {
64 var cb = (res instanceof Error) ? reject : resolve;
65 cb(res);
66 });
67 });
68 } else if (isFunction(promise)) {
69 this._promise = new Promise(promise);
70 } else if (promise instanceof Error) {
71 this._promise = Promise.reject(promise);
72 } else {
73 this._promise = Promise.resolve(promise);
74 }
75 this._cancel = onCancelled;
76 }
77
78 // Setup prototype chain (for instanceof)
79 // FIXME: Change to Promise.prototype once MochiKit is removed
80 Async.prototype = Object.create(MochiKit.Async.Deferred.prototype);
81
82 Object.assign(Async.prototype, {
83 constructor: Async,
84
85 /**
86 * Registers one or two callback functions to this promise.
87 *
88 * @memberof RapidContext.Async.prototype
89 * @param {function} [onFulfilled] a callback if promise fulfilled
90 * @param {function} [onRejected] a callback if promise rejected
91 * @returns {Async} a new promise that resolves to whatever value the
92 * callback functions return
93 */
94 then: function (onFulfilled, onRejected) {
95 var promise = wrapPromise(this, onFulfilled, onRejected);
96 return new Async(promise, () => this.cancel());
97 },
98
99 /**
100 * Registers a reject callback function to this promise.
101 *
102 * @memberof RapidContext.Async.prototype
103 * @param {function} onRejected a callback if promise rejected
104 * @returns {Async} a new promise that resolves to whatever value the
105 * callback function returned
106 */
107 catch: function (onRejected) {
108 return this.then(undefined, onRejected);
109 },
110
111 /**
112 * Registers a finalizer callback function to this promise. Note that
113 * the finalizer MAY NOT BE CALLED if the promise is cancelled.
114 *
115 * @memberof RapidContext.Async.prototype
116 * @param {function} onFinally a callback for promise resolved
117 * @returns {Async} a new promise
118 */
119 finally: function (onFinally) {
120 var promise = this._promise.finally(onFinally);
121 return new Async(promise, () => this.cancel());
122 },
123
124 /**
125 * Cancels this promise and calls the registered cancellation handler.
126 * No other callbacks will be triggered after the promise has been
127 * cancelled (except finalizer).
128 *
129 * @memberof RapidContext.Async.prototype
130 */
131 cancel: function () {
132 this._cancelled = true;
133 isFunction(this._cancel) && this._cancel();
134 isCancellable(this._result) && this._result.cancel();
135 },
136
137 /**
138 * Registers one or two callback functions to this promise.
139 *
140 * @memberof RapidContext.Async.prototype
141 * @param {function} [onFulfilled] a callback if promise fulfilled
142 * @param {function} [onRejected] a callback if promise rejected
143 * @returns {Async} this same promise
144 * @deprecated Provided for `MochiKit.Async.Deferred` compatibility
145 */
146 addCallbacks: function (callback, errback) {
147 this._promise = wrapPromise(this, callback, errback);
148 return this;
149 },
150
151 /**
152 * Registers a fulfilled callback function to this promise.
153 *
154 * @memberof RapidContext.Async.prototype
155 * @param {function} [callback] a callback if promise fulfilled
156 * @returns {Async} this same promise
157 * @deprecated Provided for `MochiKit.Async.Deferred` compatibility
158 */
159 addCallback: function (callback) {
160 if (arguments.length > 1) {
161 var args = Array.from(arguments);
162 args.splice(1, 0, undefined);
163 callback = callback.bind.apply(callback, args);
164 }
165 this._promise = wrapPromise(this, callback, undefined);
166 return this;
167 },
168
169 /**
170 * Registers a reject callback function to this promise.
171 *
172 * @memberof RapidContext.Async.prototype
173 * @param {function} errback a callback if promise rejected
174 * @returns {Async} this same promise
175 * @deprecated Provided for `MochiKit.Async.Deferred` compatibility
176 */
177 addErrback: function (errback) {
178 if (arguments.length > 1) {
179 var args = Array.from(arguments);
180 args.splice(1, 0, undefined);
181 errback = errback.bind.apply(errback, args);
182 }
183 this._promise = wrapPromise(this, undefined, errback);
184 return this;
185 },
186
187 /**
188 * Registers a callback function to this promise.
189 *
190 * @memberof RapidContext.Async.prototype
191 * @param {function} [callback] a callback if promise either fulfilled
192 * or rejected
193 * @returns {Async} this same promise
194 * @deprecated Provided for `MochiKit.Async.Deferred` compatibility
195 */
196 addBoth: function (callback) {
197 if (arguments.length > 1) {
198 var args = Array.from(arguments);
199 args.splice(1, 0, undefined);
200 callback = callback.bind.apply(callback, args);
201 }
202 this._promise = wrapPromise(this, callback, callback);
203 return this;
204 }
205 });
206
207 function wrapPromise(self, callback, errback) {
208 var onSuccess = isFunction(callback) ? wrapCallback(self, callback) : callback;
209 var onError = isFunction(errback) ? wrapCallback(self, errback) : errback;
210 return self._promise.then(onSuccess, onError);
211 }
212
213 function wrapCallback(self, callback) {
214 return function (val) {
215 var res = self._cancelled ? undefined : callback(val);
216 return self._result = (isDeferred(res) ? new Async(res) : res);
217 };
218 }
219
220 /**
221 * Returns a delayed value.
222 *
223 * @memberOf RapidContext.Async
224 * @param {number} millis the number of milliseconds to wait
225 * @param {Object} [value] the value to resolve with
226 * @return {Async} a new promise that resolves with the value
227 */
228 function wait(millis, value) {
229 var timer = null;
230 function callLater(resolve) {
231 timer = setTimeout(() => resolve(value), millis);
232 }
233 return new Async(callLater, () => clearTimeout(timer));
234 }
235
236 /**
237 * Loads an image from a URL.
238 *
239 * @memberOf RapidContext.Async
240 * @param {string} url the image URL to load
241 * @return {Async} a promise that resolves with the DOM `<img>` element
242 */
243 function img(url) {
244 return create("img", { src: url });
245 }
246
247 /**
248 * Injects a CSS stylesheet to the current page.
249 *
250 * @memberOf RapidContext.Async
251 * @param {string} url the stylesheet URL to load
252 * @return {Async} a promise that resolves with the DOM `<link>` element
253 */
254 function css(url) {
255 var attrs = { rel: "stylesheet", type: "text/css", href: url };
256 return create("link", attrs, document.head);
257 }
258
259 /**
260 * Injects a JavaScript to the current page.
261 *
262 * @memberOf RapidContext.Async
263 * @param {string} url the script URL to load
264 * @return {Async} a promise that resolves with the DOM `<script>` element
265 */
266 function script(url) {
267 return create("script", { src: url, async: false }, document.head);
268 }
269
270 function create(tag, attrs, parent) {
271 return new Async(function (resolve, reject) {
272 var el = document.createElement(tag);
273 el.onload = function () {
274 el = el.onload = el.onerror = null;
275 resolve(el);
276 };
277 el.onerror = function (err) {
278 var url = el.src || el.href;
279 el = el.onload = el.onerror = null;
280 reject(new URIError("failed to load: " + url, url));
281 };
282 Object.assign(el, attrs);
283 parent && parent.append(el);
284 });
285 }
286
287 /**
288 * Performs an XmlHttpRequest to a URL.
289 *
290 * @memberOf RapidContext.Async
291 * @param {string} url the URL to request
292 * @param {Object} [opts] the request options
293 * @param {string} [opts.method] the HTTP method (e.g. `GET`, `POST`...)
294 * @param {Object} [opts.headers] the HTTP headers to send
295 * @param {number} [opts.timeout] the timeout in milliseconds (default is 30s)
296 * @param {string} [opts.log] the logging prefix (defaults to `null` for no logs)
297 * @param {string} [opts.responseType] the expected HTTP response (e.g. `json`)
298 * @param {string} [opts.body] the HTTP request body to send
299 *
300 * @return {Async} a new promise that resolves with either the XHR object,
301 * or an error if the request failed
302 */
303 function xhr(url, opts) {
304 opts = { method: "GET", headers: {}, timeout: 30000, log: null, ...opts };
305 if (opts.responseType === "json" && !opts.headers["Accept"]) {
306 opts.headers["Accept"] = "application/json";
307 }
308 var xhr = new XMLHttpRequest();
309 var promise = new Promise(function (resolve, reject) {
310 xhr.open(opts.method, url, true);
311 for (var key in opts.headers) {
312 xhr.setRequestHeader(key, opts.headers[key]);
313 }
314 xhr.responseType = opts.responseType || "text";
315 xhr.timeout = opts.timeout;
316 if (xhr.upload && isFunction(opts.progress)) {
317 xhr.upload.addEventListener("progress", opts.progress);
318 }
319 xhr.onreadystatechange = function () {
320 var err;
321 if (xhr && xhr.readyState === 4) {
322 if (xhr.status >= 200 && xhr.status <= 299 && xhr.response != null) {
323 resolve(xhr);
324 } else if (xhr.status === 0) {
325 err = "communication error or timeout";
326 reject(new AsyncError(opts.method, url, xhr, err, opts.log));
327 } else {
328 err = "unexpected response code/data";
329 reject(new AsyncError(opts.method, url, xhr, err, opts.log));
330 }
331 xhr = null; // Stop duplicate events
332 }
333 };
334 xhr.send(opts.body);
335 });
336 var cancel = function () {
337 xhr && setTimeout(xhr.abort.bind(xhr));
338 xhr = null;
339 };
340 return new Async(promise, cancel);
341 }
342
343 function AsyncError(method, url, xhr, detail, log) {
344 var parts = [].concat(detail, " [");
345 if (xhr && xhr.status > 0) {
346 parts.push("HTTP ", xhr.status, ": ");
347 }
348 parts.push(method, " ", url, "]");
349 this.message = parts.filter(Boolean).join("");
350 this.method = method;
351 this.url = url;
352 this.code = xhr && xhr.status;
353 this.stack = new Error().stack;
354 if (log) {
355 let logger = /timeout/i.test(this.message) ? console.info : console.warn;
356 logger([log, this.message].join(": "), xhr && xhr.response);
357 }
358 }
359
360 AsyncError.prototype = Object.create(Error.prototype);
361 Object.assign(AsyncError.prototype, {
362 constructor: AsyncError,
363 name: "AsyncError"
364 });
365
366 // Create namespace and export API
367 var RapidContext = window.RapidContext || (window.RapidContext = {});
368 RapidContext.Async = Async;
369 Object.assign(Async, { isPromise, wait, img, css, script, xhr, AsyncError });
370
371})(this);
372
RapidContext
Access · Discovery · Insight
www.rapidcontext.com