Source RapidContext_Browser.js

1/*
2 * RapidContext <https://www.rapidcontext.com/>
3 * Copyright (c) 2007-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/**
16 * Provides a browser compatibility and diagnostics information.
17 * @namespace RapidContext.Browser
18 */
19(function (window) {
20    /* eslint no-var: "off", prefer-template: "off" */
21
22    /**
23     * List of all required browser features.
24     *
25     * @name REQUIRED
26     * @memberof RapidContext.Browser
27     * @constant
28     */
29    var REQUIRED = [
30        "AbortController",
31        "AbortSignal",
32        "Array.isArray",
33        "Array.from",
34        "Array.of",
35        "Array.prototype.every",
36        "Array.prototype.filter",
37        "Array.prototype.find",
38        "Array.prototype.findIndex",
39        "Array.prototype.flat",
40        "Array.prototype.flatMap",
41        "Array.prototype.forEach",
42        "Array.prototype.includes",
43        "Array.prototype.map",
44        "Array.prototype.reduce",
45        "Array.prototype.some",
46        "Blob",
47        "CSS.supports",
48        "CustomEvent",
49        "Date.now",
50        "Date.prototype.toISOString",
51        "Date.prototype.toLocaleString",
52        "DOMParser",
53        "DOMTokenList.prototype.add",
54        "DOMTokenList.prototype.remove",
55        "DOMTokenList.prototype.toggle",
56        "Element.prototype.after",
57        "Element.prototype.append",
58        "Element.prototype.before",
59        "Element.prototype.closest",
60        "Element.prototype.matches",
61        "Element.prototype.prepend",
62        "Element.prototype.remove",
63        "Element.prototype.replaceWith",
64        "FileReader",
65        "FormData",
66        "Function.prototype.bind",
67        "HTMLTemplateElement",
68        "HTMLSlotElement",
69        "JSON.parse",
70        "JSON.stringify",
71        "Math.imul",
72        "NodeList.prototype.forEach",
73        "Number.prototype.toLocaleString",
74        "Object.assign",
75        "Object.create",
76        "Object.defineProperty",
77        "Object.entries",
78        "Object.fromEntries",
79        "Object.getOwnPropertyDescriptor",
80        "Object.getOwnPropertyNames",
81        "Object.getPrototypeOf",
82        "Object.is",
83        "Object.keys",
84        "Object.setPrototypeOf",
85        "Object.values",
86        "Promise",
87        "Promise.all",
88        "Promise.allSettled",
89        "Promise.race",
90        "Promise.reject",
91        "Promise.resolve",
92        "Promise.prototype.finally",
93        "Set",
94        "String.prototype.endsWith",
95        "String.prototype.includes",
96        "String.prototype.padEnd",
97        "String.prototype.padStart",
98        "String.prototype.startsWith",
99        "String.prototype.trim",
100        "String.raw", // proxy for template literals
101        "TextDecoder",
102        "TextEncoder",
103        "URL",
104        "URL.createObjectURL",
105        "URL.revokeObjectURL",
106        "URLSearchParams",
107        "console.assert",
108        "console.debug",
109        "console.error",
110        "console.info",
111        "console.log",
112        "console.warn",
113        "document.querySelectorAll",
114        "fetch",
115        "localStorage.getItem",
116        "sessionStorage.setItem",
117        { test: "'head' in document", name: "document.head shortcut" },
118        { test: "'classList' in Element.prototype", name: "Element.prototype.classList" },
119        { test: "'dataset' in HTMLElement.prototype", name: "HTMLElement.prototype.dataset" },
120        { test: "'onload' in HTMLLinkElement.prototype", name: "<link> element onload" },
121        {
122            test: "var el = document.createElement('div');el.style.cssText='width:calc(10px);';!!el.style.length",
123            name: "CSS calc()"
124        },
125        { test: "let a = 2; a === 2", name: "Let statements" },
126        { test: "const a = 3; a === 3", name: "Const statements" },
127        { test: "for (var el of []) el; true", name: "loops with for...of" },
128        { test: "var a = 42; ({ a }).a === 42", name: "shorthand property names" },
129        { test: "({ f(arg) { return arg; } }).f(true)", name: "shorthand method names" },
130        { test: "var k = 'a'; ({ [k]: 42 })['a'] === 42", name: "computed property names" },
131        { test: "((a,  b, ...args) => true)()", name: "rest parameters" },
132        { test: "Math.max(...[1, 2, 3]) === 3", name: "spread parameters" },
133        { test: "[...[1, 2, 3]].length === 3", name: "spread array literals" },
134        { test: "({...{ a: 42}}).a == 42", name: "spread object literals" },
135        { test: "var [a, ...b] = [1, 2, 3]; a == 1 && b.length == 2", name: "array destructuring assignment" },
136        { test: "var {a, b} = { a: 1, b: 2, c: 3 }; a == 1 && b == 2", name: "object destructuring assignment" },
137        { test: "class Test {}; true", name: "Class declarations" },
138        { test: "(() => true)()", name: "Arrow functions" },
139        { test: "async () => await Promise.resolve(true)", name: "async/await functions" },
140        { test: "typeof(Symbol.iterator) === 'symbol'", name: "Symbol.iterator" },
141        { test: "globalThis === window", name: "globalThis" },
142        { test: "'key' in KeyboardEvent.prototype", name: "KeyboardEvent.key" },
143        { test: "/^\\p{L}+$/u.test('a\u00E5\u00C4')", name: "regexp Unicode character class escape" },
144        { test: "'noModule' in HTMLScriptElement.prototype", name: "ES modules (import/export)" },
145        "--variable-name: #fff",
146        "display: flex",
147    ];
148
149    /**
150     * List of all optional (or recommended) browser features. These are
151     * not used in built-in libraries and apps, but will be in the future.
152     *
153     * @name OPTIONAL
154     * @memberof RapidContext.Browser
155     * @constant
156     */
157    var OPTIONAL = [
158        // "Array.prototype.at",
159        "BigInt",
160        "Element.prototype.replaceChildren",
161        // "Element.prototype.scrollIntoView",
162        // "HTMLDialogElement",
163        // "HTMLScriptElement.supports",
164        "navigator.credentials.create",
165        "navigator.credentials.get",
166        // "Object.hasOwn",
167        "Promise.any",
168        "String.prototype.matchAll",
169        "String.prototype.replaceAll",
170        "SubtleCrypto.prototype.digest",
171        // "Temporal",
172        "ResizeObserver",
173        "WeakRef",
174        // { test: "HTMLScriptElement.supports('module')", name: "ES module script", },
175        // { test: "HTMLScriptElement.supports('importmap')", name: "ES module import maps", },
176        { test: "123_456.78_9_12 == 123456.78912", name: "numeric separators (_)" },
177        { test: "new Error('test', { cause: true }).cause", name: "error cause property" },
178        { test: "var a = null; a?.dummy; true", name: "optional chaining operator (?.)" },
179        { test: "null ?? true", name: "nullish coalescing operator (??)" },
180        { test: "var a = true; a &&= true; a", name: "logical and assignment operator (&&=)" },
181        { test: "var a = false; a ||= true; a", name: "logical or assignment operator (||=)" },
182        { test: "var a = null; a ??= true; a", name: "nullish assignment operator (??=)" },
183        { test: "class Test { a; b = 123; static c = true; }; Test.c", name: "public class fields" },
184        { test: "class Test { #a; #b = 123; static #c = 0; }; true", name: "private class fields" },
185        { test: "class Test { static { 1; } }; true", name: "static class initialization block" },
186        // "selector(:has(*))",
187        "appearance: none",
188        "inset: 0",
189        // "line-clamp: 3",
190        // "user-select: none",
191    ];
192
193    /**
194     * Checks if the browser supports all required APIs.
195     *
196     * @return {boolean} true if the browser is supported, or false otherwise
197     *
198     * @memberof RapidContext.Browser
199     */
200    function isSupported() {
201        var hasRequired = supports(REQUIRED);
202        var hasOptional = supports(OPTIONAL);
203        if (!hasRequired) {
204            console.error("browser: required features are missing", info());
205        } else if (!hasOptional) {
206            console.warn("browser: some recommended features are missing", info());
207        }
208        return hasRequired;
209    }
210
211    /**
212     * Checks for browser support for one or more specified APIs. Supports
213     * checking JavaScript APIs, JavaScript syntax and CSS support.
214     *
215     * @param {...(string|Object|Array)} feature one or more features to check
216     * @return {boolean} `true` if supported, or `false` otherwise
217     *
218     * @example
219     * RapidContext.Browser.supports("Array.isArray") ==> true;
220     * RapidContext.Browser.supports({ test: "let a = 2; a === 2", name: "Let statements" }) ==> true;
221     * RapidContext.Browser.supports("display: flex") ==> false;
222     *
223     * @memberof RapidContext.Browser
224     */
225    function supports(feature/*, ...*/) {
226        function checkCSS(code) {
227            try {
228                return CSS.supports("(" + code + ")");
229            } catch (ignore) {
230                return false;
231            }
232        }
233        function checkPath(base, path) {
234            while (base && path.length > 0) {
235                base = base[path.shift()];
236            }
237            return typeof(base) === "function";
238        }
239        function checkEval(code) {
240            try {
241                /* eslint no-eval: "off" */
242                var val = eval(code);
243                return val !== undefined && val !== null;
244            } catch (ignore) {
245                return false;
246            }
247        }
248        function check(test) {
249            var isCSS = /^[a-z-]+:|selector\(/i.test(test);
250            var isPath = /^[a-z]+(\.[a-z]+)*$/i.test(test);
251            var isValid = (
252                (isCSS && checkCSS(test)) ||
253                (isPath && checkPath(window, test.split("."))) ||
254                (!isCSS && !isPath && checkEval(test))
255            );
256            return isValid;
257        }
258        // NOTE: Code below uses old school JavaScript for compatibility
259        var res = true;
260        var features = (feature instanceof Array) ? feature : Array.prototype.slice.call(arguments);
261        for (var i = 0; i < features.length; i++) {
262            var def = features[i].test ? features[i] : { test: features[i] };
263            if (!check(def.test)) {
264                var explain = [def.name, def.test].filter(Boolean).join(": ");
265                console.warn("browser: missing support for " + explain);
266                res = false;
267            }
268        }
269        return res;
270    }
271
272    /**
273     * Returns browser information version and platform information.
274     *
275     * @param {string} [userAgent] the agent string, or undefined for this browser
276     * @return {Object} a browser meta-data object
277     *
278     * @memberof RapidContext.Browser
279     */
280    function info(userAgent) {
281        function firstMatch(patterns, text) {
282            for (var k in patterns) {
283                var m = patterns[k].exec(text);
284                if (m) {
285                    var extra = m[1] ? " " + m[1] : "";
286                    if (/^[ \d_.]+$/.test(extra)) {
287                        extra = extra.replace(/_/g, ".");
288                    }
289                    return k + extra;
290                }
291            }
292            return null;
293        }
294        function clone(obj, keys) {
295            var res = {};
296            for (var i = 0; i < keys.length; i++) {
297                var k = keys[i];
298                res[k] = obj[k];
299            }
300            return res;
301        }
302        var BROWSERS = {
303            "Bot": /Bot|Spider|PhantomJS|Headless|Electron|slimerjs|Python/i,
304            "Edge": /Edg(?:e|A|iOS|)\/([^\s;]+)/,
305            "Chrome": /Chrome\/([^\s;]+)/,
306            "Chrome iOS": /CriOS\/([^\s;]+)/,
307            "Firefox": /Firefox\/([^\s;]+)/,
308            "Firefox iOS": /FxiOS\/([^\s;]+)/,
309            "Safari": /Version\/(\S+).* Safari\/[^\s;]+/,
310            "MSIE": /(?:MSIE |Trident\/.*rv:)([^\s;)]+)/
311        };
312        var PLATFORMS = {
313            "Android": /Android ([^;)]+)/,
314            "Chrome OS": /CrOS [^\s]+ ([^;)]+)/,
315            "iOS": /(?:iPhone|CPU) OS ([\d_]+)/,
316            "macOS": /Mac OS X ([^;)]+)/,
317            "Linux": /Linux ([^;)]+)/,
318            "Windows": /Windows ([^;)]+)/
319        };
320        var DEVICES = {
321            "iPad": /iPad/,
322            "iPhone": /iPhone/,
323            "Tablet": /Tablet/,
324            "Mobile": /Mobile|Android/
325        };
326        var ua = userAgent || window.navigator.userAgent;
327        var browser = firstMatch(BROWSERS, ua);
328        var platform = firstMatch(PLATFORMS, ua);
329        var device = firstMatch(DEVICES, ua);
330        var res = {};
331        if (browser && platform) {
332            res["browser"] = browser;
333            res["platform"] = platform;
334            res["device"] = device || "Desktop/Other";
335        }
336        if (!userAgent) {
337            res["language"] = window.navigator.language;
338            res["screen"] = clone(window.screen, ["width", "height", "colorDepth"]);
339            res["window"] = clone(window, ["innerWidth", "innerHeight", "devicePixelRatio"]);
340            res["cookies"] = _cookies();
341        }
342        res["userAgent"] = String(ua);
343        return res;
344    }
345
346    function _cookies() {
347        var cookies = {};
348        var pairs = window.document.cookie.split(/\s*;\s*/g);
349        pairs.forEach(function (pair) {
350            var name = pair.split("=")[0];
351            var value = pair.substr(name.length + 1);
352            if (name && value) {
353                cookies[decodeURIComponent(name)] = decodeURIComponent(value);
354            }
355        });
356        return cookies;
357    }
358
359    /**
360     * Gets, sets or removes browser cookies.
361     *
362     * @param {string} [name] the cookie name to get/set
363     * @param {string} [value] the cookie value to set, or null to remove
364     * @return {Object|string} all cookie values or a single value
365     *
366      * @memberof RapidContext.Browser
367     */
368    function cookie(name, value) {
369        if (name === undefined || name === null) {
370            return _cookies();
371        } else if (value === undefined) {
372            return _cookies()[name];
373        } else if (value === null) {
374            var prefix = encodeURIComponent(name) + "=";
375            var suffix = ";path=/;expires=" + new Date(0).toUTCString();
376            var domain = window.location.hostname.split(".");
377            while (domain.length > 1) {
378                var domainPart = ";domain=" + domain.join(".");
379                window.document.cookie = prefix + domainPart + suffix;
380                domain.shift();
381            }
382            window.document.cookie = prefix + suffix;
383            return null;
384        } else {
385            window.document.cookie = encodeURIComponent(name) + "=" + encodeURIComponent(value) + ";path=/";
386            return value;
387        }
388    }
389
390    // Create namespaces
391    var RapidContext = window.RapidContext || (window.RapidContext = {});
392    var module = RapidContext.Browser || (RapidContext.Browser = {});
393
394    // Export namespace symbols
395    module.REQUIRED = REQUIRED;
396    module.OPTIONAL = OPTIONAL;
397    module.isSupported = isSupported;
398    module.supports = supports;
399    module.info = info;
400    module.cookie = cookie;
401
402})(globalThis);
403