[ Feeds & publishing ]
@sdxc/feed
One feed API over RSS, Atom and JSON Feed, with conditional fetching and autodiscovery
- Used by
- reader
- Source
- packages/feed
One feed API over RSS, Atom and JSON Feed, with conditional fetching and autodiscovery.
Feed sniffs a syndication feed, reads it as RSS 2.0, Atom 1.0 or JSON Feed 1.1, and normalizes all
three into one shape. It also fetches a feed with conditional requests, so polling an unchanged
feed costs a 304, and discovers the feed a site's page advertises. Reach for
@sdxc/rss,
@sdxc/atom or
@sdxc/json-feed when you need a format's own
vocabulary.
Installation
npm add @sdxc/feed
Every entry point answers a Result from @sdxc/result;
Feed.fromXML takes a document from @sdxc/xml.
Usage
Parse feed text
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;
feed.format; // "rss" | "atom" | "json"
for (let item of feed.items) console.log(item.title, item.url, item.publishedAt);
url is the base relative links resolve against and the fallback for feedUrl.
Fetch a feed, sending the validators you stored
import { Feed } from "@sdxc/feed";
import { isFailure } from "@sdxc/result";
let result = await Feed.fetch("https://example.com/feed.xml", {
etag: stored.etag,
lastModified: stored.lastModified,
});
if (isFailure(result)) throw result.error;
if (result.data.notModified) {
// 304: nothing was downloaded or parsed
} else {
console.log(result.data.feed.items.length);
}
stored = { etag: result.data.etag, lastModified: result.data.lastModified };
notModified narrows feed: undefined on a 304, a Feed otherwise. The validators you pass
in carry forward when a 304 omits them, so storing them from every response is always safe.
Discover a feed from a site
import { Feed } from "@sdxc/feed";
import { isFailure } from "@sdxc/result";
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 answers itself from that one request, so this takes whatever someone pasted, site or feed.
API
Feed.parse(source: string, options?: Feed.ParseOptions): Result<Feed, FeedParseError | FeedFormatError>
Parses feed text in any of the three formats. Text that opens a JSON object reads as JSON Feed,
anything else as XML; the format comes from the document, never from a Content-Type.
Feed.fromXML(xml: XML, options?: Feed.ParseOptions)
Reads a feed from an already-parsed @sdxc/xml document, answering the same Result as parse.
Feed.fromJSON(value: unknown, options?: Feed.ParseOptions)
Reads a feed from an already-parsed JSON value, answering the same Result as parse.
Feed.fetch(input: string | URL, options?: Feed.FetchOptions): Promise<Result<Feed.FetchResult, FeedFetchError | FeedParseError | FeedFormatError>>
Retrieves and parses a feed. Options:
etag,lastModified: sent asIf-None-MatchandIf-Modified-Since.url: the base for relative links, in place of the URL the response came from.headers,signal: passed to the request.maxBytes: the body size it reads before refusing withFeedLimitError; 10 MiB by default.maxRedirects: the redirects it follows before refusing withFeedLimitError; 5 by default.hosts:"public"(default) lets the URL and every redirect reach only publichttp:andhttps:hosts without credentials, refusing private addresses and reserved names such aslocalhost;"any"allows your own network.
The request carries no cache directive, so the origin is free to answer 304. Any content type is accepted.
Feed.discover(input: string | URL, options?: Feed.FetchOptions): Promise<Result<Feed.Discovery[], FeedFetchError>>
Finds the feeds a URL leads to: the URL itself when it is a feed, otherwise the page's
<link rel="alternate"> elements typed application/rss+xml, application/atom+xml,
application/feed+json or application/json, in document order (the first is conventionally
the main feed). application/json links drop out when the page also offers
application/feed+json. Each feed is reported at the URL its response finally came from, and
the request options and limits match fetch.
Feed.selectHub(header?: Feed.Link[], document?: Feed.Link[]): Feed.Hub | undefined
Chooses the WebSub hub to subscribe to from the response's Link header relations and the
document's. The header wins over the document, the first rel=hub wins within each, and only an
https: hub qualifies.
Feed.selectSubscription(header?: Feed.Link[], document?: Feed.Link[]): Feed.Subscription | undefined
Chooses the hub as selectHub does and pairs it with the topic: the rel=self declared in the
same place as the hub, else the document's, else null.
new Feed(data: Feed.Data)
Wraps normalized data, such as a stored toJSON() result.
feed accessors
format, title, description, siteUrl, feedUrl, language, imageUrl, updatedAt,
items and links; toJSON() answers the whole feed as Feed.Data. links holds every
relation the document declared, rel lower-cased and href resolved, whatever the format.
Normalized fields
| 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 |
Every URL is absolute. Dates are Date objects, and an unparseable one reads as undefined.
Items keep document order, deduplicated by guid with the first occurrence kept; every item has
a guid. author is the first of authors. contentHtml and summary hold the publisher's
markup unsanitized, and contentText is plain text that needs escaping before it goes into HTML.
Errors
FeedParseError: malformed JSON or XML, or a JSON Feed whose contents fail validation.FeedFormatError: a well-formed document that is no feed this package reads, such as an HTML page or RSS 1.0 (RDF), named in the message.FeedFetchError: a failed request, an error status, or a URL or redirect thehostspolicy refuses (no request is sent for it).FeedLimitError: aFeedFetchErrorfor a body pastmaxBytesor a chain pastmaxRedirects.
Types
Feed.Data is the whole feed and Feed.Item one post, with Feed.Author and Feed.Enclosure
inside it; Feed.Format is "rss" | "atom" | "json". Feed.Link is one declared relation,
Feed.Hub and Feed.Subscription what the hub selectors answer. Feed.ParseOptions and
Feed.FetchOptions are the options above. Feed.FetchResult is Feed.Fetched (notModified: false, feed) or Feed.NotModified (notModified: true, status: 304), both with the final
url, status, etag, lastModified and the response's Link header relations as links.
Feed.Discovery is { url, type, title? }.
Pattern: Subscribe From Whatever Someone Pasted
Discover the feed behind a site or feed URL, fetch it once, and keep its address and validators for the next poll.
import { Feed } from "@sdxc/feed";
import { isFailure } from "@sdxc/result";
async function subscribe(pasted: string) {
let found = await Feed.discover(pasted);
if (isFailure(found)) return found;
let [first] = found.data;
if (!first) return { status: "no-feed" as const };
let fetched = await Feed.fetch(first.url);
if (isFailure(fetched) || fetched.data.notModified) return fetched;
let { feed, url, etag, lastModified } = fetched.data;
return {
status: "subscribed" as const,
subscription: { url, title: feed.title, siteUrl: feed.siteUrl, etag, lastModified },
items: feed.items,
};
}
Pattern: Poll Feeds On A Schedule
Send back the stored validators on every poll; an unchanged feed answers 304 and costs no
parsing, and new items are the ones whose guid you have not seen.
import { Feed } from "@sdxc/feed";
import { isFailure } from "@sdxc/result";
interface Subscription {
url: string;
etag?: string;
lastModified?: string;
seen: Set<string>;
}
async function poll(subscription: Subscription): Promise<Feed.Item[]> {
let result = await Feed.fetch(subscription.url, {
etag: subscription.etag,
lastModified: subscription.lastModified,
signal: AbortSignal.timeout(10_000),
});
if (isFailure(result)) return [];
subscription.url = result.data.url;
subscription.etag = result.data.etag;
subscription.lastModified = result.data.lastModified;
if (result.data.notModified) return [];
let fresh = result.data.feed.items.filter((item) => !subscription.seen.has(item.guid));
for (let item of fresh) subscription.seen.add(item.guid);
return fresh;
}
Pattern: Tell Failures Apart
Each error class names who is at fault, so a poller can back off a broken origin, flag an oversized feed, and tell the user the URL serves no feed.
import { Feed, FeedFetchError, FeedFormatError, FeedLimitError, FeedParseError } from "@sdxc/feed";
import { isFailure } from "@sdxc/result";
let result = await Feed.fetch(url, { maxBytes: 2 * 1024 * 1024, maxRedirects: 3 });
if (isFailure(result)) {
let error = result.error;
if (error instanceof FeedLimitError) {
// the origin answered with more than allowed: too large or too many redirects
} else if (error instanceof FeedFetchError) {
// unreachable, an error status, or a host the policy refuses: retry later
} else if (error instanceof FeedFormatError) {
// a document that is no feed this package reads, such as a web page or RSS 1.0
} else if (error instanceof FeedParseError) {
// malformed JSON or XML
}
}
Pattern: Subscribe To A WebSub Hub
Read hubs from both the response's Link header and the document, then subscribe under the
topic declared beside the chosen hub.
import { Feed } from "@sdxc/feed";
import { randomToken } from "@sdxc/crypto";
import { isFailure } from "@sdxc/result";
let fetched = await Feed.fetch(feedUrl);
if (!isFailure(fetched) && !fetched.data.notModified) {
let subscription = Feed.selectSubscription(fetched.data.links, fetched.data.feed.links);
if (subscription) {
await fetch(subscription.hub, {
method: "POST",
body: new URLSearchParams({
"hub.mode": "subscribe",
"hub.topic": subscription.topic ?? fetched.data.url,
"hub.callback": "https://reader.example.com/websub",
"hub.secret": randomToken(),
}),
});
}
}
Feed.selectHub answers the hub alone when you only need its address.
Pattern: Parse A Document You Already Hold, And Store It
Hand an already-parsed XML document or JSON value to the reader directly, and keep the normalized result as plain data to wrap again later.
import { Feed } from "@sdxc/feed";
import { isFailure } from "@sdxc/result";
import { XML } from "@sdxc/xml";
let xml = XML.parse(text);
if (isFailure(xml)) throw xml.error;
let feed = Feed.fromXML(xml.data, { url: "https://example.com/feed.xml" });
if (isFailure(feed)) throw feed.error;
let stored = JSON.stringify(feed.data.toJSON());
let restored = new Feed(JSON.parse(stored));
restored.title;
let fromJson = Feed.fromJSON(await response.json(), { url: response.url });
Dates round-trip through JSON.stringify as strings, so revive them before wrapping when you
read publishedAt or updatedAt from a stored copy.
Pattern: Read Feeds On Your Own Network
The default host policy refuses private addresses and names such as localhost; pass
hosts: "any" for a feed served inside your own network, along with any headers it needs.
import { Feed } from "@sdxc/feed";
let result = await Feed.fetch("http://intranet.local/news.xml", {
hosts: "any",
headers: { Authorization: `Bearer ${token}` },
});