Seed Protocol
@seedprotocol/feed

Overview

What @seedprotocol/feed and @seedprotocol/feed-hyper do, how to install them, the site config, the environment variables they read, and how feed caching works.

@seedprotocol/feed turns the published items of a model into RSS 2.0, Atom and JSON Feed documents, with paging, monthly archives and ready-made HTTP handlers. @seedprotocol/feed-hyper writes the same documents into a Hyperdrive and distributes them peer to peer. Both read items through @seedprotocol/query, so they see every app's published items of a schema, from EAS, with revoked items left out.

import { handleFeedRequest, setSiteConfig } from '@seedprotocol/feed'
import { publishFeed, serveFeed } from '@seedprotocol/feed-hyper'

For a walkthrough, see Serve published items as a feed. This page covers installation, configuration and caching. The API is on createFeed and handlers and Hyper distribution.

Install

npm install @seedprotocol/feed
# Only if you distribute feeds over Hyperswarm:
npm install @seedprotocol/feed-hyper
PackageRuntimeNotes
@seedprotocol/feedNode 20.18.1 or later, or Bun, on a serverUses process.env, node:fs and node:crypto. Not for the browser. Brings @seedprotocol/query, @seedprotocol/eas and @seedprotocol/arweave with it, each at the same exact version
@seedprotocol/feed-hyperNode 20.18.1 or later only (engines: >=20.18.1)Depends on Holepunch native modules (Hyperdrive, Hyperswarm, Corestore), which may crash under Bun. Installs the seed-feed command

Both packages have a single entry point (.) and no subpaths. They ship ESM; the require condition points at the same ESM file.

The type declarations of both packages use relative imports without file extensions. Under "moduleResolution": "nodenext" or "node16" with skipLibCheck on, the affected exports silently become any. Type-check with "moduleResolution": "bundler".

What's in the feed package

AreaExportsWhere
HTTP handlershandleFeedRequest, handleArchiveFeedRequest, resetCacheManagercreateFeed and handlers
GenerationcreateFeed, getFeedItemsBySchemaName, fetchFeedItemsBySchemaName, getFeedItemsBySchemaNameForMonthcreateFeed and handlers
Site configsetSiteConfig, getSiteConfig, resetSiteConfig, resolveSiteConfig, DEFAULT_SITE_CONFIG, loadFeedConfigbelow
SetupinitializeFeedPlatform, teardownFeedPlatform, DEFAULT_ARWEAVE_HOSTbelow
Rich textpickFeedItemContent, pickFeedItemDescription, feedItemRichTextContainsDataUriImage, filterItemsByRichTextDataUriImagePolicycreateFeed and handlers
Reading RSSparseRssStringcreateFeed and handlers
Media helpersclassifyMediaRef, normalizeFeedItemFields, getFeedItemStringField, isEasAttestationExplorerUrl, resolveSeedRssImageRelationRef, resolveSeedRssImageRelationFromItem, SEED_RSS_IMAGE_RELATION_FIELD_KEYS and their typesRe-exported unchanged from @seedprotocol/arweave. resolveMediaRef isn't in either package; it's exported by @seedprotocol/sdk
TypesFeedConfig, FeedFormat, FeedItem, TransformOptions, FeedPaginationOptions, FeedArchiveLink, SiteConfigOverrides, ParsedRssChannelcreateFeed and handlers

To render these fields in a React app, use useNormalizedFeedItemFields and the SeedMedia* components from @seedprotocol/react.

initializeFeedPlatform

Makes item reads work: it keeps the EAS and Arweave clients already registered (by a host app or a running SeedClient) and registers the Node ones for whichever is missing. It's query's initializeQueryPlatform under the feed's name. You don't have to call it: handleFeedRequest, handleArchiveFeedRequest and getFeedItemsBySchemaName call it themselves. Call it at startup only to choose the Arweave gateway.

function initializeFeedPlatform(options?: { arweaveDomain?: string }): Promise<void>
function teardownFeedPlatform(): Promise<void>
OptionTypeDefaultDescription
arweaveDomainstringNEXT_PUBLIC_ARWEAVE_HOST, ARWEAVE_HOST or VITE_ARWEAVE_HOST, else arweave.netGateway host for Arweave content: rich-text bodies, image probing, and the host of the Arweave links in feed items. The default applies only when no Arweave client was registered

The first call wins: later calls return immediately and ignore their options. arweaveDomain, when you pass it, becomes the Arweave host even when a client was already registered, and locks it, so gateway probing won't move reads off it; a host from the environment or the default isn't locked. teardownFeedPlatform() only lets the next call run again; registered clients stay.

Every Arweave link in a feed item, in a link field or inside Html, is written on this public host, because feed readers are outside your app: a local Hyper sidecar address is never used, and a client whose gateway endpoints are resolved uses its arweaveDomain. Links to an item's own storage transaction take the /raw/ form (https://arweave.net/raw/<txId>). A guid keeps the value it was given and isn't moved onto the public host; a stored item's default guid is ar://<txId> (field mapping).

If rich-text bodies come out as gateway URLs instead of HTML, no read gateway returned them (see query's SeedRecord).

setSiteConfig

Sets the channel metadata of every feed this process generates: title, home page, feed URL, author and so on.

function setSiteConfig(overrides: SiteConfigOverrides): FeedConfig

It returns the resolved config. The config is process-wide and synchronous, so call it once at startup.

FeedConfig fields

FieldTypeDefaultUsed for
titlestring'Seed Protocol'Feed title, as "<title> - <Collection>", e.g. My Blog - Posts
descriptionstring'Content published via Seed Protocol'RSS description, Atom subtitle, JSON Feed description
siteUrlstring'https://seedprotocol.io'The channel's home page: RSS link, Atom alternate link, JSON Feed home_page_url. Not item links
feedUrlstring'https://feed.seedprotocol.io'Where the feeds live. Atom and JSON Feed self links (<feedUrl>/posts/atom), and the paging and archive links the handlers build (<feedUrl>/posts/rss?page=2, <feedUrl>/posts/archive/2026/9/rss)
languagestring'en'RSS language
copyrightstring'© <year> All rights reserved'RSS copyright, Atom rights, media:copyright
author{ name: string; email?: string; link?: string }{ name: 'Seed Protocol', email: 'info@seedprotocol.io', link: 'https://seedprotocol.io' }Atom author, JSON Feed author (name, url), RSS webMaster (the email) and the fallback media:credit

<year> in the default copyright is the year the module was loaded.

How overrides merge

  • Each call merges onto Seed Protocol's defaults, not onto your previous call. setSiteConfig({ title: 'A' }) followed by setSiteConfig({ siteUrl: 'https://b.example' }) leaves the title at Seed Protocol.
  • author is merged field by field.

Setting only part of author keeps Seed Protocol's values for the rest. setSiteConfig({ author: { name: 'My Blog' } }) still publishes info@seedprotocol.io as the RSS webMaster and Atom author email, and https://seedprotocol.io as the author link. Set all three fields.

Example

import { setSiteConfig } from '@seedprotocol/feed'

setSiteConfig({
  title: 'My Blog',
  description: 'Posts from my Seed app',
  siteUrl: 'https://blog.example.com',
  feedUrl: 'https://feeds.example.com',
  author: { name: 'My Blog', email: 'editor@example.com', link: 'https://blog.example.com' },
})

getSiteConfig, resetSiteConfig and resolveSiteConfig

function getSiteConfig(): FeedConfig
function resetSiteConfig(): void
function resolveSiteConfig(overrides?: SiteConfigOverrides, base?: FeedConfig): FeedConfig
const DEFAULT_SITE_CONFIG: FeedConfig
  • getSiteConfig() returns a copy of the current process config.
  • resetSiteConfig() restores the defaults.
  • resolveSiteConfig(overrides, base) merges overrides onto base (default: the current process config, not the defaults) with the same field-by-field author merge, without changing anything. This is what the handlers and createFeed do with their siteConfigOverrides argument.
  • DEFAULT_SITE_CONFIG is the default object. Don't mutate it.

SiteConfigOverrides is Partial<FeedConfig> with author also partial.

Environment variables

The variables the feed package reads. Values are read when used, mostly per request, except where noted.

Feed output

VariableDefaultWhat it does
FEED_ITEM_URL_BASEThe EAS explorer of the read chain, e.g. https://optimism-sepolia.easscan.org (https://easscan.org if the chain has none)Base of each item's link and guid when the item has no link of its own
FEED_ITEM_URL_PATHattestation/viewPath between the base and the Seed UID. Applies to the default explorer base too
FEED_PAGE_SIZE25Items per page in handleFeedRequest. A value that isn't a positive integer gives 25
FEED_INCLUDE_DATA_URI_HTML_ITEMSofftrue keeps items whose HTML embeds data:image/...;base64 images. See data:image items

So with FEED_ITEM_URL_BASE=https://blog.example.com and FEED_ITEM_URL_PATH=posts, item links become https://blog.example.com/posts/<seedUid>. siteUrl in the site config never affects item links. The full link rules are under Field mapping.

Caching

VariableDefaultWhat it does
CACHE_ENABLEDOn, except when NODE_ENV=developmentfalse, 0, no or off turns caching off. true, 1 or yes turns it on, including in development
CACHE_TTL3600The longest, in seconds, the first page of each feed is kept without a rebuild; it's checked against query on every request. Also query's cache lifetime
CACHE_MAX_AGE0Seconds in the first page's Cache-Control max-age and s-maxage. Also query's window in which a checked collection is reused with no indexer request, so new posts can reach the first page this many seconds late
CACHE_PAGE_TTL300Seconds ?page=2 and later pages are cached
CACHE_ARCHIVE_TTL86400Seconds monthly archives are cached
CACHE_DIR./cacheWhere cached documents, image metadata and query's cache are written, relative to the working directory

The TTLs (IMAGE_METADATA_TTL too) and CACHE_DIR are read once, when the first request creates the feed's cache manager; call resetCacheManager() to make the next request read them again. Query reads the same variables, and CACHE_MAX_AGE, once per process for its own cache.

Image metadata

Both handlers probe Arweave for the image an item's enclosure uses, to find its type, size and dimensions. For image items that's the item's own storage transaction; for other schemas it's the feature or cover image (Field mapping lists the fields) when its URL is an Arweave gateway URL and the item doesn't already give its size and type. Up to 8 probes run at once. Gateways are tried in order until one answers; only an error or a non-ok status moves on to the next. A result is cached while IMAGE_METADATA_ENABLED is on, even with CACHE_ENABLED=false, including "not an image". When no gateway answers, nothing is cached and the next build probes again; the item keeps the image link it has, typed image/jpeg in RSS. createFeed doesn't probe.

VariableDefaultWhat it does
IMAGE_METADATA_ENABLEDonfalse turns probing off
IMAGE_METADATA_TTL604800 (7 days)Seconds a probe result is cached, in memory and under <CACHE_DIR>/image-metadata/
IMAGE_METADATA_GATEWAYSThe primary Arweave host followed by the read gateways (ARWEAVE_READ_GATEWAYS or the defaults)Comma-separated hosts to probe, in order, after any preferred read gateways. Hosts whose circuit breaker is open are skipped
IMAGE_METADATA_TIMEOUT5000Milliseconds per gateway request

Inherited from query, eas and arweave

Item reads also follow the chain, indexer and Arweave gateway variables (EAS_CHAIN_ID, EAS_ENDPOINT, ARWEAVE_HOST, ARWEAVE_READ_GATEWAYS and their aliases) and NODE_ENV, listed on @seedprotocol/query. Two of them do more here: EAS_CHAIN_ID also picks the explorer that item links default to, and ARWEAVE_READ_GATEWAYS also goes into the default image probe list.

@seedprotocol/feed-hyper reads no environment variables of its own. seed-feed publish and publishFeed generate through the feed package, so everything above applies to them.

loadFeedConfig

Returns the feed settings the environment variables above resolve to. Useful for logging your configuration at startup.

function loadFeedConfig(): {
  itemUrlBase: string
  itemUrlPath: string
  pageSize: number
  richTextDataUriImages: 'omit_items' | 'include_items'
}

Caching

There are two caches, both on by default and both under CACHE_DIR:

  1. Feed documents (this package). handleFeedRequest and handleArchiveFeedRequest store each generated document in memory and as a JSON file (post-rss.json, post-rss-page-2.json, post-atom-archive-2026-9.json and so on), keyed by schema, format and page or archive month. The first page is checked on every request: the handler asks query for the page's items and rebuilds the document only when query's collection changed. Later pages and archives are served as stored until their TTL runs out. createFeed doesn't cache.
  2. Assembled items (@seedprotocol/query), under collections/ and items/. When the feed reads items, query asks EAS what changed and only re-assembles changed items. See query caching.

What that means for a request to /posts/rss:

  • Hit: the stored document, with X-Cache: HIT. If the request's If-None-Match matches the stored ETag, weak (W/"…") or not, a 304 with no body. For the first page a hit still costs one round of indexer requests, the check that query's collection is unchanged, unless query checked it less than CACHE_MAX_AGE seconds ago.
  • Miss or expired: items are read through query, the document is generated, stored, and returned with X-Cache: MISS.
  • Cache off: every request generates the document. Responses carry an ETag and Last-Modified, but a matching If-None-Match still gets a 200.

The first page's ETag comes from query's collection ETag, the site config and the cacheBust value, so it stays the same across rebuilds of an unchanged feed. Later pages' and archives' come from the document.

A new post reaches the first page on the next request (or after CACHE_MAX_AGE seconds, if you set it), but it can take up to CACHE_PAGE_TTL seconds to shift later pages and CACHE_ARCHIVE_TTL to reach an archive already cached. To refresh those sooner, delete the files in CACHE_DIR and restart. resetCacheManager() alone isn't enough: it drops the in-memory copies, but the next request finds the files on disk.

A cached document is served only for the site config and cacheBust value it was built with, but the cache key is still only schema, format and page or month. Serving two sites from one process with different siteConfigOverrides makes each site's request overwrite the other's entry, so every request is a miss, and when generation fails there's often no stale document for the site that asked. If you serve several sites, run one process per site, set CACHE_ENABLED=false, or build the documents with createFeed and cache them yourself.

Other things to know:

  • Cache-Control is public, max-age=<n>, s-maxage=<n>, must-revalidate, with or without the cache: CACHE_MAX_AGE (default 0) for the first page, 3600 for later pages and 86400 for archives, whatever the TTLs are.
  • When generation fails (the indexer is down, say), the handlers serve a cached document that hasn't expired and was built for the same site config, with X-Cache: STALE and Warning: 110 - "Response is stale". Since 0.7.12 each entry records its site config, so an entry cached before the upgrade isn't served stale until it has been rebuilt. An expired entry is deleted when it's read, so with nothing unexpired the response is a 500. Later pages and archives serve an unexpired entry as a hit before generation starts, so for them a failure usually ends in the 500.
  • Feed and query share CACHE_DIR. Add it to .gitignore.
  • With the cache off, the handlers log Cache is DISABLED once, when the first request creates the cache manager. Per-request logs (hits, misses, item counts, image probes) print only with DEBUG=seedFeed.

On this page