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
59 changes: 59 additions & 0 deletions packages/core/src/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,65 @@ describe("createSpecStreamCompiler", () => {
const result = compiler.getResult();
expect(result).toEqual({ z: 1, w: 2 });
});

describe("onError callback", () => {
it("calls onError for malformed JSON", () => {
const errors: string[] = [];
const compiler = createSpecStreamCompiler({
onError: (line) => errors.push(line),
});

compiler.push('{"op":"add"\n'); // Missing closing brace
compiler.push('not-json\n');
compiler.push('{"invalid":}\n'); // Invalid JSON

expect(errors).toHaveLength(2);
expect(errors[0]).toContain('{"op":"add"');
expect(errors[1]).toContain('{"invalid":}');
});

it("calls onError when patch application fails", () => {
const errors: Array<{ line: string; error?: unknown }> = [];
const compiler = createSpecStreamCompiler({
onError: (line, error) => errors.push({ line, error }),
});

// Valid JSON but test operation fails
compiler.push('{"op":"test","path":"/x","value":"wrong"}\n');

expect(errors).toHaveLength(1);
expect(errors[0]?.error).toBeDefined();
});

it("does not call onError for valid patches", () => {
const errors: string[] = [];
const compiler = createSpecStreamCompiler({
onError: (line) => errors.push(line),
});

compiler.push('{"op":"add","path":"/x","value":1}\n');
const result = compiler.getResult();

expect(errors).toHaveLength(0);
expect(result).toEqual({ x: 1 });
});

it("continues processing after errors", () => {
const errors: string[] = [];
const compiler = createSpecStreamCompiler({
onError: (line) => errors.push(line),
});

compiler.push('{"invalid"}\n');
compiler.push('{"op":"add","path":"/x","value":1}\n');
compiler.push('{broken\n');
compiler.push('{"op":"add","path":"/y","value":2}\n');

expect(errors).toHaveLength(2);
const result = compiler.getResult();
expect(result).toEqual({ x: 1, y: 2 });
});
});
});

// =============================================================================
Expand Down
57 changes: 46 additions & 11 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -791,14 +791,29 @@ export interface SpecStreamCompiler<T> {
reset(initial?: Partial<T>): void;
}

/**
* Options for createSpecStreamCompiler.
*/
export interface SpecStreamCompilerOptions<T> {
/** Initial state */
initial?: Partial<T>;
/**
* Called when a line fails to parse as a valid patch.
* Useful for logging malformed JSON during debugging.
*/
onError?: (line: string, error?: unknown) => void;
}

/**
* Create a streaming SpecStream compiler.
*
* SpecStream is json-render's streaming format. AI outputs patch operations
* line by line, and this compiler progressively builds the final spec.
*
* @example
* const compiler = createSpecStreamCompiler<TimelineSpec>();
* const compiler = createSpecStreamCompiler<TimelineSpec>({
* onError: (line) => console.warn('Failed to parse:', line),
* });
*
* // Process streaming response
* const reader = response.body.getReader();
Expand All @@ -813,8 +828,9 @@ export interface SpecStreamCompiler<T> {
* }
*/
export function createSpecStreamCompiler<T = Record<string, unknown>>(
initial: Partial<T> = {},
options: SpecStreamCompilerOptions<T> = {},
Copy link
Contributor

Choose a reason for hiding this comment

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

Breaking API change in createSpecStreamCompiler signature causes type errors in 3 example files that still pass Partial<T> instead of SpecStreamCompilerOptions<T>.

Fix on Vercel

): SpecStreamCompiler<T> {
const { initial = {}, onError } = options;
let result = { ...initial } as T;
let buffer = "";
const appliedPatches: SpecStreamLine[] = [];
Expand All @@ -836,9 +852,19 @@ export function createSpecStreamCompiler<T = Record<string, unknown>>(

const patch = parseSpecStreamLine(trimmed);
if (patch) {
applySpecStreamPatch(result as Record<string, unknown>, patch);
appliedPatches.push(patch);
newPatches.push(patch);
try {
applySpecStreamPatch(result as Record<string, unknown>, patch);
appliedPatches.push(patch);
newPatches.push(patch);
} catch (error) {
// Patch failed to apply (e.g., test operation failed)
if (onError) {
onError(trimmed, error);
}
}
} else if (trimmed.startsWith("{") && onError) {
// Line looks like JSON but failed to parse
onError(trimmed);
}
}

Expand All @@ -853,12 +879,21 @@ export function createSpecStreamCompiler<T = Record<string, unknown>>(
getResult(): T {
// Process any remaining buffer
if (buffer.trim()) {
const patch = parseSpecStreamLine(buffer);
if (patch && !processedLines.has(buffer.trim())) {
processedLines.add(buffer.trim());
applySpecStreamPatch(result as Record<string, unknown>, patch);
appliedPatches.push(patch);
result = { ...result };
const trimmed = buffer.trim();
const patch = parseSpecStreamLine(trimmed);
if (patch && !processedLines.has(trimmed)) {
processedLines.add(trimmed);
try {
applySpecStreamPatch(result as Record<string, unknown>, patch);
appliedPatches.push(patch);
result = { ...result };
} catch (error) {
if (onError) {
onError(trimmed, error);
}
}
} else if (trimmed.startsWith("{") && !patch && onError) {
onError(trimmed);
}
buffer = "";
}
Expand Down