diff --git a/common/src/main/java/de/bluecolored/bluemap/common/config/WebserverConfig.java b/common/src/main/java/de/bluecolored/bluemap/common/config/WebserverConfig.java index 533f69514..4cef22e5b 100644 --- a/common/src/main/java/de/bluecolored/bluemap/common/config/WebserverConfig.java +++ b/common/src/main/java/de/bluecolored/bluemap/common/config/WebserverConfig.java @@ -24,6 +24,7 @@ */ package de.bluecolored.bluemap.common.config; +import lombok.Getter; import org.spongepowered.configurate.objectmapping.ConfigSerializable; import java.net.InetAddress; @@ -33,6 +34,7 @@ @SuppressWarnings({"FieldMayBeFinal", "FieldCanBeLocal"}) @ConfigSerializable +@Getter public class WebserverConfig { private boolean enabled = true; @@ -41,19 +43,9 @@ public class WebserverConfig { private String ip = "0.0.0.0"; private int port = 8100; - private LogConfig log = new LogConfig(); - - public boolean isEnabled() { - return enabled; - } - - public Path getWebroot() { - return webroot; - } + private boolean sseEnabled = true; - public String getIp() { - return ip; - } + private LogConfig log = new LogConfig(); public InetAddress resolveIp() throws UnknownHostException { if (ip.isEmpty() || ip.equals("0.0.0.0") || ip.equals("::0")) { @@ -65,33 +57,14 @@ public InetAddress resolveIp() throws UnknownHostException { } } - public int getPort() { - return port; - } - - public LogConfig getLog() { - return log; - } - @ConfigSerializable + @Getter public static class LogConfig { private String file = null; private boolean append = false; private String format = "%1$s \"%3$s %4$s %5$s\" %6$s %7$s"; - public String getFile() { - return file; - } - - public boolean isAppend() { - return append; - } - - public String getFormat() { - return format; - } - } } diff --git a/common/src/main/java/de/bluecolored/bluemap/common/plugin/Plugin.java b/common/src/main/java/de/bluecolored/bluemap/common/plugin/Plugin.java index a61e3721d..c9596860e 100644 --- a/common/src/main/java/de/bluecolored/bluemap/common/plugin/Plugin.java +++ b/common/src/main/java/de/bluecolored/bluemap/common/plugin/Plugin.java @@ -234,7 +234,7 @@ private void load(@Nullable ResourcePack preloadedResourcePack) throws IOExcepti null; LiveMarkersDataSupplier liveMarkersDataSupplier = new LiveMarkersDataSupplier(map.getMarkerSets()); - mapRequestHandler = new MapRequestHandler(map.getStorage(), livePlayersDataSupplier, liveMarkersDataSupplier); + mapRequestHandler = new MapRequestHandler(map, livePlayersDataSupplier, liveMarkersDataSupplier, webserverConfig.isSseEnabled()); } else { Storage storage = blueMap.getOrLoadStorage(mapConfig.getStorage()); mapRequestHandler = new MapRequestHandler(storage.map(id)); diff --git a/common/src/main/java/de/bluecolored/bluemap/common/web/CachedRateLimitDataSupplier.java b/common/src/main/java/de/bluecolored/bluemap/common/web/CachedRateLimitDataSupplier.java deleted file mode 100644 index d14e0798f..000000000 --- a/common/src/main/java/de/bluecolored/bluemap/common/web/CachedRateLimitDataSupplier.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * This file is part of BlueMap, licensed under the MIT License (MIT). - * - * Copyright (c) Blue (Lukas Rieger) - * Copyright (c) contributors - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ -package de.bluecolored.bluemap.common.web; - -import java.util.concurrent.locks.ReentrantLock; -import java.util.function.Supplier; - -public class CachedRateLimitDataSupplier implements Supplier { - - private final ReentrantLock lock = new ReentrantLock(); - - private final Supplier delegate; - private final long rateLimitMillis; - - private long updateTime = -1; - private String data = null; - - public CachedRateLimitDataSupplier(Supplier delegate, long rateLimitMillis) { - this.delegate = delegate; - this.rateLimitMillis = rateLimitMillis; - } - - @Override - public String get() { - update(); - return data; - } - - protected void update() { - if (lock.tryLock()) { - try { - long now = System.currentTimeMillis(); - if (data != null && now < updateTime + this.rateLimitMillis) return; - this.data = delegate.get(); - this.updateTime = now; - } finally { - lock.unlock(); - } - } - } - -} diff --git a/common/src/main/java/de/bluecolored/bluemap/common/web/LiveDataSupplierBroadcaster.java b/common/src/main/java/de/bluecolored/bluemap/common/web/LiveDataSupplierBroadcaster.java new file mode 100644 index 000000000..3e513a44c --- /dev/null +++ b/common/src/main/java/de/bluecolored/bluemap/common/web/LiveDataSupplierBroadcaster.java @@ -0,0 +1,127 @@ +/* + * This file is part of BlueMap, licensed under the MIT License (MIT). + * + * Copyright (c) Blue (Lukas Rieger) + * Copyright (c) contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package de.bluecolored.bluemap.common.web; + +import de.bluecolored.bluemap.core.BlueMap; + +import java.io.Closeable; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * Polls a {@link Supplier} and notifies registered listeners whenever the + * returned value changes. + *

+ * Polling and updating the data is done by the {@link BlueMap#SCHEDULER} and + * {@link BlueMap#THREAD_POOL}. + *

+ * Call {@link #update()} to get the current value (rate-limited by the polling rate) + * Call {@link #close()} to stop the background polling. + */ +public class LiveDataSupplierBroadcaster implements Supplier, Closeable { + + private final Supplier dataSupplier; + private final long pollIntervalMillis; + private final Set> listeners = ConcurrentHashMap.newKeySet(); + private ScheduledFuture pollTask = null; + private boolean closed = false; + private long lastUpdate = -1; + private T data = null; + + public LiveDataSupplierBroadcaster(Supplier dataSupplier, long pollIntervalMillis) { + this.dataSupplier = dataSupplier; + this.pollIntervalMillis = pollIntervalMillis; + } + + public synchronized void addUpdateListener(Consumer listener) { + listeners.add(listener); + + // have a listener - ensure scheduled poll task is running + if (!closed && pollTask == null) { + pollTask = BlueMap.SCHEDULER.scheduleWithFixedDelay( + () -> BlueMap.THREAD_POOL.execute(this::update), + 0, + pollIntervalMillis, + TimeUnit.MILLISECONDS + ); + } + } + + public synchronized void removeUpdateListener(Consumer listener) { + listeners.remove(listener); + + // stop automatically updating if nothing is listening anymore + if (listeners.isEmpty() && pollTask != null) { + pollTask.cancel(false); + pollTask = null; + } + } + + /** + * Ensure the data is up to date and return it. + *

+ * Note that this will only get new data from the supplier if the current + * cached data is stale (according to {@code pollIntervalMillis}). + */ + @Override + public T get() { + update(); + return this.data; + } + + private void update() { + synchronized (this) { + long now = System.currentTimeMillis(); + if (this.data != null && now < this.lastUpdate + this.pollIntervalMillis) return; + this.lastUpdate = now; + + T newdata = dataSupplier.get(); + if (newdata == null || newdata.equals(this.data)) return; + + // new data, update listeners + this.data = newdata; + } + for (Consumer listener : listeners) { + listener.accept(this.data); + } + } + + /** + * Stops the background polling. + */ + @Override + public synchronized void close() { + closed = true; + if (pollTask != null) { + pollTask.cancel(false); + pollTask = null; + } + } + +} diff --git a/common/src/main/java/de/bluecolored/bluemap/common/web/MapRequestHandler.java b/common/src/main/java/de/bluecolored/bluemap/common/web/MapRequestHandler.java index 16122e429..f0e4603c1 100644 --- a/common/src/main/java/de/bluecolored/bluemap/common/web/MapRequestHandler.java +++ b/common/src/main/java/de/bluecolored/bluemap/common/web/MapRequestHandler.java @@ -24,35 +24,104 @@ */ package de.bluecolored.bluemap.common.web; +import de.bluecolored.bluemap.common.web.http.HttpResponse; +import de.bluecolored.bluemap.common.web.http.HttpStatusCode; +import de.bluecolored.bluemap.core.map.BmMap; import de.bluecolored.bluemap.core.storage.MapStorage; import org.jetbrains.annotations.Nullable; +import com.flowpowered.math.vector.Vector2i; + +import java.io.IOException; +import java.util.function.Consumer; import java.util.function.Supplier; public class MapRequestHandler extends RoutingRequestHandler { + private final SseConnectionManager sseConnections = new SseConnectionManager(); + + public MapRequestHandler( + BmMap map, + @Nullable Supplier livePlayersDataSupplier, + @Nullable Supplier liveMarkerDataSupplier, + boolean useSSE + ) { + this(map.getStorage(), livePlayersDataSupplier, liveMarkerDataSupplier, useSSE); + + if (useSSE) { + map.getHiresModelManager().addTileUpdateListener(tile -> onTileUpdate(tile, 0)); + map.getLowresTileManager().addTileUpdateListener(this::onTileUpdate); + } + } + public MapRequestHandler(MapStorage mapStorage) { - this(mapStorage, null, null); + this(mapStorage, null, null, false); } public MapRequestHandler( MapStorage mapStorage, @Nullable Supplier livePlayersDataSupplier, - @Nullable Supplier liveMarkerDataSupplier + @Nullable Supplier liveMarkerDataSupplier, + boolean useSSE ) { register(".*", new MapStorageRequestHandler(mapStorage)); + if (useSSE) { + register("live/sse", "", _ -> { + HttpResponse response = new HttpResponse(HttpStatusCode.OK); + response.addHeader("Content-Type", "text/event-stream"); + response.addHeader("Cache-Control", "no-cache"); + + // attempt to turn off buffering in upstream proxy + response.addHeader("X-Accel-Buffering", "no"); + + try { + response.setBody(sseConnections.openConnection()); + } catch (IOException e) { + return new HttpResponse(HttpStatusCode.INTERNAL_SERVER_ERROR); + } + return response; + }); + } + if (livePlayersDataSupplier != null) { - register("live/players\\.json", "", new JsonDataRequestHandler( - new CachedRateLimitDataSupplier(livePlayersDataSupplier,1000) - )); + LiveDataSupplierBroadcaster playerDataBroadcaster = new LiveDataSupplierBroadcaster<>(livePlayersDataSupplier, 1000); + if (useSSE) registerSseCallback(playerDataBroadcaster, this::onPlayerUpdate); + register("live/players\\.json", "", new JsonDataRequestHandler(playerDataBroadcaster)); } if (liveMarkerDataSupplier != null) { - register("live/markers\\.json", "", new JsonDataRequestHandler( - new CachedRateLimitDataSupplier(liveMarkerDataSupplier,10000) - )); + LiveDataSupplierBroadcastermarkerDataBroadcaster = new LiveDataSupplierBroadcaster<>(liveMarkerDataSupplier, 10000); + if (useSSE) registerSseCallback(markerDataBroadcaster, this::onMarkerUpdate); + register("live/markers\\.json", "", new JsonDataRequestHandler(markerDataBroadcaster)); } } + /** + * Helper function to subscribe to updates from a broadcaster (forcing it to auto-refresh) + * only if the SSE manager has a connection. + */ + private void registerSseCallback(LiveDataSupplierBroadcaster broadcaster, Consumer callback){ + sseConnections.addHasConnectionsListener(hasConnections -> { + if (hasConnections) { + broadcaster.addUpdateListener(callback); + } else { + broadcaster.removeUpdateListener(callback); + } + }); + } + + private void onTileUpdate(Vector2i tile, int lod) { + // since the data is all ints there's no escaping issues so just build the JSON the hacky fast way + sseConnections.broadcast("tile", "{\"x\":" + tile.getX() + ",\"y\":" + tile.getY() + ",\"lod\":" + lod + "}"); + } + + private void onPlayerUpdate(String data) { + sseConnections.broadcast("player", data); + } + + private void onMarkerUpdate(String data) { + sseConnections.broadcast("marker", data); + } + } diff --git a/common/src/main/java/de/bluecolored/bluemap/common/web/SseConnection.java b/common/src/main/java/de/bluecolored/bluemap/common/web/SseConnection.java new file mode 100644 index 000000000..e211cb795 --- /dev/null +++ b/common/src/main/java/de/bluecolored/bluemap/common/web/SseConnection.java @@ -0,0 +1,148 @@ +/* + * This file is part of BlueMap, licensed under the MIT License (MIT). + * + * Copyright (c) Blue (Lukas Rieger) + * Copyright (c) contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package de.bluecolored.bluemap.common.web; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.BlockingQueue; + +import de.bluecolored.bluemap.core.util.stream.OnCloseInputStream; +import lombok.SneakyThrows; + +/** + * Represents a single Server-Sent Events (SSE) connection. + *

+ * Read the events from the {@link PipedInputStream} returned from {@link #getInputStream()}. + * Reading from the stream will block until a new event is delivered to it. + *

+ * Events are queued via {@link #enqueue(String, String)} and delivered via a virtual thread + * owned by this connection so a slow client only blocks its own delivery. + */ +public class SseConnection implements Closeable { + + private static final int PIPE_BUFFER_SIZE = 1024; + + // how many messages can be queued up for sending (in addition to the above buffer) + // before being dropped + private static final int QUEUE_CAPACITY = 16; + + private final PipedOutputStream pipeOut; + private final InputStream pipeIn; + private final BlockingQueue queue = new LinkedBlockingQueue<>(QUEUE_CAPACITY); + private final Thread sendThread; + private volatile boolean closed = false; + private volatile Runnable onClose; + + public SseConnection() throws IOException { + // add a hook to the pipe to close the conneciton if the stream is closed + this.pipeOut = new PipedOutputStream(); + this.pipeIn = new OnCloseInputStream(new PipedInputStream(pipeOut, PIPE_BUFFER_SIZE), SseConnection.this); + + this.sendThread = Thread.ofVirtual().name("BlueMap-SSE-send").start(this::sendLoop); + } + + /** + * Returns an {@link InputStream} to read events from. + * Closing it also closes this connection. + */ + public InputStream getInputStream() { + return pipeIn; + } + + public boolean isClosed() { + return closed; + } + + /** + * Registers a callback that's called when this connection closes. + *

+ * Returns true if the regsitration suceeded, false if the connection was already closed. + */ + public synchronized boolean setOnClose(Runnable onClose) { + if (closed) return false; + this.onClose = onClose; + return true; + } + + /** + * Queues an SSE event to be delivered to this connection. + *

+ * If this connection's queue is full (due to a slowly-reading client), the event is + * silently dropped and the connection will be closed. + */ + public void enqueue(String eventType, String data) { + if (closed) return; + if (!queue.offer(new String[]{eventType, data})) { + close(); + } + } + + private void sendLoop() { + try { + while (!closed) { + String[] event = queue.take(); + send(event[0], event[1]); + } + } catch (InterruptedException | IOException ignored) {} + } + + @SneakyThrows(IOException.class) // allows using this function in the forEach below + private void writeLine(String line){ + pipeOut.write((line + "\n").getBytes(StandardCharsets.UTF_8)); + } + + /** + * Write one SSE event with optional data to the stream and flush it. + * + * @throws IOException if the connection is closed or the client has disconnected + */ + private synchronized void send(String eventType, String data) throws IOException { + if (closed) throw new IOException("SSE connection is closed"); + try { + writeLine("event: " + eventType); + data.lines().forEach(l -> writeLine("data: " + l)); + pipeOut.write('\n'); + pipeOut.flush(); + } catch (IOException e) { + close(); + throw e; + } + } + + @Override + public synchronized void close() { + if (closed) return; + closed = true; + sendThread.interrupt(); + try { pipeOut.close(); } catch (IOException ignored) {} + if (onClose != null) onClose.run(); + } + +} diff --git a/common/src/main/java/de/bluecolored/bluemap/common/web/SseConnectionManager.java b/common/src/main/java/de/bluecolored/bluemap/common/web/SseConnectionManager.java new file mode 100644 index 000000000..4f462adb5 --- /dev/null +++ b/common/src/main/java/de/bluecolored/bluemap/common/web/SseConnectionManager.java @@ -0,0 +1,121 @@ +/* + * This file is part of BlueMap, licensed under the MIT License (MIT). + * + * Copyright (c) Blue (Lukas Rieger) + * Copyright (c) contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package de.bluecolored.bluemap.common.web; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; + +/** + * Tracks active {@link SseConnection}s and provides broadcast delivery. + *

+ * Broadcasting just queues events into each managed {@link SseConnection} without + * blocking so a slow or stuck client only affects itself. + */ +public class SseConnectionManager implements Closeable { + + private final Set connections = ConcurrentHashMap.newKeySet(); + private volatile boolean closed; + + // allow objects to listen for when the connection count transitions between 0 and non-0 + private final Set> hasConnectionsListeners = ConcurrentHashMap.newKeySet(); + + public void addHasConnectionsListener(Consumer listener) { + hasConnectionsListeners.add(listener); + } + + public void removeHasConnectionsListener(Consumer listener) { + hasConnectionsListeners.remove(listener); + } + + /** + * Creates a new {@link SseConnection}, registers it, and returns an {@link InputStream} suitable + * for use as an HTTP response body. When the stream is closed (either because the client + * disconnected or the server closed the connection), the connection is automatically removed + * from this manager. + */ + public InputStream openConnection() throws IOException { + SseConnection connection = new SseConnection(); + add(connection); + return connection.getInputStream(); + } + + public void add(SseConnection connection) { + boolean fire; + synchronized (this) { + fire = connections.isEmpty(); + if (connections.add(connection)) { + if (!connection.setOnClose(() -> remove(connection))){ + // failed to register an onClose callback (connection is already closed?) + connections.remove(connection); + fire = false; + } + } + } + if (fire) notifyHasConnections(true); + } + + public void remove(SseConnection connection) { + boolean fire; + synchronized (this) { + fire = connections.remove(connection) && connections.isEmpty(); + } + if (fire) notifyHasConnections(false); + } + + /** + * Broadcast an SSE event to all live connections. + * Returns immediately without blocking. + * + * @param eventType the SSE event type + * @param data the event data payload + */ + public void broadcast(String eventType, String data) { + if (closed) return; + for (SseConnection conn : connections) { + conn.enqueue(eventType, data); + } + } + + /** + * Closes all registered connections. Each connection removes itself from the registry + * (via {@link #remove(SseConnection)}) as it closes. + */ + @Override + public void close() { + closed = true; + for (SseConnection conn : connections) { + conn.close(); + } + } + + private void notifyHasConnections(boolean hasConnections) { + hasConnectionsListeners.forEach(listener -> listener.accept(hasConnections)); + } + +} diff --git a/common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpResponseOutputStream.java b/common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpResponseOutputStream.java index ea1abb715..d1b053e00 100644 --- a/common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpResponseOutputStream.java +++ b/common/src/main/java/de/bluecolored/bluemap/common/web/http/HttpResponseOutputStream.java @@ -68,6 +68,7 @@ public void write(HttpResponse response) throws IOException { writeLine(Integer.toHexString(read)); outputStream.write(byteBuffer, 0, read); writeLine(); + outputStream.flush(); // prevent SSE from being buffered } writeLine(Integer.toHexString(0)); diff --git a/common/src/main/resources/de/bluecolored/bluemap/config/webserver.conf b/common/src/main/resources/de/bluecolored/bluemap/config/webserver.conf index 44174b457..ad85f7fda 100644 --- a/common/src/main/resources/de/bluecolored/bluemap/config/webserver.conf +++ b/common/src/main/resources/de/bluecolored/bluemap/config/webserver.conf @@ -17,6 +17,10 @@ webroot: "${webroot}" # Default is 8100 port: 8100 +# Whether to use Server-Sent Events (SSE) for pushing tile and marker-updates to the connected clients. +# Default is true +sse-enabled: true + # Config-section for webserver activity logging: log: { # The file where all the webserver activity will be logged to. diff --git a/common/webapp/src/js/BlueMapApp.js b/common/webapp/src/js/BlueMapApp.js index ba3fbc685..7d85fc3fc 100644 --- a/common/webapp/src/js/BlueMapApp.js +++ b/common/webapp/src/js/BlueMapApp.js @@ -28,7 +28,7 @@ import {MapControls} from "./controls/map/MapControls"; import {FreeFlightControls} from "./controls/freeflight/FreeFlightControls"; import {MathUtils, Vector3} from "three"; import {Map as BlueMapMap} from "./map/Map"; -import {alert, animate, EasingFunctions} from "./util/Utils"; +import {alert, animate, EasingFunctions, hashTile} from "./util/Utils"; import {MainMenu} from "./MainMenu"; import {PopupMarker} from "./PopupMarker"; import {MarkerSet} from "./markers/MarkerSet"; @@ -57,6 +57,9 @@ export class BlueMapApp { /** @type {NormalMarkerManager} */ this.markerFileManager = null; + /** @type {EventSource} */ + this.mapEventSource = null; + /** @type {{ * version: string, * useCookies: boolean, @@ -108,6 +111,11 @@ export class BlueMapApp { debug: false }); + // close SSE connection when the page is closed + window.addEventListener("beforeunload", () => { + if (this.mapEventSource) this.mapEventSource.close(); + }); + // init this.updateControlsSettings(); this.initGeneralEvents(); @@ -265,7 +273,8 @@ export class BlueMapApp { await Promise.all([ this.initPlayerMarkerManager(), - this.initMarkerFileManager() + this.initMarkerFileManager(), + this.initEventSource(), ]); } @@ -400,6 +409,48 @@ export class BlueMapApp { }); } + initEventSource(){ + if (this.mapEventSource) { + this.mapEventSource.close(); + } + + const map = this.mapViewer.map; + if (!map) return; + + this.mapEventSource = new EventSource(map.data.liveDataRoot + "/live/sse"); + this.mapEventSource.addEventListener("error", () => { + alert(this.events, "SSE event source error - enabling polling", "debug"); + this.playerMarkerManager.resumeAutoUpdates(); + this.markerFileManager.resumeAutoUpdates(); + }); + this.mapEventSource.addEventListener("open", () => { + alert(this.events, "Connected to SSE event source - disabling polling", "debug"); + this.playerMarkerManager.pauseAutoUpdates(); + this.markerFileManager.pauseAutoUpdates(); + }); + + this.mapEventSource.addEventListener("tile", ({ data }) => { + const parsed = JSON.parse(data); + + const mgr = parsed.lod > 0 ? map.lowresTileManager[parsed.lod - 1] : map.hiresTileManager; + if (mgr && !mgr.unloaded) { + const tilehash = hashTile(parsed.x, parsed.y); + const tile = mgr.tiles.get(tilehash); + if (tile && !tile.loading) { + tile.load(mgr.tileLoader, true); + } + } + }); + + this.mapEventSource.addEventListener("player", ({ data }) => { + this.playerMarkerManager.updateFromData(JSON.parse(data)); + }); + + this.mapEventSource.addEventListener("marker", ({ data }) => { + this.markerFileManager.updateFromData(JSON.parse(data)); + }); + } + initPlayerMarkerManager() { if (this.playerMarkerManager) this.playerMarkerManager.dispose() @@ -411,7 +462,8 @@ export class BlueMapApp { this.mapViewer.markers, map.data.liveDataRoot + "/live/players.json", map.data.mapDataRoot + "/assets/playerheads/", - this.events + this.events, + true ); this.playerMarkerManager.setAutoUpdateInterval(0); return this.playerMarkerManager.update() @@ -431,7 +483,7 @@ export class BlueMapApp { const map = this.mapViewer.map; if (!map) return; - this.markerFileManager = new NormalMarkerManager(this.mapViewer.markers, map.data.liveDataRoot + "/live/markers.json", this.events); + this.markerFileManager = new NormalMarkerManager(this.mapViewer.markers, map.data.liveDataRoot + "/live/markers.json", this.events, true); return this.markerFileManager.update() .then(() => { this.markerFileManager.setAutoUpdateInterval(1000 * 10); diff --git a/common/webapp/src/js/map/LowresTileLoader.js b/common/webapp/src/js/map/LowresTileLoader.js index 632d6052c..3388683de 100644 --- a/common/webapp/src/js/map/LowresTileLoader.js +++ b/common/webapp/src/js/map/LowresTileLoader.js @@ -62,11 +62,14 @@ export class LowresTileLoader { this.geometry.translate(tileSettings.tileSize.x / 2 + 1, 0, tileSettings.tileSize.x / 2 + 1); } - load = (tileX, tileZ, cancelCheck = () => false) => { + load = (tileX, tileZ, cancelCheck = () => false, force = false) => { let tileUrl = this.tilePath + this.lod + "/" + pathFromCoords(tileX, tileZ) + '.png'; //await this.loadBlocker(); return new Promise((resolve, reject) => { + if (force) { + this.revalidatedUrls.delete(tileUrl); + } this.textureLoader.setRevalidatedUrls(this.revalidatedUrls); this.textureLoader.load(tileUrl, async texture => { diff --git a/common/webapp/src/js/map/Tile.js b/common/webapp/src/js/map/Tile.js index 375bc56c7..a85599dd5 100644 --- a/common/webapp/src/js/map/Tile.js +++ b/common/webapp/src/js/map/Tile.js @@ -50,20 +50,21 @@ export class Tile { * @param tileLoader {TileLoader} * @returns {Promise} */ - load(tileLoader) { + load(tileLoader, force = false) { if (this.loading) return Promise.reject("tile is already loading!"); this.loading = true; - this.unload(); - this.unloaded = false; - return tileLoader.load(this.x, this.z, () => this.unloaded) + return tileLoader.load(this.x, this.z, () => this.unloaded, force) .then(model => { if (this.unloaded){ Tile.disposeModel(model); return; } + this.unload(); + this.unloaded = false; + this.model = model; this.onLoad(this); }, () => { diff --git a/common/webapp/src/js/map/TileLoader.js b/common/webapp/src/js/map/TileLoader.js index 7fedf0047..49e580160 100644 --- a/common/webapp/src/js/map/TileLoader.js +++ b/common/webapp/src/js/map/TileLoader.js @@ -61,13 +61,16 @@ export class TileLoader { this.bufferGeometryLoader = new PRBMLoader(); } - load = (tileX, tileZ, cancelCheck = () => false) => { + load = (tileX, tileZ, cancelCheck = () => false, force = false) => { let tileUrl = this.tilePath + pathFromCoords(tileX, tileZ) + '.prbm'; if (this.clientDecompression) { tileUrl += '.gz'; } return new Promise((resolve, reject) => { + if (force) { + this.revalidatedUrls.delete(tileUrl); + } this.fileLoader.setRevalidatedUrls(this.revalidatedUrls); this.fileLoader.load(tileUrl, async data => { diff --git a/common/webapp/src/js/markers/MarkerManager.js b/common/webapp/src/js/markers/MarkerManager.js index 33b31cab1..59bb27db7 100644 --- a/common/webapp/src/js/markers/MarkerManager.js +++ b/common/webapp/src/js/markers/MarkerManager.js @@ -37,7 +37,7 @@ export class MarkerManager { * @param fileUrl {string} - The marker file from which this manager updates its markers * @param events {EventTarget} */ - constructor(root, fileUrl, events = null) { + constructor(root, fileUrl, events = null, paused = false) { Object.defineProperty(this, 'isMarkerManager', {value: true}); this.root = root; @@ -47,6 +47,8 @@ export class MarkerManager { /** @type {NodeJS.Timeout} */ this._updateInterval = null; + this._updateIntervalMillis = 0; + this._paused = paused; } /** @@ -55,28 +57,48 @@ export class MarkerManager { * @param ms - interval in milliseconds */ setAutoUpdateInterval(ms) { + this._updateIntervalMillis = ms; if (this._updateInterval) clearTimeout(this._updateInterval); - if (ms > 0) { + if (!this._paused && ms > 0) { let autoUpdate = () => { if (this.disposed) return; this.update() .then(success => { - if (success) { - this._updateInterval = setTimeout(autoUpdate, ms); - } else { - this._updateInterval = setTimeout(autoUpdate, Math.max(ms, 1000 * 15)); + if (!this._paused){ + if (success) { + this._updateInterval = setTimeout(autoUpdate, ms); + } else { + this._updateInterval = setTimeout(autoUpdate, Math.max(ms, 1000 * 15)); + } } }) .catch(e => { alert(this.events, e, "warning"); - this._updateInterval = setTimeout(autoUpdate, Math.max(ms, 1000 * 15)); + if (!this._paused) this._updateInterval = setTimeout(autoUpdate, Math.max(ms, 1000 * 15)); }); }; - this._updateInterval = setTimeout(autoUpdate, ms); + if (!this._paused) this._updateInterval = setTimeout(autoUpdate, ms); } } + /** + * Pause auto-updates + */ + pauseAutoUpdates() { + this._paused = true; + if (this._updateInterval) clearTimeout(this._updateInterval); + } + + /** + * Resume auto-updates + */ + resumeAutoUpdates(){ + if (this.disposed) return; + this._paused = false; + this.setAutoUpdateInterval(this._updateIntervalMillis) + } + /** * Loads the marker-file and updates all managed markers. * @returns {Promise} - A promise completing when the markers finished updating @@ -88,7 +110,6 @@ export class MarkerManager { } /** - * @protected * @param markerData */ updateFromData(markerData) {} diff --git a/common/webapp/src/js/markers/NormalMarkerManager.js b/common/webapp/src/js/markers/NormalMarkerManager.js index a9d821671..c0cda1a4b 100644 --- a/common/webapp/src/js/markers/NormalMarkerManager.js +++ b/common/webapp/src/js/markers/NormalMarkerManager.js @@ -33,8 +33,8 @@ export class NormalMarkerManager extends MarkerManager { * @param fileUrl {string} - The marker file from which this manager updates its markers * @param events {EventTarget} */ - constructor(root, fileUrl, events = null) { - super(root, fileUrl, events); + constructor(root, fileUrl, events = null, paused = false) { + super(root, fileUrl, events, paused); } /** diff --git a/common/webapp/src/js/markers/PlayerMarkerManager.js b/common/webapp/src/js/markers/PlayerMarkerManager.js index 93a99d3c2..0501c88c5 100644 --- a/common/webapp/src/js/markers/PlayerMarkerManager.js +++ b/common/webapp/src/js/markers/PlayerMarkerManager.js @@ -36,8 +36,8 @@ export class PlayerMarkerManager extends MarkerManager { * @param playerheadsUrl {string} - The url from which playerhead images should be loaded * @param events {EventTarget} */ - constructor(root, fileUrl, playerheadsUrl, events = null) { - super(root, fileUrl, events); + constructor(root, fileUrl, playerheadsUrl, events = null, paused = false) { + super(root, fileUrl, events, paused); this.playerheadsUrl = playerheadsUrl; } diff --git a/core/src/main/java/de/bluecolored/bluemap/core/map/hires/HiresModelManager.java b/core/src/main/java/de/bluecolored/bluemap/core/map/hires/HiresModelManager.java index dcd6085a2..2cfb8d281 100644 --- a/core/src/main/java/de/bluecolored/bluemap/core/map/hires/HiresModelManager.java +++ b/core/src/main/java/de/bluecolored/bluemap/core/map/hires/HiresModelManager.java @@ -40,12 +40,15 @@ import java.io.OutputStream; import java.util.Collection; import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; public class HiresModelManager { private final World world; private final GridStorage storage; private final ThreadLocal> renderPasses; + private final List> tileUpdateListeners = new CopyOnWriteArrayList<>(); @Getter private final Grid tileGrid; @@ -113,6 +116,14 @@ public void unrender(Vector2i tile, TileMetaConsumer tileMetaConsumer) { ); } + public void addTileUpdateListener(Consumer listener) { + tileUpdateListeners.add(listener); + } + + public void removeTileUpdateListener(Consumer listener) { + tileUpdateListeners.remove(listener); + } + private void save(final ArrayTileModel model, Vector2i tile) { try ( OutputStream out = storage.write(tile.getX(), tile.getY()); @@ -121,6 +132,12 @@ private void save(final ArrayTileModel model, Vector2i tile) { modelWriter.write(model); } catch (IOException e){ Logger.global.logError("Failed to save hires model: " + tile, e); + return; + } + + // notify listeners that the tile changed + for (Consumer listener : this.tileUpdateListeners) { + listener.accept(tile); } } diff --git a/core/src/main/java/de/bluecolored/bluemap/core/map/lowres/LowresLayer.java b/core/src/main/java/de/bluecolored/bluemap/core/map/lowres/LowresLayer.java index a7b90b990..f782a155a 100644 --- a/core/src/main/java/de/bluecolored/bluemap/core/map/lowres/LowresLayer.java +++ b/core/src/main/java/de/bluecolored/bluemap/core/map/lowres/LowresLayer.java @@ -39,9 +39,12 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; public class LowresLayer { @@ -61,6 +64,7 @@ public class LowresLayer { @Nullable private final LowresLayer nextLayer; private final Map pendingChanges; + private final List> tileUpdateListeners = new CopyOnWriteArrayList<>(); public LowresLayer( GridStorage storage, Grid tileGrid, int lodFactor, @@ -89,6 +93,14 @@ public LowresLayer( this.pendingChanges = new ConcurrentHashMap<>(); } + public void addTileUpdateListener(BiConsumer listener) { + tileUpdateListeners.add(listener); + } + + public void removeTileUpdateListener(BiConsumer listener) { + tileUpdateListeners.remove(listener); + } + public void save() { pendingChanges.entrySet().removeIf(entry -> saveTile(entry.getKey(), entry.getValue())); if (pendingChanges.size() >= DISCARD_THRESHOLD) { @@ -130,6 +142,11 @@ private boolean saveTile(Vector2i tilePos, LowresTile tile) { return false; } + // notify listeners that the tile changed + for (BiConsumer listener : this.tileUpdateListeners) { + listener.accept(tilePos, lod); + } + if (this.nextLayer == null) return true; // write to next LOD (prepare for the most confusing grid-math you will ever see) diff --git a/core/src/main/java/de/bluecolored/bluemap/core/map/lowres/LowresTileManager.java b/core/src/main/java/de/bluecolored/bluemap/core/map/lowres/LowresTileManager.java index 6e16b5457..e99b19023 100644 --- a/core/src/main/java/de/bluecolored/bluemap/core/map/lowres/LowresTileManager.java +++ b/core/src/main/java/de/bluecolored/bluemap/core/map/lowres/LowresTileManager.java @@ -24,11 +24,14 @@ */ package de.bluecolored.bluemap.core.map.lowres; +import com.flowpowered.math.vector.Vector2i; import de.bluecolored.bluemap.core.map.TileMetaConsumer; import de.bluecolored.bluemap.core.storage.MapStorage; import de.bluecolored.bluemap.core.util.Grid; import de.bluecolored.bluemap.core.util.math.Color; +import java.util.function.BiConsumer; + public class LowresTileManager implements TileMetaConsumer { private final Grid tileGrid; @@ -48,6 +51,18 @@ public LowresTileManager(MapStorage storage, Grid tileGrid, int lodCount, int lo } } + public void addTileUpdateListener(BiConsumer listener) { + for (LowresLayer layer : this.layers) { + layer.addTileUpdateListener(listener); + } + } + + public void removeTileUpdateListener(BiConsumer listener) { + for (LowresLayer layer : this.layers) { + layer.removeTileUpdateListener(listener); + } + } + public synchronized void save() { for (LowresLayer layer : this.layers) { layer.save(); diff --git a/implementations/cli/src/main/java/de/bluecolored/bluemap/cli/BlueMapCLI.java b/implementations/cli/src/main/java/de/bluecolored/bluemap/cli/BlueMapCLI.java index 903329dab..4cd02e4b0 100644 --- a/implementations/cli/src/main/java/de/bluecolored/bluemap/cli/BlueMapCLI.java +++ b/implementations/cli/src/main/java/de/bluecolored/bluemap/cli/BlueMapCLI.java @@ -32,6 +32,7 @@ import de.bluecolored.bluemap.common.api.BlueMapAPIImpl; import de.bluecolored.bluemap.common.commands.TextFormat; import de.bluecolored.bluemap.common.config.*; +import de.bluecolored.bluemap.common.live.LiveMarkersDataSupplier; import de.bluecolored.bluemap.common.metrics.Metrics; import de.bluecolored.bluemap.common.plugin.MapUpdateService; import de.bluecolored.bluemap.common.rendermanager.MapUpdatePreparationTask; @@ -238,15 +239,20 @@ public void run() { ); // wait until done, then shutdown if not watching - renderManager.awaitIdle(); - if (shutdownInProgress) return; + Thread.ofPlatform().name("BlueMap-CLI-Render").start(() -> { + try { + renderManager.awaitIdle(); + } catch (InterruptedException ignore) {} - Logger.global.logInfo("Your maps are now all up-to-date!"); + if (shutdownInProgress) return; - if (!watch) { - Runtime.getRuntime().removeShutdownHook(shutdownHook); - shutdown.run(); - } + Logger.global.logInfo("Your maps are now all up-to-date!"); + + if (!watch) { + Runtime.getRuntime().removeShutdownHook(shutdownHook); + shutdown.run(); + } + }); } public void updateMarkers(BlueMapService blueMap, @Nullable String mapsToUpdate) { @@ -292,11 +298,16 @@ public void startWebserver(BlueMapService blueMap, boolean verbose) throws IOExc for (var mapConfigEntry : blueMap.getConfig().getMapConfigs().entrySet()) { MapStorage storage = blueMap.getOrLoadStorage(mapConfigEntry.getValue().getStorage()) .map(mapConfigEntry.getKey()); + BmMap map = blueMap.getMaps().get(mapConfigEntry.getKey()); + + MapRequestHandler mapRequestHandler = map != null ? + new MapRequestHandler(map, null, new LiveMarkersDataSupplier(map.getMarkerSets()), config.isSseEnabled()) : + new MapRequestHandler(storage); routingRequestHandler.register( "maps/" + Pattern.quote(mapConfigEntry.getKey()) + "/(.*)", "$1", - new MapRequestHandler(storage) + mapRequestHandler ); } @@ -418,13 +429,6 @@ public static void main(String[] args) { blueMap = new BlueMapService(configs); boolean noActions = true; - if (cmd.hasOption("w")) { - noActions = false; - - cli.startWebserver(blueMap, cmd.hasOption("b")); - Thread.sleep(1000); //wait a second to let the webserver start, looks nicer in the log if anything comes after that - } - if (cmd.hasOption("r") || cmd.hasOption("f") || cmd.hasOption("u") || cmd.hasOption("e")) { noActions = false; @@ -451,6 +455,11 @@ public static void main(String[] args) { } } + if (cmd.hasOption("w")) { + noActions = false; + cli.startWebserver(blueMap, cmd.hasOption("b")); + } + // if nothing has been defined to do if (noActions) { Logger.global.logInfo("Generated default config files for you, here: " + cli.configFolder.toAbsolutePath().normalize() + "\n");