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 10:46:00 +03:00
committed by GitHub
parent e15a7e9066
commit 13d0f24423
77 changed files with 2070 additions and 1151 deletions
-150
View File
@@ -1,150 +0,0 @@
class Source {
url: string;
eventSource: EventSource | null;
listening: Record<string, boolean>;
clients: Array<MessagePort>;
constructor(url: string) {
this.url = url;
this.eventSource = new EventSource(url);
this.listening = {};
this.clients = [];
this.listen('open');
this.listen('close');
this.listen('logout');
this.listen('notification-count');
this.listen('stopwatches');
this.listen('error');
}
register(port: MessagePort) {
if (this.clients.includes(port)) return;
this.clients.push(port);
port.postMessage({
type: 'status',
message: `registered to ${this.url}`,
});
// replay the "open" event to ports attaching to an already-open source
if (this.eventSource?.readyState === EventSource.OPEN) {
port.postMessage({type: 'open'});
}
}
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;
}
close() {
if (!this.eventSource) return;
this.eventSource.close();
this.eventSource = null;
}
listen(eventType: string) {
if (this.listening[eventType]) return;
this.listening[eventType] = true;
this.eventSource?.addEventListener(eventType, (event) => {
this.notifyClients({
type: eventType,
data: event.data,
});
});
}
notifyClients(event: {type: string, data: any}) {
for (const client of this.clients) {
client.postMessage(event);
}
}
status(port: MessagePort) {
port.postMessage({
type: 'status',
message: `url: ${this.url} readyState: ${this.eventSource?.readyState}`,
});
}
}
const sourcesByUrl = new Map<string, Source | null>();
const sourcesByPort = new Map<MessagePort, Source | null>();
(self as unknown as SharedWorkerGlobalScope).addEventListener('connect', (e: MessageEvent) => {
for (const port of e.ports) {
port.addEventListener('message', (event: MessageEvent) => {
if (!self.EventSource) {
// some browsers (like PaleMoon, Firefox<53) don't support EventSource in SharedWorkerGlobalScope.
// this event handler needs EventSource when doing "new Source(url)", so just post a message back to the caller,
// in case the caller would like to use a fallback method to do its work.
port.postMessage({type: 'no-event-source'});
return;
}
if (event.data.type === 'start') {
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);
return;
}
source = sourcesByPort.get(port);
if (source) {
if (source.eventSource && 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) {
source.close();
sourcesByUrl.set(source.url, null);
}
}
// Create a new Source
source = new Source(url);
source.register(port);
sourcesByUrl.set(url, source);
sourcesByPort.set(port, source);
} else if (event.data.type === 'listen') {
const source = sourcesByPort.get(port)!;
source.listen(event.data.eventType);
} 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;
}
source.status(port);
} else {
// just send it back
port.postMessage({
type: 'error',
message: `received but don't know how to handle: ${event.data}`,
});
}
});
port.start();
}
});
+15 -28
View File
@@ -1,51 +1,38 @@
import {GET} from '../modules/fetch.ts';
import {toggleElem, createElementFromHTML} from '../utils/dom.ts';
import {UserEventsSharedWorker} from '../modules/worker.ts';
import {onUserEvent} from '../modules/worker.ts';
const {appSubUrl, notificationSettings} = window.config;
let notificationSequenceNumber = 0;
async function receiveUpdateCount(event: MessageEvent<{type: string, data: string}>) {
try {
const data = JSON.parse(event.data.data);
for (const count of document.querySelectorAll('.notification_count')) {
count.classList.toggle('tw-hidden', data.Count === 0);
count.textContent = String(data.Count);
}
await updateNotificationTable();
} catch (error) {
console.error(error, event);
async function receiveUpdateCount(count: number) {
toggleElem('.notification_count', count !== 0);
for (const el of document.querySelectorAll('.notification_count')) {
el.textContent = String(count);
}
await updateNotificationTable();
}
export function initNotificationCount() {
if (!document.querySelector('.notification_count')) return;
let usingPeriodicPoller = false;
const startPeriodicPoller = (timeout: number, lastCount?: number) => {
if (timeout <= 0 || !Number.isFinite(timeout)) return;
usingPeriodicPoller = true;
lastCount = lastCount ?? getCurrentCount();
setTimeout(async () => {
await updateNotificationCountWithCallback(startPeriodicPoller, timeout, lastCount);
}, timeout);
};
if (notificationSettings.EventSourceUpdateTime > 0 && window.EventSource && window.SharedWorker) {
// Try to connect to the event source via the shared worker first
const worker = new UserEventsSharedWorker('notification-worker');
worker.addMessageEventListener((event: MessageEvent) => {
if (event.data.type === 'no-event-source') {
if (!usingPeriodicPoller) startPeriodicPoller(notificationSettings.MinTimeout);
} else if (event.data.type === 'notification-count') {
receiveUpdateCount(event); // no await
}
});
worker.startPort();
return;
}
startPeriodicPoller(notificationSettings.MinTimeout);
let pollerStarted = false;
onUserEvent('notification-count', (msg) => { receiveUpdateCount(msg.eventData.count) }); // no await
// On each (re)connect, reconcile the count from the server to recover any push dropped during the connect gap.
onUserEvent('worker-connected', () => { updateNotificationCount(); updateNotificationTable() }); // no await
onUserEvent('worker-unavailable', () => {
if (pollerStarted) return;
pollerStarted = true;
startPeriodicPoller(notificationSettings.MinTimeout);
});
}
function getCurrentCount() {
+30 -29
View File
@@ -1,7 +1,8 @@
import {createTippy} from '../modules/tippy.ts';
import {GET} from '../modules/fetch.ts';
import {hideElem, queryElems, showElem} from '../utils/dom.ts';
import {UserEventsSharedWorker} from '../modules/worker.ts';
import {onUserEvent} from '../modules/worker.ts';
import type {StopwatchData} from '../types.ts';
const {appSubUrl, notificationSettings, enableTimeTracking} = window.config;
@@ -17,15 +18,13 @@ export function initStopwatch() {
return;
}
// global stop watch (in the head_navbar), it should always work in any case either the EventSource or the PeriodicPoller is used.
// Init the icon + popup even when no stopwatch is active so a real-time push has a target to toggle.
const seconds = stopwatchEls[0]?.getAttribute('data-seconds');
if (seconds) {
updateStopwatchTime(parseInt(seconds));
}
for (const stopwatchEl of stopwatchEls) {
stopwatchEl.removeAttribute('href'); // intended for noscript mode only
createTippy(stopwatchEl, {
content: stopwatchPopup.cloneNode(true) as Element,
placement: 'bottom-end',
@@ -34,33 +33,28 @@ export function initStopwatch() {
interactive: true,
hideOnClick: true,
theme: 'default',
onShow(instance) {
// Re-clone on every open so the popup reflects the latest stopwatch state,
// including the case where the icon became visible via a real-time push.
instance.setContent(stopwatchPopup.cloneNode(true) as Element);
},
});
}
let usingPeriodicPoller = false;
const startPeriodicPoller = (timeout: number) => {
if (timeout <= 0 || !Number.isFinite(timeout)) return;
usingPeriodicPoller = true;
setTimeout(() => updateStopwatchWithCallback(startPeriodicPoller, timeout), timeout);
};
// if the browser supports EventSource and SharedWorker, use it instead of the periodic poller
if (notificationSettings.EventSourceUpdateTime > 0 && window.EventSource && window.SharedWorker) {
// Try to connect to the event source via the shared worker first
const worker = new UserEventsSharedWorker('stopwatch-worker');
worker.addMessageEventListener((event) => {
if (event.data.type === 'no-event-source') {
// browser doesn't support EventSource, falling back to periodic poller
if (!usingPeriodicPoller) startPeriodicPoller(notificationSettings.MinTimeout);
} else if (event.data.type === 'stopwatches') {
updateStopwatchData(JSON.parse(event.data.data));
}
});
worker.startPort();
return;
}
startPeriodicPoller(notificationSettings.MinTimeout);
let pollerStarted = false;
onUserEvent('stopwatches', (msg) => updateStopwatchData(msg.eventData));
// On each (re)connect, reconcile stopwatch state from the server to recover any push dropped during the connect gap.
onUserEvent('worker-connected', () => { updateStopwatch() }); // no await
onUserEvent('worker-unavailable', () => {
if (pollerStarted) return;
pollerStarted = true;
startPeriodicPoller(notificationSettings.MinTimeout);
});
}
async function updateStopwatchWithCallback(callback: (timeout: number) => void, timeout: number) {
@@ -76,16 +70,22 @@ async function updateStopwatchWithCallback(callback: (timeout: number) => void,
}
async function updateStopwatch() {
const response = await GET(`${appSubUrl}/user/stopwatches`);
if (!response.ok) {
console.error('Failed to fetch stopwatch data');
try {
const response = await GET(`${appSubUrl}/user/stopwatches`);
if (!response.ok) {
console.error('Failed to fetch stopwatch data');
return false;
}
const data = await response.json();
return updateStopwatchData(data);
} catch (error) {
console.error(error);
return false;
}
const data = await response.json();
return updateStopwatchData(data);
}
function updateStopwatchData(data: any) {
function updateStopwatchData(data: Array<StopwatchData>) {
if (!data) return;
const watch = data[0];
const btnEls = document.querySelectorAll('.active-stopwatch');
if (!watch) {
@@ -93,6 +93,7 @@ function updateStopwatchData(data: any) {
} else {
const {repo_owner_name, repo_name, issue_index, seconds} = watch;
const issueUrl = `${appSubUrl}/${repo_owner_name}/${repo_name}/issues/${issue_index}`;
for (const btnEl of btnEls) btnEl.setAttribute('href', issueUrl);
document.querySelector('.stopwatch-link')?.setAttribute('href', issueUrl);
document.querySelector('.stopwatch-commit')?.setAttribute('action', `${issueUrl}/times/stopwatch/stop`);
document.querySelector('.stopwatch-cancel')?.setAttribute('action', `${issueUrl}/times/stopwatch/cancel`);
-1
View File
@@ -44,7 +44,6 @@ interface Window {
MinTimeout: number,
TimeoutStep: number,
MaxTimeout: number,
EventSourceUpdateTime: number,
},
enableTimeTracking: boolean,
mermaidMaxSourceCharacters: number,
+69
View File
@@ -0,0 +1,69 @@
import type {UserEventMessage, WorkerInboundMessage} from '../types.ts';
// Minimal SharedWorker/MessagePort doubles: worker.ts wires user events onto
// `sharedWorker.port`, so we capture the port to feed it messages and assert dispatch behavior.
type PortListener = (ev: {data: WorkerInboundMessage}) => void;
class MockMessagePort {
listeners: Record<string, PortListener[]> = {};
posted: unknown[] = [];
addEventListener(type: string, cb: PortListener) {
(this.listeners[type] ||= []).push(cb);
}
removeEventListener() {}
postMessage(msg: unknown) { this.posted.push(msg) }
start() {}
close() {}
// Simulate the underlying worker delivering a message to the page.
deliver(msg: UserEventMessage) {
for (const cb of this.listeners['message'] || []) cb({data: {msgType: 'user-event', msgData: msg}});
}
}
let lastWorker: MockSharedWorker;
class MockSharedWorker {
port = new MockMessagePort();
// eslint-disable-next-line unicorn/no-this-assignment
constructor() { lastWorker = this }
addEventListener() {}
}
// worker.ts caches module-scope state (subscribers, initialized), so re-import
// a fresh module per test after stubbing the globals it reads on init.
async function freshWorker() {
vi.resetModules();
vi.stubGlobal('WebSocket', class {});
vi.stubGlobal('SharedWorker', MockSharedWorker);
return await import('./worker.ts');
}
afterEach(() => {
vi.unstubAllGlobals();
});
// sequential: freshWorker resets the module registry and stubs globals, which is unsafe
// to interleave with the other test under the repo's `sequence.concurrent` vitest config.
// Every push follows a real DB write, so a repeated value still means the
// underlying list changed (e.g. a new comment on an already-unread issue).
test('dispatches every push, including repeated values', {concurrent: false}, async () => {
const {onUserEvent} = await freshWorker();
const received: number[] = [];
onUserEvent('notification-count', (msg) => { received.push(msg.eventData.count) });
lastWorker.port.deliver({eventType: 'notification-count', eventData: {count: 1}});
lastWorker.port.deliver({eventType: 'notification-count', eventData: {count: 1}});
expect(received).toEqual([1, 1]);
});
test('worker-connected flags the page and reaches its subscribers', {concurrent: false}, async () => {
const {onUserEvent} = await freshWorker();
let connects = 0;
onUserEvent('worker-connected', () => { connects++ });
lastWorker.port.deliver({eventType: 'worker-connected'});
expect(connects).toBe(1);
expect(document.documentElement.getAttribute('data-user-events-connected')).toBe('true');
});
+121 -59
View File
@@ -1,68 +1,130 @@
import type {
SharedWorkerControlMessage,
UserEventMessage,
UserEventType,
WorkerEventMessage,
WorkerInboundMessage,
} from '../types.ts';
const {appSubUrl, sharedWorkerUri} = window.config;
export class UserEventsSharedWorker {
sharedWorker: SharedWorker;
type EventOf<T extends UserEventType> = Extract<UserEventMessage, {eventType: T}>;
type Subscriber<T extends UserEventType = UserEventType> = (msg: EventOf<T>) => void;
const subscribers = new Map<UserEventType, Set<Subscriber>>();
let fallbackSignalled = false;
let sharedWorker: SharedWorker | null = null;
// options can be either a string (the debug name of the worker) or an object of type WorkerOptions
constructor(options?: string | WorkerOptions) {
const worker = new SharedWorker(sharedWorkerUri, options);
this.sharedWorker = worker;
worker.addEventListener('error', (event) => {
console.error('worker error', event);
});
worker.port.addEventListener('messageerror', () => {
console.error('unable to deserialize message');
});
worker.port.postMessage({
type: 'start',
url: `${window.location.origin}${appSubUrl}/user/events`,
});
worker.port.addEventListener('error', (e) => {
console.error('worker port error', e);
});
window.addEventListener('beforeunload', () => {
// FIXME: this logic is not quite right.
// "beforeunload" can be canceled by some actions like "are-you-sure" and the navigation can be cancelled.
// In this case: the worker port is incorrectly closed while the page is still there.
worker.port.postMessage({type: 'close'});
worker.port.close();
});
function dispatch(msg: UserEventMessage) {
if (msg.eventType === 'worker-connected') {
document.documentElement.setAttribute('data-user-events-connected', 'true');
}
const set = subscribers.get(msg.eventType);
if (!set) return;
for (const cb of set) cb(msg);
}
addMessageEventListener(listener: (event: MessageEvent) => void) {
this.sharedWorker.port.addEventListener('message', (event: MessageEvent) => {
if (!event.data || !event.data.type) {
console.error('unknown worker message event', event);
function signalFallback() {
if (fallbackSignalled) return;
fallbackSignalled = true;
dispatch({eventType: 'worker-unavailable'});
}
function init() {
try {
sharedWorker = new SharedWorker(sharedWorkerUri, {type: 'module', name: 'user-events'});
} catch (err) {
console.warn('SharedWorker unavailable, falling back to periodic polling', err);
queueMicrotask(signalFallback);
return;
}
// Browsers without module-SharedWorker support fail at parse time, before the WebSocket opens.
sharedWorker.addEventListener('error', (event) => {
console.error('worker error', event);
signalFallback();
});
sharedWorker.port.addEventListener('messageerror', () => {
console.error('unable to deserialize message');
});
sharedWorker.port.addEventListener('error', (e) => {
console.error('worker port error', e);
});
const postSharedWorkerControlMessage = (sw: SharedWorker, msg: SharedWorkerControlMessage) => {
sw.port.postMessage(msg);
};
const handleWorkerEvent = (msg: WorkerEventMessage) => {
if (msg.workerEvent === 'error') {
console.error('worker port event error', msg);
} else if (msg.workerEvent === 'close') {
postSharedWorkerControlMessage(sharedWorker!, {type: 'close'});
sharedWorker!.port.close();
} else {
console.error('unknown worker port event', msg);
}
};
const handleLogout = () => {
postSharedWorkerControlMessage(sharedWorker!, {type: 'close'});
sharedWorker!.port.close();
// slightly delay our "logout" for a short while, in case there are other logout requests in-flight.
// * if the logout is triggered by a page redirection (e.g.: user clicks "/user/logout")
// * "beforeunload" event is triggered, this code path won't execute
// * if the logout is triggered by a fetch call
// * "beforeunload" event is not triggered until JS does the redirection.
// * in this case, the logout fetch call already completes and has sent the "logout" message to the worker
// * there can be a data-race between the fetch call's redirection and the "logout" message from the worker
// * the fetch call's logout redirection should always win over the worker message, because it might have a custom location
setTimeout(() => { window.location.assign(`${appSubUrl}/`) }, 1000);
};
sharedWorker.port.addEventListener('message', (event: MessageEvent<WorkerInboundMessage>) => {
const msg = event.data;
if (msg?.msgType === 'worker-event') {
handleWorkerEvent(msg.msgData);
} else if (msg?.msgType === 'user-event') {
if (msg.msgData?.eventType === 'logout') {
handleLogout();
return;
}
dispatch(msg.msgData);
} else {
console.error('unknown inbound message', msg);
}
});
if (event.data.type === 'error') {
console.error('worker port event error', event.data);
} else if (event.data.type === 'logout') {
if (event.data.data !== 'here') return;
this.sharedWorker.port.postMessage({type: 'close'});
this.sharedWorker.port.close();
// slightly delay our "logout" for a short while, in case there are other logout requests in-flight.
// * if the logout is triggered by a page redirection (e.g.: user clicks "/user/logout")
// * "beforeunload" event is triggered, this code path won't execute
// * if the logout is triggered by a fetch call
// * "beforeunload" event is not triggered until JS does the redirection.
// * in this case, the logout fetch call already completes and has sent the "logout" message to the worker
// * there can be a data-race between the fetch call's redirection and the "logout" message from the worker
// * the fetch call's logout redirection should always win over the worker message, because it might have a custom location
setTimeout(() => { window.location.assign(`${appSubUrl}/`) }, 1000);
} else if (event.data.type === 'close') {
this.sharedWorker.port.postMessage({type: 'close'});
this.sharedWorker.port.close();
} else if (event.data.type === 'open') {
// e2e tests wait for this attribute to know events cannot be missed anymore
document.documentElement.setAttribute('data-user-events-connected', 'true');
}
listener(event);
});
}
startPort() {
this.sharedWorker.port.start();
}
sharedWorker.port.start();
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
postSharedWorkerControlMessage(sharedWorker, {
type: 'start',
url: `${wsProtocol}//${window.location.host}${appSubUrl}/-/ws`,
showDebugLog: !window.config.runModeIsProd,
});
window.addEventListener('beforeunload', () => {
// FIXME: this logic is not quite right.
// "beforeunload" can be canceled by some actions like "are-you-sure" and the navigation can be cancelled.
// In this case: the worker port is incorrectly closed while the page is still there.
postSharedWorkerControlMessage(sharedWorker!, {type: 'close'});
sharedWorker!.port.close();
});
}
let initialized = false;
export function onUserEvent<T extends UserEventType>(type: T, cb: Subscriber<T>): () => void {
if (!initialized) {
initialized = true;
if (window.WebSocket && window.SharedWorker) {
init();
} else {
queueMicrotask(signalFallback);
}
}
let set = subscribers.get(type);
if (!set) {
set = new Set();
subscribers.set(type, set);
}
const wrapped: Subscriber = (msg) => cb(msg as EventOf<T>);
set.add(wrapped);
return () => { set.delete(wrapped) };
}
+35
View File
@@ -63,3 +63,38 @@ export type FomanticInitFunction = {
export type GitRefType = 'branch' | 'tag';
export type Promisable<T> = T | Promise<T>; // stricter than type-fest which uses PromiseLike
export type StopwatchData = {
repo_owner_name: string,
repo_name: string,
issue_index: number,
seconds: number,
};
// keep in sync with services/websocket/events.go
export type ServerUserEventMessage =
{eventType: 'notification-count', eventData: {count: number}} |
{eventType: 'stopwatches', eventData: Array<StopwatchData>} |
{eventType: 'logout'};
export const serverUserEventTypes = ['notification-count', 'stopwatches', 'logout'] as const satisfies ReadonlyArray<ServerUserEventMessage['eventType']>;
export type UserEventMessage = ServerUserEventMessage |
{eventType: 'worker-unavailable'} |
{eventType: 'worker-connected'};
export type UserEventType = UserEventMessage['eventType'];
export type WorkerEventMessage =
{workerEvent: 'error', message: string} |
{workerEvent: 'close'};
export type WorkerInboundMessage =
{msgType: 'user-event', msgData: UserEventMessage} |
{msgType: 'worker-event', msgData: WorkerEventMessage};
export type SharedWorkerControlMessage = {
type: 'start',
url: string,
showDebugLog: boolean,
} | {type: 'close'};
+243
View File
@@ -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();
}
});
+1 -1
View File
@@ -8,7 +8,7 @@ window.config = {
runModeIsProd: true,
customEmojis: {},
pageData: {},
notificationSettings: {MinTimeout: 0, TimeoutStep: 0, MaxTimeout: 0, EventSourceUpdateTime: 0},
notificationSettings: {MinTimeout: 0, TimeoutStep: 0, MaxTimeout: 0},
enableTimeTracking: true,
mermaidMaxSourceCharacters: 5000,
i18n: {},