// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. using System; using System.Diagnostics; using System.Linq; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.ClearScript.Properties; using Microsoft.ClearScript.Util; using Microsoft.ClearScript.Util.Web; namespace Microsoft.ClearScript.V8 { internal sealed class V8DebugAgent : IDisposable { #region data private const string faviconUrl = "https://clearscript.clearfoundry.net/favicon.png"; private readonly Guid targetId = Guid.NewGuid(); private readonly string name; private readonly string version; private readonly int port; private readonly IV8DebugListener listener; private TcpListener tcpListener; private V8DebugClient activeClient; private readonly InterlockedOneWayFlag disposedFlag = new(); #endregion #region constructors public V8DebugAgent(string name, string version, int port, bool remote, IV8DebugListener listener) { this.name = name; this.version = version; this.port = port; this.listener = listener; var started = false; if (remote) { started = MiscHelpers.Try( static ctx => { ctx.self.tcpListener = new TcpListener(IPAddress.Any, ctx.port); ctx.self.tcpListener.Start(); }, (self: this, port) ); } if (!started) { started = MiscHelpers.Try( static ctx => { ctx.self.tcpListener = new TcpListener(IPAddress.Loopback, ctx.port); ctx.self.tcpListener.Start(); }, (self: this, port) ); } if (started) { StartAcceptWebClient(); } } #endregion #region public members public void SendCommand(V8DebugClient client, string command) { if (client == activeClient) { listener.SendCommand(command); } } public void SendMessage(string message) { if (!disposedFlag.IsSet) { var client = activeClient; client?.SendMessage(message); } } public void OnClientFailed(V8DebugClient client) { if (Interlocked.CompareExchange(ref activeClient, null, client) == client) { listener.DisconnectClient(); ThreadPool.QueueUserWorkItem(_ => V8Runtime.OnDebuggerDisconnected(new V8RuntimeDebuggerEventArgs(name, port))); } } #endregion #region Web endpoint private void StartAcceptWebClient() { tcpListener.AcceptSocketAsync().ContinueWith(OnWebClientAccepted); } private void OnWebClientAccepted(Task task) { var succeeded = MiscHelpers.Try(out var socket, static task => task.Result, task); if (!disposedFlag.IsSet) { if (succeeded) { WebContext.CreateAsync(socket).ContinueWith(OnWebContextCreated); } StartAcceptWebClient(); } } private void OnWebContextCreated(Task task) { if (MiscHelpers.Try(out var webContext, static task => task.Result, task) && !disposedFlag.IsSet) { if (!webContext.Request.IsWebSocketRequest) { HandleWebRequest(webContext); } else if (!webContext.Request.RawUrl.Equals("/" + targetId, StringComparison.OrdinalIgnoreCase)) { webContext.Response.Close(404); } else { StartAcceptWebSocket(webContext); } } } private void HandleWebRequest(WebContext webContext) { // https://github.com/buggerjs/bugger-daemon/blob/master/README.md#api, // https://github.com/nodejs/node/blob/master/src/inspector_socket_server.cc if (webContext.Request.RawUrl.Equals("/json", StringComparison.OrdinalIgnoreCase) || webContext.Request.RawUrl.Equals("/json/list", StringComparison.OrdinalIgnoreCase)) { if (activeClient is not null) { SendWebResponse(webContext, MiscHelpers.FormatInvariant( "[ {{\r\n" + " \"id\": \"{0}\",\r\n" + " \"type\": \"node\",\r\n" + " \"description\": \"ClearScript V8 runtime: {1}\",\r\n" + " \"title\": \"{2}\",\r\n" + " \"url\": \"{3}\",\r\n" + " \"faviconUrl\": \"{4}\"\r\n" + "}} ]\r\n", targetId, JsonEscape(name), JsonEscape(AppDomain.CurrentDomain.FriendlyName), JsonEscape(new Uri(Process.GetCurrentProcess().MainModule.FileName)), faviconUrl )); } else { SendWebResponse(webContext, MiscHelpers.FormatInvariant( "[ {{\r\n" + " \"id\": \"{0}\",\r\n" + " \"type\": \"node\",\r\n" + " \"description\": \"ClearScript V8 runtime: {1}\",\r\n" + " \"title\": \"{2}\",\r\n" + " \"url\": \"{3}\",\r\n" + " \"faviconUrl\": \"{6}\",\r\n" + " \"devtoolsFrontendUrl\": \"devtools://devtools/bundled/js_app.html?experiments=true&v8only=true&ws={4}:{5}/{0}\",\r\n" + " \"devtoolsFrontendUrlCompat\": \"devtools://devtools/bundled/inspector.html?experiments=true&v8only=true&ws={4}:{5}/{0}\",\r\n" + " \"webSocketDebuggerUrl\": \"ws://{4}:{5}/{0}\"\r\n" + "}} ]\r\n", targetId, JsonEscape(name), JsonEscape(AppDomain.CurrentDomain.FriendlyName), JsonEscape(new Uri(Process.GetCurrentProcess().MainModule.FileName)), webContext.Request.Uri.Host, webContext.Request.Uri.Port, faviconUrl )); } } else if (webContext.Request.RawUrl.Equals("/json/version", StringComparison.OrdinalIgnoreCase)) { SendWebResponse(webContext, MiscHelpers.FormatInvariant( "{{\r\n" + " \"Browser\": \"ClearScript/v{0}, V8 {1}\",\r\n" + " \"Protocol-Version\": \"1.1\"\r\n" + "}}\r\n", ClearScriptVersion.Informational, version )); } else if (webContext.Request.RawUrl.StartsWith("/json/activate/", StringComparison.OrdinalIgnoreCase)) { var requestTargetId = webContext.Request.RawUrl.Substring(15); if (requestTargetId.Equals(targetId.ToString(), StringComparison.OrdinalIgnoreCase)) { SendWebResponse(webContext, "Target activated", "text/plain"); } else { SendWebResponse(webContext, "No such target id: " + requestTargetId, "text/plain", 404); } } else if (webContext.Request.RawUrl.StartsWith("/json/close/", StringComparison.OrdinalIgnoreCase)) { var requestTargetId = webContext.Request.RawUrl.Substring(12); if (requestTargetId.Equals(targetId.ToString(), StringComparison.OrdinalIgnoreCase)) { SendWebResponse(webContext, "Target is closing", "text/plain"); } else { SendWebResponse(webContext, "No such target id: " + requestTargetId, "text/plain", 404); } } else if (webContext.Request.RawUrl.StartsWith("/json/new?", StringComparison.OrdinalIgnoreCase) || webContext.Request.RawUrl.Equals("/json/protocol", StringComparison.OrdinalIgnoreCase)) { webContext.Response.Close(501); } else { webContext.Response.Close(404); } } #endregion #region WebSocket client connection private void StartAcceptWebSocket(WebContext webContext) { webContext.AcceptWebSocketAsync().ContinueWith(task => OnWebSocketAccepted(webContext, task)); } private void OnWebSocketAccepted(WebContext webContext, Task task) { if (MiscHelpers.Try(out var webSocket, static task => task.Result, task)) { if (!ConnectClient(webSocket)) { webSocket.Close(WebSocket.ErrorCode.PolicyViolation, "A debugger is already connected"); } } else { webContext.Response.Close(500); } } private bool ConnectClient(WebSocket webSocket) { var client = new V8DebugClient(this, webSocket); if (Interlocked.CompareExchange(ref activeClient, client, null) is null) { listener.ConnectClient(); client.Start(); ThreadPool.QueueUserWorkItem(_ => V8Runtime.OnDebuggerConnected(new V8RuntimeDebuggerEventArgs(name, port))); return true; } return false; } private void DisconnectClient(WebSocket.ErrorCode errorCode, string message) { var client = Interlocked.Exchange(ref activeClient, null); if (client is not null) { client.Dispose(errorCode, message); listener.DisconnectClient(); ThreadPool.QueueUserWorkItem(_ => V8Runtime.OnDebuggerDisconnected(new V8RuntimeDebuggerEventArgs(name, port))); } } #endregion #region IDisposable implementation public void Dispose() { if (disposedFlag.Set()) { if (tcpListener is not null) { MiscHelpers.Try(static tcpListener => tcpListener.Stop(), tcpListener); } DisconnectClient(WebSocket.ErrorCode.EndpointUnavailable, "The V8 runtime has been destroyed"); listener.Dispose(); } } #endregion #region protocol utilities private static void SendWebResponse(WebContext webContext, string content, string contentType = "application/json", int statusCode = 200) { using (webContext.Response) { var contentBytes = Encoding.UTF8.GetBytes(content); webContext.Response.ContentType = contentType + "; charset=UTF-8"; webContext.Response.OutputStream.Write(contentBytes, 0, contentBytes.Length); webContext.Response.StatusCode = statusCode; } } private static string JsonEscape(object value) { return new string(value.ToString().Select(ch => ((ch == '\"') || (ch == '\\')) ? '_' : ch).ToArray()); } #endregion } }