diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff3e592377e..0f712d72576 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -726,6 +726,9 @@ importers: isbinaryfile: specifier: ^5.0.2 version: 5.0.4 + json-stream-stringify: + specifier: ^3.1.6 + version: 3.1.6 jwt-decode: specifier: ^4.0.0 version: 4.0.0 @@ -7022,6 +7025,10 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-stream-stringify@3.1.6: + resolution: {integrity: sha512-x7fpwxOkbhFCaJDJ8vb1fBY3DdSa4AlITaz+HHILQJzdPMnHEFjxPwVUi1ALIbcIxDE0PNe/0i7frnY8QnBQog==} + engines: {node: '>=7.10.1'} + json-stringify-safe@5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} @@ -14114,7 +14121,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) '@vitest/utils@3.2.4': dependencies: @@ -17148,6 +17155,8 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json-stream-stringify@3.1.6: {} + json-stringify-safe@5.0.1: {} json5@2.2.3: {} diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index f9bc7a85cda..c6bd3ff66f1 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1249,7 +1249,7 @@ export const webviewMessageHandler = async ( const exists = await fileExistsAtPath(mcpPath) if (!exists) { - await safeWriteJson(mcpPath, { mcpServers: {} }) + await safeWriteJson(mcpPath, { mcpServers: {} }, { prettyPrint: true }) } await openFile(mcpPath) diff --git a/src/package.json b/src/package.json index c1e38199aaf..41c19bdad0b 100644 --- a/src/package.json +++ b/src/package.json @@ -465,6 +465,7 @@ "i18next": "^25.0.0", "ignore": "^7.0.3", "isbinaryfile": "^5.0.2", + "json-stream-stringify": "^3.1.6", "jwt-decode": "^4.0.0", "lodash.debounce": "^4.0.8", "mammoth": "^1.9.1", diff --git a/src/services/mcp/McpHub.ts b/src/services/mcp/McpHub.ts index 2db26332fae..52046d82697 100644 --- a/src/services/mcp/McpHub.ts +++ b/src/services/mcp/McpHub.ts @@ -1531,7 +1531,7 @@ export class McpHub { } this.isProgrammaticUpdate = true try { - await safeWriteJson(configPath, updatedConfig) + await safeWriteJson(configPath, updatedConfig, { prettyPrint: true }) } finally { // Reset flag after watcher debounce period (non-blocking) this.flagResetTimer = setTimeout(() => { @@ -1616,7 +1616,7 @@ export class McpHub { mcpServers: config.mcpServers, } - await safeWriteJson(configPath, updatedConfig) + await safeWriteJson(configPath, updatedConfig, { prettyPrint: true }) // Update server connections with the correct source await this.updateServerConnections(config.mcpServers, serverSource) @@ -1767,7 +1767,7 @@ export class McpHub { } this.isProgrammaticUpdate = true try { - await safeWriteJson(normalizedPath, config) + await safeWriteJson(normalizedPath, config, { prettyPrint: true }) } finally { // Reset flag after watcher debounce period (non-blocking) this.flagResetTimer = setTimeout(() => { diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 719bbd72167..3c6dcf8e67c 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -2,8 +2,20 @@ import * as fs from "fs/promises" import * as fsSync from "fs" import * as path from "path" import * as lockfile from "proper-lockfile" -import Disassembler from "stream-json/Disassembler" -import Stringer from "stream-json/Stringer" +import { JsonStreamStringify } from "json-stream-stringify" + +/** + * Options for safeWriteJson function + */ +export interface SafeWriteJsonOptions { + /** + * Whether to pretty-print the JSON output with indentation. + * When true, uses tab characters for indentation. + * When false or undefined, outputs compact JSON. + * @default false + */ + prettyPrint?: boolean +} /** * Safely writes JSON data to a file. @@ -12,13 +24,15 @@ import Stringer from "stream-json/Stringer" * - Writes to a temporary file first. * - If the target file exists, it's backed up before being replaced. * - Attempts to roll back and clean up in case of errors. + * - Supports pretty-printing with indentation while maintaining streaming efficiency. * * @param {string} filePath - The absolute path to the target file. * @param {any} data - The data to serialize to JSON and write. + * @param {SafeWriteJsonOptions} options - Optional configuration for JSON formatting. * @returns {Promise} */ -async function safeWriteJson(filePath: string, data: any): Promise { +async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJsonOptions): Promise { const absoluteFilePath = path.resolve(filePath) let releaseLock = async () => {} // Initialized to a no-op @@ -75,7 +89,7 @@ async function safeWriteJson(filePath: string, data: any): Promise { `.${path.basename(absoluteFilePath)}.new_${Date.now()}_${Math.random().toString(36).substring(2)}.tmp`, ) - await _streamDataToFile(actualTempNewFilePath, data) + await _streamDataToFile(actualTempNewFilePath, data, options?.prettyPrint) // Step 2: Check if the target file exists. If so, rename it to a backup path. try { @@ -182,53 +196,26 @@ async function safeWriteJson(filePath: string, data: any): Promise { * Helper function to stream JSON data to a file. * @param targetPath The path to write the stream to. * @param data The data to stream. + * @param prettyPrint Whether to format the JSON with indentation. * @returns Promise */ -async function _streamDataToFile(targetPath: string, data: any): Promise { +async function _streamDataToFile(targetPath: string, data: any, prettyPrint = false): Promise { // Stream data to avoid high memory usage for large JSON objects. const fileWriteStream = fsSync.createWriteStream(targetPath, { encoding: "utf8" }) - const disassembler = Disassembler.disassembler() - // Output will be compact JSON as standard Stringer is used. - const stringer = Stringer.stringer() - - return new Promise((resolve, reject) => { - let errorOccurred = false - const handleError = (_streamName: string) => (err: Error) => { - if (!errorOccurred) { - errorOccurred = true - if (!fileWriteStream.destroyed) { - fileWriteStream.destroy(err) - } - reject(err) - } - } - disassembler.on("error", handleError("Disassembler")) - stringer.on("error", handleError("Stringer")) - fileWriteStream.on("error", (err: Error) => { - if (!errorOccurred) { - errorOccurred = true - reject(err) - } - }) - - fileWriteStream.on("finish", () => { - if (!errorOccurred) { - resolve() - } - }) - - disassembler.pipe(stringer).pipe(fileWriteStream) + // JsonStreamStringify traverses the object and streams tokens directly + // The 'spaces' parameter adds indentation during streaming, not via a separate pass + const stringifyStream = new JsonStreamStringify( + data, + undefined, // replacer + prettyPrint ? "\t" : undefined, // spaces for indentation + ) - // stream-json's Disassembler might error if `data` is undefined. - // JSON.stringify(undefined) would produce the string "undefined" if it's the root value. - // Writing 'null' is a safer JSON representation for a root undefined value. - if (data === undefined) { - disassembler.write(null) - } else { - disassembler.write(data) - } - disassembler.end() + return new Promise((resolve, reject) => { + stringifyStream.on("error", reject) + fileWriteStream.on("error", reject) + fileWriteStream.on("finish", resolve) + stringifyStream.pipe(fileWriteStream) }) }