import path from "node:path"; import type { NextConfig } from "next"; // A ?site= bookmark to one of the retired Sites satellites, matched at the // config level so it lands on that site's tab. The value is SITE_ID_RE; Next // anchors it, so ?site=__all__ misses and falls through to the bare rule. const siteQuery = { type: "query", key: "site", value: "(?[a-z0-9][a-z0-9-]*)", } as const; const nextConfig: NextConfig = { // The e2e suite's start mode builds into its own directory (scripts/ // e2e-stamp.mjs), so a rebuild for a test run never replaces the build a // running editor serves from `.next`. Unset everywhere else. distDir: process.env.E2E_NEXT_DIST_DIR || ".next", turbopack: { // Pin the workspace root. Turbopack infers it by walking up for a lockfile // and taking the outermost one, so an unrelated pnpm-lock.yaml anywhere // above the checkout (a stray one in $HOME is enough) silently relocates // the root — after which `@import "../../common/styles/tokens.css"` in // app/globals.css resolves outside the project and `next dev` fails to // boot at all. Nothing in this repo lives above the monorepo root, so // pinning it costs nothing. root: path.join(__dirname, ".."), // Suppress the harmless "whole project was traced unintentionally" NFT // warning. Turbopack's file tracer conservatively globs the cwd because our // server code does legitimate runtime-dynamic fs reads it can't statically // bound (e.g. readdir(videoDir), reading per-job logs, settings.json). We // don't use `output: 'standalone'`, so the .nft.json traces are never // consumed at runtime and the over-tracing is purely cosmetic. Scoped to // this exact issue (path + title) so other Turbopack warnings still surface. ignoreIssue: [ { path: "**/next.config.ts", title: "Encountered unexpected file in NFT list", }, ], }, transpilePackages: ["yt-dlp-transcript-common"], serverExternalPackages: ["lmdb", "msgpackr", "msgpackr-extract"], experimental: { serverActions: { bodySizeLimit: "10mb", }, // Client router cache lifetimes, in SECONDS. `dynamic` defaults to 0 (as of // Next 15 — it used to be 30), which means every back-and-forth between two // sidebar links is a fresh server round trip even when you were just there. // // This, plus Next's automatic link prefetching, is what actually makes // sidebar navigation instant here — measured: with the destination warm the // click commits from the router cache with no pending state at all. (There // are deliberately no loading.tsx skeletons; they never showed on a client // navigation AND they broke 404 status codes. See e2e/navigation.spec.ts.) // // DO NOT RAISE IT: /jobs is a live operational view — its head is polled, // its tail rides the pulse — and a stale queue is worse than a slow one. staleTimes: { dynamic: 15, static: 180 }, }, // Every retired route redirects rather than 404s — the nav rule this repo // shares with umtool (see editor/app/lib/nav.ts). /auto-queue was the console // for two runner lanes and grew into a board over six operations; it is // /operations now, one page per operation. // // /actionable was FOUR answers to "what needs doing" on one page; its // sections live with the operation that runs them, with /channels, with // /cleanup and with /review now, and the board is where a bookmark lands. // // /scheduler was the sync operation's page before sync was catalogued; // /api/scheduler/* is NOT redirected — the cron client and the console's own // poll never moved. // // /jobs/active and /jobs/queue were the live third and the scheduler's third // of one list; /jobs is that list now — the live head is polled, the history // tail is paged. /api/jobs/active is NOT redirected: the dashboard, the widget // and the page itself poll it. // // /charts, /aliases, /deploy, /build and /homepage were the Sites group's // five satellites. A site's charts, search aliases and publish controls are // its TABS now (/sites//{charts,aliases,publish}); what applies to the // whole family — the release notes, the batch build, the hub, the pool — // is on /sites itself. The `has` rules carry a ?site= bookmark to the // site's tab: the value regex is SITE_ID_RE, anchored by Next, so // ?site=__all__ (underscores) misses it and lands on the family page. The // matched query is NOT stripped — /charts?site=a lands on // /sites/a/charts?site=a — which the tab ignores, and the picker too: on a // site's own pages the path is the selection (app/lib/activeSite.ts). // Order matters: first match wins, so each `has` rule precedes its bare one. // // 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. // // 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 }, { source: "/actionable", destination: "/operations", permanent: false }, { source: "/scheduler", destination: "/operations/sync", permanent: false }, { source: "/jobs/active", destination: "/jobs", permanent: false }, { source: "/jobs/queue", destination: "/jobs", permanent: false }, { source: "/charts", has: [siteQuery], destination: "/sites/:site/charts", permanent: false }, { source: "/charts", destination: "/sites", permanent: false }, { source: "/aliases", has: [siteQuery], destination: "/sites/:site/aliases", permanent: false }, { source: "/aliases", destination: "/sites", permanent: false }, { source: "/deploy", has: [siteQuery], destination: "/sites/:site/publish", permanent: false }, { source: "/deploy", destination: "/sites", permanent: false }, { source: "/build", destination: "/sites", permanent: false }, { source: "/homepage", destination: "/sites", permanent: false }, ]; }, // 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= // 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*" }, ]; }, }; export default nextConfig;