refactor: components
This commit is contained in:
+1
-1
File diff suppressed because one or more lines are too long
@@ -139,6 +139,16 @@ const routes = [
|
||||
name: 'PageError',
|
||||
component: PageError
|
||||
},
|
||||
{
|
||||
path: '/ext/:extId',
|
||||
name: 'WasmExtensionRoot',
|
||||
component: window.WasmExtensionComponent
|
||||
},
|
||||
{
|
||||
path: '/ext/:extId/:pathMatch(.*)*',
|
||||
name: 'WasmExtension',
|
||||
component: window.WasmExtensionComponent
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'DynamicComponent',
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
window.WasmExtensionComponent = {
|
||||
template: `
|
||||
<div class="wasm-extension-page relative-position">
|
||||
<q-inner-loading :showing="loading && !frameUrl">
|
||||
<q-spinner-dots size="40px"></q-spinner-dots>
|
||||
</q-inner-loading>
|
||||
<q-banner v-if="error" class="q-ma-md bg-negative text-white">
|
||||
{{ error }}
|
||||
</q-banner>
|
||||
<iframe
|
||||
v-else-if="frameUrl"
|
||||
ref="frame"
|
||||
:key="frameUrl"
|
||||
class="wasm-extension-frame"
|
||||
:src="frameUrl"
|
||||
:title="extensionName || 'Extension'"
|
||||
sandbox="allow-scripts"
|
||||
allow="clipboard-write"
|
||||
referrerpolicy="no-referrer"
|
||||
></iframe>
|
||||
</div>
|
||||
`,
|
||||
data() {
|
||||
return {
|
||||
allowedPaymentHashes: new Set(),
|
||||
bridge: {
|
||||
apiRoutes: [],
|
||||
extensionId: '',
|
||||
public: false,
|
||||
query: {},
|
||||
routeParams: {}
|
||||
},
|
||||
bridgePort: null,
|
||||
error: '',
|
||||
extensionName: '',
|
||||
frameUrl: '',
|
||||
handleWindowMessage: null,
|
||||
loading: false,
|
||||
loadId: 0,
|
||||
paymentSubscriptions: new Map()
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.handleWindowMessage = event => this.onWindowMessage(event)
|
||||
window.addEventListener('message', this.handleWindowMessage)
|
||||
},
|
||||
unmounted() {
|
||||
window.removeEventListener('message', this.handleWindowMessage)
|
||||
this.closeBridgePort()
|
||||
},
|
||||
watch: {
|
||||
'$route.fullPath': {
|
||||
immediate: true,
|
||||
handler() {
|
||||
this.loadFrameConfig()
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
emptyBridge() {
|
||||
return {
|
||||
apiRoutes: [],
|
||||
extensionId: '',
|
||||
public: false,
|
||||
query: {},
|
||||
routeParams: {}
|
||||
}
|
||||
},
|
||||
plainBridgeContext() {
|
||||
return {
|
||||
extensionId: String(this.bridge.extensionId || ''),
|
||||
public: Boolean(this.bridge.public),
|
||||
routeParams: this.plainValue(this.bridge.routeParams || {}),
|
||||
query: this.plainValue(this.bridge.query || {})
|
||||
}
|
||||
},
|
||||
plainValue(value) {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value))
|
||||
} catch (_error) {
|
||||
return {}
|
||||
}
|
||||
},
|
||||
async loadFrameConfig() {
|
||||
const extId = String(this.$route.params.extId || '')
|
||||
const loadId = ++this.loadId
|
||||
this.loading = true
|
||||
this.error = ''
|
||||
this.frameUrl = ''
|
||||
this.bridge = this.emptyBridge()
|
||||
this.allowedPaymentHashes.clear()
|
||||
this.closeBridgePort()
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/ext/${encodeURIComponent(extId)}/_ui/frame`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
path: this.$route.path,
|
||||
query: this.$route.query || {}
|
||||
})
|
||||
}
|
||||
)
|
||||
const text = await response.text()
|
||||
let data = {}
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch (_error) {
|
||||
data = {detail: text}
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.detail || 'Failed to load extension page.')
|
||||
}
|
||||
if (loadId !== this.loadId) return
|
||||
|
||||
this.bridge = data.bridge || this.emptyBridge()
|
||||
this.extensionName = data.extension?.name || extId
|
||||
this.frameUrl = data.frameUrl
|
||||
} catch (error) {
|
||||
if (loadId !== this.loadId) return
|
||||
console.error('[lnbits wasm extension] Failed to load frame.', error)
|
||||
this.error = error instanceof Error ? error.message : String(error)
|
||||
} finally {
|
||||
if (loadId === this.loadId) {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
},
|
||||
extensionFrameWindow() {
|
||||
return this.$refs.frame?.contentWindow
|
||||
},
|
||||
sendResponse(reply, id, payload) {
|
||||
reply({
|
||||
type: 'lnbits-extension:response',
|
||||
id,
|
||||
...payload
|
||||
})
|
||||
},
|
||||
allowedApiRoute(method, path) {
|
||||
let url
|
||||
try {
|
||||
url = new URL(path, window.location.origin)
|
||||
} catch (_error) {
|
||||
return false
|
||||
}
|
||||
if (url.origin !== window.location.origin) return false
|
||||
|
||||
method = String(method || 'GET').toUpperCase()
|
||||
return (this.bridge.apiRoutes || []).some(route => {
|
||||
return (
|
||||
route.method === method &&
|
||||
new RegExp(route.pattern).test(url.pathname)
|
||||
)
|
||||
})
|
||||
},
|
||||
async callApi(message) {
|
||||
const method = String(message.method || 'GET').toUpperCase()
|
||||
const path = String(message.path || '')
|
||||
if (!this.allowedApiRoute(method, path)) {
|
||||
throw new Error('Extension API route is not allowed.')
|
||||
}
|
||||
|
||||
const options = {
|
||||
method,
|
||||
headers: {},
|
||||
credentials: 'same-origin'
|
||||
}
|
||||
if (message.body !== undefined && message.body !== null) {
|
||||
options.headers['content-type'] = 'application/json'
|
||||
options.body = JSON.stringify(message.body)
|
||||
}
|
||||
|
||||
const response = await fetch(path, options)
|
||||
const text = await response.text()
|
||||
let data = text
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch (_error) {
|
||||
data = text
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
typeof data === 'object' && data.detail ? data.detail : text
|
||||
)
|
||||
}
|
||||
this.rememberPaymentHashes(data)
|
||||
return data
|
||||
},
|
||||
notify(message) {
|
||||
const level = ['positive', 'negative', 'warning', 'info'].includes(
|
||||
message.level
|
||||
)
|
||||
? message.level
|
||||
: 'info'
|
||||
if (window.Quasar?.Notify) {
|
||||
window.Quasar.Notify.create({
|
||||
color: level,
|
||||
message: String(message.message || '')
|
||||
})
|
||||
}
|
||||
},
|
||||
rememberPaymentHashes(value) {
|
||||
if (!value || typeof value !== 'object') return
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(item => this.rememberPaymentHashes(item))
|
||||
return
|
||||
}
|
||||
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (
|
||||
['paymentHash', 'payment_hash'].includes(key) &&
|
||||
this.isPaymentHash(item)
|
||||
) {
|
||||
this.allowedPaymentHashes.add(item)
|
||||
}
|
||||
this.rememberPaymentHashes(item)
|
||||
}
|
||||
},
|
||||
isPaymentHash(value) {
|
||||
return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value)
|
||||
},
|
||||
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()
|
||||
},
|
||||
sendBridgeEvent(message) {
|
||||
if (!this.bridgePort) return
|
||||
this.bridgePort.postMessage({
|
||||
type: 'lnbits-extension:event',
|
||||
...message
|
||||
})
|
||||
},
|
||||
closePaymentSubscription(subscriptionId) {
|
||||
const subscription = this.paymentSubscriptions.get(subscriptionId)
|
||||
if (!subscription) return
|
||||
this.paymentSubscriptions.delete(subscriptionId)
|
||||
try {
|
||||
subscription.socket.close()
|
||||
} catch (_error) {}
|
||||
},
|
||||
closePaymentSubscriptions() {
|
||||
for (const subscriptionId of Array.from(
|
||||
this.paymentSubscriptions.keys()
|
||||
)) {
|
||||
this.closePaymentSubscription(subscriptionId)
|
||||
}
|
||||
},
|
||||
closeBridgePort() {
|
||||
this.closePaymentSubscriptions()
|
||||
this.bridgePort?.close()
|
||||
this.bridgePort = null
|
||||
},
|
||||
subscribePayment(message) {
|
||||
const subscriptionId = String(message.subscriptionId || '')
|
||||
const paymentHash = String(message.paymentHash || '')
|
||||
|
||||
if (!subscriptionId || !this.isPaymentHash(paymentHash)) {
|
||||
throw new Error('Invalid payment subscription.')
|
||||
}
|
||||
if (!this.allowedPaymentHashes.has(paymentHash)) {
|
||||
throw new Error('Payment subscription is not allowed.')
|
||||
}
|
||||
|
||||
this.closePaymentSubscription(subscriptionId)
|
||||
|
||||
const socket = new WebSocket(
|
||||
this.websocketUrl(`/api/v1/ws/${encodeURIComponent(paymentHash)}`)
|
||||
)
|
||||
this.paymentSubscriptions.set(subscriptionId, {paymentHash, socket})
|
||||
|
||||
socket.addEventListener('message', event => {
|
||||
let data = event.data
|
||||
try {
|
||||
data = JSON.parse(event.data)
|
||||
} catch (_error) {}
|
||||
|
||||
this.sendBridgeEvent({
|
||||
event: 'payment.update',
|
||||
subscriptionId,
|
||||
paymentHash,
|
||||
data
|
||||
})
|
||||
|
||||
if (
|
||||
data &&
|
||||
typeof data === 'object' &&
|
||||
(data.pending === false ||
|
||||
['success', 'settled', 'paid'].includes(String(data.status || '')))
|
||||
) {
|
||||
this.sendBridgeEvent({
|
||||
event: 'payment.settled',
|
||||
subscriptionId,
|
||||
paymentHash,
|
||||
data
|
||||
})
|
||||
this.closePaymentSubscription(subscriptionId)
|
||||
}
|
||||
})
|
||||
socket.addEventListener('error', () => {
|
||||
this.sendBridgeEvent({
|
||||
event: 'payment.error',
|
||||
subscriptionId,
|
||||
paymentHash
|
||||
})
|
||||
this.closePaymentSubscription(subscriptionId)
|
||||
})
|
||||
socket.addEventListener('close', () => {
|
||||
this.paymentSubscriptions.delete(subscriptionId)
|
||||
})
|
||||
},
|
||||
async handleBridgeRequest(message, reply) {
|
||||
if (!message || message.type !== 'lnbits-extension:request') return
|
||||
|
||||
try {
|
||||
if (message.action === 'context') {
|
||||
this.sendResponse(reply, message.id, {
|
||||
ok: true,
|
||||
data: this.plainBridgeContext()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (message.action === 'api') {
|
||||
this.sendResponse(reply, message.id, {
|
||||
ok: true,
|
||||
data: await this.callApi(message)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (message.action === 'ui.notify') {
|
||||
this.notify(message)
|
||||
this.sendResponse(reply, message.id, {
|
||||
ok: true,
|
||||
data: {ok: true}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (message.action === 'payment.subscribe') {
|
||||
this.subscribePayment(message)
|
||||
this.sendResponse(reply, message.id, {
|
||||
ok: true,
|
||||
data: {ok: true}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (message.action === 'payment.unsubscribe') {
|
||||
this.closePaymentSubscription(String(message.subscriptionId || ''))
|
||||
this.sendResponse(reply, message.id, {
|
||||
ok: true,
|
||||
data: {ok: true}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error('Unknown extension bridge action.')
|
||||
} catch (error) {
|
||||
this.sendResponse(reply, message.id, {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
},
|
||||
onWindowMessage(event) {
|
||||
if (event.source !== this.extensionFrameWindow()) return
|
||||
const message = event.data
|
||||
if (!message || message.type !== 'lnbits-extension:connect') return
|
||||
|
||||
const port = event.ports?.[0]
|
||||
if (!port) return
|
||||
|
||||
this.closeBridgePort()
|
||||
this.bridgePort = port
|
||||
this.bridgePort.addEventListener('message', portEvent => {
|
||||
this.handleBridgeRequest(portEvent.data, response => {
|
||||
port.postMessage(response)
|
||||
})
|
||||
})
|
||||
this.bridgePort.start()
|
||||
this.bridgePort.postMessage({
|
||||
type: 'lnbits-extension:connected',
|
||||
id: message.id
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,7 @@
|
||||
"js/components/extension-settings.js",
|
||||
"js/components/data-fields.js",
|
||||
"js/components.js",
|
||||
"js/wasm-extension-component.js",
|
||||
"js/init-app.js"
|
||||
],
|
||||
"css": ["vendor/quasar.css", "css/base.css"]
|
||||
|
||||
Reference in New Issue
Block a user