npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

Readme

framer-framer

CI

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-framer

Requires 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 preserved

HTML 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 preserved

ExpandOptions

| 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 http and https are 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, ::1 are 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: localhost is 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:image

Logging

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 entries

Responsive 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");                      // false

canEmbed() 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 hook

clearHooks() — remove all hooks

clearHooks(); // removes all before and after hooks

Metrics

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 failed

Unsubscribe

const unsubscribe = onMetrics((event) => { /* ... */ });
unsubscribe(); // removes only this callback

clearMetrics() — 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 hono

Basic 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_TOKEN

POST /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

metaauth.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 only

Facebook/Instagram require a Meta access token via env var:

META_ACCESS_TOKEN=APP_ID|CLIENT_TOKEN node tools/render-check.mjs

License

MIT