mirror of
https://github.com/go-gitea/gitea.git
synced 2026-04-15 21:54:21 +02:00
Add a thin in-memory pubsub broker and a SharedWorker-based WebSocket client to deliver real-time notification count updates. This replaces the SSE path for notification-count events with a persistent WebSocket connection shared across all tabs. New files: - services/pubsub/broker.go: fan-out pubsub broker (DefaultBroker singleton) - services/websocket/notifier.go: polls notification counts, publishes to broker - routers/web/websocket/websocket.go: /-/ws endpoint, per-user topic subscription - web_src/js/features/websocket.sharedworker.ts: SharedWorker with exponential backoff reconnect (50ms initial, 10s max, reconnect on close and error) Modified files: - routers/init.go: register websocket_service.Init() - routers/web/web.go: add GET /-/ws route - services/context/response.go: add Hijack() to forward http.Hijacker so coder/websocket can upgrade the connection - web_src/js/features/notification.ts: port from SSE SharedWorker to WS SharedWorker - webpack.config.ts: add websocket.sharedworker entry point Part of RFC #36942.
145 lines
4.2 KiB
TypeScript
145 lines
4.2 KiB
TypeScript
// One WebSocket connection per URL, shared across all tabs via SharedWorker.
|
|
// Messages from the server are JSON objects broadcast to all connected ports.
|
|
export {}; // make this a module to avoid global scope conflicts with other sharedworker files
|
|
|
|
const RECONNECT_DELAY_INITIAL = 50;
|
|
const RECONNECT_DELAY_MAX = 10000;
|
|
|
|
class WsSource {
|
|
url: string;
|
|
ws: WebSocket | null;
|
|
clients: MessagePort[];
|
|
reconnectTimer: ReturnType<typeof setTimeout> | null;
|
|
reconnectDelay: number;
|
|
|
|
constructor(url: string) {
|
|
this.url = url;
|
|
this.ws = null;
|
|
this.clients = [];
|
|
this.reconnectTimer = null;
|
|
this.reconnectDelay = RECONNECT_DELAY_INITIAL;
|
|
this.connect();
|
|
}
|
|
|
|
connect() {
|
|
this.ws = new WebSocket(this.url);
|
|
|
|
this.ws.addEventListener('open', () => {
|
|
this.reconnectDelay = RECONNECT_DELAY_INITIAL;
|
|
this.broadcast({type: 'status', message: `connected to ${this.url}`});
|
|
});
|
|
|
|
this.ws.addEventListener('message', (event: MessageEvent<string>) => {
|
|
try {
|
|
const msg = JSON.parse(event.data);
|
|
this.broadcast(msg);
|
|
} catch {
|
|
// ignore malformed JSON
|
|
}
|
|
});
|
|
|
|
this.ws.addEventListener('close', () => {
|
|
this.ws = null;
|
|
this.scheduleReconnect();
|
|
});
|
|
|
|
this.ws.addEventListener('error', () => {
|
|
this.broadcast({type: 'error', message: 'websocket error'});
|
|
this.ws = null;
|
|
this.scheduleReconnect();
|
|
});
|
|
}
|
|
|
|
scheduleReconnect() {
|
|
if (this.clients.length === 0 || this.reconnectTimer !== null) return;
|
|
this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_DELAY_MAX);
|
|
this.reconnectTimer = setTimeout(() => {
|
|
this.reconnectTimer = null;
|
|
this.connect();
|
|
}, this.reconnectDelay);
|
|
}
|
|
|
|
register(port: MessagePort) {
|
|
if (this.clients.includes(port)) return;
|
|
this.clients.push(port);
|
|
port.postMessage({type: 'status', message: `registered to ${this.url}`});
|
|
}
|
|
|
|
deregister(port: MessagePort): number {
|
|
const idx = this.clients.indexOf(port);
|
|
if (idx >= 0) this.clients.splice(idx, 1);
|
|
return this.clients.length;
|
|
}
|
|
|
|
close() {
|
|
if (this.reconnectTimer !== null) {
|
|
clearTimeout(this.reconnectTimer);
|
|
this.reconnectTimer = null;
|
|
}
|
|
this.ws?.close();
|
|
this.ws = null;
|
|
}
|
|
|
|
broadcast(msg: unknown) {
|
|
for (const port of this.clients) {
|
|
port.postMessage(msg);
|
|
}
|
|
}
|
|
}
|
|
|
|
const sourcesByUrl = new Map<string, WsSource | null>();
|
|
const sourcesByPort = new Map<MessagePort, WsSource | null>();
|
|
|
|
(self as unknown as SharedWorkerGlobalScope).addEventListener('connect', (e: MessageEvent) => {
|
|
for (const port of e.ports) {
|
|
port.addEventListener('message', (event: MessageEvent) => {
|
|
if (event.data.type === 'start') {
|
|
const {url} = event.data;
|
|
let source = sourcesByUrl.get(url);
|
|
if (source) {
|
|
source.register(port);
|
|
sourcesByPort.set(port, source);
|
|
return;
|
|
}
|
|
source = sourcesByPort.get(port);
|
|
if (source) {
|
|
const count = source.deregister(port);
|
|
if (count === 0) {
|
|
source.close();
|
|
sourcesByUrl.set(source.url, null);
|
|
}
|
|
}
|
|
source = new WsSource(url);
|
|
source.register(port);
|
|
sourcesByUrl.set(url, source);
|
|
sourcesByPort.set(port, source);
|
|
} else if (event.data.type === 'close') {
|
|
const source = sourcesByPort.get(port);
|
|
if (!source) return;
|
|
const count = source.deregister(port);
|
|
if (count === 0) {
|
|
source.close();
|
|
sourcesByUrl.set(source.url, null);
|
|
sourcesByPort.set(port, null);
|
|
}
|
|
} else if (event.data.type === 'status') {
|
|
const source = sourcesByPort.get(port);
|
|
if (!source) {
|
|
port.postMessage({type: 'status', message: 'not connected'});
|
|
return;
|
|
}
|
|
port.postMessage({
|
|
type: 'status',
|
|
message: `url: ${source.url} readyState: ${source.ws?.readyState ?? 'null'}`,
|
|
});
|
|
} else {
|
|
port.postMessage({
|
|
type: 'error',
|
|
message: `received but don't know how to handle: ${JSON.stringify(event.data)}`,
|
|
});
|
|
}
|
|
});
|
|
port.start();
|
|
}
|
|
});
|