Implement automatic workspace folders support for Chrome DevTools (#19949)

This commit is contained in:
Jarred Sumner
2025-05-28 00:25:30 -07:00
committed by GitHub
parent bf02d04479
commit df017990aa
4 changed files with 184 additions and 0 deletions

View File

@@ -206,6 +206,38 @@ Each call to `console.log` or `console.error` will be broadcast to the terminal
Internally, this reuses the existing WebSocket connection from hot module reloading to send the logs.
### Edit files in the browser
Bun's frontend dev server has support for [Automatic Workspace Folders](https://chromium.googlesource.com/devtools/devtools-frontend/+/main/docs/ecosystem/automatic_workspace_folders.md) in Chrome DevTools, which lets you save edits to files in the browser.
{% image src="/images/bun-chromedevtools.gif" alt="Bun's frontend dev server has support for Automatic Workspace Folders in Chrome DevTools, which lets you save edits to files in the browser." /%}
{% details summary="How it works" %}
Bun's dev server automatically adds a `/.well-known/appspecific/com.chrome.devtools.json` route to the server.
This route returns a JSON object with the following shape:
```json
{
"workspace": {
"root": "/path/to/your/project",
"uuid": "a-unique-identifier-for-this-workspace"
}
}
```
For security reasons, this is only enabled when:
1. The request is coming from localhost, 127.0.0.1, or ::1.
2. Hot Module Reloading is enabled.
3. The `chromeDevToolsAutomaticWorkspaceFolders` flag is set to `true` or `undefined`.
4. There are no other routes that match the request.
You can disable this by passing `development: { chromeDevToolsAutomaticWorkspaceFolders: false }` in `Bun.serve`'s options.
{% /details %}
## Keyboard Shortcuts
While the server is running:

View File

@@ -3357,6 +3357,30 @@ declare module "bun" {
* @default false
*/
console?: boolean;
/**
* Enable automatic workspace folders for Chrome DevTools
*
* This lets you persistently edit files in the browser. It works by adding the following route to the server:
* `/.well-known/appspecific/com.chrome.devtools.json`
*
* The response is a JSON object with the following shape:
* ```json
* {
* "workspace": {
* "root": "<cwd>",
* "uuid": "<uuid>"
* }
* }
* ```
*
* The `root` field is the current working directory of the server.
* The `"uuid"` field is a hash of the file that started the server and a hash of the current working directory.
*
* For security reasons, if the remote socket address is not from localhost, 127.0.0.1, or ::1, the request is ignored.
* @default true
*/
chromeDevToolsAutomaticWorkspaceFolders?: boolean;
};
error?: (this: Server, error: ErrorLike) => Response | Promise<Response> | void | Promise<void>;

View File

@@ -348,6 +348,13 @@ pub const ServerConfig = struct {
development: DevelopmentOption = .development,
broadcast_console_log_from_browser_to_server_for_bake: bool = false,
/// Enable automatic workspace folders for Chrome DevTools
/// https://chromium.googlesource.com/devtools/devtools-frontend/+/main/docs/ecosystem/automatic_workspace_folders.md
/// https://github.com/ChromeDevTools/vite-plugin-devtools-json/blob/76080b04422b36230d4b7a674b90d6df296cbff5/src/index.ts#L60-L77
///
/// If HMR is not enabled, then this field is ignored.
enable_chrome_devtools_automatic_workspace_folders: bool = true,
onError: JSC.JSValue = JSC.JSValue.zero,
onRequest: JSC.JSValue = JSC.JSValue.zero,
onNodeHTTPRequest: JSC.JSValue = JSC.JSValue.zero,
@@ -1326,6 +1333,10 @@ pub const ServerConfig = struct {
if (try dev.getBooleanStrict(global, "console")) |console| {
args.broadcast_console_log_from_browser_to_server_for_bake = console;
}
if (try dev.getBooleanStrict(global, "chromeDevToolsAutomaticWorkspaceFolders")) |enable_chrome_devtools_automatic_workspace_folders| {
args.enable_chrome_devtools_automatic_workspace_folders = enable_chrome_devtools_automatic_workspace_folders;
}
} else {
args.development = if (dev.toBoolean()) .development else .production;
}
@@ -7088,12 +7099,90 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
ctx.toAsync(req, request_object);
}
// https://chromium.googlesource.com/devtools/devtools-frontend/+/main/docs/ecosystem/automatic_workspace_folders.md
fn onChromeDevToolsJSONRequest(this: *ThisServer, req: *uws.Request, resp: *App.Response) void {
if (comptime Environment.enable_logs)
httplog("{s} - {s}", .{ req.method(), req.url() });
const authorized = brk: {
if (this.dev_server == null)
break :brk false;
if (resp.getRemoteSocketInfo()) |*address| {
// IPv4 loopback addresses
if (strings.startsWith(address.ip, "127.")) {
break :brk true;
}
// IPv6 loopback addresses
if (strings.startsWith(address.ip, "::ffff:127.") or
strings.startsWith(address.ip, "::1") or
strings.eqlComptime(address.ip, "0:0:0:0:0:0:0:1"))
{
break :brk true;
}
}
break :brk false;
};
if (!authorized) {
req.setYield(true);
return;
}
// They need a 16 byte uuid. It needs to be somewhat consistent. We don't want to store this field anywhere.
// So we first use a hash of the main field:
const first_hash_segment: [8]u8 = brk: {
const buffer = bun.PathBufferPool.get();
defer bun.PathBufferPool.put(buffer);
const main = JSC.VirtualMachine.get().main;
const len = @min(main.len, buffer.len);
break :brk @bitCast(bun.hash(bun.strings.copyLowercase(main[0..len], buffer[0..len])));
};
// And then we use a hash of their project root directory:
const second_hash_segment: [8]u8 = brk: {
const buffer = bun.PathBufferPool.get();
defer bun.PathBufferPool.put(buffer);
const root = this.dev_server.?.root;
const len = @min(root.len, buffer.len);
break :brk @bitCast(bun.hash(bun.strings.copyLowercase(root[0..len], buffer[0..len])));
};
// We combine it together to get a 16 byte uuid.
const hash_bytes: [16]u8 = first_hash_segment ++ second_hash_segment;
const uuid = bun.UUID.initWith(&hash_bytes);
// interface DevToolsJSON {
// workspace?: {
// root: string,
// uuid: string,
// }
// }
const json_string = std.fmt.allocPrint(bun.default_allocator, "{{ \"workspace\": {{ \"root\": {}, \"uuid\": \"{}\" }} }}", .{
bun.fmt.formatJSONStringUTF8(this.dev_server.?.root, .{}),
uuid,
}) catch bun.outOfMemory();
defer bun.default_allocator.free(json_string);
resp.writeStatus("200 OK");
resp.writeHeader("Content-Type", "application/json");
resp.end(json_string, resp.shouldCloseConnection());
}
fn setRoutes(this: *ThisServer) JSC.JSValue {
var route_list_value = JSC.JSValue.zero;
const app = this.app.?;
const any_server = AnyServer.from(this);
const dev_server = this.dev_server;
// https://chromium.googlesource.com/devtools/devtools-frontend/+/main/docs/ecosystem/automatic_workspace_folders.md
// Only enable this when we're using the dev server.
var should_add_chrome_devtools_json_route = debug_mode and this.config.allow_hot and dev_server != null and this.config.enable_chrome_devtools_automatic_workspace_folders;
const chrome_devtools_route = "/.well-known/appspecific/com.chrome.devtools.json";
// --- 1. Handle user_routes_to_build (dynamic JS routes) ---
// (This part remains conceptually the same: populate this.user_routes and route_list_value
// Crucially, ServerConfig.fromJS must ensure `route.method` is correctly .specific or .any)
@@ -7143,6 +7232,12 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
has_any_user_route_for_star_path = true;
}
if (should_add_chrome_devtools_json_route) {
if (strings.eqlComptime(user_route.route.path, chrome_devtools_route) or strings.hasPrefix(user_route.route.path, "/.well-known/")) {
should_add_chrome_devtools_json_route = false;
}
}
// Register HTTP routes
switch (user_route.route.method) {
.any => {
@@ -7209,6 +7304,12 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
}
}
if (should_add_chrome_devtools_json_route) {
if (strings.eqlComptime(entry.path, chrome_devtools_route) or strings.hasPrefix(entry.path, "/.well-known/")) {
should_add_chrome_devtools_json_route = false;
}
}
switch (entry.route) {
.static => |static_route| {
ServerConfig.applyStaticRoute(any_server, ssl_enabled, app, *StaticRoute, static_route, entry.path, entry.method);
@@ -7295,6 +7396,10 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
}
}
if (should_add_chrome_devtools_json_route) {
app.get(chrome_devtools_route, *ThisServer, this, onChromeDevToolsJSONRequest);
}
// If onNodeHTTPRequest is configured, it might be needed for Node.js compatibility layer
// for specific Node API routes, even if it's not the main "/*" handler.
if (this.config.onNodeHTTPRequest != .zero) {

View File

@@ -199,3 +199,26 @@ devTest("memory leak case 1", {
await dev.fetch("/"); // previously leaked source map
},
});
devTest("chrome devtools automatic workspace folders", {
files: {
"index.html": `
<script type="module" src="/script.ts"></script>
`,
"script.ts": `
console.log("hello");
`,
},
async test(dev) {
const response = await dev.fetch("/.well-known/appspecific/com.chrome.devtools.json");
expect(response.status).toBe(200);
const json = await response.json();
const root = dev.join(".");
expect(json).toMatchObject({
workspace: {
root,
uuid: expect.any(String),
},
});
},
});