@molecule/api-oembed

Utility · oembed · API (Node) · v1.0.3 · Apache-2.0

oEmbed consumer + producer — discovers oEmbed endpoints and fetches normalized embed payloads (rich-link cards, Notion-style embeds).

npm install @molecule/api-oembed

npm · Source on GitHub

How it works

@molecule/api-oembed is a utility package for the API (Node) side (oembed).

import { oembed } from '@molecule/api-oembed'

const embed = await oembed('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
// → { type: 'video', version: '1.0', title, html, width, height, ... }

Reference

oEmbed consumer.

Resolves a URL into a normalized { type, version, title?, author_name?, provider_name?, thumbnail_url?, html?, width?, height?, ... } payload by either:

  1. Using a built-in provider registry (YouTube, Vimeo, Twitter/X, SoundCloud, Spotify, Codepen) to skip HTML discovery for popular providers, OR
  2. Fetching the target page and discovering the oEmbed endpoint via <link rel="alternate" type="application/json+oembed">.

Used by blog (rich-link cards), discussion-boards, and productivity apps (Notion-style embeds).

Built-in safety:

  • SSRF guard refuses private/loopback/link-local hosts by default on every URL — input, redirects, AND the discovered oEmbed endpoint.
  • The SSRF guard is DNS-aware: it resolves each host before fetching and rejects it if any A/AAAA record lands in a private/internal/ metadata range, so a public hostname that resolves to a private address (DNS-rebinding / resolve-to-internal) is caught — not just IP/host literals. On the default fetch path the connection is pinned to the validated address (no check-then-connect rebind window); an injected options.fetch is still resolve-validated but owns its own transport, so for a hard guarantee there also restrict egress at the network layer.
  • HTML sanitization on OEmbedResponse.html strips <script> blocks, on*= event handlers, and javascript: URLs.
  • Manual redirect handling re-validates each hop.
  • Hard timeout (default 5s) via AbortController.
  • Body-size cap (default 256 KiB for JSON, 1 MiB for HTML discovery).

Pure HTTP fetch — no DOM, no headless browser. Works in any Node ≥ 20 runtime that ships fetch.

Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit src/index.ts JSDoc, not this file.

Quick Start

import { oembed } from '@molecule/api-oembed'

const embed = await oembed('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
// → { type: 'video', version: '1.0', title, html, width, height, ... }
// With a @molecule/api-cache-compatible adapter:
const embed = await oembed(url, {
  cache: myCacheAdapter,
  cacheTtlMs: 600_000,
  timeoutMs: 3_000,
  maxWidth: 640,
})

Type

utility

Installation

npm install @molecule/api-oembed undici

API

Interfaces

FetchedText

Result of a successful fetchText call.

interface FetchedText {
  /**
   * Truncated body bytes decoded as UTF-8 text.
   */
  body: string

  /**
   * Final URL after redirects.
   */
  finalUrl: string

  /**
   * Lowercased response content-type prefix (e.g. `text/html`,
   * `application/json`).
   */
  contentType: string
}

FetchTextOptions

Fetch options passed through fetchText.

interface FetchTextOptions extends Pick<
  OEmbedOptions,
  'fetch' | 'userAgent' | 'timeoutMs' | 'maxBodyBytes' | 'maxRedirects' | 'allowPrivateNetworks'
> {
  /**
   * Comma-separated `Accept` header value sent with the request.
   * Defaults to `*\/\*` so the same fetcher works for HTML discovery
   * and JSON oEmbed retrieval.
   */
  accept?: string

  /**
   * Allowed content-type prefixes. The response is rejected if its
   * `content-type` doesn't start with one of these. Pass `undefined`
   * to skip the check.
   */
  acceptedContentTypes?: string[]
}

ResolvedAddress

A resolved DNS record — the shape returned by node:dns/promises lookup(host, { all: true }).

interface ResolvedAddress {
  /**
   * The resolved IP address literal (v4 or v6).
   */
  address: string

  /**
   * IP family — `4` or `6`.
   */
  family: number
}

OEmbedResponse

Normalized oEmbed response. Mirrors the oEmbed 1.0 spec — all fields except type and version are optional. The html field has been sanitized before being placed on the result (no <script> tags or on* attribute handlers).

interface OEmbedResponse {
  /**
   * Resource type — one of `photo`, `video`, `link`, `rich`.
   */
  type: OEmbedType

  /**
   * oEmbed format version. Always `"1.0"` for compliant providers.
   */
  version: string

  /**
   * Resource title.
   */
  title?: string

  /**
   * Author display name.
   */
  author_name?: string

  /**
   * Author homepage / profile URL.
   */
  author_url?: string

  /**
   * Provider display name (e.g. `"YouTube"`, `"Spotify"`).
   */
  provider_name?: string

  /**
   * Provider homepage URL.
   */
  provider_url?: string

  /**
   * Suggested cache duration in seconds.
   */
  cache_age?: number

  /**
   * Thumbnail image URL.
   */
  thumbnail_url?: string

  /**
   * Thumbnail width in pixels.
   */
  thumbnail_width?: number

  /**
   * Thumbnail height in pixels.
   */
  thumbnail_height?: number

  /**
   * Width of the embedded resource in pixels (`photo`, `video`, `rich`).
   */
  width?: number

  /**
   * Height of the embedded resource in pixels (`photo`, `video`, `rich`).
   */
  height?: number

  /**
   * Direct image URL (`photo` type only).
   */
  url?: string

  /**
   * Sanitized embed HTML (`video` and `rich` types). Never contains
   * `<script>` tags or `on*` event-handler attributes — see
   * `sanitizeHtml`.
   */
  html?: string

  /**
   * Provider-specific extensions. Keys outside the oEmbed spec are
   * preserved here so consumers can opt into them on a per-provider
   * basis.
   */
  [key: string]: unknown
}

OEmbedCache

Minimal cache contract — compatible with @molecule/api-cache providers. Only get/set of string keys to JSON-serializable values is required.

interface OEmbedCache {
  /**
   * Get a previously cached response, or `undefined` if not present.
   */
  get(key: string): Promise<OEmbedResponse | undefined> | OEmbedResponse | undefined

  /**
   * Set a response with an optional TTL in milliseconds.
   */
  set(key: string, value: OEmbedResponse, ttlMs?: number): Promise<void> | void
}

OEmbedProvider

Map of provider URL patterns → oEmbed endpoints. Used as an optimization to skip HTML discovery for popular providers (YouTube, Twitter/X, Vimeo, Spotify, SoundCloud, Codepen).

Each entry's match is tested against the input URL string with RegExp.test. The first match wins. The endpoint URL has the resource URL appended as a url query parameter, so it should NOT include a trailing ?url=.

interface OEmbedProvider {
  /**
   * Provider display name — e.g. `"YouTube"`.
   */
  name: string

  /**
   * Regex tested against the input URL. The first matching provider
   * wins, so order entries from most-specific to least-specific.
   */
  match: RegExp

  /**
   * Provider oEmbed endpoint URL — the input URL is appended as a
   * `url` query parameter at request time.
   */
  endpoint: string
}

OEmbedOptions

Options for oembed.

interface OEmbedOptions {
  /**
   * Custom `fetch` implementation. Defaults to the global `fetch`.
   * Useful for tests, custom retry/timeout logic, or running behind an
   * HTTP proxy.
   */
  fetch?: typeof globalThis.fetch

  /**
   * `User-Agent` header sent with the request. Defaults to a polite
   * descriptive UA.
   */
  userAgent?: string

  /**
   * Request timeout in milliseconds. Defaults to 5_000 (5 seconds).
   * Implemented via `AbortController`.
   */
  timeoutMs?: number

  /**
   * Maximum response body size in bytes that will be parsed. Defaults
   * to 262_144 (256 KiB). oEmbed JSON payloads are tiny in practice;
   * the cap keeps a hostile server from feeding us a 10 GB stream.
   */
  maxBodyBytes?: number

  /**
   * Maximum number of redirects to follow during discovery + fetch.
   * Defaults to 5.
   */
  maxRedirects?: number

  /**
   * Optional cache. When provided, successful responses are cached
   * under the input URL.
   */
  cache?: OEmbedCache

  /**
   * TTL in milliseconds for cached entries. Defaults to 600_000 (10
   * minutes). Only applied when `OEmbedOptions.cache` is set.
   */
  cacheTtlMs?: number

  /**
   * SSRF guard — when `false` (the default) requests to private /
   * loopback / link-local IP ranges are refused, including hosts that
   * resolve to such an address (DNS-aware). Set `true` to allow them
   * (for example in an internal-network embed scraper). Use with care.
   */
  allowPrivateNetworks?: boolean

  /**
   * Override or extend the built-in provider registry. Entries here
   * are tried BEFORE the built-ins, so callers can short-circuit a
   * built-in entry by registering an entry with an overlapping match.
   */
  providers?: OEmbedProvider[]

  /**
   * Optional `maxwidth` parameter forwarded to providers — most
   * providers honor this to scale embedded media.
   */
  maxWidth?: number

  /**
   * Optional `maxheight` parameter forwarded to providers.
   */
  maxHeight?: number
}

Types

HostLookup

DNS resolver injected into hostResolvesToPrivate / isHostBlocked. Defaults to node:dns/promises lookup(host, { all: true }); overridable so unit tests can supply deterministic answers without touching real DNS.

type HostLookup = (hostname: string) => Promise<ResolvedAddress[]>
  • hostname — Hostname to resolve.

Returns: All A/AAAA records for the hostname.

OEmbedType

Discriminator for OEmbedResponse. Per spec, the four valid types are photo, video, link, and rich.

type OEmbedType = 'photo' | 'video' | 'link' | 'rich'

OEmbedErrorCode

Stable error codes emitted by OEmbedError.

type OEmbedErrorCode =
  | 'invalid-url'
  | 'private-network-blocked'
  | 'unsupported-protocol'
  | 'http-error'
  | 'unsupported-content-type'
  | 'too-many-redirects'
  | 'timeout'
  | 'fetch-failed'
  | 'no-oembed-endpoint'
  | 'invalid-oembed-payload'

Classes

OEmbedError

Error thrown when oembed cannot produce a normalized response. code is a stable machine-readable string; message is the developer-facing English description (handler-error pattern — locale bond not required for this utility).

class OEmbedError extends Error {
  /**
   * Stable machine-readable error code. Compare against
   * `OEmbedErrorCode` values.
   */
  public readonly code: OEmbedErrorCode

  /**
   * The URL that was being processed when the error occurred.
   */
  public readonly url: string

  /**
   * Construct a new `OEmbedError`.
   *
   * @param code - Stable error code.
   * @param message - Developer-facing English description.
   * @param url - URL that was being processed.
   */
  public constructor(code: OEmbedErrorCode, message: string, url: string) {
    super(message)
    this.name = 'OEmbedError'
    this.code = code
    this.url = url
  }
}

Functions

discoverOembedUrl(html, baseUrl)

Find the JSON oEmbed discovery URL inside an HTML document.

Looks for the first <link rel="alternate" type="application/json+oembed" href="..."> tag in the <head> region. The XML variant (type="text/xml+oembed") is intentionally ignored — this consumer only speaks JSON.

function discoverOembedUrl(html: string, baseUrl: string): string | undefined
  • html — HTML body.
  • baseUrl — Final URL of the page (for relative href resolution).

Returns: Absolute oEmbed discovery URL, or undefined if not present.

validateUrl(rawUrl, allowPrivate)

Validate a URL string. Rejects non-http(s) protocols, malformed URLs, and (unless explicitly allowed) private-network host literals.

This is the synchronous fast-fail check. The DNS-aware guard that rejects a public hostname resolving to a private address runs per-hop inside fetchText (see isHostBlocked).

function validateUrl(rawUrl: string, allowPrivate: boolean): URL
  • rawUrl — URL string to validate.
  • allowPrivate — If true, private/loopback/link-local hosts are permitted.

Returns: The parsed URL object.

fetchText(inputUrl, options)

Fetch the body of url with manual redirect handling, total timeout, and body-size cap. Returns the truncated body, final URL, and content-type.

Redirects are followed manually so each intermediate URL is SSRF-validated — the built-in redirect: 'follow' mode would let a malicious server bounce us to 127.0.0.1.

SSRF is enforced per-hop by a DNS-aware guard (isHostBlocked): a public hostname whose A/AAAA record resolves to a private/internal/ metadata address is rejected before the request. On the default fetch path the connection is additionally pinned to the validated address (via ssrfSafeAgent) so it cannot rebind between check and connect. An injected options.fetch is guarded by the pre-fetch resolution but not pinned (it owns its transport).

function fetchText(inputUrl: string, options?: FetchTextOptions): Promise<FetchedText>
  • inputUrl — URL to fetch.
  • options — Fetch options.

Returns: Truncated body + final URL + content-type.

oembed(url, options)

Discover and fetch the normalized oEmbed payload for url.

Resolution strategy:

  1. Validate url (protocol + SSRF guard).
  2. If a built-in or caller-supplied provider matches, use that provider's oEmbed endpoint directly — skips HTML discovery.
  3. Otherwise, fetch the HTML and look for a <link rel="alternate" type="application/json+oembed"> tag.
  4. Fetch the discovered endpoint, validate the JSON payload, and sanitize any embedded html.

Honors:

  • SSRF guard — rejects private/loopback/link-local hosts, and hosts that RESOLVE to such addresses (DNS-aware), unless allowPrivateNetworks: true is passed (applies to the input URL, every redirect hop, AND the discovered oEmbed endpoint).
  • Manual redirect following with per-hop SSRF re-validation.
  • Total per-request timeout via AbortController (default 5s).
  • Body-size cap (default 256 KiB for oEmbed JSON; HTML discovery uses the same cap).
  • HTML sanitization — <script> blocks and on*= attributes are stripped from the returned html field; javascript: URLs are neutralized.
  • Optional response cache (@molecule/api-cache-compatible).

Locale bonds are intentionally not used — error messages on the thrown OEmbedError are developer-facing English (handler- error pattern). Consumers should map error.code to translated user-facing strings in the calling handler.

function oembed(url: string, options?: OEmbedOptions): Promise<OEmbedResponse>
  • url — URL to embed. Must be http: or https:.
  • options — Caller-supplied options. See OEmbedOptions.

Returns: Normalized OEmbedResponse.

findProvider(url, providers)

Find the first matching provider for url from providers, falling back to builtinProviders. Caller-supplied entries are tested BEFORE built-ins so consumers can override defaults.

function findProvider(url: string, providers?: OEmbedProvider[]): OEmbedProvider | undefined
  • url — URL to embed.
  • providers — Optional caller-supplied provider list. Tested before the built-ins.

Returns: The first matching provider, or undefined if none match.

buildProviderEndpoint(provider, targetUrl, maxWidth, maxHeight)

Build the final oEmbed endpoint URL for provider + targetUrl, appending the url, maxwidth, and maxheight query parameters as necessary.

function buildProviderEndpoint(
  provider: OEmbedProvider,
  targetUrl: string,
  maxWidth?: number,
  maxHeight?: number,
): string
  • provider — Matched provider entry.
  • targetUrl — The URL to be embedded.
  • maxWidth — Optional maxwidth parameter.
  • maxHeight — Optional maxheight parameter.

Returns: Fully-formed oEmbed endpoint URL.

sanitizeHtml(html)

Strip dangerous constructs from an oEmbed html payload.

Removes:

  • <script>...</script> blocks (any case, with or without attributes).
  • Self-closing <script ... /> and unclosed <script ...> tags.
  • on*= event-handler attributes (onclick, onload, …) on any element, regardless of quoting style.
  • javascript: URLs in href and src attributes.
function sanitizeHtml(html: string): string
  • html — Raw HTML from an oEmbed response.

Returns: Sanitized HTML safe to render.

isPrivateHost(hostname)

Decide whether hostname is a private / loopback / link-local literal (hostname or IP literal). Returns true if it should be blocked.

This is the synchronous fast path — it does NOT perform DNS lookups. For DNS-resolving protection against a public hostname that resolves to a private address, use isHostBlocked / hostResolvesToPrivate, which the fetcher runs before every outbound hop.

function isPrivateHost(hostname: string): boolean
  • hostname — Hostname or IP literal extracted from a URL.

Returns: true if the hostname is a blocked literal.

isPrivateIPv4(ip)

Whether a dotted-quad IPv4 string falls in a non-routable range.

Blocks: 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10 (CGNAT), 127.0.0.0/8, 169.254.0.0/16 (link-local, incl. 169.254.169.254 metadata), 172.16.0.0/12, 192.0.0.0/24, 192.168.0.0/16, 198.18.0.0/15 (benchmarking), 224.0.0.0/4 (multicast), 240.0.0.0/4 (reserved), 255.255.255.255 (broadcast).

function isPrivateIPv4(ip: string): boolean
  • ip — Dotted-quad IPv4 string.

Returns: true if ip is private/loopback/link-local/reserved.

isPrivateIPv6(ip)

Whether an IPv6 literal falls in a non-routable range. Blocks ::1, ::, link-local (fe80::/10), unique-local (fc00::/7), and IPv4-mapped private addresses in every representation.

IPv4-mapped addresses must be decoded to their embedded v4 and re-checked — the kernel routes ::ffff:7f00:1 → 127.0.0.1, so a mapped literal (or a DNS answer in mapped form) is a real loopback / metadata reach. The WHATWG URL parser normalizes [::ffff:169.254.169.254] to the HEX form ::ffff:a9fe:a9fe (never the dotted form), and DNS can answer in hex too, so BOTH the dotted and hex (incl. the SIIT ::ffff:0:HHHH:HHHH) forms are decoded — and any ::ffff:… form we cannot parse fails CLOSED (treated as private).

function isPrivateIPv6(ip: string): boolean
  • ip — IPv6 literal (without surrounding brackets).

Returns: true if ip is private/loopback/link-local.

isPrivateAddress(ip)

Whether a resolved IP literal (v4, v6, or IPv4-mapped v6) is private/internal/metadata. Dispatches by address family.

function isPrivateAddress(ip: string): boolean
  • ip — IPv4 or IPv6 literal string (e.g. a DNS answer).

Returns: true if ip is in a blocked range.

isIpLiteral(hostname)

Whether hostname is an IP literal (v4 or v6, optionally bracketed). Literal IPs are fully validated by isPrivateHost and need no DNS resolution.

function isIpLiteral(hostname: string): boolean
  • hostname — Hostname extracted from a URL.

Returns: true if hostname is an IP literal.

hostResolvesToPrivate(hostname, dnsLookup)

Resolve hostname and return true if ANY A/AAAA answer is a private/reserved/loopback/link-local/metadata address — the DNS-rebinding / resolve-to-internal defense the literal isPrivateHost check cannot provide.

Fails CLOSED: an unresolvable or empty answer returns true (we cannot prove the host is public, so we refuse it rather than hand an unverified host to fetch).

function hostResolvesToPrivate(hostname: string, dnsLookup?: HostLookup): Promise<boolean>
  • hostname — Hostname to resolve and classify.
  • dnsLookup — Injectable resolver (defaults to node:dns/promises).

Returns: true if the host resolves to a blocked address (or fails to resolve).

isHostBlocked(hostname, dnsLookup)

DNS-aware host guard used before every outbound fetch hop. Returns true (block) when the host is a known-private literal/name OR resolves to a private address. Public IP literals short-circuit without a DNS lookup (they are already fully validated by isPrivateHost).

function isHostBlocked(hostname: string, dnsLookup?: HostLookup): Promise<boolean>
  • hostname — Hostname extracted from the URL about to be fetched.
  • dnsLookup — Injectable resolver (defaults to node:dns/promises).

Returns: true if the host must be blocked.

Constants

DEFAULT_USER_AGENT

Default polite, brand-neutral User-Agent — matches the link-preview convention. Identifies the request as an oEmbed fetcher without claiming any specific product; hosts should pass their own userAgent (with a contact URL/email) in production.

const DEFAULT_USER_AGENT: 'Mozilla/5.0 (compatible; oEmbedBot/1.0)'

builtinProviders

Built-in provider table. Order matters: more-specific entries (e.g. youtu.be before youtube.com) appear first.

const builtinProviders: OEmbedProvider[]

Injection Notes

Runtime Dependencies

  • undici

Throws OEmbedError (error.code is one of invalid-url, private-network-blocked, unsupported-protocol, http-error, unsupported-content-type, too-many-redirects, timeout, fetch-failed, no-oembed-endpoint, invalid-oembed-payload). Map error.code to translated user-facing text in the calling handler — this utility intentionally has no locale bond (handler-error pattern).

Proxied egress: Node's built-in fetch ignores HTTP_PROXY/HTTPS_PROXY. In proxy-only environments (e.g. molecule.dev sandboxes) pass a proxy-aware implementation via options.fetch, or every resolution fails with fetch-failed. Remember the targets are arbitrary user-submitted hosts — an egress allowlist and this package are natural partners.