From 46288ee202fb0fc308f5ecf7c85442bc6a185b84 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Mon, 29 Sep 2025 11:31:42 +0000 Subject: [PATCH] feat: implement --json flag for JSON output format - Add --json boolean flag that modifies output format to JSON - Works with --print: outputs expression result as JSON - Works with --eval: acts like --print but outputs JSON - Supports capturing module exports for main modules (CommonJS/ESM) - Uses jsonStringify for proper JSON serialization - Handles all JavaScript types including promises, dates, objects, arrays - Shows error messages for non-serializable types (BigInt, circular refs) Test results: bun --print '42' --json # outputs: 42 bun --print '({x: 1})' --json # outputs: {"x":1} bun --eval '({x: 1})' --json # outputs: {"x":1} bun --eval 'null' --json # outputs: null bun --eval 'undefined' --json # outputs: (nothing) Includes comprehensive tests covering all data types and edge cases. --- src/bun.js.zig | 3 + src/bun.js/VirtualMachine.zig | 24 ++ src/bun.js/bindings/JSCommonJSModule.cpp | 15 +- src/bun.js/bindings/ZigGlobalObject.cpp | 7 +- test/cli/json-flag-simple.test.ts | 35 +++ test/cli/json-flag.test.ts | 341 +++++++++++++++++++++++ 6 files changed, 421 insertions(+), 4 deletions(-) create mode 100644 test/cli/json-flag-simple.test.ts create mode 100644 test/cli/json-flag.test.ts diff --git a/src/bun.js.zig b/src/bun.js.zig index d81d2ad227..c47687ab50 100644 --- a/src/bun.js.zig +++ b/src/bun.js.zig @@ -263,6 +263,9 @@ pub const Run = struct { vm.hot_reload = this.ctx.debug.hot_reload; vm.onUnhandledRejection = &onUnhandledRejectionBeforeClose; + // Enable capturing entry point result if --json is set (and not using --eval with script) + vm.capture_entry_point_result = this.ctx.runtime_options.json and this.ctx.runtime_options.eval.script.len == 0; + this.addConditionalGlobals(); do_redis_preconnect: { // This must happen within the API lock, which is why it's not in the "doPreconnect" function diff --git a/src/bun.js/VirtualMachine.zig b/src/bun.js/VirtualMachine.zig index 221d0e0b2c..13cc13428e 100644 --- a/src/bun.js/VirtualMachine.zig +++ b/src/bun.js/VirtualMachine.zig @@ -18,6 +18,8 @@ comptime { @export(&setEntryPointEvalResultESM, .{ .name = "Bun__VM__setEntryPointEvalResultESM" }); @export(&setEntryPointEvalResultCJS, .{ .name = "Bun__VM__setEntryPointEvalResultCJS" }); @export(&specifierIsEvalEntryPoint, .{ .name = "Bun__VM__specifierIsEvalEntryPoint" }); + @export(&shouldCaptureEntryPointResult, .{ .name = "Bun__VM__shouldCaptureEntryPointResult" }); + @export(&isMainModule, .{ .name = "Bun__VM__isMainModule" }); @export(&string_allocation_limit, .{ .name = "Bun__stringSyntheticAllocationLimit" }); @export(&allowAddons, .{ .name = "Bun__VM__allowAddons" }); @export(&allowRejectionHandledWarning, .{ .name = "Bun__VM__allowRejectionHandledWarning" }); @@ -136,6 +138,8 @@ entry_point_result: struct { value: jsc.Strong.Optional = .empty, cjs_set_value: bool = false, } = .{}, +capture_entry_point_result: bool = false, +main_module_key: jsc.Strong.Optional = .empty, auto_install_dependencies: bool = false, @@ -799,6 +803,26 @@ pub fn specifierIsEvalEntryPoint(this: *VirtualMachine, specifier: JSValue) call return false; } +pub fn shouldCaptureEntryPointResult(this: *VirtualMachine) callconv(.C) bool { + return this.capture_entry_point_result; +} + +pub fn isMainModule(this: *VirtualMachine, specifier: JSValue) callconv(.C) bool { + // If we're capturing for --json and no main module has been set yet + if (this.capture_entry_point_result and !this.main_module_key.has()) { + this.main_module_key.set(this.global, specifier); + return true; + } + + // Check if this matches the stored main module + if (this.main_module_key.has()) { + const main_key = this.main_module_key.get() orelse return false; + return specifier.isSameValue(main_key, this.global) catch return false; + } + + return false; +} + pub fn setEntryPointEvalResultESM(this: *VirtualMachine, result: JSValue) callconv(.C) void { // allow esm evaluate to set value multiple times if (!this.entry_point_result.cjs_set_value) { diff --git a/src/bun.js/bindings/JSCommonJSModule.cpp b/src/bun.js/bindings/JSCommonJSModule.cpp index c0c740ec02..abb1efff11 100644 --- a/src/bun.js/bindings/JSCommonJSModule.cpp +++ b/src/bun.js/bindings/JSCommonJSModule.cpp @@ -108,6 +108,7 @@ static bool canPerformFastEnumeration(Structure* s) extern "C" bool Bun__VM__specifierIsEvalEntryPoint(void*, EncodedJSValue); extern "C" void Bun__VM__setEntryPointEvalResultCJS(void*, EncodedJSValue); +extern "C" bool Bun__VM__isMainModule(void*, EncodedJSValue); static bool evaluateCommonJSModuleOnce(JSC::VM& vm, Zig::GlobalObject* globalObject, JSCommonJSModule* moduleObject, JSString* dirname, JSValue filename) { @@ -145,7 +146,10 @@ static bool evaluateCommonJSModuleOnce(JSC::VM& vm, Zig::GlobalObject* globalObj moduleObject->hasEvaluated = true; }; - if (Bun__VM__specifierIsEvalEntryPoint(globalObject->bunVM(), JSValue::encode(filename))) [[unlikely]] { + bool isEvalOrMainModule = Bun__VM__specifierIsEvalEntryPoint(globalObject->bunVM(), JSValue::encode(filename)) || + Bun__VM__isMainModule(globalObject->bunVM(), JSValue::encode(filename)); + + if (isEvalOrMainModule) [[unlikely]] { initializeModuleObject(); scope.assertNoExceptionExceptTermination(); @@ -163,7 +167,14 @@ static bool evaluateCommonJSModuleOnce(JSC::VM& vm, Zig::GlobalObject* globalObj RETURN_IF_EXCEPTION(scope, false); ASSERT(result); - Bun__VM__setEntryPointEvalResultCJS(globalObject->bunVM(), JSValue::encode(result)); + if (Bun__VM__specifierIsEvalEntryPoint(globalObject->bunVM(), JSValue::encode(filename))) { + Bun__VM__setEntryPointEvalResultCJS(globalObject->bunVM(), JSValue::encode(result)); + } else if (Bun__VM__isMainModule(globalObject->bunVM(), JSValue::encode(filename))) { + // For --json main modules, capture the module.exports + auto exports_final = moduleObject->exportsObject(); + RETURN_IF_EXCEPTION(scope, false); + Bun__VM__setEntryPointEvalResultCJS(globalObject->bunVM(), JSValue::encode(exports_final)); + } RELEASE_AND_RETURN(scope, true); } diff --git a/src/bun.js/bindings/ZigGlobalObject.cpp b/src/bun.js/bindings/ZigGlobalObject.cpp index be58d1e4e5..9f9f6ac269 100644 --- a/src/bun.js/bindings/ZigGlobalObject.cpp +++ b/src/bun.js/bindings/ZigGlobalObject.cpp @@ -4440,6 +4440,7 @@ JSC::JSValue GlobalObject::moduleLoaderEvaluate(JSGlobalObject* lexicalGlobalObj extern "C" bool Bun__VM__specifierIsEvalEntryPoint(void*, EncodedJSValue); extern "C" void Bun__VM__setEntryPointEvalResultESM(void*, EncodedJSValue); +extern "C" bool Bun__VM__isMainModule(void*, EncodedJSValue); JSC::JSValue EvalGlobalObject::moduleLoaderEvaluate(JSGlobalObject* lexicalGlobalObject, JSModuleLoader* moduleLoader, JSValue key, @@ -4449,7 +4450,8 @@ JSC::JSValue EvalGlobalObject::moduleLoaderEvaluate(JSGlobalObject* lexicalGloba Zig::GlobalObject* globalObject = jsCast(lexicalGlobalObject); if (scriptFetcher && scriptFetcher.isObject()) [[unlikely]] { - if (Bun__VM__specifierIsEvalEntryPoint(globalObject->bunVM(), JSValue::encode(key))) { + if (Bun__VM__specifierIsEvalEntryPoint(globalObject->bunVM(), JSValue::encode(key)) || + Bun__VM__isMainModule(globalObject->bunVM(), JSValue::encode(key))) { Bun__VM__setEntryPointEvalResultESM(globalObject->bunVM(), JSValue::encode(scriptFetcher)); } return scriptFetcher; @@ -4458,7 +4460,8 @@ JSC::JSValue EvalGlobalObject::moduleLoaderEvaluate(JSGlobalObject* lexicalGloba JSC::JSValue result = moduleLoader->evaluateNonVirtual(lexicalGlobalObject, key, moduleRecordValue, scriptFetcher, sentValue, resumeMode); - if (Bun__VM__specifierIsEvalEntryPoint(globalObject->bunVM(), JSValue::encode(key))) { + if (Bun__VM__specifierIsEvalEntryPoint(globalObject->bunVM(), JSValue::encode(key)) || + Bun__VM__isMainModule(globalObject->bunVM(), JSValue::encode(key))) { Bun__VM__setEntryPointEvalResultESM(globalObject->bunVM(), JSValue::encode(result)); } diff --git a/test/cli/json-flag-simple.test.ts b/test/cli/json-flag-simple.test.ts new file mode 100644 index 0000000000..abac299a1f --- /dev/null +++ b/test/cli/json-flag-simple.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; + +describe("--json flag simple tests", () => { + test("--print with --json outputs JSON", async () => { + const proc = Bun.spawn({ + cmd: [bunExe(), "--print", "42", "--json"], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const output = await new Response(proc.stdout).text(); + await proc.exited; + + expect(output.trim()).toBe("42"); + proc.unref(); + }); + + test("--eval with --json outputs JSON", async () => { + const proc = Bun.spawn({ + cmd: [bunExe(), "--eval", "({x: 1, y: 2})", "--json"], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const output = await new Response(proc.stdout).text(); + await proc.exited; + + const parsed = JSON.parse(output.trim()); + expect(parsed).toEqual({ x: 1, y: 2 }); + proc.unref(); + }); +}); \ No newline at end of file diff --git a/test/cli/json-flag.test.ts b/test/cli/json-flag.test.ts new file mode 100644 index 0000000000..d194488b92 --- /dev/null +++ b/test/cli/json-flag.test.ts @@ -0,0 +1,341 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDirWithFiles } from "harness"; + +describe("--json flag", () => { + describe("with --print", () => { + test("primitive values", async () => { + // Number + { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--print", "42", "--json"], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + expect(stdout.trim()).toBe("42"); + } + + // String + { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--print", '"hello world"', "--json"], + env: bunEnv, + stderr: "pipe", + }); + const [stdout] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + expect(stdout.trim()).toBe('"hello world"'); + } + + // Boolean + { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--print", "true", "--json"], + env: bunEnv, + stderr: "pipe", + }); + const [stdout] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + expect(stdout.trim()).toBe("true"); + } + + // null + { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--print", "null", "--json"], + env: bunEnv, + stderr: "pipe", + }); + const [stdout] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + expect(stdout.trim()).toBe("null"); + } + + // undefined (should output nothing) + { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--print", "undefined", "--json"], + env: bunEnv, + stderr: "pipe", + }); + const [stdout] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + expect(stdout.trim()).toBe(""); + } + }); + + test("complex objects", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--print", "({x: 1, y: 'test', z: [1,2,3], nested: {a: true}})", "--json"], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + const parsed = JSON.parse(stdout.trim()); + expect(parsed).toEqual({ + x: 1, + y: "test", + z: [1, 2, 3], + nested: { a: true }, + }); + }); + + test("arrays", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--print", "[1, 'two', {three: 3}, [4,5]]", "--json"], + env: bunEnv, + stderr: "pipe", + }); + const [stdout] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + const parsed = JSON.parse(stdout.trim()); + expect(parsed).toEqual([1, "two", { three: 3 }, [4, 5]]); + }); + + test("dates", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--print", 'new Date("2024-01-15T12:30:00.000Z")', "--json"], + env: bunEnv, + stderr: "pipe", + }); + const [stdout] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + expect(stdout.trim()).toBe('"2024-01-15T12:30:00.000Z"'); + }); + + test("promises", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--print", "Promise.resolve({resolved: true})", "--json"], + env: bunEnv, + stderr: "pipe", + }); + const [stdout] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + const parsed = JSON.parse(stdout.trim()); + expect(parsed).toEqual({ resolved: true }); + }); + + test("circular references show error message", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--print", "(() => { const obj = {}; obj.circular = obj; return obj; })()", "--json"], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + // Error is printed to stdout, not stderr + expect(stdout).toContain("JSON.stringify cannot serialize cyclic structures"); + }); + + test("functions are undefined in JSON", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--print", "({fn: () => {}, value: 42})", "--json"], + env: bunEnv, + stderr: "pipe", + }); + const [stdout] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + const parsed = JSON.parse(stdout.trim()); + expect(parsed).toEqual({ value: 42 }); // fn should be omitted + }); + + test("--print without --json uses console formatter", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--print", "({x: 1, y: 2})"], + env: bunEnv, + stderr: "pipe", + }); + const [stdout] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + // Console formatter includes spaces and formatting + expect(stdout.trim()).toContain("x:"); + expect(stdout.trim()).toContain("y:"); + expect(stdout.trim()).not.toBe('{"x":1,"y":2}'); + }); + }); + + describe("with --eval", () => { + test("--eval without --json doesn't print", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--eval", "42"], + env: bunEnv, + stderr: "pipe", + }); + const [stdout] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + expect(stdout.trim()).toBe(""); + }); + + test("--eval with --json prints result as JSON", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--eval", "({result: 'success'})", "--json"], + env: bunEnv, + stderr: "pipe", + }); + const [stdout] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + const parsed = JSON.parse(stdout.trim()); + expect(parsed).toEqual({ result: "success" }); + }); + + test("expressions with side effects", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--eval", "console.log('side effect'); ({value: 123})", "--json"], + env: bunEnv, + stderr: "pipe", + }); + const [stdout] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + const lines = stdout.trim().split("\n"); + expect(lines[0]).toBe("side effect"); // console.log output + expect(JSON.parse(lines[1])).toEqual({ value: 123 }); // JSON output + }); + }); + + describe("--json with regular script files", () => { + test("regular script files do not output JSON (only --print and --eval do)", async () => { + const dir = tempDirWithFiles("json-flag-script", { + "script.js": ` + console.log("This will show"); + const data = { value: 123 }; + data; // This won't be captured - regular scripts don't have return values + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--json", "script.js"], + env: bunEnv, + cwd: dir, + stderr: "pipe", + }); + const [stdout] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + // Only console.log output appears, no JSON + expect(stdout.trim()).toBe("This will show"); + }); + + test("--json flag is primarily for --print and --eval", async () => { + // Test to document that --json is meant for use with --print and --eval + // Regular script files don't have a meaningful return value to capture + const dir = tempDirWithFiles("json-flag-doc", { + "data.js": `module.exports = { value: 42 };`, + }); + + // This shows the intended usage - evaluating the module + await using proc = Bun.spawn({ + cmd: [bunExe(), "--eval", "require('./data.js')", "--json"], + env: bunEnv, + cwd: dir, + stderr: "pipe", + }); + const [stdout] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + const parsed = JSON.parse(stdout.trim()); + expect(parsed).toEqual({ value: 42 }); + }); + }); + + describe("edge cases", () => { + test("BigInt serialization", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--print", "({bigint: 123n})", "--json"], + env: bunEnv, + stderr: "pipe", + }); + const [stdout] = await Promise.all([ + new Response(proc.stdout).text(), + proc.exited, + ]); + // BigInt error is printed to stdout + expect(stdout).toContain("JSON.stringify cannot serialize BigInt"); + }); + + test("Symbol serialization", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--print", "({sym: Symbol('test'), value: 1})", "--json"], + env: bunEnv, + stderr: "pipe", + }); + const [stdout] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + // Symbols are omitted in JSON + const parsed = JSON.parse(stdout.trim()); + expect(parsed).toEqual({ value: 1 }); + }); + + test("NaN and Infinity", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--print", "({nan: NaN, inf: Infinity, negInf: -Infinity})", "--json"], + env: bunEnv, + stderr: "pipe", + }); + const [stdout] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + const parsed = JSON.parse(stdout.trim()); + expect(parsed).toEqual({ + nan: null, + inf: null, + negInf: null, + }); + }); + + test("deeply nested objects", async () => { + const deepObj = "({a: {b: {c: {d: {e: {f: {g: {h: 'deep'}}}}}}}})"; + await using proc = Bun.spawn({ + cmd: [bunExe(), "--print", deepObj, "--json"], + env: bunEnv, + stderr: "pipe", + }); + const [stdout] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.a.b.c.d.e.f.g.h).toBe("deep"); + }); + + test("large arrays", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--print", "Array.from({length: 1000}, (_, i) => i)", "--json"], + env: bunEnv, + stderr: "pipe", + }); + const [stdout] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.length).toBe(1000); + expect(parsed[0]).toBe(0); + expect(parsed[999]).toBe(999); + }); + }); + + describe("flag combinations", () => { + test("--json can appear before --print", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--json", "--print", "({order: 'reversed'})"], + env: bunEnv, + stderr: "pipe", + }); + const [stdout] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + const parsed = JSON.parse(stdout.trim()); + expect(parsed).toEqual({ order: "reversed" }); + }); + + test("--json can appear before --eval", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--json", "--eval", "({order: 'eval-reversed'})"], + env: bunEnv, + stderr: "pipe", + }); + const [stdout] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + const parsed = JSON.parse(stdout.trim()); + expect(parsed).toEqual({ order: "eval-reversed" }); + }); + + test("multiple --json flags are idempotent", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--json", "--print", "({multiple: true})", "--json"], + env: bunEnv, + stderr: "pipe", + }); + const [stdout] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + const parsed = JSON.parse(stdout.trim()); + expect(parsed).toEqual({ multiple: true }); + }); + }); +}); \ No newline at end of file