Skip to content

Commit df01799

Browse files
Implement automatic workspace folders support for Chrome DevTools (#19949)
1 parent bf02d04 commit df01799

File tree

4 files changed

+184
-0
lines changed

4 files changed

+184
-0
lines changed

docs/bundler/html.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,38 @@ Each call to `console.log` or `console.error` will be broadcast to the terminal
206206

207207
Internally, this reuses the existing WebSocket connection from hot module reloading to send the logs.
208208

209+
### Edit files in the browser
210+
211+
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.
212+
213+
{% 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." /%}
214+
215+
{% details summary="How it works" %}
216+
217+
Bun's dev server automatically adds a `/.well-known/appspecific/com.chrome.devtools.json` route to the server.
218+
219+
This route returns a JSON object with the following shape:
220+
221+
```json
222+
{
223+
"workspace": {
224+
"root": "/path/to/your/project",
225+
"uuid": "a-unique-identifier-for-this-workspace"
226+
}
227+
}
228+
```
229+
230+
For security reasons, this is only enabled when:
231+
232+
1. The request is coming from localhost, 127.0.0.1, or ::1.
233+
2. Hot Module Reloading is enabled.
234+
3. The `chromeDevToolsAutomaticWorkspaceFolders` flag is set to `true` or `undefined`.
235+
4. There are no other routes that match the request.
236+
237+
You can disable this by passing `development: { chromeDevToolsAutomaticWorkspaceFolders: false }` in `Bun.serve`'s options.
238+
239+
{% /details %}
240+
209241
## Keyboard Shortcuts
210242

211243
While the server is running:

packages/bun-types/bun.d.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3357,6 +3357,30 @@ declare module "bun" {
33573357
* @default false
33583358
*/
33593359
console?: boolean;
3360+
3361+
/**
3362+
* Enable automatic workspace folders for Chrome DevTools
3363+
*
3364+
* This lets you persistently edit files in the browser. It works by adding the following route to the server:
3365+
* `/.well-known/appspecific/com.chrome.devtools.json`
3366+
*
3367+
* The response is a JSON object with the following shape:
3368+
* ```json
3369+
* {
3370+
* "workspace": {
3371+
* "root": "<cwd>",
3372+
* "uuid": "<uuid>"
3373+
* }
3374+
* }
3375+
* ```
3376+
*
3377+
* The `root` field is the current working directory of the server.
3378+
* The `"uuid"` field is a hash of the file that started the server and a hash of the current working directory.
3379+
*
3380+
* For security reasons, if the remote socket address is not from localhost, 127.0.0.1, or ::1, the request is ignored.
3381+
* @default true
3382+
*/
3383+
chromeDevToolsAutomaticWorkspaceFolders?: boolean;
33603384
};
33613385

33623386
error?: (this: Server, error: ErrorLike) => Response | Promise<Response> | void | Promise<void>;

src/bun.js/api/server.zig

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,13 @@ pub const ServerConfig = struct {
348348
development: DevelopmentOption = .development,
349349
broadcast_console_log_from_browser_to_server_for_bake: bool = false,
350350

351+
/// Enable automatic workspace folders for Chrome DevTools
352+
/// https://chromium.googlesource.com/devtools/devtools-frontend/+/main/docs/ecosystem/automatic_workspace_folders.md
353+
/// https://github.com/ChromeDevTools/vite-plugin-devtools-json/blob/76080b04422b36230d4b7a674b90d6df296cbff5/src/index.ts#L60-L77
354+
///
355+
/// If HMR is not enabled, then this field is ignored.
356+
enable_chrome_devtools_automatic_workspace_folders: bool = true,
357+
351358
onError: JSC.JSValue = JSC.JSValue.zero,
352359
onRequest: JSC.JSValue = JSC.JSValue.zero,
353360
onNodeHTTPRequest: JSC.JSValue = JSC.JSValue.zero,
@@ -1326,6 +1333,10 @@ pub const ServerConfig = struct {
13261333
if (try dev.getBooleanStrict(global, "console")) |console| {
13271334
args.broadcast_console_log_from_browser_to_server_for_bake = console;
13281335
}
1336+
1337+
if (try dev.getBooleanStrict(global, "chromeDevToolsAutomaticWorkspaceFolders")) |enable_chrome_devtools_automatic_workspace_folders| {
1338+
args.enable_chrome_devtools_automatic_workspace_folders = enable_chrome_devtools_automatic_workspace_folders;
1339+
}
13291340
} else {
13301341
args.development = if (dev.toBoolean()) .development else .production;
13311342
}
@@ -7088,12 +7099,90 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
70887099
ctx.toAsync(req, request_object);
70897100
}
70907101

7102+
// https://chromium.googlesource.com/devtools/devtools-frontend/+/main/docs/ecosystem/automatic_workspace_folders.md
7103+
fn onChromeDevToolsJSONRequest(this: *ThisServer, req: *uws.Request, resp: *App.Response) void {
7104+
if (comptime Environment.enable_logs)
7105+
httplog("{s} - {s}", .{ req.method(), req.url() });
7106+
7107+
const authorized = brk: {
7108+
if (this.dev_server == null)
7109+
break :brk false;
7110+
7111+
if (resp.getRemoteSocketInfo()) |*address| {
7112+
// IPv4 loopback addresses
7113+
if (strings.startsWith(address.ip, "127.")) {
7114+
break :brk true;
7115+
}
7116+
7117+
// IPv6 loopback addresses
7118+
if (strings.startsWith(address.ip, "::ffff:127.") or
7119+
strings.startsWith(address.ip, "::1") or
7120+
strings.eqlComptime(address.ip, "0:0:0:0:0:0:0:1"))
7121+
{
7122+
break :brk true;
7123+
}
7124+
}
7125+
7126+
break :brk false;
7127+
};
7128+
7129+
if (!authorized) {
7130+
req.setYield(true);
7131+
return;
7132+
}
7133+
7134+
// They need a 16 byte uuid. It needs to be somewhat consistent. We don't want to store this field anywhere.
7135+
7136+
// So we first use a hash of the main field:
7137+
const first_hash_segment: [8]u8 = brk: {
7138+
const buffer = bun.PathBufferPool.get();
7139+
defer bun.PathBufferPool.put(buffer);
7140+
const main = JSC.VirtualMachine.get().main;
7141+
const len = @min(main.len, buffer.len);
7142+
break :brk @bitCast(bun.hash(bun.strings.copyLowercase(main[0..len], buffer[0..len])));
7143+
};
7144+
7145+
// And then we use a hash of their project root directory:
7146+
const second_hash_segment: [8]u8 = brk: {
7147+
const buffer = bun.PathBufferPool.get();
7148+
defer bun.PathBufferPool.put(buffer);
7149+
const root = this.dev_server.?.root;
7150+
const len = @min(root.len, buffer.len);
7151+
break :brk @bitCast(bun.hash(bun.strings.copyLowercase(root[0..len], buffer[0..len])));
7152+
};
7153+
7154+
// We combine it together to get a 16 byte uuid.
7155+
const hash_bytes: [16]u8 = first_hash_segment ++ second_hash_segment;
7156+
const uuid = bun.UUID.initWith(&hash_bytes);
7157+
7158+
// interface DevToolsJSON {
7159+
// workspace?: {
7160+
// root: string,
7161+
// uuid: string,
7162+
// }
7163+
// }
7164+
const json_string = std.fmt.allocPrint(bun.default_allocator, "{{ \"workspace\": {{ \"root\": {}, \"uuid\": \"{}\" }} }}", .{
7165+
bun.fmt.formatJSONStringUTF8(this.dev_server.?.root, .{}),
7166+
uuid,
7167+
}) catch bun.outOfMemory();
7168+
defer bun.default_allocator.free(json_string);
7169+
7170+
resp.writeStatus("200 OK");
7171+
resp.writeHeader("Content-Type", "application/json");
7172+
resp.end(json_string, resp.shouldCloseConnection());
7173+
}
7174+
70917175
fn setRoutes(this: *ThisServer) JSC.JSValue {
70927176
var route_list_value = JSC.JSValue.zero;
70937177
const app = this.app.?;
70947178
const any_server = AnyServer.from(this);
70957179
const dev_server = this.dev_server;
70967180

7181+
// https://chromium.googlesource.com/devtools/devtools-frontend/+/main/docs/ecosystem/automatic_workspace_folders.md
7182+
// Only enable this when we're using the dev server.
7183+
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;
7184+
const chrome_devtools_route = "/.well-known/appspecific/com.chrome.devtools.json";
7185+
70977186
// --- 1. Handle user_routes_to_build (dynamic JS routes) ---
70987187
// (This part remains conceptually the same: populate this.user_routes and route_list_value
70997188
// 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
71437232
has_any_user_route_for_star_path = true;
71447233
}
71457234

7235+
if (should_add_chrome_devtools_json_route) {
7236+
if (strings.eqlComptime(user_route.route.path, chrome_devtools_route) or strings.hasPrefix(user_route.route.path, "/.well-known/")) {
7237+
should_add_chrome_devtools_json_route = false;
7238+
}
7239+
}
7240+
71467241
// Register HTTP routes
71477242
switch (user_route.route.method) {
71487243
.any => {
@@ -7209,6 +7304,12 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
72097304
}
72107305
}
72117306

7307+
if (should_add_chrome_devtools_json_route) {
7308+
if (strings.eqlComptime(entry.path, chrome_devtools_route) or strings.hasPrefix(entry.path, "/.well-known/")) {
7309+
should_add_chrome_devtools_json_route = false;
7310+
}
7311+
}
7312+
72127313
switch (entry.route) {
72137314
.static => |static_route| {
72147315
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
72957396
}
72967397
}
72977398

7399+
if (should_add_chrome_devtools_json_route) {
7400+
app.get(chrome_devtools_route, *ThisServer, this, onChromeDevToolsJSONRequest);
7401+
}
7402+
72987403
// If onNodeHTTPRequest is configured, it might be needed for Node.js compatibility layer
72997404
// for specific Node API routes, even if it's not the main "/*" handler.
73007405
if (this.config.onNodeHTTPRequest != .zero) {

test/bake/dev/html.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,3 +199,26 @@ devTest("memory leak case 1", {
199199
await dev.fetch("/"); // previously leaked source map
200200
},
201201
});
202+
203+
devTest("chrome devtools automatic workspace folders", {
204+
files: {
205+
"index.html": `
206+
<script type="module" src="/script.ts"></script>
207+
`,
208+
"script.ts": `
209+
console.log("hello");
210+
`,
211+
},
212+
async test(dev) {
213+
const response = await dev.fetch("/.well-known/appspecific/com.chrome.devtools.json");
214+
expect(response.status).toBe(200);
215+
const json = await response.json();
216+
const root = dev.join(".");
217+
expect(json).toMatchObject({
218+
workspace: {
219+
root,
220+
uuid: expect.any(String),
221+
},
222+
});
223+
},
224+
});

0 commit comments

Comments
 (0)