Source RapidContext_Async.js

1/*
2 * RapidContext <https://www.rapidcontext.com/>
3 * Copyright (c) 2022-2023 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, or `null` (defaults to `request`)
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: "XHR request", ...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            console.warn([log, this.message].join(": "), xhr && xhr.response);
356        }
357    }
358
359    AsyncError.prototype = Object.create(Error.prototype);
360    Object.assign(AsyncError.prototype, {
361        constructor: AsyncError,
362        name: "AsyncError"
363    });
364
365    // Create namespace and export API
366    var RapidContext = window.RapidContext || (window.RapidContext = {});
367    RapidContext.Async = Async;
368    Object.assign(Async, { isPromise, wait, img, css, script, xhr, AsyncError });
369
370})(this);
371