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
}