Reconnect on failure, with backoff; Deduping notifications
This commit is contained in:
parent
3fac1c3432
commit
1536201e9a
6 changed files with 62 additions and 45 deletions
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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`;
|
||||||
|
|
|
@ -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("");
|
||||||
|
|
|
@ -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);
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in a new issue