From 84a2273e33b2c6e1656a5b1319cc8ef5dc7065c4 Mon Sep 17 00:00:00 2001 From: Artsen Date: Mon, 20 Jul 2026 00:20:04 -0400 Subject: [PATCH] fix: make local and Docker startup reliable --- .dockerignore | 6 + .env.example | 3 + .github/workflows/ci.yml | 64 +++++++ README.md | 32 ++-- apps/api/Dockerfile | 10 +- apps/api/src/app.test.ts | 54 ++++++ apps/api/src/routes/index.ts | 2 + apps/api/src/routes/readiness-routes.ts | 15 ++ apps/api/src/runtime/api-runtime.ts | 2 + apps/api/src/runtime/production-runtime.ts | 15 ++ .../src/services/readiness-service.test.ts | 138 +++++++++++++++ apps/api/src/services/readiness-service.ts | 102 +++++++++++ apps/web/Dockerfile | 12 +- apps/web/package.json | 2 +- apps/web/src/app/App.test.tsx | 55 ++++++ apps/web/src/app/App.tsx | 46 ++++- apps/web/src/app/useAppBootstrap.ts | 63 ++++++- apps/web/src/app/useVideoOptimizerApp.tsx | 14 +- apps/web/src/components/AppShell.tsx | 11 ++ apps/web/src/styles/components.css | 39 +++++ apps/web/vite.config.ts | 2 +- docker-compose.yml | 29 +++- docs/api.md | 3 + docs/architecture.md | 18 +- docs/configuration.md | 4 +- docs/getting-started.md | 50 +++--- e2e/specs/app-shell.spec.ts | 28 +++ eslint.config.mjs | 8 + package.json | 8 +- packages/contracts/src/index.ts | 1 + packages/contracts/src/readiness.ts | 35 ++++ packages/contracts/tests/readiness.test.ts | 28 +++ scripts/dev-processes.mjs | 163 ++++++++++++++++++ scripts/dev-processes.test.mjs | 137 +++++++++++++++ scripts/dev.mjs | 4 + 35 files changed, 1140 insertions(+), 63 deletions(-) create mode 100644 apps/api/src/routes/readiness-routes.ts create mode 100644 apps/api/src/services/readiness-service.test.ts create mode 100644 apps/api/src/services/readiness-service.ts create mode 100644 packages/contracts/src/readiness.ts create mode 100644 packages/contracts/tests/readiness.test.ts create mode 100644 scripts/dev-processes.mjs create mode 100644 scripts/dev-processes.test.mjs create mode 100644 scripts/dev.mjs diff --git a/.dockerignore b/.dockerignore index ea3a542..6f55b2b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,4 +3,10 @@ dist build .git data +.tmp +coverage +playwright-report +test-results +.env +.env.* *.log diff --git a/.env.example b/.env.example index dcc7bd6..2d4e9a6 100644 --- a/.env.example +++ b/.env.example @@ -19,3 +19,6 @@ VITE_API_BASE_URL=http://localhost:4000 YT_DLP_BIN=C:\path\to\yt-dlp.exe # Optional. The API automatically passes its current Node runtime to yt-dlp. YT_DLP_JS_RUNTIME=node:C:\Program Files\nodejs\node.exe +# Optional local caption generation. Leave unset unless whisper.cpp is installed. +# WHISPER_CPP_BIN=C:\path\to\whisper-cli.exe +# WHISPER_CPP_MODEL=C:\path\to\ggml-base.en.bin diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3b300e..8b8369d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,3 +121,67 @@ jobs: test-results/ if-no-files-found: ignore retention-days: 7 + + docker-smoke: + runs-on: ubuntu-latest + timeout-minutes: 25 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Print Docker versions + run: | + docker --version + docker compose version + + - name: Build Docker stack + run: docker compose build --no-cache + + - name: Start Docker stack + run: docker compose up -d + + - name: Wait for healthy services + run: | + for attempt in {1..30}; do + api_status="$(docker inspect --format='{{.State.Health.Status}}' "$(docker compose ps -q api)")" + web_status="$(docker inspect --format='{{.State.Health.Status}}' "$(docker compose ps -q web)")" + echo "Attempt ${attempt}: api=${api_status} web=${web_status}" + if [ "${api_status}" = "healthy" ] && [ "${web_status}" = "healthy" ]; then + exit 0 + fi + sleep 2 + done + exit 1 + + - name: Verify Docker endpoints + run: | + node - <<'NODE' + const checks = [ + ["API health", "http://127.0.0.1:4000/health"], + ["API readiness", "http://127.0.0.1:4000/ready"], + ["Web root", "http://127.0.0.1:5173/"] + ]; + for (const [label, url] of checks) { + const response = await fetch(url); + console.log(`${label}: ${response.status}`); + if (!response.ok) process.exit(1); + } + NODE + + - name: Verify browser-facing API URL + run: | + docker compose exec -T web node -e "process.exit(process.env.VITE_API_BASE_URL === 'http://localhost:4000' ? 0 : 1)" + + - name: Verify Docker services remain running + run: | + docker compose ps + test "$(docker compose ps --status running -q | wc -l)" -eq 2 + + - name: Docker logs + if: failure() + run: docker compose logs --no-color + + - name: Clean up Docker stack + if: always() + run: docker compose down -v --remove-orphans diff --git a/README.md b/README.md index 15cca38..cfff42b 100644 --- a/README.md +++ b/README.md @@ -29,30 +29,40 @@ Preparing video for the web usually means juggling codec settings, fallback file ## Quick Start -### Docker Compose +### Local Node -Docker is the easiest way to run the app when Docker is available: +Local development needs Node.js 20 or newer and FFmpeg/FFprobe on PATH. ```powershell git clone https://github.com/Artsen/web-video-optimizer.git cd web-video-optimizer -docker compose up --build +npm ci +npm run dev ``` -Open . The API listens on , and media is stored in the Docker `video_data` volume. +Open . The API listens on and must remain running while the web interface is used. -### Local Node +On Windows PowerShell, use `npm.cmd` if script execution policy blocks `npm.ps1`. -Local development needs Node.js 20 or newer and FFmpeg/FFprobe on PATH. +The two-console workflow is still supported: ```powershell -git clone https://github.com/Artsen/web-video-optimizer.git -cd web-video-optimizer -npm ci -npm run dev +npm run dev:api ``` -On Windows PowerShell, use `npm.cmd` if script execution policy blocks `npm.ps1`. +```powershell +npm run dev:web +``` + +### Docker Compose + +Docker is optional for ordinary local development. When Docker is available: + +```powershell +docker compose up --build +``` + +Open . The API listens on , and media is stored in the Docker `video_data` volume. Docker validation for this project is performed by GitHub Actions on Ubuntu. See [Getting Started](docs/getting-started.md) for FFmpeg setup, LAN access, yt-dlp imports, and optional whisper.cpp captions. diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 477d18f..67aec2d 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -6,14 +6,20 @@ RUN apt-get update \ WORKDIR /app -COPY package.json package-lock.json* ./ +COPY package.json package-lock.json ./ +COPY packages/contracts/package.json packages/contracts/package.json +COPY packages/video-core/package.json packages/video-core/package.json COPY apps/api/package.json apps/api/package.json COPY apps/web/package.json apps/web/package.json -RUN npm install --workspace @local-video-optimizer/api +RUN npm ci +COPY packages/contracts packages/contracts +COPY packages/video-core packages/video-core COPY apps/api apps/api +RUN npm run build:packages + WORKDIR /app/apps/api EXPOSE 4000 diff --git a/apps/api/src/app.test.ts b/apps/api/src/app.test.ts index 9e389a8..df3feb3 100644 --- a/apps/api/src/app.test.ts +++ b/apps/api/src/app.test.ts @@ -8,6 +8,7 @@ import { CapabilitiesSchema, HistorySnapshotSchema, JobDtoSchema, + ReadinessDtoSchema, StorageCleanupResultDtoSchema, StorageStatusDtoSchema, VideoRecordDtoSchema, @@ -15,6 +16,7 @@ import { type HistorySnapshot, type JobDto, type OptimizationSettings, + type ReadinessDto, type StorageCleanupResultDto, type StorageStatusDto, type VideoMetadata, @@ -141,6 +143,28 @@ class FakeRuntime implements ApiRuntime { }; } + async getReadiness(): Promise { + return { + state: "ready", + checks: { + runtimeInitialized: { ok: true, state: "ready", message: "API runtime initialized" }, + storageAvailable: { ok: true, state: "ready", message: "Managed storage available" }, + manifestLoaded: { ok: true, state: "ready", message: "Manifest state loaded" }, + ffmpegAvailable: { ok: true, state: "ready", message: "FFmpeg available" }, + ffprobeAvailable: { ok: true, state: "ready", message: "FFprobe available" }, + h264Encoding: { ok: true, state: "ready", message: "H.264/AAC fallback encoding available" }, + modernWebmAv1: { ok: true, state: "ready", message: "AV1/WebM with Opus available" }, + storagePressure: { ok: true, state: "ready", message: "Storage pressure normal" } + }, + optional: { + ytDlpAvailable: { ok: false, state: "degraded", message: "yt-dlp available" }, + whisperCppAvailable: { ok: false, state: "degraded", message: "whisper.cpp executable available" }, + whisperModelConfigured: { ok: false, state: "degraded", message: "whisper.cpp model configured" } + }, + storage: { pressure: "normal" } + }; + } + async getStorageStatus(): Promise { return storageStatus(); } @@ -334,6 +358,36 @@ describe("public API response shapes", () => { noPrivateFields(response.body); }); + it("returns redacted readiness matching the shared schema", async () => { + const { app } = makeApp(); + const response = await request(app).get("/ready").expect(200); + + ReadinessDtoSchema.parse(response.body); + expect(response.body.state).toBe("ready"); + noPrivateFields(response.body); + expect(JSON.stringify(response.body)).not.toContain("D:/"); + expect(JSON.stringify(response.body)).not.toContain("C:\\"); + }); + + it("returns 503 when readiness reports required failures", async () => { + class NotReadyRuntime extends FakeRuntime { + override async getReadiness(): Promise { + return { + ...(await super.getReadiness()), + state: "not_ready", + checks: { + ...(await super.getReadiness()).checks, + ffmpegAvailable: { ok: false, state: "not_ready", message: "FFmpeg available" } + } + }; + } + } + const { app } = makeApp(new NotReadyRuntime()); + + const response = await request(app).get("/ready").expect(503); + ReadinessDtoSchema.parse(response.body); + }); + it("returns history matching the shared schema without private fields", async () => { const { app } = makeApp(); const response = await request(app).get("/api/history").expect(200); diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index a8707fe..8b2fdd1 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -8,6 +8,7 @@ import { createHistoryRouter } from "./history-routes.js"; import { createImportRouter } from "./import-routes.js"; import { createJobRouter } from "./job-routes.js"; import { createPackageRouter } from "./package-routes.js"; +import { createReadinessRouter } from "./readiness-routes.js"; import { createStorageRouter } from "./storage-routes.js"; import { createVideoRouter } from "./video-routes.js"; @@ -19,6 +20,7 @@ export type RouteDependencies = { export function registerRoutes(app: Express, dependencies: RouteDependencies): void { app.use(createHealthRouter()); + app.use(createReadinessRouter(dependencies.runtime)); app.use(createCapabilityRouter(dependencies.runtime)); app.use(createStorageRouter(dependencies.runtime)); app.use(createHistoryRouter(dependencies.runtime)); diff --git a/apps/api/src/routes/readiness-routes.ts b/apps/api/src/routes/readiness-routes.ts new file mode 100644 index 0000000..2771be7 --- /dev/null +++ b/apps/api/src/routes/readiness-routes.ts @@ -0,0 +1,15 @@ +import { Router } from "express"; +import { asyncHandler } from "../middleware/async-handler.js"; +import type { ApiRuntime } from "../runtime/api-runtime.js"; + +export function createReadinessRouter(runtime: ApiRuntime): Router { + const router = Router(); + router.get( + "/ready", + asyncHandler(async (_req, res) => { + const readiness = await runtime.getReadiness(); + res.status(readiness.state === "not_ready" ? 503 : 200).json(readiness); + }) + ); + return router; +} diff --git a/apps/api/src/runtime/api-runtime.ts b/apps/api/src/runtime/api-runtime.ts index 3a93335..807e410 100644 --- a/apps/api/src/runtime/api-runtime.ts +++ b/apps/api/src/runtime/api-runtime.ts @@ -7,6 +7,7 @@ import type { VideoMetadata, VideoRecordDto, StorageCleanupResultDto, + ReadinessDto, StorageStatusDto } from "@local-video-optimizer/contracts"; import type { OpenedStoredFile, StorageArea } from "../storage/storage-boundary.js"; @@ -32,6 +33,7 @@ export type CaptionPayload = { export interface ApiRuntime { initialize(): Promise; getCapabilities(): Promise; + getReadiness(): Promise; getStorageStatus(): Promise; cleanupStorage(): Promise; getHistory(): HistorySnapshot; diff --git a/apps/api/src/runtime/production-runtime.ts b/apps/api/src/runtime/production-runtime.ts index 0c1eb10..d36029b 100644 --- a/apps/api/src/runtime/production-runtime.ts +++ b/apps/api/src/runtime/production-runtime.ts @@ -32,6 +32,7 @@ import { JobExecutionService } from "../services/job-execution-service.js"; import { JobLifecycleService } from "../services/job-lifecycle-service.js"; import { JobService } from "../services/job-service.js"; import { PackageService } from "../services/package-service.js"; +import { ReadinessService } from "../services/readiness-service.js"; import { ManifestStatePersistenceService } from "../services/state-persistence-service.js"; import { VideoService } from "../services/video-service.js"; import { StorageHousekeepingService } from "../storage/housekeeping-service.js"; @@ -139,6 +140,15 @@ export function createProductionRuntime( storage ); const capabilitiesService = new CapabilitiesService(ffmpegCapabilitiesAdapter, whisperAdapter, videoDownloader); + let runtimeInitialized = false; + let manifestLoaded = false; + const readinessService = new ReadinessService({ + getCapabilities: () => capabilitiesService.getCapabilities(), + commandRunner, + storagePolicy, + isRuntimeInitialized: () => runtimeInitialized, + isManifestLoaded: () => manifestLoaded + }); const videoService = new VideoService( videoRepository, jobRepository, @@ -236,11 +246,13 @@ export function createProductionRuntime( processRegistry.clear(); await storage.initialize(); const recovery = await statePersistence.load(); + manifestLoaded = true; await videoService.mergeDuplicateVideos(); await cleanupService.pruneOrphanFiles(); if (dependencies.startHousekeeping !== false) housekeeping.start(); await statePersistence.save(); await statePersistence.flush(); + runtimeInitialized = true; if ( recovery.recoveredFromBackup || recovery.canceledInterruptedJobs > 0 || @@ -253,6 +265,9 @@ export function createProductionRuntime( async getCapabilities() { return capabilitiesService.getCapabilities(); }, + async getReadiness() { + return readinessService.getReadiness(); + }, async getStorageStatus() { return storagePolicy.getStatus(); }, diff --git a/apps/api/src/services/readiness-service.test.ts b/apps/api/src/services/readiness-service.test.ts new file mode 100644 index 0000000..bcb01db --- /dev/null +++ b/apps/api/src/services/readiness-service.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Capabilities, StorageStatusDto } from "@local-video-optimizer/contracts"; +import type { CommandRunner } from "../infrastructure/tools/command-runner.js"; +import type { StoragePolicyService } from "../storage/storage-policy-service.js"; +import { ReadinessService } from "./readiness-service.js"; + +function capabilities(overrides: Partial = {}): Capabilities { + return { + libx264: true, + libaomAv1: true, + libvpxVp9: false, + aac: true, + libopus: true, + whisperCpp: false, + whisperModel: false, + ytDlp: false, + ...overrides + }; +} + +function storage(overrides: Partial = {}): StorageStatusDto { + return { + managedBytes: 0, + reservedBytes: 0, + availableBytes: 1_000_000_000, + minimumFreeBytes: 1024, + pressure: "normal", + areas: { + uploads: { bytes: 0, fileCount: 0 }, + outputs: { bytes: 0, fileCount: 0 }, + temporary: { bytes: 0, fileCount: 0 }, + staging: { bytes: 0, fileCount: 0 } + }, + cleanup: { staleTemporaryBytes: 0, staleTemporaryFileCount: 0 }, + ...overrides + }; +} + +function service( + options: { + capabilities?: Capabilities; + storage?: StorageStatusDto; + ffprobe?: boolean; + initialized?: boolean; + manifestLoaded?: boolean; + } = {} +) { + const getCapabilities = vi.fn().mockResolvedValue(options.capabilities ?? capabilities()); + const commandRunner = { + commandExists: vi.fn().mockResolvedValue(options.ffprobe ?? true) + } as unknown as CommandRunner; + const storagePolicy = { + getStatus: vi.fn().mockResolvedValue(options.storage ?? storage()) + } as unknown as StoragePolicyService; + return { + getCapabilities, + commandRunner, + readiness: new ReadinessService({ + getCapabilities, + commandRunner, + storagePolicy, + isRuntimeInitialized: () => options.initialized ?? true, + isManifestLoaded: () => options.manifestLoaded ?? true + }) + }; +} + +describe("ReadinessService", () => { + it("reports ready when required runtime, tools, codecs, and storage are available", async () => { + const { readiness } = service(); + + await expect(readiness.getReadiness()).resolves.toMatchObject({ + state: "ready", + checks: { + runtimeInitialized: { ok: true }, + storageAvailable: { ok: true }, + manifestLoaded: { ok: true }, + ffmpegAvailable: { ok: true }, + ffprobeAvailable: { ok: true }, + h264Encoding: { ok: true }, + modernWebmAv1: { ok: true }, + storagePressure: { ok: true, state: "ready" } + } + }); + }); + + it("reports degraded for storage pressure warnings and missing optional tools", async () => { + const { readiness } = service({ storage: storage({ pressure: "warning" }) }); + + await expect(readiness.getReadiness()).resolves.toMatchObject({ + state: "degraded", + checks: { storagePressure: { ok: true, state: "degraded" } }, + optional: { + ytDlpAvailable: { ok: false, state: "degraded" }, + whisperCppAvailable: { ok: false, state: "degraded" }, + whisperModelConfigured: { ok: false, state: "degraded" } + } + }); + }); + + it("reports not ready for required capability failures", async () => { + const { readiness } = service({ capabilities: capabilities({ libx264: false }) }); + + await expect(readiness.getReadiness()).resolves.toMatchObject({ + state: "not_ready", + checks: { h264Encoding: { ok: false, state: "not_ready" } } + }); + }); + + it("caches expensive capability and ffprobe checks between readiness calls", async () => { + const { commandRunner, getCapabilities, readiness } = service(); + + await readiness.getReadiness(); + await readiness.getReadiness(); + + expect(getCapabilities).toHaveBeenCalledTimes(1); + expect(commandRunner.commandExists).toHaveBeenCalledTimes(1); + }); + + it("does not expose raw executable paths or model paths", async () => { + const { readiness } = service({ + capabilities: capabilities({ + whisperCpp: true, + whisperModel: true, + whisperCommand: "D:/whisper-bin-x64/Release/whisper-cli.exe", + whisperModelPath: "D:/ggml-base.en.bin", + ytDlpCommand: "C:/tools/yt-dlp.exe", + ytDlpJsRuntime: "node:C:/Program Files/nodejs/node.exe" + }) + }); + + const result = await readiness.getReadiness(); + + expect(JSON.stringify(result)).not.toContain("D:/"); + expect(JSON.stringify(result)).not.toContain("C:/"); + expect(JSON.stringify(result)).not.toContain("whisper-cli.exe"); + }); +}); diff --git a/apps/api/src/services/readiness-service.ts b/apps/api/src/services/readiness-service.ts new file mode 100644 index 0000000..2af494b --- /dev/null +++ b/apps/api/src/services/readiness-service.ts @@ -0,0 +1,102 @@ +import type { Capabilities, ReadinessCheck, ReadinessDto } from "@local-video-optimizer/contracts"; +import type { CommandRunner } from "../infrastructure/tools/command-runner.js"; +import type { StoragePolicyService } from "../storage/storage-policy-service.js"; + +export class ReadinessService { + #capabilities: Promise | undefined; + #ffprobeAvailable: Promise | undefined; + + constructor( + private readonly dependencies: { + getCapabilities: () => Promise; + commandRunner: CommandRunner; + storagePolicy: StoragePolicyService; + isRuntimeInitialized: () => boolean; + isManifestLoaded: () => boolean; + } + ) {} + + async getReadiness(): Promise { + const [capabilities, ffprobeAvailable, storageResult] = await Promise.all([ + this.getCachedCapabilities(), + this.getCachedFfprobeAvailability(), + this.getStorageResult() + ]); + const storageStatus = storageResult.status; + const storagePressure = storageStatus?.pressure ?? "critical"; + + const checks = { + runtimeInitialized: check(this.dependencies.isRuntimeInitialized(), "API runtime initialized"), + storageAvailable: check(Boolean(storageStatus), "Managed storage available"), + manifestLoaded: check(this.dependencies.isManifestLoaded(), "Manifest state loaded"), + ffmpegAvailable: check(Object.values(pickFfmpegCapabilities(capabilities)).some(Boolean), "FFmpeg available"), + ffprobeAvailable: check(ffprobeAvailable, "FFprobe available"), + h264Encoding: check(capabilities.libx264 && capabilities.aac, "H.264/AAC fallback encoding available"), + modernWebmAv1: check(capabilities.libaomAv1 && capabilities.libopus, "AV1/WebM with Opus available"), + storagePressure: storagePressureCheck(storagePressure) + }; + const optional = { + ytDlpAvailable: optionalCheck(Boolean(capabilities.ytDlp), "yt-dlp available"), + whisperCppAvailable: optionalCheck(Boolean(capabilities.whisperCpp), "whisper.cpp executable available"), + whisperModelConfigured: optionalCheck(Boolean(capabilities.whisperModel), "whisper.cpp model configured") + }; + const requiredChecks = Object.values(checks); + const requiredFailed = requiredChecks.some((item) => !item.ok); + const degraded = requiredChecks.some((item) => item.state === "degraded"); + + return { + state: requiredFailed ? "not_ready" : degraded ? "degraded" : "ready", + checks, + optional, + storage: { pressure: storagePressure } + }; + } + + private getCachedCapabilities(): Promise { + this.#capabilities ??= this.dependencies.getCapabilities(); + return this.#capabilities; + } + + private getCachedFfprobeAvailability(): Promise { + this.#ffprobeAvailable ??= this.dependencies.commandRunner.commandExists("ffprobe", ["-version"]); + return this.#ffprobeAvailable; + } + + private async getStorageResult(): Promise<{ status?: Awaited> }> { + try { + return { status: await this.dependencies.storagePolicy.getStatus() }; + } catch { + return {}; + } + } +} + +function pickFfmpegCapabilities( + capabilities: Capabilities +): Pick { + return { + libx264: capabilities.libx264, + libaomAv1: capabilities.libaomAv1, + libvpxVp9: capabilities.libvpxVp9, + aac: capabilities.aac, + libopus: capabilities.libopus + }; +} + +function check(ok: boolean, message: string): ReadinessCheck { + return { ok, state: ok ? "ready" : "not_ready", message }; +} + +function optionalCheck(ok: boolean, message: string): ReadinessCheck { + return { ok, state: ok ? "ready" : "degraded", message }; +} + +function storagePressureCheck(pressure: "normal" | "warning" | "critical"): ReadinessCheck { + if (pressure === "critical") { + return { ok: false, state: "not_ready", message: "Storage pressure is critical" }; + } + if (pressure === "warning") { + return { ok: true, state: "degraded", message: "Storage pressure warning" }; + } + return { ok: true, state: "ready", message: "Storage pressure normal" }; +} diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 8b83353..8cb1d6b 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -2,16 +2,22 @@ FROM node:20-bookworm-slim WORKDIR /app -COPY package.json package-lock.json* ./ +COPY package.json package-lock.json ./ +COPY packages/contracts/package.json packages/contracts/package.json +COPY packages/video-core/package.json packages/video-core/package.json COPY apps/web/package.json apps/web/package.json COPY apps/api/package.json apps/api/package.json -RUN npm install --workspace @local-video-optimizer/web +RUN npm ci +COPY packages/contracts packages/contracts +COPY packages/video-core packages/video-core COPY apps/web apps/web +RUN npm run build:packages + WORKDIR /app/apps/web EXPOSE 5173 -CMD ["npm", "run", "dev"] +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] diff --git a/apps/web/package.json b/apps/web/package.json index 5857922..f0852b2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "vite --host 0.0.0.0", + "dev": "vite --host 127.0.0.1", "build": "tsc -p tsconfig.json && vite build", "preview": "vite preview --host 0.0.0.0", "typecheck": "tsc -p tsconfig.json --noEmit", diff --git a/apps/web/src/app/App.test.tsx b/apps/web/src/app/App.test.tsx index ba13f27..4468123 100644 --- a/apps/web/src/app/App.test.tsx +++ b/apps/web/src/app/App.test.tsx @@ -107,6 +107,43 @@ describe("App behavior", () => { expect(api.getStorageStatus).toHaveBeenCalledTimes(1); }); + it("shows an accessible startup panel when the API is unreachable", async () => { + const api = createApi({ + getHistory: vi.fn().mockRejectedValue(new TypeError("Failed to fetch C:\\secret\\manifest.json")), + getCapabilities: vi.fn().mockRejectedValue(new TypeError("Failed to fetch D:\\tools\\ffmpeg.exe")), + getStorageStatus: vi.fn().mockRejectedValue(new TypeError("Failed to fetch")) + }); + renderApp(api); + + const alert = await screen.findByRole("alert"); + expect(within(alert).getByRole("heading", { name: "Cannot reach the local API" })).toBeInTheDocument(); + expect(within(alert).getByRole("button", { name: "Retry connection" })).toBeInTheDocument(); + expect(within(alert).getByText("http://localhost:4000")).toBeInTheDocument(); + expect(alert).not.toHaveTextContent("C:\\secret"); + expect(alert).not.toHaveTextContent("D:\\tools"); + expect(screen.queryByRole("heading", { name: "Ready for a source video" })).not.toBeInTheDocument(); + }); + + it("keeps usable startup with a partial bootstrap warning and clears it after retry", async () => { + const user = userEvent.setup(); + const api = createApi({ + getStorageStatus: vi.fn().mockRejectedValueOnce(new Error("D:\\private\\data")).mockResolvedValue(storageStatus()) + }); + renderApp(api); + + expect(await screen.findByRole("heading", { name: "Ready for a source video" })).toBeInTheDocument(); + const warning = screen.getByRole("status"); + expect(warning).toHaveTextContent("Storage status"); + expect(warning).not.toHaveTextContent("D:\\private"); + + await user.click(within(warning).getByRole("button", { name: "Retry connection" })); + + await waitFor(() => expect(screen.queryByRole("status")).not.toBeInTheDocument()); + expect(api.getHistory).toHaveBeenCalledTimes(2); + expect(api.getCapabilities).toHaveBeenCalledTimes(2); + expect(api.getStorageStatus).toHaveBeenCalledTimes(2); + }); + it("uploads a selected file, activates the source, and displays metadata", async () => { const user = userEvent.setup(); const api = createApi(); @@ -240,6 +277,24 @@ describe("App behavior", () => { expect(api.deleteHistory).toHaveBeenCalledWith(["history-video"], []); }); + it("surfaces history deletion failures", async () => { + const user = userEvent.setup(); + const restored = { ...videoRecord({ id: "history-video", originalName: "archive.mp4" }), jobIds: [] }; + const api = createApi({ + getHistory: vi.fn().mockResolvedValue(historySnapshot({ videos: [restored], jobs: [] })), + deleteHistory: vi.fn().mockRejectedValue(new Error("Could not delete selected local files.")) + }); + renderApp(api); + + await user.click(await screen.findByRole("button", { name: /archive.mp4/i })); + await user.click(screen.getAllByRole("button", { name: /^library$/i })[0]); + await user.click(screen.getByRole("button", { name: "Source row actions" })); + await user.click(screen.getByRole("menuitem", { name: "Delete source" })); + + await waitFor(() => expect(api.deleteHistory).toHaveBeenCalledWith(["history-video"], [])); + expect(await screen.findByText("Could not delete selected local files.")).toBeInTheDocument(); + }); + it("shows storage pressure, usage details, and temporary cleanup feedback", async () => { const user = userEvent.setup(); const api = createApi({ diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index a6a8ccb..a90e970 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -15,15 +15,23 @@ export function App({ dependencies }: { dependencies: AppDependencies }) { {navigation.activeTab === "workflow" && ( <> - {!navigation.isBootstrapped && } + {navigation.bootstrap.unreachable && } + {!navigation.bootstrap.unreachable && !navigation.isBootstrapped && } {navigation.missingSourceId && } - {!navigation.missingSourceId && + {!navigation.bootstrap.unreachable && + !navigation.missingSourceId && (navigation.activeView === "prepare" || navigation.activeView === "results") && ( )} - {source.video && navigation.activeView === "custom" && } - {source.video && navigation.activeView === "compare" && } - {source.video && navigation.activeView === "captions" && } + {!navigation.bootstrap.unreachable && source.video && navigation.activeView === "custom" && ( + + )} + {!navigation.bootstrap.unreachable && source.video && navigation.activeView === "compare" && ( + + )} + {!navigation.bootstrap.unreachable && source.video && navigation.activeView === "captions" && ( + + )} )} @@ -40,6 +48,34 @@ function SourceWorkspace({ controller }: { controller: ReturnType }) { + const { apiBaseUrl, navigation } = controller; + return ( +
+

Cannot reach the local API

+

+ Web Video Optimizer could not connect to the API at the configured local address. Keep the API running while + using the web interface. +

+
+
+
Expected API URL
+
{apiBaseUrl}
+
+
+
Two-console fallback
+
Run npm run dev:api, then npm run dev:web in another console.
+
+
+
+ +
+
+ ); +} + function RouteLoadingState() { return (
diff --git a/apps/web/src/app/useAppBootstrap.ts b/apps/web/src/app/useAppBootstrap.ts index 7339ce8..7172db2 100644 --- a/apps/web/src/app/useAppBootstrap.ts +++ b/apps/web/src/app/useAppBootstrap.ts @@ -2,6 +2,25 @@ import React from "react"; import type { Capabilities, HistorySnapshot, StorageStatusDto } from "@local-video-optimizer/contracts"; import type { AppDependencies } from "./app-dependencies"; +export type BootstrapRequestKey = "history" | "capabilities" | "storage"; + +export type BootstrapIssue = { + key: BootstrapRequestKey; + label: string; +}; + +export type BootstrapState = { + isLoading: boolean; + unreachable: boolean; + issues: BootstrapIssue[]; +}; + +const bootstrapRequests: Record = { + history: "Library history", + capabilities: "Media capabilities", + storage: "Storage status" +}; + export function useAppBootstrap({ api, theme, @@ -16,7 +35,14 @@ export function useAppBootstrap({ setHistory: React.Dispatch>; setStorageStatus: React.Dispatch>; setReady?: React.Dispatch>; -}) { +}): { bootstrap: BootstrapState; retryBootstrap: () => void } { + const [attempt, setAttempt] = React.useState(0); + const [bootstrap, setBootstrap] = React.useState({ + isLoading: true, + unreachable: false, + issues: [] + }); + React.useEffect(() => { document.documentElement.dataset.theme = theme; }, [theme]); @@ -25,14 +51,37 @@ export function useAppBootstrap({ let canceled = false; setReady?.(false); void Promise.allSettled([ - api.getHistory().then(setHistory), - api.getCapabilities().then(setCapabilities), - api.getStorageStatus().then(setStorageStatus) - ]).then(() => { - if (!canceled) setReady?.(true); + api.getHistory().then((value) => ({ key: "history" as const, value })), + api.getCapabilities().then((value) => ({ key: "capabilities" as const, value })), + api.getStorageStatus().then((value) => ({ key: "storage" as const, value })) + ]).then((results) => { + if (canceled) return; + const issues: BootstrapIssue[] = []; + for (const [index, result] of results.entries()) { + if (result.status === "fulfilled") { + if (result.value.key === "history") setHistory(result.value.value); + if (result.value.key === "capabilities") setCapabilities(result.value.value); + if (result.value.key === "storage") setStorageStatus(result.value.value); + continue; + } + const key = (["history", "capabilities", "storage"] as const)[index]; + issues.push({ key, label: bootstrapRequests[key] }); + } + const unreachable = issues.length === results.length; + setBootstrap({ isLoading: false, unreachable, issues }); + setReady?.(!unreachable); }); return () => { canceled = true; }; - }, [api, setCapabilities, setHistory, setReady, setStorageStatus]); + }, [api, attempt, setCapabilities, setHistory, setReady, setStorageStatus]); + + return { + bootstrap, + retryBootstrap: () => { + setReady?.(false); + setBootstrap({ isLoading: true, unreachable: false, issues: [] }); + setAttempt((current) => current + 1); + } + }; } diff --git a/apps/web/src/app/useVideoOptimizerApp.tsx b/apps/web/src/app/useVideoOptimizerApp.tsx index 8cb4b6a..c7c7881 100644 --- a/apps/web/src/app/useVideoOptimizerApp.tsx +++ b/apps/web/src/app/useVideoOptimizerApp.tsx @@ -194,7 +194,14 @@ export function useVideoOptimizerApp(dependencies: AppDependencies) { document.title = title; }, [activeTab, activeView, missingSourceId, video]); - useAppBootstrap({ api, theme, setCapabilities, setHistory, setReady: setIsBootstrapped, setStorageStatus }); + const { bootstrap, retryBootstrap } = useAppBootstrap({ + api, + theme, + setCapabilities, + setHistory, + setReady: setIsBootstrapped, + setStorageStatus + }); const applyRouteState = React.useCallback((route: AppRoute) => { setMissingSourceId(null); @@ -586,7 +593,8 @@ export function useVideoOptimizerApp(dependencies: AppDependencies) { let nextHistory: HistorySnapshot; try { nextHistory = await api.deleteHistory(videoIds, jobIds); - } catch { + } catch (deleteError) { + setError(getReadableApiError(deleteError)); return; } setHistory(nextHistory); @@ -638,6 +646,7 @@ export function useVideoOptimizerApp(dependencies: AppDependencies) { route: browserRoute.route, isBootstrapped, missingSourceId, + bootstrap, openLibraryRoute, openNewRoute, openRouteForSource, @@ -645,6 +654,7 @@ export function useVideoOptimizerApp(dependencies: AppDependencies) { setActiveTab, setActiveView, startNewVideo, + retryBootstrap, toggleTheme: () => setTheme((current) => (current === "dark" ? "light" : "dark")), theme }, diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx index 61cb6ed..282c7a5 100644 --- a/apps/web/src/components/AppShell.tsx +++ b/apps/web/src/components/AppShell.tsx @@ -30,6 +30,17 @@ export function AppShell({ controller, children }: { controller: VideoOptimizerA
{status.error &&
{status.error}
} + {navigation.bootstrap.issues.length > 0 && !navigation.bootstrap.unreachable && ( +
+ + Some startup information could not be loaded:{" "} + {navigation.bootstrap.issues.map((issue) => issue.label).join(", ")}. + + +
+ )} {navigation.activeTab === "history" && } {children}
diff --git a/apps/web/src/styles/components.css b/apps/web/src/styles/components.css index e6c0321..852388f 100644 --- a/apps/web/src/styles/components.css +++ b/apps/web/src/styles/components.css @@ -524,6 +524,45 @@ progress::-webkit-progress-value { color: var(--color-text-secondary); } +.startup-error-panel { + text-align: left; +} + +.startup-details { + display: grid; + gap: var(--space-2); + margin: 0; +} + +.startup-details div { + display: grid; + gap: 2px; + padding: var(--space-3); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-card); + background: var(--color-surface-recessed); +} + +.startup-details dt { + color: var(--color-text-subtle); + font: var(--font-metadata); +} + +.startup-details dd { + margin: 0; + overflow-wrap: anywhere; + color: var(--color-text-primary); + font: var(--font-body-compact); +} + +.notice.global-error { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + align-items: center; + justify-content: space-between; +} + .route-state-actions { display: flex; flex-wrap: wrap; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 71a1c56..ae16be0 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -4,7 +4,7 @@ import { defineConfig } from "vite"; export default defineConfig({ plugins: [react()], server: { - host: "0.0.0.0", + host: "127.0.0.1", port: 5173 } }); diff --git a/docker-compose.yml b/docker-compose.yml index 62bcdac..78b1f9b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,9 @@ services: dockerfile: apps/api/Dockerfile environment: NODE_ENV: development + HOST: 0.0.0.0 PORT: 4000 + ALLOW_LAN_ACCESS: "true" CORS_ORIGIN: http://localhost:5173 STORAGE_ROOT: /app/data MIN_FREE_STORAGE_BYTES: 536870912 @@ -16,6 +18,18 @@ services: - video_data:/app/data ports: - "4000:4000" + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:4000/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + ] + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s web: build: @@ -26,7 +40,20 @@ services: ports: - "5173:5173" depends_on: - - api + api: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:5173/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + ] + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s volumes: video_data: diff --git a/docs/api.md b/docs/api.md index 7e30cb8..a442065 100644 --- a/docs/api.md +++ b/docs/api.md @@ -13,8 +13,11 @@ http://localhost:4000 | Method | Path | Purpose | | ------ | ------------------- | ---------------------------------------------------------- | | `GET` | `/health` | Returns API health. | +| `GET` | `/ready` | Returns redacted operational readiness for startup checks. | | `GET` | `/api/capabilities` | Reports FFmpeg, whisper.cpp, and yt-dlp capability status. | +`/health` is intentionally lightweight liveness. `/ready` reports required startup checks such as managed storage, manifest load state, FFmpeg/FFprobe availability, core encoding capabilities, and storage pressure without exposing local paths, executable paths, filenames, raw commands, or manifest content. Required failures return `503` with `state: "not_ready"`. Warnings such as low storage return `200` with `state: "degraded"`. Optional tools such as yt-dlp and whisper.cpp are reported separately and are not required for core readiness. + ## Sources | Method | Path | Purpose | diff --git a/docs/architecture.md b/docs/architecture.md index 4aae961..3509bf2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,7 +14,7 @@ flowchart LR API --> FFmpeg[FFmpeg / FFprobe] API --> Whisper[optional whisper.cpp] API --> YtDlp[optional yt-dlp] - Browser -->|localStorage route state| BrowserState[(Browser state)] + Browser -->|URL query parameters and History API| BrowserState[(Browser route state)] ``` The API is intentionally local and unauthenticated. CORS defaults only allow the local development origins. LAN binding is available through explicit configuration and should be treated as trusted-network-only operation. @@ -104,18 +104,24 @@ erDiagram VIDEO { string id string originalName - string storedFileName - number sizeBytes + string storedPath + string sourceHash + string uploadedAt object metadata } JOB { string id string videoId - string type + string kind string status number progress + string message + string outputPath string outputFileName + string sidecarPath string sidecarFileName + number outputSize + string ffmpegCommand } ``` @@ -138,7 +144,9 @@ FFprobe extracts metadata and subtitle-track status. FFmpeg handles optimization ## Frontend Model -The web app has a small bootstrap in `main.tsx`, a production app factory, route helpers, feature-local hooks, and focused views for Prepare, Results, Compare, Library, Settings, captions, posters, and packages. +Entity fields such as `storedPath`, `sourceHash`, `outputPath`, `sidecarPath`, and raw manifest content are API-private and are not returned through public DTOs. Public video and job DTOs expose safe IDs, display filenames, metadata, job state, output sizes, and command previews needed by the browser. + +The web app has a small bootstrap in `main.tsx`, a production app factory, route helpers, feature-local hooks, and focused views for Prepare, Results, Compare, Library, custom export settings, captions, posters, and packages. Prepare and Results are progressive states of the same source workspace: diff --git a/docs/configuration.md b/docs/configuration.md index 6e8ed42..ca20271 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -52,9 +52,11 @@ FFmpeg and FFprobe are invoked from PATH. Docker Compose sets: +- API `HOST=0.0.0.0` - API `PORT=4000` +- API `ALLOW_LAN_ACCESS=true` - API `CORS_ORIGIN=http://localhost:5173` - API `STORAGE_ROOT=/app/data` - web `VITE_API_BASE_URL=http://localhost:4000` -The `/app/data` path is backed by the `video_data` Docker volume. +The `/app/data` path is backed by the `video_data` Docker volume. Docker uses non-loopback API binding only so the published host port can reach the API inside the container; local Node development defaults to loopback-only binding unless you explicitly enable LAN access. diff --git a/docs/getting-started.md b/docs/getting-started.md index 9c00b7a..c57805e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,6 +1,6 @@ # Getting Started -Web Video Optimizer can run with Docker Compose or directly with Node.js. Docker is simplest when available; local Node is more convenient for active development. +Web Video Optimizer can run directly with Node.js or with Docker Compose. Local Node is the recommended everyday workflow and does not require Docker. ## Requirements @@ -12,24 +12,6 @@ Web Video Optimizer can run with Docker Compose or directly with Node.js. Docker - Optional: yt-dlp for YouTube imports - Optional: whisper.cpp for local caption generation -## Docker Compose - -```powershell -git clone https://github.com/Artsen/web-video-optimizer.git -cd web-video-optimizer -docker compose up --build -``` - -Open . - -The API listens on . Runtime media is stored in the Docker `video_data` volume. Stop the app with `Ctrl+C`, then run: - -```powershell -docker compose down -``` - -Use `docker compose down -v` only when you intentionally want to remove the stored media volume. - ## Local Node Install dependencies: @@ -46,7 +28,7 @@ Start both API and web: npm run dev ``` -Open . +Open . The API runs at and must remain running while the web interface is used. If PowerShell blocks `npm.ps1`, use: @@ -56,18 +38,44 @@ npm.cmd run dev ## Separate Dev Consoles -You can also run the API and web app separately: +You can also run the API and web app separately. This is useful when you want each service in its own terminal: + +Console 1: ```powershell npm run dev:api ``` +Console 2: + ```powershell npm run dev:web ``` The default API is . The default web app is . +## Docker Compose + +Docker is optional for ordinary local development. The project validates Docker Compose startup in GitHub Actions on Ubuntu. + +```powershell +git clone https://github.com/Artsen/web-video-optimizer.git +cd web-video-optimizer +docker compose up --build +``` + +Open . + +The API listens on . Runtime media is stored in the Docker `video_data` volume. Inside Docker, the API binds to `0.0.0.0` with `ALLOW_LAN_ACCESS=true` so the published host port can reach the container; ordinary local Node development still defaults to loopback-only API binding. + +Stop the app with `Ctrl+C`, then run: + +```powershell +docker compose down +``` + +Use `docker compose down -v` only when you intentionally want to remove the stored media volume. + ## FFmpeg The API expects `ffmpeg` and `ffprobe` on PATH. diff --git a/e2e/specs/app-shell.spec.ts b/e2e/specs/app-shell.spec.ts index 23882c1..a065e75 100644 --- a/e2e/specs/app-shell.spec.ts +++ b/e2e/specs/app-shell.spec.ts @@ -37,3 +37,31 @@ test("loads the empty app, navigates, toggles theme, and passes an empty-state a expect(api.requests.map((request) => request.url)).toContain("/api/history"); await assertNoBrowserErrors(); }); + +test("shows API-unreachable startup state and retries successfully", async ({ page }, testInfo) => { + const assertNoBrowserErrors = attachBrowserConsoleGate(page, testInfo, { + allowConsoleError: (text) => text === "Failed to load resource: net::ERR_FAILED", + allowRequestFailure: (text) => text.includes("http://127.0.0.1:4100/api/") && text.includes("net::ERR_FAILED") + }); + let apiReachable = false; + + await installMockApi(page); + await page.route("**/api/**", async (route) => { + if (!apiReachable) { + await route.abort("failed"); + return; + } + await route.fallback(); + }); + + await page.goto("/"); + await expect(page.getByRole("alert").getByRole("heading", { name: "Cannot reach the local API" })).toBeVisible(); + await expect(page.getByRole("alert")).toContainText("http://127.0.0.1:4100"); + + apiReachable = true; + await page.getByRole("button", { name: "Retry connection" }).click(); + + await expect(page.getByRole("heading", { name: "Ready for a source video" })).toBeVisible(); + await expect(page.getByRole("alert")).toHaveCount(0); + await assertNoBrowserErrors(); +}); diff --git a/eslint.config.mjs b/eslint.config.mjs index 53673a3..a44a60b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -29,6 +29,14 @@ export default tseslint.config( } } }, + { + files: ["scripts/**/*.mjs"], + languageOptions: { + globals: { + ...globals.node + } + } + }, { files: ["e2e/**/*.{ts,mjs}", "playwright.config.ts"], languageOptions: { diff --git a/package.json b/package.json index a5102ab..6bcac97 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "apps/*" ], "scripts": { - "dev": "npm run dev:api", + "dev": "node scripts/dev.mjs", "dev:api": "npm run build:packages && npm run dev --workspace apps/api", "dev:web": "npm run build:packages && npm run dev --workspace apps/web", "build:packages": "npm run build --workspace packages/contracts && npm run build --workspace packages/video-core", @@ -26,8 +26,10 @@ "format:check": "prettier --check .", "typecheck": "npm run build:packages && npm run typecheck --workspaces --if-present", "test": "npm run build:packages && npm run test --workspaces --if-present", - "test:run": "npm run build:packages && npm run test:run --workspaces --if-present", - "test:coverage": "npm run build:packages && npm run test:coverage --workspaces --if-present", + "test:scripts": "vitest run --environment node ./scripts/dev-processes.test.mjs", + "test:scripts:coverage": "vitest run --coverage --environment node --coverage.reportsDirectory coverage/scripts ./scripts/dev-processes.test.mjs", + "test:run": "npm run build:packages && npm run test:scripts && npm run test:run --workspaces --if-present", + "test:coverage": "npm run build:packages && npm run test:scripts:coverage && npm run test:coverage --workspaces --if-present", "test:integration:media": "npm run build && npm run test:integration:media --workspace apps/api", "test:e2e": "node e2e/support/run-playwright.mjs", "test:e2e:real": "node e2e/support/run-playwright.mjs --grep @real-stack", diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index db4f55d..dc9e6ac 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -5,3 +5,4 @@ export * from "./history.js"; export * from "./capabilities.js"; export * from "./packaging.js"; export * from "./storage.js"; +export * from "./readiness.js"; diff --git a/packages/contracts/src/readiness.ts b/packages/contracts/src/readiness.ts new file mode 100644 index 0000000..85b760d --- /dev/null +++ b/packages/contracts/src/readiness.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; +import { StoragePressureSchema } from "./storage.js"; + +export const ReadinessStateSchema = z.enum(["ready", "degraded", "not_ready"]); +export type ReadinessState = z.infer; + +export const ReadinessCheckSchema = z.object({ + ok: z.boolean(), + state: ReadinessStateSchema, + message: z.string().optional() +}); +export type ReadinessCheck = z.infer; + +export const ReadinessDtoSchema = z.object({ + state: ReadinessStateSchema, + checks: z.object({ + runtimeInitialized: ReadinessCheckSchema, + storageAvailable: ReadinessCheckSchema, + manifestLoaded: ReadinessCheckSchema, + ffmpegAvailable: ReadinessCheckSchema, + ffprobeAvailable: ReadinessCheckSchema, + h264Encoding: ReadinessCheckSchema, + modernWebmAv1: ReadinessCheckSchema, + storagePressure: ReadinessCheckSchema + }), + optional: z.object({ + ytDlpAvailable: ReadinessCheckSchema, + whisperCppAvailable: ReadinessCheckSchema, + whisperModelConfigured: ReadinessCheckSchema + }), + storage: z.object({ + pressure: StoragePressureSchema + }) +}); +export type ReadinessDto = z.infer; diff --git a/packages/contracts/tests/readiness.test.ts b/packages/contracts/tests/readiness.test.ts new file mode 100644 index 0000000..5f6f37f --- /dev/null +++ b/packages/contracts/tests/readiness.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { ReadinessDtoSchema } from "../src/readiness.js"; + +describe("ReadinessDtoSchema", () => { + it("accepts the redacted readiness response shape", () => { + const parsed = ReadinessDtoSchema.parse({ + state: "degraded", + checks: { + runtimeInitialized: { ok: true, state: "ready" }, + storageAvailable: { ok: true, state: "ready" }, + manifestLoaded: { ok: true, state: "ready" }, + ffmpegAvailable: { ok: true, state: "ready" }, + ffprobeAvailable: { ok: true, state: "ready" }, + h264Encoding: { ok: true, state: "ready" }, + modernWebmAv1: { ok: true, state: "ready" }, + storagePressure: { ok: true, state: "degraded", message: "Storage pressure warning" } + }, + optional: { + ytDlpAvailable: { ok: false, state: "degraded" }, + whisperCppAvailable: { ok: false, state: "degraded" }, + whisperModelConfigured: { ok: false, state: "degraded" } + }, + storage: { pressure: "warning" } + }); + + expect(parsed.state).toBe("degraded"); + }); +}); diff --git a/scripts/dev-processes.mjs b/scripts/dev-processes.mjs new file mode 100644 index 0000000..b03da73 --- /dev/null +++ b/scripts/dev-processes.mjs @@ -0,0 +1,163 @@ +import { spawn } from "node:child_process"; + +export function npmExecutable(platform = process.platform) { + return platform === "win32" ? "npm.cmd" : "npm"; +} + +export function createNpmCommand(args, options = {}) { + return { + command: npmExecutable(options.platform), + args + }; +} + +export function createDevPlan(options = {}) { + return { + build: createNpmCommand(["run", "build:packages"], options), + children: [ + { + label: "api", + ...createNpmCommand(["run", "dev", "--workspace", "apps/api"], options) + }, + { + label: "web", + ...createNpmCommand(["run", "dev", "--workspace", "apps/web"], options) + } + ] + }; +} + +export function terminateProcessTree(child, options = {}) { + if (!child || child.exitCode !== null || child.killed) return; + const platform = options.platform ?? process.platform; + if (platform === "win32") { + const spawnProcess = options.spawnProcess ?? spawn; + spawnProcess("taskkill", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore", windowsHide: true }); + return; + } + child.kill("SIGTERM"); +} + +export async function runDevLauncher(options = {}) { + const spawnProcess = options.spawnProcess ?? spawn; + const logger = options.logger ?? console; + const platform = options.platform ?? process.platform; + const signalSource = options.signalSource ?? process; + const stdout = options.stdout ?? process.stdout; + const stderr = options.stderr ?? process.stderr; + const nodeExecutable = options.nodeExecutable ?? process.execPath; + const npmExecPath = options.npmExecPath ?? process.env.npm_execpath; + const plan = createDevPlan({ platform }); + const children = new Set(); + let shuttingDown = false; + let exitCode = 0; + let shutdownHandler; + + const spawnStep = (step) => { + try { + return spawnProcess(step.command, step.args, { + stdio: "inherit", + shell: false, + windowsHide: true + }); + } catch (error) { + if (platform !== "win32" || error?.code !== "EINVAL") throw error; + logger.error(`[${step.label ?? "build"}] inherited terminal output was unavailable; retrying with piped output.`); + const fallbackCommand = npmExecPath + ? { command: nodeExecutable, args: [npmExecPath, ...step.args] } + : { command: step.command, args: step.args }; + const child = spawnProcess(fallbackCommand.command, fallbackCommand.args, { + stdio: ["ignore", "pipe", "pipe"], + shell: false, + windowsHide: true + }); + child.stdout?.on("data", (chunk) => stdout.write(chunk)); + child.stderr?.on("data", (chunk) => stderr.write(chunk)); + return child; + } + }; + + const stopChildren = () => { + shuttingDown = true; + for (const child of children) terminateProcessTree(child, { platform, spawnProcess }); + }; + + const runCommand = (step) => + new Promise((resolve) => { + let child; + try { + child = spawnStep(step); + } catch (error) { + logger.error(`[${step.label ?? "build"}] ${error.message}`); + resolve(1); + return; + } + child.on("error", (error) => { + logger.error(`[${step.label ?? "build"}] ${error.message}`); + resolve(1); + }); + child.on("exit", (code, signal) => { + if (signal) logger.error(`[${step.label ?? "build"}] exited from ${signal}`); + resolve(code ?? (signal ? 1 : 0)); + }); + }); + + logger.log("Building shared packages..."); + const buildCode = await runCommand({ label: "build", ...plan.build }); + if (buildCode !== 0) return buildCode; + + logger.log("Starting Web Video Optimizer development services..."); + logger.log("Web: http://localhost:5173"); + logger.log("API: http://localhost:4000"); + logger.log("Health: http://localhost:4000/health"); + logger.log("Readiness: http://localhost:4000/ready"); + + await new Promise((resolve) => { + const finish = (code) => { + if (shuttingDown) return; + exitCode = code; + stopChildren(); + resolve(); + }; + + for (const step of plan.children) { + let child; + try { + child = spawnStep(step); + } catch (error) { + logger.error(`[${step.label}] ${error.message}`); + finish(1); + return; + } + children.add(child); + child.on("error", (error) => { + logger.error(`[${step.label}] ${error.message}`); + finish(1); + }); + child.on("exit", (code, signal) => { + children.delete(child); + if (!shuttingDown) { + logger.error(`[${step.label}] exited unexpectedly${signal ? ` from ${signal}` : ` with code ${code ?? 0}`}`); + finish(code && code !== 0 ? code : 1); + } + if (shuttingDown && children.size === 0) resolve(); + }); + } + + shutdownHandler = () => { + if (!shuttingDown) { + exitCode = 0; + stopChildren(); + } + }; + signalSource.once("SIGINT", shutdownHandler); + signalSource.once("SIGTERM", shutdownHandler); + }); + + if (shutdownHandler) { + signalSource.off?.("SIGINT", shutdownHandler); + signalSource.off?.("SIGTERM", shutdownHandler); + } + + return exitCode; +} diff --git a/scripts/dev-processes.test.mjs b/scripts/dev-processes.test.mjs new file mode 100644 index 0000000..19b13d7 --- /dev/null +++ b/scripts/dev-processes.test.mjs @@ -0,0 +1,137 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, it, vi } from "vitest"; +import { createDevPlan, npmExecutable, runDevLauncher, terminateProcessTree } from "./dev-processes.mjs"; + +class FakeChild extends EventEmitter { + constructor(pid) { + super(); + this.pid = pid; + this.exitCode = null; + this.killed = false; + } + + kill(signal) { + this.killed = signal; + } +} + +function createSpawnHarness() { + const calls = []; + const children = []; + const spawnProcess = vi.fn((command, args, options) => { + const child = new FakeChild(1000 + children.length); + children.push(child); + calls.push({ command, args, options, child }); + return child; + }); + return { calls, children, spawnProcess }; +} + +describe("development launcher process plan", () => { + it("selects npm.cmd on Windows", () => { + expect(npmExecutable("win32")).toBe("npm.cmd"); + expect(npmExecutable("linux")).toBe("npm"); + }); + + it("builds shared packages before starting both development servers", async () => { + const harness = createSpawnHarness(); + const signals = new EventEmitter(); + const promise = runDevLauncher({ + spawnProcess: harness.spawnProcess, + logger: { log: vi.fn(), error: vi.fn() }, + platform: "linux", + signalSource: signals + }); + + expect(harness.calls[0]).toMatchObject({ command: "npm", args: ["run", "build:packages"] }); + harness.children[0].emit("exit", 0); + await Promise.resolve(); + expect(harness.calls.slice(1).map((call) => call.args)).toEqual([ + ["run", "dev", "--workspace", "apps/api"], + ["run", "dev", "--workspace", "apps/web"] + ]); + signals.emit("SIGINT"); + expect(harness.children[1].killed).toBe("SIGTERM"); + expect(harness.children[2].killed).toBe("SIGTERM"); + harness.children[1].emit("exit", 0); + harness.children[2].emit("exit", 0); + await expect(promise).resolves.toBe(0); + }); + + it("returns nonzero when the shared package build fails", async () => { + const harness = createSpawnHarness(); + const promise = runDevLauncher({ + spawnProcess: harness.spawnProcess, + logger: { log: vi.fn(), error: vi.fn() }, + platform: "linux" + }); + + harness.children[0].emit("exit", 1); + + await expect(promise).resolves.toBe(1); + expect(harness.calls).toHaveLength(1); + }); + + it("terminates the sibling when one development child fails", async () => { + const harness = createSpawnHarness(); + const signals = new EventEmitter(); + const promise = runDevLauncher({ + spawnProcess: harness.spawnProcess, + logger: { log: vi.fn(), error: vi.fn() }, + platform: "linux", + signalSource: signals + }); + harness.children[0].emit("exit", 0); + await Promise.resolve(); + + harness.children[1].emit("exit", 9); + await Promise.resolve(); + + expect(harness.children[2].killed).toBe("SIGTERM"); + harness.children[2].emit("exit", null, "SIGTERM"); + await expect(promise).resolves.toBe(9); + }); + + it("uses taskkill for Windows process trees", () => { + const child = new FakeChild(1234); + const taskkill = vi.fn(() => new FakeChild(9999)); + terminateProcessTree(child, { platform: "win32", spawnProcess: taskkill }); + expect(child.killed).toBe(false); + expect(taskkill).toHaveBeenCalledWith("taskkill", ["/pid", "1234", "/t", "/f"], { + stdio: "ignore", + windowsHide: true + }); + }); + + it("retries with piped output when inherited Windows stdio is unavailable", async () => { + const harness = createSpawnHarness(); + const error = new Error("invalid inherited stdio"); + error.code = "EINVAL"; + harness.spawnProcess.mockImplementationOnce(() => { + throw error; + }); + + const promise = runDevLauncher({ + spawnProcess: harness.spawnProcess, + logger: { log: vi.fn(), error: vi.fn() }, + platform: "win32", + nodeExecutable: "node.exe", + npmExecPath: "npm-cli.js", + stdout: { write: vi.fn() }, + stderr: { write: vi.fn() } + }); + + expect(harness.calls[0]).toMatchObject({ + command: "node.exe", + args: ["npm-cli.js", "run", "build:packages"], + options: { shell: false, stdio: ["ignore", "pipe", "pipe"] } + }); + harness.children[0].emit("exit", 1); + + await expect(promise).resolves.toBe(1); + }); + + it("describes the intended development plan", () => { + expect(createDevPlan({ platform: "win32" }).children.map((child) => child.command)).toEqual(["npm.cmd", "npm.cmd"]); + }); +}); diff --git a/scripts/dev.mjs b/scripts/dev.mjs new file mode 100644 index 0000000..263b22c --- /dev/null +++ b/scripts/dev.mjs @@ -0,0 +1,4 @@ +import { runDevLauncher } from "./dev-processes.mjs"; + +const exitCode = await runDevLauncher(); +process.exit(exitCode);