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
RapidContext
Access · Discovery · Insight
www.rapidcontext.com