Source RapidContext_Async.js

1/*
2 * RapidContext <https://www.rapidcontext.com/>
3 * Copyright (c) 2022-2025 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                    const 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            const 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            const 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            console.warn("deprecated: call to RapidContext.Async.addCallbacks(), use then() instead.");
148            this._promise = wrapPromise(this, callback, errback);
149            return this;
150        },
151
152        /**
153         * Registers a fulfilled callback function to this promise.
154         *
155         * @memberof RapidContext.Async.prototype
156         * @param {function} [callback] a callback if promise fulfilled
157         * @returns {Async} this same promise
158         * @deprecated Provided for `MochiKit.Async.Deferred` compatibility
159         */
160        addCallback: function (callback) {
161            console.warn("deprecated: call to RapidContext.Async.addCallback(), use then() instead.");
162            if (arguments.length > 1) {
163                const args = Array.from(arguments);
164                args.splice(1, 0, undefined);
165                callback = callback.bind(...args);
166            }
167            this._promise = wrapPromise(this, callback, undefined);
168            return this;
169        },
170
171        /**
172         * Registers a reject callback function to this promise.
173         *
174         * @memberof RapidContext.Async.prototype
175         * @param {function} errback a callback if promise rejected
176         * @returns {Async} this same promise
177         * @deprecated Provided for `MochiKit.Async.Deferred` compatibility
178         */
179        addErrback: function (errback) {
180            console.warn("deprecated: call to RapidContext.Async.addErrback(), use catch() instead.");
181            if (arguments.length > 1) {
182                const args = Array.from(arguments);
183                args.splice(1, 0, undefined);
184                errback = errback.bind(...args);
185            }
186            this._promise = wrapPromise(this, undefined, errback);
187            return this;
188        },
189
190        /**
191         * Registers a callback function to this promise.
192         *
193         * @memberof RapidContext.Async.prototype
194         * @param {function} [callback] a callback if promise either fulfilled
195         *        or rejected
196         * @returns {Async} this same promise
197         * @deprecated Provided for `MochiKit.Async.Deferred` compatibility
198         */
199        addBoth: function (callback) {
200            console.warn("deprecated: call to RapidContext.Async.addBoth(), use then() instead.");
201            if (arguments.length > 1) {
202                const args = Array.from(arguments);
203                args.splice(1, 0, undefined);
204                callback = callback.bind(...args);
205            }
206            this._promise = wrapPromise(this, callback, callback);
207            return this;
208        }
209    });
210
211    function wrapPromise(self, callback, errback) {
212        const onSuccess = isFunction(callback) ? wrapCallback(self, callback) : callback;
213        const onError = isFunction(errback) ? wrapCallback(self, errback) : errback;
214        return self._promise.then(onSuccess, onError);
215    }
216
217    function wrapCallback(self, callback) {
218        return function (val) {
219            const res = self._cancelled ? undefined : callback(val);
220            return self._result = (isDeferred(res) ? new Async(res) : res);
221        };
222    }
223
224    /**
225     * Returns a delayed value.
226     *
227     * @memberOf RapidContext.Async
228     * @param {number} millis the number of milliseconds to wait
229     * @param {Object} [value] the value to resolve with
230     * @return {Async} a new promise that resolves with the value
231     */
232    function wait(millis, value) {
233        let timer = null;
234        function callLater(resolve) {
235            timer = setTimeout(() => resolve(value), millis);
236        }
237        return new Async(callLater, () => clearTimeout(timer));
238    }
239
240    /**
241     * Loads an image from a URL.
242     *
243     * @memberOf RapidContext.Async
244     * @param {string} url the image URL to load
245     * @return {Async} a promise that resolves with the DOM `<img>` element
246     */
247    function img(url) {
248        return create("img", { src: url });
249    }
250
251    /**
252     * Injects a CSS stylesheet to the current page.
253     *
254     * @memberOf RapidContext.Async
255     * @param {string} url the stylesheet URL to load
256     * @return {Async} a promise that resolves with the DOM `<link>` element
257     */
258    function css(url) {
259        const attrs = { rel: "stylesheet", type: "text/css", href: url };
260        return create("link", attrs, document.head);
261    }
262
263    /**
264     * Injects a JavaScript to the current page.
265     *
266     * @memberOf RapidContext.Async
267     * @param {string} url the script URL to load
268     * @return {Async} a promise that resolves with the DOM `<script>` element
269     */
270    function script(url) {
271        return create("script", { src: url, async: false }, document.head);
272    }
273
274    function create(tag, attrs, parent) {
275        return new Async(function (resolve, reject) {
276            let el = document.createElement(tag);
277            el.onload = function () {
278                el = el.onload = el.onerror = null;
279                resolve(el);
280            };
281            el.onerror = function (err) {
282                const url = el.src || el.href;
283                el = el.onload = el.onerror = null;
284                reject(new URIError(`failed to load: ${url}`, url));
285            };
286            Object.assign(el, attrs);
287            parent && parent.append(el);
288        });
289    }
290
291    /**
292     * Performs an XmlHttpRequest to a URL.
293     *
294     * @memberOf RapidContext.Async
295     * @param {string} url the URL to request
296     * @param {Object} [opts] the request options
297     * @param {string} [opts.method] the HTTP method (e.g. `GET`, `POST`...)
298     * @param {Object} [opts.headers] the HTTP headers to send
299     * @param {number} [opts.timeout] the timeout in milliseconds (default is 30s)
300     * @param {string} [opts.log] the logging prefix (defaults to `null` for no logs)
301     * @param {string} [opts.responseType] the expected HTTP response (e.g. `json`)
302     * @param {string} [opts.body] the HTTP request body to send
303     *
304     * @return {Async} a new promise that resolves with either the XHR object,
305     *     or an error if the request failed
306     */
307    function xhr(url, opts) {
308        opts = { method: "GET", headers: {}, timeout: 30000, log: null, ...opts };
309        if (opts.responseType === "json" && !opts.headers["Accept"]) {
310            opts.headers["Accept"] = "application/json";
311        }
312        let xhr = new XMLHttpRequest();
313        const promise = new Promise(function (resolve, reject) {
314            xhr.open(opts.method, url, true);
315            for (const key in opts.headers) {
316                xhr.setRequestHeader(key, opts.headers[key]);
317            }
318            xhr.responseType = opts.responseType || "text";
319            xhr.timeout = opts.timeout;
320            if (xhr.upload && isFunction(opts.progress)) {
321                xhr.upload.addEventListener("progress", opts.progress);
322            }
323            xhr.onreadystatechange = function () {
324                let err;
325                if (xhr && xhr.readyState === 4) {
326                    if (xhr.status >= 200 && xhr.status <= 299 && xhr.response != null) {
327                        resolve(xhr);
328                    } else if (xhr.status === 0) {
329                        err = "communication error or timeout";
330                        reject(new AsyncError(opts.method, url, xhr, err, opts.log));
331                    } else {
332                        err = "unexpected response code/data";
333                        reject(new AsyncError(opts.method, url, xhr, err, opts.log));
334                    }
335                    xhr = null; // Stop duplicate events
336                }
337            };
338            xhr.send(opts.body);
339        });
340        const cancel = function () {
341            xhr && setTimeout(xhr.abort.bind(xhr));
342            xhr = null;
343        };
344        return new Async(promise, cancel);
345    }
346
347    function AsyncError(method, url, xhr, detail, log) {
348        const parts = [].concat(detail, " [");
349        if (xhr && xhr.status > 0) {
350            parts.push("HTTP ", xhr.status, ": ");
351        }
352        parts.push(method, " ", url, "]");
353        this.message = parts.filter(Boolean).join("");
354        this.method = method;
355        this.url = url;
356        this.code = xhr && xhr.status;
357        this.stack = new Error().stack;
358        if (log) {
359            const logger = /timeout/i.test(this.message) ? console.info : console.warn;
360            logger([log, this.message].join(": "), xhr && xhr.response);
361        }
362    }
363
364    AsyncError.prototype = Object.create(Error.prototype);
365    Object.assign(AsyncError.prototype, {
366        constructor: AsyncError,
367        name: "AsyncError"
368    });
369
370    // Create namespace and export API
371    const RapidContext = window.RapidContext || (window.RapidContext = {});
372    RapidContext.Async = Async;
373    Object.assign(Async, { isPromise, wait, img, css, script, xhr, AsyncError });
374
375})(globalThis);
376