PodcastFeedVapor

Published on · Updated · 16 min

Wlad
Wlad
Founder & Swift Tech Lead

Where It Comes From

PodcastFeedMaker can generate, parse, validate, and audit podcast RSS feeds. Great. But once you have your clean XML… you still need to serve it somewhere.

On a client project, I needed feeds generated on-the-fly from a database — no static files, no CDN, real RSS built at HTTP request time. I started with a naive Vapor handler: build the feed, serialize, return the response. It worked, but without ETag, without Cache-Control, without CORS, without any of what a production RSS feed really requires.

Rather than reinventing these building blocks on every project, I extracted everything into a separate lib. PodcastFeedVapor was born from this concrete need: turn any Vapor app into a podcast server, with everything properly handled on the HTTP side, while keeping a strict separation between what's reusable (the open-source lib) and what's business logic (your app).

What PodcastFeedVapor Does

It's a middleware layer for Vapor that sits between your business logic and the HTTP client. Basically: you build a PodcastFeed with PodcastFeedMaker's DSL, and PodcastFeedVapor handles the rest — headers, caching, CORS, streaming, pagination.

The whole thing is split into four Swift Package Manager targets. You import only what you need, nothing more.

  • HTTP Middleware — Three stackable middlewares: X-Generator header, ETag/304 with SHA256, and CORS with OPTIONS preflight

  • Route builder — A DSL that registers feed routes in one line: app.podcastFeed("feed.xml") { req in ... }

  • XML Streaming — Chunked generation via StreamingFeedResponse for 10,000+ episode catalogs without blowing up RAM

  • Pagination — Automatic parsing of ?limit=N&offset=N with security clamping to prevent abuse

  • Podping — Webhook notifications when a feed changes, to alert aggregators in real-time

  • Batch audit — Quality scoring in parallel across multiple feeds at once, with grades and recommendations

  • Fluent mapping — Three pure Swift protocols to convert your models to feeds without Fluent dependency in the lib

  • Redis cache — Optional target with FeedCacheStore and Redis implementation (SETEX + SCAN)

  • Queue workers — Optional target for background feed regeneration via Vapor Queues

  • Streaming cache — Stream-through caching via StreamingCacheResponse: XML is cached while being streamed to the client, with automatic ETag on cache hits

  • MetricsFeedMetricsMiddleware compatible with swift-metrics: request counters, latency timers, response size recorders — plug in Prometheus, StatsD, or Datadog

  • WebSocket Podping — Real-time notifications via WebSocket with PodpingWebSocketManager: connection, per-feed subscription, filtered broadcast

  • Strict concurrency — All types are Sendable, Swift 6.2 strict concurrency end-to-end

Serve a Feed in 10 Lines

Here's everything you need for a Vapor app to serve a podcast RSS feed with HTTP caching, CORS, and a custom generation header. No magic, just configuration:

A GET /feed.xml returns XML with Content-Type: application/rss+xml, a SHA256 ETag, a Cache-Control: public, max-age=3600, a Last-Modified, and an X-Generator: MyApp/1.0 header. 200 OK the first time, then 304 Not Modified if the content hasn't changed. Exactly what you'd expect from a production feed.

The Three Middlewares

Three middlewares cover the classic HTTP needs of a production RSS feed. They stack in a specific order — CORS first (outermost), then cache, then the generation header (innermost). Each only touches RSS responses and silently ignores the rest. Your JSON API keeps working normally.

PodcastFeedMiddleware

Adds a configurable X-Generator header on each RSS response. Useful for debugging, monitoring, and especially for identifying which server produced a feed in a distributed environment. Non-RSS responses (JSON, HTML, etc.) pass through unchanged — the middleware detects the Content-Type and only acts on application/rss+xml.

FeedCacheMiddleware

Handles conditional HTTP caching. On each RSS response, it computes a SHA256 hash of the body and attaches it as an ETag. If the client sends back that tag in an If-None-Match, and the content hasn't changed, the middleware short-circuits the response with a 304 Not Modified — zero body, zero generation work on the server.

It also adds a Last-Modified (generation date) and a Cache-Control: public, max-age=N configurable via CacheControlDuration — in minutes, hours, or seconds depending on your needs.

CORSFeedMiddleware

Full CORS with OPTIONS preflight handling. By default, it allows all origins (*) with a max-age of 86400 seconds. You can restrict to a specific origin if your use case requires it. Preflight returns a 204 No Content with no body — exactly what a modern browser expects.

Headers Produced

HeaderMiddlewareExample

X-Generator

PodcastFeedMiddleware

MyApp/1.0

ETag

FeedCacheMiddleware

"41293260aba6..." (SHA256)

Last-Modified

FeedCacheMiddleware

Sat, 15 Feb 2026 14:30:00 GMT

Cache-Control

FeedCacheMiddleware

public, max-age=300

Access-Control-Allow-Origin

CORSFeedMiddleware

*

Access-Control-Max-Age

CORSFeedMiddleware

86400

The Route Builder

podcastFeed() is an extension on Application and RoutesBuilder that registers a GET with automatic PodcastFeed to XML encoding. It supports static paths and dynamic parameters, exactly like the Vapor routes you already know.

Internally, FeedResponseEncoder converts the PodcastFeed to a Response with the right Content-Type, pretty-print if configured, and hands off to middlewares for HTTP headers. If you prefer full control, req.feedResponse(feed) and Response.xml("...") are also available.

Pagination and Large Catalogs

For a podcast with 500 episodes, serving all 500 on every request makes no sense. FeedPagination extracts limit and offset parameters from the query string and clamps them within reasonable bounds. No accidental limit of 10,000, no negative offset that would crash your SQL query.

For truly massive catalogs where even pagination isn't enough, StreamingFeedResponse generates XML in chunks and streams it via Transfer-Encoding: chunked — the server never needs to hold the entire feed in memory. Perfect for platforms hosting thousands of episodes.

Mapping From Your Models

Three pure Swift protocols transform your types into feeds. Note: these are not Fluent protocols — they work with any type, struct or class. It's just a simple contract: you implement a to*() method, and the lib knows how to convert.

ProtocolConverts ToUsage

FeedMappable

PodcastFeed

The main model (show → complete feed)

ChannelMappable

Channel

Show metadata

ItemMappable

Item

An individual episode

Podping and Batch Audit

When you publish an episode, aggregators don't continuously check your feed — they wait for a signal. Podping is that signal. PodpingNotifier sends an HTTP POST webhook to alert the ecosystem that a feed has changed. Simple, effective.

Batch audit exposes an endpoint that scores multiple feeds in parallel via PodcastFeedMaker's FeedAuditor. Just pass the URLs in the query string — each feed is fetched, parsed, analyzed, and graded. Handy for monitoring an entire catalog's quality.

The JSON response contains the score (0-100), letter grade (A to F), and number of recommendations for each feed. We tested with real production feeds during development — a Simplecast got a D/68 with 9 recommendations, a Megaphone returning HTML instead of RSS logically got an F/0. Real-world results.

Redis and Queues — The Optional Targets

PodcastFeedVapor's core has no dependency on Redis or Vapor Queues. Two optional targets add these capabilities for deployments that actually need them.

PodcastFeedVaporRedis

FeedCacheStore defines a simple contract: store, retrieve, invalidate. The Redis implementation uses SETEX (set with expiry) and SCAN for pattern invalidation. It plugs in with one line in your config:

PodcastFeedVaporQueues

For heavy feeds or scheduled regenerations, FeedRegenerationJob integrates with Vapor Queues. You implement a handler, register it, and you can dispatch regeneration jobs from any route handler.

Streaming Cache

The 0.1.0 XML streaming solved the memory problem on large catalogs. But each request regenerated the feed from scratch. StreamingCacheResponse combines both: XML is streamed to the client AND cached simultaneously. The first request generates and stores, subsequent ones are served from cache with a SHA256 ETag.

FeedCacheStore is a protocol — you plug in whatever backend you want. InMemoryFeedCache is provided for dev and tests, RedisFeedCache for production.

First request: Transfer-Encoding: chunked, no ETag — XML is generated and streamed in real-time. Second request: fixed Content-Length, SHA256 ETag, served from cache. The client can then send an If-None-Match to get a 304.

Metrics

The PodcastFeedVaporMetrics target builds on Apple's swift-metrics — backend-agnostic, you plug in Prometheus, StatsD, Datadog, or any compatible collector. FeedMetricsMiddleware goes first in the middleware stack and automatically records every feed request.

MetricTypeDescription

pfv_feed_requests_total

Counter

Request count by route, status, cache hit/miss

pfv_feed_request_duration_seconds

Timer

Latency by route and status

pfv_feed_response_size_bytes

Recorder

Response size by route

pfv_feed_active_streams

Gauge

Active streaming connections in real-time

The pfv_ prefix is configurable via FeedMetricsConfiguration. The active streams gauge is a dedicated actor (FeedActiveStreamsGauge) that increments when a stream starts and decrements when it ends — no locks, no race conditions.

WebSocket Podping

0.1.0 had PodpingNotifier for outgoing HTTP webhooks. 0.2.0 adds an incoming WebSocket channel: clients connect, subscribe to specific feeds, and receive push notifications when a feed is updated. Both mechanisms coexist — webhook for notifying aggregators, WebSocket for notifying your own clients.

On the client side, communication uses JSON with 5 message types: welcome on connection, subscribe/unsubscribe for filtering, subscribed as confirmation, and notification for updates. A client without filters receives everything; a subscribed client only gets the feeds they care about.

The Sample Server

During development, we built a complete test server to validate each feature with real HTTP calls. The repo includes a script that automatically generates it as a standalone Vapor project — handy for seeing how everything fits together.

The server exposes 11 endpoints covering all lib features: static feed, rich feed with Podcast NS 2.0 metadata, dynamic feed with pagination, batch audit, health check, CORS preflight, streaming cache with ETag, real-time WebSocket Podping, and swift-metrics.

MethodRouteWhat It Does

GET

/health

JSON health check (status, version, uptime)

GET

/feed.xml

Static feed — 3 episodes

GET

/rich/feed.xml

Rich feed — full metadata, enclosures, Podcast NS 2.0

GET

/shows/:showId/feed.xml

Dynamic feed with route parameter

GET

/shows/:showId/feed.xml?limit=N&offset=N

Dynamic feed with pagination

GET

/feeds/audit?urls=...

Batch audit — parallel quality scoring

OPTIONS

/feed.xml

CORS preflight — 204 No Content

GET

/api/status

JSON endpoint — verifies middleware skip

GET

/cached/feed.xml

Cached feed with StreamingCacheResponse (stream-through + ETag)

WS

/podping

WebSocket Podping — real-time notifications

POST

/notify?url=...&reason=...

Triggers a broadcast to WebSocket clients

24 Live-Validated Scenarios

Every feature was tested with curl against a real Vapor server running locally. 24 scenarios total — 12 static checks and 12 live functional tests including streaming cache, WebSocket Podping, and metrics. When we say it works, it's because we actually ran it.

Why Two Swift Files

An architecture detail worth mentioning: in the sample server, feed construction (Feeds.swift) is separated from the Vapor entry point (App.swift). Why? A naming conflict between PodcastFeedMaker's Channel and NIO's Channel (Vapor's network runtime). By keeping imports separate, the compiler resolves types unambiguously. It's documented in the project because every developer mixing both imports will run into this sooner or later.

Architecture

Tests

282 tests across 55 suites on 4 targets. Overall coverage of 93.85% — up +3.6% from 0.1.0. Core reaches 99%+, queues 98.1%. Redis is lower (27.3%) because integration tests require a real Redis server, but the FeedCacheStore protocol is fully tested via a mock.

Each feature has showcase tests that also serve as executable documentation. The code examples in DocC and in this article come from these tests — what's written here has been compiled and executed.

Installation

Requirements

  • Swift 6.2+ with strict concurrency

  • Vapor 4.121+

  • PodcastFeedMaker 0.2.0+

  • Platforms: macOS 14+ · Linux (Ubuntu 22.04+)

Swift Package Manager

Four products are available. Import the core, and add Redis, Queues, or Metrics only if your deployment actually uses them:

References

The specs PodcastFeedVapor builds on, via PodcastFeedMaker:

What's Next

  • Redis + distributed streaming cache — Combine RedisFeedCache with StreamingCacheResponse for a shared cache across instances on multi-node deployments (Kubernetes, Docker Swarm)

  • WebSocket authentication — Token authentication support on Podping WebSocket connections to secure environments where feeds are not public

  • Grafana dashboard — An importable JSON with all 4 pfv_* metrics ready to plug into any Prometheus deployment

Under the Hood

  • Swift 6.2 — Strict concurrency, all types Sendable, actors everywhere

  • 4 modular targets — Core, Redis, Queues, Metrics — import what you need

  • 282 tests — 55 suites, 93.85% coverage

  • macOS + Linux — CI on both platforms

  • 8+ DocC guides — Complete documentation with executable examples

Links

GitHub - atelier-socle/podcast-feed-maker-vapor: Vapor middleware for serving podcast RSS feeds with caching, streaming, Podping, and Fluent integration. Built on PodcastFeedMaker.

GitHub - atelier-socle/podcast-feed-maker-vapor: Vapor middleware for serving podcast RSS feeds with caching, streaming, Podping, and Fluent integration. Built on PodcastFeedMaker.

Vapor middleware for serving podcast RSS feeds with caching, streaming, Podping, and Fluent integration. Built on PodcastFeedMaker. - atelier-socle/po…

GitHub