Works mostly
This commit is contained in:
parent
e79b50f010
commit
5c2b6d18ec
7 changed files with 349 additions and 128 deletions
10
README.md
Normal file
10
README.md
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
|
||||||
|
|
||||||
|
echo "mychan:long process is done" | nc -N ntfy.sh 9999
|
||||||
|
curl -d "long process is done" ntfy.sh/mychan
|
||||||
|
publish on channel
|
||||||
|
|
||||||
|
curl ntfy.sh/mychan
|
||||||
|
subscribe to channel
|
||||||
|
|
||||||
|
ntfy.sh/mychan/ws
|
2
go.mod
2
go.mod
|
@ -1,3 +1,5 @@
|
||||||
module heckel.io/notifyme
|
module heckel.io/notifyme
|
||||||
|
|
||||||
go 1.16
|
go 1.16
|
||||||
|
|
||||||
|
require github.com/gorilla/websocket v1.4.2 // indirect
|
||||||
|
|
2
go.sum
2
go.sum
|
@ -0,0 +1,2 @@
|
||||||
|
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||||
|
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
131
main.go
131
main.go
|
@ -1,138 +1,13 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"heckel.io/notifyme/server"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"log"
|
"log"
|
||||||
"math/rand"
|
|
||||||
"net/http"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Message struct {
|
|
||||||
Time int64 `json:"time"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Channel struct {
|
|
||||||
id string
|
|
||||||
listeners map[int]listener
|
|
||||||
last time.Time
|
|
||||||
ctx context.Context
|
|
||||||
mu sync.Mutex
|
|
||||||
}
|
|
||||||
|
|
||||||
type Server struct {
|
|
||||||
channels map[string]*Channel
|
|
||||||
mu sync.Mutex
|
|
||||||
}
|
|
||||||
|
|
||||||
type listener func(msg *Message)
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
s := &Server{
|
s := server.New()
|
||||||
channels: make(map[string]*Channel),
|
if err := s.Run(); err != nil {
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
time.Sleep(5 * time.Second)
|
|
||||||
s.mu.Lock()
|
|
||||||
log.Printf("channels: %d", len(s.channels))
|
|
||||||
s.mu.Unlock()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
http.HandleFunc("/", s.handle)
|
|
||||||
if err := http.ListenAndServe(":9997", nil); err != nil {
|
|
||||||
log.Fatalln(err)
|
log.Fatalln(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if err := s.handleInternal(w, r); err != nil {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
_, _ = io.WriteString(w, err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
if len(r.URL.Path) == 0 {
|
|
||||||
return errors.New("invalid path")
|
|
||||||
}
|
|
||||||
channel := s.channel(r.URL.Path[1:])
|
|
||||||
switch r.Method {
|
|
||||||
case http.MethodGet:
|
|
||||||
return s.handleGET(w, r, channel)
|
|
||||||
case http.MethodPut:
|
|
||||||
return s.handlePUT(w, r, channel)
|
|
||||||
default:
|
|
||||||
return errors.New("invalid method")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleGET(w http.ResponseWriter, r *http.Request, ch *Channel) error {
|
|
||||||
fl, ok := w.(http.Flusher)
|
|
||||||
if !ok {
|
|
||||||
return errors.New("not a flusher")
|
|
||||||
}
|
|
||||||
listenerID := rand.Int()
|
|
||||||
l := func (msg *Message) {
|
|
||||||
json.NewEncoder(w).Encode(&msg)
|
|
||||||
fl.Flush()
|
|
||||||
}
|
|
||||||
ch.mu.Lock()
|
|
||||||
ch.listeners[listenerID] = l
|
|
||||||
ch.last = time.Now()
|
|
||||||
ch.mu.Unlock()
|
|
||||||
select {
|
|
||||||
case <-ch.ctx.Done():
|
|
||||||
case <-r.Context().Done():
|
|
||||||
}
|
|
||||||
ch.mu.Lock()
|
|
||||||
delete(ch.listeners, listenerID)
|
|
||||||
if len(ch.listeners) == 0 {
|
|
||||||
s.mu.Lock()
|
|
||||||
delete(s.channels, ch.id)
|
|
||||||
s.mu.Unlock()
|
|
||||||
}
|
|
||||||
ch.mu.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handlePUT(w http.ResponseWriter, r *http.Request, ch *Channel) error {
|
|
||||||
ch.mu.Lock()
|
|
||||||
defer ch.mu.Unlock()
|
|
||||||
if len(ch.listeners) == 0 {
|
|
||||||
return errors.New("no listeners")
|
|
||||||
}
|
|
||||||
defer r.Body.Close()
|
|
||||||
ch.last = time.Now()
|
|
||||||
msg, _ := io.ReadAll(r.Body)
|
|
||||||
for _, l := range ch.listeners {
|
|
||||||
l(&Message{
|
|
||||||
Time: time.Now().UnixMilli(),
|
|
||||||
Message: string(msg),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) channel(channelID string) *Channel {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
c, ok := s.channels[channelID]
|
|
||||||
if !ok {
|
|
||||||
ctx, _ := context.WithCancel(context.Background()) // FIXME
|
|
||||||
c = &Channel{
|
|
||||||
id: channelID,
|
|
||||||
listeners: make(map[int]listener),
|
|
||||||
last: time.Now(),
|
|
||||||
ctx: ctx,
|
|
||||||
mu: sync.Mutex{},
|
|
||||||
}
|
|
||||||
s.channels[channelID] = c
|
|
||||||
}
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
79
server/index.html
Normal file
79
server/index.html
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>ntfy.sh</title>
|
||||||
|
<style>
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>ntfy.sh</h1>
|
||||||
|
|
||||||
|
Topics:
|
||||||
|
<ul id="topics">
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<input type="text" id="topic" size="64" autofocus />
|
||||||
|
<button id="topicButton">Add topic</button>
|
||||||
|
<button onclick="notifyMe('test'); return false">Notify me!</button>
|
||||||
|
|
||||||
|
<div id="error"></div>
|
||||||
|
<script type="text/javascript">
|
||||||
|
window.onload = function() {
|
||||||
|
let topics = {};
|
||||||
|
|
||||||
|
const topicField = document.getElementById("topic");
|
||||||
|
const topicsList = document.getElementById("topics");
|
||||||
|
const topicButton = document.getElementById("topicButton");
|
||||||
|
const errorField = document.getElementById("error");
|
||||||
|
|
||||||
|
const subscribe = function (topic) {
|
||||||
|
let conn = new WebSocket(`ws://${document.location.host}/${topic}/ws`);
|
||||||
|
conn.onclose = function (evt) {
|
||||||
|
errorField.innerHTML = "Connection closed";
|
||||||
|
};
|
||||||
|
conn.onmessage = function (evt) {
|
||||||
|
notify(evt.data)
|
||||||
|
};
|
||||||
|
topics[topic] = conn;
|
||||||
|
|
||||||
|
let topicEntry = document.createElement('li');
|
||||||
|
topicEntry.innerHTML = `${topic} <button onclick="unsubscribe('${topic}')">Unsubscribe</button>`;
|
||||||
|
topicsList.appendChild(topicEntry);
|
||||||
|
};
|
||||||
|
|
||||||
|
const notify = function (msg) {
|
||||||
|
if (!("Notification" in window)) {
|
||||||
|
alert("This browser does not support desktop notification");
|
||||||
|
} else if (Notification.permission === "granted") {
|
||||||
|
var notification = new Notification(msg);
|
||||||
|
} else if (Notification.permission !== "denied") {
|
||||||
|
Notification.requestPermission().then(function (permission) {
|
||||||
|
if (permission === "granted") {
|
||||||
|
var notification = new Notification(msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
topicButton.onclick = function () {
|
||||||
|
if (!topicField.value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
subscribe(topicField.value);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!window["Notification"]) {
|
||||||
|
errorField.innerHTML = "Your browser does not support desktop notifications";
|
||||||
|
topicField.disabled = true;
|
||||||
|
topicButton.disabled = true;
|
||||||
|
} else if (!window["Notification"]) {
|
||||||
|
errorField.innerHTML = "Your browser does not support WebSockets.";
|
||||||
|
topicField.disabled = true;
|
||||||
|
topicButton.disabled = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
185
server/server.go
Normal file
185
server/server.go
Normal file
|
@ -0,0 +1,185 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
_ "embed" // required for go:embed
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
topics map[string]*topic
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
type message struct {
|
||||||
|
Time int64 `json:"time"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
messageLimit = 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
topicRegex = regexp.MustCompile(`^/[^/]+$`)
|
||||||
|
wsRegex = regexp.MustCompile(`^/[^/]+/ws$`)
|
||||||
|
jsonRegex = regexp.MustCompile(`^/[^/]+/json$`)
|
||||||
|
wsUpgrader = websocket.Upgrader{
|
||||||
|
ReadBufferSize: messageLimit,
|
||||||
|
WriteBufferSize: messageLimit,
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:embed "index.html"
|
||||||
|
indexSource string
|
||||||
|
)
|
||||||
|
|
||||||
|
func New() *Server {
|
||||||
|
return &Server{
|
||||||
|
topics: make(map[string]*topic),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Run() error {
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
s.mu.Lock()
|
||||||
|
log.Printf("topics: %d", len(s.topics))
|
||||||
|
for _, t := range s.topics {
|
||||||
|
t.mu.Lock()
|
||||||
|
log.Printf("- %s: %d subscriber(s), %d message(s) sent, last active = %s",
|
||||||
|
t.id, len(t.subscribers), t.messages, t.last.String())
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
// TODO kill dead topics
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
log.Printf("Listening on :9997")
|
||||||
|
http.HandleFunc("/", s.handle)
|
||||||
|
return http.ListenAndServe(":9997", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := s.handleInternal(w, r); err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = io.WriteString(w, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
if r.Method == http.MethodGet && r.URL.Path == "/" {
|
||||||
|
return s.handleHome(w, r)
|
||||||
|
} else if r.Method == http.MethodGet && wsRegex.MatchString(r.URL.Path) {
|
||||||
|
return s.handleSubscribeWS(w, r)
|
||||||
|
} else if r.Method == http.MethodGet && jsonRegex.MatchString(r.URL.Path) {
|
||||||
|
return s.handleSubscribeHTTP(w, r)
|
||||||
|
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicRegex.MatchString(r.URL.Path) {
|
||||||
|
return s.handlePublishHTTP(w, r)
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
_, err := io.WriteString(w, indexSource)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePublishHTTP(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
t, err := s.topic(r.URL.Path[1:])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reader := io.LimitReader(r.Body, messageLimit)
|
||||||
|
b, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
msg := &message{
|
||||||
|
Time: time.Now().UnixMilli(),
|
||||||
|
Message: string(b),
|
||||||
|
}
|
||||||
|
return t.Publish(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
t := s.createTopic(strings.TrimSuffix(r.URL.Path[1:], "/json")) // Hack
|
||||||
|
subscriberID := t.Subscribe(func (msg *message) error {
|
||||||
|
if err := json.NewEncoder(w).Encode(&msg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if fl, ok := w.(http.Flusher); ok {
|
||||||
|
fl.Flush()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
defer t.Unsubscribe(subscriberID)
|
||||||
|
select {
|
||||||
|
case <-t.ctx.Done():
|
||||||
|
case <-r.Context().Done():
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
conn, err := wsUpgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
t := s.createTopic(strings.TrimSuffix(r.URL.Path[1:], "/ws")) // Hack
|
||||||
|
t.Subscribe(func (msg *message) error {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := json.NewEncoder(&buf).Encode(&msg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
/*conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||||
|
if !ok {
|
||||||
|
// The hub closed the channel.
|
||||||
|
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||||
|
return
|
||||||
|
}*/
|
||||||
|
|
||||||
|
w, err := conn.NextWriter(websocket.TextMessage)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := w.Write([]byte(msg.Message)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createTopic(id string) *topic {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if _, ok := s.topics[id]; !ok {
|
||||||
|
s.topics[id] = newTopic(id)
|
||||||
|
}
|
||||||
|
return s.topics[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) topic(topicID string) (*topic, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
c, ok := s.topics[topicID]
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("topic does not exist")
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
68
server/topic.go
Normal file
68
server/topic.go
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"math/rand"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type topic struct {
|
||||||
|
id string
|
||||||
|
subscribers map[int]subscriber
|
||||||
|
messages int
|
||||||
|
last time.Time
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
type subscriber func(msg *message) error
|
||||||
|
|
||||||
|
func newTopic(id string) *topic {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
return &topic{
|
||||||
|
id: id,
|
||||||
|
subscribers: make(map[int]subscriber),
|
||||||
|
last: time.Now(),
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *topic) Subscribe(s subscriber) int {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
subscriberID := rand.Int()
|
||||||
|
t.subscribers[subscriberID] = s
|
||||||
|
t.last = time.Now()
|
||||||
|
return subscriberID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *topic) Unsubscribe(id int) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
delete(t.subscribers, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *topic) Publish(m *message) error {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
if len(t.subscribers) == 0 {
|
||||||
|
return errors.New("no subscribers")
|
||||||
|
}
|
||||||
|
t.last = time.Now()
|
||||||
|
t.messages++
|
||||||
|
for _, s := range t.subscribers {
|
||||||
|
if err := s(m); err != nil {
|
||||||
|
log.Printf("error publishing message to subscriber x")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *topic) Close() {
|
||||||
|
t.cancel()
|
||||||
|
}
|
Loading…
Reference in a new issue