Reconnect on failure, with backoff; Deduping notifications

This commit is contained in:
Philipp Heckel 2022-02-24 09:52:49 -05:00
parent 3fac1c3432
commit 1536201e9a
6 changed files with 62 additions and 45 deletions

View file

@ -1,18 +1,31 @@
import {shortTopicUrl, topicUrlWs} from "./utils";
const retryBackoffSeconds = [5, 10, 15, 20, 30, 45, 60, 120];
class Connection { class Connection {
constructor(wsUrl, subscriptionId, onNotification) { constructor(subscriptionId, baseUrl, topic, since, onNotification) {
this.wsUrl = wsUrl;
this.subscriptionId = subscriptionId; this.subscriptionId = subscriptionId;
this.baseUrl = baseUrl;
this.topic = topic;
this.since = since;
this.shortUrl = shortTopicUrl(baseUrl, topic);
this.onNotification = onNotification; this.onNotification = onNotification;
this.ws = null; this.ws = null;
this.retryCount = 0;
this.retryTimeout = null;
} }
start() { start() {
const socket = new WebSocket(this.wsUrl); const since = (this.since === 0) ? "all" : this.since.toString();
socket.onopen = (event) => { const wsUrl = topicUrlWs(this.baseUrl, this.topic, since);
console.log(`[Connection] [${this.subscriptionId}] Connection established`); console.log(`[Connection, ${this.shortUrl}] Opening connection to ${wsUrl}`);
this.ws = new WebSocket(wsUrl);
this.ws.onopen = (event) => {
console.log(`[Connection, ${this.shortUrl}] Connection established`, event);
this.retryCount = 0;
} }
socket.onmessage = (event) => { this.ws.onmessage = (event) => {
console.log(`[Connection] [${this.subscriptionId}] Message received from server: ${event.data}`); console.log(`[Connection, ${this.shortUrl}] Message received from server: ${event.data}`);
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
const relevantAndValid = const relevantAndValid =
@ -21,31 +34,43 @@ class Connection {
'time' in data && 'time' in data &&
'message' in data; 'message' in data;
if (!relevantAndValid) { if (!relevantAndValid) {
console.log(`[Connection, ${this.shortUrl}] Message irrelevant or invalid. Ignoring.`);
return; return;
} }
this.since = data.time;
this.onNotification(this.subscriptionId, data); this.onNotification(this.subscriptionId, data);
} catch (e) { } catch (e) {
console.log(`[Connection] [${this.subscriptionId}] Error handling message: ${e}`); console.log(`[Connection, ${this.shortUrl}] Error handling message: ${e}`);
} }
}; };
socket.onclose = (event) => { this.ws.onclose = (event) => {
if (event.wasClean) { if (event.wasClean) {
console.log(`[Connection] [${this.subscriptionId}] Connection closed cleanly, code=${event.code} reason=${event.reason}`); console.log(`[Connection, ${this.shortUrl}] Connection closed cleanly, code=${event.code} reason=${event.reason}`);
this.ws = null;
} else { } else {
console.log(`[Connection] [${this.subscriptionId}] Connection died`); const retrySeconds = retryBackoffSeconds[Math.min(this.retryCount, retryBackoffSeconds.length-1)];
this.retryCount++;
console.log(`[Connection, ${this.shortUrl}] Connection died, retrying in ${retrySeconds} seconds`);
this.retryTimeout = setTimeout(() => this.start(), retrySeconds * 1000);
} }
}; };
socket.onerror = (event) => { this.ws.onerror = (event) => {
console.log(this.subscriptionId, `[Connection] [${this.subscriptionId}] ${event.message}`); console.log(`[Connection, ${this.shortUrl}] Error occurred: ${event}`, event);
}; };
this.ws = socket;
} }
cancel() { close() {
if (this.ws !== null) { console.log(`[Connection, ${this.shortUrl}] Closing connection`);
this.ws.close(); const socket = this.ws;
this.ws = null; const retryTimeout = this.retryTimeout;
if (socket !== null) {
socket.close();
} }
if (retryTimeout !== null) {
clearTimeout(retryTimeout);
}
this.retryTimeout = null;
this.ws = null;
} }
} }

View file

@ -14,10 +14,12 @@ export class ConnectionManager {
subscriptions.forEach((id, subscription) => { subscriptions.forEach((id, subscription) => {
const added = !this.connections.get(id) const added = !this.connections.get(id)
if (added) { if (added) {
const wsUrl = subscription.wsUrl(); const baseUrl = subscription.baseUrl;
const connection = new Connection(wsUrl, id, onNotification); const topic = subscription.topic;
const since = 0;
const connection = new Connection(id, baseUrl, topic, since, onNotification);
this.connections.set(id, connection); this.connections.set(id, connection);
console.log(`[ConnectionManager] Starting new connection ${id} using URL ${wsUrl}`); console.log(`[ConnectionManager] Starting new connection ${id}`);
connection.start(); connection.start();
} }
}); });
@ -27,7 +29,7 @@ export class ConnectionManager {
console.log(`[ConnectionManager] Closing connection ${id}`); console.log(`[ConnectionManager] Closing connection ${id}`);
const connection = this.connections.get(id); const connection = this.connections.get(id);
this.connections.delete(id); this.connections.delete(id);
connection.cancel(); connection.close();
}); });
} }
} }

View file

@ -1,24 +1,15 @@
import {topicUrl, shortTopicUrl, topicUrlWs} from './utils'; import {topicUrl, shortTopicUrl, topicUrlWs} from './utils';
export default class Subscription { export default class Subscription {
id = '';
baseUrl = '';
topic = '';
notifications = [];
lastActive = null;
constructor(baseUrl, topic) { constructor(baseUrl, topic) {
this.id = topicUrl(baseUrl, topic); this.id = topicUrl(baseUrl, topic);
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
this.topic = topic; this.topic = topic;
this.notifications = new Map();
} }
addNotification(notification) { addNotification(notification) {
if (notification.time === null) { this.notifications.set(notification.id, notification);
return this;
}
this.notifications.push(notification);
this.lastActive = notification.time;
return this; return this;
} }
@ -27,12 +18,8 @@ export default class Subscription {
return this; return this;
} }
url() { getNotifications() {
return this.id; return Array.from(this.notifications.values());
}
wsUrl() {
return topicUrlWs(this.baseUrl, this.topic);
} }
shortUrl() { shortUrl() {

View file

@ -1,5 +1,5 @@
export const topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`; export const topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`;
export const topicUrlWs = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/ws` export const topicUrlWs = (baseUrl, topic, since) => `${topicUrl(baseUrl, topic)}/ws?since=${since}`
.replaceAll("https://", "wss://") .replaceAll("https://", "wss://")
.replaceAll("http://", "ws://"); .replaceAll("http://", "ws://");
export const topicUrlJson = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/json`; export const topicUrlJson = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/json`;

View file

@ -9,7 +9,8 @@ import DialogTitle from '@mui/material/DialogTitle';
import {useState} from "react"; import {useState} from "react";
import Subscription from "../app/Subscription"; import Subscription from "../app/Subscription";
const defaultBaseUrl = "https://ntfy.sh" const defaultBaseUrl = "http://127.0.0.1"
//const defaultBaseUrl = "https://ntfy.sh"
const AddDialog = (props) => { const AddDialog = (props) => {
const [topic, setTopic] = useState(""); const [topic, setTopic] = useState("");

View file

@ -101,7 +101,7 @@ const SubscriptionNavItem = (props) => {
} }
const App = () => { const App = () => {
console.log("Launching App component"); console.log(`[App] Rendering main view`);
const [drawerOpen, setDrawerOpen] = useState(true); const [drawerOpen, setDrawerOpen] = useState(true);
const [subscriptions, setSubscriptions] = useState(new Subscriptions()); const [subscriptions, setSubscriptions] = useState(new Subscriptions());
@ -114,6 +114,7 @@ const App = () => {
}); });
}; };
const handleSubscribeSubmit = (subscription) => { const handleSubscribeSubmit = (subscription) => {
console.log(`[App] New subscription: ${subscription.id}`);
setSubscribeDialogOpen(false); setSubscribeDialogOpen(false);
setSubscriptions(prev => prev.add(subscription).clone()); setSubscriptions(prev => prev.add(subscription).clone());
setSelectedSubscription(subscription); setSelectedSubscription(subscription);
@ -126,10 +127,11 @@ const App = () => {
}); });
}; };
const handleSubscribeCancel = () => { const handleSubscribeCancel = () => {
console.log(`Cancel clicked`); console.log(`[App] Cancel clicked`);
setSubscribeDialogOpen(false); setSubscribeDialogOpen(false);
}; };
const handleUnsubscribe = (subscriptionId) => { const handleUnsubscribe = (subscriptionId) => {
console.log(`[App] Unsubscribing from ${subscriptionId}`);
setSubscriptions(prev => { setSubscriptions(prev => {
const newSubscriptions = prev.remove(subscriptionId).clone(); const newSubscriptions = prev.remove(subscriptionId).clone();
setSelectedSubscription(newSubscriptions.firstOrNull()); setSelectedSubscription(newSubscriptions.firstOrNull());
@ -137,10 +139,10 @@ const App = () => {
}); });
}; };
const handleSubscriptionClick = (subscriptionId) => { const handleSubscriptionClick = (subscriptionId) => {
console.log(`Selected subscription ${subscriptionId}`); console.log(`[App] Selected ${subscriptionId}`);
setSelectedSubscription(subscriptions.get(subscriptionId)); setSelectedSubscription(subscriptions.get(subscriptionId));
}; };
const notifications = (selectedSubscription !== null) ? selectedSubscription.notifications : []; const notifications = (selectedSubscription !== null) ? selectedSubscription.getNotifications() : [];
const toggleDrawer = () => { const toggleDrawer = () => {
setDrawerOpen(!drawerOpen); setDrawerOpen(!drawerOpen);
}; };