Skip to content

feat(websocket): Phase 1 & 2 — replace SSE notification count with WebSocket#36965

Open
mohammad-rj wants to merge 40 commits intogo-gitea:mainfrom
mohammad-rj:feat/websocket-migration
Open

feat(websocket): Phase 1 & 2 — replace SSE notification count with WebSocket#36965
mohammad-rj wants to merge 40 commits intogo-gitea:mainfrom
mohammad-rj:feat/websocket-migration

Conversation

@mohammad-rj
Copy link
Copy Markdown

@mohammad-rj mohammad-rj commented Mar 23, 2026

Summary

Completes the SSE → WebSocket migration proposed in #36942.

Phase 1 — Infrastructure + notification count

Type File Description
New services/pubsub/broker.go In-memory fan-out pubsub broker with DefaultBroker singleton
New services/websocket/notifier.go Polls GetUIDsAndNotificationCounts, publishes {"type":"notification-count","count":N} to broker. Registers wsNotifier so NotificationCountChange fires immediately on DB write
New services/websocket/notification_notifier.go Implements notify.Notifier (NotificationCountChange); called by uinotification after each DB write for targeted immediate push
New routers/web/websocket/websocket.go /-/ws endpoint — accepts anonymous and signed-in connections; signed-in users subscribe to user-{id} topic, anonymous users stay connected for future public event types
Modified routers/init.go Register single websocket_service.Init() (replaces separate Init + InitStopwatch calls)
Modified routers/web/web.go Add GET /-/ws route
Modified web_src/js/features/eventsource.sharedworker.ts Add WsSource class alongside existing Source — one connection per origin shared across tabs, exponential backoff reconnect (50ms → 10s max)
Modified services/notify/notifier.go Add NotificationCountChange(ctx, userID) to Notifier interface
Modified services/notify/null.go Add no-op NotificationCountChange to NullNotifier
Modified services/notify/notify.go Add NotificationCountChange dispatch function
Modified services/uinotification/notify.go Call NotificationCountChange after DB write when ReceiverID != 0

Phase 2 — Remove remaining SSE, complete migration

New files:

File Description
services/websocket/stopwatch_notifier.go Polls active stopwatches for broadcast; exposes PublishStopwatchesForUser for immediate push on start/stop/cancel
services/websocket/logout_publisher.go Publishes logout events with session ID for here/elsewhere distinction
tests/e2e/events.test.ts E2E tests: notification count, stopwatch at page load, stopwatch via real-time push, logout propagation

Modified files:

File Change
routers/web/websocket/websocket.go Add routing.MarkLongPolling; add rewriteLogout — rewrites raw session ID to "here"/"elsewhere"
routers/web/events/events.go Remove stopwatch and logout event handling from SSE
routers/web/repo/issue_stopwatch.go Call PublishStopwatchesForUser on start/stop/cancel for immediate push to all open tabs
routers/web/web.go Remove /user/events SSE route
routers/common/blockexpensive.go Update long-polling path from /user/events to /-/ws
routers/common/qos.go Same — release WS connections from QoS limiter
routers/web/auth/auth.go Replace eventsource import with websocket for logout
services/user/user.go Same — logout uses websocket publisher
modules/eventsource/ (5 files) Remove stopwatch/logout handling, keep only event primitives
tests/integration/eventsource_test.go Deleted — replaced by e2e tests
web_src/js/features/eventsource.sharedworker.ts Remove SSE EventSource, keep only WsSource for WebSocket
web_src/js/features/stopwatch.ts Use UserEventsSharedWorker for WS events; silent POST handlers for navbar popup and issue sidebar
web_src/js/features/notification.ts Use UserEventsSharedWorker instead of raw SharedWorker
web_src/js/modules/worker.ts UserEventsSharedWorker class — shared abstraction for notification and stopwatch listeners

Stopwatch multi-tab fix

The stopwatch icon previously only appeared in the tab where the timer was started. This was because the navbar icon and popup were conditionally rendered with {{if $activeStopwatch}}, so tabs loaded before the stopwatch started had no DOM element for the JS to update.

File Fix
templates/base/head_navbar_icons.tmpl Always render .active-stopwatch with tw-hidden when inactive
templates/base/head_navbar.tmpl Always render .active-stopwatch-popup outside conditional
templates/repo/issue/sidebar/stopwatch_timetracker.tmpl Render both start and stop button groups, toggle with tw-hidden
web_src/js/features/stopwatch.ts onShow re-clones popup content; delegated handlers for silent POST (no page reload on start/stop/cancel)

Design decisions

  • Single SharedWorkerWsSource lives inside eventsource.sharedworker.ts. One connection per origin, shared across all tabs.
  • github.com/coder/websocket — lightweight, actively maintained WebSocket library.
  • Custom pubsub broker — Gitea internal queue has FIFO/single-consumer semantics; fan-out broadcast needs different semantics. Redis-backed implementation is a natural follow-up for multi-node setups.
  • NotificationCountChange hook — targeted immediate push on DB write; the periodic poller acts as fallback for the ReceiverID==0 (all-watchers) case.
  • Anonymous WebSocket connections — accepted without auth; only signed-in users subscribe to user-specific topics, leaving the endpoint ready for future public event types.
  • JSON signals — notification count, stopwatch state, and logout are all scalar/simple values, trivially JSON.

What's removed

  • modules/eventsource — stopwatch polling, logout broadcasting, SSE manager run loop
  • /user/events SSE route
  • tests/integration/eventsource_test.go
  • eventsource.sharedworker.ts EventSource class (only WsSource remains)

AI disclosure

Parts of this PR were developed with AI assistance (Claude).

Closes #36942.

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 go-gitea#36942.
@GiteaBot GiteaBot added the lgtm/need 2 This PR needs two approvals by maintainers to be considered for merging. label Mar 23, 2026
@github-actions github-actions bot added modifies/go Pull requests that update Go code modifies/frontend labels Mar 23, 2026
Replace timeutil.TimeStampNow() calls in the websocket notifier with a
nowTS() helper that reads time.Now().Unix() directly. TimeStampNow reads
a package-level mock variable that TestIncomingEmail writes concurrently,
causing a race detected by the race detector in test-pgsql CI.
assets/go-licenses.json was missing the license entry for the newly
added github.com/coder/websocket dependency. Running make tidy
regenerates this file via build/generate-go-licenses.go.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements Phase 1 of the SSE → WebSocket migration for real-time notification count updates by introducing a WebSocket endpoint plus a SharedWorker client, backed by an in-memory fan-out pubsub broker.

Changes:

  • Add an in-memory pubsub broker (DefaultBroker) and a polling notifier that publishes {"type":"notification-count","count":N} per user.
  • Add GET /-/ws WebSocket endpoint that authenticates the signed-in user and streams broker messages.
  • Add a WebSocket SharedWorker entry + client wiring to replace the SSE SharedWorker path for notification count updates.

Reviewed changes

Copilot reviewed 11 out of 12 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
services/pubsub/broker.go New in-memory fan-out pubsub broker used to broadcast per-user events
services/websocket/notifier.go Polls notification counts and publishes JSON events to the broker
routers/web/websocket/websocket.go Implements /-/ws WebSocket upgrade + message forwarding from broker
services/context/response.go Adds Hijack() forwarding to support WebSocket upgrades
routers/init.go Registers the new websocket service init
routers/web/web.go Registers the new GET /-/ws route
web_src/js/features/websocket.sharedworker.ts New SharedWorker maintaining one WS connection per URL with reconnect/backoff
web_src/js/features/notification.ts Switch notification count updates from SSE worker to WS worker
webpack.config.ts Adds the SharedWorker entrypoint to the build
go.mod / go.sum Adds github.com/coder/websocket as a direct dependency
assets/go-licenses.json Adds the license entry for github.com/coder/websocket

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

- Move /-/ws route inside reqSignIn middleware group; remove manual
  ctx.IsSigned check from handler (auth is now enforced by the router)
- Fix scheduleReconnect() to schedule using current delay then double,
  so first reconnect fires after 50ms not 100ms (reported by silverwind)
- Replace sourcesByPort.set(port, null) with delete() to prevent
  MessagePort retention after tab close (memory leak fix)
- Centralize topic naming in pubsub.UserTopic() — removes duplication
  between the notifier and the WebSocket handler
- Skip DB polling in notifier when broker has no active subscribers
  to avoid unnecessary load on idle instances
- Hold RLock for the full Publish fan-out loop to prevent a race
  where cancel() closes a channel between slice read and send
@mohammad-rj
Copy link
Copy Markdown
Author

All review feedback addressed in the latest commit:

  • Auth: moved /-/ws inside a reqSignIn middleware group in web.go; removed the manual ctx.IsSigned check from the handler
  • Reconnect delay: fixed scheduleReconnect() to schedule with the current delay first, then double — first reconnect now fires after 50 ms as documented
  • Memory leak: replaced sourcesByPort.set(port, null) with sourcesByPort.delete(port) (and sourcesByUrl.delete) to release MessagePort references after tab close
  • Topic naming: centralized in pubsub.UserTopic() — notifier and handler now share one source of truth
  • Idle DB load: notifier now skips GetUIDsAndNotificationCounts when the broker has no active subscribers
  • Broker race: Publish now holds the RLock for the full fan-out loop, preventing a panic on send-to-closed-channel if cancel() races with the send

Re the sharedworker consolidation (#36896): understood, will rebase and merge the WS logic into the single sharedworker.ts once that lands.

reqSignIn sends a 303 redirect which breaks WebSocket upgrade; use the
same pattern as /user/events: register the route without middleware and
return 401 inside the handler when the user is not signed in.

Also fix copyright year to 2026 in all three new Go files and add a
console.warn for malformed JSON in the SharedWorker.
… worker

- Remove `export {}` which caused webpack to tree-shake the entire
  SharedWorker bundle, resulting in an empty JS file with no connect
  handler — root cause of WebSocket never opening
- Rename SharedWorker instance from 'notification-worker' to
  'notification-worker-ws' to force browser to create a fresh worker
  instance instead of reusing a cached empty one
@silverwind
Copy link
Copy Markdown
Member

All review feedback addressed in the latest commit:

Please click "resolve" on the resolved review comments above :)

- Add export{} to declare websocket.sharedworker.ts as an ES module,
  preventing TypeScript TS2451 redeclaration errors caused by global
  scope conflicts with eventsource.sharedworker.ts
- Always delete port from sourcesByPort on close regardless of remaining
  subscriber count, preventing MessagePort keys from leaking in the Map
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 go-gitea#36942.
Replace timeutil.TimeStampNow() calls in the websocket notifier with a
nowTS() helper that reads time.Now().Unix() directly. TimeStampNow reads
a package-level mock variable that TestIncomingEmail writes concurrently,
causing a race detected by the race detector in test-pgsql CI.
assets/go-licenses.json was missing the license entry for the newly
added github.com/coder/websocket dependency. Running make tidy
regenerates this file via build/generate-go-licenses.go.
- Move /-/ws route inside reqSignIn middleware group; remove manual
  ctx.IsSigned check from handler (auth is now enforced by the router)
- Fix scheduleReconnect() to schedule using current delay then double,
  so first reconnect fires after 50ms not 100ms (reported by silverwind)
- Replace sourcesByPort.set(port, null) with delete() to prevent
  MessagePort retention after tab close (memory leak fix)
- Centralize topic naming in pubsub.UserTopic() — removes duplication
  between the notifier and the WebSocket handler
- Skip DB polling in notifier when broker has no active subscribers
  to avoid unnecessary load on idle instances
- Hold RLock for the full Publish fan-out loop to prevent a race
  where cancel() closes a channel between slice read and send
reqSignIn sends a 303 redirect which breaks WebSocket upgrade; use the
same pattern as /user/events: register the route without middleware and
return 401 inside the handler when the user is not signed in.

Also fix copyright year to 2026 in all three new Go files and add a
console.warn for malformed JSON in the SharedWorker.
… worker

- Remove `export {}` which caused webpack to tree-shake the entire
  SharedWorker bundle, resulting in an empty JS file with no connect
  handler — root cause of WebSocket never opening
- Rename SharedWorker instance from 'notification-worker' to
  'notification-worker-ws' to force browser to create a fresh worker
  instance instead of reusing a cached empty one
mohammad-rj and others added 8 commits April 2, 2026 00:54
… starts

The stopwatch navbar icon and popup were only rendered by the server
when a stopwatch was already active at page load.  If a tab was opened
before the stopwatch started, `initStopwatch()` found no
`.active-stopwatch` element in the DOM, returned early, and never
registered a SharedWorker listener.  As a result the WebSocket push
from the stopwatch notifier had nowhere to land and the icon never
appeared.

Fix by always rendering both the icon anchor and the popup skeleton in
the navbar (hidden with `tw-hidden` when no stopwatch is active).
`initStopwatch()` can now set up the SharedWorker in every tab, and
`updateStopwatchData` can call `showElem`/`hideElem` as stopwatch state
changes arrive in real time.

Also add `onShow` to `createTippy` so the popup content is re-cloned
from the (JS-updated) original each time the tooltip opens, keeping
it current even when the stopwatch was started after page load.

Add a new e2e test (`stopwatch appears via real-time push`) that
verifies the icon appears after `apiStartStopwatch` is called with
the page already loaded.
…bar popup

The navbar popup stop/cancel forms used form-fetch-action, which always
reloads the page when the server returns an empty redirect. Remove that
class and add a dedicated delegated submit handler in stopwatch.ts that
POSTs the action silently; the WebSocket push (or periodic poller) then
updates the icon without any navigation.
… sidebar

Replace link-action buttons in the sidebar time-tracker with plain
buttons handled by a delegated click listener in stopwatch.ts. After a
successful POST the two button groups (.issue-start-buttons /
.issue-stop-cancel-buttons) are toggled immediately via show/hide,
so the page never reloads. The navbar icon continues to update via
the WebSocket push or periodic poller as before.
The shared worker no longer emits 'no-event-source' messages since the
EventSource transport was removed. Clean up the unreachable branches
in both notification.ts and stopwatch.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This Gitea Actions workflow was used for local compliance testing on
git.epid.co and should not be part of the upstream PR.
…i-phase2-push

# Conflicts:
#	routers/init.go
#	routers/web/web.go
#	routers/web/websocket/websocket.go
#	web_src/js/features/eventsource.sharedworker.ts
@github-actions github-actions bot added the modifies/templates This PR modifies the template files label Apr 2, 2026
# Conflicts:
#	templates/base/head_navbar_icons.tmpl
@mohammad-rj
Copy link
Copy Markdown
Author

How feasible is it do all phases here? I think I'd prefer that instead of temporarily having only 1 mechanism use websocket.

Done — Phase 2 is now pushed. SSE is fully removed: stopwatches, logout, and notification count all go through WebSocket now. The /user/events SSE route is gone, modules/eventsource is stripped down to just the event primitives.

Also fixed a pre-existing issue where the stopwatch icon only appeared in the tab that started the timer — now it shows in all tabs via WS push.

The e2e tests cover: notification count, stopwatch at page load, stopwatch via real-time push, and logout propagation.

@mohammad-rj mohammad-rj marked this pull request as ready for review April 2, 2026 08:46
@mohammad-rj mohammad-rj changed the title feat(websocket): Phase 1 — replace SSE notification count with WebSocket feat(websocket): Phase 1 & 2 — replace SSE notification count with WebSocket Apr 2, 2026
@silverwind
Copy link
Copy Markdown
Member

FYI htmx removal is in progress at #37079, don't think you have any touching points with it here but I recall it was mentioned during the initial discussion.

@mohammad-rj
Copy link
Copy Markdown
Author

FYI htmx removal is in progress at #37079, don't think you have any touching points with it here but I recall it was mentioned during the initial discussion.

Thanks for the heads up. Checked, no htmx usage in our changes.

…ous connections

- Merge Init and InitStopwatch into a single Init() so the service has
  one entry point; remove the separate mustInit call from routers/init.go
- Add NotificationCountChange to the notify.Notifier interface and
  implement it in a new wsNotifier; uinotification calls it after each
  DB write so connected clients get an immediate push instead of waiting
  for the next polling tick
- Replace PublishEmptyStopwatches with PublishStopwatchesForUser which
  fetches and publishes the current list; call it on start/stop/cancel
  so all open tabs update without waiting for the next tick
- Accept anonymous WebSocket connections (remove 401 early-return); only
  signed-in users subscribe to user-specific topics, leaving the endpoint
  ready for future public event types
- Add routing.MarkLongPolling at the start of Serve() so the request
  logger treats WS connections consistently with SSE
// pushes it immediately to all connected WebSocket clients, bypassing the
// periodic polling loop for this specific user.
func (n *wsNotifier) NotificationCountChange(ctx context.Context, userID int64) {
count, err := db.Count[activities_model.Notification](ctx, activities_model.FindNotificationOptions{
Copy link
Copy Markdown
Member

@lunny lunny Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's better to check whether the user is in the connection pool, otherwise we don't need to do anything.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pub/sub system should not behave like a traditional persistent pub/sub model. If an event is fired when no clients are subscribed, it should be discarded immediately rather than stored and delivered later when a client comes online. It should function as a runtime-only pub/sub system. When a new client connects, the page should load the latest state directly from the database, so replaying missed events is unnecessary.

} else if (event.data.type === 'listen') {
const source = sourcesByPort.get(port)!;
source.listen(event.data.eventType);
const wsUrl = url.replace(/^http/, 'ws').replace(/\/user\/events$/, '/-/ws');
Copy link
Copy Markdown
Member

@silverwind silverwind Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use this.wsUrl instead of clumsy replace?

});

test('stopwatch appears via real-time push', async ({page, request}) => {
const name = `ev-sw-push-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use randomString added in d80640f.


// Start stopwatch after page is loaded — icon should appear via WebSocket push
await apiStartStopwatch(request, name, name, 1, {headers});
await expect(stopwatch).toBeVisible({timeout: 15000});
Copy link
Copy Markdown
Member

@silverwind silverwind Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

15000 * timeoutFactor, check if it actually needs to be that high. The number 15000 should be roughly 3 times what it takes on a modern machine, so if it takes 5000 on your machine, it is correct.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lgtm/need 2 This PR needs two approvals by maintainers to be considered for merging. modifies/dependencies modifies/frontend modifies/go Pull requests that update Go code modifies/templates This PR modifies the template files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Replace EventSource/SSE with WebSocket — RFC & Implementation Plan

6 participants