diff --git a/lnbits/core/extensions/__init__.py b/lnbits/core/extensions/__init__.py index 31bae0077..ea6ccf646 100644 --- a/lnbits/core/extensions/__init__.py +++ b/lnbits/core/extensions/__init__.py @@ -11,6 +11,7 @@ from .api import ( from .loader import WasmExtension, load_wasm_extension, register_wasm_extension from .prototype import InMemoryExtensionAPI, InMemoryExtensionState from .runtime import ExtensionAPIHost +from .wasm import invoke_wasm_extension_export __all__ = [ "ExtensionAPI", @@ -22,6 +23,7 @@ __all__ = [ "extension_api_contract", "extension_api_method", "get_extension_api_method", + "invoke_wasm_extension_export", "load_wasm_extension", "list_extension_api_methods", "register_wasm_extension", diff --git a/lnbits/core/extensions/loader.py b/lnbits/core/extensions/loader.py index d4fce6fb4..9763132ea 100644 --- a/lnbits/core/extensions/loader.py +++ b/lnbits/core/extensions/loader.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from pathlib import Path from typing import Any -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException, Request from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from loguru import logger @@ -46,6 +46,7 @@ def register_wasm_extension(app: FastAPI, ext_id: str) -> WasmExtension: loaded = load_wasm_extension(ext_id) _mount_wasm_extension_static(app, loaded) _register_wasm_extension_routes(app, loaded) + _register_wasm_extension_invoke_route(app, loaded) extensions = getattr(app.state, "lnbits_wasm_extensions", {}) extensions[ext_id] = loaded @@ -113,6 +114,67 @@ def _register_wasm_extension_routes(app: FastAPI, extension: WasmExtension) -> N _add_wasm_extension_page_route(app, extension, route_path, entrypoint) +def _register_wasm_extension_invoke_route( + app: FastAPI, + extension: WasmExtension, +) -> None: + route_path = f"/api/v1/extensions/{extension.id}/invoke/{{export_name}}" + if any(getattr(route, "path", None) == route_path for route in app.routes): + return + + async def invoke_wasm_extension_export( + export_name: str, + request: Request, + ) -> dict[str, Any]: + from .wasm import invoke_wasm_extension_export as invoke_export + + try: + _require_http_wasm_export(extension, export_name) + payload = await _read_json_object(request) + return await invoke_export( + app, + extension.id, + export_name, + payload, + ) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + app.add_api_route( + route_path, + invoke_wasm_extension_export, + methods=["POST"], + name=f"{extension.id}:invoke", + include_in_schema=False, + ) + + +async def _read_json_object(request: Request) -> dict[str, Any]: + body = await request.body() + if not body: + return {} + value = json.loads(body) + if not isinstance(value, dict): + raise TypeError("WASM extension invoke payload must be a JSON object.") + return value + + +def _require_http_wasm_export(extension: WasmExtension, export_name: str) -> None: + for export in extension.exports: + if export.get("name") != export_name: + continue + if export.get("visibility") in {"public", "authenticated"}: + return + raise PermissionError( + f"WASM export '{export_name}' is not callable over HTTP." + ) + raise KeyError(f"WASM extension '{extension.id}' has no export '{export_name}'.") + + def _add_wasm_extension_page_route( app: FastAPI, extension: WasmExtension, diff --git a/lnbits/core/extensions/wasm.py b/lnbits/core/extensions/wasm.py new file mode 100644 index 000000000..a2d28439c --- /dev/null +++ b/lnbits/core/extensions/wasm.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import asyncio +import json +import re +from collections.abc import Mapping +from functools import lru_cache +from typing import Any + +from fastapi import FastAPI + +from .api import list_extension_api_methods +from .loader import WasmExtension, register_wasm_extension +from .prototype import InMemoryExtensionAPI, InMemoryExtensionState +from .runtime import ExtensionAPIHost + + +async def invoke_wasm_extension_export( + app: FastAPI, + ext_id: str, + export_name: str, + payload: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + extension = _get_registered_extension(app, ext_id) + state = _get_extension_state(app) + permissions = _extension_permissions(extension) + api = InMemoryExtensionAPI(extension.id, permissions, state=state) + + return await asyncio.to_thread( + _invoke_wasm_extension_export_sync, + extension, + export_name, + payload or {}, + api, + ) + + +def _invoke_wasm_extension_export_sync( + extension: WasmExtension, + export_name: str, + payload: Mapping[str, Any], + api: InMemoryExtensionAPI, +) -> dict[str, Any]: + try: + from wasmtime import Store, WasiConfig, component + except ImportError as exc: + raise RuntimeError( + "WASM extension runtime is not installed. Install the 'wasmtime' " + "Python package to run WASM extensions." + ) from exc + + engine = _wasm_engine() + store = Store(engine) + store.set_wasi(WasiConfig()) + + linker = component.Linker(engine) + linker.add_wasip2() + _add_extension_host_imports(linker, ExtensionAPIHost(api)) + + wasm_component = component.Component.from_file(engine, extension.module_path) + instance = linker.instantiate(store, wasm_component) + function = instance.get_func(store, export_name) + if not function: + raise KeyError(f"WASM extension '{extension.id}' has no export '{export_name}'.") + + result = function(store, json.dumps(payload)) + function.post_return(store) + return _parse_wasm_export_result(extension, result) + + +@lru_cache(maxsize=1) +def _wasm_engine() -> Any: + try: + from wasmtime import Config, Engine + except ImportError as exc: + raise RuntimeError( + "WASM extension runtime is not installed. Install the 'wasmtime' " + "Python package to run WASM extensions." + ) from exc + + config = Config() + config.wasm_component_model = True + return Engine(config) + + +def _add_extension_host_imports(linker: Any, api_host: ExtensionAPIHost) -> None: + with linker.root() as root: + with root.add_instance("lnbits:extension/host") as host: + for method in list_extension_api_methods(): + host.add_func( + method.host_name.replace("_", "-"), + _make_host_import(api_host, method.host_name), + ) + + +def _make_host_import(api_host: ExtensionAPIHost, host_name: str) -> Any: + def host_import(_store: Any, request: Any = None) -> Any: + payload = _component_payload_to_dict(request) + response = asyncio.run(api_host.invoke(host_name, payload)) + return _dict_to_component_record(response) + + return host_import + + +def _component_payload_to_dict(value: Any) -> dict[str, Any]: + if value is None: + return {} + if hasattr(value, "__dict__"): + return dict(value.__dict__) + if isinstance(value, Mapping): + return dict(value) + raise TypeError("WASM host function payload must be a record.") + + +def _dict_to_component_record(value: Mapping[str, Any]) -> Any: + from wasmtime import component + + record = component.Record() + for key, item in value.items(): + setattr(record, _camel_to_kebab(key), item) + return record + + +def _parse_wasm_export_result(extension: WasmExtension, value: Any) -> dict[str, Any]: + if isinstance(value, bytes): + value = value.decode() + if not isinstance(value, str): + return {"ok": True, "data": value} + + max_response_bytes = ( + (extension.config.get("wasm") or {}) + .get("resource_limits", {}) + .get("max_response_bytes") + ) + if isinstance(max_response_bytes, int): + response_size = len(value.encode()) + if response_size > max_response_bytes: + raise ValueError( + f"WASM extension response is too large: {response_size} bytes." + ) + + parsed = json.loads(value) + if isinstance(parsed, dict): + return parsed + return {"ok": True, "data": parsed} + + +def _get_registered_extension(app: FastAPI, ext_id: str) -> WasmExtension: + extensions = getattr(app.state, "lnbits_wasm_extensions", {}) + extension = extensions.get(ext_id) + if extension: + return extension + return register_wasm_extension(app, ext_id) + + +def _get_extension_state(app: FastAPI) -> InMemoryExtensionState: + state = getattr(app.state, "lnbits_extension_state", None) + if not state: + state = InMemoryExtensionState() + app.state.lnbits_extension_state = state + return state + + +def _extension_permissions(extension: WasmExtension) -> set[str]: + permissions = set() + for permission in extension.config.get("permissions") or []: + if isinstance(permission, Mapping) and isinstance(permission.get("id"), str): + permissions.add(permission["id"]) + return permissions + + +def _camel_to_kebab(value: str) -> str: + return re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", value).replace("_", "-").lower() diff --git a/pyproject.toml b/pyproject.toml index 1780cb8a1..20328fc5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ dependencies = [ "greenlet~=3.3.0", "urllib3>=2.7.0", "pyinstrument>=5.1.2", + "wasmtime>=45.0.0", ] [project.scripts] diff --git a/uv.lock b/uv.lock index 3c09798a4..47b571ff3 100644 --- a/uv.lock +++ b/uv.lock @@ -1333,6 +1333,7 @@ dependencies = [ { name = "urllib3" }, { name = "uvicorn" }, { name = "uvloop" }, + { name = "wasmtime" }, { name = "websocket-client" }, { name = "websockets" }, ] @@ -1422,6 +1423,7 @@ requires-dist = [ { name = "uvicorn", specifier = "~=0.40.0" }, { name = "uvloop", specifier = "~=0.22.1" }, { name = "wallycore", marker = "extra == 'liquid'", specifier = "~=1.5.1" }, + { name = "wasmtime", specifier = ">=45.0.0" }, { name = "websocket-client", specifier = "~=1.9.0" }, { name = "websockets", specifier = "~=15.0.1" }, ] @@ -2858,6 +2860,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/e5/1c1d2979d074cba4f0a1516f2bf4c3ee66067b6ba3b96e5cb4460555d474/wallycore-1.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3343a1df11a7ef4572521e002f4550a0157aea2c749dcfd9be62fb5babe0d03", size = 1728038, upload-time = "2026-04-15T22:04:36.434Z" }, ] +[[package]] +name = "wasmtime" +version = "45.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/ff/db9cfc61d988bc15303134bb174176a29839976876dfd18c3a12548ad291/wasmtime-45.0.0.tar.gz", hash = "sha256:2ad4bf7ca286ceea35c1e420d10b368d7f83faf9a5ffde87b4ee334a9b7f55f3", size = 128297, upload-time = "2026-05-26T17:57:39.131Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/56/7d941adba273210dcf4198266a47f472a5eeca20172005b443af71a9a3e7/wasmtime-45.0.0-py3-none-android_26_arm64_v8a.whl", hash = "sha256:4e843795b53e66c71313f2254731467372e5e1549227cf14accb9e2d57701c10", size = 8659052, upload-time = "2026-05-26T17:57:12.338Z" }, + { url = "https://files.pythonhosted.org/packages/3f/81/c4d81ebf3db8aa28f789a9569640f30790d5234c509a0234cd502aa2638b/wasmtime-45.0.0-py3-none-android_26_x86_64.whl", hash = "sha256:35e713f907264e470f3bc9b592b81b8ed0f8f5651725d9f07a5d52beb0642e38", size = 9619373, upload-time = "2026-05-26T17:57:14.979Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c7/7594da7fa8a3bc5e765733ad57aac9b7b27262c4afa47521bd500e4a4574/wasmtime-45.0.0-py3-none-any.whl", hash = "sha256:6251ee5074a8b8bfaa98e6e99cb5d49d6d0f2320b3265d5aa6c2ee5df5fb4519", size = 8019034, upload-time = "2026-05-26T17:57:20.138Z" }, + { url = "https://files.pythonhosted.org/packages/75/76/7d0e440ca03a717a97889dbb7b68f952c20ed4ffd3f59addf9553579e1d5/wasmtime-45.0.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:3579b0ec6d001750d66ec7089aaeee2c048f88328c82743e15f099af01b0cf84", size = 9401625, upload-time = "2026-05-26T17:57:22.149Z" }, + { url = "https://files.pythonhosted.org/packages/5b/0b/a81b5daf5adea482ecb68d9615f6a348486ab4d8e980a915d4420e57ee4d/wasmtime-45.0.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:31d10f25c330cebcfb364e9a357123deeec96c41725ff2bba91b705587f38a93", size = 8255954, upload-time = "2026-05-26T17:57:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8c/e9019a28e908214031310aefd78e4755221d02303190b54b2c85cb69573e/wasmtime-45.0.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:5d1416ec6da8cd87c29e2e9eb074358c91839c2fff971fe428c8921eaae68e73", size = 9681185, upload-time = "2026-05-26T17:57:26.641Z" }, + { url = "https://files.pythonhosted.org/packages/42/56/ed5f492bd553a31c8e28d621f8256f2c7b1a133b28f73525d96ca355891a/wasmtime-45.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:a499f6ab0eebb70dca83d6a4904b743cd122f322af3abe86af08ad753533d946", size = 8582001, upload-time = "2026-05-26T17:57:28.883Z" }, + { url = "https://files.pythonhosted.org/packages/62/12/9b41740da83f51014b88181c9086de0ed75d736a5329baff7323c4fb6eff/wasmtime-45.0.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bef65282b7de744106a91da43e4d06ba19d2d587bc54abb83b3e757f0c4fc030", size = 8633462, upload-time = "2026-05-26T17:57:31.423Z" }, + { url = "https://files.pythonhosted.org/packages/ea/63/49d8317706a108d9ed1d4166d0fc710796da1b20e591a98a96575dec367a/wasmtime-45.0.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a0b6ca14b4628a5d1ffa91ccf2c0f2c58fa171f126ec085d564b09d5795395dd", size = 9712524, upload-time = "2026-05-26T17:57:33.839Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/8e31ea472ceb934e7261ac59a786e82cd82b4d4dcb7c870d498aa9c3c21e/wasmtime-45.0.0-py3-none-win_amd64.whl", hash = "sha256:1736a70a48f713aaf1a878514d29cc6f554213b5431e04447813a3b9b4320381", size = 8019039, upload-time = "2026-05-26T17:57:36.04Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d1/ac536e92ac95a02e137be5b6829f15b87d5eef93ace32e5ee8035155b839/wasmtime-45.0.0-py3-none-win_arm64.whl", hash = "sha256:ae9726590e6d90c6305b8b507c93468b145204d4390aa9a2e29e26babcae110e", size = 6845659, upload-time = "2026-05-26T17:57:37.696Z" }, +] + [[package]] name = "websocket-client" version = "1.9.0"