import { contentTypeOf, readCapped, safeFetch } from './net';
/** maximum oEmbed response size. */
const OEMBED_MAX_BYTES = 64 * 1024;
/** supported oEmbed types. */
const OEMBED_TYPES = new Set(['link', 'photo', 'rich', 'video']);
/** maximum endpoints tried for one link. */
const MAX_CANDIDATES = 3;
/** the parts of an oEmbed payload a link card can use. */
export interface OEmbedMeta {
/** author or channel name, used as the description. */
description?: string;
/** provider thumbnail, possibly relative to the page. */
image?: string;
title?: string;
}
/** an endpoint worth asking, paired with the page whose metadata its answer would be. */
export interface OEmbedCandidate {
endpoint: URL;
page: URL;
}
const stringOf = (value: unknown): string | undefined =>
typeof value === 'string' && value ? value : undefined;
/** the endpoint most providers expose: `/oembed` on the host that serves the content. */
const conventionalEndpoint = (page: URL): URL => {
const endpoint = new URL('/oembed', page.origin);
endpoint.searchParams.set('url', page.href);
endpoint.searchParams.set('format', 'json');
return endpoint;
};
/**
* fetches an oEmbed endpoint and validates the response before using it.
*
* @returns the usable fields, or undefined when the endpoint is unreachable or serves something else
*/
const fetchOEmbed = async (endpoint: URL, signal: AbortSignal): Promise<OEmbedMeta | undefined> => {
try {
const { response } = await safeFetch(endpoint, {
accept: 'application/json',
// do not follow endpoint redirects; each candidate is already bounded.
maxRedirects: 0,
signal,
timeoutMs: 5_000,
});
if (!response.ok || !contentTypeOf(response).includes('json')) {
await response.body?.cancel().catch(() => {});
return undefined;
}
// validate the parsed payload below.
const payload: Record<string, unknown> = JSON.parse(
new TextDecoder().decode(
await readCapped(response, {
maxBytes: OEMBED_MAX_BYTES,
truncate: false,
}),
),
);
// required fields distinguish an oEmbed response from a JSON error body.
const type = payload.type;
if (!payload.version || typeof type !== 'string' || !OEMBED_TYPES.has(type)) {
return undefined;
}
return {
description: stringOf(payload.author_name),
image: stringOf(payload.thumbnail_url),
title: stringOf(payload.title),
};
} catch {
return undefined;
}
};
export interface ResolveOEmbedOptions {
/** the endpoint the page advertised in its discovery tag, tried ahead of any guess. */
advertised: OEmbedCandidate | undefined;
/** the redirect chain the page fetch walked, in order. */
chain: readonly URL[];
signal: AbortSignal;
}
/**
* finds oEmbed metadata for a page, trying the endpoint it advertised before guessing at the conventional
* `/oembed` path on each distinct origin in its redirect chain. at most {@link MAX_CANDIDATES} are tried.
*
* @returns the metadata and the hop it describes, or undefined when nothing answered
*/
export const resolveOEmbed = async ({
advertised,
chain,
signal,
}: ResolveOEmbedOptions): Promise<{ meta: OEmbedMeta; page: URL } | undefined> => {
const candidates: OEmbedCandidate[] = advertised ? [advertised] : [];
// try each distinct redirect origin so short links can resolve their provider endpoint.
const origins = new Set<string>();
for (const hop of chain) {
if (origins.has(hop.origin)) {
continue;
}
origins.add(hop.origin);
candidates.push({ endpoint: conventionalEndpoint(hop), page: hop });
}
for (const { endpoint, page } of candidates.slice(0, MAX_CANDIDATES)) {
const meta = await fetchOEmbed(endpoint, signal);
if (meta) {
return { meta, page };
}
}
return undefined;
};