PodcastFeedVapor
Published on · Updated · 16 min
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-Generatorheader, ETag/304 with SHA256, and CORS withOPTIONSpreflightRoute builder — A DSL that registers feed routes in one line:
app.podcastFeed("feed.xml") { req in ... }XML Streaming — Chunked generation via
StreamingFeedResponsefor 10,000+ episode catalogs without blowing up RAMPagination — Automatic parsing of
?limit=N&offset=Nwith security clamping to prevent abusePodping — 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
FeedCacheStoreand 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 hitsMetrics —
FeedMetricsMiddlewarecompatible with swift-metrics: request counters, latency timers, response size recorders — plug in Prometheus, StatsD, or DatadogWebSocket Podping — Real-time notifications via WebSocket with
PodpingWebSocketManager: connection, per-feed subscription, filtered broadcastStrict 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
| Header | Middleware | Example |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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.
| Protocol | Converts To | Usage |
|---|---|---|
|
| The main model (show → complete feed) |
|
| Show metadata |
|
| 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.
| Metric | Type | Description |
|---|---|---|
| Counter | Request count by route, status, cache hit/miss |
| Timer | Latency by route and status |
| Recorder | Response size by route |
| 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.
| Method | Route | What It Does |
|---|---|---|
GET |
| JSON health check (status, version, uptime) |
GET |
| Static feed — 3 episodes |
GET |
| Rich feed — full metadata, enclosures, Podcast NS 2.0 |
GET |
| Dynamic feed with route parameter |
GET |
| Dynamic feed with pagination |
GET |
| Batch audit — parallel quality scoring |
OPTIONS |
| CORS preflight — 204 No Content |
GET |
| JSON endpoint — verifies middleware skip |
GET |
| Cached feed with StreamingCacheResponse (stream-through + ETag) |
WS |
| WebSocket Podping — real-time notifications |
POST |
| 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
RedisFeedCachewithStreamingCacheResponsefor 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 everywhere4 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.
Vapor middleware for serving podcast RSS feeds with caching, streaming, Podping, and Fluent integration. Built on PodcastFeedMaker. - atelier-socle/po…