refactor: implement modular monorepo packages
- Extract database schema and migrations into @openreader/database - Extract startup and orchestration scripts into @openreader/bootstrap - Move compute-worker into packages/compute-worker - Remove runtime dependency on drizzle-kit - Update Dockerfile to deploy isolated packages without merging node_modules
This commit is contained in:
parent
2abadde949
commit
358a56cc9a
211 changed files with 497 additions and 466 deletions
4
.github/workflows/docker-publish.yml
vendored
4
.github/workflows/docker-publish.yml
vendored
|
|
@ -62,13 +62,13 @@ jobs:
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
runner: ubuntu-24.04
|
runner: ubuntu-24.04
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./compute-worker/Dockerfile
|
dockerfile: ./packages/compute-worker/Dockerfile
|
||||||
- image_target: compute-worker
|
- image_target: compute-worker
|
||||||
arch: arm64
|
arch: arm64
|
||||||
platform: linux/arm64
|
platform: linux/arm64
|
||||||
runner: ubuntu-24.04-arm
|
runner: ubuntu-24.04-arm
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./compute-worker/Dockerfile
|
dockerfile: ./packages/compute-worker/Dockerfile
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
|
|
|
||||||
41
Dockerfile
41
Dockerfile
|
|
@ -20,8 +20,9 @@ WORKDIR /app
|
||||||
|
|
||||||
# Copy workspace manifests needed for dependency installation
|
# Copy workspace manifests needed for dependency installation
|
||||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
COPY compute-worker/package.json ./compute-worker/package.json
|
COPY packages/bootstrap/package.json ./packages/bootstrap/package.json
|
||||||
COPY docker/entrypoint-migration-tools/package.json ./docker/entrypoint-migration-tools/package.json
|
COPY packages/compute-worker/package.json ./packages/compute-worker/package.json
|
||||||
|
COPY packages/database/package.json ./packages/database/package.json
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
@ -32,8 +33,9 @@ COPY . .
|
||||||
# Build the Next.js application
|
# Build the Next.js application
|
||||||
RUN pnpm exec next telemetry disable
|
RUN pnpm exec next telemetry disable
|
||||||
RUN AUTH_SECRET=build-placeholder-secret-value-32chars!! BASE_URL=http://localhost:3003 pnpm build
|
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/bootstrap deploy /opt/openreader/bootstrap
|
||||||
RUN pnpm --config.inject-workspace-packages=true --filter @openreader/compute-worker deploy /opt/embedded-compute-worker
|
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.
|
# Generate third-party dependency license report plus copied license files.
|
||||||
RUN mkdir -p /app/THIRD_PARTY_LICENSES && \
|
RUN mkdir -p /app/THIRD_PARTY_LICENSES && \
|
||||||
pnpm dlx license-checker-rseidelsohn@4.3.0 \
|
pnpm dlx license-checker-rseidelsohn@4.3.0 \
|
||||||
|
|
@ -62,34 +64,15 @@ COPY --from=app-builder /app/.next/standalone ./
|
||||||
COPY --from=app-builder /app/.next/static ./.next/static
|
COPY --from=app-builder /app/.next/static ./.next/static
|
||||||
COPY --from=app-builder /app/public ./public
|
COPY --from=app-builder /app/public ./public
|
||||||
|
|
||||||
# Copy the entrypoint and migration/runtime helper files it invokes directly.
|
# Ship startup orchestration and the embedded worker as independent deployed bundles.
|
||||||
COPY --from=app-builder /app/scripts/openreader-entrypoint.mjs ./scripts/openreader-entrypoint.mjs
|
COPY --from=app-builder /opt/openreader/bootstrap /opt/openreader/bootstrap
|
||||||
COPY --from=app-builder /app/scripts/migrate-fs-v2.mjs ./scripts/migrate-fs-v2.mjs
|
COPY --from=app-builder /opt/openreader/embedded-compute-worker /opt/openreader/embedded-compute-worker
|
||||||
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 /app/node_modules/pg && \
|
|
||||||
rm -rf /tmp/runtime-tools-node_modules/@aws-sdk \
|
|
||||||
/tmp/runtime-tools-node_modules/better-sqlite3 \
|
|
||||||
/tmp/runtime-tools-node_modules/ffmpeg-static && \
|
|
||||||
cp -an /tmp/runtime-tools-node_modules/. /app/node_modules/ && \
|
|
||||||
rm -rf /tmp/runtime-tools-node_modules
|
|
||||||
# Drizzle Kit discovers the Postgres driver through ESM import during startup migrations.
|
|
||||||
RUN node --input-type=module -e "await import('pg')"
|
|
||||||
|
|
||||||
# Ship the embedded compute worker as a separate deployed bundle.
|
|
||||||
COPY --from=app-builder /opt/embedded-compute-worker ./embedded-compute-worker
|
|
||||||
# Include third-party license report and copied license texts at a stable path in the image.
|
# 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
|
COPY --from=app-builder /app/THIRD_PARTY_LICENSES /licenses
|
||||||
# Include SeaweedFS license text for the copied weed binary.
|
# Include SeaweedFS license text for the copied weed binary.
|
||||||
COPY --from=seaweedfs-builder /tmp/SeaweedFS-LICENSE.txt /licenses/SeaweedFS-LICENSE.txt
|
COPY --from=seaweedfs-builder /tmp/SeaweedFS-LICENSE.txt /licenses/SeaweedFS-LICENSE.txt
|
||||||
# Include static model notices for runtime-downloaded assets.
|
# Include static model notices for runtime-downloaded assets.
|
||||||
COPY --from=app-builder /app/compute-worker/src/inference/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 seaweedfs weed binary for optional embedded local S3.
|
||||||
COPY --from=seaweedfs-builder /tmp/weed /usr/local/bin/weed
|
COPY --from=seaweedfs-builder /tmp/weed /usr/local/bin/weed
|
||||||
|
|
@ -99,7 +82,7 @@ COPY --from=nats-builder /tmp/nats-server /usr/local/bin/nats-server
|
||||||
RUN chmod +x /usr/local/bin/nats-server
|
RUN chmod +x /usr/local/bin/nats-server
|
||||||
|
|
||||||
# Include OpenAI Whisper license text for runtime-downloaded ONNX artifacts.
|
# Include OpenAI Whisper license text for runtime-downloaded ONNX artifacts.
|
||||||
COPY --from=app-builder /app/compute-worker/src/inference/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
|
# Match the app's historical container port now that standalone server.js
|
||||||
# is started directly instead of `next start -p 3003`.
|
# is started directly instead of `next start -p 3003`.
|
||||||
|
|
@ -109,5 +92,5 @@ ENV PORT=3003
|
||||||
EXPOSE 3003
|
EXPOSE 3003
|
||||||
|
|
||||||
# Start the application
|
# Start the application
|
||||||
ENTRYPOINT ["node", "scripts/openreader-entrypoint.mjs", "--"]
|
ENTRYPOINT ["node", "/opt/openreader/bootstrap/src/cli.mjs", "--"]
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -103,7 +103,7 @@ services:
|
||||||
image: openreader-compute-worker:local
|
image: openreader-compute-worker:local
|
||||||
build:
|
build:
|
||||||
context: ../..
|
context: ../..
|
||||||
dockerfile: compute-worker/Dockerfile
|
dockerfile: packages/compute-worker/Dockerfile
|
||||||
depends_on:
|
depends_on:
|
||||||
nats:
|
nats:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,18 @@ import TabItem from '@theme/TabItem';
|
||||||
|
|
||||||
This page covers migration behavior for both database schema and storage data in OpenReader.
|
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
|
## Startup migration behavior
|
||||||
|
|
||||||
By default, the shared entrypoint runs migrations automatically before app startup in:
|
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
|
- Postgres when `POSTGRES_URL` is set
|
||||||
- SQLite when `POSTGRES_URL` is unset
|
- 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
|
```bash
|
||||||
# Run pending migrations for one target:
|
# Run pending migrations for one target:
|
||||||
# - Postgres if POSTGRES_URL is set
|
# - Postgres if POSTGRES_URL is set
|
||||||
|
|
@ -71,26 +78,15 @@ pnpm migrate-fs
|
||||||
pnpm migrate-fs:dry-run
|
pnpm migrate-fs:dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
</TabItem>
|
`pnpm migrate` uses the programmatic Drizzle migrator from `@openreader/database`. Drizzle Kit is
|
||||||
<TabItem value="drizzle-direct" label="Manual Drizzle Cmd">
|
not a production or startup dependency; it is used only to generate new migration files.
|
||||||
|
|
||||||
```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>
|
|
||||||
|
|
||||||
## Generate migrations
|
## Generate migrations
|
||||||
|
|
||||||
`pnpm generate` is a two-phase script for contributors and schema changes:
|
`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`).
|
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
|
:::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.
|
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:
|
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`
|
- `packages/database/src/schema_auth_sqlite.ts`
|
||||||
- `src/db/schema_auth_postgres.ts`
|
- `packages/database/src/schema_auth_postgres.ts`
|
||||||
|
|
||||||
App-specific tables are manually maintained in the standard Drizzle schema files:
|
App-specific tables are manually maintained in the standard Drizzle schema files:
|
||||||
|
|
||||||
- `src/db/schema_sqlite.ts`
|
- `packages/database/src/schema_sqlite.ts`
|
||||||
- `src/db/schema_postgres.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:
|
When app schema changes (for example `tts_segments`), keep these in sync:
|
||||||
|
|
||||||
- `src/db/schema_sqlite.ts`
|
- `packages/database/src/schema_sqlite.ts`
|
||||||
- `src/db/schema_postgres.ts`
|
- `packages/database/src/schema_postgres.ts`
|
||||||
- `drizzle/sqlite/*.sql` + `drizzle/sqlite/meta/_journal.json`
|
- `packages/database/migrations/sqlite/*.sql` + `packages/database/migrations/sqlite/meta/_journal.json`
|
||||||
- `drizzle/postgres/*.sql` + `drizzle/postgres/meta/_journal.json`
|
- `packages/database/migrations/postgres/*.sql` + `packages/database/migrations/postgres/meta/_journal.json`
|
||||||
|
|
||||||
<Tabs groupId="generate-migration-commands">
|
<Tabs groupId="generate-migration-commands">
|
||||||
<TabItem value="project-script" label="Project Script" default>
|
<TabItem value="project-script" label="Project Script" default>
|
||||||
|
|
@ -130,10 +127,10 @@ pnpm generate
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Generate SQLite migrations only (skips Better Auth CLI)
|
# 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)
|
# 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
|
:::warning
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@ For all variables and defaults, see [Environment Variables](../reference/environ
|
||||||
|
|
||||||
## 4. Database and data migrations
|
## 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` 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.
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -46,7 +46,7 @@ const nextConfig: NextConfig = {
|
||||||
canvas: '@napi-rs/canvas',
|
canvas: '@napi-rs/canvas',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
transpilePackages: [],
|
transpilePackages: ['@openreader/database'],
|
||||||
serverExternalPackages,
|
serverExternalPackages,
|
||||||
outputFileTracingIncludes: {
|
outputFileTracingIncludes: {
|
||||||
'/api/audiobook': [
|
'/api/audiobook': [
|
||||||
|
|
|
||||||
22
package.json
22
package.json
|
|
@ -4,12 +4,12 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.33.4",
|
"packageManager": "pnpm@10.33.4",
|
||||||
"scripts": {
|
"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",
|
"dev:raw": "next dev --turbopack -p 3003",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"build:bundle-guard": "node scripts/check-next-server-bundle.mjs",
|
"build:bundle-guard": "node scripts/check-next-server-bundle.mjs",
|
||||||
"check:compute-boundary": "node scripts/check-compute-boundary.mjs",
|
"check:compute-boundary": "node scripts/check-compute-boundary.mjs",
|
||||||
"start": "node scripts/openreader-entrypoint.mjs -- pnpm start:raw",
|
"start": "pnpm --filter @openreader/bootstrap start pnpm start:raw",
|
||||||
"start:raw": "next start -p 3003",
|
"start:raw": "next start -p 3003",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"test": "playwright test",
|
"test": "playwright test",
|
||||||
|
|
@ -19,10 +19,10 @@
|
||||||
"compose": "docker compose -f docker/examples/compose.yml up",
|
"compose": "docker compose -f docker/examples/compose.yml up",
|
||||||
"compose:full": "docker compose -f docker/examples/compose.full.yml up",
|
"compose:full": "docker compose -f docker/examples/compose.full.yml up",
|
||||||
"compose:local": "docker compose -f docker/examples/compose.local.yml up --build",
|
"compose:local": "docker compose -f docker/examples/compose.local.yml up --build",
|
||||||
"migrate": "node drizzle/scripts/migrate.mjs",
|
"migrate": "pnpm --filter @openreader/database migrate",
|
||||||
"migrate-fs": "node scripts/migrate-fs-v2.mjs",
|
"migrate-fs": "pnpm --filter @openreader/bootstrap migrate-storage",
|
||||||
"migrate-fs:dry-run": "node scripts/migrate-fs-v2.mjs --dry-run true",
|
"migrate-fs:dry-run": "pnpm --filter @openreader/bootstrap migrate-storage --dry-run true",
|
||||||
"generate": "node drizzle/scripts/generate.mjs",
|
"generate": "node packages/database/scripts/generate.mjs",
|
||||||
"docs:init": "pnpm --dir docs-site install",
|
"docs:init": "pnpm --dir docs-site install",
|
||||||
"docs:dev": "pnpm --dir docs-site start",
|
"docs:dev": "pnpm --dir docs-site start",
|
||||||
"docs:build": "pnpm --dir docs-site build",
|
"docs:build": "pnpm --dir docs-site build",
|
||||||
|
|
@ -31,8 +31,8 @@
|
||||||
"docs:clear": "pnpm --dir docs-site clear",
|
"docs:clear": "pnpm --dir docs-site clear",
|
||||||
"compute:dev": "pnpm --filter @openreader/compute-worker dev",
|
"compute:dev": "pnpm --filter @openreader/compute-worker dev",
|
||||||
"compute:start": "pnpm --filter @openreader/compute-worker start",
|
"compute:start": "pnpm --filter @openreader/compute-worker start",
|
||||||
"compute:openapi:generate": "pnpm --dir compute-worker openapi:generate && openapi-typescript compute-worker/openapi.json -o src/lib/server/compute-worker/generated.ts",
|
"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 -- compute-worker/openapi.json 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"
|
"lint:route-errors": "node scripts/check-route-error-responses.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -42,20 +42,19 @@
|
||||||
"@microsoft/antissrf": "^1.0.0",
|
"@microsoft/antissrf": "^1.0.0",
|
||||||
"@mozilla/readability": "^0.6.0",
|
"@mozilla/readability": "^0.6.0",
|
||||||
"@napi-rs/canvas": "^0.1.100",
|
"@napi-rs/canvas": "^0.1.100",
|
||||||
|
"@openreader/database": "workspace:*",
|
||||||
"@speech-sdk/core": "^0.15.0",
|
"@speech-sdk/core": "^0.15.0",
|
||||||
"@tanstack/react-query": "^5.101.0",
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"@types/archiver": "^7.0.0",
|
"@types/archiver": "^7.0.0",
|
||||||
"@vercel/analytics": "^1.6.1",
|
"@vercel/analytics": "^1.6.1",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"better-auth": "^1.6.14",
|
"better-auth": "^1.6.14",
|
||||||
"better-sqlite3": "^12.10.0",
|
|
||||||
"cmpstr": "^3.3.0",
|
"cmpstr": "^3.3.0",
|
||||||
"compromise": "^14.15.1",
|
"compromise": "^14.15.1",
|
||||||
"core-js": "^3.49.0",
|
"core-js": "^3.49.0",
|
||||||
"dexie": "^4.4.3",
|
"dexie": "^4.4.3",
|
||||||
"dexie-react-hooks": "^4.4.0",
|
"dexie-react-hooks": "^4.4.0",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"drizzle-kit": "^0.31.10",
|
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"epubjs": "^0.3.93",
|
"epubjs": "^0.3.93",
|
||||||
"fast-xml-parser": "^5.8.0",
|
"fast-xml-parser": "^5.8.0",
|
||||||
|
|
@ -67,7 +66,6 @@
|
||||||
"next": "^15.5.19",
|
"next": "^15.5.19",
|
||||||
"openai": "^6.41.0",
|
"openai": "^6.41.0",
|
||||||
"pdfjs-dist": "4.8.69",
|
"pdfjs-dist": "4.8.69",
|
||||||
"pg": "^8.21.0",
|
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
|
|
@ -98,6 +96,8 @@
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/turndown": "^5.0.6",
|
"@types/turndown": "^5.0.6",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
|
"better-sqlite3": "^12.10.0",
|
||||||
|
"drizzle-kit": "^0.31.10",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-config-next": "^15.5.19",
|
"eslint-config-next": "^15.5.19",
|
||||||
"postcss": "^8.5.15",
|
"postcss": "^8.5.15",
|
||||||
|
|
|
||||||
25
packages/bootstrap/package.json
Normal file
25
packages/bootstrap/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,11 +3,23 @@ import { spawn, spawnSync } from 'node:child_process';
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
import { once } from 'node:events';
|
import { once } from 'node:events';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { setTimeout as delay } from 'node:timers/promises';
|
import { setTimeout as delay } from 'node:timers/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import * as dotenv from 'dotenv';
|
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 loadEnvFiles() {
|
function loadEnvFiles() {
|
||||||
const cwd = process.cwd();
|
const cwd = process.cwd();
|
||||||
|
|
@ -49,83 +61,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) {
|
function parseCommandFromArgs(argv) {
|
||||||
const marker = argv.indexOf('--');
|
const marker = argv.indexOf('--');
|
||||||
if (marker >= 0) return argv.slice(marker + 1);
|
if (marker >= 0) return argv.slice(marker + 1);
|
||||||
|
|
@ -143,51 +78,6 @@ 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) {
|
function spawnMainCommand(command, env) {
|
||||||
const [cmd, ...args] = command;
|
const [cmd, ...args] = command;
|
||||||
const child = spawn(cmd, args, {
|
const child = spawn(cmd, args, {
|
||||||
|
|
@ -219,53 +109,13 @@ function spawnMainCommand(command, env) {
|
||||||
return { child, exitPromise };
|
return { child, exitPromise };
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveEmbeddedWorkerLaunch() {
|
async function runDbMigrations(env) {
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Running database migrations...');
|
console.log('Running database migrations...');
|
||||||
const migration = spawnSync(process.execPath, [migrateScript], {
|
await runMigrations({ cwd: process.cwd(), env });
|
||||||
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}.`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function runStorageMigrations(env) {
|
function runStorageMigrations(env) {
|
||||||
const migrateScript = path.join(process.cwd(), 'scripts', 'migrate-fs-v2.mjs');
|
const migrateScript = fileURLToPath(new URL('./storage-migration.mjs', import.meta.url));
|
||||||
if (!fs.existsSync(migrateScript)) {
|
|
||||||
throw new Error(`Could not find storage migration script at ${migrateScript}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Running storage migrations (v2)...');
|
console.log('Running storage migrations (v2)...');
|
||||||
const migration = spawnSync(process.execPath, [migrateScript, '--dry-run', 'false', '--delete-local', 'false'], {
|
const migration = spawnSync(process.execPath, [migrateScript, '--dry-run', 'false', '--delete-local', 'false'], {
|
||||||
|
|
@ -335,7 +185,7 @@ async function main() {
|
||||||
|
|
||||||
const command = parseCommandFromArgs(process.argv.slice(2));
|
const command = parseCommandFromArgs(process.argv.slice(2));
|
||||||
if (command.length === 0) {
|
if (command.length === 0) {
|
||||||
console.error('Usage: node scripts/openreader-entrypoint.mjs -- <command> [args]');
|
console.error('Usage: openreader -- <command> [args]');
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -424,7 +274,7 @@ async function main() {
|
||||||
try {
|
try {
|
||||||
const shouldRunDbMigrations = resolveBooleanEnv(runtimeEnv, 'RUN_DRIZZLE_MIGRATIONS', true);
|
const shouldRunDbMigrations = resolveBooleanEnv(runtimeEnv, 'RUN_DRIZZLE_MIGRATIONS', true);
|
||||||
if (shouldRunDbMigrations) {
|
if (shouldRunDbMigrations) {
|
||||||
runDbMigrations(runtimeEnv);
|
await runDbMigrations(runtimeEnv);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (useEmbeddedWeed) {
|
if (useEmbeddedWeed) {
|
||||||
|
|
@ -482,7 +332,7 @@ async function main() {
|
||||||
console.log('Starting embedded SeaweedFS weed mini...');
|
console.log('Starting embedded SeaweedFS weed mini...');
|
||||||
launchWeed(runtimeEnv.S3_ENDPOINT);
|
launchWeed(runtimeEnv.S3_ENDPOINT);
|
||||||
const startupEndpoint = parseS3Endpoint(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}`);
|
console.log(`Embedded SeaweedFS is ready at ${runtimeEnv.S3_ENDPOINT}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -555,7 +405,7 @@ async function main() {
|
||||||
console.error(`Embedded nats-server failed to start: ${error instanceof Error ? error.message : String(error)}`);
|
console.error(`Embedded nats-server failed to start: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
scheduleFatalShutdown('nats-server', null, null, 'failed to start');
|
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(`Embedded nats-server is ready at nats://127.0.0.1:${embeddedNatsPort}`);
|
||||||
|
|
||||||
console.log(`Starting embedded compute-worker on 127.0.0.1:${embeddedWorkerPort}...`);
|
console.log(`Starting embedded compute-worker on 127.0.0.1:${embeddedWorkerPort}...`);
|
||||||
|
|
@ -589,7 +439,7 @@ async function main() {
|
||||||
console.error(`Embedded compute-worker failed to start: ${error instanceof Error ? error.message : String(error)}`);
|
console.error(`Embedded compute-worker failed to start: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
scheduleFatalShutdown('compute-worker', null, null, 'failed to start');
|
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}`);
|
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()) {
|
} 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.');
|
throw new Error('COMPUTE_WORKER_URL and COMPUTE_WORKER_TOKEN are required when embedded compute worker startup is disabled.');
|
||||||
5
packages/bootstrap/src/embedded-nats.mjs
Normal file
5
packages/bootstrap/src/embedded-nats.mjs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
|
||||||
|
export function hasNatsBinary() {
|
||||||
|
return !spawnSync('nats-server', ['-v'], { stdio: 'ignore' }).error;
|
||||||
|
}
|
||||||
95
packages/bootstrap/src/embedded-seaweedfs.mjs
Normal file
95
packages/bootstrap/src/embedded-seaweedfs.mjs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
packages/bootstrap/src/embedded-worker.mjs
Normal file
30
packages/bootstrap/src/embedded-worker.mjs
Normal 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.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,17 @@
|
||||||
FROM node:lts AS deploy-stage
|
FROM node:lts AS deploy-stage
|
||||||
|
|
||||||
RUN npm install -g pnpm@11.1.2
|
RUN npm install -g pnpm@10.33.4
|
||||||
|
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|
||||||
COPY .npmrc package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
COPY .npmrc package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
COPY compute-worker/package.json compute-worker/package.json
|
COPY packages/compute-worker/package.json packages/compute-worker/package.json
|
||||||
COPY compute-worker compute-worker
|
COPY packages/compute-worker packages/compute-worker
|
||||||
|
|
||||||
RUN pnpm --config.inject-workspace-packages=true --filter @openreader/compute-worker deploy /opt/compute-worker
|
RUN pnpm --config.inject-workspace-packages=true --filter @openreader/compute-worker deploy /opt/compute-worker
|
||||||
|
|
||||||
FROM node:lts
|
FROM node:lts
|
||||||
|
|
||||||
RUN npm install -g pnpm@11.1.2
|
|
||||||
|
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|
||||||
COPY --from=deploy-stage /opt/compute-worker ./
|
COPY --from=deploy-stage /opt/compute-worker ./
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { BaseDocument } from '../../../../../src/types/documents';
|
import type { BaseDocument } from '../../../../../../src/types/documents';
|
||||||
import type { ParsedPdfBlock, ParsedPdfBlockKind, ParsedPdfDocument, ParsedPdfPage } from '../../../../src/api/types';
|
import type { ParsedPdfBlock, ParsedPdfBlockKind, ParsedPdfDocument, ParsedPdfPage } from '../../../../src/api/types';
|
||||||
|
|
||||||
export function makeBaseDocument(overrides: Partial<BaseDocument> = {}): BaseDocument {
|
export function makeBaseDocument(overrides: Partial<BaseDocument> = {}): BaseDocument {
|
||||||
|
|
@ -45,6 +45,17 @@ class FakeKvStore implements KvStoreLike {
|
||||||
this.revision += 1;
|
this.revision += 1;
|
||||||
this.data.set(key, { operation: 'PUT', value: data.slice(), revision: this.revision });
|
this.data.set(key, { operation: 'PUT', value: data.slice(), revision: this.revision });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async keys(filter?: string | string[]): Promise<AsyncIterable<string>> {
|
||||||
|
const keys = Array.from(this.data.keys());
|
||||||
|
return {
|
||||||
|
async *[Symbol.asyncIterator]() {
|
||||||
|
for (const key of keys) {
|
||||||
|
yield key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakeJetStream {
|
class FakeJetStream {
|
||||||
|
|
@ -24,8 +24,8 @@ describe('orphan recovery', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const firstSweep = await recoverOrphanedOperations({
|
const firstSweep = await recoverOrphanedOperations({
|
||||||
operationStateStore: fake.deps.operationStateStore,
|
operationStateStore: fake.deps.operationStateStore!,
|
||||||
orchestrator: fake.deps.orchestrator,
|
orchestrator: fake.deps.orchestrator!,
|
||||||
whisperTimeoutMs: 30_000,
|
whisperTimeoutMs: 30_000,
|
||||||
pdfTimeoutMs: 300_000,
|
pdfTimeoutMs: 300_000,
|
||||||
opStaleMs: 1_800_000,
|
opStaleMs: 1_800_000,
|
||||||
|
|
@ -38,8 +38,8 @@ describe('orphan recovery', () => {
|
||||||
vi.advanceTimersByTime(31_000);
|
vi.advanceTimersByTime(31_000);
|
||||||
|
|
||||||
const secondSweep = await recoverOrphanedOperations({
|
const secondSweep = await recoverOrphanedOperations({
|
||||||
operationStateStore: fake.deps.operationStateStore,
|
operationStateStore: fake.deps.operationStateStore!,
|
||||||
orchestrator: fake.deps.orchestrator,
|
orchestrator: fake.deps.orchestrator!,
|
||||||
whisperTimeoutMs: 30_000,
|
whisperTimeoutMs: 30_000,
|
||||||
pdfTimeoutMs: 300_000,
|
pdfTimeoutMs: 300_000,
|
||||||
opStaleMs: 1_800_000,
|
opStaleMs: 1_800_000,
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"extends": "../tsconfig.json",
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
},
|
},
|
||||||
9
packages/database/bin/migrate.mjs
Executable file
9
packages/database/bin/migrate.mjs
Executable file
|
|
@ -0,0 +1,9 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import { runMigrations } from '../src/migrate.mjs';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runMigrations();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
@ -10,8 +10,8 @@ if (!url) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
schema: ['./src/db/schema_postgres.ts', './src/db/schema_auth_postgres.ts'],
|
schema: ['./packages/database/src/schema_postgres.ts', './packages/database/src/schema_auth_postgres.ts'],
|
||||||
out: './drizzle/postgres',
|
out: './packages/database/migrations/postgres',
|
||||||
dialect: 'postgresql',
|
dialect: 'postgresql',
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url,
|
url,
|
||||||
|
|
@ -4,8 +4,8 @@ import * as dotenv from 'dotenv';
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
schema: ['./src/db/schema_sqlite.ts', './src/db/schema_auth_sqlite.ts'],
|
schema: ['./packages/database/src/schema_sqlite.ts', './packages/database/src/schema_auth_sqlite.ts'],
|
||||||
out: './drizzle/sqlite',
|
out: './packages/database/migrations/sqlite',
|
||||||
dialect: 'sqlite',
|
dialect: 'sqlite',
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: 'docstore/sqlite3.db',
|
url: 'docstore/sqlite3.db',
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue