v4: what the server and the client both state is declared once — several rules were written out in two or more files, some already drifting apart. A link's site name (its host without www.) was computed in five places, now shared/url.ts's siteHost; dumpFileVersion, which the frontend and the server had to keep equal by hand, is one function in shared/dump.ts; the Markdown flattening of the chat's reply previews and the digest's quotes is shared/markdown.ts's markdownToPlainText, so the digest also keeps an image's alt text; an attachment's URL and how to find one in a text are shared/attachment.ts, which the editor, Markdown and attachment-service read, its id pattern the new UUID_RE that the file and thumbnail routes check ids with. On the server, the Bandcamp, YouTube playlist and link preview caches use one TtlCache (api/lib/ttl-cache.ts) instead of three copies of the same expiry and eviction; the link previews' theme-color and the emails' theme tokens read colours with one parser (api/lib/css-color.ts), and a transparent theme-color now gives no tint; the WebSocket route sends through ws-service's send, the welcome included, so it is type-checked; fetch.ts strips an IPv6 literal's brackets with public-address's bareHostname; and a category's slug is checked by the same validateSlug as a dump's, with its own length. The migrations keep their frozen copies, as they document. 272 backend tests and 677 frontend tests pass, and deno check, tsc, eslint and doc:lint are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,8 @@
|
||||
*/
|
||||
|
||||
import type { RichContent } from "../../shared/dump.ts";
|
||||
import { siteHost } from "../../shared/url.ts";
|
||||
import { TtlCache } from "../lib/ttl-cache.ts";
|
||||
import type { RichContentProvider } from "../remote/providers/provider.ts";
|
||||
import { youtubeProvider } from "../remote/providers/youtube.ts";
|
||||
import { bandcampProvider } from "../remote/providers/bandcamp.ts";
|
||||
@@ -47,19 +49,12 @@ export interface FetchRichContentResult {
|
||||
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;
|
||||
/**
|
||||
* Makes posting a link fast after the create form previewed it, by keeping the
|
||||
* previews fetched in the last few minutes, by URL. Without it, the second
|
||||
* previews fetched in the last five minutes, by URL. Without it, 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> }
|
||||
>();
|
||||
const recent = new TtlCache<Promise<FetchRichContentResult>>(5 * 60_000, 200);
|
||||
|
||||
/**
|
||||
* Forgets every cached preview. The tests' `stubNetwork` calls it, since
|
||||
@@ -78,20 +73,13 @@ 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);
|
||||
const hit = fresh ? undefined : recent.get(url);
|
||||
if (hit) return hit;
|
||||
|
||||
// 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);
|
||||
const result = recent.set(url, fetchRichContentUncached(url));
|
||||
result.then(({ ok }) => {
|
||||
if (!ok && recent.get(url) === entry) recent.delete(url);
|
||||
if (!ok) recent.delete(url, result);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
@@ -108,18 +96,10 @@ async function fetchRichContentUncached(
|
||||
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 };
|
||||
}
|
||||
const siteName = siteHost(url);
|
||||
return siteName
|
||||
? { ok: false, content: { type: "generic", url, siteName } }
|
||||
: { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user