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.

frdel committed Mar 9, 2026 at 11:21 UTC 27730153ac995abd61a4644c86f1a1532ac513a6
9 files changed +86 -38
extensions/webui/fetch_api_call_after/.gitkeep renamed
extensions/webui/fetch_api_call_before/.gitkeep renamed
extensions/webui/json_api_call_after/.gitkeep
extensions/webui/json_api_call_after/cache_reset.js new
+14
@@ -0,0 +1,14 @@
1 +import { clear } from "/js/cache.js";
2 +
3 +export default async function resetCache(ctx) {
4 + try {
5 + // clear frontend cache areas when backend caches are cleared via API
6 + if (ctx.endpoint == "cache_reset") {
7 + for (const area of ctx.data.areas) {
8 + clear(area);
9 + }
10 + }
11 + } catch (e) {
12 + console.error(e);
13 + }
14 +}
extensions/webui/json_api_call_before/.gitkeep
helpers/plugins.py
+4 -1
@@ -71,7 +71,7 @@ class PluginListItem(BaseModel):
71 toggle_state: ToggleState = "disabled"
72
73
74 -def invalidate_plugin_cache():
74 +def clear_plugin_cache():
75 cache.clear("*(plugins)*")
76
77
@@ -189,6 +189,7 @@ def delete_plugin(plugin_name: str):
189 if not files.is_in_dir(plugin_dir, custom_plugins_dir):
190 raise ValueError("Only custom plugins can be deleted")
191 files.delete_dir(plugin_dir)
192 + clear_plugin_cache()
193
194
195 def get_plugin_paths(*subpaths: str) -> List[str]:
@@ -347,6 +348,7 @@ def toggle_plugin(
348 files.write_file(enabled_file, "")
349 else:
350 files.write_file(disabled_file, "")
351 + clear_plugin_cache()
352
353
354 def get_plugin_config(
@@ -401,6 +403,7 @@ def save_plugin_config(
403 )
404 if file_path:
405 files.write_file(file_path, json.dumps(settings))
406 + clear_plugin_cache()
407
408
409 def find_plugin_asset(
jsconfig.json
+1 -1
@@ -13,5 +13,5 @@
13 "/usr/plugins/*": ["usr/plugins/*"]
14 }
15 },
16 - "include": ["webui/**/*.js", "plugins/**/*.js", "usr/plugins/**/*.js"]
16 + "include": ["webui/**/*.js", "extensions/**/*.js", "plugins/**/*.js", "usr/plugins/**/*.js"]
17 }
\ No newline at end of file
plugins/plugin_installer/helpers/install.py
+6 -4
@@ -12,7 +12,7 @@ from helpers import files
12 from helpers.plugins import (
13 META_FILE_NAME,
14 PluginMetadata,
15 - invalidate_plugin_cache,
15 + clear_plugin_cache,
16 )
17 from helpers import yaml as yaml_helper
18
@@ -29,7 +29,7 @@ def _sanitize_plugin_name(name: str) -> str:
29 Converts dots and dashes to underscores for Python import compatibility.
30 Raises ValueError if the name is unsafe for filesystem use."""
31 name = name.strip().strip(".")
32 - name = re.sub(r"[-.]", "_", name)
32 + name = re.sub(r"[^a-zA-Z0-9]+", "_", name)
33 if not name or not _SAFE_NAME_RE.match(name):
34 raise ValueError(
35 f"Invalid plugin name: '{name}'. "
@@ -107,7 +107,7 @@ def install_from_zip(zip_path: str) -> dict:
107 dest = os.path.join(_get_user_plugins_dir(), plugin_name)
108 os.makedirs(os.path.dirname(dest), exist_ok=True)
109 shutil.move(plugin_root, dest)
110 - invalidate_plugin_cache()
110 + clear_plugin_cache()
111
112 return {
113 "success": True,
@@ -147,6 +147,7 @@ def install_from_git(url: str, token: Optional[str] = None) -> dict:
147 except Exception as e:
148 # Cleanup partial clone
149 shutil.rmtree(dest, ignore_errors=True)
150 + clear_plugin_cache()
151 raise ValueError(f"Git clone failed: {e}") from e
152
153 try:
@@ -154,9 +155,10 @@ def install_from_git(url: str, token: Optional[str] = None) -> dict:
155 except ValueError:
156 # No plugin.yaml — remove cloned repo
157 shutil.rmtree(dest, ignore_errors=True)
158 + clear_plugin_cache()
159 raise
160
159 - invalidate_plugin_cache()
161 + clear_plugin_cache()
162
163 return {
164 "success": True,
webui/js/api.js
+61 -32
@@ -1,3 +1,22 @@
1 +let _extensionsModule = null;
2 +
3 +async function _getExtensions() {
4 + if (!_extensionsModule) _extensionsModule = await import("./extensions.js");
5 + return _extensionsModule;
6 +}
7 +
8 +async function _shouldCallApiExtensions(apiUrl) {
9 + const extensions = await _getExtensions();
10 + const excluded = extensions.API_EXTENSION_EXCLUDED_ENDPOINTS;
11 + return !(excluded instanceof Set && excluded.has(apiUrl));
12 +}
13 +
14 +function _normalizeApiUrl(url) {
15 + return url.startsWith("/api/") || url.startsWith("api/")
16 + ? `/${url.replace(/^\/+/, "")}`
17 + : `/api/${url.replace(/^\/+/, "")}`;
18 +}
19 +
20 /**
21 * Call a JSON-in JSON-out API endpoint
22 * Data is automatically serialized
@@ -6,21 +25,54 @@
25 * @returns {Promise<any>} The JSON response from the API
26 */
27 export async function callJsonApi(endpoint, data) {
9 - const response = await fetchApi(endpoint, {
28 + const apiUrl = _normalizeApiUrl(endpoint);
29 +
30 + /** @type {{ endpoint: string, data: any, response: Response | null, result: any, error: Error | null }} */
31 + const ctx = {
32 + endpoint,
33 + data,
34 + response: null,
35 + result: null,
36 + error: null,
37 + };
38 +
39 + if (await _shouldCallApiExtensions(apiUrl)) {
40 + const extensions = await _getExtensions();
41 + await extensions.callJsExtensions("json_api_call_before", ctx);
42 + }
43 +
44 + const response = await fetchApi(ctx.endpoint, {
45 method: "POST",
46 headers: {
47 "Content-Type": "application/json",
48 },
49 credentials: "same-origin",
15 - body: JSON.stringify(data),
50 + body: JSON.stringify(ctx.data),
51 });
52 + ctx.response = response;
53
54 if (!response.ok) {
55 const error = await response.text();
20 - throw new Error(error);
56 + ctx.error = new Error(error);
57 +
58 + if (await _shouldCallApiExtensions(apiUrl)) {
59 + const extensions = await _getExtensions();
60 + await extensions.callJsExtensions("json_api_call_error", ctx);
61 + }
62 +
63 + if (ctx.error) throw ctx.error;
64 +
65 + return ctx.result;
66 + }
67 +
68 + ctx.result = await response.json();
69 +
70 + if (await _shouldCallApiExtensions(apiUrl)) {
71 + const extensions = await _getExtensions();
72 + await extensions.callJsExtensions("json_api_call_after", ctx);
73 }
22 - const jsonResponse = await response.json();
23 - return jsonResponse;
74 +
75 + return ctx.result;
76 }
77
78 /**
@@ -31,25 +83,6 @@ export async function callJsonApi(endpoint, data) {
83 * @returns {Promise<Response>} The fetch response
84 */
85 export async function fetchApi(url, request) {
34 - async function _getExtensions() {
35 - try {
36 - return await import("./extensions.js");
37 - } catch {
38 - return null;
39 - }
40 - }
41 -
42 - /**
43 - * @param {string} apiUrl
44 - * @returns {Promise<boolean>}
45 - */
46 - async function _shouldCallApiExtensions(apiUrl) {
47 - const extensions = await _getExtensions();
48 - if (!extensions) return false;
49 - const excluded = extensions.API_EXTENSION_EXCLUDED_ENDPOINTS;
50 - return !(excluded instanceof Set && excluded.has(apiUrl));
51 - }
52 -
86 async function _wrap(retry) {
87 // get the CSRF token
88 const token = await getCsrfToken();
@@ -64,7 +97,7 @@ export async function fetchApi(url, request) {
97 finalRequest.headers["X-CSRF-Token"] = token;
98
99 // perform the fetch with the updated request
67 - const apiUrl = url.startsWith('/api/') || url.startsWith('api/') ? `/${url.replace(/^\/+/, '')}` : `/api/${url.replace(/^\/+/, '')}`;
100 + const apiUrl = _normalizeApiUrl(url);
101
102 /** @type {{ url: string, apiUrl: string, request: any, response: Response | null, retry: boolean }} */
103 const ctx = {
@@ -77,19 +110,15 @@ export async function fetchApi(url, request) {
110
111 if (await _shouldCallApiExtensions(apiUrl)) {
112 const extensions = await _getExtensions();
80 - if (extensions) {
81 - await extensions.callJsExtensions("api_call_before", ctx);
82 - }
113 + await extensions.callJsExtensions("fetch_api_call_before", ctx);
114 }
115
85 - const response = ctx.response || await fetch(ctx.apiUrl, ctx.request);
116 + const response = ctx.response || (await fetch(ctx.apiUrl, ctx.request));
117 ctx.response = response;
118
119 if (await _shouldCallApiExtensions(apiUrl)) {
120 const extensions = await _getExtensions();
90 - if (extensions) {
91 - await extensions.callJsExtensions("api_call_after", ctx);
92 - }
121 + await extensions.callJsExtensions("fetch_api_call_after", ctx);
122 }
123
124 const finalResponse = ctx.response;