diff --git a/docs/bundler/html.md b/docs/bundler/html.md
index 8147c4fa74..b4de1bdf83 100644
--- a/docs/bundler/html.md
+++ b/docs/bundler/html.md
@@ -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:
diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts
index 958ffa3658..fcdf05b290 100644
--- a/packages/bun-types/bun.d.ts
+++ b/packages/bun-types/bun.d.ts
@@ -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": "",
+ * "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 | void | Promise;
diff --git a/src/bun.js/api/server.zig b/src/bun.js/api/server.zig
index 15b01208a7..b47b5380c3 100644
--- a/src/bun.js/api/server.zig
+++ b/src/bun.js/api/server.zig
@@ -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) {
diff --git a/test/bake/dev/html.test.ts b/test/bake/dev/html.test.ts
index 6149104c5d..8186a97cff 100644
--- a/test/bake/dev/html.test.ts
+++ b/test/bake/dev/html.test.ts
@@ -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.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),
+ },
+ });
+ },
+});