diff --git a/src/http/lib.rs b/src/http/lib.rs index 57bfb2fccb51..bb01bea1fb49 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -1436,8 +1436,11 @@ pub(crate) fn print_request( Protocol::Http2 => "HTTP/2", Protocol::Http3 => "HTTP/3", }; + // The templates escape `>`: the pretty formatter drops a bare one as a tag terminator. + let prefix = picohttp::trace_line_prefix(); bun_core::pretty_errorln!( - "> {} {} {}", + "{}\\> {} {} {}", + prefix, ver, BStr::new(request.method), bun_core::fmt::redacted_npm_url(url), @@ -1450,12 +1453,13 @@ pub(crate) fn print_request( let value = header.value(); let scheme_len = strings::index_of_char_usize(value, b' ').map_or(0, |i| i + 1); bun_core::pretty_errorln!( - "> {}: {}[redacted]", + "{}\\> {}: {}[redacted]", + prefix, BStr::new(name), BStr::new(&value[..scheme_len]), ); } else { - bun_core::pretty_errorln!("> {}", header); + bun_core::pretty_errorln!("{}\\> {}", prefix, header); } } Output::flush(); diff --git a/src/picohttp/lib.rs b/src/picohttp/lib.rs index ac1689919ff5..c3fea63cff6f 100644 --- a/src/picohttp/lib.rs +++ b/src/picohttp/lib.rs @@ -155,6 +155,17 @@ impl Header { } } +/// Start of every line of the verbose request/response trace (`[fetch] > ...`, +/// `[fetch] < ...`). The tag is only shown when stderr has colors; plain output +/// starts at the `>` / `<`. +pub fn trace_line_prefix() -> &'static str { + if enable_ansi_colors_stderr() { + pretty_fmt!("[fetch] ", true) + } else { + "" + } +} + impl fmt::Display for Header { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { // NOTE: pretty_fmt! is the compile-time ANSI-tag expander (`` → escape @@ -298,28 +309,6 @@ impl<'a> Request<'a> { } } -impl fmt::Display for Request<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if enable_ansi_colors_stderr() { - f.write_str(pretty_fmt!("[fetch] ", true))?; - } - writeln!( - f, - "> HTTP/1.1 {} {}", - BStr::new(self.method), - BStr::new(self.path) - )?; - for header in self.headers { - if enable_ansi_colors_stderr() { - f.write_str(pretty_fmt!("[fetch] ", true))?; - } - f.write_str("> ")?; - writeln!(f, "{}", header)?; - } - Ok(()) - } -} - pub struct RequestCurlFormatter<'a> { request: &'a Request<'a>, ignore_insecure: bool, @@ -556,10 +545,7 @@ impl<'a> Response<'a> { impl fmt::Display for Response<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if enable_ansi_colors_stderr() { - f.write_str(pretty_fmt!("[fetch] ", true))?; - } - + f.write_str(trace_line_prefix())?; writeln!( f, "< {} {}", @@ -569,10 +555,7 @@ impl fmt::Display for Response<'_> { BStr::new(self.status), )?; for header in self.headers.list { - if enable_ansi_colors_stderr() { - f.write_str(pretty_fmt!("[fetch] ", true))?; - } - + f.write_str(trace_line_prefix())?; f.write_str("< ")?; writeln!(f, "{}", header)?; } diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index 20a0e898e0d8..8d6dd7ef68ac 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -3592,10 +3592,88 @@ it("verbose fetch logging prints [redacted] in place of Authorization credential expect(stdout).toBe("Bearer sekret-token\n"); const authorizationLines = stderr.split(/\r?\n/).flatMap(line => { - const match = /^(?:\[fetch\])?\s*>?\s*authorization:(.*)$/i.exec(line); + const match = /^> authorization:(.*)$/i.exec(line); return match ? [match[1].trim()] : []; }); expect(authorizationLines).toEqual(["Bearer [redacted]"]); expect(stderr).not.toContain("sekret-token"); expect(exitCode).toBe(0); }); + +describe.concurrent("verbose fetch logging line prefixes", () => { + // Every line of the trace starts with "> " (request) or "< " (response). On a + // color terminal each line is additionally tagged with a dim "[fetch] ". + const fetchTag = "\x1b[0m\x1b[2m[fetch]\x1b[0m "; + + async function traceLines(env: Record) { + using server = Bun.serve({ + port: 0, + fetch() { + return new Response("ok", { headers: { "x-response-header": "yes" } }); + }, + }); + + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const init = { headers: { "Authorization": "Bearer sekret-token", "x-request-header": "yes" } }; + if (process.env.FETCH_VERBOSE) init.verbose = true; + const res = await fetch(process.env.SERVER_URL, init); + console.log(await res.text());`, + ], + env: { ...bunEnv, SERVER_URL: server.url.href, ...env }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe("ok\n"); + expect(exitCode).toBe(0); + return { url: server.url.href, lines: stderr.split(/\r?\n/).filter(line => line.length > 0) }; + } + + it("verbose: true prints > request lines and < response lines", async () => { + const { url, lines } = await traceLines({ FETCH_VERBOSE: "1" }); + + expect(lines.filter(line => !/^[<>] /.test(line))).toEqual([]); + expect(lines[0]).toBe(`> HTTP/1.1 GET ${url}`); + expect(lines).toContain("> Authorization: Bearer [redacted]"); + expect(lines).toContain("> x-request-header: yes"); + expect(lines).toContain("< 200 OK"); + expect(lines).toContain("< x-response-header: yes"); + }); + + it("BUN_CONFIG_VERBOSE_FETCH=1 prints > request lines and < response lines", async () => { + const { url, lines } = await traceLines({ BUN_CONFIG_VERBOSE_FETCH: "1" }); + + expect(lines.filter(line => !/^[<>] /.test(line))).toEqual([]); + expect(lines[0]).toBe(`> HTTP/1.1 GET ${url}`); + expect(lines).toContain("> x-request-header: yes"); + expect(lines).toContain("< 200 OK"); + }); + + it("BUN_CONFIG_VERBOSE_FETCH=curl prints the curl line, then > request lines", async () => { + const { url, lines } = await traceLines({ BUN_CONFIG_VERBOSE_FETCH: "curl" }); + + expect(lines[0]).toStartWith(`curl --http1.1 "${url}"`); + expect(lines.slice(1).filter(line => !/^[<>] /.test(line))).toEqual([]); + expect(lines[1]).toBe(`> HTTP/1.1 GET ${url}`); + expect(lines).toContain("> x-request-header: yes"); + expect(lines).toContain("< 200 OK"); + }); + + it("with colors, request lines get the same [fetch] tag as response lines", async () => { + const { url, lines } = await traceLines({ FETCH_VERBOSE: "1", NO_COLOR: undefined, FORCE_COLOR: "1" }); + + expect(lines.filter(line => !line.startsWith(fetchTag))).toEqual([]); + expect(lines[0]).toBe(`${fetchTag}> HTTP/1.1 GET ${url}`); + + const plain = lines.map(line => Bun.stripANSI(line)); + expect(plain.filter(line => !/^\[fetch\] [<>] /.test(line))).toEqual([]); + expect(plain).toContain("[fetch] > Authorization: Bearer [redacted]"); + expect(plain).toContain("[fetch] > x-request-header: yes"); + expect(plain).toContain("[fetch] < 200 OK"); + expect(plain).toContain("[fetch] < x-response-header: yes"); + }); +});