Skip to content
Merged
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
50 changes: 27 additions & 23 deletions web/app/(workspace)/book/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { Loader2, MessageSquare } from "lucide-react";
import { notify } from "@/lib/notifications";
import { useTranslation } from "react-i18next";

import { bookApi, openBookSocket } from "@/lib/book-api";
import { bookApi, type BookWsEvent } from "@/lib/book-api";
import type {
Block,
BlockType,
Expand Down Expand Up @@ -132,12 +132,12 @@ function BookPageInner() {
return () => clearTimeout(timer);
}, [toast]);

// ── Live WS event subscription ─────────────────────────────────────
// ── Live WS event handling ─────────────────────────────────────────

useEffect(() => {
if (!selectedBookId) return;
const socket = openBookSocket((event) => {
// Always feed the progress reducer so the timeline updates live.
const handleBookOperationEvent = useCallback(
(event: BookWsEvent) => {
// Each long-running operation owns its WebSocket. Feed those streamed
// events into the shared timeline and refresh persisted milestones.
dispatchProgress(event);

const meta =
Expand All @@ -146,23 +146,18 @@ function BookPageInner() {
(event.content as string) || (meta.kind as string) || "",
);
if (
kind === "block_ready" ||
kind === "block_error" ||
kind === "page_compiled" ||
kind === "page_planned" ||
kind === "spine_ready"
selectedBookId &&
(kind === "block_ready" ||
kind === "block_error" ||
kind === "page_compiled" ||
kind === "page_planned" ||
kind === "spine_ready")
) {
void loadBookDetail(selectedBookId);
}
});
return () => {
try {
socket.close();
} catch {
// ignore
}
};
}, [selectedBookId, loadBookDetail]);
},
[selectedBookId, loadBookDetail],
);

// ── Selectors ──────────────────────────────────────────────────────

Expand Down Expand Up @@ -288,7 +283,11 @@ function BookPageInner() {
if (!pendingBook) return;
setConfirmingProposal(true);
try {
const result = await bookApi.confirmProposal(pendingBook.id, edited);
const result = await bookApi.confirmProposal(
pendingBook.id,
edited,
handleBookOperationEvent,
);
setPendingBook(result.book);
setPendingProposal(null);
await loadBookDetail(result.book.id);
Expand Down Expand Up @@ -322,7 +321,12 @@ function BookPageInner() {
if (!selectedBookId) return;
setCompilingPageId(pageId);
try {
await bookApi.compilePage(selectedBookId, pageId, force);
await bookApi.compilePage(
selectedBookId,
pageId,
force,
handleBookOperationEvent,
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
notify(`Compile failed: ${msg}`, { tone: "error", durationMs: 8000 });
Expand All @@ -332,7 +336,7 @@ function BookPageInner() {
await loadBookDetail(selectedBookId);
}
},
[selectedBookId, loadBookDetail],
[selectedBookId, loadBookDetail, handleBookOperationEvent],
);

const handleSelectPage = (pageId: string) => {
Expand Down
52 changes: 41 additions & 11 deletions web/lib/book-api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { apiFetch, apiUrl, wsUrl } from "@/lib/api";
import {
runBookSocketOperation,
type BookWsEvent,
} from "@/lib/book-ws-operation";
import type {
Book,
BookDetail,
Expand All @@ -10,6 +14,17 @@ import type {

const BASE = "/api/v1/book";

function requestOverSocket<T extends BookWsEvent>(
message: BookWsEvent,
resultType: string,
onEvent?: (event: BookWsEvent) => void,
): Promise<T> {
return runBookSocketOperation<T>(
() => new WebSocket(wsUrl(`${BASE}/ws`)),
{ message, resultType, onEvent },
);
}

async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await apiFetch(apiUrl(`${BASE}${path}`), {
headers: { "Content-Type": "application/json", ...(init?.headers || {}) },
Expand Down Expand Up @@ -59,21 +74,36 @@ export const bookApi = {
method: "POST",
body: JSON.stringify(payload),
}),
confirmProposal: (book_id: string, proposal?: BookProposal) =>
request<{ book: Book; spine: Spine }>("/books/confirm-proposal", {
method: "POST",
body: JSON.stringify({ book_id, proposal: proposal ?? null }),
}),
confirmProposal: (
book_id: string,
proposal?: BookProposal,
onEvent?: (event: BookWsEvent) => void,
) =>
requestOverSocket<{
type: "confirm_proposal_result";
book: Book;
spine: Spine;
}>(
{ type: "confirm_proposal", book_id, proposal: proposal ?? null },
"confirm_proposal_result",
onEvent,
),
confirmSpine: (book_id: string, spine?: Spine, auto_compile = true) =>
request<{ pages: Page[] }>("/books/confirm-spine", {
method: "POST",
body: JSON.stringify({ book_id, spine: spine ?? null, auto_compile }),
}),
compilePage: (book_id: string, page_id: string, force = false) =>
request<{ page: Page }>("/books/compile-page", {
method: "POST",
body: JSON.stringify({ book_id, page_id, force }),
}),
compilePage: (
book_id: string,
page_id: string,
force = false,
onEvent?: (event: BookWsEvent) => void,
) =>
requestOverSocket<{ type: "compile_page_result"; page: Page }>(
{ type: "compile_page", book_id, page_id, force },
"compile_page_result",
onEvent,
),
regenerateBlock: (
book_id: string,
page_id: string,
Expand Down Expand Up @@ -227,7 +257,7 @@ export async function getLegacyChatSession(

// ── WebSocket helper ─────────────────────────────────────────────────

export type BookWsEvent = { type: string; [key: string]: unknown };
export type { BookWsEvent } from "@/lib/book-ws-operation";

export function openBookSocket(
onEvent: (event: BookWsEvent) => void,
Expand Down
101 changes: 101 additions & 0 deletions web/lib/book-ws-operation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
export type BookWsEvent = { type: string; [key: string]: unknown };

export interface BookSocketLike {
onopen: ((event: Event) => void) | null;
onmessage: ((event: MessageEvent<string>) => void) | null;
onerror: ((event: Event) => void) | null;
onclose: ((event: CloseEvent) => void) | null;
send(data: string): void;
close(): void;
}

export interface BookSocketOperationOptions {
message: BookWsEvent;
resultType: string;
onEvent?: (event: BookWsEvent) => void;
}

function errorMessage(event: BookWsEvent): string {
const detail = event.content ?? event.message ?? event.detail;
return typeof detail === "string" && detail.trim()
? detail
: "Book WebSocket operation failed";
}

export function runBookSocketOperation<T extends BookWsEvent = BookWsEvent>(
createSocket: () => BookSocketLike,
options: BookSocketOperationOptions,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const socket = createSocket();
let settled = false;

const finish = (callback: () => void, closeSocket: boolean): void => {
if (settled) return;
settled = true;
if (closeSocket) {
try {
socket.close();
} catch {
// The operation result is authoritative even if cleanup fails.
}
}
callback();
};

socket.onopen = () => {
try {
socket.send(JSON.stringify(options.message));
} catch (error) {
finish(
() =>
reject(
error instanceof Error
? error
: new Error("Failed to send Book WebSocket operation"),
),
true,
);
}
};

socket.onmessage = (message) => {
let event: BookWsEvent;
try {
event = JSON.parse(message.data) as BookWsEvent;
} catch {
return;
}

options.onEvent?.(event);

if (event.type === "error") {
finish(() => reject(new Error(errorMessage(event))), true);
return;
}

if (event.type === options.resultType) {
finish(() => resolve(event as T), true);
}
};

socket.onerror = () => {
finish(
() => reject(new Error("Book WebSocket connection failed")),
true,
);
};

socket.onclose = () => {
finish(
() =>
reject(
new Error(
`Book WebSocket closed before ${options.resultType} was received`,
),
),
false,
);
};
});
}
23 changes: 23 additions & 0 deletions web/tests/book-api-transport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";

const source = readFileSync(
path.resolve(process.cwd(), "lib/book-api.ts"),
"utf8",
);

test("long-running Book operations use the streaming WebSocket transport", () => {
// These operations routinely outlive the Next.js HTTP proxy's idle window.
// Keeping this contract explicit prevents a future refactor from restoring
// the false-failure pattern where REST disconnects while the backend keeps
// generating the spine or page.
assert.doesNotMatch(source, /\/books\/confirm-proposal/);
assert.doesNotMatch(source, /\/books\/compile-page/);

assert.match(source, /type:\s*"confirm_proposal"/);
assert.match(source, /"confirm_proposal_result"/);
assert.match(source, /type:\s*"compile_page"/);
assert.match(source, /"compile_page_result"/);
});
Loading