commit 01934bcf79a1a826d58ea95b727576aa60644719
parent cd6ec28eae72d7ad2eedc92406f55c4421753eb1
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 2 Jul 2026 01:57:51 -0400
Phase 6a: PWA opt-in — dumb-by-default site instances
Make an installable PWA (service worker + web manifest + installability) an
opt-in axis independent of the shell, per the federation plan:
- New export/app/lib/mode.ts: instanceMode() ("hub" iff INSTANCE_MODE=hub, else
"site") and shipsPwa() (true in hub mode; in site mode reads the selected
site's `pwa` config flag — default false).
- layout.tsx gates <ServiceWorkerRegister/> and the manifest metadata link on
shipsPwa(). A dumb instance renders neither.
- Relocate the SW source out of the auto-copied public/ dir
(export/service-worker/site-sw.js) and have compose-site emit public/sw.js
ONLY when the site ships a PWA — so a dumb instance emits no /sw.js at all,
not just an unregistered one. public/sw.js is now generated (gitignored),
mirroring site.json/_headers.
- compose-site's shipsPwa() resolves from site.pwa (|| INSTANCE_MODE=hub),
matching mode.ts, so the descriptor's pwa field and the app shell agree.
BEHAVIOR CHANGE (approved): existing sites without `pwa: true` are no longer
installable; they stay fully federatable (CORS JSON + /site.json) but ship no
service worker. Set pwa: true in a site's config to keep it installable. The
federation contract is emitted regardless of pwa.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat:
5 files changed, 60 insertions(+), 8 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -58,6 +58,7 @@ yarn-error.log*
/export/public/duplicates.json
/export/public/site.json
/export/public/_headers
+/export/public/sw.js
/export/.export-index/
/transcripts/index.mdb/
diff --git a/common/bin/compose-site.ts b/common/bin/compose-site.ts
@@ -57,7 +57,7 @@ async function emitFederationFiles(
if (!(await exists(manifestPath))) return; // no composed data → no descriptor
const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as Manifest;
const descriptor = buildSiteDescriptor(site, manifest, resolveSocialLinks(site), {
- pwa: shipsPwa(),
+ pwa: shipsPwa(site),
hubUrl: resolveHubUrl(site),
});
await writeFile(
@@ -66,10 +66,26 @@ async function emitFederationFiles(
);
}
-// Whether this build ships an installable PWA. Site builds default to a dumb
-// instance unless SHIP_PWA is set (see export/app/lib/mode.ts shipsPwa()).
-function shipsPwa(): boolean {
- return process.env.SHIP_PWA === "1" || process.env.INSTANCE_MODE === "hub";
+// Whether this build ships an installable PWA. Resolved from the site's `pwa`
+// config flag (site builds are dumb instances by default) or forced on in hub
+// mode. Keep in sync with export/app/lib/mode.ts shipsPwa().
+function shipsPwa(site: Site): boolean {
+ return site.pwa === true || process.env.INSTANCE_MODE === "hub";
+}
+
+// Compose the service worker into the served public dir ONLY when this instance
+// ships a PWA. The SW source lives outside public/ (export/service-worker/) so
+// a dumb instance emits no /sw.js at all — not just an unregistered one. Mirrors
+// how site.json/_headers are generated rather than checked in.
+async function composeServiceWorker(
+ site: Site,
+ paths: ReturnType<typeof getPaths>,
+): Promise<void> {
+ const dest = path.join(paths.exportPublicDir, "sw.js");
+ await rm(dest, { force: true });
+ if (!shipsPwa(site)) return;
+ const src = path.join(paths.monorepoRoot, "export", "service-worker", "site-sw.js");
+ if (await exists(src)) await cp(src, dest);
}
async function exists(p: string): Promise<boolean> {
@@ -182,6 +198,9 @@ async function main(): Promise<void> {
// --- federation contract: /site.json descriptor + CORS _headers ---
await emitFederationFiles(site, paths);
+ // --- service worker (only when this instance ships a PWA) ---
+ await composeServiceWorker(site, paths);
+
const channelDirs = (await readdir(paths.exportTranscriptsDir).catch(
() => [] as string[],
)).length;
diff --git a/export/app/layout.tsx b/export/app/layout.tsx
@@ -5,6 +5,7 @@ import { ThemeProvider } from "yt-dlp-transcript-common/components/ThemeProvider
import { QueryProvider } from "yt-dlp-transcript-common/components/QueryProvider";
import { parseAccent, siteAccentVars } from "yt-dlp-transcript-common/lib/accent";
import { currentSite } from "./lib/site";
+import { shipsPwa } from "./lib/mode";
import Header from "./components/Header";
import Footer from "./components/Footer";
import { ServiceWorkerRegister } from "./components/ServiceWorkerRegister";
@@ -23,8 +24,10 @@ export function generateMetadata(): Metadata {
template: `%s — ${s.siteTitle}`,
},
description: s.siteDescription,
- // Installable PWA: manifest + icon set + iOS standalone hints.
- manifest: "/manifest.webmanifest",
+ // Installability is opt-in: only a PWA-shipping instance links the manifest
+ // (and registers the SW below). A dumb instance omits both — it stays
+ // federatable JSON, but not independently installable.
+ ...(shipsPwa() ? { manifest: "/manifest.webmanifest" } : {}),
icons: {
icon: [
{ url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
@@ -62,7 +65,7 @@ export default async function RootLayout({
>
<body className="min-h-full flex flex-col bg-background text-foreground font-sans">
<ThemeScript defaultTheme="base" defaultMode="system" />
- <ServiceWorkerRegister />
+ {shipsPwa() && <ServiceWorkerRegister />}
<ThemeProvider defaultTheme="base" defaultMode="system">
<QueryProvider>
<Header />
diff --git a/export/app/lib/mode.ts b/export/app/lib/mode.ts
@@ -0,0 +1,29 @@
+import { currentSite } from "./site";
+
+// The export app renders one of two shells, selected at build time:
+// site (default): a single-site archive, exactly as before.
+// hub: the federated Archilyzer hub, reading many sites at runtime.
+//
+// The single-site path is never touched unless INSTANCE_MODE is explicitly
+// "hub", so a plain `next build` stays a site build.
+export type InstanceMode = "site" | "hub";
+
+export function instanceMode(): InstanceMode {
+ return process.env.INSTANCE_MODE === "hub" ? "hub" : "site";
+}
+
+// Whether this build ships an installable PWA — the "dangerous permissions"
+// surface: a service worker, a web manifest, and installability. This is an
+// axis INDEPENDENT of the shell:
+// - hub mode always ships the PWA (Archilyzer IS the installable app);
+// - site mode is a dumb instance by default (federatable JSON only, not
+// installable) and opts in per site via the `pwa` config flag.
+//
+// Resolved purely from config that is already available at build/render time
+// (INSTANCE_MODE env + the selected site's `pwa`), so the app shell and the
+// compose step agree without threading an extra env var. Keep this in sync with
+// compose-site.ts's shipsPwa().
+export function shipsPwa(): boolean {
+ if (instanceMode() === "hub") return true;
+ return currentSite().pwa === true;
+}
diff --git a/export/public/sw.js b/export/service-worker/site-sw.js