A video stays on /watch/, anything else is given out on /file/ and a folder on /folder/. The token is the whole key, so every path opens every link and the links already sent keep working. The new paths are kept out of search indexes like /watch/, boot the player alone, and the unfurl card calls a file a file.
159 lines
7.0 KiB
TypeScript
159 lines
7.0 KiB
TypeScript
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 imports as the bundler does — the static ones and the lazy
|
|
// `import("…")` chunks it would fetch later — and names the path to any of what the app alone may load.
|
|
const SRC = resolve(__dirname);
|
|
// Stands for an `import()` whose argument is not a string literal.
|
|
const DYNAMIC = "<computed import()>";
|
|
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/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",
|
|
[DYNAMIC]: "an import() of a computed path — no walk can say what it loads",
|
|
};
|
|
// …and the app's layers above the platform, by folder, so a file moved or added inside one is still
|
|
// forbidden (a single path to the shell's App went stale when App moved).
|
|
const FORBIDDEN_DIRS: Record<string, string> = {
|
|
"src/shell/": "the app shell",
|
|
"src/modules/": "an app page",
|
|
"src/features/": "the app's shared features",
|
|
};
|
|
|
|
function resolveSpec(from: string, spec: string): string | null {
|
|
if (spec === DYNAMIC) return DYNAMIC;
|
|
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`), and
|
|
* every `import()` anywhere in it — a lazy chunk is loaded by the page as surely as a static one. */
|
|
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);
|
|
}
|
|
}
|
|
const visit = (node: ts.Node) => {
|
|
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
|
const arg = node.arguments[0];
|
|
specs.push(arg && ts.isStringLiteralLike(arg) ? arg.text : DYNAMIC);
|
|
}
|
|
ts.forEachChild(node, visit);
|
|
};
|
|
visit(sf);
|
|
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 paths a share link opens on", () => {
|
|
// The server's list: each of the places below that has to know them follows it.
|
|
const main = readFileSync(join(SRC, "..", "..", "backend", "app", "main.py"), "utf8");
|
|
const prefixes = [
|
|
...(/SHARE_PREFIXES = \(([^)]*)\)/.exec(main)?.[1] ?? "").matchAll(/"(\w+)\/"/g),
|
|
].map((m) => m[1]);
|
|
|
|
it("are read off the server", () => {
|
|
expect(prefixes).toEqual(["watch", "file", "folder"]);
|
|
});
|
|
|
|
it("are all kept out of search indexes", () => {
|
|
const robots = readFileSync(join(SRC, "..", "public", "robots.txt"), "utf8");
|
|
for (const p of prefixes) expect(robots).toContain(`Disallow: /${p}/`);
|
|
});
|
|
|
|
it("all boot the player alone in `vite dev` and all give up their token", () => {
|
|
const vite = readFileSync(join(SRC, "..", "vite.config.ts"), "utf8");
|
|
const page = readFileSync(join(SRC, "watch", "WatchPage.tsx"), "utf8");
|
|
for (const src of [vite, page]) expect(src).toContain(`(?:${prefixes.join("|")})`);
|
|
});
|
|
});
|
|
|
|
describe("the /watch entry", () => {
|
|
it("loads nothing the app alone may load", () => {
|
|
const seen = reach(join(SRC, "watchMain.tsx"));
|
|
expect(seen.has("src/watch/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)}`);
|
|
for (const mod of seen.keys())
|
|
for (const [dir, what] of Object.entries(FORBIDDEN_DIRS))
|
|
if (mod.startsWith(dir)) leaks.push(`${what}: ${chain(seen, mod)}`);
|
|
expect(leaks).toEqual([]);
|
|
});
|
|
|
|
// Parses every module the whole app reaches: ~1.3 s idle, past 5 s on a busy machine.
|
|
it("catches a leak one import deep (the walk is not vacuous)", { timeout: 30_000 }, () => {
|
|
// main.tsx is the app: walking it must find the app's i18n, and a page it only loads lazily.
|
|
const app = reach(join(SRC, "main.tsx"));
|
|
expect(app.has("src/i18n/index.ts")).toBe(true);
|
|
expect(app.has("src/components/legal/Terms.tsx")).toBe(true);
|
|
expect([...app.keys()].some((m) => m.startsWith("src/shell/"))).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"');
|
|
});
|
|
});
|