let customQDNHistoryPaths = []; // Array to track visited paths let currentIndex = -1; // Index to track the current position in the history let isManualNavigation = true; // Flag to control when to add new paths. set to false when navigating through a back/forward call function resetVariables() { let customQDNHistoryPaths = []; let currentIndex = -1; let isManualNavigation = true; } function getNameAfterService(url) { try { const parsedUrl = new URL(url); const pathParts = parsedUrl.pathname.split("/"); // Find the index of "WEBSITE" or "APP" and get the next part const serviceIndex = pathParts.findIndex( (part) => part === "WEBSITE" || part === "APP" ); if (serviceIndex !== -1 && pathParts[serviceIndex + 1]) { return pathParts[serviceIndex + 1]; } else { return null; // Return null if "WEBSITE" or "APP" is not found or has no following part } } catch (error) { console.error("Invalid URL provided:", error); return null; } } function parseUrl(url) { try { const parsedUrl = new URL(url); // Check if isManualNavigation query exists and is set to "false" const isManual = parsedUrl.searchParams.get("isManualNavigation"); if (isManual !== null && isManual == "false") { isManualNavigation = false; // Optional: handle this condition if needed (e.g., return or adjust the response) } // Remove theme, identifier, and time queries if they exist parsedUrl.searchParams.delete("theme"); parsedUrl.searchParams.delete("lang"); parsedUrl.searchParams.delete("identifier"); parsedUrl.searchParams.delete("time"); parsedUrl.searchParams.delete("isManualNavigation"); // Extract the pathname and remove the prefix if it matches "render/APP" or "render/WEBSITE" const path = parsedUrl.pathname.replace( /^\/render\/(APP|WEBSITE)\/[^/]+/, "" ); // Combine the path with remaining query params (if any) return path + parsedUrl.search; } catch (error) { console.error("Invalid URL provided:", error); return null; } } // Tell the client to open a new tab. Done when an app is linking to another app function openNewTab(data) { window.parent.postMessage( { action: "SET_TAB", requestedHandler: "UI", payload: data, }, "*" ); } // sends navigation information to the client in order to manage back/forward navigation function sendNavigationInfoToParent(isDOMContentLoaded) { window.parent.postMessage( { action: "NAVIGATION_HISTORY", requestedHandler: "UI", payload: { customQDNHistoryPaths, currentIndex, isDOMContentLoaded: isDOMContentLoaded ? true : false, }, }, "*" ); } function handleQDNResourceDisplayed(pathurl, isDOMContentLoaded) { // make sure that an empty string the root path if (pathurl?.startsWith("/render/hash/")) return; const path = pathurl || "/"; if (!isManualNavigation) { isManualNavigation = true; // If the navigation is automatic (back/forward), do not add new entries return; } // If it's a new path, add it to the history array and adjust the index if (customQDNHistoryPaths[currentIndex] !== path) { customQDNHistoryPaths = customQDNHistoryPaths.slice(0, currentIndex + 1); // Add the new path and move the index to the new position customQDNHistoryPaths.push(path); currentIndex = customQDNHistoryPaths.length - 1; sendNavigationInfoToParent(isDOMContentLoaded); } else { currentIndex = customQDNHistoryPaths.length - 1; sendNavigationInfoToParent(isDOMContentLoaded); } // Reset isManualNavigation after handling isManualNavigation = true; } // Request deduplication cache const requestCache = new Map(); const REQUEST_CACHE_TTL = 5000; // 5 seconds // Pending request tracking for cleanup const pendingMessageChannels = new Map(); // Request queue to limit concurrent requests const MAX_CONCURRENT_REQUESTS = 30; let activeRequestCount = 0; const requestQueue = []; // Debug logging (set to true to enable) const DEBUG_REQUESTS = false; function debugLog(...args) { if (DEBUG_REQUESTS) { console.log("[q-apps.js]", ...args); } } function httpGet(url) { // Check cache first const cached = requestCache.get(url); if (cached && Date.now() - cached.timestamp < REQUEST_CACHE_TTL) { return cached.data; } var request = new XMLHttpRequest(); request.open("GET", url, false); request.send(null); // Cache the response requestCache.set(url, { data: request.responseText, timestamp: Date.now(), }); return request.responseText; } // Async request deduplication const pendingAsyncRequests = new Map(); function httpGetAsyncWithEvent(event, url) { // Check if same request is already pending if (pendingAsyncRequests.has(url)) { // Attach to existing request instead of creating new one pendingAsyncRequests .get(url) .then((responseText) => { handleResponse(event, responseText); }) .catch((error) => { let res = {}; res.error = error; handleResponse(event, JSON.stringify(res)); }); return; } // Create new request and cache the promise const requestPromise = fetch(url) .then((response) => response.text()) .then((responseText) => { // Remove from pending cache pendingAsyncRequests.delete(url); if (responseText == null) { // Pass to parent (UI), in case they can fulfil this request event.data.requestedHandler = "UI"; parent.postMessage(event.data, "*", [event.ports[0]]); return null; } return responseText; }); // Store the promise for deduplication pendingAsyncRequests.set(url, requestPromise); requestPromise .then((responseText) => { if (responseText !== null) { handleResponse(event, responseText); } }) .catch((error) => { // Remove from pending cache on error pendingAsyncRequests.delete(url); let res = {}; res.error = error; handleResponse(event, JSON.stringify(res)); }); } function handleResponse(event, response) { if (event == null) { return; } // Handle empty or missing responses if (response == null || response.length == 0) { response = '{"error": "Empty response"}'; } // Parse response let responseObj; try { responseObj = JSON.parse(response); } catch (e) { // Not all responses will be JSON responseObj = response; } // GET_QDN_RESOURCE_URL has custom handling const data = event.data; if (data.action == "GET_QDN_RESOURCE_URL") { if ( responseObj == null || responseObj.status == null || responseObj.status == "NOT_PUBLISHED" ) { responseObj = {}; responseObj.error = "Resource does not exist"; } else { responseObj = buildResourceUrl( data.service, data.name, data.identifier, data.path, false ); } } // Respond to app if (responseObj.error != null) { event.ports[0].postMessage({ result: null, error: responseObj, }); } else { event.ports[0].postMessage({ result: responseObj, error: null, }); } } function buildResourceUrl(service, name, identifier, path, isLink) { if (isLink == false) { // If this URL isn't being used as a link, then we need to fetch the data // synchronously, instead of showing the loading screen. url = "/arbitrary/" + service + "/" + name; if (identifier != null) url = url.concat("/" + identifier); if (path != null) url = url.concat("?filepath=" + path); } else if (_qdnContext == "render") { url = "/render/" + service + "/" + name; if (path != null) url = url.concat((path.startsWith("/") ? "" : "/") + path); if (identifier != null) url = url.concat("?identifier=" + identifier); } else if (_qdnContext == "gateway") { url = "/" + service + "/" + name; if (identifier != null) url = url.concat("/" + identifier); if (path != null) url = url.concat((path.startsWith("/") ? "" : "/") + path); } else { // domainMap only serves websites right now url = "/" + name; if (path != null) url = url.concat((path.startsWith("/") ? "" : "/") + path); } if (isLink) { const hasQuery = url.includes("?"); const queryPrefix = hasQuery ? "&" : "?"; url += queryPrefix + "theme=" + _qdnTheme + "&lang=" + _qdnLang; } return url; } function extractComponents(url) { if (!url.startsWith("qortal://")) { return null; } url = url.replace(/^(qortal\:\/\/)/, ""); if (url.includes("/")) { let parts = url.split("/"); const service = parts[0].toUpperCase(); parts.shift(); const name = parts[0]; parts.shift(); let identifier; if (parts.length > 0) { identifier = parts[0]; // Do not shift yet // Check if a resource exists with this service, name and identifier combination const url = "/arbitrary/resource/status/" + service + "/" + name + "/" + identifier; const response = httpGet(url); const responseObj = JSON.parse(response); if (responseObj.totalChunkCount > 0) { // Identifier exists, so don't include it in the path parts.shift(); } else { identifier = null; } } const path = parts.join("/"); const components = {}; components["service"] = service; components["name"] = name; components["identifier"] = identifier; components["path"] = path; return components; } return null; } function convertToResourceUrl(url, isLink) { if (!url.startsWith("qortal://")) { return null; } const c = extractComponents(url); if (c == null) { return null; } return buildResourceUrl(c.service, c.name, c.identifier, c.path, isLink); } window.addEventListener( "message", async (event) => { if (event == null || event.data == null || event.data.length == 0) { return; } if (event.data.action == null) { // This could be a response from the UI handleResponse(event, event.data); } if ( event.data.requestedHandler != null && event.data.requestedHandler === "UI" ) { // This request was destined for the UI, so ignore it return; } let url; let data = event.data; let identifier; switch (data.action) { case "GET_ACCOUNT_DATA": return httpGetAsyncWithEvent(event, "/addresses/" + data.address); case "GET_ACCOUNT_NAMES": return httpGetAsyncWithEvent(event, "/names/address/" + data.address); case "SEARCH_NAMES": url = "/names/search?"; if (data.query != null) url = url.concat("&query=" + data.query); if (data.prefix != null) url = url.concat("&prefix=" + new Boolean(data.prefix).toString()); if (data.limit != null) url = url.concat("&limit=" + data.limit); if (data.offset != null) url = url.concat("&offset=" + data.offset); if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString()); return httpGetAsyncWithEvent(event, url); case "GET_NAME_DATA": return httpGetAsyncWithEvent(event, "/names/" + data.name); case "GET_QDN_RESOURCE_URL": // Check status first; URL is built and returned automatically after status check url = "/arbitrary/resource/status/" + data.service + "/" + data.name; if (data.identifier != null) url = url.concat("/" + data.identifier); return httpGetAsyncWithEvent(event, url); case "LINK_TO_QDN_RESOURCE": if (data.service == null) data.service = "WEBSITE"; // Default to WEBSITE const nameOfCurrentApp = getNameAfterService(window.location.href); // Check to see if the link is an external app. If it is, request that the client opens a new tab instead of manipulating the window's history stack. if (nameOfCurrentApp !== data.name) { // Attempt to open a new tab and wait for a response const navigationPromise = new Promise((resolve, reject) => { function handleMessage(event) { if ( event.data?.action === "SET_TAB_SUCCESS" && event.data.payload?.name === data.name ) { window.removeEventListener("message", handleMessage); resolve(); } } window.addEventListener("message", handleMessage); // Send the message to the parent window openNewTab({ name: data.name, service: data.service, identifier: data.identifier, path: data.path, }); // Set a timeout to reject the promise if no response is received within 200ms setTimeout(() => { window.removeEventListener("message", handleMessage); reject(new Error("No response within 200ms")); }, 200); }); // Handle the promise, and if it times out, fall back to the else block navigationPromise .then(() => { console.log("Tab opened successfully"); }) .catch(() => { console.warn("No response, proceeding with window.location"); window.location = buildResourceUrl( data.service, data.name, data.identifier, data.path, true ); }); } else { window.location = buildResourceUrl( data.service, data.name, data.identifier, data.path, true ); } return; case "LIST_QDN_RESOURCES": url = "/arbitrary/resources?"; if (data.service != null) url = url.concat("&service=" + data.service); if (data.name != null) url = url.concat("&name=" + data.name); if (data.identifier != null) url = url.concat("&identifier=" + data.identifier); if (data.default != null) url = url.concat("&default=" + new Boolean(data.default).toString()); if (data.includeStatus != null) url = url.concat( "&includestatus=" + new Boolean(data.includeStatus).toString() ); if (data.includeMetadata != null) url = url.concat( "&includemetadata=" + new Boolean(data.includeMetadata).toString() ); if (data.nameListFilter != null) url = url.concat("&namefilter=" + data.nameListFilter); if (data.followedOnly != null) url = url.concat( "&followedonly=" + new Boolean(data.followedOnly).toString() ); if (data.excludeBlocked != null) url = url.concat( "&excludeblocked=" + new Boolean(data.excludeBlocked).toString() ); if (data.limit != null) url = url.concat("&limit=" + data.limit); if (data.offset != null) url = url.concat("&offset=" + data.offset); if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString()); return httpGetAsyncWithEvent(event, url); case "SEARCH_QDN_RESOURCES": url = "/arbitrary/resources/search?"; if (data.service != null) url = url.concat("&service=" + data.service); if (data.query != null) url = url.concat("&query=" + data.query); if (data.identifier != null) url = url.concat("&identifier=" + data.identifier); if (data.name != null) url = url.concat("&name=" + data.name); if (data.names != null) data.names.forEach((x, i) => (url = url.concat("&name=" + x))); if (data.keywords != null) data.keywords.forEach((x, i) => (url = url.concat("&keywords=" + x))); if (data.title != null) url = url.concat("&title=" + data.title); if (data.description != null) url = url.concat("&description=" + data.description); if (data.prefix != null) url = url.concat("&prefix=" + new Boolean(data.prefix).toString()); if (data.exactMatchNames != null) url = url.concat( "&exactmatchnames=" + new Boolean(data.exactMatchNames).toString() ); if (data.default != null) url = url.concat("&default=" + new Boolean(data.default).toString()); if (data.mode != null) url = url.concat("&mode=" + data.mode); if (data.minLevel != null) url = url.concat("&minlevel=" + data.minLevel); if (data.includeStatus != null) url = url.concat( "&includestatus=" + new Boolean(data.includeStatus).toString() ); if (data.includeMetadata != null) url = url.concat( "&includemetadata=" + new Boolean(data.includeMetadata).toString() ); if (data.nameListFilter != null) url = url.concat("&namefilter=" + data.nameListFilter); if (data.followedOnly != null) url = url.concat( "&followedonly=" + new Boolean(data.followedOnly).toString() ); if (data.excludeBlocked != null) url = url.concat( "&excludeblocked=" + new Boolean(data.excludeBlocked).toString() ); if (data.before != null) url = url.concat("&before=" + data.before); if (data.after != null) url = url.concat("&after=" + data.after); if (data.limit != null) url = url.concat("&limit=" + data.limit); if (data.offset != null) url = url.concat("&offset=" + data.offset); if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString()); return httpGetAsyncWithEvent(event, url); case "FETCH_QDN_RESOURCE": url = "/arbitrary/" + data.service + "/" + data.name; if (data.identifier != null) url = url.concat("/" + data.identifier); url = url.concat("?"); if (data.filepath != null) url = url.concat("&filepath=" + data.filepath); if (data.rebuild != null) url = url.concat("&rebuild=" + new Boolean(data.rebuild).toString()); if (data.encoding != null) url = url.concat("&encoding=" + data.encoding); return httpGetAsyncWithEvent(event, url); case "GET_QDN_RESOURCE_STATUS": url = "/arbitrary/resource/status/" + data.service + "/" + data.name; if (data.identifier != null) url = url.concat("/" + data.identifier); url = url.concat("?"); if (data.build != null) url = url.concat("&build=" + new Boolean(data.build).toString()); return httpGetAsyncWithEvent(event, url); case "GET_QDN_RESOURCE_PROPERTIES": identifier = data.identifier != null ? data.identifier : "default"; url = "/arbitrary/resource/properties/" + data.service + "/" + data.name + "/" + identifier; return httpGetAsyncWithEvent(event, url); case "GET_QDN_RESOURCE_METADATA": identifier = data.identifier != null ? data.identifier : "default"; url = "/arbitrary/metadata/" + data.service + "/" + data.name + "/" + identifier; return httpGetAsyncWithEvent(event, url); case "SEARCH_CHAT_MESSAGES": url = "/chat/messages?"; if (data.before != null) url = url.concat("&before=" + data.before); if (data.after != null) url = url.concat("&after=" + data.after); if (data.txGroupId != null) url = url.concat("&txGroupId=" + data.txGroupId); if (data.involving != null) data.involving.forEach( (x, i) => (url = url.concat("&involving=" + x)) ); if (data.reference != null) url = url.concat("&reference=" + data.reference); if (data.chatReference != null) url = url.concat("&chatreference=" + data.chatReference); if (data.hasChatReference != null) url = url.concat( "&haschatreference=" + new Boolean(data.hasChatReference).toString() ); if (data.encoding != null) url = url.concat("&encoding=" + data.encoding); if (data.limit != null) url = url.concat("&limit=" + data.limit); if (data.offset != null) url = url.concat("&offset=" + data.offset); if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString()); return httpGetAsyncWithEvent(event, url); case "LIST_GROUPS": url = "/groups?"; if (data.limit != null) url = url.concat("&limit=" + data.limit); if (data.offset != null) url = url.concat("&offset=" + data.offset); if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString()); return httpGetAsyncWithEvent(event, url); case "GET_BALANCE": url = "/addresses/balance/" + data.address; if (data.assetId != null) url = url.concat("&assetId=" + data.assetId); return httpGetAsyncWithEvent(event, url); case "GET_AT": url = "/at/" + data.atAddress; return httpGetAsyncWithEvent(event, url); case "GET_AT_DATA": url = "/at/" + data.atAddress + "/data"; return httpGetAsyncWithEvent(event, url); case "LIST_ATS": url = "/at/byfunction/" + data.codeHash58 + "?"; if (data.isExecutable != null) url = url.concat("&isExecutable=" + data.isExecutable); if (data.limit != null) url = url.concat("&limit=" + data.limit); if (data.offset != null) url = url.concat("&offset=" + data.offset); if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString()); return httpGetAsyncWithEvent(event, url); case "FETCH_BLOCK": if (data.signature != null) { url = "/blocks/signature/" + data.signature; } else if (data.height != null) { url = "/blocks/byheight/" + data.height; } url = url.concat("?"); if (data.includeOnlineSignatures != null) url = url.concat( "&includeOnlineSignatures=" + data.includeOnlineSignatures ); return httpGetAsyncWithEvent(event, url); case "FETCH_BLOCK_RANGE": url = "/blocks/range/" + data.height + "?"; if (data.count != null) url = url.concat("&count=" + data.count); if (data.reverse != null) url = url.concat("&reverse=" + data.reverse); if (data.includeOnlineSignatures != null) url = url.concat( "&includeOnlineSignatures=" + data.includeOnlineSignatures ); return httpGetAsyncWithEvent(event, url); case "SEARCH_TRANSACTIONS": url = "/transactions/search?"; if (data.startBlock != null) url = url.concat("&startBlock=" + data.startBlock); if (data.blockLimit != null) url = url.concat("&blockLimit=" + data.blockLimit); if (data.txGroupId != null) url = url.concat("&txGroupId=" + data.txGroupId); if (data.txType != null) data.txType.forEach((x, i) => (url = url.concat("&txType=" + x))); if (data.address != null) url = url.concat("&address=" + data.address); if (data.confirmationStatus != null) url = url.concat("&confirmationStatus=" + data.confirmationStatus); if (data.limit != null) url = url.concat("&limit=" + data.limit); if (data.offset != null) url = url.concat("&offset=" + data.offset); if (data.reverse != null) url = url.concat("&reverse=" + new Boolean(data.reverse).toString()); return httpGetAsyncWithEvent(event, url); case "GET_PRICE": url = "/crosschain/price/" + data.blockchain + "?"; if (data.maxtrades != null) url = url.concat("&maxtrades=" + data.maxtrades); if (data.inverse != null) url = url.concat("&inverse=" + data.inverse); return httpGetAsyncWithEvent(event, url); case "PERFORMING_NON_MANUAL": isManualNavigation = false; currentIndex = data.currentIndex; return; default: // Pass to parent (UI), in case they can fulfil this request event.data.requestedHandler = "UI"; parent.postMessage(event.data, "*", [event.ports[0]]); return; } }, false ); /** * Listen for and intercept all link click events */ function interceptClickEvent(e) { var target = e.target || e.srcElement; if (target.tagName !== "A") { target = target.closest("A"); } if (target == null || target.getAttribute("href") == null) { return; } let href = target.getAttribute("href"); if (href.startsWith("qortal://")) { const c = extractComponents(href); if (c != null) { qortalRequest({ action: "LINK_TO_QDN_RESOURCE", service: c.service, name: c.name, identifier: c.identifier, path: c.path, }); } e.preventDefault(); } else if ( href.startsWith("http://") || href.startsWith("https://") || href.startsWith("//") ) { // Block external links e.preventDefault(); } } if (document.addEventListener) { document.addEventListener("click", interceptClickEvent); } else if (document.attachEvent) { document.attachEvent("onclick", interceptClickEvent); } /** * Intercept image loads from the DOM */ document.addEventListener("DOMContentLoaded", () => { const imgElements = document.querySelectorAll("img"); imgElements.forEach((img) => { let url = img.src; const newUrl = convertToResourceUrl(url, false); if (newUrl != null) { document.querySelector("img").src = newUrl; } }); }); /** * Intercept img src updates */ document.addEventListener("DOMContentLoaded", () => { const imgElements = document.querySelectorAll("img"); imgElements.forEach((img) => { let observer = new MutationObserver((changes) => { changes.forEach((change) => { if (change.attributeName.includes("src")) { const newUrl = convertToResourceUrl(img.src, false); if (newUrl != null) { document.querySelector("img").src = newUrl; } } }); }); observer.observe(img, { attributes: true }); }); }); const awaitTimeout = (timeout, reason) => new Promise((resolve, reject) => setTimeout( () => (reason === undefined ? resolve() : reject(reason)), timeout ) ); function getDefaultTimeout(action) { if (action != null) { // Some actions need longer default timeouts, especially those that create transactions switch (action) { case "GET_USER_ACCOUNT": case "SAVE_FILE": case "SIGN_TRANSACTION": case "DECRYPT_DATA": // User may take a long time to accept/deny the popup return 60 * 60 * 1000; case "SEARCH_QDN_RESOURCES": // Searching for data can be slow, especially when metadata and statuses are also being included return 30 * 1000; case "FETCH_QDN_RESOURCE": // Fetching data can take a while, especially if the status hasn't been checked first return 60 * 1000; case "PUBLISH_QDN_RESOURCE": case "PUBLISH_MULTIPLE_QDN_RESOURCES": // Publishing could take a very long time on slow system, due to the proof-of-work computation return 60 * 60 * 1000; case "SEND_CHAT_MESSAGE": // Chat messages rely on PoW computations, so allow extra time return 60 * 1000; case "CREATE_TRADE_BUY_ORDER": case "CREATE_TRADE_SELL_ORDER": case "CANCEL_TRADE_SELL_ORDER": case "VOTE_ON_POLL": case "CREATE_POLL": case "JOIN_GROUP": case "DEPLOY_AT": case "SEND_COIN": // Allow extra time for other actions that create transactions, even if there is no PoW return 5 * 60 * 1000; case "GET_WALLET_BALANCE": // Getting a wallet balance can take a while, if there are many transactions return 2 * 60 * 1000; default: break; } } return 30 * 1000; } /** * Process queued requests when a slot becomes available */ function processRequestQueue() { while ( activeRequestCount < MAX_CONCURRENT_REQUESTS && requestQueue.length > 0 ) { const queuedRequest = requestQueue.shift(); queuedRequest.execute(); } } /** * Execute a request immediately (bypasses queue) * @param {object} request - The request payload * @param {number} [effectiveTimeoutMs] - Timeout used for this request (for cleanup); if omitted, cleanup uses getDefaultTimeout(request.action) */ function executeQortalRequestImmediate(request, effectiveTimeoutMs) { return new Promise((res, rej) => { const channel = new MessageChannel(); const requestId = Math.random().toString(36).substring(2, 15) + Date.now(); // Track this channel for cleanup (effectiveTimeoutMs used so cleanup respects qortalRequest / qortalRequestWithTimeout timeouts) pendingMessageChannels.set(requestId, { channel: channel, request: request, timestamp: Date.now(), effectiveTimeoutMs: effectiveTimeoutMs, }); channel.port1.onmessage = ({ data }) => { channel.port1.close(); pendingMessageChannels.delete(requestId); activeRequestCount--; processRequestQueue(); // Process next queued request if (data.error) { rej(data.error); } else { res(data.result); } }; // Handle port closure/errors channel.port1.onmessageerror = () => { channel.port1.close(); pendingMessageChannels.delete(requestId); activeRequestCount--; processRequestQueue(); rej(new Error("MessageChannel error")); }; window.postMessage(request, "*", [channel.port2]); }); } /** * Make a Qortal (Q-Apps) request with no timeout * @param {object} request - The request payload * @param {number} [effectiveTimeoutMs] - Timeout used for this request (for orphan cleanup only); if omitted, cleanup uses getDefaultTimeout(request.action) */ const qortalRequestWithNoTimeout = (request, effectiveTimeoutMs) => { return new Promise((res, rej) => { const executeRequest = () => { activeRequestCount++; executeQortalRequestImmediate(request, effectiveTimeoutMs).then(res).catch(rej); }; // If under concurrent limit, execute immediately if (activeRequestCount < MAX_CONCURRENT_REQUESTS) { executeRequest(); } else { // Queue the request requestQueue.push({ execute: executeRequest }); } }); }; // Pending qortal request deduplication const pendingQortalRequests = new Map(); /** * Create a unique key for request deduplication */ function getRequestKey(request) { // Create a stable key from the request object const keyObj = { action: request.action, // Include key parameters that make requests unique service: request.service, name: request.name, identifier: request.identifier, path: request.path, address: request.address, // For search/query requests query: request.query, limit: request.limit, offset: request.offset, }; return JSON.stringify(keyObj); } /** * Make a Qortal (Q-Apps) request with the default timeout (10 seconds) */ const qortalRequest = (request) => { // Check if identical request is already pending const requestKey = getRequestKey(request); if (pendingQortalRequests.has(requestKey)) { debugLog("Request deduplication hit for:", request.action); // Return the existing promise instead of creating a new request return pendingQortalRequests.get(requestKey); } debugLog( "New request:", request.action, "Queue size:", requestQueue.length, "Active:", activeRequestCount ); // Create new request promise const defaultTimeout = getDefaultTimeout(request.action); const requestPromise = Promise.race([ qortalRequestWithNoTimeout(request, defaultTimeout), awaitTimeout(defaultTimeout, "The request timed out"), ]) .then((result) => { debugLog("Request completed:", request.action); return result; }) .catch((error) => { debugLog("Request failed:", request.action, error); throw error; }) .finally(() => { // Remove from pending cache when done (success or failure) pendingQortalRequests.delete(requestKey); }); // Store in pending cache pendingQortalRequests.set(requestKey, requestPromise); return requestPromise; }; /** * Make a Qortal (Q-Apps) request with a custom timeout, specified in milliseconds */ const qortalRequestWithTimeout = (request, timeout) => { // Check if identical request is already pending const requestKey = getRequestKey(request); if (pendingQortalRequests.has(requestKey)) { // Return the existing promise instead of creating a new request return pendingQortalRequests.get(requestKey); } // Create new request promise const requestPromise = Promise.race([ qortalRequestWithNoTimeout(request, timeout), awaitTimeout(timeout, "The request timed out"), ]).finally(() => { // Remove from pending cache when done (success or failure) pendingQortalRequests.delete(requestKey); }); // Store in pending cache pendingQortalRequests.set(requestKey, requestPromise); return requestPromise; }; // Clean up channels this long after the request's timeout would have fired const CLEANUP_BUFFER_MS = 5000; /** * Cleanup orphaned MessageChannels that have been waiting too long. * Uses each request's effective timeout (from qortalRequest or qortalRequestWithTimeout) so long-running requests are not closed early. */ function cleanupOrphanedChannels() { const now = Date.now(); let cleanedCount = 0; for (const [requestId, data] of pendingMessageChannels.entries()) { const maxAge = (data.effectiveTimeoutMs != null ? data.effectiveTimeoutMs : getDefaultTimeout(data.request.action)) + CLEANUP_BUFFER_MS; if (now - data.timestamp > maxAge) { console.warn( "Cleaning up orphaned MessageChannel for request:", data.request.action, "Age:", Math.round((now - data.timestamp) / 1000), "seconds" ); try { data.channel.port1.close(); } catch (e) { // Port may already be closed } pendingMessageChannels.delete(requestId); cleanedCount++; } } // Cleanup old cache entries let cacheCleanedCount = 0; for (const [url, cached] of requestCache.entries()) { if (now - cached.timestamp > REQUEST_CACHE_TTL * 2) { requestCache.delete(url); cacheCleanedCount++; } } if (cleanedCount > 0 || cacheCleanedCount > 0) { debugLog( "Cleanup complete. Channels:", cleanedCount, "Cache entries:", cacheCleanedCount ); debugLog( "Stats - Pending channels:", pendingMessageChannels.size, "Active requests:", activeRequestCount, "Queued:", requestQueue.length ); } } // Run cleanup every 30 seconds setInterval(cleanupOrphanedChannels, 30000); /** * Send current page details to UI */ document.addEventListener("DOMContentLoaded", (event) => { resetVariables(); qortalRequest({ action: "QDN_RESOURCE_DISPLAYED", service: _qdnService, name: _qdnName, identifier: _qdnIdentifier, path: _qdnPath, }); // send to the client the first path when the app loads. const firstPath = parseUrl(window?.location?.href || ""); handleQDNResourceDisplayed(firstPath, true); // Increment counter when page fully loads }); /** * Handle app navigation */ navigation.addEventListener("navigate", (event) => { const url = new URL(event.destination.url); let fullpath = url.pathname + url.hash; const processedPath = fullpath.startsWith(_qdnBase) ? fullpath.slice(_qdnBase.length) : fullpath; qortalRequest({ action: "QDN_RESOURCE_DISPLAYED", service: _qdnService, name: _qdnName, identifier: _qdnIdentifier, path: processedPath, }); // Put a timeout so that the DOMContentLoaded listener's logic executes before the navigate listener setTimeout(() => { handleQDNResourceDisplayed(processedPath); }, 100); }); /** * Cleanup on page unload */ window.addEventListener("beforeunload", () => { // Close all pending MessageChannels for (const [requestId, data] of pendingMessageChannels.entries()) { try { data.channel.port1.close(); } catch (e) { // Port may already be closed } } pendingMessageChannels.clear(); // Clear all caches requestCache.clear(); pendingAsyncRequests.clear(); pendingQortalRequests.clear(); // Clear request queue requestQueue.length = 0; activeRequestCount = 0; });