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

View file

@ -13,5 +13,5 @@
"/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 (
META_FILE_NAME,
PluginMetadata,
invalidate_plugin_cache,
clear_plugin_cache,
)
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.
Raises ValueError if the name is unsafe for filesystem use."""
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):
raise ValueError(
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)
os.makedirs(os.path.dirname(dest), exist_ok=True)
shutil.move(plugin_root, dest)
invalidate_plugin_cache()
clear_plugin_cache()
return {
"success": True,
@ -147,6 +147,7 @@ def install_from_git(url: str, token: Optional[str] = None) -> dict:
except Exception as e:
# Cleanup partial clone
shutil.rmtree(dest, ignore_errors=True)
clear_plugin_cache()
raise ValueError(f"Git clone failed: {e}") from e
try:
@ -154,9 +155,10 @@ def install_from_git(url: str, token: Optional[str] = None) -> dict:
except ValueError:
# No plugin.yaml — remove cloned repo
shutil.rmtree(dest, ignore_errors=True)
clear_plugin_cache()
raise
invalidate_plugin_cache()
clear_plugin_cache()
return {
"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
* Data is automatically serialized
@ -6,21 +25,54 @@
* @returns {Promise<any>} The JSON response from the API
*/
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",
headers: {
"Content-Type": "application/json",
},
credentials: "same-origin",
body: JSON.stringify(data),
body: JSON.stringify(ctx.data),
});
ctx.response = response;
if (!response.ok) {
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
*/
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) {
// get the CSRF token
const token = await getCsrfToken();
@ -64,7 +97,7 @@ export async function fetchApi(url, request) {
finalRequest.headers["X-CSRF-Token"] = token;
// 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 }} */
const ctx = {
@ -77,19 +110,15 @@ export async function fetchApi(url, request) {
if (await _shouldCallApiExtensions(apiUrl)) {
const extensions = await _getExtensions();
if (extensions) {
await extensions.callJsExtensions("api_call_before", ctx);
}
await extensions.callJsExtensions("fetch_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;
if (await _shouldCallApiExtensions(apiUrl)) {
const extensions = await _getExtensions();
if (extensions) {
await extensions.callJsExtensions("api_call_after", ctx);
}
await extensions.callJsExtensions("fetch_api_call_after", ctx);
}
const finalResponse = ctx.response;