|
| 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