refactor(compute): modularize core contracts and runtime, enable flexible backend resolution

Restructure compute core by extracting job contracts and runtime logic into separate
modules (`contracts.ts`, `local-runtime.ts`) for improved modularity and clearer
API boundaries. Update exports and imports across worker, server, and local
compute backends to use the new modules. Enhance Next.js config to support
dynamic backend selection via build-time constants and aliasing, enabling
seamless switching between local and worker compute modes. Adjust TypeScript
paths and Dockerfile entrypoint to align with the new structure.
This commit is contained in:
Richard R 2026-05-20 15:07:22 -06:00
parent 8310091816
commit 1bd13c02fe
15 changed files with 178 additions and 111 deletions

View file

@ -12,6 +12,8 @@
"pdfjs-dist": "4.8.69"
},
"exports": {
".": "./src/index.ts"
".": "./src/index.ts",
"./contracts": "./src/contracts.ts",
"./local-runtime": "./src/local-runtime.ts"
}
}

View file

@ -0,0 +1,62 @@
import type { TTSSentenceAlignment } from './types/tts';
import type { ParsedPdfDocument } from './types/parsed-pdf';
export type {
TTSAudioBuffer,
TTSAudioBytes,
TTSSentenceAlignment,
TTSSentenceWord,
} from './types/tts';
export type {
ParsedPdfBlockKind,
ParsedPdfBlockFragment,
ParsedPdfBlock,
ParsedPdfPage,
ParsedPdfDocument,
} from './types/parsed-pdf';
export const ALIGN_QUEUE_NAME = 'whisper-align';
export const PDF_LAYOUT_QUEUE_NAME = 'pdf-layout';
export interface WhisperAlignJobRequest {
text: string;
lang?: string;
cacheKey?: string;
audioObjectKey: string;
}
export interface WhisperAlignJobResult {
alignments: TTSSentenceAlignment[];
timing?: WorkerJobTiming;
}
export interface PdfLayoutJobRequest {
documentId: string;
namespace: string | null;
documentObjectKey: string;
}
export interface PdfLayoutJobResult {
parsed: ParsedPdfDocument;
timing?: WorkerJobTiming;
}
export type WorkerJobState = 'queued' | 'running' | 'succeeded' | 'failed';
export interface WorkerJobErrorShape {
message: string;
code?: string;
}
export interface WorkerJobTiming {
queueWaitMs?: number;
s3FetchMs?: number;
computeMs?: number;
}
export interface WorkerJobStatusResponse<Result> {
status: WorkerJobState;
result?: Result;
error?: WorkerJobErrorShape;
timing?: WorkerJobTiming;
}

View file

@ -1,96 +1,6 @@
import type { TTSSentenceAlignment } from './types/tts';
import type { ParsedPdfDocument } from './types/parsed-pdf';
import { ensureWhisperModel } from './whisper/ensureModel';
import { alignAudioWithText } from './whisper/alignment';
import { ensureModel as ensurePdfLayoutModel } from './pdf-layout/ensureModel';
import { parsePdf } from './pdf-layout/parsePdf';
export type {
TTSAudioBuffer,
TTSAudioBytes,
TTSSentenceAlignment,
TTSSentenceWord,
} from './types/tts';
export type {
ParsedPdfBlockKind,
ParsedPdfBlockFragment,
ParsedPdfBlock,
ParsedPdfPage,
ParsedPdfDocument,
} from './types/parsed-pdf';
export const ALIGN_QUEUE_NAME = 'whisper-align';
export const PDF_LAYOUT_QUEUE_NAME = 'pdf-layout';
export interface WhisperAlignJobRequest {
text: string;
lang?: string;
cacheKey?: string;
audioObjectKey: string;
}
export interface WhisperAlignJobResult {
alignments: TTSSentenceAlignment[];
timing?: WorkerJobTiming;
}
export interface PdfLayoutJobRequest {
documentId: string;
namespace: string | null;
documentObjectKey: string;
}
export interface PdfLayoutJobResult {
parsed: ParsedPdfDocument;
timing?: WorkerJobTiming;
}
export type WorkerJobState = 'queued' | 'running' | 'succeeded' | 'failed';
export interface WorkerJobErrorShape {
message: string;
code?: string;
}
export interface WorkerJobTiming {
queueWaitMs?: number;
s3FetchMs?: number;
computeMs?: number;
}
export interface WorkerJobStatusResponse<Result> {
status: WorkerJobState;
result?: Result;
error?: WorkerJobErrorShape;
timing?: WorkerJobTiming;
}
export async function ensureComputeModels(): Promise<void> {
await Promise.all([ensureWhisperModel(), ensurePdfLayoutModel()]);
}
export async function runWhisperAlignmentFromAudioBuffer(input: {
audioBuffer: ArrayBuffer;
text: string;
cacheKey?: string;
lang?: string;
}): Promise<WhisperAlignJobResult> {
const alignments = await alignAudioWithText(
input.audioBuffer,
input.text,
input.cacheKey,
{ lang: input.lang },
);
return { alignments };
}
export async function runPdfLayoutFromPdfBuffer(input: {
documentId: string;
pdfBytes: ArrayBuffer;
}): Promise<PdfLayoutJobResult> {
const parsed = await parsePdf({
documentId: input.documentId,
pdfBytes: input.pdfBytes,
});
return { parsed };
}
export * from './contracts';
export {
ensureComputeModels,
runPdfLayoutFromPdfBuffer,
runWhisperAlignmentFromAudioBuffer,
} from './local-runtime';

View file

@ -0,0 +1,34 @@
import { ensureWhisperModel } from './whisper/ensureModel';
import { alignAudioWithText } from './whisper/alignment';
import { ensureModel as ensurePdfLayoutModel } from './pdf-layout/ensureModel';
import { parsePdf } from './pdf-layout/parsePdf';
export async function ensureComputeModels(): Promise<void> {
await Promise.all([ensureWhisperModel(), ensurePdfLayoutModel()]);
}
export async function runWhisperAlignmentFromAudioBuffer(input: {
audioBuffer: ArrayBuffer;
text: string;
cacheKey?: string;
lang?: string;
}) {
const alignments = await alignAudioWithText(
input.audioBuffer,
input.text,
input.cacheKey,
{ lang: input.lang },
);
return { alignments };
}
export async function runPdfLayoutFromPdfBuffer(input: {
documentId: string;
pdfBytes: ArrayBuffer;
}) {
const parsed = await parsePdf({
documentId: input.documentId,
pdfBytes: input.pdfBytes,
});
return { parsed };
}

View file

@ -26,4 +26,4 @@ ENV COMPUTE_WORKER_PORT=8081
EXPOSE 8081
CMD ["pnpm", "dev"]
CMD ["node", "--import", "tsx", "src/server.ts"]

View file

@ -16,9 +16,7 @@
"fastify": "^5.6.2",
"pino": "^10.3.1",
"pino-pretty": "^13.1.2",
"tsx": "^4.22.3",
"zod": "^4.1.12"
},
"devDependencies": {
"tsx": "^4.22.3"
}
}

View file

@ -24,13 +24,15 @@ import {
ensureComputeModels,
runPdfLayoutFromPdfBuffer,
runWhisperAlignmentFromAudioBuffer,
} from '@openreader/compute-core/local-runtime';
import {
type PdfLayoutJobRequest,
type PdfLayoutJobResult,
type WhisperAlignJobRequest,
type WhisperAlignJobResult,
type WorkerJobStatusResponse,
type WorkerJobTiming,
} from '@openreader/compute-core';
} from '@openreader/compute-core/contracts';
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
const JOBS_STREAM_NAME = 'compute_jobs';

View file

@ -1,4 +1,5 @@
import type { NextConfig } from "next";
import path from "node:path";
const securityHeaders = [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
@ -43,7 +44,7 @@ const nextConfig: NextConfig = {
canvas: '@napi-rs/canvas',
},
},
transpilePackages: ['@openreader/compute-core'],
transpilePackages: computeLocal ? ['@openreader/compute-core'] : [],
serverExternalPackages,
outputFileTracingIncludes: {
'/api/audiobook': [
@ -75,6 +76,36 @@ const nextConfig: NextConfig = {
},
}
: {}),
webpack: (config, { isServer }) => {
if (isServer) {
// Use runtime require to avoid adding an explicit webpack TS dependency.
const { DefinePlugin } = require('webpack') as { DefinePlugin: new (defs: Record<string, string>) => unknown };
config.plugins = config.plugins || [];
config.plugins.push(
new DefinePlugin({
__OPENREADER_COMPUTE_MODE__: JSON.stringify(computeMode),
}),
);
}
if (isServer && !computeLocal) {
const workerComputeEntry = path.resolve(__dirname, 'src/lib/server/compute/index.worker.ts');
const computeIndexTs = path.resolve(__dirname, 'src/lib/server/compute/index.ts');
const computeIndexNoExt = path.resolve(__dirname, 'src/lib/server/compute/index');
const computeDir = path.resolve(__dirname, 'src/lib/server/compute');
config.resolve.alias = {
...(config.resolve.alias || {}),
'@/lib/server/compute$': workerComputeEntry,
'@/lib/server/compute/index$': workerComputeEntry,
[`${computeIndexTs}$`]: workerComputeEntry,
[`${computeIndexNoExt}$`]: workerComputeEntry,
[`${computeDir}$`]: workerComputeEntry,
'@openreader/compute-core/local-runtime$': false,
'onnxruntime-node$': false,
'@huggingface/tokenizers$': false,
};
}
return config;
},
};
export default nextConfig;

View file

@ -233,13 +233,12 @@ importers:
pino-pretty:
specifier: ^13.1.2
version: 13.1.3
zod:
specifier: ^4.1.12
version: 4.4.3
devDependencies:
tsx:
specifier: ^4.22.3
version: 4.22.3
zod:
specifier: ^4.1.12
version: 4.4.3
packages:

View file

@ -2,9 +2,16 @@ import type { ComputeBackend, ComputeMode } from '@/lib/server/compute/types';
import { isComputeModeAvailable, readComputeMode } from '@/lib/server/compute/mode';
import { WorkerComputeBackend } from '@/lib/server/compute/worker';
declare const __OPENREADER_COMPUTE_MODE__: 'local' | 'worker' | 'none';
let backendPromise: Promise<ComputeBackend> | null = null;
async function createBackend(): Promise<ComputeBackend> {
if (__OPENREADER_COMPUTE_MODE__ === 'worker') return new WorkerComputeBackend();
if (__OPENREADER_COMPUTE_MODE__ === 'local') {
const { LocalComputeBackend } = await import('@/lib/server/compute/local');
return new LocalComputeBackend();
}
const mode: ComputeMode = readComputeMode();
if (mode === 'worker') return new WorkerComputeBackend();
const { LocalComputeBackend } = await import('@/lib/server/compute/local');

View file

@ -0,0 +1,20 @@
import type { ComputeBackend } from '@/lib/server/compute/types';
import { WorkerComputeBackend } from '@/lib/server/compute/worker';
let backendPromise: Promise<ComputeBackend> | null = null;
export async function getCompute(): Promise<ComputeBackend> {
if (!backendPromise) {
backendPromise = Promise.resolve()
.then(() => new WorkerComputeBackend())
.catch((error) => {
backendPromise = null;
throw error;
});
}
return backendPromise;
}
export function isComputeAvailable(): boolean {
return true;
}

View file

@ -4,7 +4,7 @@ import { getTtsSegmentAudioObject } from '@/lib/server/tts/segments-blobstore';
import {
runPdfLayoutFromPdfBuffer,
runWhisperAlignmentFromAudioBuffer,
} from '@openreader/compute-core';
} from '@openreader/compute-core/local-runtime';
export class LocalComputeBackend implements ComputeBackend {
readonly mode = 'local' as const;

View file

@ -1,4 +1,4 @@
import type { TTSAudioBuffer, TTSSentenceAlignment, ParsedPdfDocument } from '@openreader/compute-core';
import type { TTSAudioBuffer, TTSSentenceAlignment, ParsedPdfDocument } from '@openreader/compute-core/contracts';
export type ComputeMode = 'local' | 'worker';

View file

@ -9,4 +9,4 @@ export {
type WorkerJobState,
type WorkerJobTiming,
type WorkerJobStatusResponse,
} from '@openreader/compute-core';
} from '@openreader/compute-core/contracts';

View file

@ -20,7 +20,9 @@
],
"paths": {
"@/*": ["./src/*"],
"@openreader/compute-core": ["./compute/core/src/index.ts"]
"@openreader/compute-core": ["./compute/core/src/index.ts"],
"@openreader/compute-core/contracts": ["./compute/core/src/contracts.ts"],
"@openreader/compute-core/local-runtime": ["./compute/core/src/local-runtime.ts"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],