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:
mohammad rahimi
2026-07-27 07:46:00 +00:00
committed by GitHub
co-authored by silverwind wxiaoguang Epid
parent e15a7e9066
commit 13d0f24423
77 changed files with 2068 additions and 1149 deletions
+26 -21
View File
@@ -69,25 +69,30 @@ func (opts FindNotificationOptions) ToOrders() string {
// CreateOrUpdateIssueNotifications creates an issue notification
// for each watcher, or updates it if already exists
// receiverID > 0 just send to receiver, else send to all watcher
func CreateOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, notificationAuthorID, receiverID int64) error {
return db.WithTx(ctx, func(ctx context.Context) error {
return createOrUpdateIssueNotifications(ctx, issueID, commentID, notificationAuthorID, receiverID)
// Returns the set of user IDs whose notification rows were created or updated.
func CreateOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, notificationAuthorID, receiverID int64) ([]int64, error) {
var notifiedIDs []int64
err := db.WithTx(ctx, func(ctx context.Context) error {
var innerErr error
notifiedIDs, innerErr = createOrUpdateIssueNotifications(ctx, issueID, commentID, notificationAuthorID, receiverID)
return innerErr
})
return notifiedIDs, err
}
func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, notificationAuthorID, receiverID int64) error {
func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, notificationAuthorID, receiverID int64) ([]int64, error) {
// init
var toNotify container.Set[int64]
notifications, err := db.Find[Notification](ctx, FindNotificationOptions{
IssueID: issueID,
})
if err != nil {
return err
return nil, err
}
issue, err := issues_model.GetIssueByID(ctx, issueID)
if err != nil {
return err
return nil, err
}
if receiverID > 0 {
@@ -97,19 +102,19 @@ func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n
toNotify = make(container.Set[int64], 32)
issueWatches, err := issues_model.GetIssueWatchersIDs(ctx, issueID, true)
if err != nil {
return err
return nil, err
}
toNotify.AddMultiple(issueWatches...)
if !(issue.IsPull && issues_model.HasWorkInProgressPrefix(issue.Title)) {
repoWatches, err := repo_model.GetRepoWatchersIDs(ctx, issue.RepoID)
if err != nil {
return err
return nil, err
}
toNotify.AddMultiple(repoWatches...)
}
issueParticipants, err := issue.GetParticipantIDsByIssue(ctx)
if err != nil {
return err
return nil, err
}
toNotify.AddMultiple(issueParticipants...)
@@ -118,19 +123,19 @@ func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n
// explicit unwatch on issue
issueUnWatches, err := issues_model.GetIssueWatchersIDs(ctx, issueID, false)
if err != nil {
return err
return nil, err
}
for _, id := range issueUnWatches {
toNotify.Remove(id)
}
}
err = issue.LoadRepo(ctx)
if err != nil {
return err
if err := issue.LoadRepo(ctx); err != nil {
return nil, err
}
// notify
notifiedIDs := make([]int64, 0, len(toNotify))
for userID := range toNotify {
issue.Repo.Units = nil
user, err := user_model.GetUserByID(ctx, userID)
@@ -139,7 +144,7 @@ func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n
continue
}
return err
return nil, err
}
if issue.IsPull && !access_model.CheckRepoUnitUser(ctx, issue.Repo, user, unit.TypePullRequests) {
continue
@@ -149,16 +154,16 @@ func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n
}
if notificationExists(notifications, issue.ID, userID) {
if err = updateIssueNotification(ctx, userID, issue.ID, commentID, notificationAuthorID); err != nil {
return err
}
continue
err = updateIssueNotification(ctx, userID, issue.ID, commentID, notificationAuthorID)
} else {
err = createIssueNotification(ctx, userID, issue, commentID, notificationAuthorID)
}
if err = createIssueNotification(ctx, userID, issue, commentID, notificationAuthorID); err != nil {
return err
if err != nil {
return nil, err
}
notifiedIDs = append(notifiedIDs, userID)
}
return nil
return notifiedIDs, nil
}
// NotificationList contains a list of notifications