shadimage
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 · light

Registry 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.

More from Delta Components

Browse all components in this library →