Files
gerbeur/api/services/rich-content-service.ts
khannurien 0d4e344d77 v3: comments say what the code can't — the documentation pass wanted JSDoc on every export and every field of an exported interface, which filled the code with restatements ("The dump.", "Whether it is loading.", "The props of X.", "Renders the X.") and module docs that told a reader nothing ("The hot and new feeds, and the props every main feed of the index takes."); some were wrong as well as vague. Every comment in api/, shared/ and src/ is rewritten under one rule, now stated in the README's "Comments" section: every module and export still has a JSDoc, and it gives context (who uses it and where), usage (what a caller must know: a precondition, what it leaves to the caller, a side effect) or its place in the chain (where its input comes from, where its output goes); a field, parameter, private function or local gets one only when it adds to its parent's, and context about a whole type goes on the type; a type's doc is phrased from the value's side ("Returned to Y by X when Z", "Passed to Y by X"); full sentences, as short as they can be, no label-colon-list; // goes wherever the code isn't obvious and says why, and the section banners and narration go. Every claim was checked against the code, which turned up some forty comments that were wrong: DumpCard's claimed it was only used by the hot and new feeds, the WebSocket welcome claimed to be a socket's first message (presence comes first), UpdateUserRequest said null removes a description (it keeps it), deletedDumpIds leaves out that it also holds dumps made private, THEME_COLOR claimed to mirror index.html's, and the like. The routers' JSDoc now says where they are mounted and what becomes of a thrown APIException. doc-lint.ts lets a field without a doc through; a module or export without one still fails. No code changes; 204 backend and 628 frontend tests pass, and doc:lint, deno check, deno lint and tsc are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:31:14 +00:00

136 lines
4.3 KiB
TypeScript

/**
* Fetches a link's preview through the provider that knows its site, and
* reuses it for a few minutes. `dump-service.ts` calls it when a link is
* posted, changed or refreshed, and `GET /api/preview` when the create form
* previews one.
*
* @module
*/
import type { RichContent } from "../../shared/dump.ts";
import type { RichContentProvider } from "../remote/providers/provider.ts";
import { youtubeProvider } from "../remote/providers/youtube.ts";
import { bandcampProvider } from "../remote/providers/bandcamp.ts";
import { soundcloudProvider } from "../remote/providers/soundcloud.ts";
import { selfProvider } from "../remote/providers/self.ts";
import { genericProvider } from "../remote/providers/generic.ts";
/**
* Lists the providers in priority order: the first that matches a URL reads
* it. `selfProvider` reads this site's own pages from the database, and
* `genericProvider` must stay last, since it matches every URL.
*/
const providers: RichContentProvider[] = [
youtubeProvider,
bandcampProvider,
soundcloudProvider,
selfProvider,
genericProvider,
];
/**
* Returned by {@link tryFetchRichContent} to `GET /api/preview`, which reports
* `ok` as `reached`, and to `refreshDumpMetadata`, which gives up when it is
* false.
*/
export interface FetchRichContentResult {
/**
* False when the page couldn't be reached at all, as opposed to reached and
* found to carry no metadata. A caller that already holds metadata must not
* overwrite it with the failure stub.
*/
ok: boolean;
/**
* On failure, a stub holding the URL and its host name. Missing only when
* the URL doesn't parse.
*/
content?: RichContent;
}
/** How long a fetched preview is reused. */
const RECENT_TTL_MS = 5 * 60 * 1000;
/** How many previews are kept. The oldest written goes first. */
const RECENT_MAX = 200;
/**
* Holds the previews fetched in the last few minutes, by URL. The create form
* previews a link and then posts it, and without this the second fetch of the
* page was the slow part of posting. Only successes are kept.
*/
const recent = new Map<
string,
{ at: number; result: Promise<FetchRichContentResult> }
>();
/**
* Forgets every cached preview. The tests' `stubNetwork` calls it, since
* previews fetched from the previous stubbed network no longer apply.
*/
export function clearRichContentCache(): void {
recent.clear();
}
/**
* Fetches the preview of `url`, and says whether the page could be reached. A
* preview fetched in the last five minutes is reused unless `fresh` is set, as
* a metadata refresh does. It never rejects: a failure resolves to a stub.
*/
export function tryFetchRichContent(
url: string,
{ fresh = false }: { fresh?: boolean } = {},
): Promise<FetchRichContentResult> {
const hit = recent.get(url);
if (!fresh && hit && Date.now() - hit.at < RECENT_TTL_MS) return hit.result;
recent.delete(url);
// Stored while in flight too, so a preview and a post racing share a fetch.
const result = fetchRichContentUncached(url);
if (recent.size >= RECENT_MAX) {
const oldest = recent.keys().next().value;
if (oldest !== undefined) recent.delete(oldest);
}
const entry = { at: Date.now(), result };
recent.set(url, entry);
result.then(({ ok }) => {
if (!ok && recent.get(url) === entry) recent.delete(url);
});
return result;
}
/**
* Fetches the preview of `url` through the first provider that matches, or
* makes the failure stub when the provider rejects.
*/
async function fetchRichContentUncached(
url: string,
): Promise<FetchRichContentResult> {
try {
const provider = providers.find((p) => p.matches(url))!;
return { ok: true, content: await provider.fetch(url) };
} catch (err) {
console.error(`[rich-content] Failed to fetch metadata for ${url}:`, err);
try {
return {
ok: false,
content: {
type: "generic",
url,
siteName: new URL(url).hostname.replace(/^www\./, ""),
},
};
} catch {
return { ok: false };
}
}
}
/**
* Returns the preview of `url`, or a stub holding its host name when the page
* can't be reached. `createUrlDump` and `updateDump` use it for a new link,
* which has no earlier preview to keep.
*/
export async function fetchRichContent(
url: string,
): Promise<RichContent | undefined> {
return (await tryFetchRichContent(url)).content;
}