feat: call ext logic

This commit is contained in:
Vlad Stan
2026-07-09 16:30:17 +03:00
parent eec9f8a053
commit 4557910a32
5 changed files with 260 additions and 1 deletions
+2
View File
@@ -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",
+63 -1
View File
@@ -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,
+173
View File
@@ -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()
+1
View File
@@ -53,6 +53,7 @@ dependencies = [
"greenlet~=3.3.0",
"urllib3>=2.7.0",
"pyinstrument>=5.1.2",
"wasmtime>=45.0.0",
]
[project.scripts]
Generated
+21
View File
@@ -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"