perf(watch): a shared link loads the player, not the app
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.
This commit is contained in:
@@ -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<string, string> = {
|
||||
"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<string, string | null> {
|
||||
const seen = new Map<string, string | null>([[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<string, string | null>, 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(/<!--(?!OG:)[\s\S]*?-->/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"');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user