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| Package | Runtime | Notes |
|---|---|---|
@seedprotocol/feed | Node 20.18.1 or later, or Bun, on a server | Uses 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-hyper | Node 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
| Area | Exports | Where |
|---|---|---|
| HTTP handlers | handleFeedRequest, handleArchiveFeedRequest, resetCacheManager | createFeed and handlers |
| Generation | createFeed, getFeedItemsBySchemaName, fetchFeedItemsBySchemaName, getFeedItemsBySchemaNameForMonth | createFeed and handlers |
| Site config | setSiteConfig, getSiteConfig, resetSiteConfig, resolveSiteConfig, DEFAULT_SITE_CONFIG, loadFeedConfig | below |
| Setup | initializeFeedPlatform, teardownFeedPlatform, DEFAULT_ARWEAVE_HOST | below |
| Rich text | pickFeedItemContent, pickFeedItemDescription, feedItemRichTextContainsDataUriImage, filterItemsByRichTextDataUriImagePolicy | createFeed and handlers |
| Reading RSS | parseRssString | createFeed and handlers |
| Media helpers | classifyMediaRef, normalizeFeedItemFields, getFeedItemStringField, isEasAttestationExplorerUrl, resolveSeedRssImageRelationRef, resolveSeedRssImageRelationFromItem, SEED_RSS_IMAGE_RELATION_FIELD_KEYS and their types | Re-exported unchanged from @seedprotocol/arweave. resolveMediaRef isn't in either package; it's exported by @seedprotocol/sdk |
| Types | FeedConfig, FeedFormat, FeedItem, TransformOptions, FeedPaginationOptions, FeedArchiveLink, SiteConfigOverrides, ParsedRssChannel | createFeed 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>| Option | Type | Default | Description |
|---|---|---|---|
arweaveDomain | string | NEXT_PUBLIC_ARWEAVE_HOST, ARWEAVE_HOST or VITE_ARWEAVE_HOST, else arweave.net | Gateway 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): FeedConfigIt returns the resolved config. The config is process-wide and synchronous, so call it once at startup.
FeedConfig fields
| Field | Type | Default | Used for |
|---|---|---|---|
title | string | 'Seed Protocol' | Feed title, as "<title> - <Collection>", e.g. My Blog - Posts |
description | string | 'Content published via Seed Protocol' | RSS description, Atom subtitle, JSON Feed description |
siteUrl | string | 'https://seedprotocol.io' | The channel's home page: RSS link, Atom alternate link, JSON Feed home_page_url. Not item links |
feedUrl | string | '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) |
language | string | 'en' | RSS language |
copyright | string | '© <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 bysetSiteConfig({ siteUrl: 'https://b.example' })leaves the title atSeed Protocol. authoris 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: FeedConfiggetSiteConfig()returns a copy of the current process config.resetSiteConfig()restores the defaults.resolveSiteConfig(overrides, base)mergesoverridesontobase(default: the current process config, not the defaults) with the same field-by-fieldauthormerge, without changing anything. This is what the handlers andcreateFeeddo with theirsiteConfigOverridesargument.DEFAULT_SITE_CONFIGis 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
| Variable | Default | What it does |
|---|---|---|
FEED_ITEM_URL_BASE | The 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_PATH | attestation/view | Path between the base and the Seed UID. Applies to the default explorer base too |
FEED_PAGE_SIZE | 25 | Items per page in handleFeedRequest. A value that isn't a positive integer gives 25 |
FEED_INCLUDE_DATA_URI_HTML_ITEMS | off | true 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
| Variable | Default | What it does |
|---|---|---|
CACHE_ENABLED | On, except when NODE_ENV=development | false, 0, no or off turns caching off. true, 1 or yes turns it on, including in development |
CACHE_TTL | 3600 | The 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_AGE | 0 | Seconds 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_TTL | 300 | Seconds ?page=2 and later pages are cached |
CACHE_ARCHIVE_TTL | 86400 | Seconds monthly archives are cached |
CACHE_DIR | ./cache | Where 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.
| Variable | Default | What it does |
|---|---|---|
IMAGE_METADATA_ENABLED | on | false turns probing off |
IMAGE_METADATA_TTL | 604800 (7 days) | Seconds a probe result is cached, in memory and under <CACHE_DIR>/image-metadata/ |
IMAGE_METADATA_GATEWAYS | The 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_TIMEOUT | 5000 | Milliseconds 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:
- Feed documents (this package).
handleFeedRequestandhandleArchiveFeedRequeststore each generated document in memory and as a JSON file (post-rss.json,post-rss-page-2.json,post-atom-archive-2026-9.jsonand 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.createFeeddoesn't cache. - Assembled items (
@seedprotocol/query), undercollections/anditems/. 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'sIf-None-Matchmatches the storedETag, weak (W/"…") or not, a304with 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 thanCACHE_MAX_AGEseconds 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
ETagandLast-Modified, but a matchingIf-None-Matchstill gets a200.
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-Controlispublic, max-age=<n>, s-maxage=<n>, must-revalidate, with or without the cache:CACHE_MAX_AGE(default0) for the first page,3600for later pages and86400for 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: STALEandWarning: 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 a500. Later pages and archives serve an unexpired entry as a hit before generation starts, so for them a failure usually ends in the500. - Feed and query share
CACHE_DIR. Add it to.gitignore. - With the cache off, the handlers log
Cache is DISABLEDonce, when the first request creates the cache manager. Per-request logs (hits, misses, item counts, image probes) print only withDEBUG=seedFeed.
Related
- Serve published items as a feed: the walkthrough
- createFeed and handlers: the generation API, routes, field mapping and types
- Hyper distribution:
@seedprotocol/feed-hyperand theseed-feedcommand - @seedprotocol/query: how items are read and cached
- @seedprotocol/arweave and @seedprotocol/eas: gateway and chain settings
Tool attestations
Write and revoke PublishedBy and PublishAuthorization attestations with @seedprotocol/publish: attestPublishedBy and the onPublished batch, attestPublishAuthorization, assessPublishAuthorizationLive, the schema helpers, and the revoke functions for every tool attestation.
createFeed and handlers
Reference for the HTTP handlers and generation functions in @seedprotocol/feed: routes, query parameters, status codes and headers, createFeed's nine arguments, how item fields are mapped, and the exported types.