Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions packages/core/src/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] } };
Expand Down
91 changes: 90 additions & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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.
Expand Down