feat: notify document updates over websocket
This commit is contained in:
@@ -1,10 +1,28 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
func (s *Server) timeoutExceptWebSocket(timeout time.Duration) func(http.Handler) http.Handler {
|
||||
timeoutMiddleware := middleware.Timeout(timeout)
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/ws" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
timeoutMiddleware(next).ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) requestLogger(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ww := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
@@ -21,7 +39,7 @@ func (s *Server) requestLogger(next http.Handler) http.Handler {
|
||||
|
||||
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'self'; frame-ancestors 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; object-src 'none'")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'self'; frame-ancestors 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ws: wss:; object-src 'none'")
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
@@ -40,3 +58,11 @@ func (w *statusWriter) WriteHeader(status int) {
|
||||
w.status = status
|
||||
w.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (w *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
hijacker, ok := w.ResponseWriter.(http.Hijacker)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("response writer does not support hijacking")
|
||||
}
|
||||
return hijacker.Hijack()
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/tim/md-hub-secure/apps/server/internal/config"
|
||||
"github.com/tim/md-hub-secure/apps/server/internal/docs"
|
||||
"github.com/tim/md-hub-secure/apps/server/internal/realtime"
|
||||
"github.com/tim/md-hub-secure/apps/server/internal/store"
|
||||
)
|
||||
|
||||
@@ -26,6 +27,7 @@ type Dependencies struct {
|
||||
Documents *docs.Service
|
||||
Repository *docs.Repository
|
||||
ContentStore *store.ContentStore
|
||||
Hub *realtime.Hub
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -34,6 +36,7 @@ type Server struct {
|
||||
documents *docs.Service
|
||||
repository *docs.Repository
|
||||
contentStore *store.ContentStore
|
||||
hub *realtime.Hub
|
||||
templates *template.Template
|
||||
webEnabled bool
|
||||
}
|
||||
@@ -54,6 +57,7 @@ func New(deps Dependencies) (http.Handler, error) {
|
||||
documents: deps.Documents,
|
||||
repository: deps.Repository,
|
||||
contentStore: deps.ContentStore,
|
||||
hub: deps.Hub,
|
||||
templates: templates,
|
||||
}
|
||||
|
||||
@@ -65,12 +69,13 @@ func New(deps Dependencies) (http.Handler, error) {
|
||||
router.Use(middleware.RequestID)
|
||||
router.Use(middleware.RealIP)
|
||||
router.Use(middleware.Recoverer)
|
||||
router.Use(middleware.Timeout(30 * time.Second))
|
||||
router.Use(server.timeoutExceptWebSocket(30 * time.Second))
|
||||
router.Use(server.requestLogger)
|
||||
router.Use(server.securityHeaders)
|
||||
|
||||
router.Get("/", server.handleIndex)
|
||||
router.Get("/health", server.handleHealth)
|
||||
router.Get("/ws", server.handleWebSocket)
|
||||
router.Get("/docs", server.handleDocsIndexRedirect)
|
||||
router.Get("/docs/*", server.handleDocument)
|
||||
router.Post("/api/uploads", server.handleUpload)
|
||||
|
||||
35
apps/server/internal/httpserver/static/realtime.js
Normal file
35
apps/server/internal/httpserver/static/realtime.js
Normal file
@@ -0,0 +1,35 @@
|
||||
(function () {
|
||||
const notice = document.querySelector("[data-version-notice]");
|
||||
const reload = document.querySelector("[data-version-reload]");
|
||||
const documentShell = document.querySelector("[data-document-path][data-document-hash]");
|
||||
|
||||
if (!notice || !reload || !documentShell || !window.WebSocket) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPath = documentShell.getAttribute("data-document-path");
|
||||
const currentHash = documentShell.getAttribute("data-document-hash");
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const socket = new WebSocket(protocol + "//" + window.location.host + "/ws");
|
||||
|
||||
socket.addEventListener("message", function (event) {
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type !== "document_version" || !payload.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.data.path === currentPath && payload.data.hash !== currentHash) {
|
||||
notice.hidden = false;
|
||||
}
|
||||
});
|
||||
|
||||
reload.addEventListener("click", function () {
|
||||
window.location.reload();
|
||||
});
|
||||
})();
|
||||
@@ -177,6 +177,41 @@ code {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.version-notice {
|
||||
position: fixed;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
max-width: min(28rem, calc(100vw - 2rem));
|
||||
padding: 0.75rem 0.85rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--panel-strong);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.version-notice[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.version-notice p {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.version-notice button {
|
||||
min-height: 2.2rem;
|
||||
padding: 0 0.8rem;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
color: white;
|
||||
background: var(--accent);
|
||||
font: 700 0.9rem/1 ui-monospace, SFMono-Regular, monospace;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.site-header__inner {
|
||||
flex-direction: column;
|
||||
@@ -191,4 +226,3 @@ code {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,11 @@
|
||||
{{ template "error_content" .Data }}
|
||||
{{ end }}
|
||||
</main>
|
||||
<div class="version-notice" data-version-notice hidden>
|
||||
<p>A newer version is available.</p>
|
||||
<button type="button" data-version-reload>Reload</button>
|
||||
</div>
|
||||
<script src="/static/realtime.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{{ define "document.gohtml" }}{{ template "base" . }}{{ end }}
|
||||
|
||||
{{ define "document_content" }}
|
||||
<article class="document-shell">
|
||||
<article class="document-shell" data-document-path="{{ .Path }}" data-document-hash="{{ .Hash }}">
|
||||
<div class="document-meta">
|
||||
<p class="eyebrow">{{ .Path }}</p>
|
||||
<h1>{{ .Title }}</h1>
|
||||
|
||||
88
apps/server/internal/httpserver/websocket.go
Normal file
88
apps/server/internal/httpserver/websocket.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"github.com/tim/md-hub-secure/apps/server/internal/realtime"
|
||||
)
|
||||
|
||||
const (
|
||||
wsWriteWait = 10 * time.Second
|
||||
wsPongWait = 60 * time.Second
|
||||
wsPingPeriod = 45 * time.Second
|
||||
)
|
||||
|
||||
var wsUpgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
return true
|
||||
}
|
||||
return origin == "http://"+r.Host || origin == "https://"+r.Host
|
||||
},
|
||||
}
|
||||
|
||||
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := wsUpgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
s.logger.Warn("upgrade websocket", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
client := s.hub.NewClient(16)
|
||||
s.hub.Register(client)
|
||||
|
||||
go s.readWebSocket(conn, client)
|
||||
s.writeWebSocket(conn, client)
|
||||
}
|
||||
|
||||
func (s *Server) readWebSocket(conn *websocket.Conn, client *realtime.Client) {
|
||||
defer func() {
|
||||
s.hub.Unregister(client)
|
||||
_ = conn.Close()
|
||||
}()
|
||||
conn.SetReadLimit(1024)
|
||||
_ = conn.SetReadDeadline(time.Now().Add(wsPongWait))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
return conn.SetReadDeadline(time.Now().Add(wsPongWait))
|
||||
})
|
||||
|
||||
for {
|
||||
if _, _, err := conn.NextReader(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) writeWebSocket(conn *websocket.Conn, client *realtime.Client) {
|
||||
ticker := time.NewTicker(wsPingPeriod)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
s.hub.Unregister(client)
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case message, ok := <-client.Send():
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
|
||||
if !ok {
|
||||
_ = conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
if err := conn.WriteMessage(websocket.TextMessage, message); err != nil {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
|
||||
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user