From 9914c6975ffaac0d178cd3151e76570d0f29005c Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 26 Jun 2026 15:01:37 +0300 Subject: [PATCH] feat: ws bridge --- lnbits/core/extensions/loader.py | 4 + lnbits/templates/wasm_extension.html | 146 ++++++++++++++++++++++++++- 2 files changed, 147 insertions(+), 3 deletions(-) diff --git a/lnbits/core/extensions/loader.py b/lnbits/core/extensions/loader.py index 1ab092508..540934f91 100644 --- a/lnbits/core/extensions/loader.py +++ b/lnbits/core/extensions/loader.py @@ -39,6 +39,10 @@ WASM_EXTENSION_CORE_STATIC_ASSETS = { "static/vendor/quasar.umd.prod.js", "text/javascript; charset=utf-8", ), + "qrcode.vue.browser.js": ( + "static/vendor/qrcode.vue.browser.js", + "text/javascript; charset=utf-8", + ), "vue.global.prod.js": ( "static/vendor/vue.global.prod.js", "text/javascript; charset=utf-8", diff --git a/lnbits/templates/wasm_extension.html b/lnbits/templates/wasm_extension.html index 01f2294f1..1af3a7703 100644 --- a/lnbits/templates/wasm_extension.html +++ b/lnbits/templates/wasm_extension.html @@ -37,6 +37,8 @@ ;(() => { const bridge = {{ bridge | tojson | safe }} let bridgePort = null + const allowedPaymentHashes = new Set() + const paymentSubscriptions = new Map() function extensionFrameWindow() { return extensionFrame()?.contentWindow @@ -49,8 +51,7 @@ function loadExtensionFrame() { const frame = extensionFrame() if (!frame) { - bridgePort?.close() - bridgePort = null + closeBridgePort() return } if (frame.dataset.loaded === 'true') return @@ -123,6 +124,7 @@ typeof data === 'object' && data.detail ? data.detail : text ) } + rememberPaymentHashes(data) return data } @@ -140,6 +142,126 @@ } } + function rememberPaymentHashes(value) { + if (!value || typeof value !== 'object') return + + if (Array.isArray(value)) { + value.forEach(rememberPaymentHashes) + return + } + + for (const [key, item] of Object.entries(value)) { + if ( + ['paymentHash', 'payment_hash'].includes(key) && + isPaymentHash(item) + ) { + allowedPaymentHashes.add(item) + } + rememberPaymentHashes(item) + } + } + + function isPaymentHash(value) { + return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value) + } + + function websocketUrl(path) { + const url = new URL(window.location.href) + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + url.pathname = path + url.search = '' + url.hash = '' + return url.toString() + } + + function sendBridgeEvent(message) { + if (!bridgePort) return + bridgePort.postMessage({ + type: 'lnbits-extension:event', + ...message + }) + } + + function closePaymentSubscription(subscriptionId) { + const subscription = paymentSubscriptions.get(subscriptionId) + if (!subscription) return + paymentSubscriptions.delete(subscriptionId) + try { + subscription.socket.close() + } catch (_error) {} + } + + function closePaymentSubscriptions() { + for (const subscriptionId of Array.from(paymentSubscriptions.keys())) { + closePaymentSubscription(subscriptionId) + } + } + + function closeBridgePort() { + closePaymentSubscriptions() + bridgePort?.close() + bridgePort = null + } + + function subscribePayment(message) { + const subscriptionId = String(message.subscriptionId || '') + const paymentHash = String(message.paymentHash || '') + + if (!subscriptionId || !isPaymentHash(paymentHash)) { + throw new Error('Invalid payment subscription.') + } + if (!allowedPaymentHashes.has(paymentHash)) { + throw new Error('Payment subscription is not allowed.') + } + + closePaymentSubscription(subscriptionId) + + const socket = new WebSocket( + websocketUrl(`/api/v1/ws/${encodeURIComponent(paymentHash)}`) + ) + paymentSubscriptions.set(subscriptionId, {paymentHash, socket}) + + socket.addEventListener('message', event => { + let data = event.data + try { + data = JSON.parse(event.data) + } catch (_error) {} + + sendBridgeEvent({ + event: 'payment.update', + subscriptionId, + paymentHash, + data + }) + + if ( + data && + typeof data === 'object' && + (data.pending === false || + ['success', 'settled', 'paid'].includes(String(data.status || ''))) + ) { + sendBridgeEvent({ + event: 'payment.settled', + subscriptionId, + paymentHash, + data + }) + closePaymentSubscription(subscriptionId) + } + }) + socket.addEventListener('error', () => { + sendBridgeEvent({ + event: 'payment.error', + subscriptionId, + paymentHash + }) + closePaymentSubscription(subscriptionId) + }) + socket.addEventListener('close', () => { + paymentSubscriptions.delete(subscriptionId) + }) + } + async function handleBridgeRequest(message, reply) { if (!message || message.type !== 'lnbits-extension:request') return @@ -174,6 +296,24 @@ return } + if (message.action === 'payment.subscribe') { + subscribePayment(message) + sendResponse(reply, message.id, { + ok: true, + data: {ok: true} + }) + return + } + + if (message.action === 'payment.unsubscribe') { + closePaymentSubscription(String(message.subscriptionId || '')) + sendResponse(reply, message.id, { + ok: true, + data: {ok: true} + }) + return + } + throw new Error('Unknown extension bridge action.') } catch (error) { sendResponse(reply, message.id, { @@ -191,7 +331,7 @@ const port = event.ports?.[0] if (!port) return - bridgePort?.close() + closeBridgePort() bridgePort = port bridgePort.addEventListener('message', portEvent => { handleBridgeRequest(portEvent.data, response => {