mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-03 06:49:39 +00:00
feat: Replace SSE with WebSocket for UI notifications (#36965)
* Closes #36942 * Fixes #19265 Replaces the SSE-based push channel (`/user/events`) with a WebSocket endpoint (`/-/ws`). ### What changes - **New `/-/ws` endpoint** (authenticated). One WebSocket per origin, shared across tabs via a single `SharedWorker`. - **Pubsub broker** (`services/pubsub`) for fan-out by topic, behind a `Broker` interface. `MemoryBroker` is the default (single process); a Redis backend is available for multi-process setups, configured via `[websocket].PUBSUB_TYPE` / `PUBSUB_CONN_STR`. The internal Gitea queue was not usable here because it has FIFO/single-consumer semantics. - **Push-only event production.** Events are emitted by write-triggered notifiers — `NotificationCountChange`, `PublishStopwatchesForUser`, and the logout publisher — wired into the existing `notify.Notifier` interface. No server-side pollers. - **Typed pub/sub on the client.** `web_src/js/modules/worker.ts` is a singleton transport; features subscribe per event type via `onUserEvent('notification-count', cb)` instead of branching on `event.data.type`. - **Wire contract** (`UserEventType` union) is shared between the worker and consumers via `web_src/js/types.ts`, kept in sync with `services/websocket/events.go`. - **Client-side periodic polling fallback** kicks in only when the WebSocket cannot be established (e.g. proxy blocks WS, browser lacks module-SharedWorker support). ### What's removed - `modules/eventsource` (SSE manager, run loop, messenger). - `/user/events` route and `tests/integration/eventsource_test.go`. - All server-side polling for stopwatches and notification counts. ### Stopwatch multi-tab fix The navbar stopwatch icon was previously rendered conditionally on `{{if $activeStopwatch}}`, so tabs loaded before the timer started had no DOM element to update. The icon and popup are now always rendered (toggled with `tw-hidden`), and the start/stop/cancel handlers POST silently so all open tabs reflect the change in real time. ### Deployment note WebSocket needs the upgrade headers to pass through a reverse proxy, e.g. for nginx: ```nginx proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; ``` Without them the WebSocket cannot be established, and after 3 consecutive failed opens the shared worker signals `push-unavailable`: the notification count and stopwatch fall back to periodic polling on the existing `[ui.notification]` timeouts. Real-time push is lost, the features keep working. The reverse-proxy docs need the same note (see the `docs-update-needed` label). --------- Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: Epid <rexmrj@gmail.com>
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
import {
|
||||
serverUserEventTypes,
|
||||
type SharedWorkerControlMessage,
|
||||
type WorkerEventMessage,
|
||||
type WorkerInboundMessage,
|
||||
} from './types.ts';
|
||||
import type {UserEventMessage} from './types.ts';
|
||||
|
||||
// chrome://inspect/#workers
|
||||
let showDebugLog = false;
|
||||
|
||||
function logDebug(...args: any[]) {
|
||||
if (!showDebugLog) return;
|
||||
console.debug('[user-events.sharedworker]', ...args);
|
||||
}
|
||||
|
||||
function isServerEventMessage(msg: unknown): msg is UserEventMessage {
|
||||
if (!msg || typeof msg !== 'object') return false;
|
||||
const userEvent = msg as UserEventMessage;
|
||||
return (serverUserEventTypes as ReadonlyArray<string>).includes(userEvent.eventType ?? '');
|
||||
}
|
||||
|
||||
function postUserEventMessage(client: MessagePort, msgData: UserEventMessage) {
|
||||
logDebug('postUserEventMessage', msgData);
|
||||
const msg: WorkerInboundMessage = {msgType: 'user-event', msgData};
|
||||
client.postMessage(msg);
|
||||
}
|
||||
|
||||
function postWorkerEventMessage(client: MessagePort, msgData: WorkerEventMessage) {
|
||||
logDebug('postWorkerEventMessage', msgData);
|
||||
const msg: WorkerInboundMessage = {msgType: 'worker-event', msgData};
|
||||
client.postMessage(msg);
|
||||
}
|
||||
|
||||
class Source {
|
||||
url: string;
|
||||
clients: Array<MessagePort>;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
this.clients = [];
|
||||
}
|
||||
|
||||
register(port: MessagePort) {
|
||||
if (this.clients.includes(port)) return;
|
||||
this.clients.push(port);
|
||||
}
|
||||
|
||||
deregister(port: MessagePort) {
|
||||
const portIdx = this.clients.indexOf(port);
|
||||
if (portIdx < 0) {
|
||||
return this.clients.length;
|
||||
}
|
||||
this.clients.splice(portIdx, 1);
|
||||
return this.clients.length;
|
||||
}
|
||||
|
||||
notifyClientsUserEvent(event: UserEventMessage) {
|
||||
for (const client of this.clients) {
|
||||
postUserEventMessage(client, event);
|
||||
}
|
||||
}
|
||||
notifyClientsWorkerEvent(event: WorkerEventMessage) {
|
||||
for (const client of this.clients) {
|
||||
postWorkerEventMessage(client, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class WsSource {
|
||||
wsUrl: string;
|
||||
ws: WebSocket | null;
|
||||
source: Source;
|
||||
reconnectTimer: ReturnType<typeof setTimeout> | null;
|
||||
reconnectDelay: number;
|
||||
failuresWithoutConnect: number;
|
||||
fallbackSignalled: boolean;
|
||||
closed: boolean;
|
||||
|
||||
constructor(wsUrl: string, source: Source) {
|
||||
this.wsUrl = wsUrl;
|
||||
this.source = source;
|
||||
this.ws = null;
|
||||
this.reconnectTimer = null;
|
||||
this.reconnectDelay = 1000;
|
||||
this.failuresWithoutConnect = 0;
|
||||
this.fallbackSignalled = false;
|
||||
this.closed = false;
|
||||
this.connect();
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.closed) return;
|
||||
this.ws = new WebSocket(this.wsUrl);
|
||||
|
||||
this.ws.addEventListener('open', () => {
|
||||
this.reconnectDelay = 1000;
|
||||
this.failuresWithoutConnect = 0;
|
||||
// Pushes fired while no client was subscribed (initial connect gap, or a
|
||||
// reconnect window) are dropped server-side, so tell clients to reconcile
|
||||
// their state from the server on every fresh connection.
|
||||
this.source.notifyClientsUserEvent({eventType: 'worker-connected'});
|
||||
});
|
||||
|
||||
this.ws.addEventListener('message', (event: MessageEvent<string>) => {
|
||||
try {
|
||||
const msg: unknown = JSON.parse(event.data);
|
||||
logDebug('websocket message', event.data);
|
||||
if (!isServerEventMessage(msg)) {
|
||||
console.error('websocket message is not a valid server user event', msg);
|
||||
return;
|
||||
}
|
||||
this.source.notifyClientsUserEvent(msg);
|
||||
} catch (err) {
|
||||
console.error('user-events: dropping malformed WebSocket message', err);
|
||||
}
|
||||
});
|
||||
|
||||
// `error` always fires before `close` on a failed connection, so we count
|
||||
// failures and schedule reconnects from `close` only — otherwise the
|
||||
// fallback threshold would trip after two real failures instead of three.
|
||||
this.ws.addEventListener('error', () => {
|
||||
this.ws = null;
|
||||
});
|
||||
|
||||
this.ws.addEventListener('close', (event: CloseEvent) => {
|
||||
this.ws = null;
|
||||
if (this.closed) return;
|
||||
// Server signals an expired/missing session via the IANA "Unauthorized" close code; reconnecting can't recover that.
|
||||
if (event.code === 3000) {
|
||||
this.closed = true;
|
||||
return;
|
||||
}
|
||||
this.failuresWithoutConnect++;
|
||||
this.maybeSignalFallback();
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
}
|
||||
|
||||
maybeSignalFallback() {
|
||||
if (this.fallbackSignalled) return;
|
||||
if (this.failuresWithoutConnect < 3) return;
|
||||
this.fallbackSignalled = true;
|
||||
this.source.notifyClientsUserEvent({eventType: 'worker-unavailable'});
|
||||
}
|
||||
|
||||
scheduleReconnect() {
|
||||
if (this.reconnectTimer !== null) return;
|
||||
// Jitter 50%–150% of base delay to prevent thundering-herd reconnects after a server restart.
|
||||
const delay = this.reconnectDelay * (0.5 + Math.random());
|
||||
logDebug(`scheduling reconnect in ${delay}ms`);
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
logDebug(`reconnecting ...`);
|
||||
this.reconnectTimer = null;
|
||||
this.connect();
|
||||
}, delay);
|
||||
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 60000);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
const sourcesByUrl = new Map<string, Source>();
|
||||
const sourcesByPort = new Map<MessagePort, Source>();
|
||||
const wsSourcesByUrl = new Map<string, WsSource>();
|
||||
|
||||
(self as unknown as SharedWorkerGlobalScope).addEventListener('connect', (e: MessageEvent) => {
|
||||
for (const port of e.ports) {
|
||||
port.addEventListener('message', (event: MessageEvent<SharedWorkerControlMessage>) => {
|
||||
if (event.data.type === 'start') {
|
||||
logDebug('received control message start', event.data);
|
||||
showDebugLog = event.data.showDebugLog;
|
||||
const url = event.data.url;
|
||||
let source = sourcesByUrl.get(url);
|
||||
if (source) {
|
||||
// we have a Source registered to this url
|
||||
source.register(port);
|
||||
sourcesByPort.set(port, source);
|
||||
// A port attaching to an already-open socket won't observe the next
|
||||
// "open", so replay "worker-connected" to it directly; otherwise a late tab
|
||||
// never learns the stream is live (mirrors the SSE sharedworker replaying
|
||||
// its built-in "open" event to late-attaching ports).
|
||||
const openWs = wsSourcesByUrl.get(url);
|
||||
if (openWs?.ws?.readyState === WebSocket.OPEN) {
|
||||
postUserEventMessage(port, {eventType: 'worker-connected'});
|
||||
}
|
||||
return;
|
||||
}
|
||||
source = sourcesByPort.get(port);
|
||||
if (source) {
|
||||
if (source.url === url) return;
|
||||
|
||||
// How this has happened I don't understand...
|
||||
// deregister from that source
|
||||
const count = source.deregister(port);
|
||||
// Clean-up
|
||||
if (count === 0) {
|
||||
sourcesByUrl.delete(source.url);
|
||||
const ws = wsSourcesByUrl.get(source.url);
|
||||
if (ws) {
|
||||
ws.close();
|
||||
wsSourcesByUrl.delete(source.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Create a new Source and its WebSocket transport
|
||||
source = new Source(url);
|
||||
source.register(port);
|
||||
sourcesByUrl.set(url, source);
|
||||
sourcesByPort.set(port, source);
|
||||
wsSourcesByUrl.set(url, new WsSource(url, source));
|
||||
} else if (event.data.type === 'close') {
|
||||
logDebug('received control message close', event.data);
|
||||
const source = sourcesByPort.get(port);
|
||||
if (!source) return;
|
||||
|
||||
const count = source.deregister(port);
|
||||
sourcesByPort.delete(port);
|
||||
if (count === 0) {
|
||||
sourcesByUrl.delete(source.url);
|
||||
const ws = wsSourcesByUrl.get(source.url);
|
||||
if (ws) {
|
||||
ws.close();
|
||||
wsSourcesByUrl.delete(source.url);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// just send it back
|
||||
console.error('received control message unknown', event.data);
|
||||
postWorkerEventMessage(port, {workerEvent: 'error', message: `received but don't know how to handle: ${JSON.stringify(event.data)}`});
|
||||
}
|
||||
});
|
||||
port.start();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user