Clear plugin cache & add API extension hooks

Add extension hooks and improve plugin cache handling. Changes include:

- Add new JSON API extension points and a cache reset handler (extensions/webui/json_api_call_before, json_api_call_after/cache_reset.js) to clear frontend cache when backend cache_reset runs.
- Rename webui extension hook directories to use fetch_api_call_* naming.
- Refactor webui/js/api.js to normalize API URLs, lazily import ./extensions.js, and call extension hooks for json_api_call_before/error/after and fetch_api_call_before/after using a ctx object.
- Rename invalidate_plugin_cache to clear_plugin_cache and call it after plugin install/delete/toggle/config save to keep plugin cache consistent.
- Harden plugin name sanitization to replace non-alphanumeric chars with underscores.
- Include extensions/**/*.js in jsconfig.json for editor tooling.

These changes improve extensibility for API lifecycle events and ensure plugin-related cache is cleared when plugin state changes.
This commit is contained in:
frdel 2026-03-09 11:21:27 +01:00
parent eb0595bb3f
commit 27730153ac
9 changed files with 86 additions and 38 deletions

View file

@ -0,0 +1,14 @@
import { clear } from "/js/cache.js";
export default async function resetCache(ctx) {
try {
// clear frontend cache areas when backend caches are cleared via API
if (ctx.endpoint == "cache_reset") {
for (const area of ctx.data.areas) {
clear(area);
}
}
} catch (e) {
console.error(e);
}
}

View file

@ -71,7 +71,7 @@ class PluginListItem(BaseModel):
toggle_state: ToggleState = "disabled" toggle_state: ToggleState = "disabled"
def invalidate_plugin_cache(): def clear_plugin_cache():
cache.clear("*(plugins)*") cache.clear("*(plugins)*")
@ -189,6 +189,7 @@ def delete_plugin(plugin_name: str):
if not files.is_in_dir(plugin_dir, custom_plugins_dir): if not files.is_in_dir(plugin_dir, custom_plugins_dir):
raise ValueError("Only custom plugins can be deleted") raise ValueError("Only custom plugins can be deleted")
files.delete_dir(plugin_dir) files.delete_dir(plugin_dir)
clear_plugin_cache()
def get_plugin_paths(*subpaths: str) -> List[str]: def get_plugin_paths(*subpaths: str) -> List[str]:
@ -347,6 +348,7 @@ def toggle_plugin(
files.write_file(enabled_file, "") files.write_file(enabled_file, "")
else: else:
files.write_file(disabled_file, "") files.write_file(disabled_file, "")
clear_plugin_cache()
def get_plugin_config( def get_plugin_config(
@ -401,6 +403,7 @@ def save_plugin_config(
) )
if file_path: if file_path:
files.write_file(file_path, json.dumps(settings)) files.write_file(file_path, json.dumps(settings))
clear_plugin_cache()
def find_plugin_asset( def find_plugin_asset(

View file

@ -13,5 +13,5 @@
"/usr/plugins/*": ["usr/plugins/*"] "/usr/plugins/*": ["usr/plugins/*"]
} }
}, },
"include": ["webui/**/*.js", "plugins/**/*.js", "usr/plugins/**/*.js"] "include": ["webui/**/*.js", "extensions/**/*.js", "plugins/**/*.js", "usr/plugins/**/*.js"]
} }

View file

@ -12,7 +12,7 @@ from helpers import files
from helpers.plugins import ( from helpers.plugins import (
META_FILE_NAME, META_FILE_NAME,
PluginMetadata, PluginMetadata,
invalidate_plugin_cache, clear_plugin_cache,
) )
from helpers import yaml as yaml_helper from helpers import yaml as yaml_helper
@ -29,7 +29,7 @@ def _sanitize_plugin_name(name: str) -> str:
Converts dots and dashes to underscores for Python import compatibility. Converts dots and dashes to underscores for Python import compatibility.
Raises ValueError if the name is unsafe for filesystem use.""" Raises ValueError if the name is unsafe for filesystem use."""
name = name.strip().strip(".") name = name.strip().strip(".")
name = re.sub(r"[-.]", "_", name) name = re.sub(r"[^a-zA-Z0-9]+", "_", name)
if not name or not _SAFE_NAME_RE.match(name): if not name or not _SAFE_NAME_RE.match(name):
raise ValueError( raise ValueError(
f"Invalid plugin name: '{name}'. " f"Invalid plugin name: '{name}'. "
@ -107,7 +107,7 @@ def install_from_zip(zip_path: str) -> dict:
dest = os.path.join(_get_user_plugins_dir(), plugin_name) dest = os.path.join(_get_user_plugins_dir(), plugin_name)
os.makedirs(os.path.dirname(dest), exist_ok=True) os.makedirs(os.path.dirname(dest), exist_ok=True)
shutil.move(plugin_root, dest) shutil.move(plugin_root, dest)
invalidate_plugin_cache() clear_plugin_cache()
return { return {
"success": True, "success": True,
@ -147,6 +147,7 @@ def install_from_git(url: str, token: Optional[str] = None) -> dict:
except Exception as e: except Exception as e:
# Cleanup partial clone # Cleanup partial clone
shutil.rmtree(dest, ignore_errors=True) shutil.rmtree(dest, ignore_errors=True)
clear_plugin_cache()
raise ValueError(f"Git clone failed: {e}") from e raise ValueError(f"Git clone failed: {e}") from e
try: try:
@ -154,9 +155,10 @@ def install_from_git(url: str, token: Optional[str] = None) -> dict:
except ValueError: except ValueError:
# No plugin.yaml — remove cloned repo # No plugin.yaml — remove cloned repo
shutil.rmtree(dest, ignore_errors=True) shutil.rmtree(dest, ignore_errors=True)
clear_plugin_cache()
raise raise
invalidate_plugin_cache() clear_plugin_cache()
return { return {
"success": True, "success": True,

View file

@ -1,3 +1,22 @@
let _extensionsModule = null;
async function _getExtensions() {
if (!_extensionsModule) _extensionsModule = await import("./extensions.js");
return _extensionsModule;
}
async function _shouldCallApiExtensions(apiUrl) {
const extensions = await _getExtensions();
const excluded = extensions.API_EXTENSION_EXCLUDED_ENDPOINTS;
return !(excluded instanceof Set && excluded.has(apiUrl));
}
function _normalizeApiUrl(url) {
return url.startsWith("/api/") || url.startsWith("api/")
? `/${url.replace(/^\/+/, "")}`
: `/api/${url.replace(/^\/+/, "")}`;
}
/** /**
* Call a JSON-in JSON-out API endpoint * Call a JSON-in JSON-out API endpoint
* Data is automatically serialized * Data is automatically serialized
@ -6,21 +25,54 @@
* @returns {Promise<any>} The JSON response from the API * @returns {Promise<any>} The JSON response from the API
*/ */
export async function callJsonApi(endpoint, data) { export async function callJsonApi(endpoint, data) {
const response = await fetchApi(endpoint, { const apiUrl = _normalizeApiUrl(endpoint);
/** @type {{ endpoint: string, data: any, response: Response | null, result: any, error: Error | null }} */
const ctx = {
endpoint,
data,
response: null,
result: null,
error: null,
};
if (await _shouldCallApiExtensions(apiUrl)) {
const extensions = await _getExtensions();
await extensions.callJsExtensions("json_api_call_before", ctx);
}
const response = await fetchApi(ctx.endpoint, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
credentials: "same-origin", credentials: "same-origin",
body: JSON.stringify(data), body: JSON.stringify(ctx.data),
}); });
ctx.response = response;
if (!response.ok) { if (!response.ok) {
const error = await response.text(); const error = await response.text();
throw new Error(error); ctx.error = new Error(error);
if (await _shouldCallApiExtensions(apiUrl)) {
const extensions = await _getExtensions();
await extensions.callJsExtensions("json_api_call_error", ctx);
}
if (ctx.error) throw ctx.error;
return ctx.result;
} }
const jsonResponse = await response.json();
return jsonResponse; ctx.result = await response.json();
if (await _shouldCallApiExtensions(apiUrl)) {
const extensions = await _getExtensions();
await extensions.callJsExtensions("json_api_call_after", ctx);
}
return ctx.result;
} }
/** /**
@ -31,25 +83,6 @@ export async function callJsonApi(endpoint, data) {
* @returns {Promise<Response>} The fetch response * @returns {Promise<Response>} The fetch response
*/ */
export async function fetchApi(url, request) { export async function fetchApi(url, request) {
async function _getExtensions() {
try {
return await import("./extensions.js");
} catch {
return null;
}
}
/**
* @param {string} apiUrl
* @returns {Promise<boolean>}
*/
async function _shouldCallApiExtensions(apiUrl) {
const extensions = await _getExtensions();
if (!extensions) return false;
const excluded = extensions.API_EXTENSION_EXCLUDED_ENDPOINTS;
return !(excluded instanceof Set && excluded.has(apiUrl));
}
async function _wrap(retry) { async function _wrap(retry) {
// get the CSRF token // get the CSRF token
const token = await getCsrfToken(); const token = await getCsrfToken();
@ -64,7 +97,7 @@ export async function fetchApi(url, request) {
finalRequest.headers["X-CSRF-Token"] = token; finalRequest.headers["X-CSRF-Token"] = token;
// perform the fetch with the updated request // perform the fetch with the updated request
const apiUrl = url.startsWith('/api/') || url.startsWith('api/') ? `/${url.replace(/^\/+/, '')}` : `/api/${url.replace(/^\/+/, '')}`; const apiUrl = _normalizeApiUrl(url);
/** @type {{ url: string, apiUrl: string, request: any, response: Response | null, retry: boolean }} */ /** @type {{ url: string, apiUrl: string, request: any, response: Response | null, retry: boolean }} */
const ctx = { const ctx = {
@ -77,19 +110,15 @@ export async function fetchApi(url, request) {
if (await _shouldCallApiExtensions(apiUrl)) { if (await _shouldCallApiExtensions(apiUrl)) {
const extensions = await _getExtensions(); const extensions = await _getExtensions();
if (extensions) { await extensions.callJsExtensions("fetch_api_call_before", ctx);
await extensions.callJsExtensions("api_call_before", ctx);
}
} }
const response = ctx.response || await fetch(ctx.apiUrl, ctx.request); const response = ctx.response || (await fetch(ctx.apiUrl, ctx.request));
ctx.response = response; ctx.response = response;
if (await _shouldCallApiExtensions(apiUrl)) { if (await _shouldCallApiExtensions(apiUrl)) {
const extensions = await _getExtensions(); const extensions = await _getExtensions();
if (extensions) { await extensions.callJsExtensions("fetch_api_call_after", ctx);
await extensions.callJsExtensions("api_call_after", ctx);
}
} }
const finalResponse = ctx.response; const finalResponse = ctx.response;