Files
hnh-map/internal/app/handlers/tile.go
Nikolay Tatarinov dc53b79d84 Enhance map update handling and connection stability
- Introduced mechanisms to detect stale connections in the map updates, allowing for automatic reconnection if no messages are received within a specified timeframe.
- Updated the `refresh` method in `SmartTileLayer` to return a boolean indicating whether the tile was refreshed, improving the handling of tile updates.
- Enhanced the `Send` method in the `Topic` struct to drop messages for full subscribers while keeping them subscribed, ensuring continuous delivery of future updates.
- Added a keepalive mechanism in the `WatchGridUpdates` handler to maintain the connection and prevent timeouts.
2026-03-04 21:29:53 +03:00

174 lines
4.6 KiB
Go

package handlers
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"path/filepath"
"regexp"
"strconv"
"time"
"github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/services"
)
var transparentPNG = []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41,
0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00,
0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
0x42, 0x60, 0x82,
}
var tileRegex = regexp.MustCompile(`([0-9]+)/([0-9]+)/([-0-9]+)_([-0-9]+)\.png`)
// WatchGridUpdates is the SSE endpoint for tile updates.
func (h *Handlers) WatchGridUpdates(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
s := h.Auth.GetSession(ctx, req)
if !h.canAccessMap(s) {
rw.WriteHeader(http.StatusUnauthorized)
return
}
rw.Header().Set("Content-Type", "text/event-stream")
rw.Header().Set("Access-Control-Allow-Origin", "*")
rw.Header().Set("X-Accel-Buffering", "no")
flusher, ok := rw.(http.Flusher)
if !ok {
JSONError(rw, http.StatusInternalServerError, "streaming unsupported", "INTERNAL_ERROR")
return
}
c := h.Map.WatchTiles()
mc := h.Map.WatchMerges()
// Option 1A: do not send full cache on connect; client requests tiles with cache=0 when missing.
// This avoids a huge JSON dump and slow parse on connect when the DB has many tiles.
tileCache := []services.TileCache{}
raw, _ := json.Marshal(tileCache)
fmt.Fprint(rw, "data: ")
_, _ = rw.Write(raw)
fmt.Fprint(rw, "\n\n")
flusher.Flush()
ticker := time.NewTicker(app.SSETickInterval)
defer ticker.Stop()
keepaliveTicker := time.NewTicker(app.SSEKeepaliveInterval)
defer keepaliveTicker.Stop()
for {
select {
case <-ctx.Done():
return
case <-keepaliveTicker.C:
if _, err := fmt.Fprint(rw, ": keepalive\n\n"); err != nil {
return
}
flusher.Flush()
case e, ok := <-c:
if !ok {
return
}
found := false
for i := range tileCache {
if tileCache[i].M == e.MapID && tileCache[i].X == e.Coord.X && tileCache[i].Y == e.Coord.Y && tileCache[i].Z == e.Zoom {
tileCache[i].T = int(e.Cache)
found = true
}
}
if !found {
tileCache = append(tileCache, services.TileCache{
M: e.MapID,
X: e.Coord.X,
Y: e.Coord.Y,
Z: e.Zoom,
T: int(e.Cache),
})
}
case e, ok := <-mc:
if !ok {
return
}
raw, err := json.Marshal(e)
if err != nil {
slog.Error("failed to marshal merge event", "error", err)
}
fmt.Fprint(rw, "event: merge\n")
fmt.Fprint(rw, "data: ")
_, _ = rw.Write(raw)
fmt.Fprint(rw, "\n\n")
flusher.Flush()
case <-ticker.C:
raw, _ := json.Marshal(tileCache)
fmt.Fprint(rw, "data: ")
if _, err := rw.Write(raw); err != nil {
return
}
fmt.Fprint(rw, "\n\n")
tileCache = tileCache[:0]
flusher.Flush()
}
}
}
// GridTile serves tile images.
func (h *Handlers) GridTile(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
s := h.Auth.GetSession(ctx, req)
if !h.canAccessMap(s) {
rw.WriteHeader(http.StatusUnauthorized)
return
}
tile := tileRegex.FindStringSubmatch(req.URL.Path)
if tile == nil || len(tile) < 5 {
JSONError(rw, http.StatusBadRequest, "invalid path", "BAD_REQUEST")
return
}
mapid, err := strconv.Atoi(tile[1])
if err != nil {
JSONError(rw, http.StatusBadRequest, "request parsing error", "BAD_REQUEST")
return
}
z, err := strconv.Atoi(tile[2])
if err != nil {
JSONError(rw, http.StatusBadRequest, "request parsing error", "BAD_REQUEST")
return
}
x, err := strconv.Atoi(tile[3])
if err != nil {
JSONError(rw, http.StatusBadRequest, "request parsing error", "BAD_REQUEST")
return
}
y, err := strconv.Atoi(tile[4])
if err != nil {
JSONError(rw, http.StatusBadRequest, "request parsing error", "BAD_REQUEST")
return
}
storageZ := z
if storageZ == 6 {
storageZ = 0
}
if storageZ < 0 || storageZ > app.MaxZoomLevel {
storageZ = 0
}
td := h.Map.GetTile(ctx, mapid, app.Coord{X: x, Y: y}, storageZ)
if td == nil {
rw.Header().Set("Content-Type", "image/png")
rw.Header().Set("Cache-Control", "private, max-age=3600")
rw.WriteHeader(http.StatusOK)
_, _ = rw.Write(transparentPNG)
return
}
rw.Header().Set("Content-Type", "image/png")
rw.Header().Set("Cache-Control", "private immutable")
http.ServeFile(rw, req, filepath.Join(h.Map.GridStorage(), td.File))
}