diff --git a/lnbits/core/extensions/routes.py b/lnbits/core/extensions/routes.py
index d6fc0ca79..22748cad7 100644
--- a/lnbits/core/extensions/routes.py
+++ b/lnbits/core/extensions/routes.py
@@ -140,6 +140,8 @@ def _mount_wasm_extension_static(app: FastAPI, extension: WasmExtension) -> None
def _register_wasm_extension_ui_routes(app: FastAPI, extension: WasmExtension) -> None:
+ _add_wasm_extension_frame_config_route(app, extension)
+
for route_index, route_config in enumerate(extension.config.get("ui_routes") or []):
route_path = _wasm_extension_ui_route_path(extension, route_config.get("path"))
entrypoint = _wasm_extension_entrypoint(
@@ -147,15 +149,12 @@ def _register_wasm_extension_ui_routes(app: FastAPI, extension: WasmExtension) -
)
frame_path = f"/ext-frame/{extension.id}/{route_index}"
auth = _wasm_extension_route_auth(extension, route_config.get("auth"))
- path_params = route_config.get("path_params") or {}
_add_wasm_extension_frame_route(app, extension, frame_path, entrypoint)
_add_wasm_extension_wrapper_route(
app,
extension,
route_path,
- frame_path,
auth,
- path_params,
)
@@ -222,6 +221,52 @@ def _add_wasm_extension_api_route(
)
+def _add_wasm_extension_frame_config_route(
+ app: FastAPI,
+ extension: WasmExtension,
+) -> None:
+ route_path = _wasm_extension_frame_config_path(extension)
+ if _has_route(app, route_path, "POST"):
+ return
+
+ async def create_wasm_extension_frame_config(
+ request: Request,
+ access_token: Annotated[str | None, Depends(check_access_token)],
+ usr: UUID4 | None = None,
+ ) -> dict[str, Any]:
+ try:
+ body = await _read_json_object(request)
+ except (TypeError, ValueError) as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+ ui_route = _match_wasm_extension_ui_route(extension, body.get("path"))
+ auth = ui_route["auth"]
+
+ if auth == "user":
+ account = await check_account_exists(request, access_token, usr)
+ user_id: str | None = account.id
+ else:
+ user_id = await _optional_wasm_user_id(request, access_token, usr)
+
+ return _wasm_extension_frame_config(
+ extension,
+ ui_route["frame_path"],
+ auth,
+ ui_route["path_params"],
+ ui_route["route_params"],
+ _read_wasm_extension_route_query(body.get("query")),
+ user_id,
+ )
+
+ app.add_api_route(
+ route_path,
+ create_wasm_extension_frame_config,
+ methods=["POST"],
+ name=f"{extension.id}:frame-config",
+ include_in_schema=False,
+ )
+
+
async def _read_api_payload(
request: Request,
path_params: dict[str, str],
@@ -307,9 +352,7 @@ def _add_wasm_extension_wrapper_route(
app: FastAPI,
extension: WasmExtension,
route_path: str,
- frame_path: str,
auth: str,
- path_params: dict[str, str],
) -> None:
if _has_route(app, route_path, "GET"):
return
@@ -322,25 +365,16 @@ def _add_wasm_extension_wrapper_route(
return _wasm_extension_wrapper_response(
request,
extension,
- frame_path,
auth,
- path_params,
user.json() if user else None,
- account.id,
)
- async def serve_public_wasm_extension_page(
- request: Request,
- user_id: str | None = Depends(_optional_wasm_user_id),
- ) -> Any:
+ async def serve_public_wasm_extension_page(request: Request) -> Any:
return _wasm_extension_wrapper_response(
request,
extension,
- frame_path,
auth,
- path_params,
None,
- user_id,
)
app.add_api_route(
@@ -398,11 +432,8 @@ def _add_wasm_extension_frame_route(
def _wasm_extension_wrapper_response(
request: Request,
extension: WasmExtension,
- frame_path: str,
auth: str,
- path_params: dict[str, str],
user_json: str | None,
- user_id: str | None,
) -> Any:
public = auth == "public"
response = template_renderer().TemplateResponse(
@@ -410,14 +441,6 @@ def _wasm_extension_wrapper_response(
"wasm_extension.html",
{
"extension": extension,
- "frame_url": _wasm_extension_frame_url(extension, frame_path, user_id),
- "bridge": {
- "extensionId": extension.id,
- "public": public,
- "routeParams": _read_api_path_params(request, path_params),
- "query": _read_api_query_params(request),
- "apiRoutes": _wasm_extension_bridge_api_routes(extension, public),
- },
"public": public,
"user": user_json,
},
@@ -541,6 +564,107 @@ def _wasm_extension_bridge_api_routes(
return routes
+def _wasm_extension_frame_config_path(extension: WasmExtension) -> str:
+ return f"/api/v1/ext/{extension.id}/_ui/frame"
+
+
+def _match_wasm_extension_ui_route(
+ extension: WasmExtension,
+ path: Any,
+) -> dict[str, Any]:
+ if not isinstance(path, str) or not path.startswith("/"):
+ raise HTTPException(status_code=404, detail="Not found")
+
+ for route_index, route_config in enumerate(extension.config.get("ui_routes") or []):
+ route_path = _wasm_extension_ui_route_path(extension, route_config.get("path"))
+ route_params = _path_template_params(route_path, path)
+ if route_params is None:
+ continue
+
+ return {
+ "frame_path": f"/ext-frame/{extension.id}/{route_index}",
+ "auth": _wasm_extension_route_auth(extension, route_config.get("auth")),
+ "path_params": route_config.get("path_params") or {},
+ "route_params": route_params,
+ }
+
+ raise HTTPException(status_code=404, detail="Not found")
+
+
+def _path_template_params(template: str, path: str) -> dict[str, str] | None:
+ template_parts = _path_parts(template)
+ path_parts = _path_parts(path)
+ if len(template_parts) != len(path_parts):
+ return None
+
+ params: dict[str, str] = {}
+ for template_part, path_part in zip(template_parts, path_parts, strict=False):
+ if template_part.startswith("{") and template_part.endswith("}"):
+ param_name = template_part[1:-1]
+ if not param_name:
+ return None
+ params[param_name] = path_part
+ continue
+
+ if template_part != path_part:
+ return None
+
+ return params
+
+
+def _path_parts(path: str) -> list[str]:
+ return [part for part in path.strip("/").split("/") if part]
+
+
+def _wasm_extension_frame_config(
+ extension: WasmExtension,
+ frame_path: str,
+ auth: str,
+ path_params: dict[str, str],
+ route_params: dict[str, str],
+ query: dict[str, Any],
+ user_id: str | None,
+) -> dict[str, Any]:
+ public = auth == "public"
+ return {
+ "extension": {
+ "id": extension.id,
+ "name": extension.name,
+ },
+ "frameUrl": _wasm_extension_frame_url(extension, frame_path, user_id),
+ "bridge": {
+ "extensionId": extension.id,
+ "public": public,
+ "routeParams": _map_wasm_extension_route_params(route_params, path_params),
+ "query": query,
+ "apiRoutes": _wasm_extension_bridge_api_routes(extension, public),
+ },
+ }
+
+
+def _map_wasm_extension_route_params(
+ route_params: dict[str, str],
+ path_params: dict[str, str],
+) -> dict[str, str]:
+ payload: dict[str, str] = {}
+ for key, value in route_params.items():
+ target = path_params.get(key) or _snake_to_camel(key)
+ payload[target] = value
+ return payload
+
+
+def _read_wasm_extension_route_query(query: Any) -> dict[str, Any]:
+ if not isinstance(query, dict):
+ return {}
+
+ payload: dict[str, Any] = {}
+ for key, value in query.items():
+ if value is None:
+ continue
+ payload[_snake_to_camel(str(key))] = value
+ return payload
+
+
def _path_template_pattern(path: str) -> str:
pattern = re.sub(r"\\{[^/{}]+\\}", r"[^/]+", re.escape(path))
return f"^{pattern}$"
diff --git a/lnbits/static/js/init-app.js b/lnbits/static/js/init-app.js
index 1f0dfe327..2c0d9c95e 100644
--- a/lnbits/static/js/init-app.js
+++ b/lnbits/static/js/init-app.js
@@ -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',
diff --git a/lnbits/static/js/wasm-extension-component.js b/lnbits/static/js/wasm-extension-component.js
new file mode 100644
index 000000000..94df0d9ea
--- /dev/null
+++ b/lnbits/static/js/wasm-extension-component.js
@@ -0,0 +1,400 @@
+window.WasmExtensionComponent = {
+ template: `
+
+
+
+
+
+ {{ error }}
+
+
+
+ `,
+ 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
+ })
+ }
+ }
+}
diff --git a/lnbits/static/vendor.json b/lnbits/static/vendor.json
index 3f7849a48..0d540800d 100644
--- a/lnbits/static/vendor.json
+++ b/lnbits/static/vendor.json
@@ -97,6 +97,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"]
diff --git a/lnbits/templates/base.html b/lnbits/templates/base.html
index c81271b2e..0fdff8456 100644
--- a/lnbits/templates/base.html
+++ b/lnbits/templates/base.html
@@ -16,6 +16,18 @@
src: url("{{ static_url_for('static', 'fonts/material-icons-v50.woff2') }}")
format('woff2');
}
+
+ .wasm-extension-page,
+ .wasm-extension-frame {
+ height: calc(100vh - 56px);
+ min-height: calc(100vh - 56px);
+ width: 100%;
+ }
+
+ .wasm-extension-frame {
+ border: 0;
+ display: block;
+ }
{% block title %}{{ SITE_TITLE }}{% endblock %}
@@ -44,12 +56,14 @@
{% block page_container %}
-
+
- .wasm-extension-frame {
- border: 0;
- display: block;
- height: calc(100vh - 56px);
- min-height: calc(100vh - 56px);
- width: 100%;
- }
-
-{% endblock %} {% block page_container %}
-
-
-
-
-
-
-
-
-
-
-
-{% endblock %} {% block scripts %}
-
-{% endblock %}
+{% extends "base.html" %}
diff --git a/package.json b/package.json
index 10c31d6e0..0ecc68129 100644
--- a/package.json
+++ b/package.json
@@ -150,6 +150,7 @@
"js/components/extension-settings.js",
"js/components/data-fields.js",
"js/components.js",
+ "js/wasm-extension-component.js",
"js/init-app.js"
],
"css": [