framer-framer
v3.0.0
Published
Universal embed resolver and REST API server - extract embed HTML from any URL (YouTube, X/Twitter, TikTok, Facebook, Instagram, Vimeo, Spotify, SoundCloud, Hugging Face, Gradio, and more)
Downloads
1,000
Maintainers
Readme
framer-framer
Universal embed resolver for Node.js — extract embed HTML from any URL using oEmbed APIs.
Supports YouTube, X/Twitter, TikTok, Flickr, Facebook, Instagram, Vimeo, Spotify, SoundCloud, SlideShare, Speaker Deck, Pinterest, Reddit, Niconico, Hugging Face Spaces, Gradio, note out of the box, with oEmbed auto-discovery and OGP metadata fallback for any other URL. Zero runtime dependencies.
Install
npm install framer-framerRequires Node.js 22+.
Usage
import { embed } from "framer-framer";
const result = await embed("https://www.youtube.com/watch?v=dQw4w9WgXcQ");
console.log(result.html); // <iframe width="200" height="113" src="..." ...>
console.log(result.type); // "video"
console.log(result.title); // "Rick Astley - Never Gonna Give You Up ..."
console.log(result.provider); // "youtube"Platform-specific functions
import {
youtube, twitter, tiktok, flickr, facebook, instagram,
vimeo, spotify, soundcloud, slideshare, speakerdeck, pinterest, reddit, niconico, huggingface, gradio, note,
} from "framer-framer";
await youtube("https://www.youtube.com/watch?v=dQw4w9WgXcQ");
await twitter("https://x.com/user/status/123456789");
await tiktok("https://www.tiktok.com/@user/video/123456789");
await flickr("https://www.flickr.com/photos/username/12345678901");
await vimeo("https://vimeo.com/76979871");
await spotify("https://open.spotify.com/track/4PTG3Z6ehGkBFwjybzWkR8");
await soundcloud("https://soundcloud.com/artist/track");
await slideshare("https://www.slideshare.net/user/presentation-title");
await speakerdeck("https://speakerdeck.com/speaker/my-presentation");
await pinterest("https://www.pinterest.com/pin/123456789/");
await reddit("https://www.reddit.com/r/typescript/comments/abc123/my_post/");
await niconico("https://www.nicovideo.jp/watch/sm9");
await huggingface("https://huggingface.co/spaces/stabilityai/stable-diffusion");
await gradio("https://user-app.hf.space");
await note("https://note.com/username/n/abc123");
// Facebook / Instagram require a Meta access token
await facebook("https://www.facebook.com/video/123", {
auth: { meta: { accessToken: "APP_ID|CLIENT_TOKEN" } },
});
await instagram("https://www.instagram.com/p/ABC123/", {
auth: { meta: { accessToken: "APP_ID|CLIENT_TOKEN" } },
});Batch resolution
Resolve multiple URLs in parallel with concurrency control. Individual failures are returned as EmbedError instances rather than throwing, so partial success is always possible.
import { embedBatch, EmbedError } from "framer-framer";
const results = await embedBatch([
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"https://x.com/user/status/123456789",
"https://vimeo.com/76979871",
], { concurrency: 3 });
for (const result of results) {
if (result instanceof EmbedError) {
console.error(result.code, result.message);
} else {
console.log(result.provider, result.html);
}
}| Option | Type | Default | Description |
| ------------- | -------- | ------- | ------------------------------------ |
| concurrency | number | 5 | Maximum number of parallel requests |
All other EmbedOptions (e.g. maxWidth, cache, timeout) are passed through to each individual resolution.
URL auto-expansion
Automatically detect and expand URLs in text or HTML to embed HTML. Ideal for CMS and blog engines.
import { expandUrls } from "framer-framer";
const text = 'Check this video: https://www.youtube.com/watch?v=dQw4w9WgXcQ and read more at [my blog](https://example.com)';
const expanded = await expandUrls(text);
// URLs are replaced with embed HTML; Markdown links are preservedHTML mode
const html = '<p>Watch https://www.youtube.com/watch?v=dQw4w9WgXcQ here</p>';
const expanded = await expandUrls(html, { format: "html" });
// Bare URLs in text content are expanded; URLs in href/src attributes are preservedExpandOptions
| Option | Type | Default | Description |
| ------------- | -------------------------- | -------- | ------------------------------------------ |
| format | "text" \| "html" | "text" | Input format (text/Markdown or HTML) |
| concurrency | number | 5 | Maximum number of parallel URL resolutions |
| exclude | (string \| RegExp)[] | — | URL patterns to skip (prefix match or regex)|
All other EmbedOptions (e.g. maxWidth, cache, timeout) are passed through to each resolution.
Options
await embed(url, {
maxWidth: 640, // Max embed width
maxHeight: 480, // Max embed height
fallback: true, // OGP fallback for unknown URLs (default: true)
auth: { // Authentication configuration
meta: { // Required for Facebook/Instagram
accessToken: "APP_ID|CLIENT_TOKEN",
},
},
retry: { // Retry on transient failures (network errors, 5xx, 429)
maxRetries: 2, // default: 2
baseDelay: 500, // default: 500ms, exponential backoff: delay = baseDelay * 2^attempt
},
timeout: 5000, // Request timeout in ms (default: 10000)
sanitize: true, // Sanitize oEmbed HTML to prevent XSS (default: true)
discovery: true, // oEmbed auto-discovery for unknown URLs (default: true)
cache: myCache, // EmbedCache instance (see Caching section)
logger: true, // Enable built-in JSON logger (see Logging section)
});URL validation
All URLs are validated before resolution for security (SSRF protection). The following checks are applied automatically:
- Protocol: Only
httpandhttpsare allowed - Private IPs:
127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16,0.0.0.0,::1are rejected - IPv4-mapped IPv6:
[::ffff:10.0.0.1]etc. are also rejected - Numeric IPs: Decimal (
2130706433), hex (0x7f000001), and octal (0177.0.0.1) representations are normalised and checked - Localhost:
localhostis rejected - URL length: Maximum 2048 characters
Invalid URLs throw an EmbedError with code VALIDATION_ERROR.
Note: URL validation operates on the URL string only and does not perform DNS resolution. Hostnames that resolve to private IPs at runtime (DNS rebinding) are not detected. For full SSRF protection in production, combine this with network-level controls such as egress firewall rules or a DNS-resolving proxy.
You can also use the validation function directly:
import { validateUrl } from "framer-framer";
validateUrl("https://example.com"); // ok
validateUrl("http://127.0.0.1"); // throws EmbedError (VALIDATION_ERROR)
validateUrl("http://2130706433"); // throws (decimal IP = 127.0.0.1)oEmbed auto-discovery
For URLs that don't match any built-in provider, framer-framer automatically looks for <link rel="alternate" type="application/json+oembed"> tags in the page HTML. If found, the oEmbed endpoint is used to resolve the embed — no provider registration required.
Resolution order: Provider match → oEmbed discovery → OGP fallback
Disable with discovery: false:
await embed("https://unknown-site.com/post/123", { discovery: false });You can also use the discovery functions directly:
import { discoverOEmbedUrl, resolveWithDiscovery } from "framer-framer";
// Just find the oEmbed endpoint URL
const oembedUrl = await discoverOEmbedUrl("https://example.com/post");
// Full resolve via discovery (returns undefined if no oEmbed link found)
const result = await resolveWithDiscovery("https://example.com/post");OGP fallback
URLs that don't match any built-in provider and have no oEmbed discovery link are resolved via OGP meta tags automatically. Disable with fallback: false.
const result = await embed("https://example.com/article", { fallback: true });
// Returns link card HTML built from og:title, og:description, og:imageLogging
Structured JSON logging for observability. Logs resolution success/failure, latency, provider, and cache hits.
// Built-in JSON logger (writes to stderr)
await embed(url, { logger: true });
// Custom logger (e.g. pino, winston)
import type { Logger } from "framer-framer";
const myLogger: Logger = {
debug: (entry) => pino.debug(entry),
info: (entry) => pino.info(entry),
warn: (entry) => pino.warn(entry),
error: (entry) => pino.error(entry),
};
await embed(url, { logger: myLogger });Log entries include:
| Field | Type | Description |
|---|---|---|
| level | string | "debug" "info" "warn" "error" |
| message | string | "embed resolved" or "embed failed" |
| timestamp | string | ISO 8601 timestamp |
| url | string | The URL being resolved |
| provider | string | Provider name (e.g. "youtube") |
| latencyMs | number | Resolution time in milliseconds |
| status | string | "provider" "discovery" "ogp_fallback" "cache_hit" "hook_short_circuit" |
Caching
Built-in LRU cache eliminates redundant network calls for repeated URLs.
import { createCache, embed } from "framer-framer";
const cache = createCache({ maxSize: 200, ttl: 60_000 }); // 200 entries, 1 min TTL
const result = await embed("https://www.youtube.com/watch?v=abc", { cache });
// Second call returns instantly from cache — no network request
await embed("https://www.youtube.com/watch?v=abc", { cache });createCache() options:
| Option | Type | Default | Description |
| --------- | -------- | -------- | -------------------------------- |
| maxSize | number | 100 | Maximum number of cached entries |
| ttl | number | 300000 | Time-to-live in milliseconds |
The cache key includes the URL and dimension options (maxWidth, maxHeight), so different option combinations are cached separately.
Set cache: false to explicitly disable caching for a single call when a cache is normally used.
cache.clear(); // remove all cached entriesResponsive wrapper
Wrap embed HTML in a responsive container that maintains aspect ratio using the CSS padding-bottom technique.
import { wrapResponsive } from "framer-framer";
// With known dimensions — aspect ratio is preserved
const html = wrapResponsive('<iframe src="https://www.youtube.com/embed/abc"></iframe>', {
width: 640,
height: 360,
});
// → nested divs with padding-bottom: 56.25% for 16:9 aspect ratio
// Without dimensions — simple width: 100% wrapper
const html = wrapResponsive('<iframe src="..."></iframe>');
// Use CSS class names instead of inline styles
const html = wrapResponsive('<iframe src="..."></iframe>', {
width: 640,
height: 360,
mode: "class",
className: "my-embed", // default: "embed-responsive"
});
// → <div class="my-embed"><div class="my-embed__ratio" style="padding-bottom:56.2500%"><div class="my-embed__inner">...</div></div></div>ResponsiveOptions:
| Option | Type | Default | Description |
| ----------- | -------- | ------------------- | -------------------------------------------------- |
| width | number | — | Embed width (for aspect ratio calculation) |
| height | number | — | Embed height (for aspect ratio calculation) |
| maxWidth | string | "100%" | Maximum width constraint (inline mode only) |
| mode | string | "inline" | "inline" for style attributes, "class" for CSS class names |
| className | string | "embed-responsive" | CSS class name prefix (class mode only) |
Provider query API
Check which providers are registered and whether a URL can be embedded.
import { getProviders, canEmbed } from "framer-framer";
// List all registered providers
const providers = getProviders();
// [{ name: "youtube", patterns: ["^https?:\\/\\/(www\\.)?youtube\\.com\\/watch\\?", ...] }, ...]
// Check if a URL can be resolved by a registered provider
canEmbed("https://www.youtube.com/watch?v=dQw4w9WgXcQ"); // true
canEmbed("https://example.com/page"); // falsecanEmbed() only checks registered providers (built-in + custom). It does not attempt oEmbed auto-discovery or OGP fallback.
ProviderInfo type:
| Field | Type | Description |
| ---------- | ---------- | -------------------------------------------- |
| name | string | Provider name (e.g. "youtube") |
| patterns | string[] | URL regex patterns (as regex source strings) |
Custom providers
import { registerProvider, OEmbedProvider } from "framer-framer";
class DailymotionProvider extends OEmbedProvider {
name = "dailymotion";
protected patterns = [/dailymotion\.com\/video\//];
protected endpoint = "https://www.dailymotion.com/services/oembed";
}
registerProvider(new DailymotionProvider());Hooks
Hooks let you intercept every resolve() call — useful for caching, analytics, HTML wrapping, and more. All resolution paths (embed(), youtube(), etc.) go through hooks.
import { onBeforeResolve, onAfterResolve, clearHooks } from "framer-framer";onBeforeResolve(hook) — runs before resolution
Return an EmbedResult to short-circuit (skip the provider call). Mutate context.url or context.options to alter downstream behavior.
// Cache example
const unsubscribe = onBeforeResolve((context) => {
const cached = cache.get(context.url);
if (cached) return cached; // skip provider, return cached result
});onAfterResolve(hook) — runs after resolution
Observe or transform the result. Return an EmbedResult to replace it.
// Analytics
onAfterResolve((context, result) => {
trackEvent("embed_resolved", { url: context.url, provider: result.provider });
});
// Wrap HTML
onAfterResolve((context, result) => ({
...result,
html: `<div data-embed-url="${context.url}">${result.html}</div>`,
}));Unsubscribe
Both functions return an unsubscribe function to remove the specific hook.
const unsubscribe = onAfterResolve((ctx, result) => { /* ... */ });
unsubscribe(); // removes only this hookclearHooks() — remove all hooks
clearHooks(); // removes all before and after hooksMetrics
Monitor resolution performance with the onMetrics() hook. Each resolution emits a MetricsEvent with provider name, duration, success/failure, cache hit status, and error code.
import { onMetrics, clearMetrics } from "framer-framer";onMetrics(callback) — observe resolution metrics
const unsubscribe = onMetrics((event) => {
console.log(`${event.provider}: ${event.duration}ms (${event.success ? "ok" : event.errorCode})`);
});
// MetricsEvent fields:
// - url: string — resolved URL
// - provider: string — provider name ('youtube', 'ogp', 'discovery', etc.)
// - duration: number — resolution time in ms (0 for cache hits)
// - success: boolean — whether resolution succeeded
// - cacheHit: boolean — whether result was served from cache
// - errorCode?: string — error code if resolution failedUnsubscribe
const unsubscribe = onMetrics((event) => { /* ... */ });
unsubscribe(); // removes only this callbackclearMetrics() — remove all metrics callbacks
clearMetrics();REST API server
framer-framer/server exports a Hono-based REST API app. Requires hono as a peer dependency.
npm install honoBasic usage
import { serve } from "@hono/node-server";
import { createApp } from "framer-framer/server";
const app = createApp();
serve({ fetch: app.fetch, port: 3000 });Endpoints
| Method | Path | Description |
| ------ | -------------- | ------------------------------------ |
| GET | /health | Health check ({ status: "ok" }) |
| GET | /providers | List registered providers |
| GET | /embed | Resolve a URL to embed data |
| POST | /embed/batch | Resolve multiple URLs in one request |
| GET | /metrics | Prometheus-format metrics (requires metrics: true) |
GET /embed query parameters:
| Parameter | Type | Description |
| ----------- | -------- | ------------------------------------ |
| url | string | (required) URL to resolve |
| maxWidth | number | Max embed width |
| maxHeight | number | Max embed height |
| fallback | string | Set to "false" to disable OGP fallback |
| sanitize | string | Set to "false" to disable HTML sanitization |
| discovery | string | Set to "false" to disable oEmbed auto-discovery |
For Facebook/Instagram, pass the Meta access token via the Authorization header:
Authorization: Bearer APP_ID|CLIENT_TOKENPOST /embed/batch request body:
{
"urls": ["https://www.youtube.com/watch?v=dQw4w9WgXcQ", "https://x.com/user/status/123"],
"maxWidth": 640,
"maxHeight": 480
}| Field | Type | Description |
| ----------- | ---------- | ------------------------------------- |
| urls | string[] | (required) URLs to resolve (max 20) |
| maxWidth | number | Max embed width |
| maxHeight | number | Max embed height |
Response:
{
"results": [
{ "type": "video", "html": "<iframe ...>", "provider": "youtube", "url": "..." },
{ "type": "about:blank", "title": "oEmbed API returned 404", "status": 422, "detail": "oEmbed API returned 404", "code": "OEMBED_FETCH_FAILED" }
]
}Each item in results is either an EmbedResult on success or a RFC 7807 Problem Details object on failure. The array order matches the input urls order. Partial failures do not affect other results.
Error responses
All error responses use the RFC 7807 Problem Details format with Content-Type: application/problem+json:
{
"type": "about:blank",
"title": "oEmbed API returned 404",
"status": 422,
"detail": "oEmbed API returned 404",
"code": "OEMBED_FETCH_FAILED",
"instance": "/embed"
}| Field | Type | Description |
| ----- | ---- | ----------- |
| type | string | Problem type URI (always "about:blank") |
| title | string | Short human-readable summary |
| status | number | HTTP status code |
| detail | string | Human-readable explanation |
| code | string | Application-specific error code (see Error codes) |
| instance | string? | Request path that caused the error |
| Status | Code | Description |
| ------ | ---- | ----------- |
| 400 | VALIDATION_ERROR | Missing or invalid url parameter |
| 422 | <EmbedErrorCode> | Resolution failed (see Error codes) |
| 422 | UNKNOWN | Unexpected error without a specific code |
ServerOptions
createApp({
basePath: "/api/v1", // prefix all routes
defaultOptions: { // default EmbedOptions for every request
maxWidth: 640,
fallback: true,
},
rateLimit: { // IP-based rate limiting (omit to disable)
windowMs: 60_000, // time window in ms (default: 60000)
max: 100, // max requests per window per IP (default: 100)
},
metrics: true, // enable GET /metrics endpoint (default: false)
});Metrics
When metrics: true is set, a GET /metrics endpoint is exposed with Prometheus text exposition format (Content-Type: text/plain; version=0.0.4; charset=utf-8).
Available metrics:
| Metric | Type | Labels | Description |
| ------------------------- | ------- | ---------------------------- | ------------------------------------- |
| embed_requests_total | counter | method, path, status | Total number of embed requests |
| embed_errors_total | counter | code | Total number of embed errors |
| embed_duration_seconds | summary | — | Duration of embed resolution |
When rate limiting is enabled, all responses include the following headers:
| Header | Description |
| --------------------- | ---------------------------------------- |
| X-RateLimit-Limit | Maximum requests allowed per window |
| X-RateLimit-Remaining | Remaining requests in the current window |
| X-RateLimit-Reset | Unix timestamp (seconds) when the window resets |
Exceeding the limit returns 429 Too Many Requests with a Retry-After header (seconds until reset).
Using as a sub-app
import { Hono } from "hono";
import { createApp } from "framer-framer/server";
const main = new Hono();
main.route("/oembed", createApp());Enabling CORS
import { cors } from "hono/cors";
import { createApp } from "framer-framer/server";
const app = createApp();
app.use("*", cors({ origin: "https://example.com" }));Migration from v2.x
meta → auth.meta
The meta option has moved under a new auth namespace:
// Before (v2.x) — still works but deprecated
await embed(url, { meta: { accessToken: "APP_ID|CLIENT_TOKEN" } });
// After (v3.x)
await embed(url, { auth: { meta: { accessToken: "APP_ID|CLIENT_TOKEN" } } });resolve() → embed()
resolve() is now deprecated in favour of embed(). Both functions are identical — resolve() will be removed in the next major version.
// Before (v2.x)
import { resolve } from "framer-framer";
const result = await resolve(url);
// After (v3.x)
import { embed } from "framer-framer";
const result = await embed(url);Error handling
All errors thrown by framer-framer are instances of EmbedError, which extends Error with a code property for programmatic error handling.
import { embed, EmbedError } from "framer-framer";
try {
await embed("https://example.com/video");
} catch (err) {
if (err instanceof EmbedError) {
console.log(err.code); // e.g. "OEMBED_FETCH_FAILED"
console.log(err.message); // human-readable description
console.log(err.cause); // original error (if any)
}
}Error codes
| Code | Description |
| --------------------- | ------------------------------------------------ |
| PROVIDER_NOT_FOUND | No provider matched and fallback is disabled |
| OEMBED_FETCH_FAILED | oEmbed API returned a non-OK HTTP status |
| OEMBED_PARSE_ERROR | oEmbed API response could not be parsed as JSON |
| OGP_FETCH_FAILED | OGP fallback: page fetch returned a non-OK status |
| OGP_PARSE_ERROR | OGP fallback: metadata extraction failed |
| VALIDATION_ERROR | Invalid input (e.g. missing Meta access token, unsafe URL) |
| TIMEOUT | Request timed out |
EmbedError also supports toJSON() for structured logging:
console.log(JSON.stringify(err));
// {"name":"EmbedError","code":"OEMBED_FETCH_FAILED","message":"..."}EmbedResult
| Field | Type | Description |
| ----------------- | -------- | --------------------------------- |
| type | string | "rich" "video" "photo" "link" |
| html | string | Embed HTML |
| provider | string | Provider name |
| url | string | Original URL |
| title | string? | Content title |
| author_name | string? | Author name |
| author_url | string? | Author URL |
| thumbnail_url | string? | Thumbnail image URL |
| thumbnail_width | number? | Thumbnail width |
| thumbnail_height| number? | Thumbnail height |
| width | number? | Embed width |
| height | number? | Embed height |
| raw | object? | Raw oEmbed response |
Development
Render check
Visually verify that all providers render correctly in a browser:
node tools/render-check.mjs # build, resolve all providers, serve on :8765
node tools/render-check.mjs --port 3333
node tools/render-check.mjs --no-serve # generate HTML onlyFacebook/Instagram require a Meta access token via env var:
META_ACCESS_TOKEN=APP_ID|CLIENT_TOKEN node tools/render-check.mjsLicense
MIT
