mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-01 01:20:17 +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:
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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() {
|
||||
|
||||
@@ -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`);
|
||||
|
||||
Vendored
-1
@@ -44,7 +44,6 @@ interface Window {
|
||||
MinTimeout: number,
|
||||
TimeoutStep: number,
|
||||
MaxTimeout: number,
|
||||
EventSourceUpdateTime: number,
|
||||
},
|
||||
enableTimeTracking: boolean,
|
||||
mermaidMaxSourceCharacters: number,
|
||||
|
||||
@@ -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
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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'};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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: {},
|
||||
|
||||
Reference in New Issue
Block a user