Merge pull request #122 from richardr1126/refactor/compute-worker

refactor: modularize monorepo packages, decouple compute-worker, and isolate ML dependencies
This commit is contained in:
Richard R 2026-06-13 15:14:34 -06:00 committed by GitHub
commit 9ee77408fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
262 changed files with 7549 additions and 3998 deletions

View file

@ -1,5 +1,6 @@
.env
.env.*
**/*.creds
README.md
.next
node_modules

View file

@ -62,13 +62,13 @@ jobs:
platform: linux/amd64
runner: ubuntu-24.04
context: .
dockerfile: ./compute/worker/Dockerfile
dockerfile: ./packages/compute-worker/Dockerfile
- image_target: compute-worker
arch: arm64
platform: linux/arm64
runner: ubuntu-24.04-arm
context: .
dockerfile: ./compute/worker/Dockerfile
dockerfile: ./packages/compute-worker/Dockerfile
steps:
- name: Checkout repository

View file

@ -25,3 +25,5 @@ jobs:
run: pnpm migrate
- name: Run Vitest suites
run: pnpm test:unit
- name: Verify compute worker contract and boundary
run: pnpm compute:openapi:check && pnpm check:compute-boundary

4
.gitignore vendored
View file

@ -56,5 +56,7 @@ node_modules/
# vscode
.vscode
# .agents
# Agents
.agents
.codex
.claude

View file

@ -20,9 +20,9 @@ WORKDIR /app
# Copy workspace manifests needed for dependency installation
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY compute/core/package.json ./compute/core/package.json
COPY compute/worker/package.json ./compute/worker/package.json
COPY docker/entrypoint-migration-tools/package.json ./docker/entrypoint-migration-tools/package.json
COPY packages/bootstrap/package.json ./packages/bootstrap/package.json
COPY packages/compute-worker/package.json ./packages/compute-worker/package.json
COPY packages/database/package.json ./packages/database/package.json
# Install dependencies
RUN pnpm install --frozen-lockfile
@ -33,8 +33,9 @@ COPY . .
# Build the Next.js application
RUN pnpm exec next telemetry disable
RUN AUTH_SECRET=build-placeholder-secret-value-32chars!! BASE_URL=http://localhost:3003 pnpm build
RUN pnpm --config.inject-workspace-packages=true --filter @openreader/entrypoint-migration-tools deploy /opt/entrypoint-migration-tools
RUN pnpm --config.inject-workspace-packages=true --filter @openreader/compute-worker deploy /opt/embedded-compute-worker
RUN pnpm --config.inject-workspace-packages=true --filter @openreader/bootstrap deploy /opt/openreader/bootstrap
RUN pnpm --dir /opt/openreader/bootstrap rebuild better-sqlite3 ffmpeg-static
RUN pnpm --config.inject-workspace-packages=true --filter @openreader/compute-worker deploy /opt/openreader/embedded-compute-worker
# Generate third-party dependency license report plus copied license files.
RUN mkdir -p /app/THIRD_PARTY_LICENSES && \
pnpm dlx license-checker-rseidelsohn@4.3.0 \
@ -63,32 +64,15 @@ COPY --from=app-builder /app/.next/standalone ./
COPY --from=app-builder /app/.next/static ./.next/static
COPY --from=app-builder /app/public ./public
# Copy the entrypoint and migration/runtime helper files it invokes directly.
COPY --from=app-builder /app/scripts/openreader-entrypoint.mjs ./scripts/openreader-entrypoint.mjs
COPY --from=app-builder /app/scripts/migrate-fs-v2.mjs ./scripts/migrate-fs-v2.mjs
COPY --from=app-builder /app/drizzle ./drizzle
COPY --from=app-builder /app/drizzle.config.pg.ts ./drizzle.config.pg.ts
COPY --from=app-builder /app/drizzle.config.sqlite.ts ./drizzle.config.sqlite.ts
COPY --from=app-builder /app/src/db ./src/db
# Merge in the dependency subset needed by the entrypoint migration scripts.
COPY --from=app-builder /opt/entrypoint-migration-tools/node_modules /tmp/runtime-tools-node_modules
RUN mkdir -p /app/node_modules && \
rm -rf /tmp/runtime-tools-node_modules/@aws-sdk \
/tmp/runtime-tools-node_modules/better-sqlite3 \
/tmp/runtime-tools-node_modules/ffmpeg-static \
/tmp/runtime-tools-node_modules/pg && \
cp -an /tmp/runtime-tools-node_modules/. /app/node_modules/ && \
rm -rf /tmp/runtime-tools-node_modules
# Ship the embedded compute worker as a separate deployed bundle.
COPY --from=app-builder /opt/embedded-compute-worker ./embedded-compute-worker
# Ship startup orchestration and the embedded worker as independent deployed bundles.
COPY --from=app-builder /opt/openreader/bootstrap /opt/openreader/bootstrap
COPY --from=app-builder /opt/openreader/embedded-compute-worker /opt/openreader/embedded-compute-worker
# Include third-party license report and copied license texts at a stable path in the image.
COPY --from=app-builder /app/THIRD_PARTY_LICENSES /licenses
# Include SeaweedFS license text for the copied weed binary.
COPY --from=seaweedfs-builder /tmp/SeaweedFS-LICENSE.txt /licenses/SeaweedFS-LICENSE.txt
# Include static model notices for runtime-downloaded assets.
COPY --from=app-builder /app/compute/core/src/pdf/assets/LICENSE.txt /licenses/pp-doclayoutv3-LICENSE.txt
COPY --from=app-builder /app/packages/compute-worker/src/inference/pdf/assets/LICENSE.txt /licenses/pp-doclayoutv3-LICENSE.txt
# Copy seaweedfs weed binary for optional embedded local S3.
COPY --from=seaweedfs-builder /tmp/weed /usr/local/bin/weed
@ -98,7 +82,7 @@ COPY --from=nats-builder /tmp/nats-server /usr/local/bin/nats-server
RUN chmod +x /usr/local/bin/nats-server
# Include OpenAI Whisper license text for runtime-downloaded ONNX artifacts.
COPY --from=app-builder /app/compute/core/src/whisper/assets/LICENSE.txt /licenses/openai-whisper-LICENSE.txt
COPY --from=app-builder /app/packages/compute-worker/src/inference/whisper/assets/LICENSE.txt /licenses/openai-whisper-LICENSE.txt
# Match the app's historical container port now that standalone server.js
# is started directly instead of `next start -p 3003`.
@ -108,5 +92,5 @@ ENV PORT=3003
EXPOSE 3003
# Start the application
ENTRYPOINT ["node", "scripts/openreader-entrypoint.mjs", "--"]
ENTRYPOINT ["node", "/opt/openreader/bootstrap/src/cli.mjs", "--"]
CMD ["node", "server.js"]

View file

@ -1,21 +0,0 @@
{
"name": "@openreader/compute-core",
"version": "0.0.0",
"private": true,
"type": "module",
"dependencies": {
"@huggingface/tokenizers": "^0.1.3",
"@napi-rs/canvas": "^0.1.100",
"ffmpeg-static": "^5.3.0",
"jszip": "^3.10.1",
"onnxruntime-node": "^1.26.0",
"pdfjs-dist": "4.8.69"
},
"exports": {
".": "./src/index.ts",
"./local-runtime": "./src/local-runtime.ts",
"./api-contracts": "./src/api-contracts/index.ts",
"./control-plane": "./src/control-plane/index.ts",
"./types": "./src/types/index.ts"
}
}

View file

@ -1,28 +0,0 @@
import os from 'node:os';
function readPositiveInt(name: string, fallback: number): number {
const raw = process.env[name]?.trim();
if (!raw) return fallback;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
return Math.floor(parsed);
}
export function getComputeJobConcurrency(): number {
return readPositiveInt('COMPUTE_JOB_CONCURRENCY', 1);
}
export function getAvailableCpuCores(): number {
if (typeof os.availableParallelism === 'function') {
const value = os.availableParallelism();
if (Number.isFinite(value) && value >= 1) return Math.floor(value);
}
const fallback = os.cpus().length;
return Number.isFinite(fallback) && fallback >= 1 ? Math.floor(fallback) : 1;
}
export function getOnnxThreadsPerJob(): number {
const concurrency = getComputeJobConcurrency();
const usableCores = Math.max(1, getAvailableCpuCores() - 1);
return Math.max(1, Math.floor(usableCores / concurrency));
}

View file

@ -1,26 +0,0 @@
export * from './api-contracts';
export {
getComputeJobConcurrency,
getAvailableCpuCores,
getOnnxThreadsPerJob,
} from './config/cpu-budget';
export {
getComputeTimeoutConfig,
getComputeOpStaleMs,
getWorkerClientWaitTimeoutMs,
withTimeout,
withIdleTimeoutAndHardCap,
type ComputeTimeoutConfig,
type ComputeOperationKind,
type IdleTimeoutAndHardCapInput,
} from './config/timeout';
export { renderPage } from './pdf/render';
export { mergeTextWithRegions } from './pdf/merge';
export { PDF_PARSER_VERSION } from './pdf/parser-version';
export { encodeParserVersion } from './pdf/parser-version-key';
export { stitchCrossPageBlocks } from './pdf/stitch';
export { normalizeTextItemsForLayout } from './pdf/normalize-text';
export { mapWordsToSentenceOffsets, type WhisperWord } from './whisper/alignment-map';
export { buildGoertzelCoefficients, goertzelPower } from './whisper/spectral';
export { buildWordsFromTimestampedTokens, extractTokenStartTimestamps } from './whisper/token-timestamps';
export * from './control-plane';

View file

@ -1,9 +0,0 @@
import { PDF_PARSER_VERSION } from './parser-version';
export function encodeParserVersion(
parserVersion: string,
defaultVersion = PDF_PARSER_VERSION,
): string {
const normalized = parserVersion.trim() || defaultVersion;
return encodeURIComponent(normalized);
}

View file

@ -1 +0,0 @@
export const PDF_PARSER_VERSION = 'pp-doclayoutv3-onnx@800+pdfjs@4.8.69';

View file

@ -1,15 +0,0 @@
import type { ParsedPdfBlockKind } from '../types/parsed-pdf';
export interface PdfTextItem {
text: string;
x: number;
y: number;
width: number;
height: number;
}
export interface LayoutRegion {
bbox: [number, number, number, number];
label: ParsedPdfBlockKind;
confidence?: number;
}

View file

@ -1,21 +0,0 @@
import fs from 'fs';
import path from 'path';
function findMonorepoRoot(startDir: string): string | null {
let current = path.resolve(startDir);
for (;;) {
const marker = path.join(current, 'pnpm-workspace.yaml');
if (fs.existsSync(marker)) return current;
const parent = path.dirname(current);
if (parent === current) return null;
current = parent;
}
}
function resolveDocstoreDir(): string {
const repoRoot = findMonorepoRoot(process.cwd());
if (repoRoot) return path.join(repoRoot, 'docstore');
return path.join(process.cwd(), 'docstore');
}
export const DOCSTORE_DIR = resolveDocstoreDir();

View file

@ -1,36 +0,0 @@
import { existsSync } from 'fs';
import ffmpegStatic from 'ffmpeg-static';
function normalizePath(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function resolveBinary(envValue: string | null, bundledValue: string | null, envVarName: string, packageName: string): string {
if (envValue) {
if ((envValue.includes('/') || envValue.includes('\\')) && !existsSync(envValue)) {
throw new Error(`${envVarName} points to a missing binary: ${envValue}`);
}
return envValue;
}
if (!bundledValue) {
throw new Error(`${packageName} binary is unavailable on this platform. Set ${envVarName} to an installed binary path.`);
}
if ((bundledValue.includes('/') || bundledValue.includes('\\')) && !existsSync(bundledValue)) {
throw new Error(`${packageName} resolved to a missing binary path: ${bundledValue}`);
}
return bundledValue;
}
export function getFFmpegPath(): string {
return resolveBinary(
normalizePath(process.env.FFMPEG_BIN),
normalizePath(ffmpegStatic),
'FFMPEG_BIN',
'ffmpeg-static',
);
}

View file

@ -1,17 +0,0 @@
export type {
TTSAudioBuffer,
TTSAudioBytes,
TTSSentenceAlignment,
TTSSentenceWord,
} from './tts';
export type {
ParsedPdfBlock,
ParsedPdfBlockFragment,
ParsedPdfBlockKind,
ParsedPdfDocument,
ParsedPdfPage,
PdfParsePhase,
PdfParseProgress,
PdfParseStatus,
} from './parsed-pdf';

View file

@ -1,16 +0,0 @@
export type TTSAudioBuffer = ArrayBuffer;
export type TTSAudioBytes = number[];
export interface TTSSentenceWord {
text: string;
startSec: number;
endSec: number;
charStart: number;
charEnd: number;
}
export interface TTSSentenceAlignment {
sentence: string;
sentenceIndex: number;
words: TTSSentenceWord[];
}

View file

@ -1,62 +0,0 @@
import type { TTSSentenceAlignment, TTSSentenceWord } from '../types/tts';
// Worker-side mirror of the app's canonical audio-text cleaning rules in
// `src/lib/shared/audio-text.ts`. This is a separate build target and cannot
// import from `@/lib`, so the rules are duplicated here on purpose. The word
// `charStart`/`charEnd` offsets this module emits are consumed against text
// normalized by that shared module, so any divergence shifts viewer highlights
// off-word — keep this byte-for-byte in sync with `audio-text.ts`.
const STRIPPED_GLYPHS = /[*•◦‣⁃∙▪▫■□●○◆◇★☆▶▸►▹➤➢❖]/g;
function preprocessSentenceForAudio(text: string): string {
return text
.replace(/\S*(?:https?:\/\/|www\.)([^\/\s]+)(?:\/\S*)?/gi, '- (link to $1) -')
.replace(/([\p{L}\p{N}\p{M}]+)-\s+([\p{L}\p{N}\p{M}]+)/gu, '$1$2')
.replace(STRIPPED_GLYPHS, '')
.replace(/\s+/g, ' ')
.trim();
}
export interface WhisperWord {
start: number;
end: number;
word: string;
}
export function mapWordsToSentenceOffsets(sentence: string, words: WhisperWord[]): TTSSentenceAlignment {
const normalizedSentence = preprocessSentenceForAudio(sentence);
const lowerSentence = normalizedSentence.toLowerCase();
let cursor = 0;
const alignedWords: TTSSentenceWord[] = words.map((w) => {
const token = w.word.trim();
if (!token) {
return {
text: '',
startSec: w.start,
endSec: w.end,
charStart: cursor,
charEnd: cursor,
};
}
const idx = lowerSentence.indexOf(token.toLowerCase(), cursor);
const start = idx >= 0 ? idx : cursor;
const end = Math.min(normalizedSentence.length, start + token.length);
cursor = Math.max(cursor, end);
return {
text: token,
startSec: w.start,
endSec: w.end,
charStart: start,
charEnd: end,
};
}).filter((word) => word.text.length > 0);
return {
sentence,
sentenceIndex: 0,
words: alignedWords,
};
}

View file

@ -1,21 +0,0 @@
export function buildGoertzelCoefficients(freqBins: number, fftSize: number): Float64Array {
const coeffs = new Float64Array(freqBins);
for (let k = 0; k < freqBins; k += 1) {
coeffs[k] = 2 * Math.cos((2 * Math.PI * k) / fftSize);
}
return coeffs;
}
export function goertzelPower(samples: Float32Array, coeff: number): number {
let s1 = 0;
let s2 = 0;
for (let i = 0; i < samples.length; i += 1) {
const s0 = samples[i] + (coeff * s1) - s2;
s2 = s1;
s1 = s0;
}
const power = (s1 * s1) + (s2 * s2) - (coeff * s1 * s2);
if (!Number.isFinite(power) || power < 0) return 0;
return power;
}

View file

@ -1,7 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"include": ["src/**/*.ts"]
}

View file

@ -1,47 +0,0 @@
services:
nats:
image: nats:2.14-alpine
container_name: openreader-compute-nats
command: ["-js", "-sd", "/data"]
ports:
- "4222:4222"
- "8222:8222"
volumes:
- nats-data:/data
compute-worker:
build:
context: ../..
dockerfile: compute/worker/Dockerfile
container_name: openreader-compute-worker
depends_on:
- nats
env_file:
- ./.env
environment:
NATS_URL: ${NATS_URL:-nats://nats:4222}
COMPUTE_WORKER_HOST: ${COMPUTE_WORKER_HOST:-0.0.0.0}
PORT: ${PORT:-8081}
COMPUTE_WORKER_TOKEN: ${COMPUTE_WORKER_TOKEN:-local-compute-token}
S3_PREFIX: ${S3_PREFIX:-openreader}
COMPUTE_PREWARM_MODELS: ${COMPUTE_PREWARM_MODELS:-false}
ports:
- "8081:8081"
develop:
watch:
- action: sync+restart
path: .
target: /workspace
- action: rebuild
path: ../../compute/core
- action: rebuild
path: ./package.json
- action: rebuild
path: ../../compute/core/package.json
- action: rebuild
path: ../../pnpm-lock.yaml
- action: rebuild
path: ../../pnpm-workspace.yaml
volumes:
nats-data:

File diff suppressed because it is too large Load diff

View file

@ -1,14 +0,0 @@
{
"name": "@openreader/entrypoint-migration-tools",
"version": "0.0.0",
"private": true,
"dependencies": {
"@aws-sdk/client-s3": "^3.1061.0",
"better-sqlite3": "^12.10.0",
"dotenv": "^17.4.2",
"drizzle-kit": "^0.31.10",
"drizzle-orm": "^0.45.2",
"ffmpeg-static": "^5.3.0",
"pg": "^8.21.0"
}
}

View file

@ -0,0 +1,124 @@
name: openreader-full
services:
openreader:
image: ghcr.io/richardr1126/openreader:latest
# Keep localhost:8333 valid for both app requests and browser-facing presigned URLs.
network_mode: service:seaweedfs
depends_on:
kokoro-tts:
condition: service_started
postgres:
condition: service_healthy
seaweedfs:
condition: service_healthy
environment:
BASE_URL: ${BASE_URL:-http://localhost:3003}
AUTH_SECRET: ${AUTH_SECRET:-local-openreader-auth-secret-change-me}
POSTGRES_URL: postgres://openreader:openreader@postgres:5432/openreader
COMPUTE_WORKER_URL: http://compute-worker:8081
COMPUTE_WORKER_TOKEN: ${COMPUTE_WORKER_TOKEN:-local-compute-token}
USE_EMBEDDED_WEED_MINI: "false"
S3_ENDPOINT: ${S3_ENDPOINT:-http://localhost:8333} # Used for internal endpoint and public facing presigned URLs.
S3_BUCKET: ${S3_BUCKET:-openreader-documents}
S3_REGION: ${S3_REGION:-us-east-1}
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-devkey}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-devsecret}
S3_FORCE_PATH_STYLE: "true"
S3_PREFIX: ${S3_PREFIX:-openreader}
RUNTIME_SEED_JSON: |-
{
"version": 1,
"runtimeConfig": {
"defaultTtsProvider": "kokoro"
},
"providers": [
{
"slug": "kokoro",
"displayName": "Kokoro",
"providerType": "custom-openai",
"baseUrl": "http://kokoro-tts:8880/v1",
"defaultModel": "kokoro",
"enabled": true
}
]
}
volumes:
- openreader-docstore:/app/docstore
kokoro-tts:
image: ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.4
environment:
ONNX_NUM_THREADS: 8
ONNX_INTER_OP_THREADS: 4
ONNX_EXECUTION_MODE: parallel
ONNX_OPTIMIZATION_LEVEL: all
ONNX_MEMORY_PATTERN: "true"
ONNX_ARENA_EXTEND_STRATEGY: kNextPowerOfTwo
API_LOG_LEVEL: DEBUG
ports:
- "8880:8880"
seaweedfs:
image: chrislusf/seaweedfs:4.18
command: ["mini", "-dir=/data"]
environment:
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-devkey}
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-devsecret}
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status >/dev/null"]
interval: 2s
timeout: 2s
retries: 30
ports:
- "3003:3003"
- "8333:8333"
volumes:
- seaweedfs-data:/data
nats:
image: nats:2.14-alpine
command: ["-js", "-sd", "/data"]
volumes:
- nats-data:/data
postgres:
image: postgres:17-alpine
environment:
POSTGRES_USER: openreader
POSTGRES_PASSWORD: openreader
POSTGRES_DB: openreader
healthcheck:
test: ["CMD-SHELL", "pg_isready -U openreader -d openreader"]
interval: 2s
timeout: 2s
retries: 30
volumes:
- postgres-data:/var/lib/postgresql/data
compute-worker:
image: ghcr.io/richardr1126/openreader-compute-worker:latest
depends_on:
nats:
condition: service_started
seaweedfs:
condition: service_healthy
environment:
NATS_URL: nats://nats:4222
COMPUTE_WORKER_HOST: 0.0.0.0
PORT: 8081
COMPUTE_WORKER_TOKEN: ${COMPUTE_WORKER_TOKEN:-local-compute-token}
S3_ENDPOINT: http://seaweedfs:8333
S3_BUCKET: ${S3_BUCKET:-openreader-documents}
S3_REGION: ${S3_REGION:-us-east-1}
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-devkey}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-devsecret}
S3_FORCE_PATH_STYLE: "true"
S3_PREFIX: ${S3_PREFIX:-openreader}
COMPUTE_PREWARM_MODELS: ${COMPUTE_PREWARM_MODELS:-false}
volumes:
openreader-docstore:
seaweedfs-data:
nats-data:
postgres-data:

View file

@ -0,0 +1,130 @@
name: openreader-local-full-build
services:
openreader:
image: openreader:local
build:
context: ../..
dockerfile: Dockerfile
# Keep localhost:8333 valid for both app requests and browser-facing presigned URLs.
network_mode: service:seaweedfs
depends_on:
kokoro-tts:
condition: service_started
postgres:
condition: service_healthy
seaweedfs:
condition: service_healthy
environment:
BASE_URL: ${BASE_URL:-http://localhost:3003}
AUTH_SECRET: ${AUTH_SECRET:-local-openreader-auth-secret-change-me}
POSTGRES_URL: postgres://openreader:openreader@postgres:5432/openreader
COMPUTE_WORKER_URL: http://compute-worker:8081
COMPUTE_WORKER_TOKEN: ${COMPUTE_WORKER_TOKEN:-local-compute-token}
USE_EMBEDDED_WEED_MINI: "false"
S3_ENDPOINT: ${S3_ENDPOINT:-http://localhost:8333} # Used for internal endpoint and public facing presigned URLs.
S3_BUCKET: ${S3_BUCKET:-openreader-documents}
S3_REGION: ${S3_REGION:-us-east-1}
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-devkey}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-devsecret}
S3_FORCE_PATH_STYLE: "true"
S3_PREFIX: ${S3_PREFIX:-openreader}
RUNTIME_SEED_JSON: |-
{
"version": 1,
"runtimeConfig": {
"defaultTtsProvider": "kokoro"
},
"providers": [
{
"slug": "kokoro",
"displayName": "Kokoro",
"providerType": "custom-openai",
"baseUrl": "http://kokoro-tts:8880/v1",
"defaultModel": "kokoro",
"enabled": true
}
]
}
volumes:
- openreader-docstore:/app/docstore
kokoro-tts:
image: ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.4
environment:
ONNX_NUM_THREADS: 8
ONNX_INTER_OP_THREADS: 4
ONNX_EXECUTION_MODE: parallel
ONNX_OPTIMIZATION_LEVEL: all
ONNX_MEMORY_PATTERN: "true"
ONNX_ARENA_EXTEND_STRATEGY: kNextPowerOfTwo
API_LOG_LEVEL: DEBUG
ports:
- "8880:8880"
seaweedfs:
image: chrislusf/seaweedfs:4.18
command: ["mini", "-dir=/data"]
environment:
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-devkey}
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-devsecret}
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status >/dev/null"]
interval: 2s
timeout: 2s
retries: 30
ports:
- "3003:3003"
- "8333:8333"
volumes:
- seaweedfs-data:/data
nats:
image: nats:2.14-alpine
command: ["-js", "-sd", "/data"]
volumes:
- nats-data:/data
postgres:
image: postgres:17-alpine
environment:
POSTGRES_USER: openreader
POSTGRES_PASSWORD: openreader
POSTGRES_DB: openreader
healthcheck:
test: ["CMD-SHELL", "pg_isready -U openreader -d openreader"]
interval: 2s
timeout: 2s
retries: 30
volumes:
- postgres-data:/var/lib/postgresql/data
compute-worker:
image: openreader-compute-worker:local
build:
context: ../..
dockerfile: packages/compute-worker/Dockerfile
depends_on:
nats:
condition: service_started
seaweedfs:
condition: service_healthy
environment:
NATS_URL: nats://nats:4222
COMPUTE_WORKER_HOST: 0.0.0.0
PORT: 8081
COMPUTE_WORKER_TOKEN: ${COMPUTE_WORKER_TOKEN:-local-compute-token}
S3_ENDPOINT: http://seaweedfs:8333
S3_BUCKET: ${S3_BUCKET:-openreader-documents}
S3_REGION: ${S3_REGION:-us-east-1}
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-devkey}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-devsecret}
S3_FORCE_PATH_STYLE: "true"
S3_PREFIX: ${S3_PREFIX:-openreader}
COMPUTE_PREWARM_MODELS: ${COMPUTE_PREWARM_MODELS:-false}
volumes:
openreader-docstore:
seaweedfs-data:
nats-data:
postgres-data:

View file

@ -0,0 +1,52 @@
name: openreader-local-slim-build
services:
openreader:
image: openreader:local
build:
context: ../..
dockerfile: Dockerfile
depends_on:
kokoro-tts:
condition: service_started
environment:
BASE_URL: ${BASE_URL:-http://localhost:3003}
AUTH_SECRET: ${AUTH_SECRET:-local-openreader-auth-secret-change-me}
RUNTIME_SEED_JSON: |-
{
"version": 1,
"runtimeConfig": {
"defaultTtsProvider": "kokoro"
},
"providers": [
{
"slug": "kokoro",
"displayName": "Kokoro",
"providerType": "custom-openai",
"baseUrl": "http://kokoro-tts:8880/v1",
"defaultModel": "kokoro",
"enabled": true
}
]
}
ports:
- "3003:3003"
- "8333:8333"
volumes:
- openreader-docstore:/app/docstore
kokoro-tts:
image: ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.4
environment:
ONNX_NUM_THREADS: 8
ONNX_INTER_OP_THREADS: 4
ONNX_EXECUTION_MODE: parallel
ONNX_OPTIMIZATION_LEVEL: all
ONNX_MEMORY_PATTERN: "true"
ONNX_ARENA_EXTEND_STRATEGY: kNextPowerOfTwo
API_LOG_LEVEL: DEBUG
ports:
- "8880:8880"
volumes:
openreader-docstore:

View file

@ -0,0 +1,49 @@
name: openreader-slim
services:
openreader:
image: ghcr.io/richardr1126/openreader:latest
depends_on:
kokoro-tts:
condition: service_started
environment:
BASE_URL: ${BASE_URL:-http://localhost:3003}
AUTH_SECRET: ${AUTH_SECRET:-local-openreader-auth-secret-change-me}
RUNTIME_SEED_JSON: |-
{
"version": 1,
"runtimeConfig": {
"defaultTtsProvider": "kokoro"
},
"providers": [
{
"slug": "kokoro",
"displayName": "Kokoro",
"providerType": "custom-openai",
"baseUrl": "http://kokoro-tts:8880/v1",
"defaultModel": "kokoro",
"enabled": true
}
]
}
ports:
- "3003:3003"
- "8333:8333"
volumes:
- openreader-docstore:/app/docstore
kokoro-tts:
image: ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.4
environment:
ONNX_NUM_THREADS: 8
ONNX_INTER_OP_THREADS: 4
ONNX_EXECUTION_MODE: parallel
ONNX_OPTIMIZATION_LEVEL: all
ONNX_MEMORY_PATTERN: "true"
ONNX_ARENA_EXTEND_STRATEGY: kNextPowerOfTwo
API_LOG_LEVEL: DEBUG
ports:
- "8880:8880"
volumes:
openreader-docstore:

View file

@ -7,6 +7,18 @@ import TabItem from '@theme/TabItem';
This page covers migration behavior for both database schema and storage data in OpenReader.
## Runtime ownership
- `@openreader/database` owns database clients, schemas, SQL migration files, and programmatic
migration execution for SQLite and PostgreSQL.
- `@openreader/bootstrap` owns startup orchestration, storage migration, and optional embedded
SeaweedFS, NATS, and compute-worker processes.
- The Next.js app imports `@openreader/database` directly, but does not orchestrate migrations or
child processes.
Docker deploys bootstrap as an isolated runtime bundle under `/opt/openreader/bootstrap`; it does
not merge migration dependencies into the standalone Next.js app under `/app`.
## Startup migration behavior
By default, the shared entrypoint runs migrations automatically before app startup in:
@ -53,11 +65,6 @@ In most cases, you do not need manual migration commands because startup runs mi
- Postgres when `POSTGRES_URL` is set
- SQLite when `POSTGRES_URL` is unset
You can always override the target explicitly with `--config`.
<Tabs groupId="apply-migration-commands">
<TabItem value="project-scripts" label="Project Scripts" default>
```bash
# Run pending migrations for one target:
# - Postgres if POSTGRES_URL is set
@ -71,26 +78,15 @@ pnpm migrate-fs
pnpm migrate-fs:dry-run
```
</TabItem>
<TabItem value="drizzle-direct" label="Manual Drizzle Cmd">
```bash
# Migrate SQLite
pnpm exec drizzle-kit migrate --config drizzle.config.sqlite.ts
# Migrate Postgres
pnpm exec drizzle-kit migrate --config drizzle.config.pg.ts
```
</TabItem>
</Tabs>
`pnpm migrate` uses the programmatic Drizzle migrator from `@openreader/database`. Drizzle Kit is
not a production or startup dependency; it is used only to generate new migration files.
## Generate migrations
`pnpm generate` is a two-phase script for contributors and schema changes:
1. **Better Auth schema generation** — runs the Better Auth CLI twice (once for SQLite, once for Postgres) to produce auto-generated Drizzle schema files for auth tables (`user`, `session`, `account`, `verification`).
2. **Drizzle migration generation** — runs `drizzle-kit generate` for both `drizzle.config.sqlite.ts` and `drizzle.config.pg.ts`, producing SQL migration files from all schema files (app + auth).
2. **Drizzle migration generation** — runs `drizzle-kit generate` for both configs in `packages/database`, producing SQL migration files from all schema files (app + auth).
:::note
Most users do not need to run `pnpm generate`. Use it when contributing or when you have changed Drizzle schema files and need new migration files.
@ -100,22 +96,23 @@ Most users do not need to run `pnpm generate`. Use it when contributing or when
Auth tables are owned by Better Auth. Their Drizzle schema definitions are auto-generated and should **not** be hand-edited:
- `src/db/schema_auth_sqlite.ts`
- `src/db/schema_auth_postgres.ts`
- `packages/database/src/schema_auth_sqlite.ts`
- `packages/database/src/schema_auth_postgres.ts`
App-specific tables are manually maintained in the standard Drizzle schema files:
- `src/db/schema_sqlite.ts`
- `src/db/schema_postgres.ts`
- `packages/database/src/schema_sqlite.ts`
- `packages/database/src/schema_postgres.ts`
Both sets of schema files are included in the Drizzle configs, so `drizzle-kit generate` and `drizzle-kit migrate` handle all tables together.
Both sets of schema files are included in the Drizzle generation configs. Runtime migration
execution is owned by `@openreader/database`.
When app schema changes (for example `tts_segments`), keep these in sync:
- `src/db/schema_sqlite.ts`
- `src/db/schema_postgres.ts`
- `drizzle/sqlite/*.sql` + `drizzle/sqlite/meta/_journal.json`
- `drizzle/postgres/*.sql` + `drizzle/postgres/meta/_journal.json`
- `packages/database/src/schema_sqlite.ts`
- `packages/database/src/schema_postgres.ts`
- `packages/database/migrations/sqlite/*.sql` + `packages/database/migrations/sqlite/meta/_journal.json`
- `packages/database/migrations/postgres/*.sql` + `packages/database/migrations/postgres/meta/_journal.json`
<Tabs groupId="generate-migration-commands">
<TabItem value="project-script" label="Project Script" default>
@ -130,10 +127,10 @@ pnpm generate
```bash
# Generate SQLite migrations only (skips Better Auth CLI)
pnpm exec drizzle-kit generate --config drizzle.config.sqlite.ts
pnpm exec drizzle-kit generate --config packages/database/drizzle.config.sqlite.ts
# Generate Postgres migrations only (skips Better Auth CLI)
pnpm exec drizzle-kit generate --config drizzle.config.pg.ts
pnpm exec drizzle-kit generate --config packages/database/drizzle.config.pg.ts
```
:::warning

View file

@ -11,7 +11,8 @@ Use this guide when OpenReader runs compute as a separate service. For the defau
- Runs PDF layout parsing jobs
- Stores durable job state in NATS JetStream and NATS KV
The app server submits work to `POST /ops` and listens for updates on `GET /ops/:opId/events`.
The app server submits resource-specific operations under `/v1` and listens for updates on
`GET /v1/operations/:opId/events`.
## When to use it
@ -37,7 +38,7 @@ S3_SECRET_ACCESS_KEY=...
```
:::important
`compute/worker/.env*` is only for standalone worker deployments.
`compute-worker/.env*` is only for standalone worker deployments.
- Embedded/local mode: configure the root `.env` only.
- External worker mode: set `COMPUTE_WORKER_URL` and `COMPUTE_WORKER_TOKEN` on the app, and worker runtime values on the worker service.

View file

@ -0,0 +1,175 @@
---
title: Docker Compose
description: Run OpenReader with the slim, full, local-slim, or local-full Docker Compose examples.
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
Use these examples to run OpenReader with Kokoro-FastAPI and persistent storage. Choose the slim
stack for the simplest deployment, or the full stack when you want PostgreSQL, SeaweedFS, NATS,
and the compute worker as separate containers. Local build variants are also available for both slim
and full stacks to build the application from your current checkout.
## Prerequisites
- A recent Docker version with Docker Compose
- A clone of the OpenReader repository
```bash
git clone https://github.com/richardr1126/openreader.git
cd openreader
```
## Choose a stack
<Tabs groupId="docker-compose-stack">
<TabItem value="slim" label="Slim" default>
The default slim example runs:
- OpenReader with embedded SeaweedFS, NATS, compute worker, and SQLite
- Kokoro-FastAPI as a companion container
```bash
docker compose -f docker/examples/compose.yml up
# Repository convenience command: pnpm compose
```
Compose file: [`docker/examples/compose.yml`](https://github.com/richardr1126/openreader/blob/main/docker/examples/compose.yml)
</TabItem>
<TabItem value="full" label="Full">
The full example runs OpenReader, Kokoro-FastAPI, PostgreSQL, SeaweedFS, NATS, and the compute
worker as separate containers using published images.
```bash
docker compose -f docker/examples/compose.full.yml up
# Repository convenience command: pnpm compose:full
```
Compose file: [`docker/examples/compose.full.yml`](https://github.com/richardr1126/openreader/blob/main/docker/examples/compose.full.yml)
For details about running the worker separately, see
[Compute Worker](./compute-worker).
</TabItem>
<TabItem value="local-slim" label="Local Slim">
The local-slim example runs a slim setup (OpenReader and Kokoro-FastAPI), but builds the OpenReader app image from the current checkout.
```bash
docker compose -f docker/examples/compose.local-slim.yml up --build
# Repository convenience command: pnpm compose:local
```
Compose file: [`docker/examples/compose.local-slim.yml`](https://github.com/richardr1126/openreader/blob/main/docker/examples/compose.local-slim.yml)
</TabItem>
<TabItem value="local-full" label="Local Full">
The local-full example uses the full multi-container layout, but builds the OpenReader app and compute-worker images from the current checkout.
```bash
docker compose -f docker/examples/compose.local-full.yml up --build
# Repository convenience command: pnpm compose:local:full
```
Compose file: [`docker/examples/compose.local-full.yml`](https://github.com/richardr1126/openreader/blob/main/docker/examples/compose.local-full.yml)
</TabItem>
</Tabs>
## Included services
| Service | Slim | Full | Local Slim | Local Full |
| --- | --- | --- | --- | --- |
| OpenReader | Published image | Published image | Local build | Local build |
| Kokoro-FastAPI | Container | Container | Container | Container |
| Database | Embedded SQLite | PostgreSQL container | Embedded SQLite | PostgreSQL container |
| SeaweedFS | Embedded | Container | Embedded | Container |
| NATS | Embedded | Container | Embedded | Container |
| Compute worker | Embedded | Published image | Embedded | Local build |
On first boot, `RUNTIME_SEED_JSON` creates an enabled Kokoro shared provider and selects it as the
default TTS provider.
## Endpoints
- OpenReader: `http://localhost:3003`
- SeaweedFS S3: `http://localhost:8333`
- Kokoro-FastAPI: `http://localhost:8880`
In the full examples, PostgreSQL, the compute worker, and NATS remain internal to the Compose
network.
## LAN access
Set `BASE_URL` and `S3_ENDPOINT` to the Docker host's LAN IP so browser-facing app and presigned
S3 URLs are reachable from other devices:
<Tabs groupId="docker-compose-lan-stack">
<TabItem value="slim" label="Slim" default>
```bash
BASE_URL=http://192.168.0.XXX:3003 \
S3_ENDPOINT=http://192.168.0.XXX:8333 \
docker compose -f docker/examples/compose.yml up
# Repository convenience command: pnpm compose
```
</TabItem>
<TabItem value="full" label="Full">
```bash
BASE_URL=http://192.168.0.XXX:3003 \
S3_ENDPOINT=http://192.168.0.XXX:8333 \
docker compose -f docker/examples/compose.full.yml up
# Repository convenience command: pnpm compose:full
```
</TabItem>
<TabItem value="local-slim" label="Local Slim">
```bash
BASE_URL=http://192.168.0.XXX:3003 \
S3_ENDPOINT=http://192.168.0.XXX:8333 \
docker compose -f docker/examples/compose.local-slim.yml up --build
# Repository convenience command: pnpm compose:local
```
</TabItem>
<TabItem value="local-full" label="Local Full">
```bash
BASE_URL=http://192.168.0.XXX:3003 \
S3_ENDPOINT=http://192.168.0.XXX:8333 \
docker compose -f docker/examples/compose.local-full.yml up --build
# Repository convenience command: pnpm compose:local:full
```
</TabItem>
</Tabs>
Replace `192.168.0.XXX` with your Docker host's LAN IP and allow inbound TCP ports `3003` and
`8333` through its firewall.
:::info Internal full-stack endpoint
The full and local-full compute workers continue using `http://seaweedfs:8333` internally.
`S3_ENDPOINT` configures the app endpoint and browser-facing presigned URLs.
:::
## Configuration
The examples use local-only default credentials. Override existing `${VARIABLE}` values through
your shell environment before using them beyond local development.
:::warning Protect public deployments
Replace the default `AUTH_SECRET`, PostgreSQL credentials, S3 credentials, and compute-worker
token before exposing a stack outside your trusted local network.
:::
For the complete configuration reference, see
[Environment Variables](../reference/environment-variables). See [Database](../configure/database)
for PostgreSQL and SQLite behavior.

View file

@ -159,45 +159,10 @@ If you need mirrors or pinned artifact locations, set `WHISPER_MODEL_BASE_URL` i
</details>
<details>
<summary><strong>External compute worker dev stack (optional)</strong></summary>
Use this only when you intentionally run compute-worker as a separate service.
Default local flow does not need `compute/worker/.env`; embedded worker startup reads root `.env`.
Full worker deployment details are in [Compute Worker (NATS JetStream)](./compute-worker).
Start only NATS + compute-worker via compose watch:
```bash
docker compose --env-file compute/worker/.env -f compute/worker/docker-compose.yml up --watch
# or: pnpm compute:dev:watch
```
`compute/worker/.env.example` contains a starter config for standalone worker service deployments.
Run the main app separately on the host:
```bash
pnpm dev
```
For app -> external worker routing, set in root `.env`:
```env
COMPUTE_WORKER_URL=http://localhost:8081
COMPUTE_WORKER_TOKEN=<same-token-used-by-worker>
```
Ownership in external worker mode:
- root `.env`: app routing/auth (`COMPUTE_WORKER_URL`, `COMPUTE_WORKER_TOKEN`) plus optional shared timeout/stale/retry overrides such as `COMPUTE_PDF_JOB_ATTEMPTS`
- `compute/worker/.env*` (or worker platform env): worker runtime variables (`NATS_*`, `S3_*`, model base URLs, worker tuning)
For embedded worker startup (`COMPUTE_WORKER_URL` unset), worker tuning values such as `COMPUTE_PDF_JOB_ATTEMPTS` must be set in the root `.env` because `compute/worker/.env*` is ignored in that mode.
Worker mode requires worker-reachable shared object storage (S3-compatible endpoint).
For external worker mode, object storage must be shared/reachable by both app and worker services.
</details>
:::tip Docker Compose
To run OpenReader and Kokoro-FastAPI with Docker Compose, including slim, full, and local-build
options, see [Docker Compose](./docker-compose).
:::
## Steps
@ -240,7 +205,7 @@ COMPUTE_WORKER_TOKEN=<same-token-used-by-worker>
Use the same ownership split:
- root `.env`: app routing/auth (`COMPUTE_WORKER_URL`, `COMPUTE_WORKER_TOKEN`) plus optional shared timeout/stale overrides
- `compute/worker/.env*` (or worker platform env): worker runtime variables (`NATS_*`, `S3_*`, model base URLs, worker tuning)
- `compute-worker/.env*` (or worker platform env): worker runtime variables (`NATS_*`, `S3_*`, model base URLs, worker tuning)
Use one of these `.env` mode templates:

View file

@ -106,7 +106,7 @@ For all variables and defaults, see [Environment Variables](../reference/environ
## 4. Database and data migrations
Vercel deployments do not run `scripts/openreader-entrypoint.mjs`, so automatic startup migrations do not run there.
Vercel deployments do not run the `@openreader/bootstrap` process, so automatic startup migrations do not run there.
- Run `pnpm migrate` in a controlled environment to apply Drizzle schema migrations to your Postgres DB.
- Run `pnpm migrate-fs` only when migrating legacy local filesystem data (`docstore/documents_v1`, `docstore/audiobooks_v1`) into object storage + DB rows. Fresh Vercel deployments usually do not need this.

View file

@ -45,21 +45,20 @@ title: Stack
## External compute worker (optional)
Monorepo packages under `compute/`:
Standalone worker package:
- **`@openreader/compute-core`** — ONNX runtime lifecycle, model management, and inference logic shared by compute worker runtime + app/worker contracts
- **`@openreader/compute-worker`** — standalone Node.js compute service containing its private inference and queue runtime
- ONNX runtime: `onnxruntime-node` with `@huggingface/tokenizers`
- Whisper alignment: `onnx-community/whisper-base_timestamped` (q4) for word-level timestamps
- PDF layout: `Bei0001/PP-DocLayoutV3-ONNX` for document block detection and layout parsing
- PDF rendering: `pdfjs-dist`, `@napi-rs/canvas` for server-side page rasterization
- Utilities: `jszip`, `ffmpeg-static`
- **`@openreader/compute-worker`** — standalone Node.js worker service
- HTTP server: [Fastify](https://fastify.dev/) v5
- HTTP server: [Fastify](https://fastify.dev/) v5 with a versioned OpenAPI contract
- Job queue + state: [NATS](https://nats.io/) JetStream WorkQueue pull consumers + NATS KV (`jobs.whisper`, `jobs.layout`)
- Storage: AWS SDK v3 S3 client for reading/writing blobs
- Logging: [Pino](https://getpino.io/)
- Validation: [Zod](https://zod.dev/)
- Heavy compute is worker-backed via `COMPUTE_WORKER_URL` + `COMPUTE_WORKER_TOKEN` (remote queue via HTTP + NATS)
- The Next.js app communicates with the worker only through the versioned HTTP API generated from OpenAPI.
## Tooling and testing

View file

@ -55,7 +55,12 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: '🚀 Deploy',
items: ['deploy/local-development', 'deploy/compute-worker', 'deploy/vercel-deployment'],
items: [
'deploy/local-development',
'deploy/docker-compose',
'deploy/compute-worker',
'deploy/vercel-deployment',
],
},
{
type: 'category',

View file

@ -1,56 +0,0 @@
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import fs from 'node:fs';
import * as dotenv from 'dotenv';
function loadEnvFiles() {
// Approximate Next.js behavior enough for server-side scripts.
// Load .env first, then .env.local (local overrides).
const cwd = process.cwd();
const envPath = path.join(cwd, '.env');
const envLocalPath = path.join(cwd, '.env.local');
if (fs.existsSync(envPath)) {
dotenv.config({ path: envPath });
}
if (fs.existsSync(envLocalPath)) {
dotenv.config({ path: envLocalPath, override: true });
}
}
loadEnvFiles();
const extraArgs = process.argv.slice(2);
const hasConfigArg = extraArgs.includes('--config');
const configFile = process.env.POSTGRES_URL ? 'drizzle.config.pg.ts' : 'drizzle.config.sqlite.ts';
const configArgs = hasConfigArg ? [] : ['--config', configFile];
// Ensure the docstore directory exists for SQLite migrations.
// drizzle-kit opens the database file directly and will fail if the parent
// directory is missing (e.g. in a fresh CI checkout).
if (!process.env.POSTGRES_URL) {
const dbDir = path.join(process.cwd(), 'docstore');
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
}
function resolveDrizzleKitBin() {
const binName = process.platform === 'win32' ? 'drizzle-kit.cmd' : 'drizzle-kit';
const localBin = path.join(process.cwd(), 'node_modules', '.bin', binName);
if (fs.existsSync(localBin)) return localBin;
return 'drizzle-kit';
}
const result = spawnSync(resolveDrizzleKitBin(), ['migrate', ...configArgs, ...extraArgs], {
stdio: 'inherit',
env: process.env,
});
if (result.error) {
console.error(result.error.message);
process.exit(1);
}
process.exit(result.status ?? 1);

View file

@ -34,16 +34,14 @@ const UI_ARCHITECTURE_FILES = [
"src/components/player/**/*.{ts,tsx}",
"src/components/reader/**/*.{ts,tsx}",
];
const COMPUTE_CORE_IMPORT_PATTERNS = [
const COMPUTE_WORKER_IMPORT_PATTERNS = [
{
group: [
"@openreader/compute-core/*",
"!@openreader/compute-core/local-runtime",
"!@openreader/compute-core/types",
"!@openreader/compute-core/api-contracts",
"@openreader/compute-worker",
"@openreader/compute-worker/*",
],
message:
"Use '@openreader/compute-core' root imports for light APIs. Allowed subpaths are '@openreader/compute-core/local-runtime', '@openreader/compute-core/types', and '@openreader/compute-core/api-contracts'.",
"The app must communicate with compute-worker through the app-owned HTTP client and generated protocol types.",
},
];
const UI_ARCHITECTURE_IMPORT_PATHS = [
@ -124,7 +122,7 @@ const eslintConfig = [
"no-restricted-imports": [
"error",
{
patterns: COMPUTE_CORE_IMPORT_PATTERNS,
patterns: COMPUTE_WORKER_IMPORT_PATTERNS,
},
],
},
@ -142,7 +140,7 @@ const eslintConfig = [
"error",
{
paths: UI_ARCHITECTURE_IMPORT_PATHS,
patterns: COMPUTE_CORE_IMPORT_PATTERNS,
patterns: COMPUTE_WORKER_IMPORT_PATTERNS,
},
],
},

View file

@ -15,7 +15,6 @@ const securityHeaders = [
},
];
const bundleWorkerCompute = true;
const pdfjsTraceFiles = [
'./node_modules/pdfjs-dist/package.json',
'./node_modules/pdfjs-dist/legacy/build/pdf.mjs',
@ -29,7 +28,6 @@ const serverExternalPackages = [
// Keep pdfjs-dist as a real package in node_modules. Server-side preview
// rendering resolves pdf.js runtime assets from the filesystem at runtime.
'pdfjs-dist',
...(!bundleWorkerCompute ? ['onnxruntime-node', '@huggingface/tokenizers'] : []),
];
const nextConfig: NextConfig = {
@ -48,7 +46,7 @@ const nextConfig: NextConfig = {
canvas: '@napi-rs/canvas',
},
},
transpilePackages: [],
transpilePackages: ['@openreader/database'],
serverExternalPackages,
outputFileTracingIncludes: {
'/api/audiobook': [
@ -88,14 +86,6 @@ const nextConfig: NextConfig = {
},
];
}
if (isServer && bundleWorkerCompute) {
config.resolve.alias = {
...(config.resolve.alias || {}),
'@openreader/compute-core/local-runtime$': false,
'onnxruntime-node$': false,
'@huggingface/tokenizers$': false,
};
}
return config;
},
};

View file

@ -4,54 +4,59 @@
"private": true,
"packageManager": "pnpm@10.33.4",
"scripts": {
"dev": "node scripts/openreader-entrypoint.mjs -- pnpm dev:raw",
"dev": "pnpm --filter @openreader/bootstrap start pnpm dev:raw",
"dev:raw": "next dev --turbopack -p 3003",
"build": "next build",
"build:bundle-guard": "node scripts/check-next-server-bundle.mjs",
"start": "node scripts/openreader-entrypoint.mjs -- pnpm start:raw",
"check:compute-boundary": "node scripts/check-compute-boundary.mjs",
"start": "pnpm --filter @openreader/bootstrap start pnpm start:raw",
"start:raw": "next start -p 3003",
"lint": "next lint",
"test": "playwright test",
"test:e2e": "playwright test",
"test:unit": "vitest run",
"test:compute": "vitest run --project compute-core --project compute-worker",
"migrate": "node drizzle/scripts/migrate.mjs",
"migrate-fs": "node scripts/migrate-fs-v2.mjs",
"migrate-fs:dry-run": "node scripts/migrate-fs-v2.mjs --dry-run true",
"generate": "node drizzle/scripts/generate.mjs",
"test:compute": "vitest run --project compute-worker",
"compose": "docker compose -f docker/examples/compose.yml up",
"compose:full": "docker compose -f docker/examples/compose.full.yml up",
"compose:local": "docker compose -f docker/examples/compose.local-slim.yml up --build",
"compose:local:slim": "docker compose -f docker/examples/compose.local-slim.yml up --build",
"compose:local:full": "docker compose -f docker/examples/compose.local-full.yml up --build",
"migrate": "pnpm --filter @openreader/database migrate",
"migrate-fs": "pnpm --filter @openreader/bootstrap migrate-storage",
"migrate-fs:dry-run": "pnpm --filter @openreader/bootstrap migrate-storage --dry-run true",
"generate": "node packages/database/scripts/generate.mjs",
"docs:init": "pnpm --dir docs-site install",
"docs:dev": "pnpm --dir docs-site start",
"docs:build": "pnpm --dir docs-site build",
"docs:serve": "pnpm --dir docs-site serve",
"docs:version": "pnpm --dir docs-site docusaurus docs:version",
"docs:clear": "pnpm --dir docs-site clear",
"compute:worker:dev": "pnpm --filter @openreader/compute-worker dev",
"compute:worker:start": "pnpm --filter @openreader/compute-worker start",
"compute:dev:compose": "docker compose --env-file compute/worker/.env -f compute/worker/docker-compose.yml up --build",
"compute:dev": "pnpm --filter @openreader/compute-worker dev",
"compute:start": "pnpm --filter @openreader/compute-worker start",
"compute:openapi:generate": "pnpm --dir packages/compute-worker openapi:generate && openapi-typescript packages/compute-worker/openapi.json -o src/lib/server/compute-worker/generated.ts",
"compute:openapi:check": "pnpm compute:openapi:generate && git diff --exit-code -- packages/compute-worker/openapi.json src/lib/server/compute-worker/generated.ts",
"lint:route-errors": "node scripts/check-route-error-responses.mjs"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1061.0",
"@aws-sdk/s3-request-presigner": "^3.1061.0",
"@headlessui/react": "^2.2.10",
"@huggingface/tokenizers": "^0.1.3",
"@microsoft/antissrf": "^1.0.0",
"@mozilla/readability": "^0.6.0",
"@napi-rs/canvas": "^0.1.100",
"@openreader/database": "workspace:*",
"@speech-sdk/core": "^0.15.0",
"@tanstack/react-query": "^5.101.0",
"@types/archiver": "^7.0.0",
"@vercel/analytics": "^1.6.1",
"archiver": "^7.0.1",
"better-auth": "^1.6.14",
"better-sqlite3": "^12.10.0",
"cmpstr": "^3.3.0",
"compromise": "^14.15.1",
"core-js": "^3.49.0",
"dexie": "^4.4.3",
"dexie-react-hooks": "^4.4.0",
"dotenv": "^17.4.2",
"drizzle-kit": "^0.31.10",
"drizzle-orm": "^0.45.2",
"epubjs": "^0.3.93",
"fast-xml-parser": "^5.8.0",
@ -61,10 +66,8 @@
"linkedom": "^0.18.12",
"lru-cache": "^11.5.1",
"next": "^15.5.19",
"onnxruntime-node": "^1.26.0",
"openai": "^6.41.0",
"pdfjs-dist": "4.8.69",
"pg": "^8.21.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"react": "^19.2.7",
@ -95,9 +98,12 @@
"@types/react-dom": "^19.2.3",
"@types/turndown": "^5.0.6",
"@types/uuid": "^10.0.0",
"better-sqlite3": "^12.10.0",
"drizzle-kit": "^0.31.10",
"eslint": "^9.39.4",
"eslint-config-next": "^15.5.19",
"postcss": "^8.5.15",
"openapi-typescript": "^7.10.1",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"vitest": "^4.1.8"

View file

@ -0,0 +1,25 @@
{
"name": "@openreader/bootstrap",
"version": "0.0.0",
"private": true,
"type": "module",
"files": [
"src"
],
"bin": {
"openreader": "./src/cli.mjs",
"openreader-migrate-storage": "./src/storage-migration.mjs"
},
"scripts": {
"start": "node src/cli.mjs --",
"migrate-storage": "node src/storage-migration.mjs"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1061.0",
"@openreader/database": "workspace:*",
"better-sqlite3": "^12.10.0",
"dotenv": "^17.4.2",
"ffmpeg-static": "^5.3.0",
"pg": "^8.21.0"
}
}

View file

@ -3,16 +3,44 @@ import { spawn, spawnSync } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { once } from 'node:events';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { setTimeout as delay } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
import * as dotenv from 'dotenv';
import { runMigrations } from '@openreader/database/migrate';
import { hasNatsBinary } from './embedded-nats.mjs';
import {
detectHostForDefaultEndpoint,
hasWeedBinary,
isRunningInDocker,
loopbackS3Endpoint,
parseS3Endpoint,
parseUrlHost,
waitForEndpoint,
} from './embedded-seaweedfs.mjs';
import { resolveEmbeddedWorkerLaunch } from './embedded-worker.mjs';
function findWorkspaceRoot(startDir = process.cwd()) {
let dir = startDir;
while (true) {
if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) {
return dir;
}
const parent = path.dirname(dir);
if (parent === dir) {
break;
}
dir = parent;
}
return startDir;
}
const workspaceRoot = findWorkspaceRoot(process.cwd());
function loadEnvFiles() {
const cwd = process.cwd();
const envPath = path.join(cwd, '.env');
const envLocalPath = path.join(cwd, '.env.local');
const envPath = path.join(workspaceRoot, '.env');
const envLocalPath = path.join(workspaceRoot, '.env.local');
if (fs.existsSync(envPath)) {
dotenv.config({ path: envPath });
@ -49,83 +77,6 @@ function requireAuthEnv(env) {
}
}
function isPrivateIPv4(address) {
if (!address) return false;
if (address.startsWith('10.')) return true;
if (address.startsWith('192.168.')) return true;
const m = /^172\.(\d+)\./.exec(address);
if (m) {
const second = Number.parseInt(m[1], 10);
if (second >= 16 && second <= 31) return true;
}
return false;
}
function detectHostForDefaultEndpoint() {
const interfaces = os.networkInterfaces();
const ipv4 = [];
for (const entries of Object.values(interfaces)) {
for (const entry of entries || []) {
if (!entry) continue;
const family = typeof entry.family === 'string' ? entry.family : String(entry.family);
if (family !== 'IPv4') continue;
if (entry.internal) continue;
ipv4.push(entry.address);
}
}
const privateAddr = ipv4.find(isPrivateIPv4);
if (privateAddr) return privateAddr;
if (ipv4[0]) return ipv4[0];
return '127.0.0.1';
}
function parseS3Endpoint(endpoint) {
let url;
try {
url = new URL(endpoint);
} catch {
throw new Error(`Invalid S3_ENDPOINT: ${endpoint}`);
}
if (!url.hostname) {
throw new Error(`Invalid S3_ENDPOINT host: ${endpoint}`);
}
const port = Number.parseInt(url.port || '8333', 10);
if (!Number.isFinite(port) || port < 1 || port > 65535) {
throw new Error(`Invalid S3_ENDPOINT port: ${endpoint}`);
}
return {
hostname: url.hostname,
port,
normalized: `${url.protocol}//${url.hostname}:${port}`,
};
}
function loopbackS3Endpoint(endpoint) {
const parsed = parseS3Endpoint(endpoint);
const url = new URL(parsed.normalized);
return `${url.protocol}//127.0.0.1:${parsed.port}`;
}
function parseUrlHost(urlValue, fieldName) {
let url;
try {
url = new URL(urlValue);
} catch {
throw new Error(`Invalid ${fieldName}: ${urlValue}`);
}
if (!url.hostname) {
throw new Error(`Invalid ${fieldName} host: ${urlValue}`);
}
return url.hostname;
}
function parseCommandFromArgs(argv) {
const marker = argv.indexOf('--');
if (marker >= 0) return argv.slice(marker + 1);
@ -143,54 +94,10 @@ function forwardChildStream(stream, target) {
};
}
function hasWeedBinary() {
const probe = spawnSync('weed', ['version'], { stdio: 'ignore' });
if (probe.error) return false;
return true;
}
function hasNatsBinary() {
const probe = spawnSync('nats-server', ['-v'], { stdio: 'ignore' });
if (probe.error) return false;
return true;
}
async function waitForEndpoint(url, timeoutSeconds) {
const waitMs = Math.max(1, timeoutSeconds) * 1000;
const deadline = Date.now() + waitMs;
while (Date.now() < deadline) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2000);
try {
const res = await fetch(url, { method: 'GET', signal: controller.signal });
if (res) return;
} catch {
// retry
} finally {
clearTimeout(timeoutId);
}
await delay(1000);
}
throw new Error(`Embedded weed mini did not become ready at ${url} within ${timeoutSeconds}s.`);
}
function isRunningInDocker() {
if (process.platform !== 'linux') return false;
if (fs.existsSync('/.dockerenv')) return true;
try {
const cgroup = fs.readFileSync('/proc/1/cgroup', 'utf8');
return /(docker|containerd|kubepods|podman)/i.test(cgroup);
} catch {
return false;
}
}
function spawnMainCommand(command, env) {
const [cmd, ...args] = command;
const child = spawn(cmd, args, {
cwd: workspaceRoot,
env,
stdio: 'inherit',
shell: process.platform === 'win32',
@ -219,53 +126,13 @@ function spawnMainCommand(command, env) {
return { child, exitPromise };
}
function resolveEmbeddedWorkerLaunch() {
const candidateDirs = [
path.join(process.cwd(), 'embedded-compute-worker'),
path.join(process.cwd(), 'compute', 'worker'),
];
for (const workerDir of candidateDirs) {
const serverEntry = path.join(workerDir, 'src', 'server.ts');
if (!fs.existsSync(serverEntry)) continue;
return {
cmd: process.execPath,
args: ['--import', 'tsx', 'src/server.ts'],
cwd: workerDir,
};
}
throw new Error(
'Could not find an embedded compute worker runtime. '
+ 'Include embedded-compute-worker/src/server.ts in the runtime image or keep compute/worker available locally.',
);
}
function runDbMigrations(env) {
const migrateScript = path.join(process.cwd(), 'drizzle', 'scripts', 'migrate.mjs');
if (!fs.existsSync(migrateScript)) {
throw new Error(`Could not find migration script at ${migrateScript}`);
}
async function runDbMigrations(env) {
console.log('Running database migrations...');
const migration = spawnSync(process.execPath, [migrateScript], {
env,
stdio: 'inherit',
});
if (migration.error) {
throw migration.error;
}
if (typeof migration.status === 'number' && migration.status !== 0) {
throw new Error(`Database migrations failed with exit code ${migration.status}.`);
}
await runMigrations({ cwd: workspaceRoot, env });
}
function runStorageMigrations(env) {
const migrateScript = path.join(process.cwd(), 'scripts', 'migrate-fs-v2.mjs');
if (!fs.existsSync(migrateScript)) {
throw new Error(`Could not find storage migration script at ${migrateScript}`);
}
const migrateScript = fileURLToPath(new URL('./storage-migration.mjs', import.meta.url));
console.log('Running storage migrations (v2)...');
const migration = spawnSync(process.execPath, [migrateScript, '--dry-run', 'false', '--delete-local', 'false'], {
@ -335,7 +202,7 @@ async function main() {
const command = parseCommandFromArgs(process.argv.slice(2));
if (command.length === 0) {
console.error('Usage: node scripts/openreader-entrypoint.mjs -- <command> [args]');
console.error('Usage: openreader -- <command> [args]');
process.exit(2);
}
@ -424,11 +291,11 @@ async function main() {
try {
const shouldRunDbMigrations = resolveBooleanEnv(runtimeEnv, 'RUN_DRIZZLE_MIGRATIONS', true);
if (shouldRunDbMigrations) {
runDbMigrations(runtimeEnv);
await runDbMigrations(runtimeEnv);
}
if (useEmbeddedWeed) {
runtimeEnv.WEED_MINI_DIR = withDefault(runtimeEnv.WEED_MINI_DIR, 'docstore/seaweedfs');
runtimeEnv.WEED_MINI_DIR = withDefault(runtimeEnv.WEED_MINI_DIR, path.join(workspaceRoot, 'docstore/seaweedfs'));
runtimeEnv.WEED_MINI_WAIT_SEC = withDefault(runtimeEnv.WEED_MINI_WAIT_SEC, '20');
runtimeEnv.S3_BUCKET = withDefault(runtimeEnv.S3_BUCKET, 'openreader-documents');
runtimeEnv.S3_REGION = withDefault(runtimeEnv.S3_REGION, 'us-east-1');
@ -482,7 +349,7 @@ async function main() {
console.log('Starting embedded SeaweedFS weed mini...');
launchWeed(runtimeEnv.S3_ENDPOINT);
const startupEndpoint = parseS3Endpoint(runtimeEnv.S3_ENDPOINT);
await waitForEndpoint(`http://127.0.0.1:${startupEndpoint.port}`, waitTimeout);
await waitForEndpoint(`http://127.0.0.1:${startupEndpoint.port}`, waitTimeout, 'Embedded SeaweedFS');
console.log(`Embedded SeaweedFS is ready at ${runtimeEnv.S3_ENDPOINT}`);
}
@ -521,7 +388,7 @@ async function main() {
runtimeEnv.COMPUTE_WORKER_HOST = withDefault(runtimeEnv.COMPUTE_WORKER_HOST, '127.0.0.1');
runtimeEnv.COMPUTE_NATS_REPLICAS = withDefault(runtimeEnv.COMPUTE_NATS_REPLICAS, '1');
const natsStoreDir = withDefault(runtimeEnv.EMBEDDED_NATS_STORE_DIR, 'docstore/nats/jetstream');
const natsStoreDir = withDefault(runtimeEnv.EMBEDDED_NATS_STORE_DIR, path.join(workspaceRoot, 'docstore/nats/jetstream'));
fs.mkdirSync(natsStoreDir, { recursive: true });
console.log(`Starting embedded nats-server on 127.0.0.1:${embeddedNatsPort}...`);
@ -555,7 +422,7 @@ async function main() {
console.error(`Embedded nats-server failed to start: ${error instanceof Error ? error.message : String(error)}`);
scheduleFatalShutdown('nats-server', null, null, 'failed to start');
});
await waitForEndpoint(`http://127.0.0.1:${embeddedNatsMonitorPort}/healthz`, 20);
await waitForEndpoint(`http://127.0.0.1:${embeddedNatsMonitorPort}/healthz`, 20, 'Embedded nats-server');
console.log(`Embedded nats-server is ready at nats://127.0.0.1:${embeddedNatsPort}`);
console.log(`Starting embedded compute-worker on 127.0.0.1:${embeddedWorkerPort}...`);
@ -589,7 +456,7 @@ async function main() {
console.error(`Embedded compute-worker failed to start: ${error instanceof Error ? error.message : String(error)}`);
scheduleFatalShutdown('compute-worker', null, null, 'failed to start');
});
await waitForEndpoint(`http://127.0.0.1:${embeddedWorkerPort}/health/ready`, 30);
await waitForEndpoint(`http://127.0.0.1:${embeddedWorkerPort}/health/ready`, 30, 'Embedded compute-worker');
console.log(`Embedded compute-worker is ready at http://127.0.0.1:${embeddedWorkerPort}`);
} else if (!runtimeEnv.COMPUTE_WORKER_URL?.trim() || !runtimeEnv.COMPUTE_WORKER_TOKEN?.trim()) {
throw new Error('COMPUTE_WORKER_URL and COMPUTE_WORKER_TOKEN are required when embedded compute worker startup is disabled.');

View file

@ -0,0 +1,5 @@
import { spawnSync } from 'node:child_process';
export function hasNatsBinary() {
return !spawnSync('nats-server', ['-v'], { stdio: 'ignore' }).error;
}

View file

@ -0,0 +1,95 @@
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import process from 'node:process';
import { setTimeout as delay } from 'node:timers/promises';
function isPrivateIPv4(address) {
if (!address) return false;
if (address.startsWith('10.')) return true;
if (address.startsWith('192.168.')) return true;
const match = /^172\.(\d+)\./.exec(address);
if (!match) return false;
const second = Number.parseInt(match[1], 10);
return second >= 16 && second <= 31;
}
export function hasWeedBinary() {
return !spawnSync('weed', ['version'], { stdio: 'ignore' }).error;
}
export function detectHostForDefaultEndpoint() {
const ipv4 = Object.values(os.networkInterfaces())
.flatMap((entries) => entries || [])
.filter((entry) => entry && !entry.internal && String(entry.family) === 'IPv4')
.map((entry) => entry.address);
return ipv4.find(isPrivateIPv4) || ipv4[0] || '127.0.0.1';
}
export function parseS3Endpoint(endpoint) {
let url;
try {
url = new URL(endpoint);
} catch {
throw new Error(`Invalid S3_ENDPOINT: ${endpoint}`);
}
if (!url.hostname) throw new Error(`Invalid S3_ENDPOINT host: ${endpoint}`);
const port = Number.parseInt(url.port || '8333', 10);
if (!Number.isFinite(port) || port < 1 || port > 65535) {
throw new Error(`Invalid S3_ENDPOINT port: ${endpoint}`);
}
return {
hostname: url.hostname,
port,
normalized: `${url.protocol}//${url.hostname}:${port}`,
};
}
export function loopbackS3Endpoint(endpoint) {
const parsed = parseS3Endpoint(endpoint);
const url = new URL(parsed.normalized);
return `${url.protocol}//127.0.0.1:${parsed.port}`;
}
export function parseUrlHost(urlValue, fieldName) {
let url;
try {
url = new URL(urlValue);
} catch {
throw new Error(`Invalid ${fieldName}: ${urlValue}`);
}
if (!url.hostname) throw new Error(`Invalid ${fieldName} host: ${urlValue}`);
return url.hostname;
}
export async function waitForEndpoint(url, timeoutSeconds, serviceName = 'service') {
const deadline = Date.now() + Math.max(1, timeoutSeconds) * 1000;
while (Date.now() < deadline) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2000);
try {
const response = await fetch(url, { method: 'GET', signal: controller.signal });
if (response) return;
} catch {
// Retry until the startup deadline.
} finally {
clearTimeout(timeoutId);
}
await delay(1000);
}
throw new Error(`${serviceName} did not become ready at ${url} within ${timeoutSeconds}s.`);
}
export function isRunningInDocker() {
if (process.platform !== 'linux') return false;
if (fs.existsSync('/.dockerenv')) return true;
try {
return /(docker|containerd|kubepods|podman)/i.test(fs.readFileSync('/proc/1/cgroup', 'utf8'));
} catch {
return false;
}
}

View file

@ -0,0 +1,30 @@
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
const bootstrapDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
export function resolveEmbeddedWorkerLaunch() {
const candidateDirs = [
path.resolve(bootstrapDir, '..', 'embedded-compute-worker'),
path.resolve(bootstrapDir, '..', 'compute-worker'),
path.join(process.cwd(), 'embedded-compute-worker'),
path.join(process.cwd(), 'packages', 'compute-worker'),
path.join(process.cwd(), 'compute-worker'),
];
for (const workerDir of candidateDirs) {
if (!fs.existsSync(path.join(workerDir, 'src', 'server.ts'))) continue;
return {
cmd: process.execPath,
args: ['--import', 'tsx', 'src/server.ts'],
cwd: workerDir,
};
}
throw new Error(
'Could not find an embedded compute worker runtime. '
+ 'Include embedded-compute-worker/src/server.ts in the runtime image or keep packages/compute-worker available locally.',
);
}

View file

@ -17,7 +17,23 @@ const { Pool } = require('pg');
const BetterSqlite3 = require('better-sqlite3');
const ffmpegStatic = require('ffmpeg-static');
const DOCSTORE_DIR = path.join(process.cwd(), 'docstore');
function findWorkspaceRoot(startDir = process.cwd()) {
let dir = startDir;
while (true) {
if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) {
return dir;
}
const parent = path.dirname(dir);
if (parent === dir) {
break;
}
dir = parent;
}
return startDir;
}
const workspaceRoot = findWorkspaceRoot(process.cwd());
const DOCSTORE_DIR = path.join(workspaceRoot, 'docstore');
const DOCUMENTS_V1_DIR = path.join(DOCSTORE_DIR, 'documents_v1');
const AUDIOBOOKS_V1_DIR = path.join(DOCSTORE_DIR, 'audiobooks_v1');
const UNCLAIMED_USER_ID = 'unclaimed';
@ -27,10 +43,15 @@ const SAFE_AUDIOBOOK_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/;
const DOCUMENT_ID_REGEX = /^[a-f0-9]{64}$/i;
function loadEnvFiles() {
const envPath = path.join(process.cwd(), '.env');
const envLocalPath = path.join(process.cwd(), '.env.local');
if (fs.existsSync(envPath)) dotenv.config({ path: envPath });
if (fs.existsSync(envLocalPath)) dotenv.config({ path: envLocalPath, override: true });
const envPath = path.join(workspaceRoot, '.env');
const envLocalPath = path.join(workspaceRoot, '.env.local');
if (fs.existsSync(envPath)) {
dotenv.config({ path: envPath });
}
if (fs.existsSync(envLocalPath)) {
dotenv.config({ path: envLocalPath, override: true });
}
}
function parseBool(value, fallback = false) {
@ -688,7 +709,7 @@ function openDatabase() {
};
}
const dbPath = path.join(process.cwd(), 'docstore', 'sqlite3.db');
const dbPath = path.join(DOCSTORE_DIR, 'sqlite3.db');
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
const sqlite = new BetterSqlite3(dbPath);
return {

View file

@ -1,22 +1,17 @@
FROM node:lts AS deploy-stage
RUN npm install -g pnpm@11.1.2
RUN npm install -g pnpm@10.33.4
WORKDIR /workspace
COPY .npmrc package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY compute/worker/package.json compute/worker/package.json
COPY compute/core/package.json compute/core/package.json
COPY compute/worker compute/worker
COPY compute/core compute/core
COPY packages/compute-worker/package.json packages/compute-worker/package.json
COPY packages/compute-worker packages/compute-worker
RUN pnpm --config.inject-workspace-packages=true --filter @openreader/compute-worker deploy /opt/compute-worker
FROM node:lts
RUN npm install -g pnpm@11.1.2
WORKDIR /workspace
COPY --from=deploy-stage /opt/compute-worker ./

File diff suppressed because it is too large Load diff

View file

@ -5,15 +5,22 @@
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"start": "tsx src/server.ts"
"start": "tsx src/server.ts",
"openapi:generate": "tsx scripts/generate-openapi.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1061.0",
"@fastify/swagger": "^9.0.0",
"@huggingface/tokenizers": "^0.1.3",
"@napi-rs/canvas": "^0.1.100",
"@nats-io/jetstream": "^3.4.0",
"@nats-io/kv": "^3.4.0",
"@nats-io/transport-node": "^3.4.0",
"@openreader/compute-core": "workspace:*",
"ffmpeg-static": "^5.3.0",
"fastify": "^5.8.5",
"jszip": "^3.10.1",
"onnxruntime-node": "^1.26.0",
"pdfjs-dist": "4.8.69",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"tsx": "^4.22.4",

View file

@ -0,0 +1,20 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { createComputeWorkerApp } from '../src/api/app';
process.env.COMPUTE_WORKER_TOKEN ||= 'openapi-generation-token';
process.env.NATS_URL ||= 'nats://127.0.0.1:4222';
const outputPath = resolve(process.cwd(), 'openapi.json');
const runtime = await createComputeWorkerApp({
workerToken: process.env.COMPUTE_WORKER_TOKEN,
disableWorkers: true,
});
try {
await runtime.app.ready();
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(runtime.app.swagger(), null, 2)}\n`, 'utf8');
} finally {
await runtime.close();
}

View file

@ -0,0 +1,372 @@
import Fastify, { type FastifyInstance } from 'fastify';
import swagger from '@fastify/swagger';
import {
credsAuthenticator,
type ConnectionOptions,
} from '@nats-io/transport-node';
import {
ensureComputeModels,
} from '../inference/runtime';
import {
getComputeTimeoutConfig,
getComputeOpStaleMs,
getAvailableCpuCores,
getOnnxThreadsPerJob,
buildLoggerConfig,
normalizeNatsReplicas,
readBoolEnv,
readPositiveIntEnv,
requireEnv,
} from '../infrastructure/config';
import { OperationOrchestrator } from '../operations';
import type {
PdfLayoutJobRequest,
PdfLayoutJobResult,
WhisperAlignJobRequest,
WhisperAlignJobResult,
} from '../operations/contracts';
import {
JetStreamOperationEventStream,
JetStreamOperationQueue,
JetStreamOperationStateStore,
} from '../infrastructure/nats-adapters';
import { createJsonCodec } from '../infrastructure/json-codec';
import { createOperationReconciler } from '../operations/reconciliation';
import {
createArtifactStorage,
createS3ClientFromEnv,
normalizeS3Prefix,
type ArtifactStorage,
} from '../infrastructure/storage';
import { createJobHandlers } from '../jobs/handlers';
import { createWorkerLoopController, type QueuedJob } from '../jobs/worker-loop';
import { createNatsSessionManager } from '../infrastructure/nats-session';
import {
EVENTS_STREAM_NAME,
LAYOUT_JOBS_SUBJECT,
NATS_API_TIMEOUT_MS,
WHISPER_JOBS_SUBJECT,
} from '../infrastructure/nats';
import { registerHttpHooks } from './http-hooks';
import {
registerComputeWorkerRoutes,
type ComputeWorkerRouteDeps,
} from './routes';
import {
apiErrorResponseSchema,
artifactReferenceSchema,
jsonSchema,
operationErrorSchema,
parsedPdfDocumentSchema,
pdfLayoutProgressSchema,
pdfLayoutResolutionSchema,
computeOperationEventSchema,
computeOperationSchema,
ttsSentenceAlignmentSchema,
} from './schemas';
// Disconnect from NATS after this much continuous idle so the worker stops
// generating outbound traffic (pull polling + keepalive PINGs) and Railway can
// put it to sleep. Reconnect happens lazily on the next inbound request.
const IDLE_DISCONNECT_MS = 120_000;
const IDLE_CHECK_INTERVAL_MS = 5_000;
const IDLE_STATUS_LOG_INTERVAL_MS = 60_000;
const ORPHAN_SWEEP_INTERVAL_MS = 15_000;
// Bounded pull window so consumer loops yield periodically and can be stopped
// cleanly when going idle, instead of blocking on a long-lived pull.
const WHISPER_MAX_DELIVER = 1;
export type { ComputeWorkerRouteDeps } from './routes';
export interface CreateComputeWorkerAppOptions {
host?: string;
port?: number;
workerToken?: string;
routeDeps?: ComputeWorkerRouteDeps;
disableWorkers?: boolean;
}
export interface ComputeWorkerApp {
app: FastifyInstance;
host: string;
port: number;
start(options?: { registerSignalHandlers?: boolean }): Promise<void>;
close(): Promise<void>;
}
export async function createComputeWorkerApp(options: CreateComputeWorkerAppOptions = {}): Promise<ComputeWorkerApp> {
const port = options.port ?? readPositiveIntEnv('PORT', 8081);
const host = options.host ?? (process.env.COMPUTE_WORKER_HOST?.trim() || '0.0.0.0');
const workerToken = options.workerToken ?? requireEnv('COMPUTE_WORKER_TOKEN');
const disableWorkers = options.disableWorkers ?? false;
const natsUrl = requireEnv('NATS_URL');
const timeoutConfig = getComputeTimeoutConfig();
const jobConcurrency = readPositiveIntEnv('COMPUTE_JOB_CONCURRENCY', 1);
const whisperTimeoutMs = timeoutConfig.whisperTimeoutMs;
const pdfTimeoutMs = timeoutConfig.pdfTimeoutMs;
const pdfHardCapMs = timeoutConfig.pdfHardCapMs;
const pdfAttempts = readPositiveIntEnv('COMPUTE_PDF_JOB_ATTEMPTS', 1);
const prewarmModels = readBoolEnv('COMPUTE_PREWARM_MODELS', false);
const jobsStreamMaxBytes = readPositiveIntEnv('COMPUTE_JOBS_STREAM_MAX_BYTES', 256 * 1024 * 1024);
const eventsStreamMaxBytes = readPositiveIntEnv('COMPUTE_EVENTS_STREAM_MAX_BYTES', 128 * 1024 * 1024);
const jobStatesMaxBytes = readPositiveIntEnv('COMPUTE_JOB_STATES_MAX_BYTES', 64 * 1024 * 1024);
const natsReplicas = normalizeNatsReplicas(readPositiveIntEnv('COMPUTE_NATS_REPLICAS', 1));
const opStaleMs = getComputeOpStaleMs();
const connectOpts: ConnectionOptions = { servers: natsUrl };
const natsCreds = process.env.NATS_CREDS?.trim();
const natsCredsFile = process.env.NATS_CREDS_FILE?.trim();
if (natsCreds) {
console.log('[compute-worker] Connecting to NATS using credentials string from NATS_CREDS');
connectOpts.authenticator = credsAuthenticator(new TextEncoder().encode(natsCreds));
} else if (natsCredsFile) {
console.log(`[compute-worker] Connecting to NATS using credentials file: ${natsCredsFile}`);
const { readFileSync } = await import('node:fs');
const credsData = readFileSync(natsCredsFile);
connectOpts.authenticator = credsAuthenticator(credsData);
}
let stopping = false;
let inFlightHttp = 0;
let activeSse = 0;
let inFlightJobs = 0;
let lastActivityAt = Date.now();
let lastActivityReason = "startup";
const markActivity = (reason: string): void => {
lastActivityAt = Date.now();
lastActivityReason = reason;
};
let sessionManager!: ReturnType<typeof createNatsSessionManager>;
const ensureConnected = () => sessionManager.ensureConnected();
const s3Prefix = normalizeS3Prefix(process.env.S3_PREFIX);
const storageDisabled = async (): Promise<never> => {
throw new Error('S3 access is disabled for this worker app instance');
};
const storage: ArtifactStorage = disableWorkers
? {
readObject: storageDisabled,
objectExists: storageDisabled,
deleteObject: storageDisabled,
putParsedPdf: storageDisabled,
}
: createArtifactStorage({
bucket: requireEnv('S3_BUCKET'),
prefix: s3Prefix,
client: createS3ClientFromEnv(requireEnv),
});
if (prewarmModels && !disableWorkers) {
await ensureComputeModels();
}
const app = Fastify({
logger: buildLoggerConfig(),
disableRequestLogging: true,
});
await app.register(swagger, {
openapi: {
openapi: '3.0.3',
info: {
title: 'OpenReader Compute Worker API',
version: '1.0.0',
},
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
},
},
schemas: {
ParsedPdfDocument: jsonSchema(parsedPdfDocumentSchema),
TTSSentenceAlignment: jsonSchema(ttsSentenceAlignmentSchema),
ArtifactReference: jsonSchema(artifactReferenceSchema),
ErrorResponse: jsonSchema(apiErrorResponseSchema),
OperationError: jsonSchema(operationErrorSchema),
PdfLayoutProgress: jsonSchema(pdfLayoutProgressSchema),
ComputeOperation: jsonSchema(computeOperationSchema),
ComputeOperationEvent: jsonSchema(computeOperationEventSchema),
PdfLayoutResolution: jsonSchema(pdfLayoutResolutionSchema),
},
},
security: [{ bearerAuth: [] }],
},
});
app.log.info({
jobConcurrency,
whisperTimeoutMs,
pdfTimeoutMs,
pdfAttempts,
opStaleMs,
availableCpuCores: getAvailableCpuCores(),
onnxThreadsPerJob: getOnnxThreadsPerJob(),
natsApiTimeoutMs: NATS_API_TIMEOUT_MS,
natsReplicas,
eventsStreamMaxBytes,
pdfLayoutHardCapMs: pdfHardCapMs,
}, 'compute runtime config');
const whisperJobCodec = createJsonCodec<QueuedJob<WhisperAlignJobRequest>>();
const layoutJobCodec = createJsonCodec<QueuedJob<PdfLayoutJobRequest>>();
const defaultOperationStateStore = new JetStreamOperationStateStore<WhisperAlignJobResult | PdfLayoutJobResult>({
getKv: async () => (await ensureConnected()).kv,
});
const defaultOperationEventStream = new JetStreamOperationEventStream<WhisperAlignJobResult | PdfLayoutJobResult>({
getJs: async () => (await ensureConnected()).js,
getJsm: async () => (await ensureConnected()).jsm,
eventsStreamName: EVENTS_STREAM_NAME,
});
const operationQueue = new JetStreamOperationQueue({
getJs: async () => (await ensureConnected()).js,
whisperSubject: WHISPER_JOBS_SUBJECT,
layoutSubject: LAYOUT_JOBS_SUBJECT,
});
const defaultOrchestrator = new OperationOrchestrator({
queue: operationQueue,
stateStore: defaultOperationStateStore,
eventStream: defaultOperationEventStream,
config: {
opStaleMs,
maxCasRetries: 10,
},
});
const operationStateStore = options.routeDeps?.operationStateStore ?? defaultOperationStateStore;
const operationEventStream = options.routeDeps?.operationEventStream ?? defaultOperationEventStream;
const orchestrator = options.routeDeps?.orchestrator ?? defaultOrchestrator;
const reconciler = createOperationReconciler({
stateStore: operationStateStore,
orchestrator,
whisperTimeoutMs,
pdfTimeoutMs,
opStaleMs,
getGeneration: () => options.routeDeps ? 0 : sessionManager.getGeneration(),
logger: app.log,
});
const ensureOrphanedOpRecovery = () => reconciler.run();
const getOpState = (opId: string) => reconciler.getOpState(opId);
const { releaseHttp } = registerHttpHooks({
app,
workerToken,
markActivity,
onInFlightHttpChanged: (delta) => {
inFlightHttp = Math.max(0, inFlightHttp + delta);
},
});
registerComputeWorkerRoutes({
app,
deps: {
orchestrator,
operationStateStore,
operationEventStream,
artifactExists: options.routeDeps?.artifactExists ?? storage.objectExists,
},
s3Prefix,
ensureOrphanedOpRecovery,
getOpState,
getNatsConnected: () => sessionManager.isConnected(),
releaseHttp,
markActivity,
onActiveSseChanged: (delta) => {
activeSse = Math.max(0, activeSse + delta);
},
});
const jobHandlers = createJobHandlers({
storage,
whisperTimeoutMs,
pdfTimeoutMs,
pdfHardCapMs,
});
const workerLoops = createWorkerLoopController({
orchestrator,
handlers: jobHandlers,
logger: app.log,
jobConcurrency,
pdfAttempts,
whisperCodec: whisperJobCodec,
pdfCodec: layoutJobCodec,
isOwnerActive: (owner) => sessionManager.isOwnerActive(owner),
isStopping: () => stopping,
markActivity,
onInFlightJobsChanged: (delta) => {
inFlightJobs = Math.max(0, inFlightJobs + delta);
},
});
sessionManager = createNatsSessionManager({
connectOptions: connectOpts,
logger: app.log,
whisperTimeoutMs,
pdfTimeoutMs,
pdfAttempts,
jobsStreamMaxBytes,
eventsStreamMaxBytes,
jobStatesMaxBytes,
natsReplicas,
isStopping: () => stopping,
getActivity: () => ({
activeSse,
inFlightHttp,
inFlightJobs,
lastActivityAt,
lastActivityReason,
}),
markActivity,
startWorkers: (session) => {
if (disableWorkers) return;
workerLoops.start(session, {
whisper: session.whisperConsumer,
pdfLayout: session.layoutConsumer,
});
},
stopWorkers: () => workerLoops.stop(),
runReconciliation: () => reconciler.run({ force: true }),
});
const close = async (): Promise<void> => {
if (stopping) return;
stopping = true;
await app.close();
await sessionManager.close();
};
const registerSignalHandlers = (): void => {
process.once('SIGINT', () => {
void close().finally(() => process.exit(0));
});
process.once('SIGTERM', () => {
void close().finally(() => process.exit(0));
});
};
return {
app,
host,
port,
async start(startOptions) {
if (startOptions?.registerSignalHandlers) {
registerSignalHandlers();
}
await app.listen({ host, port });
app.log.info({ host, port }, 'compute worker listening');
},
close,
};
}
export async function startComputeWorkerFromEnv(): Promise<void> {
const runtime = await createComputeWorkerApp();
await runtime.start({ registerSignalHandlers: true });
}

View file

@ -0,0 +1,44 @@
import type { WorkerOperationState } from '../operations/contracts';
import { pdfSubjectFromOperationKey } from '../operations/keys';
export type ComputeOperationSubject =
| { kind: 'whisper_align' }
| { kind: 'pdf_layout'; documentId: string; namespace: string | null };
export interface ComputeOperation<Result = unknown> {
opId: string;
subject: ComputeOperationSubject;
status: WorkerOperationState['status'];
queuedAt: number;
updatedAt: number;
startedAt?: number;
result?: Result;
error?: WorkerOperationState['error'];
timing?: WorkerOperationState['timing'];
progress?: WorkerOperationState['progress'];
}
export interface ComputeOperationEvent<Result = unknown> {
eventId: number;
snapshot: ComputeOperation<Result>;
}
export function toComputeOperation<Result>(
state: WorkerOperationState<Result>,
): ComputeOperation<Result> {
const subject = state.kind === 'pdf_layout'
? (pdfSubjectFromOperationKey(state.opKey) ?? { kind: 'pdf_layout', documentId: '', namespace: null })
: { kind: 'whisper_align' as const };
return {
opId: state.opId,
subject,
status: state.status,
queuedAt: state.queuedAt,
updatedAt: state.updatedAt,
...(state.startedAt === undefined ? {} : { startedAt: state.startedAt }),
...(state.result === undefined ? {} : { result: state.result }),
...(state.error === undefined ? {} : { error: state.error }),
...(state.timing === undefined ? {} : { timing: state.timing }),
...(state.progress === undefined ? {} : { progress: state.progress }),
};
}

View file

@ -0,0 +1,80 @@
import type { FastifyInstance, FastifyRequest } from 'fastify';
const REQUEST_STARTED_AT_MS_KEY = Symbol('request-started-at-ms');
const REQUEST_COUNTED_KEY = Symbol('request-activity-counted');
function requestPath(request: FastifyRequest): string {
return request.url.split('?')[0] ?? request.url;
}
function isHealthPath(path: string): boolean {
return path === '/health/live' || path === '/health/ready';
}
function isAuthed(request: FastifyRequest, expectedToken: string): boolean {
const auth = request.headers.authorization;
return auth?.startsWith('Bearer ') === true
&& auth.slice('Bearer '.length).trim() === expectedToken;
}
function extractTraceId(request: FastifyRequest): string | null {
const header = request.headers['x-openreader-trace-id'];
if (Array.isArray(header)) return header[0] ?? null;
return typeof header === 'string' ? header : null;
}
function extractOpId(request: FastifyRequest, path: string): string | null {
const params = request.params as { opId?: unknown } | undefined;
if (typeof params?.opId === 'string' && params.opId.trim()) return params.opId.trim();
const match = path.match(/^\/v1\/operations\/([^/]+)/);
if (!match?.[1]) return null;
try {
return decodeURIComponent(match[1]);
} catch {
return match[1];
}
}
export function registerHttpHooks(input: {
app: FastifyInstance;
workerToken: string;
markActivity: (reason: string) => void;
onInFlightHttpChanged: (delta: number) => void;
}) {
const releaseHttp = (request: FastifyRequest): void => {
const counted = request as FastifyRequest & { [REQUEST_COUNTED_KEY]?: boolean };
if (!counted[REQUEST_COUNTED_KEY]) return;
counted[REQUEST_COUNTED_KEY] = false;
input.onInFlightHttpChanged(-1);
input.markActivity('http_completed');
};
input.app.addHook('onRequest', async (request, reply) => {
const path = requestPath(request);
(request as FastifyRequest & { [REQUEST_STARTED_AT_MS_KEY]?: number })[REQUEST_STARTED_AT_MS_KEY] = Date.now();
(request as FastifyRequest & { [REQUEST_COUNTED_KEY]?: boolean })[REQUEST_COUNTED_KEY] = true;
input.onInFlightHttpChanged(1);
input.markActivity(`http_started:${path}`);
if (!isHealthPath(path) && !isAuthed(request, input.workerToken)) {
return reply.code(401).send({ error: 'Unauthorized' });
}
});
input.app.addHook('onResponse', async (request, reply) => {
releaseHttp(request);
const path = requestPath(request);
if (isHealthPath(path) || reply.statusCode < 500) return;
const startedAt = (request as FastifyRequest & { [REQUEST_STARTED_AT_MS_KEY]?: number })[REQUEST_STARTED_AT_MS_KEY];
input.app.log.error({
reqId: request.id,
method: request.method,
path,
statusCode: reply.statusCode,
durationMs: Number.isFinite(startedAt) ? Math.max(0, Date.now() - (startedAt as number)) : -1,
traceId: extractTraceId(request),
opId: extractOpId(request, path),
}, 'http.error');
});
return { releaseHttp };
}

View file

@ -0,0 +1,356 @@
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { encodeSseFrame } from '../operations';
import type {
PdfLayoutJobResult,
WorkerJobTiming,
WorkerOperationEvent,
WorkerOperationRequest,
WorkerOperationState,
WhisperAlignJobResult,
} from '../operations/contracts';
import { hashOpKey } from '../infrastructure/nats-adapters';
import type { StreamedOperationState } from '../operations/recovery';
import type { ReconciliationStateStore } from '../operations/reconciliation';
import { parsedPdfArtifactKey } from '../storage/artifact-addressing';
import { buildPdfOperationKey, buildWhisperOperationKey } from '../operations/keys';
import { toComputeOperation, type ComputeOperationEvent } from './compute-operation';
import {
apiErrorResponseSchema,
jsonSchema,
operationEventsQuerySchema,
operationParamsSchema,
pdfLayoutResolutionSchema,
pdfOperationCreateSchema,
pdfResolveSchema,
computeOperationSchema,
whisperOperationCreateSchema,
} from './schemas';
const OP_EVENTS_KEEPALIVE_MS = 15_000;
const OP_EVENTS_RECONNECT_HINT_MS = 120_000;
const errorResponseSchema = jsonSchema(apiErrorResponseSchema);
interface OperationEventStreamLike {
subscribe(input: {
opId: string;
sinceEventId?: number;
onEvent: (event: WorkerOperationEvent<WhisperAlignJobResult | PdfLayoutJobResult>) => void | Promise<void>;
onError?: (error: unknown) => void;
}): Promise<() => void>;
}
interface OperationStateStoreLike extends ReconciliationStateStore {}
interface OrchestratorLike {
enqueueOrReuse(request: WorkerOperationRequest): Promise<WorkerOperationState>;
markRunning(input: {
opId: string;
startedAt?: number;
updatedAt?: number;
timing?: WorkerJobTiming;
}): Promise<unknown>;
markProgress(input: {
opId: string;
progress: WorkerOperationState['progress'];
updatedAt?: number;
timing?: WorkerJobTiming;
}): Promise<unknown>;
markSucceeded(input: {
opId: string;
result: unknown;
updatedAt?: number;
timing?: WorkerJobTiming;
}): Promise<unknown>;
markFailed(input: {
opId: string;
error: { message: string; code?: string } | string;
updatedAt?: number;
timing?: WorkerJobTiming;
}): Promise<unknown>;
markFailedIfUnchanged?(input: {
current: StreamedOperationState;
expectedRevision: number;
error: { message: string; code?: string } | string;
updatedAt?: number;
timing?: WorkerJobTiming;
}): Promise<unknown>;
}
export interface ComputeWorkerRouteDeps {
orchestrator: OrchestratorLike;
operationStateStore: OperationStateStoreLike;
operationEventStream: OperationEventStreamLike;
artifactExists?: (key: string) => Promise<boolean>;
}
function toErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) return error.message;
return String(error);
}
function isTerminalStatus(status: import('../operations/contracts').WorkerJobState): boolean {
return status === 'succeeded' || status === 'failed';
}
export function registerComputeWorkerRoutes(input: {
app: FastifyInstance;
deps: ComputeWorkerRouteDeps;
s3Prefix: string;
ensureOrphanedOpRecovery: () => Promise<void>;
getOpState: (opId: string) => Promise<StreamedOperationState | null>;
getNatsConnected: () => boolean;
releaseHttp: (request: FastifyRequest) => void;
markActivity: (reason: string) => void;
onActiveSseChanged: (delta: number) => void;
}) {
const {
app,
deps,
s3Prefix,
ensureOrphanedOpRecovery,
getOpState,
getNatsConnected,
releaseHttp,
markActivity,
onActiveSseChanged,
} = input;
app.get('/health/live', {
schema: {
security: [],
response: { 200: { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'] } },
},
}, async () => ({ ok: true }));
app.get('/health/ready', {
schema: {
security: [],
response: {
200: {
type: 'object',
properties: { ok: { type: 'boolean' }, natsConnected: { type: 'boolean' } },
required: ['ok', 'natsConnected'],
},
},
},
}, async () => ({ ok: true, natsConnected: getNatsConnected() }));
app.post('/v1/whisper-align/operations', {
schema: {
body: jsonSchema(whisperOperationCreateSchema),
response: { 202: jsonSchema(computeOperationSchema), 400: errorResponseSchema },
},
}, async (request, reply) => {
const parsed = whisperOperationCreateSchema.safeParse(request.body);
if (!parsed.success) {
reply.code(400);
return { error: 'Invalid request body', issues: parsed.error.issues };
}
const requestOp: WorkerOperationRequest = {
kind: 'whisper_align',
opKey: buildWhisperOperationKey(parsed.data),
payload: parsed.data,
};
await ensureOrphanedOpRecovery();
const op = await deps.orchestrator.enqueueOrReuse(requestOp);
app.log.info({
kind: requestOp.kind,
opId: op.opId,
jobId: op.jobId,
status: op.status,
opKeyHash: hashOpKey(requestOp.opKey.trim()).slice(0, 16),
}, 'op.accepted');
reply.code(202);
return toComputeOperation(op);
});
app.post('/v1/pdf-layout/operations', {
schema: {
body: jsonSchema(pdfOperationCreateSchema),
response: { 202: jsonSchema(computeOperationSchema), 400: errorResponseSchema },
},
}, async (request, reply) => {
const parsed = pdfOperationCreateSchema.safeParse(request.body);
if (!parsed.success) {
reply.code(400);
return { error: 'Invalid request body', issues: parsed.error.issues };
}
const requestOp: WorkerOperationRequest = {
kind: 'pdf_layout',
opKey: buildPdfOperationKey(parsed.data),
payload: {
documentId: parsed.data.documentId,
namespace: parsed.data.namespace,
documentObjectKey: parsed.data.documentObjectKey,
},
};
await ensureOrphanedOpRecovery();
const op = await deps.orchestrator.enqueueOrReuse(requestOp);
reply.code(202);
return toComputeOperation(op);
});
app.post('/v1/pdf-layout/resolve', {
schema: {
body: jsonSchema(pdfResolveSchema),
response: { 200: jsonSchema(pdfLayoutResolutionSchema), 400: errorResponseSchema },
},
}, async (request, reply) => {
const parsed = pdfResolveSchema.safeParse(request.body);
if (!parsed.success) {
reply.code(400);
return { error: 'Invalid request body', issues: parsed.error.issues };
}
await ensureOrphanedOpRecovery();
const artifactKey = parsedPdfArtifactKey({
documentId: parsed.data.documentId,
namespace: parsed.data.namespace,
prefix: s3Prefix,
});
const hasArtifact = await deps.artifactExists?.(artifactKey) ?? false;
const opKey = buildPdfOperationKey(parsed.data);
const index = await deps.operationStateStore.getOpIndex?.(opKey);
const operation = index?.opId ? await deps.operationStateStore.getOpState(index.opId) : null;
return {
artifact: hasArtifact ? { objectKey: artifactKey } : null,
operation: operation ? toComputeOperation(operation) : null,
};
});
app.get('/v1/operations/:opId', {
schema: {
params: jsonSchema(operationParamsSchema),
response: { 200: jsonSchema(computeOperationSchema), 400: errorResponseSchema, 404: errorResponseSchema },
},
}, async (request, reply) => {
const params = operationParamsSchema.safeParse(request.params);
if (!params.success) {
reply.code(400);
return { error: 'Invalid op id' };
}
const state = await getOpState(params.data.opId);
if (!state) {
reply.code(404);
return { error: 'Operation not found' };
}
return toComputeOperation(state);
});
app.get('/v1/operations/:opId/events', {
schema: {
params: jsonSchema(operationParamsSchema),
querystring: jsonSchema(operationEventsQuerySchema),
response: {
200: { type: 'string', description: 'Server-sent ComputeOperationEvent stream' },
400: errorResponseSchema,
404: errorResponseSchema,
},
},
}, async (request, reply) => {
const params = operationParamsSchema.safeParse(request.params);
if (!params.success) {
reply.code(400);
return { error: 'Invalid op id' };
}
const initial = await getOpState(params.data.opId);
if (!initial) {
reply.code(404);
return { error: 'Operation not found' };
}
const cursorQueryRaw = request.query as { sinceEventId?: string | number | null } | undefined;
const cursorFromQuery = Number(cursorQueryRaw?.sinceEventId ?? 0);
const lastEventIdHeader = request.headers['last-event-id'];
const cursorFromHeader = Number(
Array.isArray(lastEventIdHeader) ? (lastEventIdHeader[0] ?? 0) : (lastEventIdHeader ?? 0),
);
const sinceEventId = Math.max(
0,
Number.isFinite(cursorFromQuery) ? Math.floor(cursorFromQuery) : 0,
Number.isFinite(cursorFromHeader) ? Math.floor(cursorFromHeader) : 0,
);
reply.hijack();
releaseHttp(request);
onActiveSseChanged(1);
markActivity('sse_started');
reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
reply.raw.setHeader('Cache-Control', 'no-cache, no-transform');
reply.raw.setHeader('Connection', 'keep-alive');
reply.raw.setHeader('X-Accel-Buffering', 'no');
reply.raw.write(encodeSseFrame({ retry: OP_EVENTS_RECONNECT_HINT_MS }));
let closed = false;
let unsubscribe: (() => void) | null = null;
let keepalive: NodeJS.Timeout | null = null;
const writeSnapshot = (snapshot: StreamedOperationState, eventId: number): void => {
if (closed || reply.raw.writableEnded) return;
const frameEvent: ComputeOperationEvent<WhisperAlignJobResult | PdfLayoutJobResult> = {
eventId,
snapshot: toComputeOperation(snapshot),
};
reply.raw.write(encodeSseFrame({ id: eventId, event: 'snapshot', data: frameEvent }));
};
const closeStream = (): void => {
if (closed) return;
closed = true;
if (unsubscribe) {
unsubscribe();
unsubscribe = null;
}
if (keepalive) {
clearInterval(keepalive);
keepalive = null;
}
onActiveSseChanged(-1);
markActivity('sse_closed');
if (!reply.raw.writableEnded) reply.raw.end();
};
request.raw.on('close', closeStream);
try {
let current = initial;
let signature = JSON.stringify(current);
writeSnapshot(current, sinceEventId > 0 ? sinceEventId : 0);
if (isTerminalStatus(current.status)) return reply;
keepalive = setInterval(() => {
if (!closed && !reply.raw.writableEnded) reply.raw.write(': keepalive\n\n');
}, OP_EVENTS_KEEPALIVE_MS);
unsubscribe = await deps.operationEventStream.subscribe({
opId: params.data.opId,
sinceEventId,
onEvent: (event) => {
if (closed || event.snapshot.opId !== params.data.opId) return;
const nextSignature = JSON.stringify(event.snapshot);
if (nextSignature !== signature) {
current = event.snapshot;
signature = nextSignature;
markActivity('sse_event');
writeSnapshot(current, event.eventId);
}
if (isTerminalStatus(event.snapshot.status)) closeStream();
},
onError: (error) => {
app.log.warn({ opId: params.data.opId, error: toErrorMessage(error) }, 'op events stream loop error');
closeStream();
},
});
await new Promise<void>((resolve) => request.raw.once('close', resolve));
} catch (error) {
app.log.warn({ opId: params.data.opId, error: toErrorMessage(error) }, 'op events stream loop error');
} finally {
closeStream();
}
return reply;
});
}

View file

@ -0,0 +1,134 @@
import { z } from 'zod';
const bboxSchema = z.tuple([z.number(), z.number(), z.number(), z.number()]);
const parsedPdfBlockKindSchema = z.enum([
'abstract', 'algorithm', 'aside_text', 'chart', 'content', 'formula',
'doc_title', 'figure_title', 'footer', 'footnote', 'formula_number',
'header', 'image', 'number', 'paragraph_title', 'reference',
'reference_content', 'seal', 'table', 'text', 'vision_footnote',
]);
const documentIdSchema = z.string().trim().regex(/^[a-f0-9]{64}$/i);
const namespaceSchema = z.string().trim().regex(/^[a-zA-Z0-9._-]{1,128}$/).nullable();
export const parsedPdfDocumentSchema = z.object({
schemaVersion: z.literal(1),
documentId: z.string(),
parserVersion: z.string(),
parsedAt: z.number(),
pages: z.array(z.object({
pageNumber: z.number(),
width: z.number(),
height: z.number(),
blocks: z.array(z.object({
id: z.string(),
kind: parsedPdfBlockKindSchema,
fragments: z.array(z.object({
page: z.number(),
bbox: bboxSchema,
text: z.string(),
readingOrder: z.number(),
modelConfidence: z.number().optional(),
})),
text: z.string(),
parentSectionId: z.string().optional(),
})),
})),
});
export const ttsSentenceAlignmentSchema = z.object({
sentence: z.string(),
sentenceIndex: z.number(),
words: z.array(z.object({
text: z.string(),
startSec: z.number(),
endSec: z.number(),
charStart: z.number(),
charEnd: z.number(),
})),
});
export const whisperOperationCreateSchema = z.object({
text: z.string().trim().min(1),
lang: z.string().trim().min(1).max(16).optional(),
cacheKey: z.string().trim().min(1).max(256).optional(),
audioObjectKey: z.string().trim().min(1).max(2048),
});
export const pdfOperationCreateSchema = z.object({
documentId: documentIdSchema,
namespace: namespaceSchema,
documentObjectKey: z.string().trim().min(1).max(2048),
replaceToken: z.string().trim().min(1).max(256).optional(),
});
export const pdfResolveSchema = z.object({
documentId: documentIdSchema,
namespace: namespaceSchema,
documentObjectKey: z.string().trim().min(1).max(2048),
});
export const operationParamsSchema = z.object({
opId: z.string().trim().min(1),
});
export const operationEventsQuerySchema = z.object({
sinceEventId: z.union([z.string(), z.number()]).optional(),
});
export const apiErrorResponseSchema = z.object({
error: z.string(),
}).passthrough();
export const operationErrorSchema = z.object({
message: z.string(),
code: z.string().optional(),
});
export const artifactReferenceSchema = z.object({
objectKey: z.string(),
});
export const pdfLayoutProgressSchema = z.object({
totalPages: z.number(),
pagesParsed: z.number(),
currentPage: z.number().optional(),
phase: z.enum(['infer', 'merge']),
});
export const computeOperationSchema = z.object({
opId: z.string(),
subject: z.discriminatedUnion('kind', [
z.object({ kind: z.literal('whisper_align') }),
z.object({
kind: z.literal('pdf_layout'),
documentId: z.string(),
namespace: z.string().nullable(),
}),
]),
status: z.enum(['queued', 'running', 'succeeded', 'failed']),
queuedAt: z.number(),
updatedAt: z.number(),
startedAt: z.number().optional(),
result: z.unknown().optional(),
error: operationErrorSchema.optional(),
timing: z.object({
queueWaitMs: z.number().optional(),
s3FetchMs: z.number().optional(),
computeMs: z.number().optional(),
}).optional(),
progress: pdfLayoutProgressSchema.optional(),
});
export const computeOperationEventSchema = z.object({
eventId: z.number(),
snapshot: computeOperationSchema,
});
export const pdfLayoutResolutionSchema = z.object({
artifact: artifactReferenceSchema.nullable(),
operation: computeOperationSchema.nullable(),
});
export function jsonSchema(schema: z.ZodType): Record<string, unknown> {
return z.toJSONSchema(schema, { target: 'draft-7' }) as Record<string, unknown>;
}

View file

@ -61,3 +61,20 @@ export interface PdfParseProgress {
currentPage?: number;
phase: PdfParsePhase;
}
export type TTSAudioBuffer = ArrayBuffer;
export type TTSAudioBytes = number[];
export interface TTSSentenceWord {
text: string;
startSec: number;
endSec: number;
charStart: number;
charEnd: number;
}
export interface TTSSentenceAlignment {
sentence: string;
sentenceIndex: number;
words: TTSSentenceWord[];
}

View file

@ -1,4 +1,18 @@
import type { LayoutRegion, PdfTextItem } from './types';
import type { ParsedPdfBlockKind } from '../../api/types';
export interface PdfTextItem {
text: string;
x: number;
y: number;
width: number;
height: number;
}
export interface LayoutRegion {
bbox: [number, number, number, number];
label: ParsedPdfBlockKind;
confidence?: number;
}
const NON_TEXT_REGION_LABELS = new Set<LayoutRegion['label']>(['chart', 'image', 'table', 'seal']);
const TEXT_ASSIGNABLE_LABELS = new Set<LayoutRegion['label']>([

View file

@ -1,8 +1,8 @@
import * as ort from 'onnxruntime-node';
import { readFile } from 'fs/promises';
import type { LayoutRegion, PdfTextItem } from './types';
import type { LayoutRegion, PdfTextItem } from './document-layout';
import { ensureModel, MODEL_CONFIG_PATH, MODEL_PREPROCESSOR_PATH } from './model';
import { getOnnxThreadsPerJob } from '../config/cpu-budget';
import { getOnnxThreadsPerJob } from '../../infrastructure/config';
interface RunLayoutInput {
pageWidth: number;

View file

@ -3,7 +3,7 @@ import { fileURLToPath } from 'url';
import { createHash } from 'crypto';
import { readFileSync } from 'fs';
import { access, mkdir, rename, writeFile, readFile, unlink, copyFile } from 'fs/promises';
import { DOCSTORE_DIR } from '../platform/docstore';
import { DOCSTORE_DIR } from '../../infrastructure/platform';
const DEFAULT_MODEL_BASE_URL = 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main';
const PDF_LAYOUT_MODEL_BASE_URL_ENV = 'PDF_LAYOUT_MODEL_BASE_URL';

View file

@ -1,5 +1,5 @@
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
import type { PdfTextItem } from './types';
import type { PdfTextItem } from './document-layout';
interface ViewportLike {
height: number;

View file

@ -1,10 +1,10 @@
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
import type { ParsedPdfDocument, ParsedPdfPage } from '../types/parsed-pdf';
import type { ParsedPdfDocument, ParsedPdfPage } from '../../api/types';
import { ensureModel } from './model';
import { runLayoutModel } from './runLayoutModel';
import { mergeTextWithRegions } from './merge';
import { configurePdfjsNodeRuntime, resolvePdfjsStandardFontDataUrl } from './pdfjs-runtime';
import { PDF_PARSER_VERSION } from './parser-version';
import { runLayoutModel } from './layout-model';
import { mergeTextWithRegions } from './document-layout';
import { configurePdfjsNodeRuntime, resolvePdfjsStandardFontDataUrl } from './pdfjs';
import { PDF_PARSER_VERSION } from '../../operations/contracts';
import { stitchCrossPageBlocks } from './stitch';
import { renderPage } from './render';
import { normalizeTextItemsForLayout } from './normalize-text';

View file

@ -1,5 +1,5 @@
import type { Canvas } from '@napi-rs/canvas';
import { configurePdfjsNodeRuntime, resolvePdfjsStandardFontDataUrl } from './pdfjs-runtime';
import { configurePdfjsNodeRuntime, resolvePdfjsStandardFontDataUrl } from './pdfjs';
type CanvasRuntime = {
DOMMatrixCtor: unknown;

View file

@ -1,4 +1,4 @@
import type { ParsedPdfDocument, ParsedPdfBlock } from '../types/parsed-pdf';
import type { ParsedPdfDocument, ParsedPdfBlock } from '../../api/types';
const STITCHABLE_KINDS = new Set<ParsedPdfBlock['kind']>([
'text',

View file

@ -7,19 +7,18 @@ import { fileURLToPath } from 'url';
import * as ort from 'onnxruntime-node';
import { Tokenizer } from '@huggingface/tokenizers';
import JSZip from 'jszip';
import type { TTSAudioBuffer, TTSAudioBytes, TTSSentenceAlignment } from '../types/tts';
import { getFFmpegPath } from '../platform/ffmpeg';
import { getOnnxThreadsPerJob } from '../config/cpu-budget';
import { getComputeTimeoutConfig } from '../config/timeout';
import type { TTSAudioBuffer, TTSAudioBytes, TTSSentenceAlignment } from '../../api/types';
import { getFFmpegPath } from '../../infrastructure/platform';
import { getOnnxThreadsPerJob } from '../../infrastructure/config';
import { getComputeTimeoutConfig } from '../../infrastructure/config';
import {
mapWordsToSentenceOffsets,
type WhisperWord,
} from './alignment-map';
import { buildGoertzelCoefficients, goertzelPower } from './spectral';
} from './timestamps';
import {
buildWordsFromTimestampedTokens,
extractTokenStartTimestamps,
} from './token-timestamps';
} from './timestamps';
import {
ensureWhisperModel,
WHISPER_CONFIG_PATH,
@ -30,6 +29,31 @@ import {
WHISPER_DECODER_MERGED_MODEL_PATH,
WHISPER_DECODER_WITH_PAST_MODEL_PATH,
} from './model';
import {
applyTokenSuppression,
applyWhisperTimestampLogitsRules,
argmax,
} from './decoder';
export function buildGoertzelCoefficients(freqBins: number, fftSize: number): Float64Array {
const coeffs = new Float64Array(freqBins);
for (let k = 0; k < freqBins; k += 1) {
coeffs[k] = 2 * Math.cos((2 * Math.PI * k) / fftSize);
}
return coeffs;
}
export function goertzelPower(samples: Float32Array, coeff: number): number {
let s1 = 0;
let s2 = 0;
for (let i = 0; i < samples.length; i += 1) {
const s0 = samples[i] + (coeff * s1) - s2;
s2 = s1;
s1 = s0;
}
const power = (s1 * s1) + (s2 * s2) - (coeff * s1 * s2);
return !Number.isFinite(power) || power < 0 ? 0 : power;
}
interface WhisperAlignmentOptions {
lang?: string;
@ -429,118 +453,6 @@ function buildEmptyPastFeeds() {
return state.emptyPastFeedsTemplate;
}
function argmax(values: Float32Array): number | null {
let bestIdx = 0;
let bestScore = Number.NEGATIVE_INFINITY;
for (let i = 0; i < values.length; i += 1) {
const score = values[i];
if (score > bestScore) {
bestScore = score;
bestIdx = i;
}
}
return Number.isFinite(bestScore) ? bestIdx : null;
}
function applyTokenSuppression(logits: Float32Array, tokens: Set<number>) {
for (const tokenId of tokens) {
if (tokenId >= 0 && tokenId < logits.length) {
logits[tokenId] = Number.NEGATIVE_INFINITY;
}
}
}
function logSoftmax(input: Float32Array): Float32Array {
let max = Number.NEGATIVE_INFINITY;
for (let i = 0; i < input.length; i += 1) {
if (input[i] > max) max = input[i];
}
if (!Number.isFinite(max)) {
return new Float32Array(input.length).fill(Number.NEGATIVE_INFINITY);
}
let sum = 0;
for (let i = 0; i < input.length; i += 1) {
sum += Math.exp(input[i] - max);
}
const logSum = Math.log(sum);
const out = new Float32Array(input.length);
for (let i = 0; i < input.length; i += 1) {
out[i] = input[i] - max - logSum;
}
return out;
}
function applyWhisperTimestampLogitsRules(input: {
logits: Float32Array;
generated: number[];
beginIndex: number;
eosTokenId: number;
noTimestampsTokenId: number;
timestampBeginTokenId: number;
maxInitialTimestampIndex: number;
}) {
const {
logits,
generated,
beginIndex,
eosTokenId,
noTimestampsTokenId,
timestampBeginTokenId,
maxInitialTimestampIndex,
} = input;
if (noTimestampsTokenId >= 0 && noTimestampsTokenId < logits.length) {
logits[noTimestampsTokenId] = Number.NEGATIVE_INFINITY;
}
if (generated.length === beginIndex) {
const upper = Math.min(timestampBeginTokenId, logits.length);
for (let i = 0; i < upper; i += 1) logits[i] = Number.NEGATIVE_INFINITY;
}
const seq = generated.slice(beginIndex);
const lastWasTimestamp = seq.length >= 1 && seq[seq.length - 1] >= timestampBeginTokenId;
const penultimateWasTimestamp = seq.length < 2 || seq[seq.length - 2] >= timestampBeginTokenId;
if (lastWasTimestamp) {
if (penultimateWasTimestamp) {
for (let i = timestampBeginTokenId; i < logits.length; i += 1) logits[i] = Number.NEGATIVE_INFINITY;
} else {
const upper = Math.min(eosTokenId, logits.length);
for (let i = 0; i < upper; i += 1) logits[i] = Number.NEGATIVE_INFINITY;
}
}
if (generated.length === beginIndex && Number.isFinite(maxInitialTimestampIndex)) {
const lastAllowed = timestampBeginTokenId + maxInitialTimestampIndex;
for (let i = lastAllowed + 1; i < logits.length; i += 1) logits[i] = Number.NEGATIVE_INFINITY;
}
const textUpper = Math.min(timestampBeginTokenId, logits.length);
if (textUpper <= 0 || textUpper >= logits.length) return;
const logprobs = logSoftmax(logits);
let maxTextTokenLogprob = Number.NEGATIVE_INFINITY;
for (let i = 0; i < textUpper; i += 1) {
if (logprobs[i] > maxTextTokenLogprob) maxTextTokenLogprob = logprobs[i];
}
let timestampProbMass = 0;
for (let i = textUpper; i < logprobs.length; i += 1) {
timestampProbMass += Math.exp(logprobs[i]);
}
const timestampLogprob = timestampProbMass > 0 ? Math.log(timestampProbMass) : Number.NEGATIVE_INFINITY;
if (timestampLogprob > maxTextTokenLogprob) {
for (let i = 0; i < textUpper; i += 1) logits[i] = Number.NEGATIVE_INFINITY;
}
}
async function getRuntime(): Promise<WhisperRuntime> {
if (state.runtimePromise) return state.runtimePromise;

View file

@ -0,0 +1,111 @@
export function argmax(values: Float32Array): number | null {
let bestIdx = 0;
let bestScore = Number.NEGATIVE_INFINITY;
for (let i = 0; i < values.length; i += 1) {
const score = values[i];
if (score > bestScore) {
bestScore = score;
bestIdx = i;
}
}
return Number.isFinite(bestScore) ? bestIdx : null;
}
export function applyTokenSuppression(logits: Float32Array, tokens: Set<number>) {
for (const tokenId of tokens) {
if (tokenId >= 0 && tokenId < logits.length) {
logits[tokenId] = Number.NEGATIVE_INFINITY;
}
}
}
function logSoftmax(input: Float32Array): Float32Array {
let max = Number.NEGATIVE_INFINITY;
for (let i = 0; i < input.length; i += 1) {
if (input[i] > max) max = input[i];
}
if (!Number.isFinite(max)) {
return new Float32Array(input.length).fill(Number.NEGATIVE_INFINITY);
}
let sum = 0;
for (let i = 0; i < input.length; i += 1) {
sum += Math.exp(input[i] - max);
}
const logSum = Math.log(sum);
const out = new Float32Array(input.length);
for (let i = 0; i < input.length; i += 1) {
out[i] = input[i] - max - logSum;
}
return out;
}
export function applyWhisperTimestampLogitsRules(input: {
logits: Float32Array;
generated: number[];
beginIndex: number;
eosTokenId: number;
noTimestampsTokenId: number;
timestampBeginTokenId: number;
maxInitialTimestampIndex: number;
}) {
const {
logits,
generated,
beginIndex,
eosTokenId,
noTimestampsTokenId,
timestampBeginTokenId,
maxInitialTimestampIndex,
} = input;
if (noTimestampsTokenId >= 0 && noTimestampsTokenId < logits.length) {
logits[noTimestampsTokenId] = Number.NEGATIVE_INFINITY;
}
if (generated.length === beginIndex) {
const upper = Math.min(timestampBeginTokenId, logits.length);
for (let i = 0; i < upper; i += 1) logits[i] = Number.NEGATIVE_INFINITY;
}
const seq = generated.slice(beginIndex);
const lastWasTimestamp = seq.length >= 1 && seq[seq.length - 1] >= timestampBeginTokenId;
const penultimateWasTimestamp = seq.length < 2 || seq[seq.length - 2] >= timestampBeginTokenId;
if (lastWasTimestamp) {
if (penultimateWasTimestamp) {
for (let i = timestampBeginTokenId; i < logits.length; i += 1) logits[i] = Number.NEGATIVE_INFINITY;
} else {
const upper = Math.min(eosTokenId, logits.length);
for (let i = 0; i < upper; i += 1) logits[i] = Number.NEGATIVE_INFINITY;
}
}
if (generated.length === beginIndex && Number.isFinite(maxInitialTimestampIndex)) {
const lastAllowed = timestampBeginTokenId + maxInitialTimestampIndex;
for (let i = lastAllowed + 1; i < logits.length; i += 1) logits[i] = Number.NEGATIVE_INFINITY;
}
const textUpper = Math.min(timestampBeginTokenId, logits.length);
if (textUpper <= 0 || textUpper >= logits.length) return;
const logprobs = logSoftmax(logits);
let maxTextTokenLogprob = Number.NEGATIVE_INFINITY;
for (let i = 0; i < textUpper; i += 1) {
if (logprobs[i] > maxTextTokenLogprob) maxTextTokenLogprob = logprobs[i];
}
let timestampProbMass = 0;
for (let i = textUpper; i < logprobs.length; i += 1) {
timestampProbMass += Math.exp(logprobs[i]);
}
const timestampLogprob = timestampProbMass > 0 ? Math.log(timestampProbMass) : Number.NEGATIVE_INFINITY;
if (timestampLogprob > maxTextTokenLogprob) {
for (let i = 0; i < textUpper; i += 1) logits[i] = Number.NEGATIVE_INFINITY;
}
}

View file

@ -3,7 +3,7 @@ import { fileURLToPath } from 'url';
import { createHash } from 'crypto';
import { readFileSync } from 'fs';
import { access, copyFile, mkdir, readFile, rename, unlink, writeFile } from 'fs/promises';
import { DOCSTORE_DIR } from '../platform/docstore';
import { DOCSTORE_DIR } from '../../infrastructure/platform';
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
const MODEL_DIR = path.join(DOCSTORE_DIR, 'model', 'whisper-base_timestamped');

View file

@ -1,5 +1,6 @@
import type { Tokenizer } from '@huggingface/tokenizers';
import type * as ort from 'onnxruntime-node';
import type { TTSSentenceAlignment, TTSSentenceWord } from '../../api/types';
const PUNCTUATION_REGEX = '\\p{P}\\u0021-\\u002F\\u003A-\\u0040\\u005B-\\u0060\\u007B-\\u007E';
const PUNCTUATION_ONLY_REGEX = new RegExp(`^[${PUNCTUATION_REGEX}]+$`, 'gu');
@ -12,6 +13,29 @@ export interface WhisperWordTiming {
endSec: number;
}
export interface WhisperWord {
start: number;
end: number;
word: string;
}
export function mapWordsToSentenceOffsets(sentence: string, words: WhisperWord[]): TTSSentenceAlignment {
const lowerSentence = sentence.toLowerCase();
let cursor = 0;
const alignedWords: TTSSentenceWord[] = words.map((word) => {
const token = word.word.trim();
if (!token) {
return { text: '', startSec: word.start, endSec: word.end, charStart: cursor, charEnd: cursor };
}
const index = lowerSentence.indexOf(token.toLowerCase(), cursor);
const start = index >= 0 ? index : cursor;
const end = Math.min(sentence.length, start + token.length);
cursor = Math.max(cursor, end);
return { text: token, startSec: word.start, endSec: word.end, charStart: start, charEnd: end };
}).filter((word) => word.text.length > 0);
return { sentence, sentenceIndex: 0, words: alignedWords };
}
function medianFilter(data: Float32Array, windowSize: number): Float32Array {
if (windowSize % 2 === 0 || windowSize <= 0) {
throw new Error('Window size must be a positive odd number');

View file

@ -19,7 +19,13 @@ export type IdleTimeoutAndHardCapInput<T> = {
label: string;
};
function readPositiveIntEnv(name: string, fallback: number): number {
export function requireEnv(name: string): string {
const value = process.env[name]?.trim();
if (!value) throw new Error(`${name} is required`);
return value;
}
export function readPositiveIntEnv(name: string, fallback: number): number {
const raw = process.env[name]?.trim();
if (!raw) return fallback;
const parsed = Number(raw);
@ -27,6 +33,34 @@ function readPositiveIntEnv(name: string, fallback: number): number {
return Math.floor(parsed);
}
export function readBoolEnv(name: string, fallback: boolean): boolean {
const raw = process.env[name]?.trim();
if (!raw) return fallback;
return ['1', 'true', 'yes', 'on'].includes(raw.toLowerCase());
}
export function normalizeNatsReplicas(value: number): number {
return value === 3 || value === 5 ? value : 1;
}
export function buildLoggerConfig(): boolean | Record<string, unknown> {
const format = process.env.LOG_FORMAT?.trim().toLowerCase() || 'pretty';
const level = process.env.COMPUTE_LOG_LEVEL?.trim() || 'info';
if (format === 'json') return { level, base: null };
return {
level,
base: null,
transport: {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:standard',
ignore: 'pid,hostname',
},
},
};
}
let timeoutConfigCache: ComputeTimeoutConfig | null = null;
let opStaleMsCache: number | null = null;
@ -117,3 +151,23 @@ export async function withIdleTimeoutAndHardCap<T>(input: IdleTimeoutAndHardCapI
throw error;
}
}
export function getComputeJobConcurrency(): number {
return readPositiveIntEnv('COMPUTE_JOB_CONCURRENCY', 1);
}
export function getAvailableCpuCores(): number {
if (typeof os.availableParallelism === 'function') {
const value = os.availableParallelism();
if (Number.isFinite(value) && value >= 1) return Math.floor(value);
}
const fallback = os.cpus().length;
return Number.isFinite(fallback) && fallback >= 1 ? Math.floor(fallback) : 1;
}
export function getOnnxThreadsPerJob(): number {
const concurrency = getComputeJobConcurrency();
const usableCores = Math.max(1, getAvailableCpuCores() - 1);
return Math.max(1, Math.floor(usableCores / concurrency));
}
import os from 'node:os';

View file

@ -8,12 +8,12 @@ import type {
OperationState,
OperationStateStore,
QueuedOperation,
} from '@openreader/compute-core/control-plane';
} from '../operations';
import type {
PdfLayoutJobRequest,
WhisperAlignJobRequest,
WorkerOperationKind,
} from '@openreader/compute-core/api-contracts';
} from '../operations/contracts';
import { createJsonCodec } from './json-codec';
export interface KvEntryLike {

View file

@ -0,0 +1,204 @@
import type { Consumer, JetStreamClient, JetStreamManager } from '@nats-io/jetstream';
import { jetstream, jetstreamManager } from '@nats-io/jetstream';
import { Kvm } from '@nats-io/kv';
import { connect, type ConnectionOptions, type NatsConnection } from '@nats-io/transport-node';
import {
COMPUTE_STATE_BUCKET,
COMPUTE_STATE_TTL_MS,
JOBS_STREAM_NAME,
LAYOUT_CONSUMER_NAME,
NATS_API_TIMEOUT_MS,
WHISPER_CONSUMER_NAME,
ensureJetStreamResources,
} from './nats';
const IDLE_DISCONNECT_MS = 120_000;
const IDLE_CHECK_INTERVAL_MS = 5_000;
const IDLE_STATUS_LOG_INTERVAL_MS = 60_000;
const ORPHAN_SWEEP_INTERVAL_MS = 15_000;
export interface NatsSession {
nc: NatsConnection;
js: JetStreamClient;
jsm: JetStreamManager;
kv: Awaited<ReturnType<Kvm['create']>>;
whisperConsumer: Consumer;
layoutConsumer: Consumer;
}
interface NatsSessionLogger {
info(data: unknown, message?: string): void;
info(message: string): void;
error(data: unknown, message?: string): void;
}
export interface NatsActivitySnapshot {
activeSse: number;
inFlightHttp: number;
inFlightJobs: number;
lastActivityAt: number;
lastActivityReason: string;
}
function toErrorMessage(error: unknown): string {
return error instanceof Error && error.message ? error.message : String(error);
}
export function createNatsSessionManager(input: {
connectOptions: ConnectionOptions;
logger: NatsSessionLogger;
whisperTimeoutMs: number;
pdfTimeoutMs: number;
pdfAttempts: number;
jobsStreamMaxBytes: number;
eventsStreamMaxBytes: number;
jobStatesMaxBytes: number;
natsReplicas: number;
isStopping: () => boolean;
getActivity: () => NatsActivitySnapshot;
markActivity: (reason: string) => void;
startWorkers: (session: NatsSession) => void;
stopWorkers: () => Promise<void>;
runReconciliation: () => Promise<void>;
}) {
let session: NatsSession | null = null;
let connecting: Promise<NatsSession> | null = null;
let idleTimer: NodeJS.Timeout | null = null;
let orphanSweepTimer: NodeJS.Timeout | null = null;
let lastIdleStatusLogAt = 0;
let generation = -1;
const clearTimers = () => {
if (idleTimer) clearInterval(idleTimer);
if (orphanSweepTimer) clearInterval(orphanSweepTimer);
idleTimer = null;
orphanSweepTimer = null;
};
const disconnect = async (reason: string): Promise<void> => {
const current = session;
if (!current) return;
const activity = input.getActivity();
input.logger.info({
reason,
activeSse: activity.activeSse,
inFlightHttp: activity.inFlightHttp,
inFlightJobs: activity.inFlightJobs,
idleForMs: Date.now() - activity.lastActivityAt,
}, 'nats dropping connection');
session = null;
clearTimers();
try {
await current.nc.close();
} catch {
// Closing an already-closed session is harmless.
}
await input.stopWorkers();
input.logger.info({ reason }, 'nats disconnected');
};
const startTimers = () => {
if (!idleTimer) {
idleTimer = setInterval(() => {
if (!session || input.isStopping()) return;
const activity = input.getActivity();
const now = Date.now();
const idleForMs = now - activity.lastActivityAt;
if (now - lastIdleStatusLogAt >= IDLE_STATUS_LOG_INTERVAL_MS) {
lastIdleStatusLogAt = now;
input.logger.info({
...activity,
idleForMs,
disconnectEligible: activity.inFlightHttp === 0
&& activity.inFlightJobs === 0
&& idleForMs >= IDLE_DISCONNECT_MS,
}, 'nats idle status');
}
if (activity.inFlightHttp > 0 || activity.inFlightJobs > 0 || idleForMs < IDLE_DISCONNECT_MS) return;
void disconnect('idle');
}, IDLE_CHECK_INTERVAL_MS);
idleTimer.unref?.();
}
if (!orphanSweepTimer) {
orphanSweepTimer = setInterval(() => {
if (!session || input.isStopping()) return;
void input.runReconciliation().catch((error) => {
input.logger.error({ error: toErrorMessage(error) }, 'periodic orphaned operation recovery failed');
});
}, ORPHAN_SWEEP_INTERVAL_MS);
orphanSweepTimer.unref?.();
}
};
const ensureConnected = async (): Promise<NatsSession> => {
if (session) return session;
if (connecting) return connecting;
connecting = (async () => {
const nc = await connect(input.connectOptions);
const js = jetstream(nc, { timeout: NATS_API_TIMEOUT_MS });
const jsm = await jetstreamManager(nc, { timeout: NATS_API_TIMEOUT_MS });
await ensureJetStreamResources({
jsm,
whisperTimeoutMs: input.whisperTimeoutMs,
pdfTimeoutMs: input.pdfTimeoutMs,
pdfAttempts: input.pdfAttempts,
jobsMaxBytes: input.jobsStreamMaxBytes,
eventsMaxBytes: input.eventsStreamMaxBytes,
natsReplicas: input.natsReplicas,
});
const kv = await new Kvm(js).create(COMPUTE_STATE_BUCKET, {
replicas: input.natsReplicas,
history: 1,
ttl: COMPUTE_STATE_TTL_MS,
max_bytes: input.jobStatesMaxBytes,
});
const next: NatsSession = {
nc,
js,
jsm,
kv,
whisperConsumer: await js.consumers.get(JOBS_STREAM_NAME, WHISPER_CONSUMER_NAME),
layoutConsumer: await js.consumers.get(JOBS_STREAM_NAME, LAYOUT_CONSUMER_NAME),
};
session = next;
generation += 1;
input.markActivity('nats_connected');
input.startWorkers(next);
startTimers();
void nc.closed().then(() => {
if (session?.nc === nc) session = null;
});
input.logger.info('nats connected');
return next;
})();
try {
return await connecting;
} finally {
connecting = null;
}
};
return {
ensureConnected,
disconnect,
isConnected: () => session !== null,
isOwnerActive: (owner: object) => session === owner,
getGeneration: () => generation,
async close(): Promise<void> {
clearTimers();
await input.stopWorkers();
const current = session;
session = null;
if (!current) return;
try {
await current.nc.drain();
} catch {
try {
await current.nc.close();
} catch {
// Closing an already-closed session is harmless.
}
}
},
};
}

View file

@ -0,0 +1,104 @@
import {
AckPolicy,
DeliverPolicy,
ReplayPolicy,
RetentionPolicy,
StorageType,
type JetStreamManager,
} from '@nats-io/jetstream';
import { nanos } from '@nats-io/transport-node';
import { OP_EVENTS_SUBJECT_WILDCARD } from './nats-adapters';
export const JOBS_STREAM_NAME = 'compute_jobs';
export const WHISPER_JOBS_SUBJECT = 'jobs.whisper';
export const LAYOUT_JOBS_SUBJECT = 'jobs.layout';
export const WHISPER_CONSUMER_NAME = 'compute_whisper';
export const LAYOUT_CONSUMER_NAME = 'compute_layout';
export const EVENTS_STREAM_NAME = 'compute_events';
export const COMPUTE_STATE_BUCKET = 'compute_state';
export const COMPUTE_STATE_TTL_MS = 24 * 60 * 60 * 1000;
export const NATS_API_TIMEOUT_MS = 60_000;
const WHISPER_MAX_DELIVER = 1;
function isAlreadyExistsError(error: unknown): boolean {
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
return message.includes('already in use') || message.includes('already exists');
}
export async function ensureJetStreamResources(input: {
jsm: JetStreamManager;
whisperTimeoutMs: number;
pdfTimeoutMs: number;
pdfAttempts: number;
jobsMaxBytes: number;
eventsMaxBytes: number;
natsReplicas: number;
}): Promise<void> {
const streamConfig = {
name: JOBS_STREAM_NAME,
subjects: [WHISPER_JOBS_SUBJECT, LAYOUT_JOBS_SUBJECT],
retention: RetentionPolicy.Workqueue,
storage: StorageType.File,
max_bytes: input.jobsMaxBytes,
num_replicas: input.natsReplicas,
};
try {
await input.jsm.streams.add(streamConfig);
} catch (error) {
if (!isAlreadyExistsError(error)) throw error;
await input.jsm.streams.update(JOBS_STREAM_NAME, {
subjects: streamConfig.subjects,
max_bytes: input.jobsMaxBytes,
num_replicas: input.natsReplicas,
});
}
const eventsStreamConfig = {
name: EVENTS_STREAM_NAME,
subjects: [OP_EVENTS_SUBJECT_WILDCARD],
retention: RetentionPolicy.Limits,
storage: StorageType.File,
max_bytes: input.eventsMaxBytes,
max_age: nanos(COMPUTE_STATE_TTL_MS),
num_replicas: input.natsReplicas,
};
try {
await input.jsm.streams.add(eventsStreamConfig);
} catch (error) {
if (!isAlreadyExistsError(error)) throw error;
await input.jsm.streams.update(EVENTS_STREAM_NAME, {
subjects: eventsStreamConfig.subjects,
max_bytes: input.eventsMaxBytes,
max_age: eventsStreamConfig.max_age,
num_replicas: input.natsReplicas,
});
}
const ensureConsumer = async (name: string, subject: string, ackWaitMs: number, maxDeliver: number) => {
const config = {
durable_name: name,
ack_policy: AckPolicy.Explicit,
deliver_policy: DeliverPolicy.All,
replay_policy: ReplayPolicy.Instant,
filter_subject: subject,
ack_wait: nanos(Math.max(ackWaitMs, 1_000)),
max_deliver: maxDeliver,
};
try {
await input.jsm.consumers.add(JOBS_STREAM_NAME, config);
} catch (error) {
if (!isAlreadyExistsError(error)) throw error;
await input.jsm.consumers.update(JOBS_STREAM_NAME, name, {
filter_subject: subject,
ack_wait: config.ack_wait,
max_deliver: maxDeliver,
});
}
};
await Promise.all([
ensureConsumer(WHISPER_CONSUMER_NAME, WHISPER_JOBS_SUBJECT, input.whisperTimeoutMs + 15_000, WHISPER_MAX_DELIVER),
ensureConsumer(LAYOUT_CONSUMER_NAME, LAYOUT_JOBS_SUBJECT, input.pdfTimeoutMs + 15_000, input.pdfAttempts),
]);
}

View file

@ -0,0 +1,68 @@
import fs from 'fs';
import path from 'path';
import ffmpegStatic from 'ffmpeg-static';
function findMonorepoRoot(startDir: string): string | null {
let current = path.resolve(startDir);
for (;;) {
const marker = path.join(current, 'pnpm-workspace.yaml');
if (fs.existsSync(marker)) return current;
const parent = path.dirname(current);
if (parent === current) return null;
current = parent;
}
}
function resolveDocstoreDir(): string {
// 1. Try to find the monorepo root (works in local development)
const repoRoot = findMonorepoRoot(process.cwd());
if (repoRoot) return path.join(repoRoot, 'docstore');
// 2. In a containerized environment, the parent bootstrap process runs in /app
// and the child process inherits process.env.PWD = '/app'.
if (process.env.PWD) {
const pwdDocstore = path.join(process.env.PWD, 'docstore');
if (fs.existsSync(pwdDocstore)) return pwdDocstore;
}
// 3. Fallback to the standard container app docstore path if it exists
if (fs.existsSync('/app/docstore')) {
return '/app/docstore';
}
// 4. Fallback to the process cwd docstore
return path.join(process.cwd(), 'docstore');
}
export const DOCSTORE_DIR = resolveDocstoreDir();
function normalizePath(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function resolveBinary(envValue: string | null, bundledValue: string | null, envVarName: string, packageName: string): string {
if (envValue) {
if ((envValue.includes('/') || envValue.includes('\\')) && !fs.existsSync(envValue)) {
throw new Error(`${envVarName} points to a missing binary: ${envValue}`);
}
return envValue;
}
if (!bundledValue) {
throw new Error(`${packageName} binary is unavailable on this platform. Set ${envVarName} to an installed binary path.`);
}
if ((bundledValue.includes('/') || bundledValue.includes('\\')) && !fs.existsSync(bundledValue)) {
throw new Error(`${packageName} resolved to a missing binary path: ${bundledValue}`);
}
return bundledValue;
}
export function getFFmpegPath(): string {
return resolveBinary(
normalizePath(process.env.FFMPEG_BIN),
normalizePath(ffmpegStatic),
'FFMPEG_BIN',
'ffmpeg-static',
);
}

View file

@ -0,0 +1,127 @@
import {
DeleteObjectCommand,
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import { parsedPdfArtifactKey } from '../storage/artifact-addressing';
export interface ArtifactStorage {
readObject(key: string): Promise<ArrayBuffer>;
objectExists(key: string): Promise<boolean>;
deleteObject(key: string): Promise<void>;
putParsedPdf(documentId: string, namespace: string | null, parsed: unknown): Promise<string>;
}
export interface ArtifactStorageConfig {
bucket: string;
prefix: string;
client: S3Client;
}
function bodyToBuffer(body: unknown): Promise<Buffer> | Buffer {
if (!body) return Buffer.alloc(0);
if (body instanceof Uint8Array) return Buffer.from(body);
if (ArrayBuffer.isView(body)) return Buffer.from(body.buffer, body.byteOffset, body.byteLength);
if (body instanceof ArrayBuffer) return Buffer.from(body);
if (typeof body === 'object' && body !== null && 'transformToByteArray' in body) {
const maybe = body as { transformToByteArray?: () => Promise<Uint8Array> };
if (typeof maybe.transformToByteArray === 'function') {
return maybe.transformToByteArray().then((bytes) => Buffer.from(bytes));
}
}
if (typeof body === 'object' && body !== null && 'on' in body) {
return (async () => {
const chunks: Buffer[] = [];
for await (const chunk of body as NodeJS.ReadableStream) {
if (Buffer.isBuffer(chunk)) chunks.push(chunk);
else if (typeof chunk === 'string') chunks.push(Buffer.from(chunk));
else chunks.push(Buffer.from(chunk as Uint8Array));
}
return Buffer.concat(chunks);
})();
}
throw new Error('Unsupported S3 response body type');
}
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return copy.buffer;
}
function isNotFound(error: unknown): boolean {
const maybe = error as { name?: string; Code?: string; $metadata?: { httpStatusCode?: number } };
return maybe.$metadata?.httpStatusCode === 404
|| maybe.name === 'NotFound'
|| maybe.name === 'NoSuchKey'
|| maybe.Code === 'NotFound'
|| maybe.Code === 'NoSuchKey';
}
export function normalizeS3Prefix(prefix: string | undefined): string {
const value = (prefix || 'openreader').trim();
return value ? value.replace(/^\/+|\/+$/g, '') : 'openreader';
}
export function createS3ClientFromEnv(requireEnv: (name: string) => string): S3Client {
return new S3Client({
region: requireEnv('S3_REGION'),
endpoint: process.env.S3_ENDPOINT?.trim() || undefined,
forcePathStyle: ['1', 'true', 'yes', 'on'].includes(process.env.S3_FORCE_PATH_STYLE?.trim().toLowerCase() ?? ''),
requestChecksumCalculation: 'WHEN_REQUIRED',
responseChecksumValidation: 'WHEN_REQUIRED',
credentials: {
accessKeyId: requireEnv('S3_ACCESS_KEY_ID'),
secretAccessKey: requireEnv('S3_SECRET_ACCESS_KEY'),
},
});
}
export function createArtifactStorage(config: ArtifactStorageConfig): ArtifactStorage {
const safeKey = (key: string): string => {
const trimmed = key.trim();
if (!trimmed.startsWith(`${config.prefix}/`)) throw new Error('Object key prefix mismatch');
return trimmed;
};
return {
async readObject(key) {
const response = await config.client.send(new GetObjectCommand({
Bucket: config.bucket,
Key: safeKey(key),
}));
return toArrayBuffer(new Uint8Array(await bodyToBuffer(response.Body)));
},
async objectExists(key) {
try {
await config.client.send(new HeadObjectCommand({
Bucket: config.bucket,
Key: safeKey(key),
}));
return true;
} catch (error) {
if (isNotFound(error)) return false;
throw error;
}
},
async deleteObject(key) {
await config.client.send(new DeleteObjectCommand({
Bucket: config.bucket,
Key: safeKey(key),
}));
},
async putParsedPdf(documentId, namespace, parsed) {
const key = parsedPdfArtifactKey({ documentId, namespace, prefix: config.prefix });
await config.client.send(new PutObjectCommand({
Bucket: config.bucket,
Key: key,
Body: Buffer.from(JSON.stringify(parsed)),
ContentType: 'application/json',
ServerSideEncryption: 'AES256',
}));
return key;
},
};
}

View file

@ -0,0 +1,126 @@
import { z } from 'zod';
import {
runPdfLayoutFromPdfBuffer,
runWhisperAlignmentFromAudioBuffer,
} from '../inference/runtime';
import { withIdleTimeoutAndHardCap, withTimeout } from '../infrastructure/config';
import type {
PdfLayoutJobRequest,
PdfLayoutJobResult,
PdfLayoutProgress,
WhisperAlignJobRequest,
WhisperAlignJobResult,
} from '../operations/contracts';
import type { ArtifactStorage } from '../infrastructure/storage';
import { persistParsedPdfWhileSourceExists } from './pdf-artifact-persistence';
import { buildInferProgressForPageParsed, buildInferProgressForPageStart } from './pdf-progress';
const whisperRequestSchema = z.object({
text: z.string().trim().min(1),
lang: z.string().trim().min(1).max(16).optional(),
cacheKey: z.string().trim().min(1).max(256).optional(),
audioObjectKey: z.string().trim().min(1).max(2048),
});
const pdfRequestSchema = z.object({
documentId: z.string().trim().min(1),
namespace: z.string().trim().min(1).max(128).nullable(),
documentObjectKey: z.string().trim().min(1).max(2048),
});
export interface JobHandlers {
runWhisper(payload: WhisperAlignJobRequest, queueWaitMs: number): Promise<WhisperAlignJobResult>;
runPdfLayout(
payload: PdfLayoutJobRequest,
queueWaitMs: number,
hooks?: { onProgress?: (progress: PdfLayoutProgress) => Promise<void> },
): Promise<PdfLayoutJobResult>;
}
export function createJobHandlers(input: {
storage: ArtifactStorage;
whisperTimeoutMs: number;
pdfTimeoutMs: number;
pdfHardCapMs: number;
}): JobHandlers {
return {
async runWhisper(payload, queueWaitMs) {
const parsed = whisperRequestSchema.parse(payload);
const s3FetchStartedAt = Date.now();
const audioBuffer = await withTimeout(
input.storage.readObject(parsed.audioObjectKey),
input.whisperTimeoutMs,
'whisper s3 fetch',
);
const s3FetchMs = Date.now() - s3FetchStartedAt;
const computeStartedAt = Date.now();
const result = await withTimeout(
runWhisperAlignmentFromAudioBuffer({
audioBuffer,
text: parsed.text,
cacheKey: parsed.cacheKey,
lang: parsed.lang,
}),
input.whisperTimeoutMs,
'whisper alignment job',
);
return {
...result,
timing: { queueWaitMs, s3FetchMs, computeMs: Date.now() - computeStartedAt },
};
},
async runPdfLayout(payload, queueWaitMs, hooks) {
const parsed = pdfRequestSchema.parse(payload);
const s3FetchStartedAt = Date.now();
const pdfBytes = await withTimeout(
input.storage.readObject(parsed.documentObjectKey),
Math.max(input.pdfTimeoutMs, 1_000),
'pdf s3 fetch',
);
const s3FetchMs = Date.now() - s3FetchStartedAt;
let lastTotalPages = 0;
let lastPagesParsed = 0;
const computeStartedAt = Date.now();
const result = await withIdleTimeoutAndHardCap({
idleTimeoutMs: Math.max(input.pdfTimeoutMs, 1_000),
hardCapMs: input.pdfHardCapMs,
label: 'pdf layout job',
run: async (touchProgress) => runPdfLayoutFromPdfBuffer({
documentId: parsed.documentId,
pdfBytes,
onPageStarted: async ({ pageNumber, totalPages }) => {
touchProgress();
lastTotalPages = totalPages;
await hooks?.onProgress?.(buildInferProgressForPageStart({ pageNumber, totalPages }));
},
onPageParsed: async ({ pageNumber, totalPages }) => {
touchProgress();
lastTotalPages = totalPages;
lastPagesParsed = pageNumber;
await hooks?.onProgress?.(buildInferProgressForPageParsed({ pageNumber, totalPages }));
},
}),
});
const computeMs = Date.now() - computeStartedAt;
if (hooks?.onProgress && lastTotalPages > 0) {
await hooks.onProgress({
totalPages: lastTotalPages,
pagesParsed: lastPagesParsed,
currentPage: lastPagesParsed || undefined,
phase: 'merge',
});
}
const parsedObjectKey = await persistParsedPdfWhileSourceExists({
sourceObjectKey: parsed.documentObjectKey,
sourceExists: input.storage.objectExists,
putParsedObject: () => input.storage.putParsedPdf(parsed.documentId, parsed.namespace, result.parsed),
deleteParsedObject: input.storage.deleteObject,
});
return {
parsedObjectKey,
timing: { queueWaitMs, s3FetchMs, computeMs },
};
},
};
}

View file

@ -1,4 +1,4 @@
import type { PdfLayoutProgress } from '@openreader/compute-core/api-contracts';
import type { PdfLayoutProgress } from '../operations/contracts';
export function buildInferProgressForPageStart(input: {
pageNumber: number;

View file

@ -1,4 +1,4 @@
import type { WorkerOperationKind } from '@openreader/compute-core/api-contracts';
import type { WorkerOperationKind } from '../operations/contracts';
export type RetryAction = 'nak_retry' | 'term_fail';

View file

@ -0,0 +1,327 @@
import type { Consumer, JsMsg } from '@nats-io/jetstream';
import type {
PdfLayoutJobRequest,
PdfLayoutJobResult,
PdfLayoutProgress,
WhisperAlignJobRequest,
WhisperAlignJobResult,
WorkerJobTiming,
WorkerOperationKind,
} from '../operations/contracts';
import type { JsonCodec } from '../infrastructure/json-codec';
import type { JobHandlers } from './handlers';
import { buildQueueWaitTiming, decideRetryAction } from './worker-loop-policy';
const LOOP_ERROR_BACKOFF_MS = 500;
const RUNNING_HEARTBEAT_MS = 5000;
const PULL_EXPIRES_MS = 5_000;
const WHISPER_MAX_DELIVER = 1;
const SLOW_JOB_LOG_THRESHOLD_MS_BY_KIND: Record<WorkerOperationKind, number> = {
whisper_align: 15_000,
pdf_layout: 120_000,
};
export interface QueuedJob<TPayload> {
jobId: string;
opId: string;
opKey: string;
kind: WorkerOperationKind;
queuedAt: number;
payload: TPayload;
}
export interface WorkerLoopOrchestrator {
markRunning(input: { opId: string; startedAt?: number; updatedAt?: number; timing?: WorkerJobTiming }): Promise<unknown>;
markProgress(input: {
opId: string;
progress: PdfLayoutProgress;
updatedAt?: number;
timing?: WorkerJobTiming;
}): Promise<unknown>;
markSucceeded(input: { opId: string; result: unknown; updatedAt?: number; timing?: WorkerJobTiming }): Promise<unknown>;
markFailed(input: {
opId: string;
error: { message: string; code?: string } | string;
updatedAt?: number;
timing?: WorkerJobTiming;
}): Promise<unknown>;
}
export interface WorkerLogger {
info(data: unknown, message?: string): void;
warn(data: unknown, message?: string): void;
error(data: unknown, message?: string): void;
}
class ConcurrencyGate {
private inFlight = 0;
private readonly queue: Array<() => void> = [];
constructor(private readonly limit: number) {}
async acquire(): Promise<void> {
if (this.inFlight < this.limit) {
this.inFlight += 1;
return;
}
await new Promise<void>((resolve) => {
this.queue.push(() => {
this.inFlight += 1;
resolve();
});
});
}
release(): void {
this.inFlight = Math.max(0, this.inFlight - 1);
this.queue.shift()?.();
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function toErrorMessage(error: unknown): string {
return error instanceof Error && error.message ? error.message : String(error);
}
function safeDurationMs(start: number, end: number): number {
return Math.max(0, Math.floor(end - start));
}
function extractTiming(result: unknown): WorkerJobTiming | undefined {
if (!result || typeof result !== 'object' || !('timing' in result)) return undefined;
return (result as { timing?: WorkerJobTiming }).timing;
}
function extractResultRef(kind: WorkerOperationKind, result: unknown): string | undefined {
if (kind !== 'pdf_layout' || !result || typeof result !== 'object') return undefined;
const maybe = result as { parsedObjectKey?: unknown };
return typeof maybe.parsedObjectKey === 'string' ? maybe.parsedObjectKey : undefined;
}
export function createWorkerLoopController(input: {
orchestrator: WorkerLoopOrchestrator;
handlers: JobHandlers;
logger: WorkerLogger;
jobConcurrency: number;
pdfAttempts: number;
whisperCodec: JsonCodec<QueuedJob<WhisperAlignJobRequest>>;
pdfCodec: JsonCodec<QueuedJob<PdfLayoutJobRequest>>;
isOwnerActive: (owner: object) => boolean;
isStopping: () => boolean;
markActivity: (reason: string) => void;
onInFlightJobsChanged: (delta: number) => void;
}) {
const gate = new ConcurrencyGate(Math.max(1, Math.floor(input.jobConcurrency)));
let loops: Promise<void>[] = [];
let stopRequested = false;
type Context<TPayload> = {
decoded: QueuedJob<TPayload>;
workerLabel: string;
startedAt: number;
queueWaitTiming?: { queueWaitMs: number };
latestProgress?: PdfLayoutProgress;
};
type JobRunner<TPayload, TResult> = (
payload: TPayload,
queueWaitMs: number,
hooks?: { onProgress?: (progress: PdfLayoutProgress) => Promise<void> },
) => Promise<TResult>;
type WorkDefinition<TPayload, TResult> = {
codec: JsonCodec<QueuedJob<TPayload>>;
run: JobRunner<TPayload, TResult>;
};
const markRunning = async <TPayload>(context: Context<TPayload>, updatedAt: number): Promise<void> => {
if (context.latestProgress) {
await input.orchestrator.markProgress({
opId: context.decoded.opId,
progress: context.latestProgress,
updatedAt,
...(context.queueWaitTiming ? { timing: context.queueWaitTiming } : {}),
});
return;
}
await input.orchestrator.markRunning({
opId: context.decoded.opId,
startedAt: context.startedAt,
updatedAt,
...(context.queueWaitTiming ? { timing: context.queueWaitTiming } : {}),
});
};
const processMessage = async <TPayload, TResult>(work: WorkDefinition<TPayload, TResult> & {
msg: JsMsg;
workerLabel: string;
}): Promise<void> => {
let context: Context<TPayload> | null = null;
let heartbeat: NodeJS.Timeout | null = null;
try {
const decoded = work.codec.decode(work.msg.data);
const startedAt = Date.now();
context = {
decoded,
workerLabel: work.workerLabel,
startedAt,
queueWaitTiming: buildQueueWaitTiming(decoded.queuedAt, startedAt),
};
await markRunning(context, startedAt);
input.logger.info({
worker: work.workerLabel,
kind: decoded.kind,
opId: decoded.opId,
jobId: decoded.jobId,
queueWaitMs: context.queueWaitTiming?.queueWaitMs ?? null,
deliveryCount: work.msg.info.deliveryCount,
}, 'job.started');
heartbeat = setInterval(() => {
void markRunning(context!, Date.now()).catch((error) => {
input.logger.error({
worker: work.workerLabel,
opId: context?.decoded.opId,
jobId: context?.decoded.jobId,
error: toErrorMessage(error),
}, 'failed to persist operation heartbeat state');
});
}, RUNNING_HEARTBEAT_MS);
const result = await work.run(decoded.payload, context.queueWaitTiming?.queueWaitMs ?? 0, {
onProgress: async (progress) => {
try {
work.msg.working();
} catch (error) {
input.logger.warn({
worker: work.workerLabel,
kind: context?.decoded.kind,
opId: context?.decoded.opId,
jobId: context?.decoded.jobId,
error: toErrorMessage(error),
}, 'failed to extend JetStream ack wait on progress');
}
context!.latestProgress = progress;
await markRunning(context!, Date.now());
},
});
const timing = extractTiming(result);
const now = Date.now();
await input.orchestrator.markSucceeded({
opId: decoded.opId,
result,
updatedAt: now,
...(timing ? { timing } : {}),
});
work.msg.ack();
const durationMs = safeDurationMs(startedAt, now);
if (durationMs >= SLOW_JOB_LOG_THRESHOLD_MS_BY_KIND[decoded.kind]) {
input.logger.info({ worker: work.workerLabel, kind: decoded.kind, opId: decoded.opId, jobId: decoded.jobId, durationMs, timing: timing ?? null }, 'job.stage');
}
input.logger.info({
worker: work.workerLabel,
kind: decoded.kind,
opId: decoded.opId,
jobId: decoded.jobId,
status: 'succeeded',
durationMs,
resultRef: extractResultRef(decoded.kind, result),
timing: timing ?? null,
}, 'job.terminal');
} catch (error) {
const errorMessage = toErrorMessage(error);
const deliveryCount = work.msg.info.deliveryCount;
const kind = context?.decoded.kind ?? 'pdf_layout';
const action = decideRetryAction({ kind, deliveryCount, pdfAttempts: input.pdfAttempts, whisperMaxDeliver: WHISPER_MAX_DELIVER });
const timing = context ? buildQueueWaitTiming(context.decoded.queuedAt, Date.now()) : undefined;
if (context) {
const update = action === 'nak_retry'
? markRunning(context, Date.now())
: input.orchestrator.markFailed({
opId: context.decoded.opId,
error: { message: errorMessage },
updatedAt: Date.now(),
...(timing ? { timing } : {}),
});
await update.catch((stateError) => input.logger.error({
worker: context?.workerLabel,
opId: context?.decoded.opId,
jobId: context?.decoded.jobId,
error: toErrorMessage(stateError),
}, 'failed to persist operation state'));
}
if (action === 'nak_retry') work.msg.nak();
else work.msg.term(errorMessage);
input.logger.error({
worker: context?.workerLabel,
kind: context?.decoded.kind,
opId: context?.decoded.opId,
jobId: context?.decoded.jobId,
status: action === 'nak_retry' ? 'running' : 'failed',
error: errorMessage,
deliveryCount,
retryAction: action === 'nak_retry' ? 'nack_retry' : 'term',
}, 'job.terminal');
} finally {
if (heartbeat) clearInterval(heartbeat);
}
};
const runLoop = async <TPayload, TResult>(work: WorkDefinition<TPayload, TResult> & {
owner: object;
consumer: Consumer;
workerLabel: string;
}): Promise<void> => {
const detached = () => input.isStopping() || stopRequested || !input.isOwnerActive(work.owner);
while (!detached()) {
let msg: JsMsg | null = null;
try {
try {
msg = await work.consumer.next({ expires: PULL_EXPIRES_MS });
} catch (error) {
if (detached()) return;
input.logger.error({ error: toErrorMessage(error), worker: work.workerLabel }, 'worker pull failed');
await sleep(LOOP_ERROR_BACKOFF_MS);
continue;
}
if (!msg) continue;
input.markActivity(`job_received:${work.workerLabel}`);
input.onInFlightJobsChanged(1);
await gate.acquire();
if (detached()) return;
await processMessage({ ...work, msg });
} finally {
if (msg) {
gate.release();
input.onInFlightJobsChanged(-1);
input.markActivity(`job_completed:${work.workerLabel}`);
}
}
}
};
return {
start(owner: object, consumers: { whisper: Consumer; pdfLayout: Consumer }): void {
stopRequested = false;
loops = [];
const whisperWork: WorkDefinition<WhisperAlignJobRequest, WhisperAlignJobResult> = {
codec: input.whisperCodec,
run: input.handlers.runWhisper,
};
const pdfWork: WorkDefinition<PdfLayoutJobRequest, PdfLayoutJobResult> = {
codec: input.pdfCodec,
run: input.handlers.runPdfLayout,
};
for (let i = 0; i < input.jobConcurrency; i += 1) {
loops.push(runLoop({ owner, consumer: consumers.whisper, ...whisperWork, workerLabel: `whisper-${i + 1}` }));
loops.push(runLoop({ owner, consumer: consumers.pdfLayout, ...pdfWork, workerLabel: `layout-${i + 1}` }));
}
},
async stop(): Promise<void> {
stopRequested = true;
await Promise.allSettled(loops);
loops = [];
},
};
}

View file

@ -1,24 +1,26 @@
import type { TTSSentenceAlignment } from '../types/tts';
import type { ParsedPdfDocument } from '../types/parsed-pdf';
import type { TTSSentenceAlignment, ParsedPdfDocument } from '../api/types';
export type {
TTSAudioBuffer,
TTSAudioBytes,
TTSSentenceAlignment,
TTSSentenceWord,
} from '../types/tts';
} from '../api/types';
export type {
ParsedPdfBlockKind,
ParsedPdfBlockFragment,
ParsedPdfBlock,
ParsedPdfPage,
ParsedPdfDocument,
} from '../types/parsed-pdf';
} from '../api/types';
export const ALIGN_QUEUE_NAME = 'whisper-align';
export const PDF_LAYOUT_QUEUE_NAME = 'pdf-layout';
export { PDF_PARSER_VERSION } from '../pdf/parser-version';
export { encodeParserVersion } from '../pdf/parser-version-key';
export const PDF_PARSER_VERSION = 'pp-doclayoutv3-onnx@800+pdfjs@4.8.69';
export function encodeParserVersion(parserVersion: string, defaultVersion = PDF_PARSER_VERSION): string {
return encodeURIComponent(parserVersion.trim() || defaultVersion);
}
export interface WhisperAlignJobBase {
text: string;

View file

@ -1,4 +1,4 @@
export * from './types';
export * from './state-machine';
export * from './orchestrator';
export * from './service';
export * from './sse';

View file

@ -0,0 +1,56 @@
import { createHash } from 'node:crypto';
import { PDF_PARSER_VERSION } from './contracts';
function sha256Hex(input: string): string {
return createHash('sha256').update(input).digest('hex');
}
export function buildWhisperOperationKey(input: {
audioObjectKey: string;
text: string;
lang?: string;
cacheKey?: string;
}): string {
const cacheKey = input.cacheKey?.trim();
if (cacheKey) {
return `whisper_align|v1|cache|${cacheKey}|${input.audioObjectKey}`;
}
return [
'whisper_align',
'v1',
input.audioObjectKey,
input.lang ?? '',
sha256Hex(input.text),
].join('|');
}
export function buildPdfOperationKey(input: {
documentId: string;
namespace: string | null;
documentObjectKey: string;
replaceToken?: string;
}, parserVersion = PDF_PARSER_VERSION): string {
return [
'pdf_layout',
'v1',
parserVersion,
input.documentId,
input.namespace ?? '',
input.documentObjectKey,
input.replaceToken?.trim() || '',
].join('|');
}
export function pdfSubjectFromOperationKey(opKey: string): {
kind: 'pdf_layout';
documentId: string;
namespace: string | null;
} | null {
const [kind, version, , documentId, namespace] = opKey.split('|');
if (kind !== 'pdf_layout' || version !== 'v1' || !documentId) return null;
return {
kind: 'pdf_layout',
documentId,
namespace: namespace || null,
};
}

View file

@ -0,0 +1,95 @@
import type { WorkerJobTiming } from './contracts';
import {
recoverOrphanedOperations,
type StreamedOperationState,
} from './recovery';
export interface ReconciliationStateStore {
getOpState(opId: string): Promise<StreamedOperationState | null>;
getOpStateRecord?(opId: string): Promise<{ state: StreamedOperationState; revision: number } | null>;
getOpIndex?(opKey: string): Promise<{ opId: string } | null>;
listOpStates?(): Promise<StreamedOperationState[]>;
}
export interface ReconciliationOrchestrator {
markFailedIfUnchanged?(input: {
current: StreamedOperationState;
expectedRevision: number;
error: { message: string; code?: string } | string;
updatedAt?: number;
timing?: WorkerJobTiming;
}): Promise<unknown>;
}
interface ReconciliationLogger {
warn(data: unknown, message?: string): void;
}
function supportsRecovery(
stateStore: ReconciliationStateStore,
orchestrator: ReconciliationOrchestrator,
): boolean {
return typeof stateStore.listOpStates === 'function'
&& typeof stateStore.getOpStateRecord === 'function'
&& typeof orchestrator.markFailedIfUnchanged === 'function';
}
export function createOperationReconciler(input: {
stateStore: ReconciliationStateStore;
orchestrator: ReconciliationOrchestrator;
whisperTimeoutMs: number;
pdfTimeoutMs: number;
opStaleMs: number;
getGeneration: () => number;
logger: ReconciliationLogger;
}) {
let recoveryPromise: Promise<void> | null = null;
let recoveredGeneration = -1;
const run = async (options?: { force?: boolean }): Promise<void> => {
if (!supportsRecovery(input.stateStore, input.orchestrator)) return;
const generation = input.getGeneration();
if (!options?.force && recoveredGeneration === generation) return;
if (recoveryPromise) return await recoveryPromise;
recoveryPromise = (async () => {
const recoveredStates = await recoverOrphanedOperations({
operationStateStore: {
getOpStateRecord: (opId) => input.stateStore.getOpStateRecord!(opId),
listOpStates: () => input.stateStore.listOpStates!(),
},
orchestrator: {
markFailedIfUnchanged: async (request) => {
const result = await input.orchestrator.markFailedIfUnchanged!(request);
return result as StreamedOperationState | null;
},
},
whisperTimeoutMs: input.whisperTimeoutMs,
pdfTimeoutMs: input.pdfTimeoutMs,
opStaleMs: input.opStaleMs,
});
if (recoveredStates.length > 0) {
input.logger.warn({
recoveredCount: recoveredStates.length,
ops: recoveredStates.map((state) => ({
opId: state.opId,
kind: state.kind,
status: state.status,
})),
}, 'recovered stale in-flight operations during reconciliation');
}
recoveredGeneration = generation;
})().finally(() => {
recoveryPromise = null;
});
await recoveryPromise;
};
return {
run,
async getOpState(opId: string): Promise<StreamedOperationState | null> {
await run();
return await input.stateStore.getOpState(opId);
},
};
}

View file

@ -4,7 +4,7 @@ import type {
WorkerJobTiming,
WorkerJobState,
WorkerOperationState,
} from '@openreader/compute-core/api-contracts';
} from '../operations/contracts';
export type StreamedOperationState = WorkerOperationState<WhisperAlignJobResult | PdfLayoutJobResult>;

View file

@ -5,7 +5,7 @@ import type {
WorkerOperationKind,
WorkerOperationRequest,
WorkerOperationState,
} from '../api-contracts';
} from '../operations/contracts';
import {
buildQueuedState,
createErrorShape,

View file

@ -4,7 +4,7 @@ import type {
WorkerOperationKind,
WorkerOperationRequest,
WorkerOperationState,
} from '../api-contracts';
} from '../operations/contracts';
export function isTerminalStatus(status: WorkerJobState): boolean {
return status === 'succeeded' || status === 'failed';

View file

@ -2,7 +2,7 @@ import type {
WorkerOperationEvent,
WorkerOperationKind,
WorkerOperationState,
} from '../api-contracts';
} from '../operations/contracts';
export type OperationState<Result = unknown> = WorkerOperationState<Result>;
export type OperationEvent<Result = unknown> = WorkerOperationEvent<Result>;

View file

@ -1,4 +1,4 @@
import { startComputeWorkerFromEnv } from './runtime';
import { startComputeWorkerFromEnv } from './api/app';
void startComputeWorkerFromEnv().catch((error) => {
console.error('[compute-worker] fatal startup error', error);

View file

@ -0,0 +1,21 @@
import { PDF_PARSER_VERSION } from '../operations/contracts';
import { encodeParserVersion } from '../operations/contracts';
const DOCUMENT_ID_REGEX = /^[a-f0-9]{64}$/i;
const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/;
export function parsedPdfArtifactKey(input: {
documentId: string;
namespace: string | null;
prefix: string;
parserVersion?: string;
}): string {
if (!DOCUMENT_ID_REGEX.test(input.documentId)) {
throw new Error(`Invalid document id: ${input.documentId}`);
}
const namespace = input.namespace && SAFE_NAMESPACE_REGEX.test(input.namespace)
? input.namespace
: null;
const namespaceSegment = namespace ? `ns/${namespace}/` : '';
return `${input.prefix}/documents_v1/parsed_v2/${namespaceSegment}${input.documentId}/${encodeParserVersion(input.parserVersion ?? PDF_PARSER_VERSION)}.json`;
}

View file

@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { createComputeWorkerApp } from '../../src/runtime';
import { createComputeWorkerApp } from '../../src/api/app';
import { FakeControlPlane } from '../fixtures/fake-control-plane';
const AUTH = { authorization: 'Bearer test-token' };
@ -25,53 +25,51 @@ describe('compute worker API routes', () => {
const live = await runtime.app.inject({ method: 'GET', url: '/health/live' });
expect(live.statusCode).toBe(200);
const protectedRoute = await runtime.app.inject({ method: 'GET', url: '/ops/op-1' });
const protectedRoute = await runtime.app.inject({ method: 'GET', url: '/v1/operations/op-1' });
expect(protectedRoute.statusCode).toBe(401);
});
test('validates operation creation body and returns 400 for invalid payload', async () => {
const response = await runtime.app.inject({
method: 'POST',
url: '/ops',
url: '/v1/pdf-layout/operations',
headers: AUTH,
payload: {
kind: 'pdf_layout',
opKey: '',
payload: {
documentId: 'd1',
namespace: null,
documentObjectKey: 's3://bucket/doc.pdf',
},
documentId: '',
namespace: null,
documentObjectKey: 'openreader/doc.pdf',
},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({ error: 'Invalid request body' });
expect(response.json()).toMatchObject({ error: 'Bad Request' });
});
test('creates operation and fetches state by op id', async () => {
const documentId = 'c'.repeat(64);
const create = await runtime.app.inject({
method: 'POST',
url: '/ops',
url: '/v1/pdf-layout/operations',
headers: AUTH,
payload: {
kind: 'pdf_layout',
opKey: 'doc-1:layout',
payload: {
documentId: 'doc-1',
namespace: null,
documentObjectKey: 'openreader/doc-1.pdf',
},
documentId,
namespace: null,
documentObjectKey: `openreader/${documentId}.pdf`,
},
});
expect(create.statusCode).toBe(202);
const created = create.json();
expect(created).toMatchObject({ kind: 'pdf_layout', status: 'queued' });
expect(created).toMatchObject({
subject: { kind: 'pdf_layout', documentId, namespace: null },
status: 'queued',
});
expect(created).not.toHaveProperty('opKey');
expect(created).not.toHaveProperty('jobId');
const fetch = await runtime.app.inject({
method: 'GET',
url: `/ops/${created.opId}`,
url: `/v1/operations/${created.opId}`,
headers: AUTH,
});
@ -79,17 +77,120 @@ describe('compute worker API routes', () => {
expect(fetch.json()).toMatchObject({ opId: created.opId, status: 'queued' });
});
test('reuses idempotent PDF requests and replaces them only with an explicit token', async () => {
const documentId = 'e'.repeat(64);
const payload = {
documentId,
namespace: null,
documentObjectKey: `openreader/${documentId}.pdf`,
};
const create = (body: typeof payload & { replaceToken?: string }) => runtime.app.inject({
method: 'POST',
url: '/v1/pdf-layout/operations',
headers: AUTH,
payload: body,
});
const initial = (await create(payload)).json();
const reused = (await create(payload)).json();
const replacement = (await create({ ...payload, replaceToken: 'replace-1' })).json();
expect(reused.opId).toBe(initial.opId);
expect(replacement.opId).not.toBe(initial.opId);
expect(replacement.subject).toEqual(initial.subject);
});
test('creates whisper alignment operations without exposing internal keys', async () => {
const response = await runtime.app.inject({
method: 'POST',
url: '/v1/whisper-align/operations',
headers: AUTH,
payload: {
text: 'Canonical worker text',
audioObjectKey: 'openreader/audio.mp3',
lang: 'en',
},
});
expect(response.statusCode).toBe(202);
expect(response.json()).toMatchObject({
subject: { kind: 'whisper_align' },
status: 'queued',
});
expect(response.json()).not.toHaveProperty('opKey');
});
test('resolves the current PDF artifact and operation without exposing parser identity', async () => {
const documentId = 'a'.repeat(64);
const create = await runtime.app.inject({
method: 'POST',
url: '/v1/pdf-layout/operations',
headers: AUTH,
payload: {
documentId,
namespace: null,
documentObjectKey: `openreader/${documentId}.pdf`,
},
});
expect(create.statusCode).toBe(202);
const artifactKey = `openreader/documents_v1/parsed_v2/${documentId}/pp-doclayoutv3-onnx%40800%2Bpdfjs%404.8.69.json`;
fake.seedArtifact(artifactKey);
const resolve = await runtime.app.inject({
method: 'POST',
url: '/v1/pdf-layout/resolve',
headers: AUTH,
payload: {
documentId,
namespace: null,
documentObjectKey: `openreader/${documentId}.pdf`,
},
});
expect(resolve.statusCode).toBe(200);
expect(resolve.json()).toMatchObject({
artifact: { objectKey: artifactKey },
operation: {
subject: { kind: 'pdf_layout', documentId, namespace: null },
},
});
expect(resolve.json().operation).not.toHaveProperty('opKey');
});
test('resolves a current PDF artifact after operation state has expired', async () => {
const documentId = 'd'.repeat(64);
const artifactKey = `openreader/documents_v1/parsed_v2/${documentId}/pp-doclayoutv3-onnx%40800%2Bpdfjs%404.8.69.json`;
fake.seedArtifact(artifactKey);
const resolve = await runtime.app.inject({
method: 'POST',
url: '/v1/pdf-layout/resolve',
headers: AUTH,
payload: {
documentId,
namespace: null,
documentObjectKey: `openreader/${documentId}.pdf`,
},
});
expect(resolve.statusCode).toBe(200);
expect(resolve.json()).toEqual({
artifact: { objectKey: artifactKey },
operation: null,
});
});
test('returns not found for unknown operation and event stream lookups', async () => {
const opResponse = await runtime.app.inject({
method: 'GET',
url: '/ops/missing',
url: '/v1/operations/missing',
headers: AUTH,
});
expect(opResponse.statusCode).toBe(404);
const eventsResponse = await runtime.app.inject({
method: 'GET',
url: '/ops/missing/events',
url: '/v1/operations/missing/events',
headers: AUTH,
});
expect(eventsResponse.statusCode).toBe(404);
@ -109,7 +210,7 @@ describe('compute worker API routes', () => {
const stream = await runtime.app.inject({
method: 'GET',
url: '/ops/op-terminal/events?sinceEventId=7',
url: '/v1/operations/op-terminal/events?sinceEventId=7',
headers: AUTH,
});
@ -159,11 +260,11 @@ describe('compute worker API routes', () => {
updatedAt: now - 310_000,
});
// GET /ops/:opId resolves via getOpState(), which first awaits the shared
// GET /v1/operations/:opId resolves via getOpState(), which first awaits the shared
// orphanRecoveryPromise path through ensureOrphanedOpRecovery().
const fetch = await runtime.app.inject({
method: 'GET',
url: '/ops/op-stale-whisper-running',
url: '/v1/operations/op-stale-whisper-running',
headers: AUTH,
});

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from 'vitest';
import { mergeTextWithRegions } from '@openreader/compute-core';
import { mergeTextWithRegions } from '../../../src/inference/pdf/document-layout';
describe('mergeTextWithRegions', () => {
test('assigns text items to containing regions by centroid and joins in reading order', () => {

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from 'vitest';
import { normalizeTextItemsForLayout } from '@openreader/compute-core';
import { normalizeTextItemsForLayout } from '../../../src/inference/pdf/normalize-text';
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
function makeTextItem(

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from 'vitest';
import { stitchCrossPageBlocks } from '@openreader/compute-core';
import type { ParsedPdfBlock, ParsedPdfDocument, ParsedPdfBlockKind } from '../../src/types/parsed-pdf';
import { stitchCrossPageBlocks } from '../../../src/inference/pdf/stitch';
import type { ParsedPdfBlock, ParsedPdfDocument, ParsedPdfBlockKind } from '../../../src/api/types';
import { makeParsedPdfDocument, makeParsedPdfPage } from './support/document-fixtures';
function makeBlock(

View file

@ -0,0 +1,54 @@
import type { BaseDocument } from '../../../../../../src/types/documents';
import type { ParsedPdfBlock, ParsedPdfBlockKind, ParsedPdfDocument, ParsedPdfPage } from '../../../../src/api/types';
export function makeBaseDocument(overrides: Partial<BaseDocument> = {}): BaseDocument {
return {
id: 'doc-1',
name: 'document.pdf',
size: 1_024,
lastModified: 1_700_000_000_000,
type: 'pdf',
...overrides,
};
}
export function makeParsedPdfBlock(input: {
id: string;
kind: ParsedPdfBlockKind;
text: string;
page: number;
readingOrder: number;
}): ParsedPdfBlock {
return {
id: input.id,
kind: input.kind,
text: input.text,
fragments: [
{
page: input.page,
bbox: [0, 0, 100, 10],
text: input.text,
readingOrder: input.readingOrder,
},
],
};
}
export function makeParsedPdfPage(pageNumber: number, blocks: ParsedPdfBlock[]): ParsedPdfPage {
return {
pageNumber,
width: 800,
height: 1_200,
blocks,
};
}
export function makeParsedPdfDocument(pages: ParsedPdfPage[]): ParsedPdfDocument {
return {
schemaVersion: 1,
documentId: 'doc-fixture',
parserVersion: 'test',
parsedAt: 1_700_000_000_000,
pages,
};
}

View file

@ -1,7 +1,7 @@
import { describe, expect, test } from 'vitest';
import {
mapWordsToSentenceOffsets,
} from '@openreader/compute-core';
} from '../../../src/inference/whisper/timestamps';
describe('whisper alignment mapping', () => {
test('maps words to sentence offsets with punctuation and repeated spaces', () => {

Some files were not shown because too many files have changed in this diff Show more