Skip to content

feat: implement JSON export functionality#2228

Closed
khs-alt wants to merge 3 commits intoprojectdiscovery:devfrom
khs-alt:json-output
Closed

feat: implement JSON export functionality#2228
khs-alt wants to merge 3 commits intoprojectdiscovery:devfrom
khs-alt:json-output

Conversation

@khs-alt
Copy link
Contributor

@khs-alt khs-alt commented Aug 4, 2025

This PR addresses issue #2218

  • Add JSON export option with -je flag

go run cmd/httpx/httpx.go -h

OUTPUT:
...
   -fepp, -filter-error-page-path string  path to store filtered error pages (default "filtered_error_page.json")
   -je, -json-export string               file to export results in JSON format

   -pdu, -dashboard-upload string  upload httpx output file (jsonl) in projectdiscovery cloud (pdcp) UI dashboard
...

go run cmd/httpx/httpx.go -u google.com -je test.json


    __    __  __       _  __
   / /_  / /_/ /_____ | |/ /
  / __ \/ __/ __/ __ \|   /
 / / / / /_/ /_/ /_/ /   |
/_/ /_/\__/\__/ .___/_/|_|
             /_/

                projectdiscovery.io

[INF] Current httpx version v1.7.1 (latest)
[WRN] UI Dashboard is disabled, Use -dashboard option to enable
https://google.com
 % cat test.json 
[{"timestamp":"2025-08-04T18:24:14.956147+09:00","cdn_name":"google","cdn_type":"cdn","port":"443","url":"https://google.com","input":"google.com","location":"https://www.google.com/","title":"301 Moved","scheme":"https","webserver":"gws","content_type":"text/html","method":"GET","host":"142.250.76.142","path":"/","time":"247.113875ms","a":["142.250.76.142"],"aaaa":["2404:6800:400a:805::200e"],"words":9,"lines":6,"status_code":301,"content_length":220,"failed":false,"cdn":true,"knowledgebase":{"PageType":"other","pHash":0},"resolvers":["1.1.1.1:53","1.0.0.1:53"]}]%  

Summary by CodeRabbit

  • New Features
    • Export enumeration results to a JSON file.
    • New CLI option --json-export (-je) to specify the JSON output filename.
    • Results are collected during a run and written to the specified JSON file upon completion; export errors are logged.

- Add JSON export option with -je flag
- Use json.Marshal for safe JSON generation
- Store results in memory and write at completion
- Maintain code consistency with other output formats
@coderabbitai
Copy link

coderabbitai bot commented Aug 4, 2025

Walkthrough

Adds a new CLI option JSONExport (--json-export, -je) and a JSON exporter implementation. When set, RunEnumeration creates a JSONExporter, calls Export per result, and Close writes accumulated results to the configured file on completion; errors are logged. No other control-flow or validation changes.

Changes

Cohort / File(s) Change Summary
Options struct & CLI flag
runner/options.go
Added JSONExport string to Options and registered --json-export (-je) CLI flag.
JSON exporter implementation
runner/jsonexporter.go
New JSONExporter and JSONExportOptions types, NewJSONExporter, Export, and Close methods; mutex-protected in-memory accumulation and JSON file write on Close.
Runner integration
runner/runner.go
When options.JSONExport is non-empty, lazily create JSONExporter, defer Close, call Export(&resp) per result, and log exporter errors.
Documentation
README.md
Documented new CLI flag -je, --json-export in OUTPUT section.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant CLI
    participant Runner
    participant JSONExporter
    participant FS

    User->>CLI: run with --json-export <file>
    CLI->>Runner: Options{JSONExport: <file>}
    Runner->>Runner: start enumeration
    alt JSONExport set
        Runner->>JSONExporter: NewJSONExporter(File: <file>)
        JSONExporter-->>Runner: exporter instance
        loop per result
            Runner->>JSONExporter: Export(result)
            JSONExporter-->>Runner: ack / error
        end
        Runner->>JSONExporter: Close()
        JSONExporter->>FS: write JSON array to <file>
        FS-->>JSONExporter: write result / error
    end
    Runner-->>User: finish
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

I hopped along the CLI trail, 🐇
Collected results in tiny scale,
Each nibble tucked in memory tight,
At dusk I wrote them out by light,
A JSON feast to end the night.

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
runner/options.go (1)

478-480: Nit: keep short-flag conventions consistent

Most short options are 1–2 characters (-j, -rr, -svrc). Using -je for Json Export is fine but slightly asymmetric with existing single-letter -j. Double-letter shorthands increase the chance of clashes later.

If backward-compatibility permits, consider making -J the short form:

-flagSet.StringVarP(&options.JSONExport, "json-export", "je", "", "file to export results in JSON format")
+flagSet.StringVarP(&options.JSONExport, "json-export", "J", "", "file to export results in JSON format")

Purely stylistic, feel free to skip if the project already embraces multi-letter shorthands elsewhere.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between faac44c and a748dc5.

📒 Files selected for processing (2)
  • runner/options.go (2 hunks)
  • runner/runner.go (2 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (1)
runner/runner.go (1)
runner/types.go (1)
  • Result (35-103)
🔇 Additional comments (1)
runner/runner.go (1)

783-784: LGTM!

The variable declarations for JSON export functionality are properly placed and typed correctly.

Comment on lines 223 to 226
StoreResponse bool
JSONOutput bool
JSONExport string
CSVOutput bool
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add validation & conflict checks for the new JSONExport field

The option is parsed and stored but never validated.
Consider extending ValidateOptions() to:

  1. Fail fast when the provided path is not writable / its parent directory does not exist.
  2. Warn or error when -json-export is combined with streaming JSONL output (-json) – the two formats differ and may confuse users / downstream tooling.
  3. Guard against passing a directory path instead of a file.

Example patch:

@@ func (options *Options) ValidateOptions() error {
+	// sanity-check json-export
+	if options.JSONExport != "" {
+		// reject if used together with json lines
+		if options.JSONOutput {
+			return fmt.Errorf("flags -json and -json-export are mutually exclusive")
+		}
+		dir := filepath.Dir(options.JSONExport)
+		if dir != "." && !fileutil.DirExists(dir) {
+			return fmt.Errorf("directory for json-export (%s) does not exist", dir)
+		}
+		if strings.HasSuffix(options.JSONExport, string(os.PathSeparator)) {
+			return fmt.Errorf("json-export expects a file path, got directory '%s'", options.JSONExport)
+		}
+	}

This prevents late-runtime errors and clarifies expected usage.

🤖 Prompt for AI Agents
In runner/options.go around lines 223 to 226, the new JSONExport field lacks
validation and conflict checks. Extend the ValidateOptions() function to verify
that the JSONExport path's parent directory exists and is writable, return an
error if not. Add a check to prevent using JSONExport together with JSONOutput,
returning an error or warning about the conflicting output formats. Also, ensure
the JSONExport path is not a directory by checking its file info and returning
an error if it is. These validations will prevent runtime errors and clarify
correct usage.

Copy link
Member

@dogancanbakir dogancanbakir left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@khs-alt Thanks for the PR! Let me know what you think!

runner/runner.go Outdated
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can pass r.options.JSONExport instead of introducing new var.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the feedback! I'll update it.

runner/runner.go Outdated
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think of copying this and adding batch support to be nice on memory and make it easier/flexible to play with the implementation details later on. Since the relatively large data could cause memory issues, and we will make sure most of the data is written to the file.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your suggestion, it looks correct!
I have two questions:

  1. httpx repo doesn't have a pkg folder, so should I make the same as nuclei pkg/reporting/exporters/jsonexporter/jsonexporter.go and add to use this code, or any path to put jsonexporter.go code?
  2. Current jsonexporter is a little different to use httpx, should it be okay to change jsonexporter.go code for using httpx, or change httpx to use jsonexporter.go code format

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

re 1: let's not go with structural change for now, we can just introduce a new go file
re 2: we can adapt it for the httpx

@khs-alt
Copy link
Contributor Author

khs-alt commented Aug 20, 2025

Hi! @dogancanbakir I'm changing the code and the readme file.

And also, I'm trying to check four tags -irh irr irrb include-chain which is written -json only also works with using our -je tag

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (6)
runner/jsonexporter.go (2)

32-40: Misleading comment: no batching implemented.

The comment says “writes batch when full” but the code only appends to an in-memory slice. Update the comment to match behavior.

-// Export adds result and writes batch when full
+// Export adds the result to the in-memory buffer
 func (exporter *JSONExporter) Export(result *Result) error {

11-15: Use value Mutex instead of pointer.

sync.Mutex should be embedded by value to avoid nil-pointer risks and reduce allocations. It’s safe and conventional.

 type JSONExporter struct {
 	options *JSONExportOptions
-	mutex   *sync.Mutex
+	mutex   sync.Mutex
 	...
 }

Then update locks: exporter.mutex.Lock() stays the same.

runner/runner.go (4)

875-875: Initialize JSON exporter once before the loop; always produce a file (even with zero results).

Currently, the exporter is lazily created inside the loop. If there are no results, the JSON file is never created. Initialize it once before ranging over output and register a single deferred Close.

Apply this diff right before the for resp := range output {:

+		// Initialize JSON exporter (if requested) before processing any results
+		if r.options.JSONExport != "" && jsonExporter == nil {
+			exportOptions := &JSONExportOptions{
+				File: r.options.JSONExport,
+			}
+			var err error
+			jsonExporter, err = NewJSONExporter(exportOptions)
+			if err != nil {
+				gologger.Error().Msgf("Failed to create JSON exporter: %s", err)
+			} else {
+				defer func() {
+					if closeErr := jsonExporter.Close(); closeErr != nil {
+						gologger.Error().Msgf("Failed to close JSON exporter: %s", closeErr)
+					}
+				}()
+			}
+		}

1176-1193: Remove per-iteration initialization and defer; keep just the Export call.

With the exporter created once before the loop, this block becomes unnecessary. It also avoids registering a defer inside the processing loop.

-			if r.options.JSONExport != "" {
-				if jsonExporter == nil {
-					exportOptions := &JSONExportOptions{
-						File: r.options.JSONExport,
-					}
-
-					var err error
-					jsonExporter, err = NewJSONExporter(exportOptions)
-					if err != nil {
-						gologger.Error().Msgf("Failed to create JSON exporter: %s", err)
-					} else {
-						defer func() {
-							if closeErr := jsonExporter.Close(); closeErr != nil {
-								gologger.Error().Msgf("Failed to close JSON exporter: %s", closeErr)
-							}
-						}()
-					}
-				}
-
-				if jsonExporter != nil {
-					if err := jsonExporter.Export(&resp); err != nil {
-						gologger.Error().Msgf("Failed to export JSON result: %s", err)
-					}
-				}
-			}
+			if jsonExporter != nil {
+				if err := jsonExporter.Export(&resp); err != nil {
+					gologger.Error().Msgf("Failed to export JSON result: %s", err)
+				}
+			}

1195-1199: Trim large payloads in JSON export to match JSONOutput behavior.

JSONOutput uses Result.JSON(&scanopts) which trims ResponseBody to MaxResponseBodySizeToSave. JSONExport currently exports the full struct, which can bloat memory and file size (especially with large bodies or screenshots). Mirror the trimming logic before export.

-				if jsonExporter != nil {
-					if err := jsonExporter.Export(&resp); err != nil {
-						gologger.Error().Msgf("Failed to export JSON result: %s", err)
-					}
-				}
+				if jsonExporter != nil {
+					exportResp := resp
+					if r.scanopts.MaxResponseBodySizeToSave > 0 && len(exportResp.ResponseBody) > r.scanopts.MaxResponseBodySizeToSave {
+						exportResp.ResponseBody = exportResp.ResponseBody[:r.scanopts.MaxResponseBodySizeToSave]
+					}
+					// Respect NoScreenshotBytes if set (already applied earlier for stored responses), keep as-is otherwise.
+					if err := jsonExporter.Export(&exportResp); err != nil {
+						gologger.Error().Msgf("Failed to export JSON result: %s", err)
+					}
+				}

1268-1333: Note: graceful shutdown vs. finalized JSON.

Exporter Close is deferred within the output routine and will execute on normal completion. If the process is terminated abruptly (SIGKILL) or crashes, the JSON array may be left unterminated. If this is a concern, consider signal handling to trigger a graceful shutdown path that closes the exporter.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 04362bd and 78e184a.

📒 Files selected for processing (3)
  • README.md (1 hunks)
  • runner/jsonexporter.go (1 hunks)
  • runner/runner.go (2 hunks)
✅ Files skipped from review due to trivial changes (1)
  • README.md
🔇 Additional comments (3)
runner/runner.go (3)

783-785: LGTM: clean integration point and isolation.

Declaring a dedicated jsonExporter scoped to the output routine keeps concerns separated and avoids cross-goroutine synchronization.


1121-1128: Good: honoring NoScreenshotBytes to avoid embedding large binary blobs.

Zeroing ScreenshotBytes when NoScreenshotBytes is set helps keep JSON and CSV outputs smaller. This will also benefit JSON export.

Also applies to: 1114-1119


1024-1057: JSON-only flags apply to both –j and –je

I’ve verified that the include-response-header (-irh), include-response (-irr), include-response-base64 (-irrb) and include-chain (--include-chain) flags are bound unconditionally to options.ResponseHeadersInStdout, options.ResponseInStdout, options.Base64ResponseInStdout and options.ChainInStdout in runner/options.go, and those in turn set scanopts.* fields in runner/runner.go regardless of whether -json or -json-export is used. The JSON exporter (NewJSONExporterExport(&resp)) simply serializes the same resp struct populated based on those scanopts flags. No additional wiring is needed—these flags already affect the JSON-export file.

Comment on lines +21 to +30
// NewJSONExporter creates a new JSON exporter
func NewJSONExporter(options *JSONExportOptions) (*JSONExporter, error) {

exporter := &JSONExporter{
mutex: &sync.Mutex{},
options: options,
rows: []Result{},
}
return exporter, nil
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Validate options early and prepare the filesystem.

Constructor doesn’t validate options or ensure the directory exists. If options.File is empty or its parent directory is missing, Close will fail late. Validate upfront and create the directory.

Apply this diff to validate inputs and prepare directories:

 func NewJSONExporter(options *JSONExportOptions) (*JSONExporter, error) {
-
-	exporter := &JSONExporter{
-		mutex:   &sync.Mutex{},
-		options: options,
-		rows:    []Result{},
-	}
-	return exporter, nil
+	if options == nil || options.File == "" {
+		return nil, errors.New("json exporter: file path is required")
+	}
+	// Ensure parent directory exists
+	if dir := filepath.Dir(options.File); dir != "." && dir != "" {
+		if err := os.MkdirAll(dir, 0o755); err != nil {
+			return nil, errors.Wrap(err, "json exporter: failed to create output directory")
+		}
+	}
+
+	exporter := &JSONExporter{
+		mutex:   &sync.Mutex{},
+		options: options,
+		rows:    []Result{},
+	}
+	return exporter, nil
 }

Also add imports:

 import (
+	"path/filepath"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// NewJSONExporter creates a new JSON exporter
func NewJSONExporter(options *JSONExportOptions) (*JSONExporter, error) {
exporter := &JSONExporter{
mutex: &sync.Mutex{},
options: options,
rows: []Result{},
}
return exporter, nil
}
// NewJSONExporter creates a new JSON exporter
func NewJSONExporter(options *JSONExportOptions) (*JSONExporter, error) {
if options == nil || options.File == "" {
return nil, errors.New("json exporter: file path is required")
}
// Ensure parent directory exists
if dir := filepath.Dir(options.File); dir != "." && dir != "" {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, errors.Wrap(err, "json exporter: failed to create output directory")
}
}
exporter := &JSONExporter{
mutex: &sync.Mutex{},
options: options,
rows: []Result{},
}
return exporter, nil
}
Suggested change
// NewJSONExporter creates a new JSON exporter
func NewJSONExporter(options *JSONExportOptions) (*JSONExporter, error) {
exporter := &JSONExporter{
mutex: &sync.Mutex{},
options: options,
rows: []Result{},
}
return exporter, nil
}
import (
// ... other imports ...
"path/filepath"
)

Comment on lines +42 to +59
// Close writes remaining data and closes
func (exporter *JSONExporter) Close() error {
exporter.mutex.Lock()
defer exporter.mutex.Unlock()

// Convert the rows to JSON byte array
obj, err := json.Marshal(exporter.rows)
if err != nil {
return errors.Wrap(err, "failed to generate JSON report")
}

// Attempt to write the JSON to file specified in options.JSONExport
if err := os.WriteFile(exporter.options.File, obj, 0644); err != nil {
return errors.Wrap(err, "failed to create JSON file")
}

return nil
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Avoid OOM by streaming JSON to disk instead of buffering all results.

Accumulating every Result in memory and marshalling at Close is risky for large scans and can crash with OOM or lead to very long Close() times. Stream the JSON array to disk incrementally.

Proposed minimal streaming exporter (open file once; write “[”, then “,” + object per Export; write “]” on Close):

@@
-package runner
+package runner
 
 import (
+	"bufio"
 	"encoding/json"
 	"os"
 	"sync"
 
 	"github.com/pkg/errors"
+	"path/filepath"
 )
@@
 type JSONExporter struct {
 	options *JSONExportOptions
-	mutex   *sync.Mutex
-	rows    []Result
+	mutex   *sync.Mutex
+	// streaming state
+	file   *os.File
+	writer *bufio.Writer
+	enc    *json.Encoder
+	first  bool // true until first element is written
 }
@@
 func NewJSONExporter(options *JSONExportOptions) (*JSONExporter, error) {
 
-	exporter := &JSONExporter{
-		mutex:   &sync.Mutex{},
-		options: options,
-		rows:    []Result{},
-	}
-	return exporter, nil
+	if options == nil || options.File == "" {
+		return nil, errors.New("json exporter: file path is required")
+	}
+	if dir := filepath.Dir(options.File); dir != "." && dir != "" {
+		if err := os.MkdirAll(dir, 0o755); err != nil {
+			return nil, errors.Wrap(err, "json exporter: failed to create output directory")
+		}
+	}
+	f, err := os.OpenFile(options.File, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
+	if err != nil {
+		return nil, errors.Wrap(err, "json exporter: failed to open output file")
+	}
+	w := bufio.NewWriter(f)
+	// start JSON array
+	if _, err := w.WriteString("["); err != nil {
+		_ = f.Close()
+		return nil, errors.Wrap(err, "json exporter: failed to initialize json array")
+	}
+	enc := json.NewEncoder(w)
+	enc.SetEscapeHTML(false)
+	exporter := &JSONExporter{
+		mutex:  &sync.Mutex{},
+		options: options,
+		file:   f,
+		writer: w,
+		enc:    enc,
+		first:  true,
+	}
+	return exporter, nil
 }
@@
-// Export adds result and writes batch when full
+// Export writes one result into the JSON array
 func (exporter *JSONExporter) Export(result *Result) error {
 	exporter.mutex.Lock()
 	defer exporter.mutex.Unlock()
 
-	exporter.rows = append(exporter.rows, *result)
+	if exporter.file == nil || exporter.writer == nil || exporter.enc == nil {
+		return errors.New("json exporter: not initialized")
+	}
+	// write comma if not the first element
+	if !exporter.first {
+		if _, err := exporter.writer.WriteString(","); err != nil {
+			return errors.Wrap(err, "json exporter: failed to write delimiter")
+		}
+	}
+	exporter.first = false
+	if err := exporter.enc.Encode(result); err != nil {
+		return errors.Wrap(err, "json exporter: failed to encode result")
+	}
 
 	return nil
 }
@@
-// Close writes remaining data and closes
+// Close finalizes the JSON array and closes the file
 func (exporter *JSONExporter) Close() error {
 	exporter.mutex.Lock()
 	defer exporter.mutex.Unlock()
 
-	// Convert the rows to JSON byte array
-	obj, err := json.Marshal(exporter.rows)
-	if err != nil {
-		return errors.Wrap(err, "failed to generate JSON report")
-	}
-
-	// Attempt to write the JSON to file specified in options.JSONExport
-	if err := os.WriteFile(exporter.options.File, obj, 0644); err != nil {
-		return errors.Wrap(err, "failed to create JSON file")
-	}
-
-	return nil
+	if exporter.writer == nil || exporter.file == nil {
+		return nil
+	}
+	// close the JSON array
+	if _, err := exporter.writer.WriteString("]"); err != nil {
+		return errors.Wrap(err, "json exporter: failed to finalize json array")
+	}
+	if err := exporter.writer.Flush(); err != nil {
+		return errors.Wrap(err, "json exporter: failed to flush writer")
+	}
+	if err := exporter.file.Close(); err != nil {
+		return errors.Wrap(err, "json exporter: failed to close file")
+	}
+	exporter.writer = nil
+	exporter.file = nil
+	return nil
 }

Benefits:

  • Constant memory (O(1) w.r.t. number of results).
  • Ensures a valid JSON array even on long-running scans.
  • Immediate I/O backpressure; earlier failures surface sooner.

If you prefer the nuclei-style exporter with batch size/rolling flush, we can adapt that instead.

@Mzack9999
Copy link
Member

Thanks for taking the time to work on this! After reviewing the changes and the discussion in #2218
, we’ve decided not to move forward with this approach. The proposed changes introduce additional complexity and memory overhead without fully addressing the concerns raised, and we’d like to explore a more robust solution in the future.

We appreciate your effort and contribution, but we’ll go ahead and close this PR for now. Thanks again for your time and interest in improving httpx!

@Mzack9999 Mzack9999 closed this Sep 12, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Issues with JSON Output in httpx (File Not Generated & Formatting Errors)

3 participants

Comments