diff --git a/packages/core/src/types.test.ts b/packages/core/src/types.test.ts index d7a90bef..8eb136e9 100644 --- a/packages/core/src/types.test.ts +++ b/packages/core/src/types.test.ts @@ -12,10 +12,93 @@ import { createSpecStreamCompiler, createMixedStreamParser, createJsonRenderTransform, + parseSpecStreamLine, SPEC_DATA_PART_TYPE, } from "./types"; import type { Spec, SpecStreamLine, StreamChunk } from "./types"; +// ============================================================================= +// parseSpecStreamLine - including truncation recovery +// ============================================================================= + +describe("parseSpecStreamLine", () => { + it("parses valid JSON patch operations", () => { + const result = parseSpecStreamLine( + '{"op":"add","path":"/root","value":"main"}', + ); + expect(result).toEqual({ op: "add", path: "/root", value: "main" }); + }); + + it("returns null for empty input", () => { + expect(parseSpecStreamLine("")).toBeNull(); + expect(parseSpecStreamLine(" ")).toBeNull(); + }); + + it("returns null for non-JSON input", () => { + expect(parseSpecStreamLine("hello world")).toBeNull(); + expect(parseSpecStreamLine("not json")).toBeNull(); + }); + + it("returns null for JSON without op field", () => { + expect(parseSpecStreamLine('{"path":"/root","value":"x"}')).toBeNull(); + }); + + it("returns null for JSON without path field", () => { + expect(parseSpecStreamLine('{"op":"add","value":"x"}')).toBeNull(); + }); + + describe("truncation recovery", () => { + it("repairs truncated JSON missing closing brace", () => { + const result = parseSpecStreamLine( + '{"op":"add","path":"/root","value":"main"', + ); + expect(result).toEqual({ op: "add", path: "/root", value: "main" }); + }); + + it("repairs truncated JSON missing closing bracket and brace", () => { + const result = parseSpecStreamLine( + '{"op":"add","path":"/items","value":[1,2,3]', + ); + expect(result).toEqual({ op: "add", path: "/items", value: [1, 2, 3] }); + }); + + it("repairs deeply nested truncated JSON", () => { + const result = parseSpecStreamLine( + '{"op":"add","path":"/el","value":{"type":"Card","props":{"title":"Hi"},"children":[]', + ); + expect(result).toEqual({ + op: "add", + path: "/el", + value: { type: "Card", props: { title: "Hi" }, children: [] }, + }); + }); + + it("repairs truncated JSON with unclosed string", () => { + const result = parseSpecStreamLine( + '{"op":"add","path":"/root","value":"main', + ); + expect(result).toEqual({ op: "add", path: "/root", value: "main" }); + }); + + it("repairs truncated array inside value", () => { + const result = parseSpecStreamLine( + '{"op":"add","path":"/arr","value":["a","b"', + ); + expect(result).toEqual({ op: "add", path: "/arr", value: ["a", "b"] }); + }); + + it("returns null for truly malformed JSON (mismatched brackets)", () => { + expect(parseSpecStreamLine('{"op":"add"]')).toBeNull(); + expect(parseSpecStreamLine('{"op":"add","value":[}')).toBeNull(); + }); + + it("returns null for incomplete patch without required fields", () => { + // Even if repaired, missing required fields = null + expect(parseSpecStreamLine('{"op":"add"')).toBeNull(); + }); + }); +}); + describe("getByPath", () => { it("gets nested values with JSON pointer paths", () => { const data = { user: { name: "John", scores: [10, 20, 30] } }; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index cbc28082..abaf5806 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -552,13 +552,35 @@ export type SpecStreamLine = JsonPatch; * * SpecStream is json-render's streaming format where each line is a JSON patch * operation that progressively builds up the final spec. + * + * Includes truncation recovery: if JSON.parse fails, attempts to repair + * truncated JSON by closing unclosed brackets/braces before giving up. */ export function parseSpecStreamLine(line: string): SpecStreamLine | null { const trimmed = line.trim(); if (!trimmed || !trimmed.startsWith("{")) return null; + // First, try parsing as-is + const parsed = tryParseJsonPatch(trimmed); + if (parsed) return parsed; + + // Attempt truncation recovery: close unclosed brackets/braces + const repaired = attemptJsonRepair(trimmed); + if (repaired && repaired !== trimmed) { + const repairedParsed = tryParseJsonPatch(repaired); + if (repairedParsed) return repairedParsed; + } + + return null; +} + +/** + * Try to parse a string as a JSON patch operation. + * Returns the patch if valid, null otherwise. + */ +function tryParseJsonPatch(str: string): SpecStreamLine | null { try { - const patch = JSON.parse(trimmed) as SpecStreamLine; + const patch = JSON.parse(str) as SpecStreamLine; if (patch.op && patch.path !== undefined) { return patch; } @@ -568,6 +590,73 @@ export function parseSpecStreamLine(line: string): SpecStreamLine | null { } } +/** + * Attempt to repair truncated JSON by closing unclosed brackets and braces. + * Only handles simple truncation cases where the JSON was cut off mid-stream. + * + * @example + * '{"op":"add","path":"/root","value":"main"' -> '{"op":"add","path":"/root","value":"main"}' + * '{"op":"add","path":"/items","value":[1,2' -> '{"op":"add","path":"/items","value":[1,2]}' + */ +function attemptJsonRepair(str: string): string | null { + // Track unclosed brackets/braces (ignoring those inside strings) + const stack: string[] = []; + let inString = false; + let escaped = false; + + for (let i = 0; i < str.length; i++) { + const char = str[i]!; + + if (escaped) { + escaped = false; + continue; + } + + if (char === "\\") { + escaped = true; + continue; + } + + if (char === '"') { + inString = !inString; + continue; + } + + if (inString) continue; + + if (char === "{" || char === "[") { + stack.push(char); + } else if (char === "}") { + if (stack.length === 0 || stack[stack.length - 1] !== "{") { + return null; // Malformed, not just truncated + } + stack.pop(); + } else if (char === "]") { + if (stack.length === 0 || stack[stack.length - 1] !== "[") { + return null; // Malformed, not just truncated + } + stack.pop(); + } + } + + // If nothing is unclosed, the JSON is complete (but invalid for other reasons) + if (stack.length === 0) return null; + + // If we're mid-string, close the string first + let repaired = str; + if (inString) { + repaired += '"'; + } + + // Close unclosed brackets/braces in reverse order + while (stack.length > 0) { + const opener = stack.pop()!; + repaired += opener === "{" ? "}" : "]"; + } + + return repaired; +} + /** * Apply a single RFC 6902 JSON Patch operation to an object. * Mutates the object in place.