feat: call ext logic
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -53,6 +53,7 @@ dependencies = [
|
||||
"greenlet~=3.3.0",
|
||||
"urllib3>=2.7.0",
|
||||
"pyinstrument>=5.1.2",
|
||||
"wasmtime>=45.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user