built-in ws: reload only associated page (WEB-49146)

GitOrigin-RevId: 73f2de71c4bc05f12130af555ec86bc524903553
This commit is contained in:
Konstantin Ulitin
2021-04-16 11:53:07 +02:00
committed by intellij-monorepo-bot
parent 3fbeaa8ae3
commit 4baf87789c
2 changed files with 138 additions and 51 deletions

View File

@@ -2,8 +2,8 @@
package org.jetbrains.builtInWebServer.liveReload;
import com.intellij.openapi.vfs.AsyncFileListener;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.openapi.vfs.newvfs.events.VFileEvent;
import com.intellij.util.containers.ContainerUtil;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -11,26 +11,9 @@ import java.util.List;
class WebServerFileContentListener implements AsyncFileListener {
private static final ChangeApplier RELOAD_ALL = new ChangeApplier() {
@Override
public void afterVfsChange() {
WebServerPageConnectionService.getInstance().reloadAll();
}
};
@Nullable
@Override
public ChangeApplier prepareChange(@NotNull List<? extends @NotNull VFileEvent> events) {
boolean hasRelatedFileChanged = false;
for (VFileEvent event : events) {
VirtualFile file = event.getFile();
if (file != null && WebServerPageConnectionService.getInstance().isFileRequested(file)) {
hasRelatedFileChanged = true;
break;
}
}
if (!hasRelatedFileChanged) return null;
return RELOAD_ALL;
return WebServerPageConnectionService.getInstance().reloadRelatedClients(ContainerUtil.map(events, VFileEvent::getFile));
}
}

View File

@@ -1,13 +1,15 @@
// Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package org.jetbrains.builtInWebServer.liveReload;
import com.google.common.net.HttpHeaders;
import com.intellij.openapi.Disposable;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.components.Service;
import com.intellij.openapi.util.Disposer;
import com.intellij.openapi.util.text.StringUtil;
import com.intellij.openapi.vfs.AsyncFileListener;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.openapi.vfs.VirtualFileManager;
import com.intellij.util.io.NettyKt;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.handler.codec.http.FullHttpRequest;
@@ -15,36 +17,70 @@ import io.netty.handler.codec.http.QueryStringDecoder;
import io.netty.util.CharsetUtil;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.builtInWebServer.BuiltInServerOptions;
import org.jetbrains.io.jsonRpc.Client;
import org.jetbrains.io.jsonRpc.ClientManager;
import org.jetbrains.io.jsonRpc.JsonRpcServer;
import org.jetbrains.io.jsonRpc.MessageServer;
import org.jetbrains.io.webSocket.WebSocketClient;
import org.jetbrains.io.webSocket.WebSocketHandshakeHandler;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.net.URI;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
/**
* Provides support for automatic reloading of pages opened on built-in web server on related files modification.
*
* Implementation:
*
* <-- html page with {@link #RELOAD_URL_PARAM} is requested
* --> response with modified html which opens WebSocket connection listening for reload message
*
* <-- script or other resource of html is requested
* start listening for related file changes
*
* file is changed
* --> reload associated pages by sending WebSocket message
*/
@Service(Service.Level.APP)
public final class WebServerPageConnectionService {
public static final String RELOAD_URL_PARAM = "_ij_reload";
private static final String RELOAD_WS_REQUEST = "reload";
private static final String RELOAD_WS_URL_PREFIX = "jb-server-page";
private static final String RELOAD_CLIENT_ID_URL_PARAMETER = "reloadServiceClientId";
private final @NotNull ByteBuf RELOAD_PAGE_MESSAGE = Unpooled.copiedBuffer(RELOAD_WS_REQUEST, CharsetUtil.US_ASCII).asReadOnly();
private @Nullable ClientManager myServer;
private @Nullable JsonRpcServer myRpcServer;
private final @NotNull AtomicInteger ourListenersCount = new AtomicInteger(0);
private volatile @Nullable Disposable ourListenerDisposable;
private final @NotNull Set<VirtualFile> myRequestedFiles = ConcurrentHashMap.newKeySet();
private final @NotNull AtomicInteger myClientsCount = new AtomicInteger(0);
private volatile @Nullable Disposable myListenerDisposable;
private final @NotNull AtomicInteger myTotalClientsCount = new AtomicInteger(0);
private final @NotNull Set<VirtualFile> myRequestedFilesWithoutReferrer = ConcurrentHashMap.newKeySet();
private final @NotNull Map<String, RequestedPage> myRequestedPages = new ConcurrentHashMap<>();
private final @NotNull AsyncFileListener.ChangeApplier RELOAD_ALL = new AsyncFileListener.ChangeApplier() {
@Override
public void afterVfsChange() {
myRequestedFilesWithoutReferrer.clear();
Iterator<Map.Entry<String, RequestedPage>> iterator = myRequestedPages.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, RequestedPage> next = iterator.next();
next.getValue().myClient.cancel(false);
iterator.remove();
}
ClientManager server = myServer;
if (server != null) {
server.send(-1, RELOAD_PAGE_MESSAGE.retainedDuplicate(), null);
}
}
};
public static WebServerPageConnectionService getInstance() {
return ApplicationManager.getApplication().getService(WebServerPageConnectionService.class);
@@ -59,19 +95,35 @@ public final class WebServerPageConnectionService {
isReloadRequest = decoder.parameters().containsKey(RELOAD_URL_PARAM);
}
if (isReloadRequest || !myRequestedFiles.isEmpty()) {
VirtualFile file = fileSupplier.get();
if (file != null) {
myRequestedFiles.add(file);
if (!isReloadRequest && myRequestedPages.isEmpty()) return null;
VirtualFile file = fileSupplier.get();
if (!isReloadRequest && file != null) {
String referer = request.headers().get(HttpHeaders.REFERER);
RequestedPage requestedPage = null;
try {
URI refererUri = URI.create(referer);
String refererWithoutHost = refererUri.getPath() + "?" + refererUri.getQuery();
requestedPage = myRequestedPages.get(refererWithoutHost);
}
catch (Throwable ignore) {}
if (requestedPage != null) {
requestedPage.myFiles.add(file);
}
else {
myRequestedFilesWithoutReferrer.add(file);
}
}
if (!isReloadRequest) return null;
String host = NettyKt.getHost(request);
if (host == null) host = "localhost:" + BuiltInServerOptions.getInstance().getEffectiveBuiltInServerPort();
int clientId = myTotalClientsCount.incrementAndGet();
myRequestedPages.put(uri, new RequestedPage(clientId, file));
return new StringBuilder()
.append("\n<script>\n")
.append("new WebSocket('ws://").append(host).append("/").append(RELOAD_WS_URL_PREFIX).append("').onmessage = function (msg) {\n")
.append("new WebSocket('ws://' + window.location.host + '/").append(RELOAD_WS_URL_PREFIX)
.append("?").append(RELOAD_CLIENT_ID_URL_PARAMETER).append("=").append(clientId)
.append("').onmessage = function (msg) {\n")
.append(" if (msg.data === '").append(RELOAD_WS_REQUEST).append("') {\n")
.append(" window.location.reload();\n")
.append(" }\n")
@@ -79,30 +131,64 @@ public final class WebServerPageConnectionService {
.append("</script>");
}
public boolean isFileRequested(@NotNull VirtualFile file) {
return myRequestedFiles.contains(file);
}
public void reloadAll() {
public @Nullable AsyncFileListener.ChangeApplier reloadRelatedClients(@NotNull List<VirtualFile> modifiedFiles) {
ClientManager server = myServer;
if (server != null) {
server.send(-1, RELOAD_PAGE_MESSAGE.retainedDuplicate(), null);
if (server == null) return null;
Set<RequestedPage> affectedPages = new HashSet<>();
for (VirtualFile modifiedFile : modifiedFiles) {
if (myRequestedFilesWithoutReferrer.contains(modifiedFile)) {
return RELOAD_ALL;
}
for (RequestedPage requestedPage : myRequestedPages.values()) {
if (requestedPage.myFiles.contains(modifiedFile)) {
affectedPages.add(requestedPage);
}
}
}
return new AsyncFileListener.ChangeApplier() {
@Override
public void afterVfsChange() {
for (RequestedPage affectedPage : affectedPages) {
affectedPage.myClient.thenAccept(client -> {
client.send(RELOAD_PAGE_MESSAGE.retainedDuplicate());
});
}
}
};
}
private void clientConnected() {
if (ourListenersCount.incrementAndGet() == 1) {
private void clientConnected(@NotNull WebSocketClient client, int clientId) {
if (myClientsCount.incrementAndGet() == 1) {
Disposable disposable =
Disposer.newDisposable(ApplicationManager.getApplication(), WebServerFileContentListener.class.getSimpleName());
VirtualFileManager.getInstance().addAsyncFileListener(new WebServerFileContentListener(), disposable);
ourListenerDisposable = disposable;
myListenerDisposable = disposable;
}
for (RequestedPage requestedPage : myRequestedPages.values()) {
if (requestedPage.myClientId == clientId) {
requestedPage.myClient.complete(client);
break;
}
}
}
private void clientDisconnected() {
if (ourListenersCount.decrementAndGet() == 0) {
Disposer.dispose(Objects.requireNonNull(ourListenerDisposable));
ourListenerDisposable = null;
private void clientDisconnected(@NotNull WebSocketClient client) {
if (myClientsCount.decrementAndGet() == 0) {
Disposer.dispose(Objects.requireNonNull(myListenerDisposable));
myListenerDisposable = null;
}
String requestedPageKey = null;
for (Map.Entry<String, RequestedPage> requestedPage : myRequestedPages.entrySet()) {
if (requestedPage.getValue().myClient.isDone() && requestedPage.getValue().myClient.getNow(null) == client) {
requestedPageKey = requestedPage.getKey();
break;
}
}
if (requestedPageKey != null) {
myRequestedPages.remove(requestedPageKey);
}
}
@@ -128,12 +214,30 @@ public final class WebServerPageConnectionService {
@Override
public void connected(@NotNull Client client, @Nullable Map<String, List<String>> parameters) {
getInstance().clientConnected();
if (parameters == null || !(client instanceof WebSocketClient)) return;
List<String> ids = parameters.get(RELOAD_CLIENT_ID_URL_PARAMETER);
if (ids.size() != 1) return;
int id = StringUtil.parseInt(ids.get(0), -1);
if (id == -1) return;
getInstance().clientConnected((WebSocketClient)client, id);
}
@Override
public void disconnected(@NotNull Client client) {
getInstance().clientDisconnected();
if (client instanceof WebSocketClient) {
getInstance().clientDisconnected((WebSocketClient)client);
}
}
}
private static class RequestedPage {
private final int myClientId;
private final @NotNull Set<VirtualFile> myFiles = ConcurrentHashMap.newKeySet();
private final @NotNull CompletableFuture<WebSocketClient> myClient = new CompletableFuture<>();
private RequestedPage(int clientId, @NotNull VirtualFile requestedPageFile) {
myClientId = clientId;
myFiles.add(requestedPageFile);
}
}
}