From df017990aa5dd95b4028a74be5fa6e40947f9857 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Wed, 28 May 2025 00:25:30 -0700 Subject: [PATCH] Implement automatic workspace folders support for Chrome DevTools (#19949) --- docs/bundler/html.md | 32 +++++++++++ packages/bun-types/bun.d.ts | 24 +++++++++ src/bun.js/api/server.zig | 105 ++++++++++++++++++++++++++++++++++++ test/bake/dev/html.test.ts | 23 ++++++++ 4 files changed, 184 insertions(+) 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), + }, + }); + }, +});