mirror of
https://github.com/oven-sh/bun
synced 2026-02-02 15:08:46 +00:00
Implement automatic workspace folders support for Chrome DevTools (#19949)
This commit is contained in:
@@ -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:
|
||||
|
||||
24
packages/bun-types/bun.d.ts
vendored
24
packages/bun-types/bun.d.ts
vendored
@@ -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>;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user