Delta Components
uiembedsource: ok · refreshed
Embed
Turns a URL into what it points to: a YouTube facade, a lazy Spotify player, an image, a video, or a plain link card, decided from the URL alone.
npx shadcn add "https://deltacomponents.dev/r/embed.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightRegistry dependencies
https://www.deltacomponents.dev/r/utils.jsonhttps://www.deltacomponents.dev/r/image.jsonskeleton
Files4 · source cached 10/8/2026, 7:21:14 AM
registry/ui/embed.tsx
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { classifyUrl, type EmbedSource } from "@/lib/embed"
import { Image, type ImageProps } from "@/components/ui/image"
import { Skeleton } from "@/components/ui/skeleton"
import type { LinkPreviewData } from "@/lib/link-preview"
// iPadOS reports itself as a Mac, distinguishable only by the touch points a
// real Mac doesn't have.
function isIOS(nav: Pick<Navigator, "userAgent" | "platform" | "maxTouchPoints">): boolean {
return (
/iP(hone|ad|od)/.test(nav.userAgent) ||
(nav.platform === "MacIntel" && nav.maxTouchPoints > 1)
)
}
// loading="lazy" does defer the request, but the browser starts it thousands
// of pixels before the viewport, so a player far down a page still loads with
// it. Holding the src back until the element is about to be seen is tighter.
function useNearViewport<T extends Element>(
ref: React.RefObject<T | null>,
rootMargin = "200px"
): boolean {
const [near, setNear] = React.useState(false)
React.useEffect(() => {
if (near) return
const el = ref.current
if (!el) return
const observer = new IntersectionObserver(
(entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return
setNear(true)
observer.disconnect()
},
{ rootMargin }
)
observer.observe(el)
return () => observer.disconnect()
}, [ref, near, rootMargin])
return near
}
interface EmbedProps {
url: string
className?: string
/** Where `LinkPreview` fetches a plain link's title, description and image
* from. `null` turns the fetch off. */
endpoint?: string | null
}
/** Turns an arbitrary URL into whichever embed fits it, or nothing at all.
* Trusts `classifyUrl` completely — a `link` classification renders as a
* card, not an iframe, and anything it refuses renders nothing. */
function Embed({ url, className, endpoint }: EmbedProps) {
const source = classifyUrl(url)
if (!source) return null
return <EmbedFromSource source={source} className={className} endpoint={endpoint} />
}
function EmbedFromSource({
source,
className,
endpoint,
}: {
source: EmbedSource
className?: string
endpoint?: string | null
}) {
switch (source.kind) {
case "youtube":
return <YouTubeEmbed videoId={source.videoId} className={className} />
case "spotify":
return <SpotifyEmbed type={source.type} id={source.id} className={className} />
case "image":
return <ImageEmbed src={source.url} className={className} />
case "video":
return <VideoEmbed src={source.url} className={className} />
case "link":
return <LinkPreview url={source.url} className={className} endpoint={endpoint} />
}
}
interface YouTubeEmbedProps {
videoId: string
title?: string
className?: string
}
// YouTube answers a missing maxresdefault with a 4:3 grey 120x90, where the
// real one is 16:9.
function isPlaceholderThumbnail(image: HTMLImageElement): boolean {
return image.currentSrc.includes("maxresdefault") && image.naturalWidth / image.naturalHeight < 1.5
}
// A facade: the real player is close to a megabyte of third-party script, and
// a page shouldn't pay for it until someone actually presses play. Until
// then this is a thumbnail and a button.
function YouTubeEmbed({ videoId, title = "YouTube video", className }: YouTubeEmbedProps) {
// A cross-origin iframe doesn't inherit the tap that landed on our facade,
// so on iOS `autoplay=1` with sound just shows YouTube's own play button —
// muted autoplay used to be the workaround. Android Chrome and desktop DO
// honour that tap through `allow="autoplay"`, so they play with sound on
// one tap. On iOS the player is mounted up front instead, once the block
// nears the viewport, so the one tap lands inside YouTube's player and
// plays with sound; the cost is the player script loading for every video
// on iOS, which is the trade the site chose.
const [playing, setPlaying] = React.useState(false)
const [eager, setEager] = React.useState(false)
React.useEffect(() => setEager(isIOS(navigator)), [])
const ref = React.useRef<HTMLDivElement>(null)
const near = useNearViewport(ref)
const showIframe = playing || (eager && near)
const [thumbnailLoaded, setThumbnailLoaded] = React.useState(false)
const [playerLoaded, setPlayerLoaded] = React.useState(false)
const thumbRef = React.useRef<HTMLImageElement>(null)
const iframeRef = React.useRef<HTMLIFrameElement>(null)
// Once the video is going, a tap on the player is a tap on YouTube's own
// chrome, and on a phone-height embed the title bar across the top — a link
// into the YouTube app — takes a good share of it, so trying to pause opens
// the app instead. On a coarse pointer a layer of ours takes the tap and
// toggles playback over the player's message API; YouTube's control bar
// along the bottom stays uncovered for scrubbing, volume and fullscreen.
// The layer only exists once playback has been seen (state 1), because
// before that the first tap has to reach the player itself on iOS.
const [playerState, setPlayerState] = React.useState<number | null>(null)
const [origin, setOrigin] = React.useState("")
React.useEffect(() => setOrigin(window.location.origin), [])
React.useEffect(() => {
const onMessage = (event: MessageEvent) => {
if (event.source !== iframeRef.current?.contentWindow || typeof event.data !== "string") return
let data: { event?: string; info?: number | { playerState?: number } }
try {
data = JSON.parse(event.data)
} catch {
return
}
if (data.event === "onStateChange" && typeof data.info === "number") setPlayerState(data.info)
else if (data.event === "infoDelivery" && typeof data.info === "object" && typeof data.info?.playerState === "number")
setPlayerState(data.info.playerState)
}
window.addEventListener("message", onMessage)
return () => window.removeEventListener("message", onMessage)
}, [])
const sendToPlayer = (func: "playVideo" | "pauseVideo" | "listening") => {
const target = iframeRef.current?.contentWindow
if (!target) return
const message = func === "listening" ? { event: "listening", id: videoId } : { event: "command", func, args: [] }
target.postMessage(JSON.stringify(message), "https://www.youtube-nocookie.com")
}
const [started, setStarted] = React.useState(false)
React.useEffect(() => {
if (playerState === 1) setStarted(true)
}, [playerState])
// A cached thumbnail can finish loading before hydration, which means
// React's onLoad below never fires for it.
React.useEffect(() => {
const image = thumbRef.current
if (!image?.complete || !image.naturalWidth) return
if (isPlaceholderThumbnail(image)) setWithoutLargeThumbnail(videoId)
else setThumbnailLoaded(true)
}, [videoId])
// 1280x720 is the largest thumbnail YouTube publishes, and it only exists
// for some videos. A missing one is not an error: YouTube answers 404 with
// a valid grey placeholder, which loads fine — `isPlaceholderThumbnail`
// above is how both the mount check and `onLoad` tell it apart from the
// real thing. Keyed on the id so a new video gets a fresh try.
const [withoutLargeThumbnail, setWithoutLargeThumbnail] = React.useState<string | null>(null)
const thumbnailSrc =
withoutLargeThumbnail === videoId
? `https://i.ytimg.com/vi/${encodeURIComponent(videoId)}/hqdefault.jpg`
: `https://i.ytimg.com/vi_webp/${encodeURIComponent(videoId)}/maxresdefault.webp`
return (
<div
ref={ref}
data-slot="youtube-embed"
// No radius by default — a consumer rounds it with `className`.
className={cn("group relative aspect-video w-full overflow-hidden bg-muted", className)}
>
{showIframe && (
<iframe
ref={iframeRef}
// `enablejsapi` with our origin is what lets the player report its
// state and accept play/pause over postMessage — see the tap layer.
src={`https://www.youtube-nocookie.com/embed/${encodeURIComponent(videoId)}?playsinline=1&enablejsapi=1&origin=${encodeURIComponent(origin)}${
playing ? "&autoplay=1" : ""
}`}
title={title}
className="absolute inset-0 h-full w-full border-0"
allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
allowFullScreen
referrerPolicy="strict-origin-when-cross-origin"
onLoad={() => {
setPlayerLoaded(true)
// The player only posts state changes to a page that asked.
sendToPlayer("listening")
}}
/>
)}
{/* The facade sits over the player until the player has painted, then
fades: the iframe is transparent while YouTube loads, then black
before its poster, and on iOS its chrome replaces ours. It never
takes a tap, so on iOS the one tap lands inside the player. */}
<div
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 transition-opacity duration-(--motion-slow) ease-spring",
playerLoaded && "opacity-0"
)}
>
{!thumbnailLoaded && <Skeleton className="absolute inset-0 rounded-none" />}
{/* hqdefault is 4:3 with black bars top and bottom; cover crops them
out instead of letterboxing the thumbnail. */}
{/* eslint-disable-next-line @next/next/no-img-element -- a registry
component installs into any React project, so it must not depend
on next/image; the consumer swaps this for their framework's
loader. */}
<img
ref={thumbRef}
src={thumbnailSrc}
onLoad={(event) => {
const image = event.currentTarget
if (isPlaceholderThumbnail(image)) setWithoutLargeThumbnail(videoId)
else setThumbnailLoaded(true)
}}
onError={() => setWithoutLargeThumbnail(videoId)}
alt=""
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
className="absolute inset-0 h-full w-full object-cover"
/>
{/* The glyph goes the moment the reader presses play — where
autoplay is honoured the video is about to start under it. */}
{!playing && (
<span className="absolute inset-0 flex items-center justify-center">
{/* Our own drawing, not YouTube's mark: a plain rounded rectangle
in their red, which is how their player shows its button. The
red is a literal because it is a brand colour, not one of the
theme's. */}
<svg viewBox="0 0 68 48" className="h-12 w-[68px]">
<rect
width="68"
height="48"
rx="12"
className={cn(
"fill-[#f00] transition-colors duration-(--motion-fast) ease-spring",
"group-hover:fill-[#c00]"
)}
/>
<path d="M27 14v20l18-10z" className="fill-white" />
</svg>
</span>
)}
</div>
{started && (
<button
type="button"
onClick={() => sendToPlayer(playerState === 1 ? "pauseVideo" : "playVideo")}
aria-label={playerState === 1 ? "Pause" : "Play"}
// Touch only, and never over the bottom strip where YouTube's own
// controls live; a mouse can aim, and desktop clicks already toggle.
className="absolute inset-x-0 top-0 bottom-12 hidden cursor-pointer pointer-coarse:block"
/>
)}
{!showIframe && (
<button
type="button"
onClick={() => setPlaying(true)}
aria-label={`Play: ${title}`}
className={cn(
"absolute inset-0 h-full w-full cursor-pointer",
// `rounded-none` beats the base-layer `:focus-visible` radius,
// which would round the ring's corners off the square container.
"rounded-none outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[color:var(--focus-ring,#6B97FF)]"
)}
/>
)}
</div>
)
}
interface SpotifyEmbedProps {
type: "track" | "playlist" | "album" | "episode" | "show"
id: string
className?: string
}
function SpotifyEmbed({ type, id, className }: SpotifyEmbedProps) {
const ref = React.useRef<HTMLDivElement>(null)
const near = useNearViewport(ref)
const [loaded, setLoaded] = React.useState(false)
const height = type === "track" || type === "episode" ? 152 : 352
return (
<div
ref={ref}
data-slot="spotify-embed"
className={cn("relative w-full", className)}
style={{ height }}
>
{/* The iframe is transparent until Spotify paints, so the skeleton
behind it reads through until then. */}
{(!near || !loaded) && (
<Skeleton aria-hidden className="absolute inset-0 rounded-[12px]" />
)}
{near && (
<iframe
src={`https://open.spotify.com/embed/${type}/${encodeURIComponent(id)}`}
title="Spotify player"
allow="encrypted-media; clipboard-write; fullscreen; picture-in-picture"
className="block h-full w-full border-0"
// Spotify's own card already has 12px corners; any other radius
// shows the iframe's background in them, and `normal` keeps a dark
// page from painting an opaque canvas behind the transparent ones.
// Literal 12px on purpose — the site's radius tokens are
// user-adjustable and would fight Spotify's fixed corners.
style={{ borderRadius: 12, colorScheme: "normal" }}
onLoad={() => setLoaded(true)}
/>
)}
</div>
)
}
interface VideoEmbedProps extends Omit<React.ComponentProps<"video">, "src" | "className"> {
src: string
className?: string
}
function VideoEmbed({ src, className, ...videoProps }: VideoEmbedProps) {
const ref = React.useRef<HTMLVideoElement>(null)
const near = useNearViewport(ref)
// Safari, on a phone above all, fetches the metadata and then paints
// nothing until play. Asking for a moment just past zero makes it decode
// and show the opening frame. The fragment never reaches the server.
const framed = src.includes("#") ? src : `${src}#t=0.001`
return (
<video
ref={ref}
data-slot="video-embed"
controls
// Without this, iOS takes the video fullscreen the moment it plays.
playsInline
preload="metadata"
src={near ? framed : undefined}
className={cn("aspect-video w-full", className)}
{...videoProps}
/>
)
}
type ImageEmbedProps = Omit<ImageProps, "alt"> & {
/** Optional here, unlike on Image: a pasted URL never came with any. */
alt?: string
}
// The registry's Image, so an embedded picture enlarges on a click like any
// other. No referrer by default: the URL is usually someone else's server.
function ImageEmbed({ alt = "", ...props }: ImageEmbedProps) {
return <Image alt={alt} referrerPolicy="no-referrer" {...props} />
}
interface LinkPreviewProps {
url: string
/** The page's title. Passing it turns the plain card into a full preview
* and skips the fetch entirely. */
title?: string
/** One or two lines under the title. Passing it skips the fetch. */
description?: string
/** A thumbnail. Passing it skips the fetch. */
image?: string
className?: string
/** Where the title, description and image are fetched from when none of
* them are passed. `null` turns the fetch off. */
endpoint?: string | null
}
// A remount (the editor remounts widgets) shouldn't refetch a URL it already
// has an answer for.
// ponytail: unbounded, lives as long as the page. Cap it if a page can show
// thousands of distinct links.
const previewCache = new Map<string, Promise<LinkPreviewData | null>>()
// The card draws what it is given, and fetches the rest itself. A page's
// title and thumbnail have to come from a server — a browser can't read
// another site's HTML — so with no `title`/`description`/`image` prop and an
// `endpoint`, it asks the route for them; a bare URL and no endpoint stays a
// plain card.
function LinkPreview({ url, title, description, image, className, endpoint = "/api/link-preview" }: LinkPreviewProps) {
// The part is exported, so it can be handed a URL `Embed` would have
// refused. Anything but http(s) — `javascript:` above all — is not a link
// this renders.
const allowed = isHttpUrl(url)
const [fetched, setFetched] = React.useState<LinkPreviewData | null>(null)
React.useEffect(() => {
if (!allowed || title || !endpoint) return
const key = `${endpoint}\n${url}`
let request = previewCache.get(key)
if (!request) {
// Shared by every card showing this URL, so no one card's unmount may
// abort it — and a failure is dropped, so a later card can try again.
request = fetch(`${endpoint}?url=${encodeURIComponent(url)}`)
.then((response) => (response.ok ? (response.json() as Promise<LinkPreviewData>) : null))
.catch(() => null)
.then((data) => {
if (!data) previewCache.delete(key)
return data
})
previewCache.set(key, request)
}
let cancelled = false
request.then((data) => {
if (!cancelled) setFetched(data)
})
return () => {
cancelled = true
}
}, [allowed, title, endpoint, url])
if (!allowed) return null
const hostname = new URL(url).hostname.replace(/^www\./, "")
const resolvedTitle = title ?? fetched?.title ?? undefined
const resolvedDescription = description ?? fetched?.description ?? undefined
const rawImage = image ?? fetched?.image ?? undefined
// Taken from an untrusted page. The route already limits it to http(s),
// but this component can't assume its own copy is what's deployed.
const resolvedImage = rawImage && isHttpUrl(rawImage) ? rawImage : undefined
return (
<a
data-slot="link-preview"
href={url}
target="_blank"
rel="noopener noreferrer"
className={cn(
"border-border/60 bg-card relative flex w-full flex-col overflow-hidden border sm:flex-row",
"rounded-[var(--radius-bg,var(--radius,0.5rem))]",
// A wash laid over the card rather than a second fill: `--hover` is
// translucent, so as the background itself it would replace the
// card's colour, and in dark mode it lands on exactly that colour.
"after:pointer-events-none after:absolute after:inset-0 after:bg-[var(--hover)] after:opacity-0",
"after:transition-opacity after:duration-(--motion-fast) after:ease-spring",
"hover:after:opacity-100 active:after:bg-[var(--active)]",
"outline-none focus-visible:ring-1 focus-visible:ring-[color:var(--focus-ring,#6B97FF)]",
className
)}
>
{resolvedImage && (
/* eslint-disable-next-line @next/next/no-img-element -- a registry
component installs into any React project, so it must not depend on
next/image; the consumer swaps this for their framework's loader. */
<img
src={resolvedImage}
alt=""
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
className="aspect-[1.91/1] w-full shrink-0 object-cover sm:w-48"
/>
)}
<span className="flex min-w-0 flex-col justify-center gap-0.5 p-3">
<span className="text-foreground truncate font-medium">{resolvedTitle ?? hostname}</span>
{resolvedDescription && (
<span className="text-muted-foreground line-clamp-2 text-sm">{resolvedDescription}</span>
)}
<span className="text-muted-foreground truncate text-sm">
{resolvedTitle ? hostname : url}
</span>
</span>
</a>
)
}
function isHttpUrl(value: string): boolean {
try {
const protocol = new URL(value).protocol
return protocol === "http:" || protocol === "https:"
} catch {
return false
}
}
export { Embed, YouTubeEmbed, SpotifyEmbed, VideoEmbed, ImageEmbed, LinkPreview, isIOS }
export type {
EmbedProps,
YouTubeEmbedProps,
SpotifyEmbedProps,
VideoEmbedProps,
ImageEmbedProps,
LinkPreviewProps,
}
registry/lib/embed.ts
export type EmbedSource =
| { kind: "youtube"; videoId: string }
| { kind: "spotify"; type: "track" | "playlist" | "album" | "episode" | "show"; id: string }
| { kind: "image"; url: string }
| { kind: "video"; url: string }
| { kind: "link"; url: string };
const ABSOLUTE_URL = /^https?:\/\/\S+$/;
const YOUTUBE_ID = /^[\w-]{11}$/;
function matchYouTube(parsed: URL): EmbedSource | null {
const host = parsed.hostname.replace(/^(www\.|m\.)/, "");
if (host === "youtu.be") {
const id = parsed.pathname.slice(1);
return YOUTUBE_ID.test(id) ? { kind: "youtube", videoId: id } : null;
}
if (host !== "youtube.com") return null;
if (parsed.pathname === "/watch") {
const id = parsed.searchParams.get("v");
return id && YOUTUBE_ID.test(id) ? { kind: "youtube", videoId: id } : null;
}
const path = parsed.pathname.match(/^\/(?:shorts|embed)\/([\w-]{11})$/);
return path ? { kind: "youtube", videoId: path[1] } : null;
}
type SpotifyType = "track" | "playlist" | "album" | "episode" | "show";
function matchSpotify(parsed: URL): EmbedSource | null {
if (parsed.hostname !== "open.spotify.com") return null;
const match = parsed.pathname.match(
/^\/(track|playlist|album|episode|show)\/([A-Za-z0-9]+)$/,
);
if (!match) return null;
return { kind: "spotify", type: match[1] as SpotifyType, id: match[2] };
}
/** For UNTRUSTED text (a pasted line, a comment body). Null means "not a URL we
* will touch". */
export function classifyUrl(text: string): EmbedSource | null {
const trimmed = text.trim();
// Root- and protocol-relative paths ("/x.png", "//host/x.png") resolve
// against the reader's own origin, so following one sends their cookies
// wherever the pasted text points. Only an absolute http(s) URL qualifies.
if (!ABSOLUTE_URL.test(trimmed)) return null;
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
return null;
}
const youtube = matchYouTube(parsed);
if (youtube) return youtube;
const spotify = matchSpotify(parsed);
if (spotify) return spotify;
// Path only, never the full string — a query string could otherwise pass
// an endpoint off as an image (e.g. "/api/logout?next=.png"), and an <img>
// fires that GET with whatever cookies the reader has.
const path = parsed.pathname;
if (/\.(jpe?g|png|gif|webp|avif|svg)$/i.test(path)) return { kind: "image", url: trimmed };
if (/\.(mp4|webm|mov)$/i.test(path)) return { kind: "video", url: trimmed };
return { kind: "link", url: trimmed };
}
registry/lib/link-preview.ts
import * as dns from "node:dns";
import * as http from "node:http";
import * as https from "node:https";
import { BlockList, isIP } from "node:net";
export interface LinkPreviewData {
title: string | null;
description: string | null;
image: string | null;
siteName: string | null;
}
export class LinkPreviewError extends Error {
constructor(
message: string,
readonly status: 400 | 502,
) {
super(message);
this.name = "LinkPreviewError";
}
}
// RFC 6890 special-use ranges, plus the ranges that matter for SSRF: loopback,
// the RFC 1918 private blocks, and 169.254.0.0/16 — where AWS, GCP and Azure
// all serve their instance-metadata endpoint.
const privateAddresses = new BlockList();
privateAddresses.addSubnet("0.0.0.0", 8, "ipv4");
privateAddresses.addSubnet("10.0.0.0", 8, "ipv4");
privateAddresses.addSubnet("100.64.0.0", 10, "ipv4");
privateAddresses.addSubnet("127.0.0.0", 8, "ipv4");
privateAddresses.addSubnet("169.254.0.0", 16, "ipv4");
privateAddresses.addSubnet("172.16.0.0", 12, "ipv4");
privateAddresses.addSubnet("192.0.0.0", 24, "ipv4");
privateAddresses.addSubnet("192.0.2.0", 24, "ipv4");
privateAddresses.addSubnet("192.168.0.0", 16, "ipv4");
privateAddresses.addSubnet("198.18.0.0", 15, "ipv4");
privateAddresses.addSubnet("198.51.100.0", 24, "ipv4");
privateAddresses.addSubnet("203.0.113.0", 24, "ipv4");
privateAddresses.addSubnet("224.0.0.0", 4, "ipv4"); // multicast
privateAddresses.addSubnet("240.0.0.0", 4, "ipv4"); // reserved, incl. broadcast
privateAddresses.addAddress("::", "ipv6");
privateAddresses.addAddress("::1", "ipv6");
privateAddresses.addSubnet("fc00::", 7, "ipv6"); // unique local
privateAddresses.addSubnet("fe80::", 10, "ipv6"); // link local
privateAddresses.addSubnet("ff00::", 8, "ipv6"); // multicast
privateAddresses.addSubnet("2001:db8::", 32, "ipv6"); // documentation
// These prefixes each carry an arbitrary IPv4 address inside an IPv6 one
// (NAT64, 6to4, Teredo, IPv4-compatible). Rather than unwrapping and
// re-checking what they carry, the whole prefix is refused.
privateAddresses.addSubnet("64:ff9b::", 96, "ipv6");
privateAddresses.addSubnet("2002::", 16, "ipv6");
privateAddresses.addSubnet("2001::", 32, "ipv6");
privateAddresses.addSubnet("::", 96, "ipv6");
/** True only for addresses on the public internet. */
export function isPublicAddress(address: string, family: 4 | 6): boolean {
if (isIP(address) === 0) return false;
// check() unwraps an IPv4-mapped IPv6 address (::ffff:a.b.c.d, dotted or
// hex) against the ipv4 subnets above before comparing.
return !privateAddresses.check(address, family === 4 ? "ipv4" : "ipv6");
}
const MAX_URL_LENGTH = 2048;
/** Throws LinkPreviewError(400) unless this is a URL the server may fetch. */
export function assertFetchableUrl(input: string): URL {
if (typeof input !== "string" || input.length === 0 || input.length > MAX_URL_LENGTH) {
throw new LinkPreviewError("URL not allowed", 400);
}
let url: URL;
try {
url = new URL(input);
} catch {
throw new LinkPreviewError("URL not allowed", 400);
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new LinkPreviewError("URL not allowed", 400);
}
if (url.username || url.password) {
throw new LinkPreviewError("URL not allowed", 400);
}
// A non-default port would let this route be pointed at whatever else is
// listening on the target host.
if (url.port !== "") {
throw new LinkPreviewError("URL not allowed", 400);
}
if (url.hostname.length === 0) {
throw new LinkPreviewError("URL not allowed", 400);
}
// This is the ONLY check a literal IP gets: Node connects straight to an
// IP hostname without calling `lookup`, so guardedLookup below never sees
// it. The URL parser has already turned forms like "2130706433" or "0x7f.1"
// into dotted form by this point.
const literalIp = url.hostname.replace(/^\[|\]$/g, "");
const ipFamily = isIP(literalIp);
if (ipFamily !== 0 && !isPublicAddress(literalIp, ipFamily === 6 ? 6 : 4)) {
throw new LinkPreviewError("URL not allowed", 400);
}
return url;
}
// Validating inside the socket's own lookup means the address that was
// checked is the address that is connected to, so a hostname cannot pass the
// check and then re-resolve to an internal one (DNS rebinding). Hostnames
// only: Node skips `lookup` for a literal IP, which assertFetchableUrl covers.
export function guardedLookup(
hostname: string,
options: dns.LookupOptions,
callback: (err: NodeJS.ErrnoException | null, address: string | dns.LookupAddress[], family?: number) => void,
): void {
dns.lookup(hostname, { ...options, all: true }, (err, addresses) => {
if (err) {
callback(err, "");
return;
}
const blocked = addresses.find(
(candidate) => !isPublicAddress(candidate.address, candidate.family === 6 ? 6 : 4),
);
if (blocked || addresses.length === 0) {
callback(new LinkPreviewError("URL not allowed", 400), "");
return;
}
if (options.all) {
callback(null, addresses);
} else {
callback(null, addresses[0].address, addresses[0].family);
}
});
}
export type RequestPage = (
url: URL,
) => Promise<{ status: number; headers: Record<string, string | string[] | undefined>; body: string }>;
const TIMEOUT_MS = 5000;
const MAX_BODY_BYTES = 512 * 1024;
const defaultRequest: RequestPage = (url) =>
new Promise((resolve, reject) => {
let settled = false;
const settle = (run: () => void) => {
if (settled) return;
settled = true;
clearTimeout(deadline);
run();
};
const transport = url.protocol === "https:" ? https : http;
const req = transport.request(
url,
{
method: "GET",
headers: {
accept: "text/html,application/xhtml+xml",
"user-agent": "link-preview/1.0",
"accept-encoding": "identity",
},
lookup: guardedLookup,
timeout: TIMEOUT_MS,
},
(res) => {
const status = res.statusCode ?? 0;
const headers = res.headers as Record<string, string | string[] | undefined>;
let received = 0;
let body = "";
res.setEncoding("utf8");
res.on("data", (chunk: string) => {
received += Buffer.byteLength(chunk, "utf8");
// A preview's tags are in the head, so the rest of the body is
// never needed — and an unbounded read is a free download on
// someone else's bill.
if (received > MAX_BODY_BYTES) {
res.destroy();
settle(() => resolve({ status, headers, body }));
return;
}
body += chunk;
});
res.on("end", () => settle(() => resolve({ status, headers, body })));
res.on("error", () => settle(() => reject(new LinkPreviewError("Could not fetch the page", 502))));
},
);
req.on("timeout", () => req.destroy(new LinkPreviewError("Could not fetch the page", 502)));
// The `timeout` option only measures idle time, so a server dripping a
// byte every few seconds would never trip it. This one is the whole budget.
const deadline = setTimeout(
() => req.destroy(new LinkPreviewError("Could not fetch the page", 502)),
TIMEOUT_MS,
);
req.on("error", (err) =>
settle(() => reject(err instanceof LinkPreviewError ? err : new LinkPreviewError("Could not fetch the page", 502))),
);
req.end();
});
const ENTITIES: Record<string, string> = {
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
nbsp: " ",
};
function decodeEntities(text: string): string {
return text.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi, (whole, entity: string) => {
if (entity[0] === "#") {
const codePoint =
entity[1]?.toLowerCase() === "x" ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10);
return Number.isNaN(codePoint) ? whole : String.fromCodePoint(codePoint);
}
return ENTITIES[entity.toLowerCase()] ?? whole;
});
}
function normalize(raw: string | undefined, max = Infinity): string | null {
if (raw === undefined) return null;
const text = decodeEntities(raw).replace(/\s+/g, " ").trim();
if (text === "") return null;
return text.length > max ? text.slice(0, max) : text;
}
function resolveImage(raw: string | undefined, pageUrl: URL): string | null {
if (!raw) return null;
try {
const resolved = new URL(raw, pageUrl);
return resolved.protocol === "http:" || resolved.protocol === "https:" ? resolved.href : null;
} catch {
return null;
}
}
const MAX_META_TAG_LENGTH = 8192;
// The page is hostile input, so nothing here may backtrack: an attribute name
// has to follow whitespace (one way in per name, not one per letter), a tag
// longer than any real one is skipped, and the title is found with indexOf.
function collectMetaTags(html: string): Record<string, string>[] {
const tags: Record<string, string>[] = [];
const tagPattern = /<meta\b[^>]*>/gi;
const attrPattern = /\s([a-zA-Z-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
let tagMatch: RegExpExecArray | null;
while ((tagMatch = tagPattern.exec(html))) {
if (tagMatch[0].length > MAX_META_TAG_LENGTH) continue;
const attrs: Record<string, string> = {};
let attrMatch: RegExpExecArray | null;
attrPattern.lastIndex = 0;
while ((attrMatch = attrPattern.exec(tagMatch[0]))) {
attrs[attrMatch[1].toLowerCase()] = attrMatch[2] ?? attrMatch[3];
}
tags.push(attrs);
}
return tags;
}
function findTitle(html: string): string | undefined {
const open = html.search(/<title[\s>]/i);
if (open === -1) return undefined;
const start = html.indexOf(">", open);
if (start === -1) return undefined;
const lower = html.slice(start + 1, start + 1 + 2048).toLowerCase();
const end = lower.indexOf("</title");
return end === -1 ? undefined : html.slice(start + 1, start + 1 + end);
}
export function extractMetadata(html: string, pageUrl: URL): LinkPreviewData {
const tags = collectMetaTags(html);
const metaContent = (name: string): string | undefined =>
tags.find((tag) => tag.property === name || tag.name === name)?.content;
const rawTitle = metaContent("og:title") ?? findTitle(html);
const rawDescription = metaContent("og:description") ?? metaContent("description");
const rawImage = metaContent("og:image") ?? metaContent("twitter:image");
const rawSiteName = metaContent("og:site_name");
return {
title: normalize(rawTitle, 300),
description: normalize(rawDescription, 500),
image: resolveImage(rawImage, pageUrl),
siteName: normalize(rawSiteName),
};
}
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
const MAX_REDIRECTS = 3;
export async function fetchLinkPreview(
input: string,
options?: { request?: RequestPage },
): Promise<LinkPreviewData> {
const request = options?.request ?? defaultRequest;
let url = assertFetchableUrl(input);
let redirects = 0;
let response: Awaited<ReturnType<RequestPage>>;
for (;;) {
try {
response = await request(url);
} catch (error) {
throw error instanceof LinkPreviewError ? error : new LinkPreviewError("Could not fetch the page", 502);
}
if (!REDIRECT_STATUSES.has(response.status)) break;
redirects += 1;
if (redirects > MAX_REDIRECTS) throw new LinkPreviewError("Too many redirects", 502);
const location = response.headers.location;
const target = Array.isArray(location) ? location[0] : location;
if (!target) throw new LinkPreviewError("Could not fetch the page", 502);
// Every hop is a new URL from an untrusted server, so every hop is
// checked like the first.
url = assertFetchableUrl(new URL(target, url).href);
}
if (response.status < 200 || response.status >= 300) {
throw new LinkPreviewError("Could not fetch the page", 502);
}
const contentType = response.headers["content-type"];
const contentTypeValue = Array.isArray(contentType) ? contentType[0] : contentType;
const isHtml =
!!contentTypeValue &&
(contentTypeValue.includes("text/html") || contentTypeValue.includes("application/xhtml+xml"));
if (!isHtml) {
return { title: null, description: null, image: null, siteName: null };
}
return extractMetadata(response.body, url);
}
registry/api/link-preview/route.ts
import { fetchLinkPreview, LinkPreviewError } from "@/lib/link-preview";
export const runtime = "nodejs";
export async function GET(request: Request) {
const url = new URL(request.url).searchParams.get("url");
if (!url) {
return Response.json({ error: "Missing url" }, { status: 400 });
}
try {
const data = await fetchLinkPreview(url);
// Only the extracted fields ever leave this route, never the page body —
// so it can't be used as a general-purpose proxy.
return Response.json(data, {
headers: { "cache-control": "public, max-age=86400, s-maxage=86400" },
});
} catch (error) {
if (error instanceof LinkPreviewError) {
return Response.json({ error: error.message }, { status: error.status });
}
return Response.json({ error: "Could not fetch the page" }, { status: 502 });
}
}
// Rate limiting is deployment-specific (a CDN, middleware, an API gateway)
// and is left to the consumer.