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:
parent
8310091816
commit
1bd13c02fe
15 changed files with 178 additions and 111 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
62
compute/core/src/contracts.ts
Normal file
62
compute/core/src/contracts.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
34
compute/core/src/local-runtime.ts
Normal file
34
compute/core/src/local-runtime.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -26,4 +26,4 @@ ENV COMPUTE_WORKER_PORT=8081
|
|||
|
||||
EXPOSE 8081
|
||||
|
||||
CMD ["pnpm", "dev"]
|
||||
CMD ["node", "--import", "tsx", "src/server.ts"]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
20
src/lib/server/compute/index.worker.ts
Normal file
20
src/lib/server/compute/index.worker.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -9,4 +9,4 @@ export {
|
|||
type WorkerJobState,
|
||||
type WorkerJobTiming,
|
||||
type WorkerJobStatusResponse,
|
||||
} from '@openreader/compute-core';
|
||||
} from '@openreader/compute-core/contracts';
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
Loading…
Reference in a new issue