Archilyzer · Source

archilyzer

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

commit a587f4b3b7f0d8868bc24e1382df14858360ff37
parent 9ebc5d241b7a5436b33e6dc8075c9c219bd9caa7
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed, 23 Sep 2026 08:56:35 -0400

ops: a deploy-site route, and preview on both deploy routes

Previewing is only useful if you can then ship the SAME bundle to production
without rebuilding it, and build-deploy could not: it always builds. deploy-site
is the other half, over the already-built export/out. The fan-out loop the two
now share moved into _lib rather than being copied — the careful parts (one
site's throw not costing the others their job ids; no jobs being a 400, not a
cheerful empty list) are exactly the parts a copy gets wrong.

`preview` is judged by the same previewBranchProblem the UI and the action use,
and judged here so the refusal precedes every job. It is refused with `all`,
because the all-sites runner has nowhere to put a branch and building every
site to ship it to production is the worst possible reading of the request.

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

Diffstat:
Meditor/app/api/ops/_lib.ts | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/api/ops/build-deploy/route.ts | 89++++++++++++++++++++++++++++++++++++-------------------------------------------
Aeditor/app/api/ops/deploy-site/route.ts | 48++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 164 insertions(+), 48 deletions(-)

diff --git a/editor/app/api/ops/_lib.ts b/editor/app/api/ops/_lib.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { authorizeWorkerRequest } from "yt-dlp-transcript-common/lib/workerToken"; import { isValidChannelSlug } from "yt-dlp-transcript-common/controller/channels"; +import { previewBranchProblem } from "yt-dlp-transcript-common/lib/pagesDeploy"; import { isValidSiteId } from "yt-dlp-transcript-common/lib/site"; import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand"; import type { QueueOutcome } from "../../channels/lib/queueForSlugs"; @@ -200,6 +201,80 @@ export function reqSiteIds(body: OpsBody): string[] { return ids; } +// A PREVIEW BRANCH, JUDGED BEFORE ANY JOB STARTS. `previewBranchProblem` is the +// same function the server action and the browser control use — this is not a +// second opinion, it is the one opinion asked earlier. Earlier matters: on +// build-deploy the action's own check happens before its build, but a `siteIds` +// fan-out would otherwise walk every site to refuse each in turn and answer +// with a joined list of the same sentence. +export function optPreviewBranch(body: OpsBody): string | undefined { + const raw = body.preview; + if (raw === undefined) return undefined; + const problem = previewBranchProblem(raw); + if (problem) throw new OpsInputError(problem); + return (raw as string).trim(); +} + +// The site fan-out the deploy routes run: start one managed job per site, +// keeping every id, and refuse with a 400 when NONE started. +// +// SHARED BECAUSE THE CAREFUL PART IS EASY TO GET WRONG TWICE. One site's throw +// cannot cost the others their job ids — an exception out of the loop becomes a +// 500 carrying no `jobs` at all, while the jobs already queued run on with +// nobody holding their ids. And asking for deploys and getting NONE is a 400 +// carrying the reason, not a cheerful `{ ok: true, jobs: [] }`: `--wait` would +// exit 0 on that and report success about a deploy that never started. +// +// `decorate` adds per-site keys to a job entry (the preview alias); they are +// repeated at the top level in the single-job case, exactly as `jobId` is, so a +// one-site caller never has to index into `jobs`. +export async function fanOutSiteJobs( + siteIds: string[], + start: (siteId: string) => Promise<StreamActionResult>, + decorate?: (siteId: string) => Record<string, unknown>, +): Promise<NextResponse> { + const jobs: Record<string, unknown>[] = []; + const extras: Record<string, unknown>[] = []; + const skipped: { siteId: string; reason: string }[] = []; + let info = false; + for (const siteId of siteIds) { + let result: StreamActionResult; + try { + result = await start(siteId); + } catch (e) { + skipped.push({ siteId, reason: (e as Error).message }); + continue; + } + if (!result.ok) { + skipped.push({ siteId, reason: result.error }); + info = info || result.info === true; + continue; + } + // The stream is cancelled, never returned — see this file's header. + void result.stream.cancel(); + const extra = decorate?.(siteId) ?? {}; + jobs.push({ siteId, jobId: result.jobId, ...extra }); + extras.push(extra); + } + if (jobs.length === 0) { + // One site asked for, one reason: the bare sentence the action gave, + // exactly as jobResponse has always returned it. + return opsFail( + skipped.length === 1 + ? skipped[0].reason + : skipped.map((s) => `${s.siteId}: ${s.reason}`).join("; "), + 400, + info ? { info: true } : undefined, + ); + } + return NextResponse.json({ + ok: true, + jobs, + skipped, + ...(jobs.length === 1 ? { jobId: jobs[0].jobId, ...extras[0] } : {}), + }); +} + export function oneOf<T extends string>( body: OpsBody, key: string, diff --git a/editor/app/api/ops/build-deploy/route.ts b/editor/app/api/ops/build-deploy/route.ts @@ -1,23 +1,27 @@ import { NextResponse } from "next/server"; +import { previewAliasUrl } from "yt-dlp-transcript-common/lib/pagesDeploy"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { getSite } from "yt-dlp-transcript-common/lib/site"; import { buildAndDeployAction, buildAndDeployAllSitesAction, } from "../../../sites/lib/buildAction"; import { + fanOutSiteJobs, jobResponse, OpsInputError, ops, - opsFail, optBool, + optPreviewBranch, reqSiteIds, } from "../_lib"; export const dynamic = "force-dynamic"; -// POST { siteId: string | siteIds: string[], skipArchives? } +// POST { siteId: string | siteIds: string[], skipArchives?, preview? } // | { all: true, skipArchives? } -// -> { ok: true, jobs: [{ siteId, jobId }], skipped: [{ siteId, reason }], -// jobId? } +// -> { ok: true, jobs: [{ siteId, jobId, previewUrl? }], +// skipped: [{ siteId, reason }], jobId?, previewUrl? } // // Build THEN deploy: one managed job per site (one log, one Cancel each), so // the caller polls /api/jobs/<jobId>/log exactly as it does for build-site. @@ -31,63 +35,52 @@ export const dynamic = "force-dynamic"; // // Asking for builds and getting NONE is still a 400 carrying the reason, not a // cheerful `{ ok: true, jobs: [] }`: --wait would exit 0 on it and report -// success about a deploy that never started. +// success about a deploy that never started. That, and the per-site error +// handling, now live in fanOutSiteJobs, shared with deploy-site. +// +// `preview` names a branch and makes the DEPLOY half a Cloudflare Pages preview +// (see deploy-site). It is refused alongside `all`: the all-sites path deploys +// every configured site through the docker phase runner, which has no per-site +// branch to thread one through, and silently building every site and shipping +// them to production would be the worst possible reading of the request. export async function POST(request: Request) { return ops( request, - ["siteId", "siteIds", "all", "skipArchives"], + ["siteId", "siteIds", "all", "skipArchives", "preview"], async (body) => { const skipArchives = optBool(body, "skipArchives"); + const preview = optPreviewBranch(body); if (optBool(body, "all")) { if (body.siteId !== undefined || body.siteIds !== undefined) { throw new OpsInputError( 'send either "siteId"/"siteIds" or "all", not both', ); } - return jobResponse(await buildAndDeployAllSitesAction(skipArchives)); - } - const jobs: { siteId: string; jobId: string }[] = []; - const skipped: { siteId: string; reason: string }[] = []; - let info = false; - for (const siteId of reqSiteIds(body)) { - // ONE SITE'S THROW CANNOT COST THE OTHERS THEIR JOB IDS. An exception - // out of here becomes a 500 carrying no `jobs` at all, while the builds - // already queued run on with nobody holding their ids. reqSiteIds - // rejects the malformed-id case before any job starts; this catches - // whatever else the action can raise. - let result: Awaited<ReturnType<typeof buildAndDeployAction>>; - try { - result = await buildAndDeployAction(siteId, skipArchives); - } catch (e) { - skipped.push({ siteId, reason: (e as Error).message }); - continue; - } - if (!result.ok) { - skipped.push({ siteId, reason: result.error }); - info = info || result.info === true; - continue; + if (preview !== undefined) { + throw new OpsInputError( + '"preview" is not supported with "all" — name the sites to preview with "siteIds"', + ); } - // The stream is cancelled, never returned — see _lib's header. - void result.stream.cancel(); - jobs.push({ siteId, jobId: result.jobId }); - } - if (jobs.length === 0) { - // One site asked for, one reason: the bare sentence the action gave, - // exactly as jobResponse has always returned it. - return opsFail( - skipped.length === 1 - ? skipped[0].reason - : skipped.map((s) => `${s.siteId}: ${s.reason}`).join("; "), - 400, - info ? { info: true } : undefined, - ); + return jobResponse(await buildAndDeployAllSitesAction(skipArchives)); } - return NextResponse.json({ - ok: true, - jobs, - skipped, - ...(jobs.length === 1 ? { jobId: jobs[0].jobId } : {}), - }); + const siteIds = reqSiteIds(body); + return fanOutSiteJobs( + siteIds, + (siteId) => + buildAndDeployAction( + siteId, + skipArchives, + preview ? { previewBranch: preview } : undefined, + ), + preview + ? (siteId) => { + // Only reached for a site whose job STARTED, which the action + // does only once it has a cloudflareProject. + const project = getSite(siteId, getPaths()).cloudflareProject; + return project ? { previewUrl: previewAliasUrl(project, preview) } : {}; + } + : undefined, + ); }, ); } diff --git a/editor/app/api/ops/deploy-site/route.ts b/editor/app/api/ops/deploy-site/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server"; +import { previewAliasUrl } from "yt-dlp-transcript-common/lib/pagesDeploy"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { getSite } from "yt-dlp-transcript-common/lib/site"; +import { deployExportAction } from "../../../sites/lib/deployAction"; +import { fanOutSiteJobs, ops, optPreviewBranch, reqSiteIds } from "../_lib"; + +export const dynamic = "force-dynamic"; + +// POST { siteId: string | siteIds: string[], preview? } +// -> { ok: true, jobs: [{ siteId, jobId, previewUrl? }], +// skipped: [{ siteId, reason }], jobId?, previewUrl? } +// +// DEPLOY ONLY — the already-built export/out, no build. build-deploy's other +// half, and the half a preview is actually for: build once, look at the preview, +// then roll the SAME bundle to production without rebuilding it. +// +// `preview` is a branch name. Cloudflare Pages treats a deploy to any branch but +// the project's production branch as a preview, reachable at the branch alias — +// which is why `previewUrl` can be in the response at all, before the job has +// done anything: the alias is a function of the project and the branch, not of +// the deployment. The immutable per-deployment URL only exists afterwards and is +// in the job's log. +// +// Response shape is build-deploy's, so a runbook can swap one for the other. +export async function POST(request: Request) { + return ops(request, ["siteId", "siteIds", "preview"], async (body) => { + const preview = optPreviewBranch(body); + const siteIds = reqSiteIds(body); + return fanOutSiteJobs( + siteIds, + (siteId) => deployExportAction(siteId, preview ? { previewBranch: preview } : undefined), + preview + ? (siteId) => { + // Only reached for a site whose job STARTED, which the action does + // only once it has a cloudflareProject — so this read cannot be the + // thing that fails. + const project = getSite(siteId, getPaths()).cloudflareProject; + return project ? { previewUrl: previewAliasUrl(project, preview) } : {}; + } + : undefined, + ); + }); +} + +export function GET() { + return NextResponse.json({ ok: false, error: "POST only" }, { status: 405 }); +}