refactor: add websocket wildcard event support and improve cache clearing

- Add wildcard pattern matching for websocket events (*, eventPattern compilation) - Update WebSocketClient.on() to handle wildcard subscriptions with onAny/offAny - Add send_data helper and set_shared_websocket_manager for global websocket access - Broadcast cache clear events to frontend via websocket - Subscribe to wildcard events in sync-store and route to extensions - Move get_default_value import to function scope in

frdel committed Mar 20, 2026 at 14:04 UTC 651deac6f72b00289eb9a25bb482a92adda71da6
6 files changed +126 -16
extensions/webui/ws_sync_push/clear_cache.js new
+21
@@ -0,0 +1,21 @@
1 +import { clear, clear_all } from "/js/cache.js";
2 +
3 +export default async function clearCache(eventType, envelope) {
4 + try {
5 + // clear frontend cache areas when backend caches are cleared via API
6 + if (eventType == "clear_cache") {
7 + const areas = envelope?.data?.areas || [];
8 + console.log("Clearing caches", areas);
9 + if (areas.length > 0) {
10 + for (const area of areas) {
11 + clear(area);
12 + }
13 + } else {
14 + // clear all caches
15 + clear_all();
16 + }
17 + }
18 + } catch (e) {
19 + console.error(e);
20 + }
21 +}
helpers/plugins.py
+22 -12
@@ -14,7 +14,6 @@ from typing import (
14 TYPE_CHECKING,
15 TypedDict,
16 )
17 -from helpers.settings import get_default_value
17
18 from regex import W
19
@@ -133,17 +132,17 @@ def register_watchdogs():
132 relevant_patterns = ["**/extensions/**/*", TOGGLE_FILE_PATTERN, HOOKS_SCRIPT]
133
134 # combine relevant patterns with base path
136 - def expand_patterns(base_path:str):
135 + def expand_patterns(base_path: str):
136 result = []
137 for pattern in relevant_patterns:
139 - result.append(base_path+pattern)
138 + result.append(base_path + pattern)
139 return result
140
141 # add watchdogs for plugin roots
142 watchdog.add_watchdog(
143 id="plugins_roots",
144 roots=get_plugin_roots(),
146 - patterns = [*expand_patterns("*/")],
145 + patterns=[*expand_patterns("*/")],
146 handler=on_plugin_change,
147 )
148
@@ -180,9 +179,17 @@ def after_plugin_change(plugin_names: list[str] | None = None):
179
180
181 def clear_plugin_cache():
183 - cache.clear("*(plugins)*")
184 - cache.clear("*(extensions)*")
185 - cache.clear("*(api)*")
182 + areas = ["*(plugins)*", "*(extensions)*", "*(api)*"]
183 + for area in areas:
184 + cache.clear(area)
185 + from helpers.websocket_manager import send_data
186 +
187 + DeferredTask().start_task(
188 + send_data,
189 + endpoint_name="/state_sync",
190 + event_name="clear_cache",
191 + data={"areas": areas},
192 + )
193
194
195 def get_plugin_roots(plugin_name: str = "") -> List[str]:
@@ -362,18 +369,17 @@ def delete_plugin(plugin_name: str):
369 raise ValueError("Only custom plugins can be deleted")
370
371 # delete additional plugin folders
365 - assets = find_plugin_assets("",plugin_name=plugin_name)
372 + assets = find_plugin_assets("", plugin_name=plugin_name)
373 for asset in assets:
374 files.delete_dir(asset["path"])
375
369 -
376 send_frontend_reload_notification(
377 [plugin_name]
378 ) # send before deletion to properly check the extensions, second notification will be skipped automatically
373 -
379 +
380 # delete main plugin folder
381 files.delete_dir(plugin_dir)
376 -
382 +
383 after_plugin_change([plugin_name])
384
385
@@ -855,12 +861,16 @@ def call_plugin_hook(
861 return default
862
863 if asyncio.iscoroutinefunction(hook):
858 - return asyncio.run(extract_tools.safe_call(hook, *args, default=default, **kwargs))
864 + return asyncio.run(
865 + extract_tools.safe_call(hook, *args, default=default, **kwargs)
866 + )
867
868 return extract_tools.safe_call(hook, *args, default=default, **kwargs)
869
870
871 def _apply_defaults_from_env(plugin_name: str, config: dict[str, Any]):
872 + from helpers.settings import get_default_value
873 +
874 def _apply(prefix: list[str], value: dict[str, Any]):
875 for key, child in value.items():
876 env_name = "__".join([plugin_name, *prefix, key])
helpers/websocket_manager.py
+36
@@ -19,12 +19,36 @@ from helpers.state_monitor import _ws_debug_enabled
19
20 BUFFER_MAX_SIZE = 100
21 BUFFER_TTL = timedelta(hours=1)
22 +_shared_websocket_manager: WebSocketManager | None = None
23
24
25 def _utcnow() -> datetime:
26 return datetime.now(timezone.utc)
27
28
29 +def set_shared_websocket_manager(manager: "WebSocketManager") -> None:
30 + global _shared_websocket_manager
31 + _shared_websocket_manager = manager
32 +
33 +
34 +def get_shared_websocket_manager() -> "WebSocketManager":
35 + manager = _shared_websocket_manager
36 + if manager is None:
37 + raise RuntimeError("Shared WebSocketManager has not been initialized")
38 + return manager
39 +
40 +
41 +async def send_data(
42 + endpoint_name: str,
43 + event_name: str,
44 + data: dict[str, Any],
45 + connection_id: str | None = None,
46 +) -> None:
47 + manager = get_shared_websocket_manager()
48 + print(f"Sending data to {endpoint_name}/{event_name} with data {data}")
49 + await manager.send_data(endpoint_name, event_name, data, connection_id)
50 +
51 +
52 @dataclass
53 class BufferedEvent:
54 event_type: str
@@ -979,6 +1003,18 @@ class WebSocketManager:
1003 }
1004 )
1005
1006 + async def send_data(
1007 + self,
1008 + endpoint_name: str,
1009 + event_name: str,
1010 + data: dict[str, Any],
1011 + connection_id: str | None = None,
1012 + ) -> None:
1013 + if connection_id is not None:
1014 + await self.emit_to(endpoint_name, connection_id, event_name, data)
1015 + return
1016 + await self.broadcast(endpoint_name, event_name, data)
1017 +
1018 async def broadcast(
1019 self,
1020 namespace: str,
run_ui.py
+2 -1
@@ -24,7 +24,7 @@ from socketio import ASGIApp, packet
24 from starlette.applications import Starlette
25 from starlette.routing import Mount
26 from uvicorn.middleware.wsgi import WSGIMiddleware
27 -from helpers.websocket_manager import WebSocketManager
27 +from helpers.websocket_manager import WebSocketManager, set_shared_websocket_manager
28 from helpers.websocket_namespace_discovery import discover_websocket_namespaces
29 from flask import send_file
30
@@ -74,6 +74,7 @@ socketio_server = socketio.AsyncServer(
74 )
75
76 websocket_manager = WebSocketManager(socketio_server, lock)
77 +set_shared_websocket_manager(websocket_manager)
78 _settings = settings_helper.get_settings()
79 settings_helper.set_runtime_settings_snapshot(_settings)
80 websocket_manager.set_server_restart_broadcast(
webui/components/sync/sync-store.js
+11
@@ -4,6 +4,7 @@ import { invalidateCsrfToken } from "/js/api.js";
4 import { applySnapshot, buildStateRequestPayload } from "/index.js";
5 import { store as chatTopStore } from "/components/chat/top-section/chat-top-store.js";
6 import { store as notificationStore } from "/components/notifications/notification-store.js";
7 +import * as Extensions from "/js/extensions.js"
8
9 const stateSocket = getNamespacedClient("/state_sync");
10
@@ -314,6 +315,12 @@ const model = {
315 });
316 debug("[syncStore] subscribed to server_restart");
317
318 + // handle all requests with extensions
319 + await stateSocket.on("*", (eventType, envelope) => {
320 + console.log(`[syncStore] *${eventType} received`);
321 + this.handleEvent(eventType, envelope)
322 + });
323 +
324 await this.sendStateRequest({ forceFull: true });
325 } catch (error) {
326 console.error("[syncStore] init failed:", error);
@@ -322,6 +329,10 @@ const model = {
329 }
330 },
331
332 + async handleEvent(eventType, envelope){
333 + await Extensions.callJsExtensions("ws_sync_push", eventType, envelope);
334 + },
335 +
336 async sendStateRequest(options = {}) {
337 const { forceFull = false } = options || {};
338 const payload = buildStateRequestPayload({ forceFull });
webui/js/websocket.js
+34 -3
@@ -115,6 +115,15 @@ function normalizeNamespace(value) {
115 return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
116 }
117
118 +function hasWildcardPattern(value) {
119 + return typeof value === "string" && value.includes("*");
120 +}
121 +
122 +function compileEventPattern(value) {
123 + const escaped = value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
124 + return new RegExp(`^${escaped.replaceAll("*", ".*")}$`);
125 +}
126 +
127 /**
128 * Generate a correlation identifier using UUIDv4 semantics.
129 *
@@ -487,9 +496,18 @@ class WebSocketClient {
496 await this.connect();
497
498 if (!this.subscriptions.has(eventType)) {
490 - const handler = (payload) => {
499 + const isWildcard = hasWildcardPattern(eventType);
500 + const eventPattern = isWildcard ? compileEventPattern(eventType) : null;
501 + const handler = (...args) => {
502 const entry = this.subscriptions.get(eventType);
503 if (!entry) return;
504 + const currentIsWildcard = Boolean(entry.eventPattern);
505 + const payload = isWildcard ? args[1] : args[0];
506 + const incomingEventType = isWildcard ? args[0] : eventType;
507 + if (currentIsWildcard) {
508 + if (typeof incomingEventType !== "string") return;
509 + if (!entry.eventPattern.test(incomingEventType)) return;
510 + }
511 let envelope;
512 try {
513 envelope = validateServerEnvelope(payload);
@@ -501,6 +519,10 @@ class WebSocketClient {
519
520 entry.callbacks.forEach((cb) => {
521 try {
522 + if (currentIsWildcard) {
523 + cb(incomingEventType, envelope);
524 + return;
525 + }
526 cb(envelope);
527 } catch (error) {
528 console.error("WebSocket callback error:", error);
@@ -509,11 +531,16 @@ class WebSocketClient {
531 };
532
533 this.subscriptions.set(eventType, {
534 + eventPattern,
535 handler,
536 callbacks: new Set(),
537 });
538
516 - this.socket.on(eventType, handler);
539 + if (isWildcard) {
540 + this.socket.onAny(handler);
541 + } else {
542 + this.socket.on(eventType, handler);
543 + }
544 }
545
546 const entry = this.subscriptions.get(eventType);
@@ -532,7 +559,11 @@ class WebSocketClient {
559
560 if (entry.callbacks.size === 0) {
561 if (this.socket) {
535 - this.socket.off(eventType, entry.handler);
562 + if (entry.eventPattern) {
563 + this.socket.offAny(entry.handler);
564 + } else {
565 + this.socket.off(eventType, entry.handler);
566 + }
567 }
568 this.subscriptions.delete(eventType);
569 }