Skip to content

Commit b0c61cf

Browse files
rss plugin: turn any pod container into an Atom/RSS feed
GET /feed/atom | /feed/rss | /feed (Accept-negotiated) over a container walked via loopback with forwarded auth (private feeds respect WAC). Property mapping by local name (schema:/dcterms:/dc:/bare): title, id/link, updated, content — XML-escaped. Newest-first, capped at maxItems, pre-sorted from the listing's inline dcterms:modified to bound the O(N) member fetch. Findings: the loopback container-walk spine is now shared by sparql/webdav/rss; forwarded auth = WAC-respecting feeds for free; the O(N) refetch is the same api.events wall sparql hit; config.baseUrl is the 4th+ api.serverInfo repetition. 8/8.
1 parent f2f1e0a commit b0c61cf

3 files changed

Lines changed: 704 additions & 0 deletions

File tree

rss/README.md

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# rss — a pod container as an RSS/Atom feed
2+
3+
Turn any LDP container into a subscribable syndication feed. A new feature
4+
built straight onto the [#206 plugin loader](https://jss.live/docs/features/plugins),
5+
not a port — but it reuses the loopback container-walk that `notifications/`,
6+
`sparql/` and `webdav/` established.
7+
8+
```js
9+
plugins: [{
10+
module: 'rss/plugin.js',
11+
prefix: '/feed',
12+
config: {
13+
baseUrl: 'http://localhost:3000', // the server's public origin (required)
14+
// loopbackUrl: 'http://127.0.0.1:3000', // where the plugin reaches the host
15+
defaultContainer: '/alice/blog/', // used when ?container= is omitted
16+
title: "Alice's blog", // feed title
17+
maxItems: 50, // cap on entries (default 50)
18+
},
19+
}]
20+
```
21+
22+
## Endpoints
23+
24+
| Route | Returns |
25+
|---|---|
26+
| `GET /feed/atom?container=/path/` | Atom 1.0 (`application/atom+xml`) |
27+
| `GET /feed/rss?container=/path/` | RSS 2.0 (`application/rss+xml`) |
28+
| `GET /feed?container=/path/` | content-negotiated on `Accept` (Atom default) |
29+
30+
`?container=` takes an absolute path (`/alice/blog/`) or a full URL on this
31+
server; omit it to fall back to `config.defaultContainer`. Both the same
32+
data, one as `<feed><entry>…`, the other as `<rss><channel><item>…`.
33+
34+
## Subscribe
35+
36+
Point any feed reader at the feed URL:
37+
38+
```
39+
http://localhost:3000/feed/atom?container=/alice/blog/
40+
```
41+
42+
For a **public** container that is the whole story — no credentials, the
43+
common feed-reader case. For a **private** feed the reader must authenticate:
44+
the plugin forwards the caller's `Authorization` header to the pod over
45+
loopback, so whatever the reader sends is what WAC sees. Most feed readers
46+
speak **HTTP Basic**, not Bearer; this plugin does not yet bridge Basic→Bearer
47+
(the `webdav/` plugin does — `Basic <anything>:<pod-token>` → `Bearer
48+
<pod-token>`), so today private feeds work with a reader that can send a raw
49+
`Authorization: Bearer <token>` header, or behind the `webdav/`-style bridge.
50+
Adding the same Basic→Bearer bridge here is a small, obvious extension.
51+
52+
## Property mapping
53+
54+
Each member resource maps to one entry. Properties are matched by the **local
55+
name** of each JSON-LD key (everything after the last `#`, `/` or `:`), so any
56+
vocabulary works with no configuration — `schema:name`, `dcterms:title`, a
57+
bare `title`, or `https://schema.org/name` all match `title`. `@graph` and
58+
top-level arrays are searched; `{"@value":…}` / `{"@id":…}` objects are
59+
unwrapped.
60+
61+
| Entry field | Source (first local-name that matches, in order) |
62+
|---|---|
63+
| **title** | `title`, `name`, `headline`, `label` — else the filename (extension stripped) |
64+
| **id / link** | the resource URL (always) |
65+
| **updated / pubDate** | `published`/`datePublished`, `modified`/`updated`/`dateModified`, `created`/`dateCreated`, `issued`, `date` — else the container listing's `dcterms:modified`, else now |
66+
| **content / description** | `content`, `articleBody`, `text`, `description`, `body`, `summary`, `abstract` — escaped |
67+
68+
A **plain text / HTML** member (no JSON to parse) uses its filename as the
69+
title and its body as the content. Sub-containers, `.acl` and `.meta` are
70+
skipped. Entries are sorted newest-first by the resolved date; the feed's own
71+
`<updated>` is the newest entry's date (or now for an empty feed).
72+
73+
## Findings
74+
75+
- **The loopback container-walk is the shared spine.** GET the container as
76+
`application/ld+json`, read `ldp:contains` (the compact `contains` key JSS
77+
emits), resolve each child `@id` against the container URL, GET the members.
78+
Identical in shape to `sparql/`'s dataset crawl and `webdav/`'s `PROPFIND`
79+
listing — three plugins, one pattern, because the plugin api gives no
80+
in-process handle on pod storage.
81+
82+
- **Forwarded auth = WAC-respecting feeds, for free.** Every loopback GET
83+
carries the caller's own `Authorization`. The plugin has no authority of its
84+
own: a private container returns 401/403 to the walk, so the feed is refused
85+
(or empty) exactly when the caller couldn't read it anyway. Public
86+
containers need no credentials. WAC by construction, never by re-derivation
87+
— the same property `notifications/` and `sparql/` rely on.
88+
89+
- **O(N) member fetches per request — the api.events gap, again.** Building a
90+
feed means one GET for the container plus one GET per member, on every
91+
request, with no caching. There is no write-hook in the plugin api (no
92+
`api.events.onResourceChange`), so a plugin cannot maintain a cached feed
93+
that invalidates on writes — the same wall `sparql/` hits for its write-time
94+
index. With that seam, this plugin would keep a per-container feed document
95+
and rebuild only the changed entry; until then every subscriber poll is a
96+
full bounded crawl. The listing's inline `dcterms:modified` lets us
97+
pre-sort and cap to `maxItems` *before* fetching, which bounds the cost, but
98+
correct global newest-first still wants every candidate member fetched.
99+
100+
- **config.baseUrl / api.serverInfo repetition.** Like `notifications/`,
101+
`sparql/` and `webdav/`, the plugin must be *told* its own public origin
102+
(`config.baseUrl`) because the api exposes no `serverInfo` — it needs the
103+
origin both to reach the host over loopback and to emit absolute entry
104+
IDs/links. The fourth plugin to hand-roll this; the `api.serverInfo` seam is
105+
well past earning its place in NOTES.md.
106+
107+
- **Basic→Bearer belongs in a shared helper.** `webdav/` and (eventually)
108+
this plugin both want to bridge a feed reader's / OS client's HTTP Basic
109+
credential to a pod Bearer token. That bridge is copy-pasteable today; a
110+
shared auth helper on the api surface would remove the duplication.

0 commit comments

Comments
 (0)