debugger: Add support for remote browser debugging (#39248)

This PR adds support for browser debugging in SSH and WSL projects. We
use the vscode-js-debug-companion extension, repackaged as a standalone
CLI (https://github.com/zed-industries/js-debug-companion-cli).

Closes #38878

Release Notes:

- debugger: Browser debugging is now supported in SSH and WSL projects.

---------

Co-authored-by: Nia <nia@zed.dev>
This commit is contained in:
Cole Miller
2025-10-08 21:57:57 +00:00
committed by GitHub
co-authored by Nia
parent 31e75b2235
commit 88887fd292
12 changed files with 497 additions and 17 deletions
+1
View File
@@ -46,6 +46,7 @@ pub trait DapDelegate: Send + Sync + 'static {
async fn which(&self, command: &OsStr) -> Option<PathBuf>;
async fn read_text_file(&self, path: &RelPath) -> Result<String>;
async fn shell_env(&self) -> collections::HashMap<String, String>;
fn is_headless(&self) -> bool;
}
#[derive(
+7
View File
@@ -120,6 +120,13 @@ impl JsDebugAdapter {
configuration
.entry("sourceMapRenames")
.or_insert(true.into());
// Set up remote browser debugging
if delegate.is_headless() {
configuration
.entry("browserLaunchLocation")
.or_insert("ui".into());
}
}
let adapter_path = if let Some(user_installed_path) = user_installed_path {
@@ -164,6 +164,7 @@ pub struct BreakpointStore {
impl BreakpointStore {
pub fn init(client: &AnyProtoClient) {
log::error!("breakpoint store init");
client.add_entity_request_handler(Self::handle_toggle_breakpoint);
client.add_entity_message_handler(Self::handle_breakpoints_for_file);
}
+75 -7
View File
@@ -22,9 +22,9 @@ use dap::{
inline_value::VariableLookupKind,
messages::Message,
};
use fs::Fs;
use fs::{Fs, RemoveOptions};
use futures::{
StreamExt,
StreamExt, TryStreamExt as _,
channel::mpsc::{self, UnboundedSender},
future::{Shared, join_all},
};
@@ -78,12 +78,15 @@ pub struct LocalDapStore {
http_client: Arc<dyn HttpClient>,
environment: Entity<ProjectEnvironment>,
toolchain_store: Arc<dyn LanguageToolchainStore>,
is_headless: bool,
}
pub struct RemoteDapStore {
remote_client: Entity<RemoteClient>,
upstream_client: AnyProtoClient,
upstream_project_id: u64,
node_runtime: NodeRuntime,
http_client: Arc<dyn HttpClient>,
}
pub struct DapStore {
@@ -134,17 +137,19 @@ impl DapStore {
toolchain_store: Arc<dyn LanguageToolchainStore>,
worktree_store: Entity<WorktreeStore>,
breakpoint_store: Entity<BreakpointStore>,
is_headless: bool,
cx: &mut Context<Self>,
) -> Self {
let mode = DapStoreMode::Local(LocalDapStore {
fs,
fs: fs.clone(),
environment,
http_client,
node_runtime,
toolchain_store,
is_headless,
});
Self::new(mode, breakpoint_store, worktree_store, cx)
Self::new(mode, breakpoint_store, worktree_store, fs, cx)
}
pub fn new_remote(
@@ -152,15 +157,20 @@ impl DapStore {
remote_client: Entity<RemoteClient>,
breakpoint_store: Entity<BreakpointStore>,
worktree_store: Entity<WorktreeStore>,
node_runtime: NodeRuntime,
http_client: Arc<dyn HttpClient>,
fs: Arc<dyn Fs>,
cx: &mut Context<Self>,
) -> Self {
let mode = DapStoreMode::Remote(RemoteDapStore {
upstream_client: remote_client.read(cx).proto_client(),
remote_client,
upstream_project_id: project_id,
node_runtime,
http_client,
});
Self::new(mode, breakpoint_store, worktree_store, cx)
Self::new(mode, breakpoint_store, worktree_store, fs, cx)
}
pub fn new_collab(
@@ -168,17 +178,55 @@ impl DapStore {
_upstream_client: AnyProtoClient,
breakpoint_store: Entity<BreakpointStore>,
worktree_store: Entity<WorktreeStore>,
fs: Arc<dyn Fs>,
cx: &mut Context<Self>,
) -> Self {
Self::new(DapStoreMode::Collab, breakpoint_store, worktree_store, cx)
Self::new(
DapStoreMode::Collab,
breakpoint_store,
worktree_store,
fs,
cx,
)
}
fn new(
mode: DapStoreMode,
breakpoint_store: Entity<BreakpointStore>,
worktree_store: Entity<WorktreeStore>,
_cx: &mut Context<Self>,
fs: Arc<dyn Fs>,
cx: &mut Context<Self>,
) -> Self {
cx.background_spawn(async move {
let dir = paths::debug_adapters_dir().join("js-debug-companion");
let mut children = fs.read_dir(&dir).await?.try_collect::<Vec<_>>().await?;
children.sort_by_key(|child| semver::Version::parse(child.file_name()?.to_str()?).ok());
if let Some(child) = children.last()
&& let Some(name) = child.file_name()
&& let Some(name) = name.to_str()
&& semver::Version::parse(name).is_ok()
{
children.pop();
}
for child in children {
fs.remove_dir(
&child,
RemoveOptions {
recursive: true,
ignore_if_not_exists: true,
},
)
.await
.ok();
}
anyhow::Ok(())
})
.detach();
Self {
mode,
next_session_id: 0,
@@ -401,6 +449,15 @@ impl DapStore {
});
}
let (remote_client, node_runtime, http_client) = match &self.mode {
DapStoreMode::Local(_) => (None, None, None),
DapStoreMode::Remote(remote_dap_store) => (
Some(remote_dap_store.remote_client.clone()),
Some(remote_dap_store.node_runtime.clone()),
Some(remote_dap_store.http_client.clone()),
),
DapStoreMode::Collab => (None, None, None),
};
let session = Session::new(
self.breakpoint_store.clone(),
session_id,
@@ -409,6 +466,9 @@ impl DapStore {
adapter,
task_context,
quirks,
remote_client,
node_runtime,
http_client,
cx,
);
@@ -538,6 +598,7 @@ impl DapStore {
local_store.environment.update(cx, |env, cx| {
env.get_worktree_environment(worktree.clone(), cx)
}),
local_store.is_headless,
))
}
@@ -870,6 +931,7 @@ pub struct DapAdapterDelegate {
http_client: Arc<dyn HttpClient>,
toolchain_store: Arc<dyn LanguageToolchainStore>,
load_shell_env_task: Shared<Task<Option<HashMap<String, String>>>>,
is_headless: bool,
}
impl DapAdapterDelegate {
@@ -881,6 +943,7 @@ impl DapAdapterDelegate {
http_client: Arc<dyn HttpClient>,
toolchain_store: Arc<dyn LanguageToolchainStore>,
load_shell_env_task: Shared<Task<Option<HashMap<String, String>>>>,
is_headless: bool,
) -> Self {
Self {
fs,
@@ -890,6 +953,7 @@ impl DapAdapterDelegate {
node_runtime,
toolchain_store,
load_shell_env_task,
is_headless,
}
}
}
@@ -953,4 +1017,8 @@ impl dap::adapters::DapDelegate for DapAdapterDelegate {
self.fs.load(&abs_path).await
}
fn is_headless(&self) -> bool {
self.is_headless
}
}
+336 -3
View File
@@ -31,21 +31,28 @@ use dap::{
RunInTerminalRequestArguments, StackFramePresentationHint, StartDebuggingRequestArguments,
StartDebuggingRequestArgumentsRequest, VariablePresentationHint, WriteMemoryArguments,
};
use futures::SinkExt;
use futures::channel::mpsc::UnboundedSender;
use futures::channel::{mpsc, oneshot};
use futures::io::BufReader;
use futures::{AsyncBufReadExt as _, SinkExt, StreamExt, TryStreamExt};
use futures::{FutureExt, future::Shared};
use gpui::{
App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, EventEmitter, SharedString,
Task, WeakEntity,
};
use http_client::HttpClient;
use node_runtime::NodeRuntime;
use remote::RemoteClient;
use rpc::ErrorExt;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use smol::stream::StreamExt;
use smol::net::TcpListener;
use std::any::TypeId;
use std::collections::BTreeMap;
use std::ops::RangeInclusive;
use std::path::PathBuf;
use std::process::Stdio;
use std::u64;
use std::{
any::Any,
@@ -56,6 +63,7 @@ use std::{
};
use task::TaskContext;
use text::{PointUtf16, ToPointUtf16};
use util::command::new_smol_command;
use util::{ResultExt, debug_panic, maybe};
use worktree::Worktree;
@@ -696,6 +704,10 @@ pub struct Session {
task_context: TaskContext,
memory: memory::Memory,
quirks: SessionQuirks,
remote_client: Option<Entity<RemoteClient>>,
node_runtime: Option<NodeRuntime>,
http_client: Option<Arc<dyn HttpClient>>,
companion_port: Option<u16>,
}
trait CacheableCommand: Any + Send + Sync {
@@ -812,6 +824,9 @@ impl Session {
adapter: DebugAdapterName,
task_context: TaskContext,
quirks: SessionQuirks,
remote_client: Option<Entity<RemoteClient>>,
node_runtime: Option<NodeRuntime>,
http_client: Option<Arc<dyn HttpClient>>,
cx: &mut App,
) -> Entity<Self> {
cx.new::<Self>(|cx| {
@@ -867,6 +882,10 @@ impl Session {
task_context,
memory: memory::Memory::new(),
quirks,
remote_client,
node_runtime,
http_client,
companion_port: None,
}
})
}
@@ -1557,7 +1576,21 @@ impl Session {
Events::ProgressStart(_) => {}
Events::ProgressUpdate(_) => {}
Events::Invalidated(_) => {}
Events::Other(_) => {}
Events::Other(event) => {
if event.event == "launchBrowserInCompanion" {
let Some(request) = serde_json::from_value(event.body).ok() else {
log::error!("failed to deserialize launchBrowserInCompanion event");
return;
};
self.launch_browser_for_remote_server(request, cx);
} else if event.event == "killCompanionBrowser" {
let Some(request) = serde_json::from_value(event.body).ok() else {
log::error!("failed to deserialize killCompanionBrowser event");
return;
};
self.kill_browser(request, cx);
}
}
}
}
@@ -2716,4 +2749,304 @@ impl Session {
pub fn quirks(&self) -> SessionQuirks {
self.quirks
}
fn launch_browser_for_remote_server(
&mut self,
mut request: LaunchBrowserInCompanionParams,
cx: &mut Context<Self>,
) {
let Some(remote_client) = self.remote_client.clone() else {
log::error!("can't launch browser in companion for non-remote project");
return;
};
let Some(http_client) = self.http_client.clone() else {
return;
};
let Some(node_runtime) = self.node_runtime.clone() else {
return;
};
let mut console_output = self.console_output(cx);
let task = cx.spawn(async move |this, cx| {
let (dap_port, _child) =
if remote_client.read_with(cx, |client, _| client.shares_network_interface())? {
(request.server_port, None)
} else {
let port = {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.context("getting port for DAP")?;
listener.local_addr()?.port()
};
let child = remote_client.update(cx, |client, _| {
let command = client.build_forward_port_command(
port,
"localhost".into(),
request.server_port,
)?;
let child = new_smol_command(command.program)
.args(command.args)
.envs(command.env)
.spawn()
.context("spawning port forwarding process")?;
anyhow::Ok(child)
})??;
(port, Some(child))
};
let mut companion_process = None;
let companion_port =
if let Some(companion_port) = this.read_with(cx, |this, _| this.companion_port)? {
companion_port
} else {
let task = cx.spawn(async move |cx| spawn_companion(node_runtime, cx).await);
match task.await {
Ok((port, child)) => {
companion_process = Some(child);
port
}
Err(e) => {
console_output
.send(format!("Failed to launch browser companion process: {e}"))
.await
.ok();
return Err(e);
}
}
};
this.update(cx, |this, cx| {
this.companion_port = Some(companion_port);
let Some(mut child) = companion_process else {
return;
};
if let Some(stderr) = child.stderr.take() {
let mut console_output = console_output.clone();
this.background_tasks.push(cx.spawn(async move |_, _| {
let mut stderr = BufReader::new(stderr);
let mut line = String::new();
while let Ok(n) = stderr.read_line(&mut line).await
&& n > 0
{
console_output
.send(format!("companion stderr: {line}"))
.await
.ok();
line.clear();
}
}));
}
this.background_tasks.push(cx.spawn({
let mut console_output = console_output.clone();
async move |_, _| match child.status().await {
Ok(status) => {
if status.success() {
console_output
.send("Companion process exited normally".into())
.await
.ok();
} else {
console_output
.send(format!(
"Companion process exited abnormally with {status:?}"
))
.await
.ok();
}
}
Err(e) => {
console_output
.send(format!("Failed to join companion process: {e}"))
.await
.ok();
}
}
}))
})?;
request
.other
.insert("proxyUri".into(), format!("127.0.0.1:{dap_port}").into());
// TODO pass wslInfo as needed
let response = http_client
.post_json(
&format!("http://127.0.0.1:{companion_port}/launch-and-attach"),
serde_json::to_string(&request)
.context("serializing request")?
.into(),
)
.await;
match response {
Ok(response) => {
if !response.status().is_success() {
console_output
.send("Launch request to companion failed".into())
.await
.ok();
return Err(anyhow!("launch request failed"));
}
}
Err(e) => {
console_output
.send("Failed to read response from companion".into())
.await
.ok();
return Err(e);
}
}
anyhow::Ok(())
});
self.background_tasks.push(cx.spawn(async move |_, _| {
task.await.log_err();
}));
}
fn kill_browser(&self, request: KillCompanionBrowserParams, cx: &mut App) {
let Some(companion_port) = self.companion_port else {
log::error!("received killCompanionBrowser but js-debug-companion is not running");
return;
};
let Some(http_client) = self.http_client.clone() else {
return;
};
cx.spawn(async move |_| {
http_client
.post_json(
&format!("http://127.0.0.1:{companion_port}/kill"),
serde_json::to_string(&request)
.context("serializing request")?
.into(),
)
.await?;
anyhow::Ok(())
})
.detach_and_log_err(cx)
}
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LaunchBrowserInCompanionParams {
server_port: u16,
#[serde(flatten)]
other: HashMap<String, serde_json::Value>,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct KillCompanionBrowserParams {
launch_id: u64,
}
async fn spawn_companion(
node_runtime: NodeRuntime,
cx: &mut AsyncApp,
) -> Result<(u16, smol::process::Child)> {
let binary_path = node_runtime
.binary_path()
.await
.context("getting node path")?;
let path = cx
.spawn(async move |cx| get_or_install_companion(node_runtime, cx).await)
.await?;
log::info!("will launch js-debug-companion version {path:?}");
let port = {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.context("getting port for companion")?;
listener.local_addr()?.port()
};
let dir = paths::data_dir()
.join("js_debug_companion_state")
.to_string_lossy()
.to_string();
let child = new_smol_command(binary_path)
.arg(path)
.args([
format!("--listen=127.0.0.1:{port}"),
format!("--state={dir}"),
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("spawning companion child process")?;
Ok((port, child))
}
async fn get_or_install_companion(node: NodeRuntime, cx: &mut AsyncApp) -> Result<PathBuf> {
const PACKAGE_NAME: &str = "@zed-industries/js-debug-companion-cli";
async fn install_latest_version(dir: PathBuf, node: NodeRuntime) -> Result<PathBuf> {
let temp_dir = tempfile::tempdir().context("creating temporary directory")?;
node.npm_install_packages(temp_dir.path(), &[(PACKAGE_NAME, "latest")])
.await
.context("installing latest companion package")?;
let version = node
.npm_package_installed_version(temp_dir.path(), PACKAGE_NAME)
.await
.context("getting installed companion version")?
.context("companion was not installed")?;
smol::fs::rename(temp_dir.path(), dir.join(&version))
.await
.context("moving companion package into place")?;
Ok(dir.join(version))
}
let dir = paths::debug_adapters_dir().join("js-debug-companion");
let (latest_installed_version, latest_version) = cx
.background_spawn({
let dir = dir.clone();
let node = node.clone();
async move {
smol::fs::create_dir_all(&dir)
.await
.context("creating companion installation directory")?;
let mut children = smol::fs::read_dir(&dir)
.await
.context("reading companion installation directory")?
.try_collect::<Vec<_>>()
.await
.context("reading companion installation directory entries")?;
children
.sort_by_key(|child| semver::Version::parse(child.file_name().to_str()?).ok());
let latest_installed_version = children.last().and_then(|child| {
let version = child.file_name().into_string().ok()?;
Some((child.path(), version))
});
let latest_version = node
.npm_package_latest_version(PACKAGE_NAME)
.await
.log_err();
anyhow::Ok((latest_installed_version, latest_version))
}
})
.await?;
let path = if let Some((installed_path, installed_version)) = latest_installed_version {
if let Some(latest_version) = latest_version
&& latest_version != installed_version
{
cx.background_spawn(install_latest_version(dir.clone(), node.clone()))
.detach();
}
Ok(installed_path)
} else {
cx.background_spawn(install_latest_version(dir.clone(), node.clone()))
.await
};
Ok(path?
.join("node_modules")
.join(PACKAGE_NAME)
.join("out")
.join("cli.js"))
}
+5
View File
@@ -1084,6 +1084,7 @@ impl Project {
toolchain_store.read(cx).as_language_toolchain_store(),
worktree_store.clone(),
breakpoint_store.clone(),
false,
cx,
)
});
@@ -1306,6 +1307,9 @@ impl Project {
remote.clone(),
breakpoint_store.clone(),
worktree_store.clone(),
node.clone(),
client.http_client(),
fs.clone(),
cx,
)
});
@@ -1503,6 +1507,7 @@ impl Project {
client.clone().into(),
breakpoint_store.clone(),
worktree_store.clone(),
fs.clone(),
cx,
)
})?;
+9 -6
View File
@@ -3426,17 +3426,20 @@ impl ProjectPanel {
new_state.max_width_item_index = Some(visited_worktrees_length + index);
}
}
if let Some((worktree_id, entry_id)) = new_selected_entry {
new_state.selection = Some(SelectedEntry {
worktree_id,
entry_id,
});
}
new_state
})
.await;
this.update_in(cx, |this, window, cx| {
let current_selection = this.state.selection;
this.state = new_state;
if let Some((worktree_id, entry_id)) = new_selected_entry {
this.state.selection = Some(SelectedEntry {
worktree_id,
entry_id,
});
} else {
this.state.selection = current_selection;
}
let elapsed = now.elapsed();
if this.last_reported_update.elapsed() > Duration::from_secs(3600) {
telemetry::event!(
+35
View File
@@ -836,6 +836,18 @@ impl RemoteClient {
connection.build_command(program, args, env, working_dir, port_forward)
}
pub fn build_forward_port_command(
&self,
local_port: u16,
host: String,
remote_port: u16,
) -> Result<CommandTemplate> {
let Some(connection) = self.remote_connection() else {
return Err(anyhow!("no ssh connection"));
};
connection.build_forward_port_command(local_port, host, remote_port)
}
pub fn upload_directory(
&self,
src_path: PathBuf,
@@ -1104,6 +1116,12 @@ pub(crate) trait RemoteConnection: Send + Sync {
working_dir: Option<String>,
port_forward: Option<(u16, String, u16)>,
) -> Result<CommandTemplate>;
fn build_forward_port_command(
&self,
local_port: u16,
remote: String,
remote_port: u16,
) -> Result<CommandTemplate>;
fn connection_options(&self) -> RemoteConnectionOptions;
fn path_style(&self) -> PathStyle;
fn shell(&self) -> String;
@@ -1533,6 +1551,23 @@ mod fake {
})
}
fn build_forward_port_command(
&self,
local_port: u16,
host: String,
remote_port: u16,
) -> anyhow::Result<CommandTemplate> {
Ok(CommandTemplate {
program: "ssh".into(),
args: vec![
"-N".into(),
"-L".into(),
format!("{local_port}:{host}:{remote_port}"),
],
env: Default::default(),
})
}
fn upload_directory(
&self,
_src_path: PathBuf,
+17
View File
@@ -145,6 +145,23 @@ impl RemoteConnection for SshRemoteConnection {
)
}
fn build_forward_port_command(
&self,
local_port: u16,
host: String,
remote_port: u16,
) -> Result<CommandTemplate> {
Ok(CommandTemplate {
program: "ssh".into(),
args: vec![
"-N".into(),
"-L".into(),
format!("{local_port}:{host}:{remote_port}"),
],
env: Default::default(),
})
}
fn upload_directory(
&self,
src_path: PathBuf,
+9
View File
@@ -433,6 +433,15 @@ impl RemoteConnection for WslRemoteConnection {
})
}
fn build_forward_port_command(
&self,
_: u16,
_: String,
_: u16,
) -> anyhow::Result<CommandTemplate> {
Err(anyhow!("WSL shares a network interface with the host"))
}
fn connection_options(&self) -> RemoteConnectionOptions {
RemoteConnectionOptions::Wsl(self.connection_options.clone())
}
@@ -123,6 +123,7 @@ impl HeadlessProject {
toolchain_store.read(cx).as_language_toolchain_store(),
worktree_store.clone(),
breakpoint_store.clone(),
true,
cx,
);
dap_store.shared(REMOTE_SERVER_PROJECT_ID, session.clone(), cx);
+1 -1
View File
@@ -1387,7 +1387,7 @@ mod tests {
let (res_tx, res_rx) = oneshot::channel();
req_tx.unbounded_send((req, res_tx)).unwrap();
serde_json::to_string(&res_rx.await.unwrap()).unwrap()
serde_json::to_string(&res_rx.await?).unwrap()
}
_ => {
panic!("Unexpected path: {}", uri)