Make the topic feeds work with the ntfy apps

The JSON stream now carries the event field the apps look for, sends open and keepalive
events, and supports ?poll=1 and ?since= with a duration or a message id, which is how the
apps catch up. Adds a walkthrough for the phone app to the README.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-27 04:22:53 -06:00
1 parent 7994ad492c
commit 955e01333d
4 files changed
+112 -10

No files matched your search

+45 -9
View File
@@ -518,11 +518,14 @@ func (s *Server) publishHTTP(w http.ResponseWriter, r *http.Request) {
bad(w, err.Error())
return
}
m.Event = "message"
writeJSON(w, 200, m)
}
// subscribeHTTP streams a topic as server-sent events (/sse) or JSON lines (/json).
// ?since=<unix seconds>|all replays stored messages first.
// ?since= replays stored messages first: "all", unix seconds, a duration such as 10m, or the id
// of the last message the client saw. ?poll=1 sends only that replay and then ends, which is
// how the ntfy apps catch up.
func (s *Server) subscribeHTTP(w http.ResponseWriter, r *http.Request) {
topic := r.PathValue("topic")
if !validTopic(topic) {
@@ -543,7 +546,14 @@ func (s *Server) subscribeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no")
bw := bufio.NewWriter(w)
event := func(kind string) {
b, _ := json.Marshal(map[string]any{"id": newID(), "time": s.now().Unix(), "event": kind, "topic": topic})
fmt.Fprintf(bw, "%s\n", b)
bw.Flush()
fl.Flush()
}
write := func(m Message) bool {
m.Event = "message"
b, _ := json.Marshal(m)
var err error
if sse {
@@ -560,21 +570,22 @@ func (s *Server) subscribeHTTP(w http.ResponseWriter, r *http.Request) {
defer unsub()
seen := map[string]bool{}
if q := r.URL.Query().Get("since"); q != "" {
since := int64(0)
if q != "all" {
since, _ = strconv.ParseInt(q, 10, 64)
}
for _, m := range s.MessagesSince(topic, since, 500) {
for _, m := range s.replaySince(topic, q) {
seen[m.ID] = true
if !write(m) {
return
}
}
}
if r.URL.Query().Get("poll") == "1" {
return
}
if sse {
fmt.Fprint(bw, ": connected\n\n")
bw.Flush()
fl.Flush()
} else {
event("open")
}
keepalive := time.NewTicker(30 * time.Second)
defer keepalive.Stop()
@@ -594,15 +605,40 @@ func (s *Server) subscribeHTTP(w http.ResponseWriter, r *http.Request) {
case <-keepalive.C:
if sse {
fmt.Fprint(bw, ": keepalive\n\n")
bw.Flush()
fl.Flush()
} else {
fmt.Fprint(bw, "\n")
event("keepalive")
}
bw.Flush()
fl.Flush()
}
}
}
// replaySince returns the stored messages a subscriber should see first for a ?since= value.
func (s *Server) replaySince(topic, q string) []Message {
if q == "all" {
return s.MessagesSince(topic, 0, 500)
}
if n, err := strconv.ParseInt(q, 10, 64); err == nil {
return s.MessagesSince(topic, n, 500)
}
if d, err := time.ParseDuration(q); err == nil {
return s.MessagesSince(topic, s.now().Add(-d).Unix(), 500)
}
// Otherwise it is the id of the last message the client has: send what came after it.
var at int64
if s.st.db.QueryRow(`SELECT time FROM messages WHERE topic=? AND id=?`, topic, q).Scan(&at) != nil {
return nil
}
all := s.MessagesSince(topic, at, 500)
for i, m := range all {
if m.ID == q {
return all[i+1:]
}
}
return all
}
// ---------- alerts in ----------
func (s *Server) inboundAlert(w http.ResponseWriter, r *http.Request) {