From e886890d6e1ae16df439c0403df4bec91e5ca00a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 5 Oct 2026 20:51:39 +0200 Subject: [PATCH] perf(watch): a shared link loads the player, not the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A /watch link is opened by whoever it was sent to — signed out, often on a phone on mobile data — and it booted through main.tsx: every app provider, the app's i18n (every locale of every area), the flag sprites, ~392 KB gzip before the player could start. It now has its own HTML entry, watch.html → watchMain.tsx, which imports WatchPage and what it renders; the server hands every /watch/ path that page (the OG block injected as before), and vite dev does the same. Measured on the build: /watch JS 290 → 87 KB gzip, CSS 104 → 19 KB; the app's own bytes unchanged. Two leaks had to go for that: lib/format imported the app's i18n for one "just now" string (the relative-time formatters now take `t`, like the file's other t-formatters), and the boundary's crash copy came from the same i18n (CrashBoundary takes its copy; ErrorBoundary is the app's wrapper, the watch entry gives it the page's own strings). The page's popover tree went too: it renders no popover. Held by: a contract test walking the entry's runtime imports, naming the path to anything only the app may load (red on a planted i18n import in lib/format), and holding watch.html's head equal to index.html's; a DB-lane test that every /watch/ path gets the player's page with the link's OG tags and app paths get the app (red on the old routing). Screenshots at 375 px, EN and HU, video / audio / password / gone / wrong password / unlocked: identical to the old entry's, bar the video's buffered bar. --- CLAUDE.md | 3 +- backend/app/downloads/og.py | 9 +- backend/app/main.py | 16 ++- backend/tests/test_spa_entries.py | 67 +++++++++++ .../src/components/AdminUsers.dom.test.tsx | 2 +- frontend/src/components/AdminUsers.tsx | 2 +- frontend/src/components/CrashBoundary.tsx | 62 ++++++++++ frontend/src/components/ErrorBoundary.tsx | 79 ++++--------- frontend/src/components/Home.tsx | 2 +- frontend/src/components/PlayerModal.tsx | 4 +- frontend/src/components/Scheduler.tsx | 2 +- frontend/src/components/VideoCard.tsx | 2 +- frontend/src/components/WatchPage.tsx | 73 +----------- frontend/src/components/downloadRow.tsx | 2 +- .../components/ui/RelativeTime.dom.test.tsx | 4 +- frontend/src/components/ui/RelativeTime.tsx | 5 +- frontend/src/components/watchStrings.ts | 78 +++++++++++++ frontend/src/lib/format.test.ts | 27 ++--- frontend/src/lib/format.ts | 13 ++- frontend/src/main.tsx | 26 ++--- frontend/src/watchEntry.contract.test.ts | 109 ++++++++++++++++++ frontend/src/watchMain.tsx | 26 +++++ frontend/vite.config.ts | 19 ++- frontend/watch.html | 35 ++++++ 24 files changed, 483 insertions(+), 184 deletions(-) create mode 100644 backend/tests/test_spa_entries.py create mode 100644 frontend/src/components/CrashBoundary.tsx create mode 100644 frontend/src/components/watchStrings.ts create mode 100644 frontend/src/watchEntry.contract.test.ts create mode 100644 frontend/src/watchMain.tsx create mode 100644 frontend/watch.html diff --git a/CLAUDE.md b/CLAUDE.md index 87ef68f4..b5d30889 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,7 +65,8 @@ how it gets built. Concretely: `downloads/`, `youtube/`, `issues/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`, `sysconfig`, `state`, `audit`, `notifications`, `userscope`, `ratelimit`, `bodylimit`, `progress`, `collation`, `security`, `logs`, `logsink`, `diagnostics`, `debugsession`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.** -Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → module +Frontend: `main.tsx` provider tree → `App.tsx` shell (a shared `/watch` link boots from its own +entry, `watch.html` → `watchMain.tsx`, held app-free by a contract test) → `moduleRegistry` route-chunks → module components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That boundary is one-way and **lint-enforced**, not just described: a platform component may not import a diff --git a/backend/app/downloads/og.py b/backend/app/downloads/og.py index 29d1e9aa..2bc7bf47 100644 --- a/backend/app/downloads/og.py +++ b/backend/app/downloads/og.py @@ -147,13 +147,14 @@ def _watch_tags(token: str, db: Session) -> str | None: return "\n ".join(tags) -def render_watch_html(token: str, index_html: Path, db: Session) -> str | None: - """Return index.html with the OG block replaced for this watch token, or None to serve the - generic page unchanged (invalid/expired/not-ready link, or a template without the markers).""" +def render_watch_html(token: str, page_html: Path, db: Session) -> str | None: + """Return the watch page (watch.html) with the OG block replaced for this token, or None to + serve the generic page unchanged (invalid/expired/not-ready link, or a template without the + markers).""" tags = _watch_tags(token, db) if tags is None: return None - text = index_html.read_text(encoding="utf-8") + text = page_html.read_text(encoding="utf-8") if "" not in text: return None # A function replacement avoids re.sub interpreting backslashes/group refs in `tags`. diff --git a/backend/app/main.py b/backend/app/main.py index 14a2dafa..e06746b9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -339,6 +339,8 @@ STATIC_DIR = Path(__file__).parent / "static_spa" # stay cached but forces revalidation (cheap 304 via the FileResponse ETag) on every load. The # hashed bundles under /assets can cache forever (a new build changes their filename). INDEX_HTML = STATIC_DIR / "index.html" +# A public share link's own page: the player alone, without the app's bundle (frontend/watch.html). +WATCH_HTML = STATIC_DIR / "watch.html" INDEX_HEADERS = {"Cache-Control": "no-cache"} app.mount( "/assets", @@ -354,16 +356,16 @@ async def index() -> FileResponse: @app.get("/watch/{token}") async def watch_page(token: str): - """Serve the SPA for a public share link, but with per-video Open Graph tags injected so the + """Serve the public player page for a share link, with per-video Open Graph tags injected so the link unfurls richly (title/channel/thumbnail) in Messenger/social crawlers. A real browser - ignores the extra tags and hydrates WatchPage as usual; invalid/expired/password links fall - back to the generic card.""" + ignores the extra tags and boots WatchPage; invalid/expired/password links fall back to the + generic card.""" from app.downloads import og with SessionLocal() as db: - rendered = og.render_watch_html(token, INDEX_HTML, db) + rendered = og.render_watch_html(token, WATCH_HTML, db) if rendered is None: - return FileResponse(INDEX_HTML, headers=INDEX_HEADERS) + return FileResponse(WATCH_HTML, headers=INDEX_HEADERS) return HTMLResponse(rendered, headers=INDEX_HEADERS) @@ -381,4 +383,8 @@ async def spa_fallback(full_path: str) -> FileResponse: # Real SPA-root assets (welcome images, favicon, robots.txt) rarely change and have # stable names, so a moderate cache is safe and satisfies the cache-lifetime audit. return FileResponse(candidate, headers={"Cache-Control": "public, max-age=604800"}) + # A /watch/ path the route above does not match (a trailing segment, no token) is still the + # player's: WatchPage reads the token off the path and says when a link is gone. + if full_path.startswith("watch/"): + return FileResponse(WATCH_HTML, headers=INDEX_HEADERS) return FileResponse(INDEX_HTML, headers=INDEX_HEADERS) diff --git a/backend/tests/test_spa_entries.py b/backend/tests/test_spa_entries.py new file mode 100644 index 00000000..7fab4d24 --- /dev/null +++ b/backend/tests/test_spa_entries.py @@ -0,0 +1,67 @@ +"""Which HTML page the server hands a path (main.py), DB-lane. + +The frontend builds two entries: the app (index.html) and a shared link's player alone (watch.html), so +the friend a /watch link was sent to never downloads the app. Every /watch/ path must get the player's +page — with the link's Open Graph tags when the token names a playable link — and nothing else may. +""" + +import pytest + +from app import main +from app.config import settings +from app.downloads import probe as probemod + +_PAGE = """ + +""" + + +@pytest.fixture +def pages(tmp_path, monkeypatch): + index, watch = tmp_path / "index.html", tmp_path / "watch.html" + index.write_text(_PAGE.format(entry="app"), encoding="utf-8") + watch.write_text(_PAGE.format(entry="watch"), encoding="utf-8") + monkeypatch.setattr(main, "INDEX_HTML", index) + monkeypatch.setattr(main, "WATCH_HTML", watch) + + +@pytest.fixture +def link_token(as_user, make_user, tmp_path, monkeypatch, upload_headers): + monkeypatch.setattr(settings, "download_root", str(tmp_path / "media")) + video = {"codec_type": "video", "codec_name": "h264", "pix_fmt": "yuv420p"} + audio = {"codec_type": "audio", "codec_name": "aac"} + data = {"streams": [video, audio], "format": {"format_name": "mov,mp4", "duration": "5"}} + monkeypatch.setattr(probemod, "probe_json", lambda _p: data) + owner = as_user(make_user()) + job = owner.post( + "/api/downloads/upload", + content=b"fake-video", + headers=upload_headers("clip.mp4", display_name="Holiday clip"), + ) + assert job.status_code == 200, job.text + link = owner.post(f"/api/downloads/{job.json()['id']}/links", json={}) + assert link.status_code == 200, link.text + return link.json()["token"] + + +@pytest.mark.parametrize("path", ["/", "/feed", "/downloads/42"]) +def test_an_app_path_gets_the_app(pages, client, path): + r = client.get(path) + assert r.status_code == 200 + assert "/assets/app.js" in r.text + + +@pytest.mark.parametrize("path", ["/watch/no-such-token", "/watch/no-such-token/extra"]) +def test_every_watch_path_gets_the_player_alone(pages, client, path): + r = client.get(path) + assert r.status_code == 200 + assert "/assets/watch.js" in r.text and "/assets/app.js" not in r.text + assert r.headers["cache-control"] == "no-cache" + + +def test_a_live_link_unfurls_from_the_players_page(pages, client, link_token): + r = client.get(f"/watch/{link_token}") + assert r.status_code == 200 + assert "/assets/watch.js" in r.text + assert 'content="Holiday clip"' in r.text + assert '' not in r.text # replaced, not appended diff --git a/frontend/src/components/AdminUsers.dom.test.tsx b/frontend/src/components/AdminUsers.dom.test.tsx index ef024bcf..7dc77a69 100644 --- a/frontend/src/components/AdminUsers.dom.test.tsx +++ b/frontend/src/components/AdminUsers.dom.test.tsx @@ -91,7 +91,7 @@ describe("the last sign-in column", () => { , ); - expect(await screen.findByText(relativeTime(at, "en"))).toBeTruthy(); + expect(await screen.findByText(relativeTime(at, "en", i18n.t))).toBeTruthy(); expect(screen.getAllByText("—")).toHaveLength(1); }); }); diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index da8953c7..5f8d3689 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -316,7 +316,7 @@ function UsersRoles({ me }: { me: Me }) { } > - {u.last_login_at ? relativeTime(u.last_login_at, lang) : "—"} + {u.last_login_at ? relativeTime(u.last_login_at, lang, t) : "—"} CrashCopy; + /** What the entry does beyond the log line (the app keeps a fatal notice in its center). */ + onCrash?: () => void; +} +interface State { + crashed: boolean; +} + +// Catches render-time crashes anywhere in the tree, logs them and offers a reload. Free of the app's +// i18n and notification store, so the public /watch entry wraps its page in it without loading +// either; `ErrorBoundary` is the app's wrapper around it. +export class CrashBoundary extends Component { + state: State = { crashed: false }; + + static getDerivedStateFromError(): State { + return { crashed: true }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + // The tab is already reloading onto a newer build (lib/staleBuild): this is the chunk it could not + // load, not a crash — no fatal notice, which would outlive the reload. + if (reloadingForNewBuild()) return; + log.fatal("ui.crash", "the app crashed while rendering", { + ...errorAttrs(error), + ...(info.componentStack ? { component_stack: info.componentStack } : {}), + }); + this.props.onCrash?.(); + } + + render() { + if (this.state.crashed) { + if (reloadingForNewBuild()) return null; + const copy = this.props.copy(); + return ( +
+
+
{copy.title}
+
{copy.subtitle}
+ +
+
+ ); + } + return this.props.children; + } +} diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index ce94e769..727e808d 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -1,61 +1,30 @@ -import { Component, type ErrorInfo, type ReactNode } from "react"; +import type { ReactNode } from "react"; import i18n from "../i18n"; import { notify } from "../lib/notifications"; -import { errorAttrs, log } from "../lib/log"; -import { reloadingForNewBuild } from "../lib/staleBuild"; -import { Button } from "./ui/Button"; +import { CrashBoundary, type CrashCopy } from "./CrashBoundary"; -interface Props { - children: ReactNode; -} -interface State { - crashed: boolean; -} +const copy = (): CrashCopy => ({ + title: i18n.t("errors.boundary.title"), + subtitle: i18n.t("errors.boundary.subtitle"), + reload: i18n.t("errors.boundary.reload"), +}); -// Catches render-time crashes anywhere in the tree, logs a fatal notification (kept -// in the center across the reload) and offers a recovery action. -export default class ErrorBoundary extends Component { - state: State = { crashed: false }; +// A fatal notification, kept in the center across the reload. +const onCrash = () => + notify({ + level: "fatal", + title: i18n.t("errors.boundary.notifTitle"), + // Never the raw `error.message`: a JS engine's English ("Cannot read properties of undefined…") + // is no message for the reader — the `ui.crash` log carries it for diagnosis. + message: i18n.t("errors.boundary.notifMessage"), + requiresInteraction: true, + }); - static getDerivedStateFromError(): State { - return { crashed: true }; - } - - componentDidCatch(error: Error, info: ErrorInfo) { - // The tab is already reloading onto a newer build (lib/staleBuild): this is the chunk it could not - // load, not a crash — no fatal notice, which would outlive the reload. - if (reloadingForNewBuild()) return; - log.fatal("ui.crash", "the app crashed while rendering", { - ...errorAttrs(error), - ...(info.componentStack ? { component_stack: info.componentStack } : {}), - }); - notify({ - level: "fatal", - title: i18n.t("errors.boundary.notifTitle"), - // Never the raw `error.message`: a JS engine's English ("Cannot read properties of undefined…") - // is no message for the reader — the `ui.crash` log above carries it for diagnosis. - message: i18n.t("errors.boundary.notifMessage"), - requiresInteraction: true, - }); - } - - render() { - if (this.state.crashed) { - if (reloadingForNewBuild()) return null; - return ( -
-
-
- {i18n.t("errors.boundary.title")} -
-
{i18n.t("errors.boundary.subtitle")}
- -
-
- ); - } - return this.props.children; - } +// The app's crash boundary: `CrashBoundary` in the app's language, plus the notice in the center. +export default function ErrorBoundary({ children }: { children: ReactNode }) { + return ( + + {children} + + ); } diff --git a/frontend/src/components/Home.tsx b/frontend/src/components/Home.tsx index 3d68b850..cd73a7ba 100644 --- a/frontend/src/components/Home.tsx +++ b/frontend/src/components/Home.tsx @@ -546,7 +546,7 @@ function Hero({ ? // The channel is the subtitle already. [ video.view_count != null && `${formatCompact(video.view_count, lang)} ${t("card.views")}`, - relativeTime(video.published_at, lang), + relativeTime(video.published_at, lang, t), ] : []; const factLine = facts.filter(Boolean).join(" · "); diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 3736cbf1..e6506986 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -977,7 +977,7 @@ export default function PlayerModal({ )} {metaSep} - {relativeTime(active.published_at, lang)} + {relativeTime(active.published_at, lang, t)} {active.published_at && ` · ${fullDate(active.published_at)}`} {active.duration_seconds != null && ( @@ -1009,7 +1009,7 @@ export default function PlayerModal({ {detail.data?.published_at && ( {metaSep} - {relativeTime(detail.data.published_at, lang)} ·{" "} + {relativeTime(detail.data.published_at, lang, t)} ·{" "} {fullDate(detail.data.published_at)} )} diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index 0d8a1967..29075f6b 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -254,7 +254,7 @@ function JobRow({ {job.running ? t("scheduler.runningNow") : job.last_finished - ? t("scheduler.lastRun", { time: relativeTime(job.last_finished, lang) }) + ? t("scheduler.lastRun", { time: relativeTime(job.last_finished, lang, t) }) : t("scheduler.neverRun")} {job.last_error ? ( diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index d8f624e1..5aa4a335 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -420,7 +420,7 @@ function VideoCard({ ); const published = ( <> - {relativeTime(video.published_at, lang)} + {relativeTime(video.published_at, lang, t)} {video.published_at && ( <> {" · "} diff --git a/frontend/src/components/WatchPage.tsx b/frontend/src/components/WatchPage.tsx index 40da9bf0..355cb66d 100644 --- a/frontend/src/components/WatchPage.tsx +++ b/frontend/src/components/WatchPage.tsx @@ -9,10 +9,11 @@ import { send } from "../lib/http"; import { Input } from "./ui/Input"; import { SURFACE } from "./ui/surfaces"; import { LinkButton } from "./ui/LinkButton"; +import { T, lang } from "./watchStrings"; -// Standalone, login-free video page for a shared /watch/ link. Rendered OUTSIDE the app -// tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny -// self-contained i18n dict (the public viewer has no app language preference). +// Standalone, login-free video page for a shared /watch/ link. Its own entry (watchMain.tsx, +// watch.html): no auth, no providers, none of the app's bundle. Uses plain fetch and a tiny +// self-contained i18n dict (`watchStrings`: the public viewer has no app language preference). // Advertise the playing video to the OS via the Media Session API: lock-screen / control-center // metadata + play/pause handlers. This is also what lets a browser's own "background playback" @@ -75,72 +76,6 @@ function displayUrl(url: string): string { } } -const STR = { - en: { - loading: "Loading…", - gone: "This link is no longer available.", - goneHint: "It may have expired or been revoked by the owner.", - passwordTitle: "This video is password-protected", - passwordPlaceholder: "Enter password", - watch: "Watch", - wrong: "Wrong password.", - download: "Download", - file: "File", - links: "Links", - brand: "Shared via Siftlode", - repeat: "Repeat", - failed: "Couldn't load this link.", - failedHint: "Check your connection and try again.", - retry: "Try again", - tooMany: "Too many attempts. Try again in a few minutes.", - unlockFailed: "Couldn't check the password. Try again.", - }, - hu: { - loading: "Betöltés…", - gone: "Ez a link már nem elérhető.", - goneHint: "Lehet, hogy lejárt, vagy a tulajdonos visszavonta.", - passwordTitle: "Ez a videó jelszóval védett", - passwordPlaceholder: "Add meg a jelszót", - watch: "Megnézem", - wrong: "Hibás jelszó.", - download: "Letöltés", - file: "Fájl", - links: "Linkek", - brand: "Megosztva a Siftlode-dal", - repeat: "Ismétlés", - failed: "Nem sikerült betölteni a linket.", - failedHint: "Ellenőrizd a kapcsolatot, és próbáld újra.", - retry: "Újrapróbálás", - tooMany: "Túl sok próbálkozás. Próbáld újra pár perc múlva.", - unlockFailed: "Nem sikerült ellenőrizni a jelszót. Próbáld újra.", - }, - de: { - loading: "Wird geladen…", - gone: "Dieser Link ist nicht mehr verfügbar.", - goneHint: "Er ist möglicherweise abgelaufen oder wurde widerrufen.", - passwordTitle: "Dieses Video ist passwortgeschützt", - passwordPlaceholder: "Passwort eingeben", - watch: "Ansehen", - wrong: "Falsches Passwort.", - download: "Herunterladen", - file: "Datei", - links: "Links", - brand: "Geteilt über Siftlode", - repeat: "Wiederholen", - failed: "Der Link konnte nicht geladen werden.", - failedHint: "Prüfe deine Verbindung und versuche es erneut.", - retry: "Erneut versuchen", - tooMany: "Zu viele Versuche. Versuche es in ein paar Minuten erneut.", - unlockFailed: "Das Passwort konnte nicht geprüft werden. Versuche es erneut.", - }, -} as const; - -const lang = - ((navigator.language || "en").slice(0, 2) as keyof typeof STR) in STR - ? ((navigator.language || "en").slice(0, 2) as keyof typeof STR) - : "en"; -const T = STR[lang]; - export default function WatchPage() { const token = window.location.pathname.split("/watch/")[1]?.split(/[/?#]/)[0] ?? ""; // Seeded from the URL rather than corrected by the effect below: with no token there is nothing diff --git a/frontend/src/components/downloadRow.tsx b/frontend/src/components/downloadRow.tsx index 45096d94..9809a525 100644 --- a/frontend/src/components/downloadRow.tsx +++ b/frontend/src/components/downloadRow.tsx @@ -304,7 +304,7 @@ export function DownloadRow({ {" · "} {" "} - {relativeTime(job.created_at, lang)} + {relativeTime(job.created_at, lang, t)} ) : null} {job.status === "error" ? ( diff --git a/frontend/src/components/ui/RelativeTime.dom.test.tsx b/frontend/src/components/ui/RelativeTime.dom.test.tsx index ff8ff74c..33b6d3eb 100644 --- a/frontend/src/components/ui/RelativeTime.dom.test.tsx +++ b/frontend/src/components/ui/RelativeTime.dom.test.tsx @@ -4,7 +4,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { cleanup, render } from "@testing-library/react"; import { formatDateTime, formatDateTimeSec, relativeTime } from "../../lib/format"; -import "../../i18n"; // the real instance, in English: the component names time in the UI language +import i18n from "../../i18n"; // the real instance, in English: the component names time in the UI language import { RelativeTime } from "./RelativeTime"; afterEach(cleanup); @@ -15,7 +15,7 @@ describe("RelativeTime", () => { it("says how long ago, and names the exact minute on hover", () => { const { container } = render(); const el = container.querySelector("time")!; - expect(el.textContent).toBe(relativeTime(THREE_DAYS_AGO, "en")); + expect(el.textContent).toBe(relativeTime(THREE_DAYS_AGO, "en", i18n.t)); expect(el.getAttribute("title")).toBe(formatDateTime(THREE_DAYS_AGO, "en")); expect(el.getAttribute("dateTime")).toBe(THREE_DAYS_AGO); }); diff --git a/frontend/src/components/ui/RelativeTime.tsx b/frontend/src/components/ui/RelativeTime.tsx index 2a01b70d..58586a79 100644 --- a/frontend/src/components/ui/RelativeTime.tsx +++ b/frontend/src/components/ui/RelativeTime.tsx @@ -17,9 +17,10 @@ export function RelativeTime({ empty?: string; className?: string; }) { - const lang = useTranslation().i18n.language; + const { t, i18n } = useTranslation(); + const lang = i18n.language; const ms = typeof at === "number" ? at : at ? new Date(at).getTime() : NaN; - const text = Number.isFinite(ms) ? relativeFromMs(ms, lang) : ""; + const text = Number.isFinite(ms) ? relativeFromMs(ms, lang, t) : ""; if (!text) return empty ? {empty} : null; const iso = new Date(ms).toISOString(); return ( diff --git a/frontend/src/components/watchStrings.ts b/frontend/src/components/watchStrings.ts new file mode 100644 index 00000000..18ab3c88 --- /dev/null +++ b/frontend/src/components/watchStrings.ts @@ -0,0 +1,78 @@ +// The public /watch page's own strings. The page is opened by whoever got the link, signed out and +// with no app language preference, so it reads the browser's language against this small dict +// instead of loading the app's i18n (every locale of every area — half of what the page weighed). + +const STR = { + en: { + loading: "Loading…", + gone: "This link is no longer available.", + goneHint: "It may have expired or been revoked by the owner.", + passwordTitle: "This video is password-protected", + passwordPlaceholder: "Enter password", + watch: "Watch", + wrong: "Wrong password.", + download: "Download", + file: "File", + links: "Links", + brand: "Shared via Siftlode", + repeat: "Repeat", + failed: "Couldn't load this link.", + failedHint: "Check your connection and try again.", + retry: "Try again", + tooMany: "Too many attempts. Try again in a few minutes.", + unlockFailed: "Couldn't check the password. Try again.", + crashTitle: "Something went wrong.", + crashHint: "Reload the page to try again.", + reload: "Reload", + }, + hu: { + loading: "Betöltés…", + gone: "Ez a link már nem elérhető.", + goneHint: "Lehet, hogy lejárt, vagy a tulajdonos visszavonta.", + passwordTitle: "Ez a videó jelszóval védett", + passwordPlaceholder: "Add meg a jelszót", + watch: "Megnézem", + wrong: "Hibás jelszó.", + download: "Letöltés", + file: "Fájl", + links: "Linkek", + brand: "Megosztva a Siftlode-dal", + repeat: "Ismétlés", + failed: "Nem sikerült betölteni a linket.", + failedHint: "Ellenőrizd a kapcsolatot, és próbáld újra.", + retry: "Újrapróbálás", + tooMany: "Túl sok próbálkozás. Próbáld újra pár perc múlva.", + unlockFailed: "Nem sikerült ellenőrizni a jelszót. Próbáld újra.", + crashTitle: "Valami hiba történt.", + crashHint: "Töltsd újra az oldalt, és próbáld újra.", + reload: "Újratöltés", + }, + de: { + loading: "Wird geladen…", + gone: "Dieser Link ist nicht mehr verfügbar.", + goneHint: "Er ist möglicherweise abgelaufen oder wurde widerrufen.", + passwordTitle: "Dieses Video ist passwortgeschützt", + passwordPlaceholder: "Passwort eingeben", + watch: "Ansehen", + wrong: "Falsches Passwort.", + download: "Herunterladen", + file: "Datei", + links: "Links", + brand: "Geteilt über Siftlode", + repeat: "Wiederholen", + failed: "Der Link konnte nicht geladen werden.", + failedHint: "Prüfe deine Verbindung und versuche es erneut.", + retry: "Erneut versuchen", + tooMany: "Zu viele Versuche. Versuche es in ein paar Minuten erneut.", + unlockFailed: "Das Passwort konnte nicht geprüft werden. Versuche es erneut.", + crashTitle: "Etwas ist schiefgelaufen.", + crashHint: "Lade die Seite neu und versuche es erneut.", + reload: "Neu laden", + }, +} as const; + +export const lang = + ((navigator.language || "en").slice(0, 2) as keyof typeof STR) in STR + ? ((navigator.language || "en").slice(0, 2) as keyof typeof STR) + : "en"; +export const T = STR[lang]; diff --git a/frontend/src/lib/format.test.ts b/frontend/src/lib/format.test.ts index e3773ece..096bb752 100644 --- a/frontend/src/lib/format.test.ts +++ b/frontend/src/lib/format.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TFunction } from "i18next"; import { RELATIVE_UNITS, formatBytes, @@ -18,12 +19,10 @@ import { import en from "../i18n/locales/en/time.json"; import hu from "../i18n/locales/hu/time.json"; -// i18n boots the whole locale glob (and touches localStorage/document) on import, so stub it. -// `language` is mutable because relativeFromMs reads it per call to pick the Intl locale — these +// `language` is mutable because relativeFromMs takes it per call to pick the Intl locale — these // cases assert both languages by flipping it. `t` echoes the key: the only string still coming // from the locale files is justNow. -const i18nStub = vi.hoisted(() => ({ language: "en", t: (key: string) => key })); -vi.mock("../i18n", () => ({ default: i18nStub })); +const i18nStub = { language: "en", t: ((key: string) => key) as unknown as TFunction }; const MIN = 60; const HOUR = 3600; @@ -74,29 +73,31 @@ describe("relativeFromMs", () => { for (const [secs, tier, expectedEn, expectedHu] of CASES) { it(`${secs}s is ${tier} — "${expectedEn}" / "${expectedHu}"`, () => { - expect(relativeFromMs(NOW - secs * 1000, i18nStub.language)).toBe(expectedEn); + expect(relativeFromMs(NOW - secs * 1000, i18nStub.language, i18nStub.t)).toBe(expectedEn); i18nStub.language = "hu"; - expect(relativeFromMs(NOW - secs * 1000, i18nStub.language)).toBe(expectedHu); + expect(relativeFromMs(NOW - secs * 1000, i18nStub.language, i18nStub.t)).toBe(expectedHu); }); } it("follows a language switch at runtime", () => { const twoHoursAgo = NOW - 2 * HOUR * 1000; - expect(relativeFromMs(twoHoursAgo, i18nStub.language)).toBe("2h ago"); + expect(relativeFromMs(twoHoursAgo, i18nStub.language, i18nStub.t)).toBe("2h ago"); i18nStub.language = "hu"; - expect(relativeFromMs(twoHoursAgo, i18nStub.language)).toBe("2 órája"); + expect(relativeFromMs(twoHoursAgo, i18nStub.language, i18nStub.t)).toBe("2 órája"); i18nStub.language = "en"; - expect(relativeFromMs(twoHoursAgo, i18nStub.language)).toBe("2h ago"); + expect(relativeFromMs(twoHoursAgo, i18nStub.language, i18nStub.t)).toBe("2h ago"); }); it("clamps a future timestamp instead of counting up", () => { - expect(relativeFromMs(NOW + 5 * DAY * 1000, i18nStub.language)).toBe("time.justNow"); + expect(relativeFromMs(NOW + 5 * DAY * 1000, i18nStub.language, i18nStub.t)).toBe( + "time.justNow", + ); }); it("renders nothing for a timestamp it cannot read", () => { - expect(relativeTime("not-a-date", i18nStub.language)).toBe(""); - expect(relativeTime(null, i18nStub.language)).toBe(""); - expect(relativeFromMs(NaN, i18nStub.language)).toBe(""); + expect(relativeTime("not-a-date", i18nStub.language, i18nStub.t)).toBe(""); + expect(relativeTime(null, i18nStub.language, i18nStub.t)).toBe(""); + expect(relativeFromMs(NaN, i18nStub.language, i18nStub.t)).toBe(""); }); // Derived from RELATIVE_UNITS, not from CASES, so adding a tier to the table without covering diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index af813387..11669904 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -1,5 +1,4 @@ import type { TFunction } from "i18next"; -import i18n from "../i18n"; // The one home of the app's LOCALE forms — numbers, units, dates (alphabetical order: `collate.ts`) — // all in the UI language, never the browser's (lint: `local/locale-format` refuses a hand-built one elsewhere). @@ -8,7 +7,9 @@ import i18n from "../i18n"; // the React Compiler memoizes a call on its arguments, so a formatter that read the language // off the i18n singleton kept its old answer after a language switch — a card still said "2 hr 4 min" // beside a hero saying "2 ó 16 p". Passing `i18n.language` (from useTranslation) makes the language a -// dependency the compiler can see; the signature makes forgetting it a type error. +// dependency the compiler can see; the signature makes forgetting it a type error. It is also why +// this file never imports the app's i18n: the public /watch entry formats a file size through it, and +// that import alone carried every locale of every area into the page a shared link opens. // Intl formatters are expensive to build and every grid formats one per card: one per language × // options, built lazily. Keyed by language because the user can switch it at runtime. @@ -23,9 +24,9 @@ function numberFormat(lang: string, opts: Intl.NumberFormatOptions = {}): Intl.N return f; } -export function relativeTime(iso: string | null, lang: string): string { +export function relativeTime(iso: string | null, lang: string, t: TFunction): string { if (!iso) return ""; - return relativeFromMs(new Date(iso).getTime(), lang); + return relativeFromMs(new Date(iso).getTime(), lang, t); } /** Human file size, binary units (1024), the number in the UI language: 501747 -> "490 KB", @@ -90,11 +91,11 @@ function relativeFormatter(lang: string): Intl.RelativeTimeFormat { * client-side notifications whose timestamps are already numbers). One implementation shared * instead of re-implemented per component. Empty string for a timestamp that can't be read, so a * caller's `|| "—"` fallback covers it like a missing one. */ -export function relativeFromMs(ms: number, lang: string): string { +export function relativeFromMs(ms: number, lang: string, t: TFunction): string { const secs = Math.max(0, (Date.now() - ms) / 1000); if (!Number.isFinite(secs)) return ""; // Sub-minute is the one case Intl has no wording for — it would say "0m ago". - if (secs < RELATIVE_UNITS[0][0]) return i18n.t("time.justNow", { lng: lang }); + if (secs < RELATIVE_UNITS[0][0]) return t("time.justNow", { lng: lang }); let [div, unit] = RELATIVE_UNITS[0]; for (const [unitSecs, unitName] of RELATIVE_UNITS) { if (secs < unitSecs) break; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 61d41044..d5576a18 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -29,15 +29,15 @@ installClientLog(appCopyOf); // A tab from before the last release reloads onto the new build instead of crashing on a lazy chunk. installStaleBuildReload(); -// Split by top-level route so each entry point is its own chunk: a public /watch share link or a -// legal page never downloads the authenticated app bundle, and the app never carries them either. -// The legal pages live outside the App tree (no /api/me) so Google's consent screen and reviewers -// can fetch them directly; the watch page is a login-free public player. Reached by full-page -// navigation (plain ), so a simple pathname switch is enough — no router needed. +// Split by top-level route so each entry point is its own chunk: a legal page never downloads the +// authenticated app bundle, and the app never carries it either. The legal pages live outside the +// App tree (no /api/me) so Google's consent screen and reviewers can fetch them directly. Reached by +// full-page navigation (plain ), so a simple pathname switch is enough — no router needed. +// A public /watch link is not here at all: it has its own HTML entry (watch.html → watchMain.tsx), +// so the friend it was sent to never loads this file's providers, i18n or CSS imports. const App = lazyWithPreload(() => import("./App")); const PrivacyPolicy = lazy(() => import("./components/legal/PrivacyPolicy")); const Terms = lazy(() => import("./components/legal/Terms")); -const WatchPage = lazy(() => import("./components/WatchPage")); // The design lab: the living style guide, rendered with the app's real CSS so a design decision // is taken on what will ship. DEV only — Vite folds the condition to `false` in a production build, // the import becomes dead code and the chunk is never emitted (devOnlyGuard fails the build if it is). @@ -46,7 +46,7 @@ const DesignLab = import.meta.env.DEV ? lazy(() => import("./design-lab/DesignLa const queryClient = createQueryClient(); // Baseline theme so the token CSS vars (--bg/--surface/--accent/…) resolve on first paint — including -// the standalone public pages (/watch, /privacy, /terms) that render outside and never call +// the standalone public pages (/privacy, /terms) that render outside and never call // applyTheme. The app overrides this with the user's saved theme once it mounts. const de = document.documentElement.dataset; de.theme ||= "dark"; @@ -54,8 +54,7 @@ de.scheme ||= "midnight"; const path = window.location.pathname; const isLab = DesignLab !== null && path === "/design-lab"; -const isPublicPage = - path === "/privacy" || path === "/terms" || path.startsWith("/watch/") || isLab; +const isPublicPage = path === "/privacy" || path === "/terms" || isLab; // Fetch the app chunk NOW, alongside BootGate's identity probe, instead of only once the gate opens: // the two waits then overlap rather than queue, and the gate usually opens straight onto the app. A // failed fetch is left to the render's own load, which retries it and reports to the ErrorBoundary. @@ -69,11 +68,6 @@ const root = ) : path === "/terms" ? ( - ) : path.startsWith("/watch/") ? ( - // FloatingTree so the public player's own popovers dispatch Escape/outside-press topmost-only. - - - ) : ( {/* One tree for the whole app: every anchored popover registers as a node so Escape and @@ -119,8 +113,8 @@ const root = createRoot(document.getElementById("root")!).render( - {/* Around EVERY entry, the public ones too: a crash on a shared /watch link, or its chunk gone - twice over after a release (lib/staleBuild), left a blank white page with no way on. */} + {/* Around EVERY entry, the public ones too: a crash, or a chunk gone twice over after a release + (lib/staleBuild), left a blank white page with no way on. */} }>{root} diff --git a/frontend/src/watchEntry.contract.test.ts b/frontend/src/watchEntry.contract.test.ts new file mode 100644 index 00000000..e03c935c --- /dev/null +++ b/frontend/src/watchEntry.contract.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import ts from "typescript"; + +// A shared /watch link has its own entry (watch.html → watchMain.tsx) so the friend it was sent to +// loads the player page and nothing of the app. That holds only while nothing the page imports +// reaches back into the app — and the first build of the entry showed how easily it does: one +// file-size formatter imported the app's i18n, and with it every locale of every area (≈ 120 KB gzip, +// half the page). This walks the entry's static imports, as the bundler does, and names the path to +// any of what the app alone may load. +const SRC = resolve(__dirname); +const FORBIDDEN: Record = { + "src/i18n/index.ts": + "the app's i18n (every locale) — the page has its own strings (watchStrings)", + i18next: "the app's i18n runtime", + "react-i18next": "the app's i18n runtime", + "@tanstack/react-query": "the app's data layer", + "src/App.tsx": "the app shell", + "src/main.tsx": "the app's entry", + "src/lib/notifications.ts": "the app's notification center", + "src/lib/logHooks.ts": "the app's client-log wiring", + "flag-icons": "the app's flag sprites", +}; + +function resolveSpec(from: string, spec: string): string | null { + if (!spec.startsWith(".")) { + const parts = spec.split("/"); + return parts.slice(0, spec.startsWith("@") ? 2 : 1).join("/"); + } + const base = resolve(dirname(from), spec); + for (const ext of ["", ".ts", ".tsx", "/index.ts", "/index.tsx"]) { + const p = base + ext; + if (existsSync(p) && statSync(p).isFile()) return p; + } + return null; +} + +/** Specifiers a file loads at run time: value imports and re-exports, never `import type`. */ +function runtimeImports(file: string): string[] { + const sf = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true); + const specs: string[] = []; + for (const st of sf.statements) { + if ( + ts.isImportDeclaration(st) && + st.importClause?.phaseModifier !== ts.SyntaxKind.TypeKeyword + ) { + specs.push((st.moduleSpecifier as ts.StringLiteral).text); + } else if (ts.isExportDeclaration(st) && st.moduleSpecifier && !st.isTypeOnly) { + specs.push((st.moduleSpecifier as ts.StringLiteral).text); + } + } + return specs; +} + +const key = (p: string) => + p.startsWith("/") || /^[A-Za-z]:/.test(p) + ? relative(join(SRC, ".."), p).split("\\").join("/") + : p; + +/** Every module the entry reaches, each with the importer that first reached it. */ +function reach(entry: string): Map { + const seen = new Map([[key(entry), null]]); + const queue = [entry]; + for (let file = queue.shift(); file; file = queue.shift()) { + if (!/\.tsx?$/.test(file)) continue; + for (const spec of runtimeImports(file)) { + const target = resolveSpec(file, spec); + if (target === null || seen.has(key(target))) continue; + seen.set(key(target), key(file)); + if (!target.startsWith("@") && /\.tsx?$/.test(target)) queue.push(target); + } + } + return seen; +} + +function chain(seen: Map, mod: string): string { + const path = [mod]; + for (let up = seen.get(mod); up; up = seen.get(up)) path.unshift(up); + return path.join(" → "); +} + +describe("the /watch entry", () => { + it("loads nothing the app alone may load", () => { + const seen = reach(join(SRC, "watchMain.tsx")); + expect(seen.has("src/components/WatchPage.tsx")).toBe(true); // the walk is reading the entry + const leaks = Object.keys(FORBIDDEN) + .filter((mod) => seen.has(mod)) + .map((mod) => `${FORBIDDEN[mod]}: ${chain(seen, mod)}`); + expect(leaks).toEqual([]); + }); + + it("catches a leak one import deep (the walk is not vacuous)", () => { + // main.tsx is the app: walking it must find the app's i18n. + expect(reach(join(SRC, "main.tsx")).has("src/i18n/index.ts")).toBe(true); + }); + + it("serves the same head as the app's page, so the server's OG block lands the same way", () => { + const page = (name: string) => readFileSync(join(SRC, "..", name), "utf8"); + // Comments aside (the OG markers are kept: the server replaces between them), and the script. + const norm = (html: string) => + html + .replace(//g, "") + .replace(/\s+/g, " ") + .replace(/src="\/src\/[^"]+"/, 'src="ENTRY"'); + expect(norm(page("watch.html"))).toBe(norm(page("index.html"))); + expect(page("watch.html")).toContain('src="/src/watchMain.tsx"'); + }); +}); diff --git a/frontend/src/watchMain.tsx b/frontend/src/watchMain.tsx new file mode 100644 index 00000000..6302f0e7 --- /dev/null +++ b/frontend/src/watchMain.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { CrashBoundary } from "./components/CrashBoundary"; +import WatchPage from "./components/WatchPage"; +import { T } from "./components/watchStrings"; +import "./index.css"; +import { installStaleBuildReload } from "./lib/staleBuild"; + +// The entry of a public /watch/ link (watch.html). Whoever opens it got the link from a +// friend: signed out, often on a phone, often on mobile data — so it imports the player page and +// what that page renders, and none of main.tsx's app (no providers, no app i18n, no client-log +// wiring: an anonymous viewer has no diagnostics consent to send it under). A contract test walks +// this file's imports and fails on any of those (`watchEntry.contract.test.ts`). + +// A tab left open across a release reloads onto the new build instead of failing on a chunk. +installStaleBuildReload(); + +const crashCopy = () => ({ title: T.crashTitle, subtitle: T.crashHint, reload: T.reload }); + +createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 51197592..cde5ed87 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from "vite"; +import { defineConfig, type Plugin } from "vite"; import { reactWithCompiler } from "./reactPlugin"; import { devOnlyGuard } from "./devOnlyGuard"; @@ -17,8 +17,21 @@ const proxy = { "/healthz": proxyOpts, }; +// Two HTML entries: the app (index.html) and a public /watch/ link (watch.html), which loads +// only the player page. In a build the backend picks the file by path (`main.py`); this does the +// same for `vite dev`, whose history fallback would otherwise hand /watch/… the app. +const watchEntry = (): Plugin => ({ + name: "siftlode-watch-entry", + configureServer(server) { + server.middlewares.use((req, _res, next) => { + if (req.url?.startsWith("/watch/")) req.url = "/watch.html"; + next(); + }); + }, +}); + export default defineConfig({ - plugins: [reactWithCompiler(), devOnlyGuard()], + plugins: [reactWithCompiler(), devOnlyGuard(), watchEntry()], server: { port: 5173, proxy }, - build: { outDir: "dist" }, + build: { outDir: "dist", rollupOptions: { input: ["index.html", "watch.html"] } }, }); diff --git a/frontend/watch.html b/frontend/watch.html new file mode 100644 index 00000000..97796a77 --- /dev/null +++ b/frontend/watch.html @@ -0,0 +1,35 @@ + + + + + + + + + + Siftlode + + + + + + + +
+ + +