openreader/src/app/api/documents/[id]/parsed/events/route.ts
Richard R f2c7760381
Redesign: shared UI design system + app-wide migration (#96)
* phase 0: token foundation

* phase 1: motion language

* phase 2: primitives and semantic tokens

* phase 3: depth and rhythm polish

* phase 4: enforce design system lint rules

* phase 5: split ui primitives into modules

* phase 7: refactor app surfaces to ui layer

* phase 9: enforce ui architecture imports

* phase 10: add ui system harness

* fix compact reader auth control

* fix pdf loader flash

* Converge sidebar and reader controls

* refactor: remove initial loader state and related logic from PDFViewerPage

* Remove legacy UI shim imports

* Converge modal and drawer frames

* Migrate secondary modals to shared frame

* Move settings modal onto shared frame

* Use shared cards in audiobook settings

* Converge choice and popover surfaces

* Converge reader navigation buttons

* Refactor UI components to use consistent button and icon styles across the application

* refactor(ui): unify button usage in settings, admin, and doclist components

Replace native button elements with shared Button, ChoiceTile, and IconButton
components for consistent UI behavior and styling. Update classNames and
props to match new component APIs. Adjust FinderSidebar to use utility
function for conditional class merging.

* feat(ui): introduce shared Listbox components for unified select and dropdown styling

Replace direct usage of Headless UI Listbox primitives with new SharedListboxButton,
SharedListboxOption, and SharedListboxOptions components across AudiobookExportModal,
FinderToolbar, VoicesControlBase, and select UI. Refactor related imports and classNames
to centralize dropdown styling and logic. Simplify UserMenu button markup for improved
consistency.

This change consolidates dropdown/select UI patterns, reduces duplication, and
improves maintainability by providing a single source of truth for Listbox styling
and behavior.

* refactor(ui): consolidate button, menu, popover, and range primitives for unified usage

Remove legacy UI harness and dev/demo files. Replace scattered button, menu, popover, and range input utilities with shared, composable primitives: Button, ButtonLink, ButtonAnchor, MenuActionItem, MenuItemsSurface, PopoverSurface, PopoverTrigger, and RangeInput. Update all usages across app, admin, player, and document components to use these new primitives, eliminating duplicated class logic and improving consistency. Remove obsolete utility files and class exports. This change streamlines UI code, centralizes styling, and reduces maintenance overhead.

* feat(ui): redesign range input with dynamic progress styling and improved accessibility

Revamp the range input component to support dynamic progress indication using CSS custom properties and linear gradients. Add logic to compute and set the progress percentage based on current value, min, and max. Refine focus and disabled states for better accessibility and usability. Update styling for both WebKit and Mozilla engines to ensure consistent appearance. This change enhances visual feedback and modernizes the range slider UI.

* style(ui): update range input to use secondary accent color for progress

Switch range input progress styling from primary to secondary accent color
for both WebKit and Mozilla engines. Remove drop shadow from slider thumb
for a cleaner appearance. This change aligns the component with the updated
design palette and simplifies visual effects.

* refactor(app): remove unused Link imports from public pages and components

Eliminate redundant imports of the Link component from Next.js in several
public-facing pages and components. These imports were no longer in use
after recent UI refactoring and consolidation of navigation elements.
This cleanup reduces bundle size and improves code clarity.

* chore(ui): remove unused export of segmented control classes from select component

Eliminate unnecessary export statements for segmentedButtonClass and
segmentedGroupClass in the select component to streamline the module's
public API and reduce potential confusion.

* test(accessibility): improve confirm dialog test coverage and refactor media state helper

Expand accessibility tests for ConfirmDialog to assert dialog semantics,
ARIA attributes, and visible destructive actions using test IDs. Refactor
expectMediaState helper to check both UI control state and underlying
media signals for more robust playback state detection.

* refactor(ui): improve segmented control accessibility and update danger color tokens

Update SegmentedControl to support full keyboard navigation and focus management,
enhancing accessibility. Replace string indicator in Select with icon, and update
button danger variant to use new --danger-strong variable for hover states. Add
danger-strong token to Tailwind config and globals. Refine dropzone disabled
behavior, adjust focus ring for better contrast, and apply minor UI consistency
tweaks across components.

* feat(ui): convert SidebarNavLink to forwardRef component

Refactor SidebarNavLink to use React.forwardRef, enabling parent components
to access the underlying anchor element's ref. Update prop typing and
function signature accordingly for improved composability and integration
with higher-order components.
2026-06-01 16:17:12 -06:00

519 lines
18 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/db';
import { documents } from '@/db/schema';
import { requireAuthContext } from '@/lib/server/auth/auth';
import { getWorkerClientConfigFromEnv } from '@/lib/server/compute/worker';
import { isAbortLikeError } from '@/lib/server/compute/abort-like-error';
import { snapshotFromWorkerState } from '@/lib/server/compute/worker-parse-state';
import { fetchWorkerOperationState } from '@/lib/server/compute/worker-op-state';
import { isValidDocumentId } from '@/lib/server/documents/blobstore';
import { normalizeParseStatus, parseDocumentParseState } from '@/lib/server/documents/parse-state';
import { healStaleDocumentParseState } from '@/lib/server/documents/parse-state-healing';
import { isS3Configured } from '@/lib/server/storage/s3';
import { createRequestLogger, hashForLog } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
import { logDegraded, logServerError } from '@/lib/server/errors/logging';
import type { PdfParseProgress, PdfParseStatus } from '@/types/parsed-pdf';
import { parseSseEventId, parseSsePayload } from '@openreader/compute-core';
import type { PdfLayoutJobResult, WorkerOperationEvent, WorkerOperationState } from '@openreader/compute-core/api-contracts';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
export const maxDuration = 300;
const SSE_KEEPALIVE_MS = 15_000;
const SSE_RESYNC_INTERVAL_MS = 30_000;
type ParseRow = {
id: string;
userId: string;
parseState: string | null;
};
type ParsedSnapshot = {
parseStatus: PdfParseStatus;
parseProgress: PdfParseProgress | null;
opId?: string | null;
};
type SnapshotState = {
snapshot: ParsedSnapshot;
opId: string | null;
fromWorker: boolean;
};
function s3NotConfiguredResponse(): NextResponse {
return NextResponse.json(
{ error: 'Documents storage is not configured. Set S3_* environment variables.' },
{ status: 503 },
);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function toSnapshotState(row: ParseRow, preferredOpId?: string | null): Promise<SnapshotState> {
const state = await healStaleDocumentParseState({
documentId: row.id,
userId: row.userId,
state: parseDocumentParseState(row.parseState),
});
const parseStatus = normalizeParseStatus(state.status);
const dbOpId = typeof state.opId === 'string' && state.opId.trim() ? state.opId.trim() : null;
const requestedOpId = preferredOpId?.trim() || null;
const opId = requestedOpId ?? dbOpId;
// When a caller pins an opId, that op is the live source of truth even if
// the per-user document row currently says "ready" or has a different opId.
if (opId && (requestedOpId !== null || parseStatus !== 'ready')) {
const workerState = await fetchWorkerOperationState<PdfLayoutJobResult>(opId);
if (workerState && workerState.opId === opId) {
return {
snapshot: {
...snapshotFromWorkerState(workerState),
opId: workerState.opId,
},
opId: workerState.opId,
fromWorker: true,
};
}
}
return {
snapshot: {
parseStatus,
parseProgress: state.progress ?? null,
opId,
},
opId,
fromWorker: false,
};
}
async function loadPreferredRow(input: {
documentId: string;
storageUserId: string;
allowedUserIds: string[];
}): Promise<ParseRow | null> {
const rows = (await db
.select({
id: documents.id,
userId: documents.userId,
parseState: documents.parseState,
})
.from(documents)
.where(and(eq(documents.id, input.documentId), inArray(documents.userId, input.allowedUserIds)))) as ParseRow[];
return rows.find((candidate) => candidate.userId === input.storageUserId) ?? rows[0] ?? null;
}
async function syncFromDb(input: {
documentId: string;
storageUserId: string;
allowedUserIds: string[];
signature: string;
preferredOpId?: string | null;
writeSnapshot: (snapshot: ParsedSnapshot) => void;
}): Promise<{ snapshot: ParsedSnapshot; opId: string | null; fromWorker: boolean; signature: string } | null> {
const nextRow = await loadPreferredRow({
documentId: input.documentId,
storageUserId: input.storageUserId,
allowedUserIds: input.allowedUserIds,
});
if (!nextRow) return null;
const nextState = await toSnapshotState(nextRow, input.preferredOpId);
const nextSignature = JSON.stringify(nextState.snapshot);
if (nextSignature !== input.signature) {
input.writeSnapshot(nextState.snapshot);
}
return {
snapshot: nextState.snapshot,
opId: nextState.opId,
fromWorker: nextState.fromWorker,
signature: nextSignature,
};
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { logger, requestId } = createRequestLogger({
route: '/api/documents/[id]/parsed/events',
request: req,
});
try {
if (!isS3Configured()) return s3NotConfiguredResponse();
const authCtxOrRes = await requireAuthContext(req);
if (authCtxOrRes instanceof Response) return authCtxOrRes;
if (!authCtxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const params = await ctx.params;
const id = (params.id || '').trim().toLowerCase();
if (!isValidDocumentId(id)) {
return NextResponse.json({ error: 'Invalid document id' }, { status: 400 });
}
const requestedOpIdRaw = req.nextUrl.searchParams.get('opId');
const requestedOpId = typeof requestedOpIdRaw === 'string' && requestedOpIdRaw.trim()
? requestedOpIdRaw.trim()
: null;
const storageUserId = authCtxOrRes.userId;
const storageUserIdHash = hashForLog(storageUserId);
const allowedUserIds = [storageUserId];
const row = await loadPreferredRow({
documentId: id,
storageUserId,
allowedUserIds,
});
if (!row) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const initialState = await toSnapshotState(row, requestedOpId);
const workerCfg = getWorkerClientConfigFromEnv();
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
let closed = false;
let keepaliveTimer: ReturnType<typeof setInterval> | null = null;
let resyncTimer: ReturnType<typeof setInterval> | null = null;
let workerAbort: AbortController | null = null;
const writeSnapshot = (snapshot: ParsedSnapshot): void => {
controller.enqueue(encoder.encode(`event: snapshot\ndata: ${JSON.stringify(snapshot)}\n\n`));
};
const closeStream = (): void => {
if (closed) return;
closed = true;
if (keepaliveTimer) {
clearInterval(keepaliveTimer);
keepaliveTimer = null;
}
if (resyncTimer) {
clearInterval(resyncTimer);
resyncTimer = null;
}
if (workerAbort) {
workerAbort.abort();
workerAbort = null;
}
try {
controller.close();
} catch {
// no-op
}
};
const runWorkerProxy = async () => {
let current = initialState.snapshot;
let signature = JSON.stringify(current);
let currentOpId = requestedOpId ?? initialState.opId;
let currentFromWorker = initialState.fromWorker;
let lastEventId: number | null = null;
let loggedMissingOpId = false;
const pinnedRequestedOp = requestedOpId ?? null;
const shouldCloseForTerminalSnapshot = (snapshot: ParsedSnapshot, fromWorker: boolean): boolean => {
const isTerminal = snapshot.parseStatus === 'ready' || snapshot.parseStatus === 'failed';
if (!isTerminal) return false;
// If caller pinned an opId, keep streaming until worker confirms that
// op state. DB fallback can report stale terminal status for other rows.
if (pinnedRequestedOp && snapshot.opId === pinnedRequestedOp && !fromWorker) return false;
return true;
};
writeSnapshot(current);
if (shouldCloseForTerminalSnapshot(current, currentFromWorker)) {
closeStream();
return;
}
keepaliveTimer = setInterval(() => {
if (closed) return;
controller.enqueue(encoder.encode(': keepalive\n\n'));
}, SSE_KEEPALIVE_MS);
resyncTimer = setInterval(() => {
if (closed) return;
void syncFromDb({
documentId: id,
storageUserId,
allowedUserIds,
signature,
preferredOpId: requestedOpId ?? currentOpId,
writeSnapshot,
}).then((next) => {
if (closed) return;
if (!next) {
closeStream();
return;
}
current = next.snapshot;
signature = next.signature;
currentFromWorker = next.fromWorker;
if (!requestedOpId && next.opId !== currentOpId) {
currentOpId = next.opId;
lastEventId = null;
if (workerAbort) {
workerAbort.abort();
workerAbort = null;
}
}
if (shouldCloseForTerminalSnapshot(current, currentFromWorker)) {
closeStream();
}
}).catch((error) => {
if (closed) return;
logDegraded(logger, {
event: 'documents.parsed.events.db_resync_failed',
msg: 'SSE DB resync failed',
step: 'db_resync',
context: {
documentId: id,
storageUserIdHash,
requestId,
},
error,
});
controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({ error: String(error) })}\n\n`));
});
}, SSE_RESYNC_INTERVAL_MS);
while (!closed) {
if (!currentOpId) {
await sleep(SSE_RESYNC_INTERVAL_MS);
const next = await syncFromDb({
documentId: id,
storageUserId,
allowedUserIds,
signature,
preferredOpId: requestedOpId ?? currentOpId,
writeSnapshot,
});
if (!next) {
closeStream();
return;
}
current = next.snapshot;
signature = next.signature;
currentFromWorker = next.fromWorker;
currentOpId = requestedOpId ?? next.opId;
if (!currentOpId) {
if (!loggedMissingOpId) {
loggedMissingOpId = true;
logger.warn({
event: 'documents.parsed.events.missing_opid_non_terminal',
degraded: true,
step: 'poll_without_worker_op',
documentId: id,
storageUserIdHash,
parseStatus: current.parseStatus,
requestedOpId,
}, 'Parse stream running without opId and non-terminal status');
}
} else if (loggedMissingOpId) {
loggedMissingOpId = false;
}
if (shouldCloseForTerminalSnapshot(current, currentFromWorker)) {
closeStream();
return;
}
continue;
}
workerAbort = new AbortController();
const query = lastEventId && lastEventId > 0
? `?sinceEventId=${encodeURIComponent(String(lastEventId))}`
: '';
const response = await fetch(
`${workerCfg.baseUrl}/ops/${encodeURIComponent(currentOpId)}/events${query}`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${workerCfg.token}`,
Accept: 'text/event-stream',
...(lastEventId && lastEventId > 0 ? { 'Last-Event-ID': String(lastEventId) } : {}),
},
cache: 'no-store',
signal: workerAbort.signal,
},
);
if (closed) return;
if (!response.ok) {
const upstreamResponseBody = await response.text().catch(() => '');
logger.warn({
event: 'documents.parsed.events.worker_stream_request_failed',
degraded: true,
step: 'worker_stream_request',
documentId: id,
opId: currentOpId,
status: response.status,
upstreamResponseBody,
error: {
name: 'WorkerStreamRequestFailed',
message: `Worker stream request failed with status ${response.status}`,
},
}, 'Worker stream request failed');
await sleep(500);
continue;
}
if (!response.body) {
logger.warn({
event: 'documents.parsed.events.worker_stream_missing_body',
degraded: true,
step: 'worker_stream_body',
documentId: id,
opId: currentOpId,
error: {
name: 'WorkerStreamMissingBody',
message: 'Worker stream response missing body',
},
}, 'Worker stream response missing body');
await sleep(500);
continue;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let streamEnded = false;
while (!closed && !streamEnded) {
const read = await reader.read();
if (read.done) {
streamEnded = true;
break;
}
buffer += decoder.decode(read.value, { stream: true });
while (true) {
const frameEnd = buffer.indexOf('\n\n');
if (frameEnd < 0) break;
const frame = buffer.slice(0, frameEnd);
buffer = buffer.slice(frameEnd + 2);
const eventId = parseSseEventId(frame);
if (eventId && eventId > 0) {
lastEventId = eventId;
}
const payload = parseSsePayload(frame);
if (!payload) continue;
let parsed: WorkerOperationEvent<PdfLayoutJobResult> | WorkerOperationState<PdfLayoutJobResult>;
try {
parsed = JSON.parse(payload) as WorkerOperationEvent<PdfLayoutJobResult> | WorkerOperationState<PdfLayoutJobResult>;
} catch {
continue;
}
const workerSnapshot: WorkerOperationState<PdfLayoutJobResult> = (
parsed && typeof parsed === 'object' && 'snapshot' in parsed
? parsed.snapshot
: parsed as WorkerOperationState<PdfLayoutJobResult>
);
if (!workerSnapshot || workerSnapshot.opId !== currentOpId) continue;
const nextSnapshot: ParsedSnapshot = {
...snapshotFromWorkerState(workerSnapshot),
opId: workerSnapshot.opId,
};
const nextSignature = JSON.stringify(nextSnapshot);
if (nextSignature !== signature) {
current = nextSnapshot;
signature = nextSignature;
currentFromWorker = true;
writeSnapshot(current);
}
if (shouldCloseForTerminalSnapshot(current, currentFromWorker)) {
closeStream();
return;
}
}
}
if (closed) return;
const next = await syncFromDb({
documentId: id,
storageUserId,
allowedUserIds,
signature,
preferredOpId: requestedOpId ?? currentOpId,
writeSnapshot,
});
if (!next) {
closeStream();
return;
}
current = next.snapshot;
signature = next.signature;
currentFromWorker = next.fromWorker;
if (!requestedOpId && next.opId !== currentOpId) {
currentOpId = next.opId;
lastEventId = null;
}
if (shouldCloseForTerminalSnapshot(current, currentFromWorker)) {
closeStream();
return;
}
await sleep(250);
}
};
void runWorkerProxy()
.catch((error) => {
if (closed || isAbortLikeError(error)) return;
logServerError(logger, {
event: 'documents.parsed.events.worker_proxy_crashed',
error,
msg: 'Worker proxy crashed while streaming parse events',
context: { documentId: id },
normalize: {
code: 'DOCUMENTS_PARSED_EVENTS_WORKER_PROXY_CRASHED',
errorClass: 'upstream',
},
});
if (!closed) {
controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({ error: String(error) })}\n\n`));
}
})
.finally(() => {
closeStream();
});
req.signal.addEventListener('abort', () => {
closeStream();
}, { once: true });
},
cancel() {
return;
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
},
});
} catch (error) {
return errorResponse(error, {
logger,
event: 'documents.parsed.events.route_failed',
msg: 'Parsed events route failed',
apiErrorMessage: 'Failed to stream parsed PDF progress',
normalize: { code: 'DOCUMENTS_PARSED_EVENTS_ROUTE_FAILED', errorClass: 'upstream' },
});
}
}