sdxc

[ Feeds & publishing ]

@sdxc/rss

RSS 2.0 feed builder and parser

npm add @sdxc/rss
pnpm add @sdxc/rss
yarn add @sdxc/rss
bun add @sdxc/rss
Used by
blog

RSS 2.0 feed builder and parser.

It writes feeds for publishing and reads feeds from other sites, covering every standard channel and item field plus atom:link, content:encoded, dc:creator and slash:comments. Any other namespaced element round-trips through extensions, so a feed survives a read and a write unchanged.

Installation

npm add @sdxc/rss

RSS.fromXML takes a document from @sdxc/xml, which installs alongside this package.

Usage

Build a feed

import { RSS } from "@sdxc/rss";

let feed = new RSS({
	title: "My Blog",
	description: "Articles about web development",
	link: "https://example.com",
	language: "en-us",
	atomLink: { href: "https://example.com/feed.xml", rel: "self", type: "application/rss+xml" },
});

feed.addItem({
	guid: { value: "tag:example.com,2026:post-1", isPermaLink: false },
	title: "Hello World",
	description: "A short summary",
	link: "https://example.com/posts/hello-world",
	pubDate: new Date().toUTCString(),
	contentEncoded: "<p>Full post content</p>",
});

let xml = feed.toString();

toString() declares the namespaces the extension fields use, so this feed carries xmlns:atom and xmlns:content on its root. Dates are the strings the document holds; write them in RFC 822 form, which is what Date.prototype.toUTCString() produces.

Read a feed

import { RSS } from "@sdxc/rss";

let feed = RSS.parse(xml);

console.log(feed.channel.title);
for (let item of feed.items) console.log(item.title, item.link);

RSS.parse throws when the text is not XML or not an RSS 2.0 document, or when the channel or an item lacks its required fields.

Fetch a feed

import { RSS } from "@sdxc/rss";

let feed = await RSS.fetch(new URL("https://example.com/feed.xml"));

RSS.fetch requests a fresh copy and throws unless the response is ok and has an XML content type.

API

new RSS(channel: RSS.Channel)

A feed with the given channel and no items. Throws when title, description or link is missing.

rss.channel

The channel, as a clone. Assigning a new RSS.Channel validates and replaces it, keeping the items.

feed.channel = { ...feed.channel, lastBuildDate: new Date().toUTCString() };

rss.items

The items, as clones, in the order they were added.

rss.addItem(item: RSS.Item): void

Appends an item. Throws when it has neither a title nor a description.

rss.removeItem(guid: string): void

Removes the first item whose guid value matches.

rss.toJSON(): RSS.Feed

The feed as plain serializable data, { channel, items }.

rss.toString(): string

The feed as RSS 2.0 XML with a UTF-8 declaration. description and contentEncoded are written as text, so embedded HTML is escaped on the way out and unescaped when parsed back.

RSS.parse(source: string): RSS

Parses RSS 2.0 XML, throwing on anything else.

RSS.fromXML(xml: XML): RSS

Reads a feed from a document already parsed with @sdxc/xml, throwing as RSS.parse does when it is not RSS 2.0.

RSS.fetch(input: URL | RequestInfo, init?: RequestInit): Promise<RSS>

Fetches with cache-control: no-cache, no-store added to init.headers, then parses the body.

Types

All types live on the RSS namespace. Every structured type also accepts attributes and extensions for data beyond the standard fields.

RSS.Channel

title, description and link, plus the optional RSS 2.0 fields language, copyright, managingEditor, webMaster, pubDate, lastBuildDate, category, generator, docs, cloud, ttl, image, rating, textInput, skipHours and skipDays; the extensions atomLink and dcCreator; and namespaces, a prefix-to-URI map declared on the root.

RSS.Item

At least one of title or description, plus link, author, category, comments, enclosure, guid, pubDate and source, and the extensions contentEncoded, atomLink, dcCreator and slashComments. RSS.ItemBase is the same shape with every field optional.

RSS.Feed

{ channel: RSS.Channel; items: RSS.Item[] }, the shape toJSON() returns.

RSS.Element and RSS.Node

An extension element: name (prefixed, such as media:rating), optional attributes, and children that are strings or further elements.

Field types

RSS.Guid, RSS.Category, RSS.Enclosure, RSS.AtomLink, RSS.Source, RSS.Image, RSS.Cloud and RSS.TextInput mirror their RSS elements. guid and category take a bare string (RSS.GuidInput, RSS.CategoryInput) or the object form; enclosure and atomLink take one object or an array (RSS.EnclosureInput, RSS.AtomLinkInput); dcCreator takes a string or an array.

Pattern: Serve a feed from a request handler

Build the feed from your records on each request and answer it with the RSS media type.

import { RSS } from "@sdxc/rss";

interface Post {
	slug: string;
	title: string;
	excerpt: string;
	html: string;
	author: string;
	tags: string[];
	publishedAt: Date;
}

export function feedResponse(posts: Post[]): Response {
	let feed = new RSS({
		title: "Example Blog",
		description: "Notes on building for the web",
		link: "https://example.com",
		lastBuildDate: new Date().toUTCString(),
		atomLink: { href: "https://example.com/feed.xml", rel: "self", type: "application/rss+xml" },
	});

	for (let post of posts) {
		let url = `https://example.com/posts/${post.slug}`;
		feed.addItem({
			guid: url,
			title: post.title,
			link: url,
			description: post.excerpt,
			contentEncoded: post.html,
			dcCreator: post.author,
			category: post.tags,
			pubDate: post.publishedAt.toUTCString(),
		});
	}

	return new Response(feed.toString(), {
		headers: { "content-type": "application/rss+xml; charset=utf-8" },
	});
}

Pattern: Publish a podcast with a custom namespace

Enclosures carry the audio; any module without a named field, such as iTunes tags, goes in extensions with its prefix declared on channel.namespaces, since a prefix needs a declaration in scope to serialize.

import { RSS } from "@sdxc/rss";

let podcast = new RSS({
	title: "Example Podcast",
	description: "Conversations about software",
	link: "https://example.com/podcast",
	image: {
		url: "https://example.com/cover.png",
		title: "Example Podcast",
		link: "https://example.com/podcast",
	},
	namespaces: { itunes: "http://www.itunes.com/dtds/podcast-1.0.dtd" },
	extensions: [
		{ name: "itunes:author", children: ["Example Studio"] },
		{ name: "itunes:category", attributes: { text: "Technology" } },
	],
});

podcast.addItem({
	guid: { value: "episode-1", isPermaLink: false },
	title: "Episode 1",
	description: "Where it all starts",
	pubDate: "Tue, 14 Apr 2026 09:00:00 GMT",
	enclosure: { url: "https://example.com/ep1.mp3", length: 24_986_239, type: "audio/mpeg" },
	extensions: [{ name: "itunes:duration", children: ["00:42:10"] }],
});

let xml = podcast.toString();

Pattern: Edit a published feed

Parse an existing document, change it, and write it back; unknown elements and their namespace declarations come through untouched.

import { RSS } from "@sdxc/rss";

let feed = RSS.parse(existingXml);

feed.removeItem("episode-1");
feed.channel = { ...feed.channel, lastBuildDate: new Date().toUTCString() };

let updated = feed.toString();

Pattern: Aggregate several feeds

Fetch in parallel, keep the feeds that parse, and merge their items newest first.

import { RSS } from "@sdxc/rss";

let sources = ["https://example.com/feed.xml", "https://example.org/rss"];

let results = await Promise.allSettled(sources.map((source) => RSS.fetch(new URL(source))));

let items = results
	.filter((result) => result.status === "fulfilled")
	.flatMap((result) => result.value.items)
	.sort((a, b) => Date.parse(b.pubDate ?? "") - Date.parse(a.pubDate ?? ""));

let failed = results.filter((result) => result.status === "rejected").length;

Markup in description and contentEncoded is exactly what each publisher wrote; sanitize it before rendering it in your own pages.

Pattern: Detect the feed format before reading it

Parse the text once with @sdxc/xml, look at the root element, and hand RSS documents to RSS.fromXML.

import { isFailure } from "@sdxc/result";
import { RSS } from "@sdxc/rss";
import { XML } from "@sdxc/xml";

export function readFeed(text: string): RSS | null {
	let parsed = XML.parse(text);
	if (isFailure(parsed)) return null;
	if (parsed.data.root.name !== "rss") return null;
	return RSS.fromXML(parsed.data);
}

XML.parse answers a Result from @sdxc/result.

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.