package com.jetbrains.lang.dart.pubServer;
import com.intellij.execution.ExecutionException;
import com.intellij.execution.configurations.GeneralCommandLine;
import com.intellij.execution.filters.TextConsoleBuilder;
import com.intellij.execution.filters.UrlFilter;
import com.intellij.execution.process.OSProcessHandler;
import com.intellij.execution.process.ProcessAdapter;
import com.intellij.execution.process.ProcessEvent;
import com.intellij.execution.process.ProcessOutputTypes;
import com.intellij.notification.Notification;
import com.intellij.notification.NotificationGroup;
import com.intellij.notification.NotificationListener;
import com.intellij.notification.NotificationType;
import com.intellij.openapi.actionSystem.ActionGroup;
import com.intellij.openapi.actionSystem.ActionManager;
import com.intellij.openapi.actionSystem.DefaultActionGroup;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.ui.popup.Balloon;
import com.intellij.openapi.util.Key;
import com.intellij.openapi.util.io.FileUtil;
import com.intellij.openapi.util.registry.Registry;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.openapi.wm.ToolWindowManager;
import com.intellij.util.Consumer;
import com.intellij.util.containers.ContainerUtil;
import com.intellij.util.net.NetKt;
import com.jetbrains.lang.dart.DartBundle;
import com.jetbrains.lang.dart.ide.actions.DartPubActionBase;
import com.jetbrains.lang.dart.ide.runner.DartConsoleFilter;
import com.jetbrains.lang.dart.ide.runner.DartRelativePathsConsoleFilter;
import com.jetbrains.lang.dart.sdk.DartSdk;
import com.jetbrains.lang.dart.sdk.DartSdkUtil;
import icons.DartIcons;
import io.netty.bootstrap.Bootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.handler.codec.http.*;
import io.netty.util.ReferenceCounted;
import io.netty.util.internal.PlatformDependent;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.builtInWebServer.ConsoleManager;
import org.jetbrains.builtInWebServer.NetService;
import org.jetbrains.concurrency.AsyncPromise;
import org.jetbrains.ide.PooledThreadExecutor;
import org.jetbrains.io.*;
import javax.swing.*;
import javax.swing.event.HyperlinkEvent;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.util.Collection;
import java.util.Deque;
import java.util.Locale;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicInteger;
import static org.jetbrains.io.NettyUtil.nioClientBootstrap;
final class PubServerService extends NetService {
private static final Logger LOG = Logger.getInstance(PubServerService.class.getName());
private static final String PUB_SERVE = "Pub Serve";
private static final NotificationGroup NOTIFICATION_GROUP = NotificationGroup.toolWindowGroup(PUB_SERVE, PUB_SERVE, false);
private volatile VirtualFile firstServedDir;
private final Bootstrap bootstrap = nioClientBootstrap(new NioEventLoopGroup(1, PooledThreadExecutor.INSTANCE));
private final ConcurrentMap<Channel, ClientInfo> serverToClientChannel = ContainerUtil.newConcurrentMap();
private final ChannelRegistrar serverChannelRegistrar = new ChannelRegistrar();
private final ConcurrentMap<VirtualFile, ServerInfo> servedDirToSocketAddress = ContainerUtil.newConcurrentMap();
private static class ServerInfo {
private final InetSocketAddress address;
private final Deque<Channel> freeServerChannels = PlatformDependent.newConcurrentDeque();
private ServerInfo(InetSocketAddress address) {
this.address = address;
}
}
private static class ClientInfo {
private final Channel channel;
private final HttpHeaders extraHeaders;
private ClientInfo(@NotNull Channel channel, @NotNull HttpHeaders extraHeaders) {
this.channel = channel;
this.extraHeaders = extraHeaders;
}
}
private final ChannelFutureListener serverChannelCloseListener = future -> {
Channel channel = future.channel();
ServerInfo serverInfo = getServerInfo(channel);
if (serverInfo != null) {
serverInfo.freeServerChannels.remove(channel);
}
ClientInfo clientInfo = serverToClientChannel.remove(channel);
if (clientInfo != null) {
sendBadGateway(clientInfo.channel, clientInfo.extraHeaders);
}
};
public PubServerService(@NotNull Project project, @NotNull ConsoleManager consoleManager) {
super(project, consoleManager);
bootstrap.handler(new ChannelInitializer() {
@Override
protected void initChannel(Channel channel) throws Exception {
channel.pipeline().addLast(serverChannelRegistrar, new HttpClientCodec());
channel.pipeline().addLast(new PubServeChannelHandler(), ChannelExceptionHandler.getInstance());
}
});
}
@Nullable
private ServerInfo getServerInfo(@NotNull Channel channel) {
for (ServerInfo serverInstanceInfo : servedDirToSocketAddress.values()) {
if (channel.remoteAddress().equals(serverInstanceInfo.address)) {
return serverInstanceInfo;
}
}
return null;
}
@Override
@NotNull
protected String getConsoleToolWindowId() {
return PUB_SERVE;
}
@Override
@NotNull
protected Icon getConsoleToolWindowIcon() {
return DartIcons.Dart_13;
}
@NotNull
@Override
public ActionGroup getConsoleToolWindowActions() {
return new DefaultActionGroup(ActionManager.getInstance().getAction("Dart.stop.pub.server"));
}
@Override
protected void configureConsole(@NotNull final TextConsoleBuilder consoleBuilder) {
consoleBuilder.addFilter(new DartConsoleFilter(getProject(), firstServedDir));
consoleBuilder.addFilter(new DartRelativePathsConsoleFilter(getProject(), firstServedDir.getParent().getPath()));
consoleBuilder.addFilter(new UrlFilter());
}
public boolean isPubServerProcessAlive() {
return getProcessHandler().has() && !getProcessHandler().getResult().isProcessTerminated();
}
public void sendToPubServer(@NotNull final Channel clientChannel,
@NotNull final FullHttpRequest clientRequest,
@NotNull HttpHeaders extraHeaders,
@NotNull final VirtualFile servedDir,
@NotNull final String pathForPubServer) {
clientRequest.retain();
if (getProcessHandler().has()) {
sendToServer(servedDir, clientChannel, clientRequest, extraHeaders, pathForPubServer);
}
else {
firstServedDir = servedDir;
getProcessHandler().get()
.done(osProcessHandler -> sendToServer(servedDir, clientChannel, clientRequest, extraHeaders, pathForPubServer))
.rejected(throwable -> sendBadGateway(clientChannel, extraHeaders));
}
}
@Override
@Nullable
protected OSProcessHandler createProcessHandler(@NotNull final Project project, final int port) throws ExecutionException {
final DartSdk dartSdk = DartSdk.getDartSdk(project);
if (dartSdk == null) return null;
final GeneralCommandLine commandLine = new GeneralCommandLine().withWorkDirectory(firstServedDir.getParent().getPath());
commandLine.setExePath(FileUtil.toSystemDependentName(DartSdkUtil.getPubPath(dartSdk)));
commandLine.addParameter("serve");
commandLine.addParameter(firstServedDir.getName());
commandLine.addParameter("--port=" + String.valueOf(port));
commandLine.withEnvironment(DartPubActionBase.PUB_ENV_VAR_NAME, DartPubActionBase.getPubEnvValue());
final OSProcessHandler processHandler = new OSProcessHandler(commandLine);
processHandler.addProcessListener(new PubServeOutputListener(project));
return processHandler;
}
@Override
protected void connectToProcess(@NotNull final AsyncPromise<OSProcessHandler> promise,
final int port,
@NotNull final OSProcessHandler processHandler,
@NotNull final Consumer<String> errorOutputConsumer) {
InetSocketAddress firstPubServerAddress = NetKt.loopbackSocketAddress(port);
ServerInfo old = servedDirToSocketAddress.put(firstServedDir, new ServerInfo(firstPubServerAddress));
LOG.assertTrue(old == null);
super.connectToProcess(promise, port, processHandler, errorOutputConsumer);
}
static void sendBadGateway(@NotNull final Channel channel, @NotNull HttpHeaders extraHeaders) {
if (channel.isActive()) {
Responses.send(HttpResponseStatus.BAD_GATEWAY, channel, null, null, extraHeaders);
}
}
@Override
protected void closeProcessConnections() {
servedDirToSocketAddress.clear();
ClientInfo[] list;
try {
Collection<ClientInfo> clientInfos = serverToClientChannel.values();
list = clientInfos.toArray(new ClientInfo[clientInfos.size()]);
for (ServerInfo serverInstanceInfo : servedDirToSocketAddress.values()) {
serverInstanceInfo.freeServerChannels.clear();
}
serverToClientChannel.clear();
}
finally {
serverChannelRegistrar.close();
}
for (ClientInfo info : list) {
try {
sendBadGateway(info.channel, info.extraHeaders);
}
catch (Exception e) {
LOG.error(e);
}
}
}
private static void connect(@NotNull final Bootstrap bootstrap,
@NotNull final SocketAddress remoteAddress,
final @NotNull Consumer<Channel> channelConsumer) {
final AtomicInteger attemptCounter = new AtomicInteger(1);
bootstrap.connect(remoteAddress).addListener(new ChannelFutureListener() {
@Override
public void operationComplete(ChannelFuture future) throws Exception {
if (future.isSuccess()) {
channelConsumer.consume(future.channel());
}
else {
int attemptCount = attemptCounter.incrementAndGet();
if (attemptCount > NettyUtil.DEFAULT_CONNECT_ATTEMPT_COUNT) {
channelConsumer.consume(null);
}
else {
Thread.sleep(attemptCount * NettyUtil.MIN_START_TIME);
bootstrap.connect(remoteAddress).addListener(this);
}
}
}
});
}
void sendToServer(@NotNull final VirtualFile servedDir,
@NotNull final Channel clientChannel,
@NotNull final FullHttpRequest clientRequest,
@NotNull HttpHeaders extraHeaders,
@NotNull final String pathToPubServe) {
ServerInfo serverInstanceInfo = servedDirToSocketAddress.get(servedDir);
final InetSocketAddress address = serverInstanceInfo.address;
if (Registry.is("dart.redirect.to.pub.server", true)) {
// We can't use 301 (MOVED_PERMANENTLY) response status because Pub Serve port will change after restart, but browser will remember outdated redirection URL
final HttpResponse response = Responses.response(HttpResponseStatus.FOUND, clientRequest, null);
//assert serverInstanceInfo != null;
response.headers().add(HttpHeaderNames.LOCATION,
"http://" + address.getHostString() + ":" + address.getPort() + pathToPubServe);
Responses.send(response, clientChannel, clientRequest, extraHeaders);
return;
}
Channel serverChannel = findFreeServerChannel(serverInstanceInfo.freeServerChannels);
if (serverChannel == null) {
connect(bootstrap, address, serverChannel1 -> {
if (serverChannel1 == null) {
if (clientChannel.isActive()) {
Responses.send(HttpResponseStatus.BAD_GATEWAY, clientChannel, clientRequest, null, extraHeaders);
}
}
else {
serverChannel1.closeFuture().addListener(serverChannelCloseListener);
sendToServer(clientChannel, clientRequest, extraHeaders, pathToPubServe, serverChannel1);
}
});
}
else {
sendToServer(clientChannel, clientRequest, extraHeaders, pathToPubServe, serverChannel);
}
}
@Nullable
private static Channel findFreeServerChannel(@NotNull Deque<Channel> freeServerChannels) {
while (true) {
Channel channel = freeServerChannels.pollLast();
if (channel == null) {
break;
}
if (channel.isActive()) {
return channel;
}
}
return null;
}
private void sendToServer(@NotNull final Channel clientChannel,
@NotNull FullHttpRequest clientRequest,
@NotNull HttpHeaders extraHeaders,
@NotNull String pathToPubServe,
@NotNull Channel serverChannel) {
ClientInfo oldClientInfo = serverToClientChannel.put(serverChannel, new ClientInfo(clientChannel, extraHeaders));
LOG.assertTrue(oldClientInfo == null);
// duplicate - content will be shared (opposite to copy), so, we use duplicate. see ByteBuf javadoc.
FullHttpRequest request = clientRequest.duplicate().setUri(pathToPubServe);
// regardless of client, we always keep connection to server
request.setProtocolVersion(HttpVersion.HTTP_1_1);
HttpUtil.setKeepAlive(request, true);
InetSocketAddress serverAddress = (InetSocketAddress)serverChannel.remoteAddress();
request.headers().set(HttpHeaderNames.HOST, serverAddress.getAddress().getHostAddress() + ':' + serverAddress.getPort());
serverChannel.writeAndFlush(request);
}
@Nullable
String getPubServeAuthority(@NotNull final VirtualFile dir) {
final ServerInfo serverInfo = servedDirToSocketAddress.get(dir);
final InetSocketAddress address = serverInfo == null ? null : serverInfo.address;
return address != null ? address.getHostString() + ":" + address.getPort() : null;
}
@ChannelHandler.Sharable
private class PubServeChannelHandler extends SimpleChannelInboundHandlerAdapter<HttpObject> {
public PubServeChannelHandler() {
super(false);
}
@Override
protected void messageReceived(@NotNull ChannelHandlerContext context, @NotNull HttpObject message) throws Exception {
Channel serverChannel = context.channel();
ClientInfo clientInfo = serverToClientChannel.get(serverChannel);
if (clientInfo == null || !clientInfo.channel.isActive()) {
// client abort request, so, just close server channel as well and don't try to reuse it
serverToClientChannel.remove(serverChannel);
serverChannel.close();
if (message instanceof ReferenceCounted) {
((ReferenceCounted)message).release();
}
}
else {
if (message instanceof HttpResponse) {
HttpResponse response = (HttpResponse)message;
HttpUtil.setKeepAlive(response, true);
response.headers().add(clientInfo.extraHeaders);
}
if (message instanceof LastHttpContent) {
serverToClientChannel.remove(serverChannel);
ServerInfo serverInfo = getServerInfo(serverChannel);
if (serverInfo != null) {
// todo sometimes dart pub server stops to respond, so, we don't reuse it for now
//serverInfo.freeServerChannels.add(serverChannel);
serverChannel.close();
}
}
clientInfo.channel.writeAndFlush(message);
}
}
}
private static class PubServeOutputListener extends ProcessAdapter {
private final Project myProject;
private boolean myNotificationAboutErrors;
private Notification myNotification;
public PubServeOutputListener(final Project project) {
myProject = project;
}
@Override
public void onTextAvailable(final ProcessEvent event, final Key outputType) {
if (outputType == ProcessOutputTypes.STDERR) {
final boolean error = event.getText().toLowerCase(Locale.US).contains("error");
ApplicationManager.getApplication().invokeLater(() -> showNotificationIfNeeded(error));
}
}
private void showNotificationIfNeeded(final boolean isError) {
if (ToolWindowManager.getInstance(myProject).getToolWindow(PUB_SERVE).isVisible()) {
return;
}
if (myNotification != null && !myNotification.isExpired()) {
final Balloon balloon1 = myNotification.getBalloon();
final Balloon balloon2 = ToolWindowManager.getInstance(myProject).getToolWindowBalloon(PUB_SERVE);
if ((balloon1 != null || balloon2 != null) && (myNotificationAboutErrors || !isError)) {
return; // already showing correct balloon
}
myNotification.expire();
}
myNotificationAboutErrors = isError; // previous errors are already reported, so reset our flag
final String message =
DartBundle.message(myNotificationAboutErrors ? "pub.serve.output.contains.errors" : "pub.serve.output.contains.warnings");
myNotification = NOTIFICATION_GROUP.createNotification("", message, NotificationType.WARNING, new NotificationListener.Adapter() {
@Override
protected void hyperlinkActivated(@NotNull final Notification notification, @NotNull final HyperlinkEvent e) {
notification.expire();
ToolWindowManager.getInstance(myProject).getToolWindow(PUB_SERVE).activate(null);
}
});
myNotification.notify(myProject);
}
}
}