Links that say where they go
A mention, a favicon in a search field, and the request hiding behind both.
A bare URL in a sentence is noise, and a word in blue is a promise you have to take on trust. A mention tells you where you’re going before you go. The idea comes from Notion, and the library’s version is what you’re looking at: every link in this note is one, like this one to unlumen UIReact components, or this one to GitHubunlumen-ui-docs. Point at them.
Made for sentences like this one, about unlumen UIBeautifully Designed React Components. Point at it, or tab to it.
Notion Mention Link is also tiny to use: you give it a URL and get a favicon, a title and a card on hover. In the notes on this site, I just write the name of the link between two pairs of brackets.
Every state still works
A link is the one thing that must never break, so the preview is treated as a bonus. While it loads, the mention shows the domain, which you can read from the URL without asking anyone. The card opens on a skeleton the same size as the result, so nothing jumps. If the preview fails, the card says so, and adds that the link is still safe to open.
The hover has a delay each way: 180 ms to open, so passing over a sentence doesn’t throw a card at you, and 120 ms to close, so traveling from the link to its card doesn’t dismiss it. It opens on keyboard focus as well. Results are cached by URL, and a mention that goes away aborts its request.
requestRef.current?.abort();
const controller = new AbortController();
requestRef.current = controller;
setStatus("loading");
const response = await fetch(url, { signal: controller.signal });
metadataCache.set(normalizedUrl, await response.json());The smallest version: just the icon
Favicon Search takes one piece of that and puts it in an input. Type a website and the search icon turns into the site’s favicon, while you’re still typing. Try one you know.
It doesn’t wait for you to type properly. No https, a www in front, half a URL: it looks for something with a dot in it and calls that the domain. The first letters do nothing, because it waits 350 milliseconds after your last key before it asks anyone.
const raw = input.includes("://") ? input : `https://${input}`;
const host = new URL(raw).hostname.replace(/^www\./, "");
if (host.includes(".") && host.split(".").every(Boolean)) return host;An icon that pops in half-loaded ruins the effect. So a hidden copy of the image loads first, and the visible one only animates in once that copy has arrived. If the site has no favicon, you never see a broken image: the icon stays a small globe.
Then a server fetches whatever it’s told
To fill that card, a server has to fetch whatever URL it’s given, and that sentence should make you nervous. Point a naive preview endpoint at an internal address, like a cloud metadata service, and your server politely goes to read it. That’s called SSRF, and a copy-paste component is a very good way to spread it to a lot of projects at once.
So the endpoint ships with the component, and it refuses things in order. Try it. This is the same list of rules, with made-up DNS answers, since a browser can’t look them up.
- http or https
http: - No credentials in the URL
none - Port 80 or 443
3000
Refused. No request leaves the server.
Checking the name isn’t enough
A domain can answer with a public address during the check and a private one a moment later, at connection time. So the check and the connection have to use the same answer.
const { url, addresses } = await validateUrl(input);
const selected = addresses[0];
transport.request(url, {
// connect to what we checked, not to what DNS says now
lookup: (_hostname, options, callback) => {
callback(null, selected.address, selected.family);
},
});Everything else has a limit
Redirects go back through the same validation, three at most, since a public page can redirect anywhere. There’s five seconds to answer, 512 KB of HTML, 5 MB for an image, and that last limit is counted while reading, because a server can lie about its content length.
response.on("data", (chunk: Buffer) => {
received += chunk.length;
if (received > options.maxBytes) {
response.destroy(new Error("Remote response is too large"));
}
});Nobody will ever notice any of this, and that’s fine. It’s still what I mean when I say somebody made it.