From 7521e45b17830e39cfcb271f98c8e686b43ebb7f Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Fri, 2 May 2025 12:55:57 -0700 Subject: [PATCH] --console & console: true (#19427) Co-authored-by: Zack Radisic <56137411+zackradisic@users.noreply.github.com> --- cmake/tools/SetupWebKit.cmake | 2 +- docs/bundler/fullstack.md | 28 + docs/bundler/html.md | 13 +- .../src/debugger/node-socket-framer.ts | 2 +- packages/bun-types/bun.d.ts | 6 + src/bake/DevServer.zig | 91 ++- src/bake/bake.private.d.ts | 6 +- src/bake/client/inspect.ts | 629 ++++++++++++++++++ src/bake/client/websocket.ts | 34 + src/bake/hmr-module.ts | 18 +- src/bake/hmr-runtime-client.ts | 110 ++- src/bun.js/api/bun/socket.zig | 7 +- src/bun.js/api/server.zig | 6 + .../InspectorBunFrontendDevServerAgent.zig | 8 + .../InspectorBunFrontendDevServerAgent.cpp | 13 + .../InspectorBunFrontendDevServerAgent.h | 2 + src/js/internal/debugger.ts | 7 +- src/js/internal/html.ts | 26 +- test/cli/inspect/BunFrontendDevServer.test.ts | 37 ++ test/cli/inspect/socket-framer.ts | 2 +- 20 files changed, 993 insertions(+), 54 deletions(-) create mode 100644 src/bake/client/inspect.ts diff --git a/cmake/tools/SetupWebKit.cmake b/cmake/tools/SetupWebKit.cmake index 86f6d43dda..6b0a392546 100644 --- a/cmake/tools/SetupWebKit.cmake +++ b/cmake/tools/SetupWebKit.cmake @@ -2,7 +2,7 @@ option(WEBKIT_VERSION "The version of WebKit to use") option(WEBKIT_LOCAL "If a local version of WebKit should be used instead of downloading") if(NOT WEBKIT_VERSION) - set(WEBKIT_VERSION 53d4176ddc98ba721e50355826f58ec758766fa8) + set(WEBKIT_VERSION c244f567ab804c2558067d00733013c01725d824) endif() string(SUBSTRING ${WEBKIT_VERSION} 0 16 WEBKIT_VERSION_PREFIX) diff --git a/docs/bundler/fullstack.md b/docs/bundler/fullstack.md index b498305561..e89b29af2e 100644 --- a/docs/bundler/fullstack.md +++ b/docs/bundler/fullstack.md @@ -203,6 +203,34 @@ When `development` is `true`, Bun will: - Include the `SourceMap` header in the response so that devtools can show the original source code - Disable minification - Re-bundle assets on each request to a .html file +- Enable hot module reloading (unless `hmr: false` is set) + +#### Echo console logs from browser to terminal + +Bun.serve() supports echoing console logs from the browser to the terminal. + +To enable this, pass `console: true` in the `development` object in `Bun.serve()`. + +```ts +import homepage from "./index.html"; + +Bun.serve({ + // development can also be an object. + development: { + // Enable Hot Module Reloading + hmr: true, + + // Echo console logs from the browser to the terminal + console: true, + }, + + routes: { + "/": homepage, + }, +}); +``` + +When `console: true` is set, Bun will stream console logs from the browser to the terminal. This reuses the existing WebSocket connection from HMR to send the logs. #### Production mode diff --git a/docs/bundler/html.md b/docs/bundler/html.md index cb2827a1e9..1f1dcefd14 100644 --- a/docs/bundler/html.md +++ b/docs/bundler/html.md @@ -194,6 +194,18 @@ import "tailwindcss"; Only one of those are necessary, not all three. +## `--console` streams console logs from the browser to the terminal + +Bun's dev server supports streaming console logs from the browser to the terminal. + +To enable, pass the `--console` CLI flag. + +{% bunDevServerTerminal alt="bun --console ./index.html" path="./index.html" routes="" /%} + +Each call to `console.log` or `console.error` will be broadcast to the terminal that started the server. + +Internally, this reuses the existing WebSocket connection from hot module reloading to send the logs. + ## Keyboard Shortcuts While the server is running: @@ -288,7 +300,6 @@ All paths are resolved relative to your HTML file, making it easy to organize yo ## This is a work in progress -- No HMR support yet - Need more plugins - Need more configuration options for things like asset handling - Need a way to configure CORS, headers, etc. diff --git a/packages/bun-debug-adapter-protocol/src/debugger/node-socket-framer.ts b/packages/bun-debug-adapter-protocol/src/debugger/node-socket-framer.ts index 3d0efa181b..4c7b9519b1 100644 --- a/packages/bun-debug-adapter-protocol/src/debugger/node-socket-framer.ts +++ b/packages/bun-debug-adapter-protocol/src/debugger/node-socket-framer.ts @@ -33,7 +33,7 @@ export class SocketFramer { } send(data: string): void { - socketFramerMessageLengthBuffer.writeUInt32BE(data.length, 0); + socketFramerMessageLengthBuffer.writeUInt32BE(Buffer.byteLength(data), 0); this.socket.write(socketFramerMessageLengthBuffer); this.socket.write(data); } diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 56ff13e00c..5ed9e31f90 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -3258,6 +3258,12 @@ declare module "bun" { * */ hmr?: boolean; + + /** + * Enable console log streaming from browser to server + * @default false + */ + console?: boolean; }; error?: (this: Server, error: ErrorLike) => Response | Promise | void | Promise; diff --git a/src/bake/DevServer.zig b/src/bake/DevServer.zig index bd71baef02..711a229c0d 100644 --- a/src/bake/DevServer.zig +++ b/src/bake/DevServer.zig @@ -20,6 +20,7 @@ pub const Options = struct { vm: *VirtualMachine, framework: bake.Framework, bundler_options: bake.SplitBundlerOptions, + broadcast_console_log_from_browser_to_server: bool, // Debugging features dump_sources: ?[]const u8 = if (Environment.isDebug) ".bake-debug" else null, @@ -228,6 +229,15 @@ has_pre_crash_handler: bool, /// Can be enabled with env var `BUN_ASSUME_PERFECT_INCREMENTAL=1` assume_perfect_incremental_bundling: bool = false, +/// If true, console logs from the browser will be echoed to the server console. +/// This works by overriding console.log & console.error in hmr-runtime-client.ts +/// with a function that sends the message from the client to the server. +/// +/// There are two usecases: +/// - Echoing browser console logs to the server for debugging +/// - WebKit Inspector remote debugging integration +broadcast_console_log_from_browser_to_server: bool, + pub const internal_prefix = "/_bun"; /// Assets which are routed to the `Assets` storage. pub const asset_prefix = internal_prefix ++ "/asset"; @@ -466,7 +476,7 @@ pub fn init(options: Options) bun.JSOOM!*DevServer { bun.getRuntimeFeatureFlag("BUN_ASSUME_PERFECT_INCREMENTAL"), .relative_path_buf_lock = .unlocked, .testing_batch_events = .disabled, - + .broadcast_console_log_from_browser_to_server = options.broadcast_console_log_from_browser_to_server, .server_transpiler = undefined, .client_transpiler = undefined, .ssr_transpiler = undefined, @@ -816,6 +826,7 @@ pub fn deinit(dev: *DevServer) void { }, .enable_after_bundle => {}, }, + .broadcast_console_log_from_browser_to_server = {}, .magic = { bun.debugAssert(dev.magic == .valid); @@ -888,7 +899,7 @@ pub fn memoryCostDetailed(dev: *DevServer) MemoryCost { .framework = {}, .bundler_options = {}, .allocation_scope = {}, - + .broadcast_console_log_from_browser_to_server = {}, // to be counted. .root = { other_bytes += dev.root.len; @@ -1580,6 +1591,7 @@ fn onHtmlRequestWithBundle(dev: *DevServer, route_bundle_index: RouteBundle.Inde const blob = html.cached_response orelse generate: { const payload = generateHTMLPayload(dev, route_bundle_index, route_bundle, html) catch bun.outOfMemory(); errdefer dev.allocator.free(payload); + html.cached_response = StaticRoute.initFromAnyBlob( &.fromOwnedSlice(dev.allocator, payload), .{ @@ -1658,6 +1670,7 @@ fn generateHTMLPayload(dev: *DevServer, route_bundle_index: RouteBundle.Index, r var array: std.ArrayListUnmanaged(u8) = try std.ArrayListUnmanaged(u8).initCapacity(dev.allocator, payload_size); errdefer array.deinit(dev.allocator); array.appendSliceAssumeCapacity(before_head_end); + // Insert all link tags before "" for (css_ids) |name| { array.appendSliceAssumeCapacity(" {}, } - const client_bundle = dev.client_graph.takeJSBundle(.{ + const client_bundle = dev.client_graph.takeJSBundle(&.{ .kind = .initial_response, .initial_response_entry_point = if (client_file) |index| dev.client_graph.bundled_files.keys()[index.get()] @@ -2088,6 +2101,7 @@ fn generateClientBundle(dev: *DevServer, route_bundle: *RouteBundle) bun.OOM![]u "", .react_refresh_entry_point = react_fast_refresh_id, .script_id = script_id, + .console_log = dev.shouldReceiveConsoleLogFromBrowser(), }); return client_bundle; @@ -2434,7 +2448,6 @@ pub fn finalizeBundle( } dev.allocation_scope.assertOwned(compile_result.code); html.bundled_html_text = compile_result.code; - html.script_injection_offset = .init(compile_result.script_injection_offset); chunk.entry_point.entry_point_id = @intCast(route_bundle_index.get()); @@ -2493,7 +2506,7 @@ pub fn finalizeBundle( // Load all new chunks into the server runtime. if (!dev.frontend_only and dev.server_graph.current_chunk_len > 0) { - const server_bundle = try dev.server_graph.takeJSBundle(.{ .kind = .hmr_chunk }); + const server_bundle = try dev.server_graph.takeJSBundle(&.{ .kind = .hmr_chunk }); defer dev.allocator.free(server_bundle); const server_modules = c.BakeLoadServerHmrPatch(@ptrCast(dev.vm.global), bun.String.createLatin1(server_bundle)) catch |err| { @@ -2738,9 +2751,10 @@ pub fn finalizeBundle( try w.writeInt(u32, entry_size, .little); // Build and send the source chunk - try dev.client_graph.takeJSBundleToList(&hot_update_payload, .{ + try dev.client_graph.takeJSBundleToList(&hot_update_payload, &.{ .kind = .hmr_chunk, .script_id = script_id, + .console_log = dev.shouldReceiveConsoleLogFromBrowser(), }); } } else { @@ -4842,6 +4856,7 @@ pub fn IncrementalGraph(side: bake.Side) type { script_id: SourceMapStore.Key, initial_response_entry_point: []const u8 = "", react_refresh_entry_point: []const u8 = "", + console_log: bool, }, .server => struct { kind: ChunkKind, @@ -4850,7 +4865,7 @@ pub fn IncrementalGraph(side: bake.Side) type { pub fn takeJSBundle( g: *@This(), - options: TakeJSBundleOptions, + options: *const TakeJSBundleOptions, ) ![]u8 { var chunk = std.ArrayList(u8).init(g.owner().allocator); try g.takeJSBundleToList(&chunk, options); @@ -4861,7 +4876,7 @@ pub fn IncrementalGraph(side: bake.Side) type { pub fn takeJSBundleToList( g: *@This(), list: *std.ArrayList(u8), - options: TakeJSBundleOptions, + options: *const TakeJSBundleOptions, ) !void { const kind = options.kind; g.owner().graph_safety_lock.assertLocked(); @@ -4908,7 +4923,12 @@ pub fn IncrementalGraph(side: bake.Side) type { try w.print("{s}", .{std.fmt.fmtSliceHexLower(std.mem.asBytes(&generation))}); try w.writeAll("\",\n version: \""); try w.writeAll(&g.owner().configuration_hash_key); - try w.writeAll("\""); + + if (options.console_log) { + try w.writeAll("\",\n console: true"); + } else { + try w.writeAll("\",\n console: false"); + } if (options.react_refresh_entry_point.len > 0) { try w.writeAll(",\n refresh: "); @@ -6286,6 +6306,9 @@ pub const IncomingMessageId = enum(u8) { set_url = 'n', /// Tells the DevServer to batch events together. testing_batch_events = 'H', + + /// Console log from the client + console_log = 'l', /// Tells the DevServer to unref a source map. /// - `u64`: SourceMapStore key unref_source_map = 'u', @@ -6294,6 +6317,11 @@ pub const IncomingMessageId = enum(u8) { _, }; +pub const ConsoleLogKind = enum(u8) { + log = 'l', + err = 'e', +}; + const HmrTopic = enum(u8) { hot_update = 'h', errors = 'e', @@ -6500,6 +6528,41 @@ const HmrSocket = struct { event.entry_points.deinit(s.dev.allocator); }, }, + .console_log => { + if (msg.len < 2) { + ws.close(); + return; + } + + const kind: ConsoleLogKind = switch (msg[1]) { + 'l' => .log, + 'e' => .err, + else => { + ws.close(); + return; + }, + }; + + const data = msg[2..]; + + if (s.dev.inspector()) |agent| { + var log_str = bun.String.init(data); + defer log_str.deref(); + agent.notifyConsoleLog(s.dev.debugger_id, kind, &log_str); + } + + if (s.dev.broadcast_console_log_from_browser_to_server) { + switch (kind) { + .log => { + bun.Output.pretty("[browser] {s}\n", .{data}); + }, + .err => { + bun.Output.prettyError("[browser] {s}\n", .{data}); + }, + } + bun.Output.flush(); + } + }, .unref_source_map => { var fbs = std.io.fixedBufferStream(msg[1..]); const r = fbs.reader(); @@ -7209,6 +7272,16 @@ fn releaseRelativePathBuf(dev: *DevServer) void { } } +/// Either of two conditions make this true: +/// - The inspector is enabled +/// - The user passed "console": true in serve({development: {console: true}}) options +/// +/// Changing this value at runtime is unsupported. It's expected that the +/// inspector domains are registered at initialization time. +fn shouldReceiveConsoleLogFromBrowser(dev: *const DevServer) bool { + return dev.inspector() != null or dev.broadcast_console_log_from_browser_to_server; +} + fn dumpStateDueToCrash(dev: *DevServer) !void { comptime assert(bun.FeatureFlags.bake_debugging_features); diff --git a/src/bake/bake.private.d.ts b/src/bake/bake.private.d.ts index be540bc34f..31aff5997c 100644 --- a/src/bake/bake.private.d.ts +++ b/src/bake/bake.private.d.ts @@ -24,9 +24,13 @@ interface Config { * the framework entry point, as well as every client component. */ roots: FileIndex[]; + /** + * If true, the client will receive console logs from the server. + */ + console: boolean; } -/** +/** * Set globally in debug builds. * Removed using --drop=ASSERT in releases. */ diff --git a/src/bake/client/inspect.ts b/src/bake/client/inspect.ts new file mode 100644 index 0000000000..522725bb6d --- /dev/null +++ b/src/bake/client/inspect.ts @@ -0,0 +1,629 @@ +/* This file provides utilities for inspecting and formatting JavaScript values in client-side code. + * It implements a Node.js-like `inspect` function that converts any JavaScript value + * into a string representation, handling circular references, various object types, + * and respecting customization options. + * + * The implementation supports: + * - Primitive values (strings, numbers, booleans, etc.) + * - Complex objects and arrays with customizable depth + * - Special handling for typed arrays, Sets, Maps, and ArrayBuffers + * - Custom inspection via Symbol.for("nodejs.util.inspect.custom") + * - Configurable output formatting (indentation, truncation, etc.) + * + * This is mostly intended for pretty printing console.log from browser to CLI. + */ + +const inspectSymbol = Symbol.for("nodejs.util.inspect.custom"); + +export interface InspectOptions { + showHidden?: boolean; + depth?: number; + maxArrayLength?: number; + maxStringLength?: number; + breakLength?: number; + compact?: number; + sorted?: boolean; + getters?: boolean; + numericSeparator?: boolean; + customInspect?: boolean; +} + +export interface InspectContext extends InspectOptions { + seen: any[]; + currentDepth: number; +} +// Default options +const defaultOptions: InspectOptions = { + showHidden: false, + depth: 2, + maxArrayLength: 100, + maxStringLength: 10000, + breakLength: 80, + compact: 3, + sorted: false, + getters: false, + numericSeparator: false, + customInspect: true, +} as const; + +/** + * Main inspection function + * @param {any} obj - Object to inspect + * @param {Object} options - Configuration options + * @returns {string} String representation + */ +export function inspect(obj: any, options: InspectOptions = {}) { + // Set up context with merged options + const ctx: InspectContext = { + seen: [], + currentDepth: 0, + ...defaultOptions, + ...options, + } as InspectContext; + + return formatValue(ctx, obj, 0); +} + +/** + * Format a value based on its type + * @param {Object} ctx - Context object with settings + * @param {any} value - Value to format + * @param {number} recurseTimes - Current recursion depth + * @returns {string} Formatted value + */ +function formatValue(ctx: InspectContext, value: any, recurseTimes: number) { + // Handle primitive types + if (value === null) return "null"; + if (value === undefined) return "undefined"; + + // Check for custom inspect implementation + if ( + ctx.customInspect !== false && + value !== null && + typeof value === "object" && + typeof value[inspectSymbol] === "function" && + value[inspectSymbol] !== inspect + ) { + return String(value[inspectSymbol](recurseTimes, { ...ctx })); + } + + // Check for circular references + if (ctx.seen.includes(value)) { + return "[Circular]"; + } + + // Format based on type + switch (typeof value) { + case "string": + return formatString(ctx, value); + case "number": + return formatNumber(value, ctx.numericSeparator!); + case "bigint": + return `${value}n`; + case "boolean": + return `${value}`; + case "symbol": + return formatSymbol(value); + case "function": + return formatFunction(value); + case "object": + return formatObject(ctx, value, recurseTimes); + default: + return String(value); + } +} + +/** + * Format a string with proper escaping + * @param {Object} ctx - Context object + * @param {string} value - String to format + * @returns {string} Formatted string + */ +function formatString(ctx, value) { + // Truncate long strings + if (value.length > ctx.maxStringLength) { + const remaining = value.length - ctx.maxStringLength; + const truncated = value.slice(0, ctx.maxStringLength); + return `'${escape(truncated)}'... ${remaining} more character${remaining > 1 ? "s" : ""}`; + } + return `'${escape(value)}'`; +} + +/** + * Escape special characters in a string + * @param {string} str - String to escape + * @returns {string} Escaped string + */ +function escape(str) { + return str + .replace(/\\/g, "\\\\") + .replace(/'/g, "\\'") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/\t/g, "\\t") + .replace(/[\x00-\x1F\x7F-\x9F]/g, ch => { + const code = ch.charCodeAt(0); + return `\\x${code.toString(16).padStart(2, "0")}`; + }); +} + +/** + * Format a number with optional numeric separators + * @param {number} value - Number to format + * @param {boolean} useNumericSeparator - Whether to use numeric separators + * @returns {string} Formatted number + */ +function formatNumber(value: number, useNumericSeparator: boolean) { + if (Object.is(value, -0)) return "-0"; + if (!useNumericSeparator) return String(value); + + const str = String(value); + if (!/^\d+$/.test(str)) return str; + + // Add numeric separators for readability + return str.replace(/\B(?=(\d{3})+(?!\d))/g, "_"); +} + +/** + * Format a symbol + * @param {Symbol} value - Symbol to format + * @returns {string} Formatted symbol + */ +function formatSymbol(value: symbol) { + return value.toString(); +} + +/** + * Format a function + * @param {Function} value - Function to format + * @returns {string} Formatted function + */ +function formatFunction(value: Function) { + const name = value.name || ""; + const constructorName = Object.getPrototypeOf(value)?.constructor?.name; + if (constructorName === "AsyncFunction") { + return `[AsyncFunction: ${name}]`; + } + if (constructorName === "GeneratorFunction") { + return `[GeneratorFunction: ${name}]`; + } + if (constructorName === "AsyncGeneratorFunction") { + return `[AsyncGeneratorFunction: ${name}]`; + } + return `[Function: ${name}]`; +} + +/** + * Format an object based on its type + * @param {Object} ctx - Context object + * @param {any} value - Object to format + * @param {number} recurseTimes - Current recursion depth + * @returns {string} Formatted object + */ +function formatObject(ctx: InspectContext, value: any, recurseTimes: number) { + // Check recursion depth + if (recurseTimes >= ctx.depth!) { + if (Array.isArray(value)) return "[Array]"; + return `[${getConstructorName(value)}]`; + } + + // Mark as seen to detect circular references + ctx.seen.push(value); + recurseTimes += 1; + + let output; + + // Handle special object types + if (Array.isArray(value)) { + output = formatArray(ctx, value, recurseTimes); + } else if (value instanceof Date) { + output = formatDate(value); + } else if (value instanceof RegExp) { + output = formatRegExp(value); + } else if (value instanceof Error) { + output = formatError(value); + } else if (value instanceof Map) { + output = formatMap(ctx, value, recurseTimes); + } else if (value instanceof Set) { + output = formatSet(ctx, value, recurseTimes); + } else if (value instanceof WeakMap) { + output = "WeakMap { ... }"; + } else if (value instanceof WeakSet) { + output = "WeakSet { ... }"; + } else if (value instanceof Promise) { + output = "Promise { ... }"; + } else if (ArrayBuffer.isView(value)) { + output = formatTypedArray(ctx, value as ArrayBufferView & { length: number }); + } else if (value instanceof ArrayBuffer) { + output = formatArrayBuffer(ctx, value); + } else { + // Regular object + output = formatPlainObject(ctx, value, recurseTimes); + } + + // Remove from seen + ctx.seen.pop(); + + return output; +} + +/** + * Format an array + * @param {Object} ctx - Context object + * @param {Array} value - Array to format + * @param {number} recurseTimes - Current recursion depth + * @returns {string} Formatted array + */ +function formatArray(ctx: InspectContext, value: any[], recurseTimes: number) { + // Special case for empty arrays + if (value.length === 0) return "[]"; + + const maxLength = Math.min(ctx.maxArrayLength!, value.length); + const output: string[] = []; + + for (let i = 0; i < maxLength; i++) { + if (Object.prototype.hasOwnProperty.call(value, i)) { + output.push(formatValue(ctx, value[i], recurseTimes)); + } else { + output.push("empty"); + } + } + + if (value.length > maxLength) { + const remaining = value.length - maxLength; + output.push(`... ${remaining} more item${remaining > 1 ? "s" : ""}`); + } + + // Add array properties that aren't indices + const keys = Object.keys(value).filter(key => { + return !(Number(key) >= 0 && Number(key) < value.length && Number(key) === +key); + }); + + if (keys.length > 0) { + for (const key of keys) { + output.push(`${key}: ${formatValue(ctx, value[key], recurseTimes)}`); + } + } + + return `[ ${output.join(", ")} ]`; +} + +/** + * Format a plain object with property enumeration + * @param {Object} ctx - Context object + * @param {Object} value - Object to format + * @param {number} recurseTimes - Current recursion depth + * @returns {string} Formatted object + */ +function formatPlainObject(ctx: InspectContext, value: any, recurseTimes: number) { + // Get constructor name for the prefix + const constructorName = getConstructorName(value); + const prefix = constructorName !== "Object" ? `${constructorName} ` : ""; + + // Get own and inherited properties + const keys = getObjectKeys(value, ctx.showHidden); + + if (keys.length === 0) { + // Handle empty objects + if (constructorName !== "Object" && getPrototypeKeys(value).length > 0) { + // If the object has no own properties but has inherited ones + return formatWithPrototype(ctx, value, recurseTimes, constructorName); + } + return `${prefix}{}`; + } + + // Format properties + const output: string[] = []; + for (const key of keys) { + try { + const desc = Object.getOwnPropertyDescriptor(value, key); + if (desc) { + if (desc.get || desc.set) { + if (desc.get && desc.set) { + output.push(`${formatPropertyKey(key)}: [Getter/Setter]`); + } else if (desc.get) { + output.push(`${formatPropertyKey(key)}: [Getter]`); + } else { + output.push(`${formatPropertyKey(key)}: [Setter]`); + } + } else { + output.push(`${formatPropertyKey(key)}: ${formatValue(ctx, value[key], recurseTimes)}`); + } + } + } catch (err) { + output.push(`${formatPropertyKey(key)}: undefined`); + } + } + + // Sort keys if requested + if (ctx.sorted) { + output.sort(); + } + + // Create the final string + if (output.length === 0) { + return `${prefix}{}`; + } + + // Check if it fits on one line + if (output.join(", ").length < ctx.breakLength!) { + return `${prefix}{ ${output.join(", ")} }`; + } + + // Otherwise format with line breaks + return `${prefix}{\n ${output.join(",\n ")}\n}`; +} + +/** + * Format an object by showing its prototype chain properties + * @param {Object} ctx - Context object + * @param {Object} value - Object to format + * @param {number} recurseTimes - Current recursion depth + * @param {string} constructorName - Constructor name + * @returns {string} Formatted object with prototype info + */ +function formatWithPrototype(ctx: InspectContext, value: any, recurseTimes: number, constructorName: string) { + const protoKeys = getPrototypeKeys(value); + + if (protoKeys.length === 0) { + return `${constructorName} {}`; + } + + const output: string[] = []; + for (const key of protoKeys) { + try { + // Add prototype prefix to distinguish from own properties + output.push(`${formatPropertyKey(key)}: ${formatValue(ctx, value[key], recurseTimes)}`); + } catch (err) { + output.push(`${formatPropertyKey(key)}: undefined`); + } + } + + if (ctx.sorted) { + output.sort(); + } + + if (output.length === 0) { + return `${constructorName} {}`; + } + + if (output.join(", ").length < ctx.breakLength!) { + return `${constructorName} { ${output.join(", ")} }`; + } + + return `${constructorName} {\n ${output.join(",\n ")}\n}`; +} + +/** + * Get keys from an object's prototype + * @param {Object} obj - Object to inspect + * @returns {Array} Array of prototype keys + */ +function getPrototypeKeys(obj) { + const proto = Object.getPrototypeOf(obj); + if (!proto || proto === Object.prototype) { + return []; + } + + const protoKeys = Object.getOwnPropertyNames(proto).filter(key => { + if (key === "constructor") return false; + + const descriptor = Object.getOwnPropertyDescriptor(proto, key); + return typeof descriptor?.value !== "function" && key !== "__proto__"; + }); + + return protoKeys; +} + +/** + * Format a property key with proper quoting if needed + * @param {string|Symbol} key - Property key + * @returns {string} Formatted key + */ +function formatPropertyKey(key: string | symbol) { + if (typeof key === "symbol") { + return `[${key.toString()}]`; + } + + if (key === "__proto__") { + return "['__proto__']"; + } + + if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) { + return key; + } + + return `'${escape(String(key))}'`; +} + +/** + * Get all relevant keys from an object + * @param {Object} obj - Object to inspect + * @param {boolean} showHidden - Whether to include non-enumerable properties + * @returns {Array} Array of property keys + */ +function getObjectKeys(obj, showHidden) { + if (showHidden) { + return Object.getOwnPropertyNames(obj); + } + return Object.keys(obj); +} + +/** + * Get constructor name of an object + * @param {Object} obj - Object to inspect + * @returns {string} Constructor name + */ +function getConstructorName(obj: any) { + if (!obj || typeof obj !== "object") { + return ""; + } + + let constructorName = obj.constructor?.name; + if (!constructorName) { + const prototype = Object.getPrototypeOf(obj); + const protoName = prototype?.constructor?.name; + if (protoName) { + constructorName = protoName; + } + } + + return constructorName || "Object"; +} + +/** + * Format a Date object + * @param {Date} value - Date to format + * @returns {string} Formatted date + */ +function formatDate(value) { + // Check if date is valid + if (isNaN(value.getTime())) { + return "Invalid Date"; + } + return `${value.toISOString()} [Date]`; +} + +/** + * Format a RegExp object + * @param {RegExp} value - RegExp to format + * @returns {string} Formatted regexp + */ +function formatRegExp(value) { + return String(value); +} + +/** + * Format an Error object + * @param {Error} value - Error to format + * @returns {string} Formatted error + */ +function formatError(value: Error) { + return value?.stack || value + ""; +} + +/** + * Format a Map object + * @param {Object} ctx - Context object + * @param {Map} value - Map to format + * @param {number} recurseTimes - Current recursion depth + * @returns {string} Formatted map + */ +function formatMap(ctx: InspectContext, value: Map, recurseTimes: number) { + const output: string[] = []; + const size = value.size; + let i = 0; + + for (const [k, v] of value) { + if (i >= ctx.maxArrayLength!) { + const remaining = size - ctx.maxArrayLength!; + output.push(`... ${remaining} more item${remaining > 1 ? "s" : ""}`); + break; + } + + output.push(`${formatValue(ctx, k, recurseTimes)} => ${formatValue(ctx, v, recurseTimes)}`); + i++; + } + + if (output.length === 0) { + return "Map {}"; + } + + const joined = output.join(", "); + + if (joined.length < ctx.breakLength!) { + return `Map { ${joined} }`; + } + + return `Map {\n ${output.join(",\n ")}\n}`; +} + +/** + * Format a Set object + * @param {Object} ctx - Context object + * @param {Set} value - Set to format + * @param {number} recurseTimes - Current recursion depth + * @returns {string} Formatted set + */ +function formatSet(ctx: InspectContext, value: Set, recurseTimes: number) { + const output: string[] = []; + const size = value.size; + let i = 0; + const max = ctx.maxArrayLength!; + + for (const v of value) { + if (i >= max) { + const remaining = size - max; + output.push(`... ${remaining} more item${remaining > 1 ? "s" : ""}`); + break; + } + + output.push(formatValue(ctx, v, recurseTimes)); + i++; + } + + if (output.length === 0) { + return "Set {}"; + } + + if (output.join(", ").length < ctx.breakLength!) { + return `Set { ${output.join(", ")} }`; + } + + return `Set {\n ${output.join(",\n ")}\n}`; +} + +/** + * Format a typed array + * @param {Object} ctx - Context object + * @param {TypedArray} value - Typed array to format + * @returns {string} Formatted typed array + */ +function formatTypedArray(ctx: InspectContext, value: ArrayBufferView & { length: number }) { + const name = value.constructor.name; + const length = value.length; + const maxLength = Math.min(ctx.maxArrayLength!, length); + const output: string[] = []; + + for (let i = 0; i < maxLength; i++) { + output.push(String(value[i])); + } + + if (value.length > maxLength) { + const remaining = value.length - maxLength; + output.push(`... ${remaining} more item${remaining > 1 ? "s" : ""}`); + } + + return `${name} [ ${output.join(", ")} ]`; +} + +/** + * Format an ArrayBuffer + * @param {Object} ctx - Context object + * @param {ArrayBuffer} value - ArrayBuffer to format + * @returns {string} Formatted array buffer + */ +function formatArrayBuffer(ctx: InspectContext, value: ArrayBuffer) { + const constructorName = getConstructorName(value); + let bytes; + try { + bytes = new Uint8Array(value); + } catch { + return `${constructorName} { [Detached] }`; + } + + const byteLength = bytes.byteLength; + const maxLength = Math.min(ctx.maxArrayLength!, byteLength); + const output: string[] = []; + + for (let i = 0; i < maxLength; i++) { + output.push(bytes[i].toString(16).padStart(2, "0")); + } + + if (byteLength > maxLength) { + const remaining = byteLength - maxLength; + output.push(`... ${remaining} more byte${remaining > 1 ? "s" : ""}`); + } + + return `${constructorName} { ${output.join(" ")} }`; +} diff --git a/src/bake/client/websocket.ts b/src/bake/client/websocket.ts index c3f2d39c54..ffea19051e 100644 --- a/src/bake/client/websocket.ts +++ b/src/bake/client/websocket.ts @@ -42,6 +42,13 @@ interface WebSocketWrapper { /** When re-connected, this is re-assigned */ wrapped: WebSocket | null; send(data: string | ArrayBuffer): void; + /** + * Send data once the connection is established. + * Buffer if the connection is not established yet. + * + * @param data String or ArrayBuffer + */ + sendBuffered(data: string | ArrayBuffer): void; close(): void; [Symbol.dispose](): void; } @@ -57,6 +64,13 @@ export function initWebSocket( let firstConnection = true; let closed = false; + // Allow some messages to be queued if sent before the connection is established. + let sendQueue: Array = []; + let sendQueueLength = 0; + + // Don't queue infinite data incase the user has a bug somewhere in their code. + const MAX_SEND_QUEUE_LENGTH = 1024 * 256; + const wsProxy: WebSocketWrapper = { wrapped: null, send(data) { @@ -65,6 +79,15 @@ export function initWebSocket( wrapped.send(data); } }, + sendBuffered(data) { + const wrapped = this.wrapped; + if (wrapped && wrapped.readyState === 1) { + wrapped.send(data); + } else if (wrapped && wrapped.readyState === 0 && sendQueueLength < MAX_SEND_QUEUE_LENGTH) { + sendQueue.push(data); + sendQueueLength += typeof data === "string" ? data.length : (data as ArrayBuffer).byteLength; + } + }, close() { closed = true; this.wrapped?.close(); @@ -84,6 +107,14 @@ export function initWebSocket( function onFirstOpen() { console.info("[Bun] Hot-module-reloading socket connected, waiting for changes..."); onStatusChange?.(true); + + // Drain the send queue. + const oldSendQueue = sendQueue; + sendQueue = []; + sendQueueLength = 0; + for (const data of oldSendQueue) { + wsProxy.send(data); + } } function onMessage(ev: MessageEvent) { @@ -110,6 +141,9 @@ export function initWebSocket( await new Promise(done => setTimeout(done, 1000)); + // Clear the send queue. + sendQueue.length = sendQueueLength = 0; + while (true) { if (closed) return; diff --git a/src/bake/hmr-module.ts b/src/bake/hmr-module.ts index 6fa6749385..308e9dca75 100644 --- a/src/bake/hmr-module.ts +++ b/src/bake/hmr-module.ts @@ -440,7 +440,7 @@ export function loadModuleAsync( DEBUG.ASSERT( isAsync // ? list.some(x => x instanceof Promise) - : list.every(x => x instanceof HMRModule) + : list.every(x => x instanceof HMRModule), ); // Running finishLoadModuleAsync synchronously when there are no promises is @@ -521,7 +521,7 @@ function parseEsmDependencies>( DEBUG.ASSERT(typeof key === "string"); // TODO: there is a bug in the way exports are verified. Additionally a // possible performance issue. For the meantime, this is disabled since - // it was not shipped in the initial 1.2.3 HMR, and real issues will + // it was not shipped in the initial 1.2.3 HMR, and real issues will // just throw 'undefined is not a function' or so on. // if (!availableExportKeys.includes(key)) { @@ -537,7 +537,7 @@ function parseEsmDependencies>( i = expectedExportKeyEnd; if (IS_BUN_DEVELOPMENT) { - DEBUG.ASSERT(list[list.length - 1] as any instanceof HMRModule); + DEBUG.ASSERT((list[list.length - 1] as any) instanceof HMRModule); } } } @@ -583,7 +583,7 @@ type HotEventHandler = (data: any) => void; // If updating this, make sure the `devserver.d.ts` types are // kept in sync. -type HMREvent = +type HMREvent = | "bun:ready" | "bun:beforeUpdate" | "bun:afterUpdate" @@ -682,9 +682,9 @@ export async function replaceModules(modules: Record, source for (const boundary of failures) { const path: Id[] = []; let current = registry.get(boundary)!; - DEBUG.ASSERT(!boundary.endsWith(".html")); // caller should have already reloaded - DEBUG.ASSERT(current); - DEBUG.ASSERT(current.selfAccept === null); + DEBUG.ASSERT(!boundary.endsWith(".html")); // caller should have already reloaded + DEBUG.ASSERT(current); + DEBUG.ASSERT(current.selfAccept === null); if (current.importers.size === 0) { message += `Module "${boundary}" is a root module that does not self-accept.\n`; continue; @@ -710,9 +710,9 @@ export async function replaceModules(modules: Record, source } message = message.trim(); if (side === "client") { - sessionStorage.setItem( + sessionStorage?.setItem?.( "bun:hmr:message", - JSON.stringify({ + JSON.stringify?.({ message, kind: "warn", }), diff --git a/src/bake/hmr-runtime-client.ts b/src/bake/hmr-runtime-client.ts index 9f3d411098..bebe2dcc34 100644 --- a/src/bake/hmr-runtime-client.ts +++ b/src/bake/hmr-runtime-client.ts @@ -1,21 +1,30 @@ // This file is the entrypoint to the hot-module-reloading runtime // In the browser, this uses a WebSocket to communicate with the bundler. -import './debug'; +import { editCssArray, editCssContent } from "./client/css-reloader"; +import { DataViewReader } from "./client/data-view"; +import { inspect } from "./client/inspect"; +import { hasFatalError, onRuntimeError, onServerErrorPayload } from "./client/overlay"; +import { + addMapping, + clearDisconnectedSourceMaps, + configureSourceMapGCSize, + getKnownSourceMaps, + SourceMapURL, +} from "./client/stack-trace"; +import { initWebSocket } from "./client/websocket"; +import "./debug"; +import { MessageId } from "./generated"; import { - loadModuleAsync, - replaceModules, - onServerSideReload, - setRefreshRuntime, emitEvent, fullReload, + loadModuleAsync, + onServerSideReload, + replaceModules, + setRefreshRuntime, } from "./hmr-module"; -import { hasFatalError, onServerErrorPayload, onRuntimeError } from "./client/overlay"; -import { DataViewReader } from "./client/data-view"; -import { initWebSocket } from "./client/websocket"; -import { MessageId } from "./generated"; -import { editCssContent, editCssArray } from "./client/css-reloader"; import { td } from "./shared"; -import { addMapping, clearDisconnectedSourceMaps, configureSourceMapGCSize, getKnownSourceMaps, SourceMapURL } from './client/stack-trace'; + +const consoleErrorWithoutInspector = console.error; if (typeof IS_BUN_DEVELOPMENT !== "boolean") { throw new Error("DCE is configured incorrectly"); @@ -75,7 +84,7 @@ globalThis[Symbol.for("bun:hmr")] = (modules: any, id: string) => { console.error(e); fullReload(); }); -} +}; let isFirstRun = true; const handlers = { @@ -97,12 +106,12 @@ const handlers = { return; } - ws.send("she"); // IncomingMessageId.subscribe with hot_update and errors - ws.send("n" + location.pathname); // IncomingMessageId.set_url + ws.sendBuffered("she"); // IncomingMessageId.subscribe with hot_update and errors + ws.sendBuffered("n" + location.pathname); // IncomingMessageId.set_url - const fn = globalThis[Symbol.for('bun:loadData')]; + const fn = globalThis[Symbol.for("bun:loadData")]; if (fn) { - document.removeEventListener('visibilitychange', fn); + document.removeEventListener("visibilitychange", fn); ws.send("i" + config.generation); } }, @@ -170,13 +179,13 @@ const handlers = { if (reader.hasMoreData()) { const sourceMapSize = reader.u32(); const rest = reader.rest(); - const sourceMapId = td.decode(new Uint8Array(rest, rest.byteLength - 24, 16)) + const sourceMapId = td.decode(new Uint8Array(rest, rest.byteLength - 24, 16)); DEBUG.ASSERT(sourceMapId.match(/[a-f0-9]{16}/)); - const blob = new Blob([rest], { type: 'application/javascript' }); + const blob = new Blob([rest], { type: "application/javascript" }); const url = URL.createObjectURL(blob); - const script = document.createElement('script'); + const script = document.createElement("script"); scriptTags.set(sourceMapId, [script, sourceMapSize]); - script.className = 'bun-hmr-script'; + script.className = "bun-hmr-script"; script.src = url; script.onerror = onHmrLoadError; document.head.appendChild(script); @@ -203,7 +212,7 @@ function onHmrLoadError(event: Event | string, source?: string, lineno?: number, } else if (error) { console.error(error); } else { - console.error('Failed to load HMR script', event); + console.error("Failed to load HMR script", event); } fullReload(); } @@ -213,7 +222,7 @@ function onHmrLoadError(event: Event | string, source?: string, lineno?: number, const truePushState = History.prototype.pushState; History.prototype.pushState = function pushState(this: History, state: any, title: string, url?: string | null) { truePushState.call(this, state, title, url); - ws.send("n" + location.pathname); + ws.sendBuffered("n" + location.pathname); }; const trueReplaceState = History.prototype.replaceState; History.prototype.replaceState = function replaceState( @@ -223,7 +232,7 @@ function onHmrLoadError(event: Event | string, source?: string, lineno?: number, url?: string | null, ) { trueReplaceState.call(this, state, title, url); - ws.send("n" + location.pathname); + ws.sendBuffered("n" + location.pathname); }; } @@ -235,7 +244,7 @@ window.addEventListener("unhandledrejection", event => { }); { - let reloadError: any = sessionStorage.getItem("bun:hmr:message"); + let reloadError: any = sessionStorage?.getItem?.("bun:hmr:message"); if (reloadError) { sessionStorage.removeItem("bun:hmr:message"); reloadError = JSON.parse(reloadError); @@ -247,6 +256,55 @@ window.addEventListener("unhandledrejection", event => { } } +// This implements streaming console.log and console.error from the browser to the server. +// +// Bun.serve({ +// development: { +// console: true, +// ^^^^^^^^^^^^^^^^ +// }, +// }) +// + +if (config.console) { + // Ensure it only runs once, and avoid the extra noise in the HTML. + const originalLog = console.log; + + function websocketInspect(logLevel: "l" | "e", values: any[]) { + let str = "l" + logLevel; + let first = true; + for (const value of values) { + if (first) { + first = false; + } else { + str += " "; + } + + if (typeof value === "string") { + str += value; + } else { + str += inspect(value); + } + } + + ws.sendBuffered(str); + } + + if (typeof originalLog === "function") { + console.log = function log(...args: any[]) { + originalLog(...args); + websocketInspect("l", args); + }; + } + + if (typeof consoleErrorWithoutInspector === "function") { + console.error = function error(...args: any[]) { + consoleErrorWithoutInspector(...args); + websocketInspect("e", args); + }; + } +} + // The following API may be altered at any point. // Thankfully, you can just call `import.meta.hot.on` let testingHook = globalThis[Symbol.for("bun testing api, may change at any time")]; @@ -267,6 +325,8 @@ try { emitEvent("bun:ready", null); } catch (e) { - console.error(e); + // Use consoleErrorWithoutInspector to avoid double-reporting errors. + consoleErrorWithoutInspector(e); + onRuntimeError(e, true, false); } diff --git a/src/bun.js/api/bun/socket.zig b/src/bun.js/api/bun/socket.zig index b63ba17042..5987ce4b81 100644 --- a/src/bun.js/api/bun/socket.zig +++ b/src/bun.js/api/bun/socket.zig @@ -1551,7 +1551,11 @@ fn NewSocket(comptime ssl: bool) type { pub fn onWritable(this: *This, _: Socket) void { JSC.markBinding(@src()); - log("onWritable", .{}); + log("onWritable detached={s}, native_callback_writable={s} sanity={s}", .{ + if (this.socket.isDetached()) "true" else "false", + if (this.native_callback.onWritable()) "true" else "false", + if (this.handlers.onWritable == .zero) "true" else "false", + }); if (this.socket.isDetached()) return; if (this.native_callback.onWritable()) return; const handlers = this.handlers; @@ -1565,6 +1569,7 @@ fn NewSocket(comptime ssl: bool) type { this.ref(); defer this.deref(); this.internalFlush(); + log("onWritable buffered_data_for_node_net {d}", .{this.buffered_data_for_node_net.len}); // is not writable if we have buffered data or if we are already detached if (this.buffered_data_for_node_net.len > 0 or this.socket.isDetached()) return; diff --git a/src/bun.js/api/server.zig b/src/bun.js/api/server.zig index 552f9acf09..b50b389d7c 100644 --- a/src/bun.js/api/server.zig +++ b/src/bun.js/api/server.zig @@ -384,6 +384,7 @@ pub const ServerConfig = struct { sni: ?bun.BabyList(SSLConfig) = null, max_request_body_size: usize = 1024 * 1024 * 128, development: DevelopmentOption = .development, + broadcast_console_log_from_browser_to_server_for_bake: bool = false, onError: JSC.JSValue = JSC.JSValue.zero, onRequest: JSC.JSValue = JSC.JSValue.zero, @@ -1306,6 +1307,10 @@ pub const ServerConfig = struct { } else { args.development = .development; } + + if (try dev.getBooleanStrict(global, "console")) |console| { + args.broadcast_console_log_from_browser_to_server_for_bake = console; + } } else { args.development = if (dev.toBoolean()) .development else .production; } @@ -6228,6 +6233,7 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d .framework = bake_options.framework, .bundler_options = bake_options.bundler_options, .vm = global.bunVM(), + .broadcast_console_log_from_browser_to_server = config.broadcast_console_log_from_browser_to_server_for_bake, }) else null; diff --git a/src/bun.js/api/server/InspectorBunFrontendDevServerAgent.zig b/src/bun.js/api/server/InspectorBunFrontendDevServerAgent.zig index 25f5273398..1e3b0f94fc 100644 --- a/src/bun.js/api/server/InspectorBunFrontendDevServerAgent.zig +++ b/src/bun.js/api/server/InspectorBunFrontendDevServerAgent.zig @@ -13,6 +13,7 @@ const InspectorBunFrontendDevServerAgentHandle = opaque { extern "c" fn InspectorBunFrontendDevServerAgent__notifyClientNavigated(agent: *InspectorBunFrontendDevServerAgentHandle, devServerId: i32, connectionId: i32, url: *bun.String, routeBundleId: i32) void; extern "c" fn InspectorBunFrontendDevServerAgent__notifyClientErrorReported(agent: *InspectorBunFrontendDevServerAgentHandle, devServerId: i32, clientErrorPayloadBase64: *bun.String) void; extern "c" fn InspectorBunFrontendDevServerAgent__notifyGraphUpdate(agent: *InspectorBunFrontendDevServerAgentHandle, devServerId: i32, visualizerPayloadBase64: *bun.String) void; + extern "c" fn InspectorBunFrontendDevServerAgent__notifyConsoleLog(agent: *InspectorBunFrontendDevServerAgentHandle, devServerId: i32, kind: u8, data: *bun.String) void; }; const notifyClientConnected = c.InspectorBunFrontendDevServerAgent__notifyClientConnected; const notifyClientDisconnected = c.InspectorBunFrontendDevServerAgent__notifyClientDisconnected; @@ -22,6 +23,7 @@ const InspectorBunFrontendDevServerAgentHandle = opaque { const notifyClientNavigated = c.InspectorBunFrontendDevServerAgent__notifyClientNavigated; const notifyClientErrorReported = c.InspectorBunFrontendDevServerAgent__notifyClientErrorReported; const notifyGraphUpdate = c.InspectorBunFrontendDevServerAgent__notifyGraphUpdate; + const notifyConsoleLog = c.InspectorBunFrontendDevServerAgent__notifyConsoleLog; }; pub const BunFrontendDevServerAgent = struct { @@ -101,6 +103,12 @@ pub const BunFrontendDevServerAgent = struct { } } + pub fn notifyConsoleLog(this: BunFrontendDevServerAgent, devServerId: DevServer.DebuggerId, kind: bun.bake.DevServer.ConsoleLogKind, data: *bun.String) void { + if (this.handle) |handle| { + handle.notifyConsoleLog(devServerId.get(), @intFromEnum(kind), data); + } + } + export fn Bun__InspectorBunFrontendDevServerAgent__setEnabled(agent: ?*InspectorBunFrontendDevServerAgentHandle) void { if (JSC.VirtualMachine.get().debugger) |*debugger| { debugger.frontend_dev_server_agent.handle = agent; diff --git a/src/bun.js/bindings/InspectorBunFrontendDevServerAgent.cpp b/src/bun.js/bindings/InspectorBunFrontendDevServerAgent.cpp index 75ea9c7865..071f40521c 100644 --- a/src/bun.js/bindings/InspectorBunFrontendDevServerAgent.cpp +++ b/src/bun.js/bindings/InspectorBunFrontendDevServerAgent.cpp @@ -125,6 +125,14 @@ void InspectorBunFrontendDevServerAgent::graphUpdate(int devServerId, const Stri // m_frontendDispatcher->graphUpdate(devServerId, visualizerPayloadBase64); } +void InspectorBunFrontendDevServerAgent::consoleLog(int devServerId, char kind, const String& data) +{ + if (!m_enabled || !m_frontendDispatcher) + return; + + m_frontendDispatcher->consoleLog(devServerId, kind, data); +} + // C API implementations for Zig extern "C" { @@ -178,6 +186,11 @@ void InspectorBunFrontendDevServerAgent__notifyGraphUpdate(InspectorBunFrontendD { agent->graphUpdate(devServerId, visualizerPayloadBase64->toWTFString()); } + +void InspectorBunFrontendDevServerAgent__notifyConsoleLog(InspectorBunFrontendDevServerAgent* agent, int devServerId, char kind, BunString* data) +{ + agent->consoleLog(devServerId, kind, data->toWTFString()); +} } } // namespace Inspector diff --git a/src/bun.js/bindings/InspectorBunFrontendDevServerAgent.h b/src/bun.js/bindings/InspectorBunFrontendDevServerAgent.h index 59ccc7459f..ec4aa23f29 100644 --- a/src/bun.js/bindings/InspectorBunFrontendDevServerAgent.h +++ b/src/bun.js/bindings/InspectorBunFrontendDevServerAgent.h @@ -41,6 +41,7 @@ public: void clientNavigated(int devServerId, int connectionId, const String& url, std::optional routeBundleId); void clientErrorReported(int devServerId, const String& clientErrorPayloadBase64); void graphUpdate(int devServerId, const String& visualizerPayloadBase64); + void consoleLog(int devServerId, char kind, const String& data); private: // JSC::JSGlobalObject& m_globalobject; @@ -59,6 +60,7 @@ void BunFrontendDevServerAgent__notifyBundleFailed(InspectorBunFrontendDevServer void BunFrontendDevServerAgent__notifyClientNavigated(InspectorBunFrontendDevServerAgent* agent, int connectionId, const BunString* url, int routeBundleId); void BunFrontendDevServerAgent__notifyClientErrorReported(InspectorBunFrontendDevServerAgent* agent, const BunString* clientErrorPayloadBase64); void BunFrontendDevServerAgent__notifyGraphUpdate(InspectorBunFrontendDevServerAgent* agent, const BunString* visualizerPayloadBase64); +void BunFrontendDevServerAgent__notifyConsoleLog(InspectorBunFrontendDevServerAgent* agent, int devServerId, char kind, const BunString* data); } } // namespace Inspector diff --git a/src/js/internal/debugger.ts b/src/js/internal/debugger.ts index f7c7a7b6af..26a714205f 100644 --- a/src/js/internal/debugger.ts +++ b/src/js/internal/debugger.ts @@ -31,7 +31,7 @@ class SocketFramer { $debug("local:", data); } - socketFramerMessageLengthBuffer.writeUInt32BE(data.length, 0); + socketFramerMessageLengthBuffer.writeUInt32BE(Buffer.byteLength(data), 0); socket.$write(socketFramerMessageLengthBuffer); socket.$write(data); } @@ -483,6 +483,11 @@ async function connectToUnixServer( socket.data.framer.onData(socket, bytes); }, + + // Ensure we always drain the socket. + // This is necessary due to socket.$write usage. + drain: _socket => {}, + close: socket => { if (socket.data) { const { backend, framer } = socket.data; diff --git a/src/js/internal/html.ts b/src/js/internal/html.ts index 80373ae9c4..1af882828a 100644 --- a/src/js/internal/html.ts +++ b/src/js/internal/html.ts @@ -15,7 +15,7 @@ async function start() { const cwd = process.cwd(); let hostname = "localhost"; let port: number | undefined = undefined; - + let enableConsoleLog = false; // Step 1. Resolve all HTML entry points for (let i = 1, argvLength = argv.length; i < argvLength; i++) { const arg = argv[i]; @@ -37,6 +37,10 @@ async function start() { hostname = host; port = parseInt(portString, 10); } + } else if (arg === "--console") { + enableConsoleLog = true; + } else if (arg === "--no-console") { + enableConsoleLog = false; } if (arg === "--help") { @@ -50,7 +54,8 @@ Options: --port= --host=, --hostname= - + --console # print console logs from browser + --no-console # don't print console logs from browser Examples: bun index.html @@ -58,6 +63,7 @@ Examples: bun index.html --host=localhost:3000 bun index.html --hostname=localhost:3000 bun ./*.html + bun index.html --console This is a small wrapper around Bun.serve() that automatically serves the HTML files you pass in without having to manually call Bun.serve() or write the boilerplate yourself. This runs Bun's bundler on @@ -215,7 +221,13 @@ yourself with Bun.serve(). try { server = Bun.serve({ static: staticRoutes, - development: env.NODE_ENV !== "production", + development: + env.NODE_ENV !== "production" + ? { + console: enableConsoleLog, + hmr: undefined, + } + : false, hostname, port, @@ -235,7 +247,13 @@ yourself with Bun.serve(). try { server = Bun.serve({ static: staticRoutes, - development: env.NODE_ENV !== "production", + development: + env.NODE_ENV !== "production" + ? { + console: enableConsoleLog, + hmr: undefined, + } + : false, hostname, diff --git a/test/cli/inspect/BunFrontendDevServer.test.ts b/test/cli/inspect/BunFrontendDevServer.test.ts index f8ac3728d9..768358d584 100644 --- a/test/cli/inspect/BunFrontendDevServer.test.ts +++ b/test/cli/inspect/BunFrontendDevServer.test.ts @@ -475,6 +475,43 @@ describe.if(isPosix)("BunFrontendDevServer inspector protocol", () => { ws.close(); }); + test("should notify on consoleLog events", async () => { + await fetch(serverUrl.href).then(r => r.blob()); + + // Connect a client to trigger connection events + const ws = await createHMRClient(); + + // Wait for clientConnected event to get the connectionId + const connectedEvent = await session.waitForEvent("BunFrontendDevServer.clientConnected"); + + // Listen for consoleLog event + const consoleLogPromise = session.waitForEvent("BunFrontendDevServer.consoleLog"); + + // Send a console log message from the client + // 'l' is the message type for console.log (see ConsoleLogKind enum in DevServer.zig) + ws.send("ll" + "Hello from client test"); + + // Verify we received the consoleLog event + const consoleLogEvent = await consoleLogPromise; + expect(consoleLogEvent).toHaveProperty("kind"); + expect(consoleLogEvent.kind).toBe("l".charCodeAt(0)); + expect(consoleLogEvent).toHaveProperty("message"); + expect(consoleLogEvent.message).toBe("Hello from client test"); + + // Test error log + const consoleErrorPromise = session.waitForEvent("BunFrontendDevServer.consoleLog"); + ws.send("le" + "Error from client test"); + + const consoleErrorEvent = await consoleErrorPromise; + expect(consoleErrorEvent).toHaveProperty("kind"); + expect(consoleErrorEvent.kind).toBe("e".charCodeAt(0)); + expect(consoleErrorEvent).toHaveProperty("message"); + expect(consoleErrorEvent.message).toBe("Error from client test"); + + // Clean up + ws.close(); + }); + test.todo("should notify on clientErrorReported events", async () => { // fs.writeFileSync(join(tempdir, "main.ts"), errorReportingScript); diff --git a/test/cli/inspect/socket-framer.ts b/test/cli/inspect/socket-framer.ts index fea0908cc5..b087f4bfd7 100644 --- a/test/cli/inspect/socket-framer.ts +++ b/test/cli/inspect/socket-framer.ts @@ -31,7 +31,7 @@ export class SocketFramer { } send(socket: Socket, data: string): void { - socketFramerMessageLengthBuffer.writeUInt32BE(data.length, 0); + socketFramerMessageLengthBuffer.writeUInt32BE(Buffer.byteLength(data), 0); socket.write(socketFramerMessageLengthBuffer); socket.write(data); }