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

@sdxc/feed

v2026.10.2

Published

One feed API over RSS, Atom and JSON Feed, with conditional fetching and autodiscovery

Readme

@sdxc/feed

One feed API over RSS, Atom and JSON Feed, with conditional fetching and autodiscovery.

A feed reader is handed a URL by someone who neither knows nor cares which syndication format is behind it. This package closes that gap: it sniffs a document, parses it as RSS 2.0, Atom 1.0 or JSON Feed 1.1, and normalizes all three into one shape.

It also owns the two capabilities that are about fetching a feed rather than about either format, and so belong to neither parser:

  • Conditional requests. Feed.fetch sends the validators you stored and reports a 304 without parsing anything, so polling an unchanged feed costs almost nothing.
  • Autodiscovery. Feed.discover follows a page's <link rel="alternate"> to the feed it advertises, so someone can paste example.com instead of example.com/feed.xml.

The normalized shape is deliberately lossy. Reach past it to @sdxc/rss, @sdxc/atom or @sdxc/json-feed when you need a format's own vocabulary.

Installation

npm add @sdxc/feed

The three parsers, the @sdxc/xml layer beneath the two XML ones, and the Result every entry point returns, from @sdxc/result, all install alongside this package.

Usage

Read A Feed

import { Feed } from "@sdxc/feed";
import { isFailure } from "@sdxc/result";

let result = Feed.parse(source, { url: "https://example.com/feed.xml" });
if (isFailure(result)) throw result.error;

let feed = result.data;
console.log(feed.format); // "rss" | "atom" | "json"
console.log(feed.title, feed.siteUrl);

for (let item of feed.items) {
	console.log(item.title, item.publishedAt);
}

Passing url matters: it is the base relative links resolve against, and the fallback for feedUrl.

Poll A Feed Without Re-downloading It

let result = await Feed.fetch(feed.feedUrl, {
	etag: stored.etag,
	lastModified: stored.lastModified,
});

if (isFailure(result)) return recordFailure(result.error);

if (result.data.notModified) {
	await touch(feed.id);
	return;
}

await save(result.data.feed.items);
await storeValidators(feed.id, result.data.etag, result.data.lastModified);

notModified discriminates the union, so feed is undefined on that branch and defined on the other with no optional check.

Store etag and lastModified from every response and send them back on the next poll. A 304 is allowed to omit them, so whatever you passed in is carried forward rather than dropped.

Discover A Feed From A Site

let result = await Feed.discover("https://example.com");
if (isFailure(result)) throw result.error;

let [main] = result.data;
// { url: "https://example.com/feed.xml", type: "application/rss+xml", title: "Main" }

A URL that is already a feed resolves from that one request, so this is safe to call whether someone pasted a site or a feed.

API

Feed.parse(source: string, options?: Feed.ParseOptions)

Parses feed text in any of the three formats. Text that opens a JSON object is read as JSON Feed, and anything else as XML.

  • options.url — the document's own URL: the base for relative links, and the feedUrl fallback.

Feed.fromXML(xml: XML, options?: Feed.ParseOptions)

Reads a feed from an already-parsed XML document, for a caller that parsed the text for some other purpose first.

Feed.fromJSON(value: unknown, options?: Feed.ParseOptions)

Reads a feed from an already-parsed JSON value, for the same reason.

Feed.fetch(input: string | URL, options?: Feed.FetchOptions)

Retrieves and parses a feed, sending the stored validators as preconditions.

  • options.etag / options.lastModified — sent as If-None-Match / If-Modified-Since
  • options.url — overrides the response URL as the base for relative links
  • options.headers, options.signal — passed through to the request
  • options.maxBytes — how many bytes of the body to read before refusing it; 10 MiB by default
  • options.maxRedirects — how many redirects to follow before refusing the chain; five by default

Resolves to a Feed.FetchResult: { notModified: false, feed, url, status, etag?, lastModified?, links? }, or { notModified: true, feed: undefined, url, status: 304, etag?, lastModified?, links? }. links carries the relations the response's own Link header declared, which is where a publisher who cannot edit their document still declares one.

Feed.selectHub(header?: Feed.Link[], document?: Feed.Link[])

Chooses the push endpoint to subscribe to, as { url, source } where source is header or document. The header's hub wins over the document's, the first rel=hub wins within each, and a hub reached over anything but https: is passed over, since a subscription carries a shared secret in a request body.

let fetched = await Feed.fetch(url);
if (!isFailure(fetched) && !fetched.data.notModified) {
	let hub = Feed.selectHub(fetched.data.links, fetched.data.feed.links);
}

Feed.selectSubscription(header?: Feed.Link[], document?: Feed.Link[])

Chooses the hub the way selectHub does and pairs it with the topic to subscribe under, as { hub, topic, source }. The topic is the rel=self declared in the same place as the hub, falling back to the document's when the header named a hub alone, and null when neither declares one, so a header-advertised hub is never subscribed under a topic the header contradicts.

let subscription = Feed.selectSubscription(fetched.data.links, fetched.data.feed.links);
if (subscription) {
	let topic = subscription.topic ?? url;
}

Feed.discover(input: string | URL, options?: Feed.FetchOptions)

Finds the feeds a URL leads to. Resolves to Feed.Discovery[] in document order, since the first alternate link is conventionally the site's main feed. Each feed is reported at the URL its response finally came from, so a caller that keys a feed by address stores where the chain ended rather than where it started.

Instance Accessors

format, title, description, siteUrl, feedUrl, language, imageUrl, updatedAt, items, links, and toJSON().

links is every relation the document declared, in document order, with each rel lower-cased and each href resolved: an Atom <link>, an RSS <atom:link>, and a JSON Feed's feed_url and hubs all arrive in the one shape. feedUrl keeps deriving from rel=self, so a caller that only wants the address reads that instead.

Errors

FeedParseError reports text that is not XML, or XML that is not a feed. FeedFormatError reports a document in a format this package does not read, naming it. FeedFetchError reports a request that failed or answered with an error status. FeedLimitError extends it and reports an origin that answered with more than the retrieval allows, so matching on FeedFetchError still catches it and matching on FeedLimitError tells a publisher this package refused from one it could not reach.

Types

Feed.Format, Feed.Item, Feed.Author, Feed.Enclosure, Feed.Link, Feed.Hub, Feed.Data, Feed.ParseOptions, Feed.FetchOptions, Feed.FetchResult, and Feed.Discovery.

The Normalized Shape

| Field | RSS 2.0 | Atom 1.0 | JSON Feed 1.1 | | ------------------ | ------------------------------------------------------- | -------------------------------------------------------------- | --------------------------------- | | title | channel.title | feed.title | title | | description | channel.description | feed.subtitle | description | | siteUrl | channel.link | alternate link, else feed.id when it is a URL | home_page_url | | feedUrl | atom:link[rel=self], else the request URL | link[rel=self], else the request URL | feed_url, else the request URL | | language | channel.language | xml:lang on the feed | language | | imageUrl | channel.image.url | feed.logo, else feed.icon | icon, else favicon | | updatedAt | lastBuildDate, else pubDate | feed.updated | the newest item date | | links | atom:link elements | link elements | feed_url and hubs | | item.guid | guid, else link, else the title | entry.id, else the alternate link | id | | item.title | title | entry.title | title | | item.url | link, else a permalink guid | alternate link | url, else id when it is a URL | | item.contentHtml | content:encoded, else description | content when inline | content_html | | item.contentText | — | — | content_text | | item.summary | description, when content:encoded supplied the body | entry.summary | summary | | item.author | author, else dc:creator, else managingEditor | entry.author, else entry.source.author, else feed.author | authors, else the feed's | | item.categories | category values | entry.category, preferring label over term | tags | | item.enclosures | enclosure | link[rel=enclosure] | attachments | | item.publishedAt | pubDate | published, else updated | date_published |

Cross-cutting rules:

  • Every URL resolves against options.url when it is relative.
  • Dates are Date objects. An unparseable date reads as undefined rather than an Invalid Date, so a consumer that stores or formats one never has to check.
  • Items keep document order and are deduplicated by guid, first occurrence winning.
  • An item's guid is always present, falling back through the chain above so an item with no identity of its own still has a stable one. A JSON Feed item always carries one, since the format has its parser discard an item without.
  • author is the first of authors, which a format writing only one still fills.

Notes

  1. Nothing is sanitized. contentHtml and summary hold exactly what the publisher wrote. Escaping or sanitizing them is the responsibility of whatever renders them. contentText is plain text, so rendering it as markup means escaping it first.
  2. Format is sniffed from the document, never from Content-Type. Feeds are served as text/xml, application/octet-stream, and worse, so the header is not evidence. Feed.fetch accepts any content type for the same reason. Text that opens a JSON object is read as JSON Feed, and its version URL is what confirms it is one.
  3. Feed.fetch sets no cache directive. Asking an origin for a fresh copy is precisely what stops it answering 304, which would defeat the preconditions being sent.
  4. RSS 1.0 (RDF) is not supported, and is reported by name rather than as a parse failure.
  5. Every retrieval is bounded. A feed URL comes from whoever pasted it, so Feed.fetch and Feed.discover read the body off the stream and stop at maxBytes, and follow at most maxRedirects hops. A Content-Length over the cap is refused before the body is read at all, and the count over the stream is what enforces the cap when a response declares no length or understates it. Both report a FeedLimitError.
  6. Discovery accepts application/rss+xml, application/atom+xml, application/feed+json and application/json. text/xml and application/xml are excluded deliberately: they appear on sitemaps and stylesheets, and accepting them would offer documents that are not feeds. Among the JSON candidates, application/feed+json wins outright, and application/json stands in only when a page names no better-typed feed.

Versioning

Releases are dated rather than semantic. A version is the UTC date it was published, written YYYY.M.D, so 2026.9.4 is the release from 4 September 2026. At most one release goes out per day.

Those numbers say when, not what: a later date means a later release and carries no compatibility promise. Any release may change or remove an export.

Depend on one exact date, and move it when you are ready to take the change:

{
	"dependencies": {
		"@sdxc/feed": "2026.9.4"
	}
}

A caret or tilde range reads the date as major, minor and patch, so it accepts every later release in the same year. An exact version keeps the upgrade yours to schedule.

License

MIT

Author

Sergio Xalambrí