Seed Protocol
@seedprotocol/feed

Hyper distribution (feed-hyper)

Reference for @seedprotocol/feed-hyper: publishFeed, serveFeed, openFeed, seedFeed and watchFeed, the seed-feed command and its flags, the drive layout, and its known limits.

@seedprotocol/feed-hyper writes the documents @seedprotocol/feed generates into a Hyperdrive, announces the drive on Hyperswarm, replicates other people's drives, and serves a drive to ordinary feed readers over localhost HTTP. A feed's identity is the drive's public key, not a hostname. It's transport only: what goes into each document is decided by the feed package, as described in createFeed and handlers.

import { publishFeed, serveFeed, openFeed, seedFeed, watchFeed } from '@seedprotocol/feed-hyper'

It runs on Node 20.18.1 or later only and depends on Holepunch native modules, which may crash under Bun. See the overview for installation.

DEFAULT_SEED_FEED_HYPER_KEY, reserved for the key of Seed's own feed, is an empty string (it's defined in @seedprotocol/arweave and re-exported here), so the package has no built-in feed to open. Everything on this page works with drives you publish yourself.

The drive

A published drive holds one file per schema and format, plus a registry:

/registry.json
/posts/rss.xml
/posts/atom.xml
/posts/feed.json
/posts/archive/2026/10/rss.xml     only with includeArchives (library only)

The collection is the lowercased, pluralized schema name, as in the HTTP routes. registry.json describes the drive:

type FeedRegistry = {
  key: string          // the drive key (z32)
  version: number      // see the warning below
  updatedAt: string    // ISO time of the publish
  schemas: Array<{
    schema: string     // e.g. 'post'
    formats: Array<'rss' | 'atom' | 'json'>
    paths: string[]    // drive paths written for this schema in this publish
  }>
}

Inside each document, the channel's feedUrl is hyper://<key>, so self and paging links look like hyper://<key>/posts/rss. siteUrl comes from the siteUrl option or the process site config, and the other channel fields, such as title and author, from the site config.

Things that hold for every drive:

  • Single writer. The drive's private key lives in the Corestore directory (storePath, or --store). Only that store can write the drive. Back it up: Hyperdrive keys can't be rotated, only replaced by a new drive under a new key.
  • One process per store. A second process on the same store fails with File descriptor could not be locked. To serve a feed while publish keeps announcing it, give serve its own store; it replicates from the publisher.
  • Append-only. Republishing writes new versions of the files. Revoked items leave the current version, but peers keep the drive's history.

Known limits of publishFeed and seed-feed publish:

  • Only page 1 is written. When that page is full, it still links rel="next" (JSON: next_url) to hyper://<key>/posts/rss?page=2, which doesn't exist. Raise the page size if readers need more items.
  • includeArchives doesn't build a real archive. It writes the same latest page as the current month's archive, linked only to the current feed.
  • registry.json's version is one behind the drive version the publish ends on, because the registry is written last.
  • Old files stay. A publish only writes; files from an earlier publish with more schemas or formats remain in the drive, though the new registry no longer lists them.

The seed-feed command

seed-feed publish --schema <names> [--format rss,atom,json] [--store <path>]
                  [--drive-name <name>] [--page-size <n>] [--site-url <url>] [--no-announce]
seed-feed seed <key> [--store <path>]
seed-feed serve <key> [--store <path>] [--port 8080] [--host 127.0.0.1] [--no-announce]
seed-feed help | --help | -h
FlagCommandsDefaultDescription
--schema <names>publish (required)noneComma-separated schema names, e.g. post,image. Matched exactly, so lowercase
--format <list>publishrss,atom,jsonComma-separated. Unknown entries are dropped; if none are left, the command fails
--store <path>all./.seed/feed-storeCorestore directory, resolved against the current directory
--drive-name <name>publishseed-feedName of the writable drive inside the store. The same store and name always give the same key
--page-size <n>publish25Items written per document. 0 or a non-number falls back to 25
--site-url <url>publishThe site config's siteUrl (https://seedprotocol.io)The channel home page in each document
--port <n>serve8080HTTP port. 0 falls back to 8080; use the library for a random port
--host <host>serve127.0.0.1HTTP bind address
--no-announcepublish, serveoffDon't join Hyperswarm. publish writes the drive and exits; serve serves only what the store already holds

What each command does:

  • publish generates the documents once through @seedprotocol/feed, writes them, and prints the drive key (z32), the hyper:// URL, the drive version and the paths written. Then it keeps announcing the drive until Ctrl+C (SIGINT or SIGTERM), unless you pass --no-announce. To update the feed, run it again with the same --store and --drive-name.
  • seed opens a drive by key (z32 or hex) and stays joined to its Hyperswarm topic until stopped. It always announces; --no-announce is ignored. See the warning under seedFeed.
  • serve opens a drive by key and serves it over HTTP until stopped. It prints the address and an example URL for post RSS.

Flags that don't apply to a command are accepted and ignored. A missing --schema or key and an unknown command print an error and the usage, and exit with status 1. An unknown flag prints only the error (Unknown option '--…') and exits with status 1. The CLI has no option for archives and no watch command; use the library for those. The generating commands read the same environment variables as the feed package (list).

publishFeed

Generates the feeds of one or more schemas and writes them into a drive.

function publishFeed(options: PublishFeedOptions): Promise<PublishFeedSession>

Options

NameTypeDefaultDescription
schemasstring[]requiredSchema names. Throws publishFeed: schemas must be non-empty if empty
formatsFeedFormat[]required'rss', 'atom', 'json'. Throws if empty
storePathstringrequiredCorestore directory
driveNamestring'seed-feed'Name of the writable drive in the store
pageSizenumber25Items per document
announcebooleantruetrue keeps the drive announced on Hyperswarm until close(). false writes, closes the store and returns
includeArchivesbooleanfalseAlso write /<collection>/archive/<year>/<month>/… for the current month. See the limits above
siteUrlstringThe site config's siteUrlChannel home page
fixtureContentsRecord<string, string>noneSkip generation and write these bodies, keyed by drive path. For tests. The registry lists them all under the first schema

Returns

type PublishFeedSession = {
  key: string        // drive public key, z32
  version: number    // drive version after this publish
  paths: string[]    // drive paths written, ending with '/registry.json'
  hyperUrl: string   // 'hyper://<key>'
  close: () => Promise<void> // leaves Hyperswarm and closes the store; safe to call twice
}

With announce: false the session is already closed when it returns.

Example

A scheduled republish that writes and exits:

republish.ts
import { publishFeed } from '@seedprotocol/feed-hyper'
import { setSiteConfig } from '@seedprotocol/feed'

setSiteConfig({ title: 'My Blog', siteUrl: 'https://blog.example.com' })

const session = await publishFeed({
  schemas: ['post'],
  formats: ['rss', 'atom', 'json'],
  storePath: '/var/seed/feed-store',
  pageSize: 50,
  announce: false,
})
console.log(`Published version ${session.version} of ${session.hyperUrl}`)

A drive is only reachable while some peer announces it, so something else (a long-running publish, or a mirror) has to stay online between runs.

serveFeed

Opens a drive by key and serves it over HTTP with serve-drive, so RSS readers can subscribe to http://127.0.0.1:8080/posts/rss.xml.

function serveFeed(options: ServeFeedOptions): Promise<ServeFeedResult>
OptionTypeDefaultDescription
keystringrequiredDrive key, z32 or hex
storePathstringrequiredCorestore directory. Can be the publisher's store or a separate one
portnumber80800 picks a free port
hoststring'127.0.0.1'Bind address
announcebooleantrueJoin Hyperswarm to fetch the drive from peers
type ServeFeedResult = {
  key: string
  port: number        // the bound port
  host: string
  baseUrl: string     // e.g. 'http://127.0.0.1:8080'
  close: () => Promise<void>
}

Behavior:

  • Both drive paths and the feed package's HTTP paths work: /posts/rss.xml and /posts/rss, /posts/feed.json and /posts/json, and the same under /archive/<year>/<month>/.
  • ?version=<n> serves a file as it was at drive version n (a serve-drive feature). Other query parameters, such as ?page=2, are ignored.
  • Content types come from the file extension: application/xml for RSS and Atom (not application/rss+xml), application/json; charset=UTF-8 for JSON Feed and the registry.
  • Missing files return 404. There's no authentication, so keep host on localhost unless you mean to publish the gateway.
  • Files are fetched from peers when first requested and kept in the store.
import { serveFeed, localFeedUrl } from '@seedprotocol/feed-hyper'

const gateway = await serveFeed({ key: process.argv[2]!, storePath: '.seed/feed-mirror', port: 8080 })
console.log(localFeedUrl(gateway.baseUrl, 'post', 'rss')) // http://127.0.0.1:8080/posts/rss.xml
// later: await gateway.close()

localFeedUrl

function localFeedUrl(baseUrl: string, schemaName: string, format: 'rss' | 'atom' | 'json'): string

Returns baseUrl plus the drive path for that schema and format.

openFeed

Opens a drive by key to read it from Node.

function openFeed(options: OpenFeedOptions): Promise<OpenedFeed>
OptionTypeDefaultDescription
keystringrequiredDrive key, z32 or hex
storePathstringrequiredCorestore directory
announcebooleantrueJoin Hyperswarm to find peers
syncTimeoutMsnumber15000When the store has nothing of this drive yet, wait up to this long for a peer before returning. 0 doesn't wait
type OpenedFeed = {
  key: string
  version: number                      // drive version when opened
  drive: Hyperdrive                    // the underlying drive (untyped)
  get: (path: string) => Promise<string | null>          // UTF-8 contents, null if missing
  getRegistry: () => Promise<FeedRegistry | null>        // parsed /registry.json, null if missing or invalid
  close: () => Promise<void>
}

openFeed doesn't throw when no peer shows up in time; it returns, and get then waits for peers for blocks that aren't local yet.

import { openFeed } from '@seedprotocol/feed-hyper'

const feed = await openFeed({ key: process.argv[2]!, storePath: '.seed/reader' })
const registry = await feed.getRegistry()
console.log(registry?.updatedAt, registry?.schemas.map((s) => s.schema))
const rss = await feed.get('/posts/rss.xml')
await feed.close()

seedFeed

Joins a drive's Hyperswarm topic and replicates it until closed, without generating or serving anything. It's what seed-feed seed runs.

function seedFeed(options: { key: string; storePath: string }): Promise<{ key: string; close: () => Promise<void> }>

seedFeed doesn't download the drive's files. It fetches the drive's metadata, but file contents are only stored when something reads them. A mirror that nobody reads through can't serve the feed once the publisher goes offline. Download the files yourself after each update:

import { openFeed, watchFeed } from '@seedprotocol/feed-hyper'

const feed = await openFeed({ key: process.argv[2]!, storePath: '/var/seed/feed-mirror' })
await feed.drive.download('/').done()
watchFeed(feed.drive, { onUpdate: () => feed.drive.download('/').done() })
// keep the process running; call feed.close() to stop

Running serve on the mirror and requesting each file once has the same effect for those files.

watchFeed

Calls you back when a drive gets a new version, for example when the publisher republishes and the change replicates.

function watchFeed(
  drive: Hyperdrive,
  options: { onUpdate: (version: number) => void | Promise<void> },
): () => void

Pass feed.drive from openFeed. It listens for appends to the drive's core, so one republish can call onUpdate several times, once per append. Errors thrown by onUpdate are logged, not rethrown. It returns a function that stops watching.

Lower-level helpers

ExportSignatureDescription
openFeedStore(options: { storePath: string; driveName?: string; key?: string; announce?: boolean }) => Promise<FeedStoreHandles>Opens a Corestore and a drive: by key (read-only replica), or the named writable drive (driveName, default seed-feed). With announce (default true) it joins Hyperswarm as client and server and replicates every connection. Returns { store, drive, swarm }, where swarm is null without announce
closeFeedStore(handles: FeedStoreHandles) => Promise<void>Leaves Hyperswarm and closes the drive and store
encodeKey(key: Buffer | Uint8Array) => stringA key as z32
keyToBuffer(key: string | Buffer | Uint8Array) => BufferA z32 or hex key as bytes
replicateStores(a: Corestore, b: Corestore) => () => voidReplicates two stores in the same process, without the network. Returns a stop function. For tests
feedDrivePath(schemaName, format, archive?: { year; month }) => string('post', 'json') → /posts/feed.json
feedHttpPath(schemaName, format) => string('post', 'json') → /posts/json
resolveHttpToDrivePath(pathname: string) => string/posts/json → /posts/feed.json; other paths unchanged
hyperFeedUrl(keyZ32: string) => stringhyper://<key>
REGISTRY_PATH'/registry.json'
DEFAULT_SEED_FEED_HYPER_KEY''Reserved for Seed's official feed key

The types PublishFeedOptions, PublishFeedResult, PublishFeedSession, OpenFeedOptions, OpenedFeed, SeedFeedOptions, SeedFeedSession, ServeFeedOptions, ServeFeedResult, FeedRegistry, FeedRegistryEntry, WatchFeedOptions, FeedStoreHandles, OpenStoreOptions and ArchivePathOptions are exported. Hyperdrive, Corestore and Hyperswarm ship no type declarations, so drive, store and swarm are untyped.

On this page