Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ packages/*/coverage
.turbo
**/xunit
.nyc_output
.env
**/.env
.DS_Store
Procfile
tunnel.sh
Expand Down
38 changes: 38 additions & 0 deletions apps/client/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# rssCloud test harness — environment template.
#
# Copy to apps/client/.env (gitignored) and adjust:
# cp apps/client/.env.example apps/client/.env
#
# The harness reads .env from its own directory (apps/client) via dotenv, so
# this file must live here, not at the repo root. `pnpm client` runs from here.
#
# The values below are tuned for LOCAL DEVELOPMENT alongside the rssCloud hub
# (apps/server), which listens on http://localhost:5337 by default.

# --- Harness identity -------------------------------------------------------
# The hostname/port this harness advertises for its own callbacks and test
# feeds. Keep PORT matching whatever you actually run it on.
DOMAIN=localhost
PORT=9000

# --- Default hub / WebSub target --------------------------------------------
# Where actions go when the UI's server/hub override field is left blank.
# Overridden per-action at runtime by that field — this is just the default
# (e.g. for testing this repo's own hub locally).
HUB_SERVER_URL=http://localhost:5337

# --- SSRF egress guard: allow loopback for local dev ------------------------
# The egress guard is ALWAYS ON and refuses loopback/private targets by
# default (feed discovery, and every pleaseNotify/ping/hub.* call this harness
# makes). For local dev the harness must reach the hub on loopback.
#
# >>> PRODUCTION: delete this line before deploying publicly. This harness is
# >>> meant to hit arbitrary third-party feeds/hubs when deployed live —
# >>> loopback exemptions have no place there.
CLIENT_FETCH_ALLOW_CIDRS=127.0.0.0/8,::1/128

# --- Optional overrides (defaults shown; uncomment to change) --------------
# REQUEST_TIMEOUT=4000 # outbound fetch timeout (ms), SSRF-guarded
# SESSION_CALLBACK_IDLE_MS=3600000 # 1h — incoming callbacks 404 past this idle window
# SESSION_GC_IDLE_MS=86400000 # 24h — a session is fully evicted from memory past this
# SESSION_GC_INTERVAL_MS=900000 # 15m — how often the GC sweep runs
43 changes: 43 additions & 0 deletions apps/client/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
FROM node:22 AS base

RUN corepack enable

WORKDIR /app

COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY apps/client/package.json apps/client/
COPY packages/xml-rpc/package.json packages/xml-rpc/
COPY packages/core/package.json packages/core/

# Build the workspace packages (TS → CJS/ESM dist) the harness consumes.
FROM base AS build

COPY packages/xml-rpc packages/xml-rpc
COPY packages/core packages/core
RUN pnpm install --frozen-lockfile --filter "@rsscloud/core..."
RUN pnpm --filter "@rsscloud/core..." run build

FROM base AS dependencies

RUN pnpm install --frozen-lockfile --filter "@rsscloud/client-app..." --prod --ignore-scripts

FROM dependencies AS runtime

COPY --chown=node:node apps/client apps/client
COPY --from=build --chown=node:node /app/packages/xml-rpc/dist packages/xml-rpc/dist
COPY --from=build --chown=node:node /app/packages/core/dist packages/core/dist

WORKDIR /app/apps/client

# Default listen port (config.js's PORT default). Documentation only —
# override PORT at runtime and publish the matching port if you change it.
# This harness holds no persistent state to volume-mount: sessions live only
# in memory for the process lifetime.
EXPOSE 9000

# node:22 ships a pre-created, unprivileged `node` user (uid 1000) for
# exactly this purpose — the app never writes to disk, so it only ever needs
# read access to what was just copied in.
USER node

CMD ["node", "--use_strict", "index.js"]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
137 changes: 96 additions & 41 deletions apps/client/README.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,34 @@
# @rsscloud/client-app

A private, interactive **dev harness** for the [rssCloud](https://github.com/rsscloud/rsscloud-server)
notification protocol — the subscriber + publisher end, the mirror of `@rsscloud/core`
(the hub end). It is **not published**; it exists to exercise a running rssCloud server
by hand.

The Express app ([`client.js`](client.js)) serves a **Subscribe/Ping UI** with a live
**request log**, and hosts the callback endpoint a hub notifies. It speaks both the
classic rssCloud protocol **and** [WebSub](https://www.w3.org/TR/websub/): the UI has a
separate WebSub control set (subscribe/unsubscribe/publish, with optional
`lease_seconds` and `secret`), the served feed advertises the hub via
`<atom:link rel="hub">`, and the WebSub callback echoes the intent-verification
challenge and reports the hub's `X-Hub-Signature` (with a valid/invalid verdict) on
content distribution. All the protocol wire work lives in [`lib/`](lib/) and is
reusable on its own.
An interactive **test harness** for the [rssCloud](https://github.com/rsscloud/rsscloud-server)
notification protocol and [WebSub](https://www.w3.org/TR/websub/) — the subscriber +
publisher end, the mirror of `@rsscloud/core` (the hub end). Unlike most of this monorepo
it's designed to be deployable as a **public utility**: it can test a hub running locally,
a hub deployed live, or any third party's rssCloud/WebSub implementation.

The Express app ([`client.js`](client.js)) serves one unified control box — pick a protocol
(rssCloud REST, rssCloud XML-RPC, or WebSub), point it at this harness's own test feed or an
arbitrary external one, and Subscribe/Ping/Publish/Unsubscribe. Every action is asynchronous
(no page navigation); the outcome — and everything this harness receives — shows up as a
combined, live traffic log via [`@andrewshell/socklog`](https://www.npmjs.com/package/@andrewshell/socklog).
All the protocol wire work lives in [`lib/`](lib/) and is reusable on its own.

## Sessions

Visiting `/` mints a session id and redirects to `/s/<id>` — that path prefix is the root
for everything this browser session does: the UI, its test feed(s), and every callback route
a hub calls back into (`/notify`, `/RPC2`, `/websub-callback`). This keeps concurrent public
users' logs, feeds, and subscriptions from ever crossing.

A session's callback/feed routes 404 once it's gone `SESSION_CALLBACK_IDLE_MS` (default 1h)
without an outgoing action — a hub that keeps probing a long-abandoned subscription gets
nothing back. The UI itself keeps working past that point (acting again resumes it). A
session is fully evicted from memory after `SESSION_GC_IDLE_MS` (default 24h) of inactivity,
independent of the callback cutoff, so a long-running public deployment doesn't leak memory.

Both cutoffs are suspended while a session has a live socklog connection — leaving the page
open (e.g. watching an external feed overnight) counts as active use on its own, even with no
button clicked, so its callback surface stays reachable and it's never evicted.

## Running

Expand All @@ -30,32 +45,61 @@ pnpm --filter @rsscloud/client-app run dev # watch mode
pnpm --filter @rsscloud/client-app start # one-shot
```

It listens on `PORT`, advertises itself as `DOMAIN`, and targets a hub at
`http://localhost:5337`. Requires Node 22+ (uses the global `fetch`).
Copy `.env.example` to `.env` and adjust — the defaults target a hub at
`http://localhost:5337` (this repo's own server, run locally) with loopback exempted from
the outbound SSRF guard for local dev. See `.env.example` for the full list of env vars
(`DOMAIN`, `PORT`, `HUB_SERVER_URL`, `CLIENT_FETCH_ALLOW_CIDRS`, `REQUEST_TIMEOUT`,
`SESSION_CALLBACK_IDLE_MS`, `SESSION_GC_IDLE_MS`, `SESSION_GC_INTERVAL_MS`). Requires Node 22+.

| Env var | Default | Purpose |
| -------- | ----------- | ----------------------------------------- |
| `PORT` | `9000` | port the harness listens on |
| `DOMAIN` | `localhost` | host it advertises as the callback domain |
Every outbound call this harness makes (feed discovery, pleaseNotify/ping, WebSub `hub.*`) is
routed through the same SSRF-guarded fetch `@rsscloud/core` gives the hub server — refusing
loopback/private/link-local targets by default, since a public deployment lets any visitor
make it originate arbitrary requests. `CLIENT_FETCH_ALLOW_CIDRS` re-enables loopback for local
dev; delete it before deploying publicly.

## Docker

```bash
docker build -f apps/client/Dockerfile -t rsscloud-client .
docker run -p 9000:9000 --env-file apps/client/.env rsscloud-client
```

See [`examples/dockge/compose.yaml`](../../examples/dockge/compose.yaml) for a stack pairing
this harness with the hub server.

## The `lib/` API

`require('./lib')` exposes these helpers (CommonJS):

- **`createRssCloudClient({ serverUrl, fetch? })`** — send `pleaseNotify` (subscribe)
and `ping` (publish) to a hub over an injectable `fetch`. Returns `{ pleaseNotify, ping }`.
- **`createWebSubClient({ serverUrl, path?, fetch? })`** — send WebSub `hub.*` requests
to a hub's front door (`path` defaults to `/websub`). Returns
`{ subscribe, unsubscribe, publish }`; each resolves to the hub's raw reply
(`{ status, body }`) and does **not** throw on a non-2xx.
- **`createRssCloudClient({ serverUrl, fetch? })`** — send `pleaseNotify` (subscribe) and
`ping` (publish) to a hub over an injectable `fetch`. Returns `{ pleaseNotify, ping }`.
- **`createWebSubClient({ serverUrl, path?, fetch? })`** — send WebSub `hub.*` requests to a
hub's front door (`path` defaults to `/websub`). Returns `{ subscribe, unsubscribe, publish }`;
each resolves to the hub's raw reply (`{ status, body }`) and does **not** throw on a non-2xx.
- **`readVerification(query)`** — given a callback GET's query, return
`{ mode, topic, challenge, leaseSeconds }` when it's a WebSub intent-verification
request (the subscriber must echo `challenge` verbatim), else `null`.
- **`renderCloudFeed(feed)`** — emit an RSS 2.0 document carrying the `<cloud>` element
that advertises a hub. Pass `hub` (a URL) to also advertise a WebSub hub via
`{ mode, topic, challenge, leaseSeconds }` when it's a WebSub intent-verification request
(the subscriber must echo `challenge` verbatim), else `null`.
- **`renderCloudFeed(feed)`** — emit an RSS 2.0 document carrying the `<cloud>` element that
advertises a hub. Pass `hub` (a URL) to also advertise a WebSub hub via
`<atom:link rel="hub">` plus a `rel="self"` link.
- **`buildNotifyResponse(success)`** — build the XML-RPC notify acknowledgement a
subscriber returns to the hub.
- **`buildNotifyResponse(success)`** — build the XML-RPC notify acknowledgement a subscriber
returns to the hub.
- **`discoverFeed({ url, fetch? })`** — fetch an arbitrary feed URL and report what it
advertises: `{ rssCloud: {domain,port,path,registerProcedure,protocol} | null, webSub:
{hubUrl} | null, error? }`. Backs the UI's "enter a feed URL" discovery action.
- **`parseFeedDiscovery(xmlText)`** — the parsing half of `discoverFeed`, given an
already-fetched body.

Two more app-root modules (not part of the portable `lib/` barrel, since they're
Express/`ws`-coupled):

- **`lib/session-store.js`**'s `createSessionStore({ now?, idGenerator? })` — the in-memory
per-session state (request log, feed items, WebSub secrets, idle tracking) described above.
- **`session-sockets.js`**'s `createSessionSockets({ sessionStore })` — the per-session
socklog WebSocket feed (`/s/:id/logs`), returning `{ attach(server), broadcast(sessionId,
entry) }`.
- **`lib/guarded-fetch.js`**'s `createGuardedFetch({ allowCidrs?, timeoutMs? })` — the
SSRF-guarded fetch described above.

### WebSub

Expand All @@ -65,13 +109,13 @@ const { createWebSubClient } = require('./lib');
const hub = createWebSubClient({ serverUrl: 'http://localhost:5337' });

await hub.subscribe({
callbackUrl: 'http://localhost:9000/websub-callback',
topicUrl: 'http://localhost:9000/rss-01.xml',
callbackUrl: 'http://localhost:9000/s/<session-id>/websub-callback',
topicUrl: 'http://localhost:9000/s/<session-id>/rss-01.xml',
leaseSeconds: 3600, // optional; the hub clamps to its configured bounds
secret: 's3cr3t' // optional; opts into a signed X-Hub-Signature delivery
});

await hub.publish({ topicUrl: 'http://localhost:9000/rss-01.xml' }); // hub.mode=publish
await hub.publish({ topicUrl: 'http://localhost:9000/s/<session-id>/rss-01.xml' }); // hub.mode=publish
await hub.unsubscribe({ callbackUrl: '…', topicUrl: '…' });
```

Expand All @@ -89,12 +133,11 @@ const { status, body } = await client.pleaseNotify({
});
```

`callback.domain` is optional and selects the hub's verification flow: when given, the
hub verifies against that host (with a challenge for `http-post`/`https-post`); when
omitted, the hub uses the caller's address. `pleaseNotify` resolves to the hub's raw
reply (`{ status, body }`) and does **not** throw on a non-2xx — inspect `status`
yourself. Pass `protocol: 'xml-rpc'` to subscribe over the `/RPC2` front door instead
of REST.
`callback.domain` is optional and selects the hub's verification flow: when given, the hub
verifies against that host (with a challenge for `http-post`/`https-post`); when omitted, the
hub uses the caller's address. `pleaseNotify` resolves to the hub's raw reply
(`{ status, body }`) and does **not** throw on a non-2xx — inspect `status` yourself. Pass
`protocol: 'xml-rpc'` to subscribe over the `/RPC2` front door instead of REST.

### Ping

Expand All @@ -106,3 +149,15 @@ const client = createRssCloudClient({ serverUrl: 'http://localhost:5337' });
await client.ping({ feedUrl: 'https://feed.example/rss' }); // REST /ping
await client.ping({ transport: 'xml-rpc', feedUrl: '…' }); // /RPC2
```

### Feed discovery

```js
const { discoverFeed } = require('./lib');

const { rssCloud, webSub, error } = await discoverFeed({
url: 'https://feed.example/rss'
});
// rssCloud: { domain, port, path, registerProcedure, protocol } | null
// webSub: { hubUrl } | null
```
19 changes: 19 additions & 0 deletions apps/client/client.hub-config.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Isolated from client.test.js: HUB_SERVER_URL must be set before config.js
// (required transitively by client.js) reads process.env, and config.js is a
// module-level singleton — this can only be exercised in its own process,
// which `node --test` already gives each file.
process.env.HUB_SERVER_URL = 'http://hub.example.org:8080';

const test = require('node:test');
const assert = require('node:assert/strict');
const { createApp } = require('./client');
const request = require('supertest');

test('the served test feed\'s <cloud> element derives domain/port from HUB_SERVER_URL', async() => {
const app = createApp();

await request(app).get('/s/cloud-config-session');
const res = await request(app).get('/s/cloud-config-session/rss-01.xml');

assert.match(res.text, /<cloud domain="hub\.example\.org" port="8080"/);
});
Loading
Loading