Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 33f338c4760dfe4776408a717138af0184f13dbb
parent 64549a205378f530760b86df53ef451ed2c193a1
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed, 23 Sep 2026 19:21:44 -0400

api: one polling route — /api/view/[name], and the eight old paths are rewrites

Eight route files that each called one builder and serialized it become one
dynamic route plus a handler table:

- `common/views/names.ts` (importless): `VIEW_NAMES`, `ViewName`, and
  `VIEW_CONTRACT` — pulse is the one "observe" view, the other seven
  "construct". A `/api/test/*` name may never join the tuple.
- `editor/app/api/view/views.ts`: `VIEWS: Record<ViewName, handler>`, total, so
  a name without a handler is a tsc error. Each handler builds its own inputs,
  exactly as its old route did; there is no shared constructor in the
  dispatcher (a hoisted liveInputs() is the ~16-flaky-spec regression).
- `editor/app/api/view/pulseView.ts`: the pulse GET moved whole, the ?rev= idle
  fast path included. `views.test.ts` reads it as text and bans the
  constructors, and asserts the loader's `CleanableChannelRow` stays
  assignable to the view's `CleanableChannel`.
- `editor/app/api/view/[name]/route.ts`: force-dynamic; the name is checked
  before any handler runs; unknown ⇒ 404. No auth, no EDITOR_TEST_ROUTES guard —
  unchanged from the eight routes it replaces.

The old paths are REWRITES in next.config.ts, not redirects: method, status,
body and the query string pass through, so pinned widgets, the dashboard and
`/api/pulse?rev=` keep working. No existing e2e assertion was edited;
`e2e/view-route.spec.ts` adds old-vs-new body equality, the 404, and ?rev
through the rewrite. `/api/widget/presets` is not a view and keeps its route.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Acommon/views/names.test.ts | 30++++++++++++++++++++++++++++++
Acommon/views/names.ts | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Deditor/app/api/auto-queue/status/route.ts | 11-----------
Deditor/app/api/jobs/active/route.ts | 11-----------
Deditor/app/api/pulse/route.ts | 52----------------------------------------------------
Deditor/app/api/scheduler/status/route.ts | 11-----------
Aeditor/app/api/view/[name]/route.ts | 41+++++++++++++++++++++++++++++++++++++++++
Aeditor/app/api/view/pulseView.ts | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/api/view/views.test.ts | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/api/view/views.ts | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Deditor/app/api/widget/actionable/route.ts | 33---------------------------------
Deditor/app/api/widget/cleanable/route.ts | 16----------------
Deditor/app/api/widget/sync/route.ts | 16----------------
Deditor/app/api/workers/route.ts | 12------------
Aeditor/e2e/view-route.spec.ts | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/next.config.ts | 47+++++++++++++++++++++++++++++++++++++++++------
16 files changed, 487 insertions(+), 168 deletions(-)

diff --git a/common/views/names.test.ts b/common/views/names.test.ts @@ -0,0 +1,30 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { VIEW_CONTRACT, VIEW_NAMES } from "./names"; + +test("every name has a contract, and the contract names nothing else", () => { + assert.deepEqual(Object.keys(VIEW_CONTRACT).sort(), [...VIEW_NAMES].sort()); +}); + +test("the names are unique", () => { + assert.equal(new Set(VIEW_NAMES).size, VIEW_NAMES.length); +}); + +// The one view that may not construct. If this flips, /api/view/pulse has +// stopped being the cheap change-token the whole UI polls, and e2e/pulse.spec.ts +// (< 250 ms, no corpus contact) is the next thing to go red. +test("pulse observes; every other view constructs", () => { + assert.equal(VIEW_CONTRACT.pulse, "observe"); + for (const name of VIEW_NAMES) { + if (name === "pulse") continue; + assert.equal(VIEW_CONTRACT[name], "construct", `${name} may construct`); + } +}); + +// A `/api/test/*` route is unauthenticated AND mutating; the dispatcher has no +// guard, so the only thing standing between the two is this tuple. +test("no test-harness route is a view", () => { + for (const name of VIEW_NAMES) { + assert.equal(/test/i.test(name), false, `${name} smells like the harness`); + } +}); diff --git a/common/views/names.ts b/common/views/names.ts @@ -0,0 +1,55 @@ +// THE POLLED VIEWS, BY NAME — the contract `/api/view/[name]` serves. +// +// Eight endpoints used to be eight route files that differed only in which +// builder they called. They are one dynamic route now, and this tuple is what +// makes that route total: `editor/app/api/view/views.ts` declares its handler +// table as `Record<ViewName, …>`, so a name added here without a handler is a +// tsc error rather than a 500 somebody finds in production. That completeness +// proof is the entire reason the names live in a shared, importless module +// instead of in the route beside the table. +// +// WHAT MAY BE ADDED. A view is something a client POLLS for state it draws: +// read-only, unauthenticated, cheap enough to ask for on a timer, and safe for +// anyone who can reach the editor at all. Nothing that mutates, nothing that +// takes a credential, and — stated because the temptation is obvious — NO +// `/api/test/*` NAME MAY EVER JOIN THIS TUPLE. Those routes fabricate jobs, +// restart lanes and set the worker token; they are gated on +// `EDITOR_TEST_ROUTES` precisely so they do not exist on a real editor, and +// putting one behind this dispatcher would hand it the one surface here that +// has no guard at all. +export const VIEW_NAMES = [ + "pulse", + "activeJobs", + "workers", + "autoQueueStatus", + "schedulerStatus", + "widgetSync", + "widgetActionable", + "cleanable", +] as const; + +export type ViewName = (typeof VIEW_NAMES)[number]; + +// OBSERVE OR CONSTRUCT, declared per view. +// +// An "observe" view may look at singletons that already exist and at file +// mtimes, and MUST NOT bring anything into being: no registry, no settings +// parse, no worker pool, no corpus read. `pulse` is the only one, and the rule +// is not stylistic — constructing from the pulse poll is what made ~16 specs +// flaky earlier this month, because a 1-second timer was racing the very +// singletons the page under test was setting up. +// +// Everything else is "construct": it reads what it needs, each handler +// assembling its OWN inputs. Do not hoist a shared constructor into the +// dispatcher "for efficiency" — that is the same regression wearing a +// different hat, and it would drag an observe view into constructing. +export const VIEW_CONTRACT: Record<ViewName, "observe" | "construct"> = { + pulse: "observe", + activeJobs: "construct", + workers: "construct", + autoQueueStatus: "construct", + schedulerStatus: "construct", + widgetSync: "construct", + widgetActionable: "construct", + cleanable: "construct", +}; diff --git a/editor/app/api/auto-queue/status/route.ts b/editor/app/api/auto-queue/status/route.ts @@ -1,11 +0,0 @@ -import { NextResponse } from "next/server"; -import { buildAutoQueueStatusPayload } from "../../../operations/status"; - -export const dynamic = "force-dynamic"; - -// Read-only view for the Auto-Queue panel: per-kind runner status, effective -// policy, recent picks, and snapshot-derived pending counts. Backs a passive UI -// poll, like /api/scheduler/status and /api/jobs/active. -export async function GET() { - return NextResponse.json(await buildAutoQueueStatusPayload()); -} diff --git a/editor/app/api/jobs/active/route.ts b/editor/app/api/jobs/active/route.ts @@ -1,11 +0,0 @@ -import { NextResponse } from "next/server"; -import { buildActiveJobsPayload } from "../../../jobs/active/buildActiveJobs"; - -export const dynamic = "force-dynamic"; - -// Backs the ~1s client poll on the /jobs head, the dashboard and the widget, -// so per-task progress bars advance live without a full RSC refresh. -export async function GET() { - const payload = await buildActiveJobsPayload(); - return NextResponse.json(payload); -} diff --git a/editor/app/api/pulse/route.ts b/editor/app/api/pulse/route.ts @@ -1,52 +0,0 @@ -import { NextResponse } from "next/server"; -import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { - computePulse, - type PulsePayload, -} from "yt-dlp-transcript-common/views/pulse"; -import { observeInputs } from "../../lib/liveInputs"; -import { cleanableTotalBytes } from "../../cleanup/lib/loadCleanup"; - -export const dynamic = "force-dynamic"; - -// The change token the global AutoRefresh polls instead of blindly re-rendering -// the whole page tree every 5 seconds. The token itself is -// `common/views/pulse.ts`; `observeInputs()` is the observer's reads. -// -// ⚠️ THIS ENDPOINT OBSERVES; IT MUST NEVER CONSTRUCT. That is now a property of -// the two functions it calls rather than a rule this file remembers — see the -// header of either one for the ~16 flaky specs that bought it. -// -// Everything on the idle path is either in-memory or a stat(). NO readdir, no -// corpus contact, no large JSON parse — asserted by e2e/pulse.spec.ts, because -// the entire point of this endpoint is that it is cheap enough to poll forever. -export async function GET(request: Request) { - const known = new URL(request.url).searchParams.get("rev"); - const { rev, activeJobs, runningJobs, busy } = computePulse(observeInputs()); - - // Idle fast path. The client already has this rev, so nothing it displays can - // have changed — skip the only part of this endpoint that touches disk. - if (known && known === rev) { - return NextResponse.json({ - rev, - changed: false, - activeJobs, - runningJobs, - cleanableBytes: -1, // not recomputed; the client keeps its last value - busy, - } satisfies PulsePayload); - } - - // Something moved: it's worth the ~40 ms to refresh the badge value too. - // (Before the corpus walk was removed this number cost ~4.4 s, which is - // precisely why it must never be on the idle path.) - const cleanableBytes = await cleanableTotalBytes(getPaths()); - return NextResponse.json({ - rev, - changed: true, - activeJobs, - runningJobs, - cleanableBytes, - busy, - } satisfies PulsePayload); -} diff --git a/editor/app/api/scheduler/status/route.ts b/editor/app/api/scheduler/status/route.ts @@ -1,11 +0,0 @@ -import { NextResponse } from "next/server"; -import { buildSchedulerStatusPayload } from "../../../scheduler/status"; - -export const dynamic = "force-dynamic"; - -// Read-only view for the /operations/sync console: the resolved per-channel -// schedule (next due / last outcome / backoff) plus the recent tick log and the -// effective scheduler settings. Backs a passive UI poll, like /api/jobs/active. -export async function GET() { - return NextResponse.json(await buildSchedulerStatusPayload()); -} diff --git a/editor/app/api/view/[name]/route.ts b/editor/app/api/view/[name]/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; +import { + VIEW_NAMES, + type ViewName, +} from "yt-dlp-transcript-common/views/names"; +import { VIEWS } from "../views"; + +export const dynamic = "force-dynamic"; + +// ONE POLLING ROUTE. Eight route files that each called one builder and +// serialized it are this file plus a table (`../views.ts`); the old paths are +// REWRITES in next.config.ts, so `/api/pulse?rev=…` and `/api/widget/actionable` +// still answer, byte for byte, for every pinned widget and open tab out there. +// +// NO AUTH AND NO ENV GUARD HERE, and that is today's behaviour preserved rather +// than a gap: all eight of these were unauthenticated polls, they are read-only, +// and the editor is a loopback-only admin surface (docker/guard-exposure.sh +// refuses to start it otherwise). Contrast `api/worker/health`, which takes the +// worker token, and `api/test/*`, which is gated on EDITOR_TEST_ROUTES because +// it MUTATES. A `/api/test/*` name may never join `VIEW_NAMES`: this dispatcher +// would serve it with no guard at all. +// +// THE NAME IS CHECKED BEFORE ANY INPUT IS CONSTRUCTED. An unknown name costs a +// tuple lookup and a 404 — no corpus read, no singleton, nothing to wedge. The +// handlers in the table are lazy closures for exactly that reason. +function isViewName(name: string): name is ViewName { + return (VIEW_NAMES as readonly string[]).includes(name); +} + +export async function GET( + request: Request, + ctx: { params: Promise<{ name: string }> }, +) { + const { name } = await ctx.params; + // 404 and not 500: a name nobody serves is a path that does not exist, which + // is what the client's `res.ok` check already knows how to handle. + if (!isViewName(name)) { + return NextResponse.json({ error: "Not Found" }, { status: 404 }); + } + return VIEWS[name](request); +} diff --git a/editor/app/api/view/pulseView.ts b/editor/app/api/view/pulseView.ts @@ -0,0 +1,57 @@ +import { NextResponse } from "next/server"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { + computePulse, + type PulsePayload, +} from "yt-dlp-transcript-common/views/pulse"; +import { observeInputs } from "../../lib/liveInputs"; +import { cleanableTotalBytes } from "../../cleanup/lib/loadCleanup"; + +// The change token the global AutoRefresh polls instead of blindly re-rendering +// the whole page tree every 5 seconds. The token itself is +// `common/views/pulse.ts`; `observeInputs()` is the observer's reads. +// +// ⚠️ THIS VIEW OBSERVES; IT MUST NEVER CONSTRUCT. That is now a property of +// the two functions it calls rather than a rule this file remembers — see the +// header of either one for the ~16 flaky specs that bought it, and +// `VIEW_CONTRACT.pulse` for the same promise stated where the dispatcher can +// be read against it. `views.test.ts` re-checks it as text. +// +// Everything on the idle path is either in-memory or a stat(). NO readdir, no +// corpus contact, no large JSON parse — asserted by e2e/pulse.spec.ts, because +// the entire point of this endpoint is that it is cheap enough to poll forever. +// +// It is the only view handler that reads the request: `?rev=` is the client's +// last token, and the fast path below is what makes an idle editor nearly free. +// The rewrite from `/api/pulse` is server-internal, so that query string +// arrives here unchanged. +export async function pulseView(request: Request): Promise<Response> { + const known = new URL(request.url).searchParams.get("rev"); + const { rev, activeJobs, runningJobs, busy } = computePulse(observeInputs()); + + // Idle fast path. The client already has this rev, so nothing it displays can + // have changed — skip the only part of this endpoint that touches disk. + if (known && known === rev) { + return NextResponse.json({ + rev, + changed: false, + activeJobs, + runningJobs, + cleanableBytes: -1, // not recomputed; the client keeps its last value + busy, + } satisfies PulsePayload); + } + + // Something moved: it's worth the ~40 ms to refresh the badge value too. + // (Before the corpus walk was removed this number cost ~4.4 s, which is + // precisely why it must never be on the idle path.) + const cleanableBytes = await cleanableTotalBytes(getPaths()); + return NextResponse.json({ + rev, + changed: true, + activeJobs, + runningJobs, + cleanableBytes, + busy, + } satisfies PulsePayload); +} diff --git a/editor/app/api/view/views.test.ts b/editor/app/api/view/views.test.ts @@ -0,0 +1,66 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { VIEW_CONTRACT } from "yt-dlp-transcript-common/views/names"; +import type { CleanableChannel } from "yt-dlp-transcript-common/views/cleanable"; +import type { CleanableChannelRow } from "../../cleanup/lib/loadCleanup"; + +// Run with: +// pnpm -C editor exec tsx --test "app/**/*.test.ts" + +const HERE = path.dirname(fileURLToPath(import.meta.url)); + +// THE OBSERVE CONTRACT, CHECKED AS TEXT. +// +// `VIEW_CONTRACT.pulse === "observe"` is a promise about what the handler does, +// and only the handler can keep it. The ban below is the same trick the corpus +// walk guard uses (common/controller/noCorpusWalkInRenderPaths.test.ts): read +// the file and refuse the names that construct. It is crude and it is the +// reason ~16 specs stopped flaking — a pulse poll that builds the registry, the +// settings or the worker pool races the singletons the page under test is +// setting up, on a 1-second timer, forever. +// +// THE MATCH IS CONTEXT-BLIND, deliberately: a comment naming one of these with +// its parenthesis fails too. Reword the comment; do not loosen the test. +const CONSTRUCTORS = [ + "liveInputs(", + "getRegistry(", + "getSettings(", + "getWorkerPool(", +]; + +test("the pulse view observes and never constructs", () => { + const src = readFileSync(path.join(HERE, "pulseView.ts"), "utf8"); + assert.equal(VIEW_CONTRACT.pulse, "observe"); + // Guard the guard: if the file stops naming the observer, the ban below is + // checking an empty claim. + assert.ok( + src.includes("observeInputs("), + "pulseView.ts must read its inputs through observeInputs()", + ); + for (const banned of CONSTRUCTORS) { + assert.ok( + !src.includes(banned), + `pulseView.ts names ${banned} — the pulse view may not construct`, + ); + } +}); + +// THE CLEANABLE ROW, ASSERTED AT COMPILE TIME. +// +// `common/views/cleanable.ts` re-declares the loader's row structurally rather +// than importing it, because a view may not reach into the app. That only keeps +// working while the two stay assignable, and tsc fails HERE — naming both +// types — instead of somewhere inside the handler table. +// +// Tuple-wrapped on both sides: a bare `A extends B ?` DISTRIBUTES over a union +// and a non-assignable member would collapse to `never`, making `true | never` +// = `true` (see common/views/streamAction.test.ts, which paid for that lesson). +type AssignableTo<A, B> = [A] extends [B] ? true : never; +const _rowIsAChannel: AssignableTo<CleanableChannelRow, CleanableChannel> = true; + +test("CleanableChannelRow is assignable to the view's CleanableChannel", () => { + assert.equal(_rowIsAChannel, true); +}); diff --git a/editor/app/api/view/views.ts b/editor/app/api/view/views.ts @@ -0,0 +1,87 @@ +import { NextResponse } from "next/server"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import type { ViewName } from "yt-dlp-transcript-common/views/names"; +import { buildCleanablePayload } from "yt-dlp-transcript-common/views/cleanable"; +import { buildWidgetActionablePayload } from "yt-dlp-transcript-common/views/widgetActionable"; +import { buildWidgetSyncPayload } from "yt-dlp-transcript-common/views/widgetSync"; +import { pulseView } from "./pulseView"; +import { buildActiveJobsPayload } from "../../jobs/active/buildActiveJobs"; +import { buildWorkersPayload } from "../../workers/buildWorkers"; +import { buildAutoQueueStatusPayload } from "../../operations/status"; +import { buildSchedulerStatusPayload } from "../../scheduler/status"; +import { widgetSyncInputs } from "../../widget/lib/syncInputs"; +import { cleanableChannels } from "../../cleanup/lib/loadCleanup"; +import { + actionableDigestReachableCount, + actionableUndownloadedCount, + actionableUntranscribedCount, + loadActionableSummary, +} from "../../lib/actionable/loadActionable"; + +// THE HANDLER TABLE, AND THE COMPLETENESS PROOF. +// +// `Record<ViewName, …>` is total: every name in `VIEW_NAMES` must appear here +// or the editor does not compile. That is the whole reason the names are a +// tuple in `common/views/names.ts` rather than a union spelled out beside this +// table — a union and a map drift; a total Record cannot. +// +// EVERY HANDLER ASSEMBLES ITS OWN INPUTS, and this file must never grow a +// shared constructor hoisted out of them. One `liveInputs()` in the dispatcher +// would be cheaper by a few milliseconds and would put the pulse view — which +// may only OBSERVE — on the constructing path, which is the ~16-flaky-spec +// regression from earlier this month rebuilt from the top. Each of these +// closures does exactly what its old route file did, no more and in the same +// order. +// +// The handlers are LAZY for the same reason: the dispatcher picks one by name +// and calls it, so an unknown name reads nothing at all. +export const VIEWS: Record<ViewName, (req: Request) => Promise<Response>> = { + // The change token, and the only handler that reads the request. + pulse: pulseView, + + // The ~1 s poll on the /jobs head, the dashboard and the widget. This one + // HEALS: `buildActiveJobsPayload` completes scheduler slots whose job record + // is already terminal. (`liveJobRows`, which the server components call, does + // not — that asymmetry is deliberate and lives in the view.) + activeJobs: async () => NextResponse.json(await buildActiveJobsPayload()), + + // Live worker status for the Workers page and the read-only widget. + workers: async () => NextResponse.json(buildWorkersPayload()), + + // The Auto-Queue panel: per-kind runner status, effective policy, recent + // picks and snapshot-derived pending counts. + autoQueueStatus: async () => + NextResponse.json(await buildAutoQueueStatusPayload()), + + // The /operations/sync console: the resolved per-channel schedule, the recent + // tick log and the effective scheduler settings. + schedulerStatus: async () => + NextResponse.json(await buildSchedulerStatusPayload()), + + // The widget's last-sync and scheduler strips, plus the corpus-wide digest + // and backfill scalars. + widgetSync: async () => + NextResponse.json(buildWidgetSyncPayload(await widgetSyncInputs())), + + // The widget's "Needs work" strip. The census read and the map through the + // four count helpers (which know the availability exclusions) stay here; the + // filter and the sort are the view's. + widgetActionable: async () => { + const summary = await loadActionableSummary(getPaths()); + return NextResponse.json( + buildWidgetActionablePayload( + summary.rows.map((row) => ({ + slug: row.channel.slug, + undownloaded: actionableUndownloadedCount(row), + untranscribed: actionableUntranscribedCount(row), + digestReachable: actionableDigestReachableCount(row), + })), + ), + ); + }, + + // The cleanable-data badge AND the widget's "Needs cleaning" list — one + // snapshot read serving both, as before. + cleanable: async () => + NextResponse.json(buildCleanablePayload(await cleanableChannels(getPaths()))), +}; diff --git a/editor/app/api/widget/actionable/route.ts b/editor/app/api/widget/actionable/route.ts @@ -1,33 +0,0 @@ -import { NextResponse } from "next/server"; -import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { buildWidgetActionablePayload } from "yt-dlp-transcript-common/views/widgetActionable"; -import { - actionableDigestReachableCount, - actionableUndownloadedCount, - actionableUntranscribedCount, - loadActionableSummary, -} from "../../../lib/actionable/loadActionable"; - -export const dynamic = "force-dynamic"; - -// Backs the monitor widget's optional "Needs work" strip. The payload is -// `common/views/widgetActionable.ts`; this file is the census read and the map -// through the count helpers, which know the availability exclusions and stay -// beside the loader. -// -// THE PATH KEEPS ITS NAME. /actionable the page is gone; /api/widget/actionable -// is a wire contract a pinned widget in someone's browser is polling right now, -// and renaming it would break that for no gain. -export async function GET() { - const summary = await loadActionableSummary(getPaths()); - return NextResponse.json( - buildWidgetActionablePayload( - summary.rows.map((row) => ({ - slug: row.channel.slug, - undownloaded: actionableUndownloadedCount(row), - untranscribed: actionableUntranscribedCount(row), - digestReachable: actionableDigestReachableCount(row), - })), - ), - ); -} diff --git a/editor/app/api/widget/cleanable/route.ts b/editor/app/api/widget/cleanable/route.ts @@ -1,16 +0,0 @@ -import { NextResponse } from "next/server"; -import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { buildCleanablePayload } from "yt-dlp-transcript-common/views/cleanable"; -import { cleanableChannels } from "../../../cleanup/lib/loadCleanup"; - -export const dynamic = "force-dynamic"; - -// Backs the monitor widget's cleanable-data indicator AND its "Needs cleaning" -// channel list — the per-channel rows come from the same snapshot read, so -// serving both costs no extra I/O and no second poll. Covers the primary "clean -// audio" reclaim across channels not excluded from the cleanup total (honors the -// per-channel excludeFromCleanup flag); the payload's `bytes` is their sum, and -// it is `common/views/cleanable.ts` that sums them. -export async function GET() { - return NextResponse.json(buildCleanablePayload(await cleanableChannels(getPaths()))); -} diff --git a/editor/app/api/widget/sync/route.ts b/editor/app/api/widget/sync/route.ts @@ -1,16 +0,0 @@ -import { NextResponse } from "next/server"; -import { buildWidgetSyncPayload } from "yt-dlp-transcript-common/views/widgetSync"; -import { widgetSyncInputs } from "../../../widget/lib/syncInputs"; - -export const dynamic = "force-dynamic"; - -// The payload is `common/views/widgetSync.ts` and its reads are -// `widget/lib/syncInputs.ts`. This file is the HTTP edge and nothing else — it -// used to hold the builder AND the type, which is how `app/page.tsx` came to -// import a page's data out of a route. Its last trace was a type re-export, -// which the client components named until they were repointed at the view; the -// only thing this file exports now is the endpoint. - -export async function GET() { - return NextResponse.json(buildWidgetSyncPayload(await widgetSyncInputs())); -} diff --git a/editor/app/api/workers/route.ts b/editor/app/api/workers/route.ts @@ -1,12 +0,0 @@ -import { NextResponse } from "next/server"; -import { buildWorkersPayload } from "../../workers/buildWorkers"; - -export const dynamic = "force-dynamic"; - -// Live worker status for the Workers page and the read-only monitor widget. -// Polled ~1s by WorkersView/MonitorWidget. Joins the pool's per-worker -// slot/state summary with the registry's in-flight transcribe tasks (which -// carry workerId) so each card can show what it's currently running. -export async function GET() { - return NextResponse.json(buildWorkersPayload()); -} diff --git a/editor/e2e/view-route.spec.ts b/editor/e2e/view-route.spec.ts @@ -0,0 +1,110 @@ +import { test, expect } from "@playwright/test"; +import { resetData } from "./helpers"; + +// ONE POLLING ROUTE, AND THE OLD PATHS THAT STILL ANSWER. +// +// Eight route files became `/api/view/[name]` plus a handler table +// (app/api/view/views.ts). The eight paths the clients used to call are +// REWRITES in next.config.ts — server-internal, so a pinned widget and the +// dashboard keep polling exactly what they always polled. +// +// The ~30 assertions the rest of the suite makes at the old paths are that +// remap's real regression test; nothing there was edited. What this spec adds +// is the part those cannot see: that each old path and its new twin return the +// SAME BODY, that an unknown view name 404s instead of 500ing, that none of +// this wants a credential, and that the query string survives the rewrite — +// which is the whole of `/api/pulse?rev=`. + +// The two fields that carry the clock. Everything else in these payloads is +// read from disk or from in-memory state that does not move in an idle fixture, +// so it is compared verbatim. +const VOLATILE: Record<string, string[]> = { + // `builtAt` is Date.now() at build time. + "/api/jobs/active": ["builtAt"], + // `now` is stamped so the console can age its rows client-side. + "/api/scheduler/status": ["now"], +}; + +const PAIRS: Array<[string, string]> = [ + ["/api/pulse", "/api/view/pulse"], + ["/api/jobs/active", "/api/view/activeJobs"], + ["/api/workers", "/api/view/workers"], + ["/api/auto-queue/status", "/api/view/autoQueueStatus"], + ["/api/scheduler/status", "/api/view/schedulerStatus"], + ["/api/widget/sync", "/api/view/widgetSync"], + ["/api/widget/actionable", "/api/view/widgetActionable"], + ["/api/widget/cleanable", "/api/view/cleanable"], +]; + +function strip(body: unknown, keys: string[]): unknown { + if (!body || typeof body !== "object") return body; + const copy = { ...(body as Record<string, unknown>) }; + for (const key of keys) delete copy[key]; + return copy; +} + +test.describe("/api/view/[name]", () => { + test.beforeEach(async () => { + await resetData("channel-with-counts"); + }); + + for (const [oldPath, viewPath] of PAIRS) { + test(`${oldPath} and ${viewPath} are the same endpoint`, async ({ + request, + }) => { + const before = await request.get(oldPath); + const after = await request.get(viewPath); + expect(before.status(), `${oldPath} status`).toBe(200); + expect(after.status(), `${viewPath} status`).toBe(200); + + const keys = VOLATILE[oldPath] ?? []; + expect(strip(await after.json(), keys)).toEqual( + strip(await before.json(), keys), + ); + }); + } + + test("an unknown view name is 404, not 500", async ({ request }) => { + for (const name of ["nope", "Pulse", "activejobs", "presets"]) { + const res = await request.get(`/api/view/${name}`); + expect(res.status(), `/api/view/${name}`).toBe(404); + } + }); + + // The dispatcher has no guard by design (these are read-only polls), but the + // harness routes DO — and they must not be reachable through it. + test("a test-harness name is not a view", async ({ request }) => { + const res = await request.get("/api/view/invalidate-cache"); + expect(res.status()).toBe(404); + }); + + // /api/widget/presets is a menu fetch on open, not a poll: it is not a view, + // it keeps its own route, and nothing here shadows it. + test("/api/widget/presets is untouched", async ({ request }) => { + const res = await request.get("/api/widget/presets"); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(Array.isArray(body.builtIn)).toBe(true); + expect(Array.isArray(body.saved)).toBe(true); + }); + + test("the rev query survives the rewrite", async ({ request }) => { + const seed = await (await request.get("/api/view/pulse")).json(); + expect(seed.changed).toBe(true); + const rev = encodeURIComponent(seed.rev); + + // Directly: the idle fast path answers `changed: false`. + const direct = await (await request.get(`/api/view/pulse?rev=${rev}`)).json(); + expect(direct.rev).toBe(seed.rev); + expect(direct.changed).toBe(false); + + // And through the rewrite — which is the assertion that matters, because a + // rewrite that dropped the query string would silently turn every idle + // poll into a full one (a disk read per client per 5 seconds) while + // returning a body that still looks correct. + const viaOldPath = await (await request.get(`/api/pulse?rev=${rev}`)).json(); + expect(viaOldPath.rev).toBe(seed.rev); + expect(viaOldPath.changed).toBe(false); + expect(viaOldPath.cleanableBytes).toBe(-1); + }); +}); diff --git a/editor/next.config.ts b/editor/next.config.ts @@ -86,9 +86,17 @@ const nextConfig: NextConfig = { // TEMPORARY, not permanent: a 308 is cached by the browser forever, and this // is a self-hosted admin surface where a wrong permanent redirect is a // support call with no remedy but a profile wipe. `permanent: false` is a - // 307, and query strings pass through to the destination. The API paths under - // /api/auto-queue/* and /api/widget/actionable are NOT redirected — they - // never moved. + // 307, and query strings pass through to the destination. + // + // NO API PATH IS EVER REDIRECTED, and since one-core phase 3 slice 2 that is + // a rule with teeth: the eight polled endpoints (/api/pulse, + // /api/jobs/active, /api/workers, /api/auto-queue/status, + // /api/scheduler/status and three of the four /api/widget/*) are served by + // ONE route now, /api/view/[name], and they reach it through the REWRITES + // below. A redirect would change the URL a poller sees, the status it checks + // and — on some clients — the method; a pinned monitor widget in someone's + // browser and the external cron tick are clients nobody can update. A rewrite + // is server-internal and changes none of that. async redirects() { return [ { source: "/auto-queue", destination: "/operations", permanent: false }, @@ -106,11 +114,38 @@ const nextConfig: NextConfig = { { source: "/homepage", destination: "/sites", permanent: false }, ]; }, - // Serve the built export artifacts (stats/summaries/transcripts) through a - // route handler so the charts authoring tab can preview real data, mirroring - // the static viewer's served paths. + // Two unrelated groups. + // + // 1. THE POLLED VIEWS. Eight endpoints, one route: /api/view/[name], whose + // names are `VIEW_NAMES` in common/views/names.ts. The old paths keep + // answering because a rewrite is a server-internal remap — method, status, + // body and QUERY STRING pass through, which is what /api/pulse?rev=<token> + // depends on, and the client never learns the path changed. That matters + // for /api/widget/*: a widget is a pinned link in someone's browser (see + // the "THE PATH KEEPS ITS NAME" note in common/views/widgetActionable.ts), + // and for /api/scheduler/status and /api/jobs/active, which pages and the + // dashboard poll. The e2e suite still asserts at the OLD paths on purpose: + // ~30 assertions nobody edited are this remap's regression test. + // + // An array-form rewrite is `afterFiles` — checked after real files and + // BEFORE dynamic routes — so /api/jobs/active cannot be swallowed by + // /api/jobs/[id]/log (a different depth anyway), and /api/widget/presets, + // which is a menu fetch rather than a poll and is NOT a view, keeps its own + // route file and is never matched here. + // + // 2. The built export artifacts (stats/summaries/transcripts), served through + // a route handler so the charts authoring tab can preview real data, + // mirroring the static viewer's served paths. async rewrites() { return [ + { source: "/api/pulse", destination: "/api/view/pulse" }, + { source: "/api/jobs/active", destination: "/api/view/activeJobs" }, + { source: "/api/workers", destination: "/api/view/workers" }, + { source: "/api/auto-queue/status", destination: "/api/view/autoQueueStatus" }, + { source: "/api/scheduler/status", destination: "/api/view/schedulerStatus" }, + { source: "/api/widget/sync", destination: "/api/view/widgetSync" }, + { source: "/api/widget/actionable", destination: "/api/view/widgetActionable" }, + { source: "/api/widget/cleanable", destination: "/api/view/cleanable" }, { source: "/stats/:path*", destination: "/exported/stats/:path*" }, { source: "/summaries/:path*", destination: "/exported/summaries/:path*" }, { source: "/transcripts/:path*", destination: "/exported/transcripts/:path*" },