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
|
||||
|
||||
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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"heckel.io/notifyme/server"
|
||||
"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() {
|
||||
s := &Server{
|
||||
channels: make(map[string]*Channel),
|
||||
}
|
||||
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 {
|
||||
s := server.New()
|
||||
if err := s.Run(); err != nil {
|
||||
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