Files
gitea/web_src/js/user-events.sharedworker.ts
mohammad rahimi 13d0f24423 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>
2026-07-27 07:46:00 +00:00

244 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
}
});