Initial implementation

This commit is contained in:
2026-07-19 21:28:54 +00:00
commit 287ba99e84
18 changed files with 1324 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
.git
.gitignore
node_modules
test
coverage
.env
README.md
+16
View File
@@ -0,0 +1,16 @@
SUWAYOMI_URL=http://suwayomi:4567
SUWAYOMI_AUTH_MODE=none
# SUWAYOMI_USERNAME=
# SUWAYOMI_PASSWORD=
# SUWAYOMI_TOKEN=
FEED_TITLE=Suwayomi releases
FEED_DESCRIPTION=New chapters in my Suwayomi library
FEED_ITEM_LIMIT=50
FEED_CACHE_SECONDS=300
# FEED_PUBLIC_URL=https://example.com/manga/rss.xml
# RSS_USERNAME=
# RSS_PASSWORD=
HOST=0.0.0.0
PORT=3000
+5
View File
@@ -0,0 +1,5 @@
node_modules/
.env
.DS_Store
coverage/
npm-debug.log*
+22
View File
@@ -0,0 +1,22 @@
FROM node:24-alpine AS dependencies
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:24-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY --from=dependencies /app/node_modules ./node_modules
COPY package.json ./
COPY src ./src
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
CMD ["node", "src/index.js"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 codinget
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+90
View File
@@ -0,0 +1,90 @@
# suwayomi-rss
A small Node.js 24 and Koa service that turns recent chapter updates from a
[Suwayomi](https://github.com/Suwayomi/Suwayomi-Server) library into an RSS 2.0
feed.
It queries Suwayomi's GraphQL API for library chapters ordered by the time they
were fetched. Each RSS item uses the Suwayomi chapter ID as a stable GUID, so a
feed reader will notify only when a chapter first appears in the feed.
## Run with Docker
```sh
docker build -t suwayomi-rss .
docker run --rm -p 3000:3000 \
-e SUWAYOMI_URL=http://suwayomi:4567 \
suwayomi-rss
```
In Compose, place this service on the same network as Suwayomi:
```yaml
services:
suwayomi-rss:
build: .
environment:
SUWAYOMI_URL: http://suwayomi:4567
SUWAYOMI_AUTH_MODE: ui_login
SUWAYOMI_USERNAME: ${SUWAYOMI_USERNAME}
SUWAYOMI_PASSWORD: ${SUWAYOMI_PASSWORD}
FEED_PUBLIC_URL: https://rss.example.com/rss.xml
RSS_USERNAME: ${RSS_USERNAME}
RSS_PASSWORD: ${RSS_PASSWORD}
ports:
- "127.0.0.1:3000:3000"
restart: unless-stopped
```
The feed is available at `/rss.xml` and `/feed.xml`. A liveness endpoint is
available at `/healthz`; it deliberately does not contact Suwayomi.
## Configuration
| Variable | Default | Description |
| --- | --- | --- |
| `SUWAYOMI_URL` | `http://suwayomi:4567` | Suwayomi server root URL |
| `SUWAYOMI_GRAPHQL_URL` | `$SUWAYOMI_URL/api/graphql` | Override the GraphQL endpoint |
| `SUWAYOMI_AUTH_MODE` | `none` | `none`, `basic`, `ui_login`, or `bearer` |
| `SUWAYOMI_USERNAME` | | Required for `basic` and `ui_login` |
| `SUWAYOMI_PASSWORD` | | Required for `basic` and `ui_login` |
| `SUWAYOMI_TOKEN` | | Required for `bearer` |
| `SUWAYOMI_TIMEOUT_MS` | `10000` | Upstream request timeout |
| `FEED_TITLE` | `Suwayomi releases` | RSS channel title |
| `FEED_DESCRIPTION` | `New chapters in my Suwayomi library` | RSS channel description |
| `FEED_LINK` | `SUWAYOMI_URL` | RSS channel link and fallback item link |
| `FEED_PUBLIC_URL` | | Public feed URL, emitted as the Atom self link |
| `FEED_ITEM_LIMIT` | `50` | Number of recent chapters, from 1 to 200 |
| `FEED_CACHE_SECONDS` | `300` | In-memory upstream response cache duration |
| `RSS_USERNAME` / `RSS_PASSWORD` | | Optional HTTP Basic protection for the feed |
| `HOST` | `0.0.0.0` | Listening address |
| `PORT` | `3000` | Listening port |
`simple_login` is not supported for the upstream connection because it is an
interactive, cookie-based mode. Prefer Suwayomi's `ui_login`, Basic Auth over a
trusted private network, or a bearer token.
The application never persists credentials or chapter data. Its cache is held
in memory and is discarded on restart.
## Local development
```sh
npm install
npm test
SUWAYOMI_URL=http://localhost:4567 npm start
```
This project uses Node's built-in `node:test` runner and has no test framework
dependency.
## Security
This feed reveals the contents of your Suwayomi library and chapter source
URLs. Keep it on a private network, protect it with `RSS_USERNAME` and
`RSS_PASSWORD`, or put it behind your existing authenticated reverse proxy.
Do not send Suwayomi or RSS credentials over unencrypted public HTTP.
## License
MIT
+405
View File
@@ -0,0 +1,405 @@
{
"name": "suwayomi-rss",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "suwayomi-rss",
"version": "0.1.0",
"dependencies": {
"koa": "^3.2.1"
},
"engines": {
"node": ">=24"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/accepts/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/accepts/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookies": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz",
"integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"keygrip": "~1.1.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/deep-equal": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz",
"integrity": "sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==",
"license": "MIT"
},
"node_modules/delegates": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
"integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
"license": "MIT"
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/http-assert": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz",
"integrity": "sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==",
"license": "MIT",
"dependencies": {
"deep-equal": "~1.0.1",
"http-errors": "~1.8.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/http-assert/node_modules/depd": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
"integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/http-assert/node_modules/http-errors": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz",
"integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==",
"license": "MIT",
"dependencies": {
"depd": "~1.1.2",
"inherits": "2.0.4",
"setprototypeof": "1.2.0",
"statuses": ">= 1.5.0 < 2",
"toidentifier": "1.0.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/http-assert/node_modules/statuses": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
"integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/keygrip": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz",
"integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==",
"license": "MIT",
"dependencies": {
"tsscmp": "1.0.6"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/koa": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/koa/-/koa-3.2.1.tgz",
"integrity": "sha512-e7IpWJrnanNUroVK2taAgMxoEZvHLXdQiNjeExSu/DEIWm83jaKGBgb7tLmu2rMYpA027qFB3iLR/k3AVpFRnA==",
"license": "MIT",
"dependencies": {
"accepts": "^1.3.8",
"content-disposition": "~1.0.1",
"content-type": "^1.0.5",
"cookies": "~0.9.1",
"delegates": "^1.0.0",
"destroy": "^1.2.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"fresh": "~0.5.2",
"http-assert": "^1.5.0",
"http-errors": "^2.0.0",
"koa-compose": "^4.1.0",
"mime-types": "^3.0.1",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/koa-compose": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-4.1.0.tgz",
"integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==",
"license": "MIT"
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/tsscmp": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz",
"integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==",
"license": "MIT",
"engines": {
"node": ">=0.6.x"
}
},
"node_modules/type-is": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT",
"dependencies": {
"content-type": "^2.0.0",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/type-is/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "suwayomi-rss",
"version": "0.1.0",
"description": "Expose recent Suwayomi library chapters as an RSS feed",
"type": "module",
"private": true,
"engines": {
"node": ">=24"
},
"scripts": {
"start": "node src/index.js",
"test": "node --test test/*.test.js",
"test:coverage": "node --test --experimental-test-coverage test/*.test.js"
},
"dependencies": {
"koa": "^3.2.1"
}
}
+90
View File
@@ -0,0 +1,90 @@
import Koa from "koa";
import { timingSafeEqual } from "node:crypto";
import { FeedService } from "./rss.js";
import { SuwayomiClient } from "./suwayomi.js";
function equal(left, right) {
const a = Buffer.from(left || "");
const b = Buffer.from(right || "");
return a.length === b.length && timingSafeEqual(a, b);
}
function authorized(ctx, config) {
if (!config.username) return true;
const [scheme, encoded] = (ctx.get("authorization") || "").split(" ");
if (scheme?.toLowerCase() !== "basic" || !encoded) return false;
let credentials;
try {
credentials = Buffer.from(encoded, "base64").toString("utf8");
} catch {
return false;
}
const separator = credentials.indexOf(":");
if (separator < 0) return false;
return (
equal(credentials.slice(0, separator), config.username) &&
equal(credentials.slice(separator + 1), config.password)
);
}
function etagMatches(header, etag) {
return header
.split(",")
.map((value) => value.trim().replace(/^W\//, ""))
.some((value) => value === "*" || value === etag);
}
export function createApp(config, options = {}) {
const app = new Koa();
const client =
options.client || new SuwayomiClient(config.suwayomi, options.fetch);
const feeds = options.feedService || new FeedService(client, config.feed);
app.use(async (ctx) => {
if (ctx.path === "/healthz") {
ctx.body = { status: "ok" };
return;
}
if (ctx.path === "/") {
ctx.body = { name: "suwayomi-rss", feed: "/rss.xml" };
return;
}
if (ctx.path !== "/rss.xml" && ctx.path !== "/feed.xml") {
ctx.status = 404;
ctx.body = { error: "Not found" };
return;
}
if (!authorized(ctx, config.feed)) {
ctx.status = 401;
ctx.set("WWW-Authenticate", 'Basic realm="suwayomi-rss", charset="UTF-8"');
ctx.body = "Authentication required";
return;
}
try {
const feed = await feeds.get();
ctx.set("ETag", feed.etag);
ctx.set("Last-Modified", feed.lastModified.toUTCString());
ctx.set(
"Cache-Control",
`private, max-age=${Math.max(0, config.feed.cacheSeconds)}`,
);
ctx.status = 200;
ctx.type = "application/rss+xml; charset=utf-8";
if (ctx.fresh || etagMatches(ctx.get("if-none-match"), feed.etag)) {
ctx.status = 304;
return;
}
ctx.body = feed.body;
} catch (error) {
ctx.app.emit("error", error, ctx);
ctx.status = 502;
ctx.body = { error: "Could not generate feed from Suwayomi" };
}
});
app.on("error", (error) => {
if (options.silent !== true) console.error(error);
});
return app;
}
+80
View File
@@ -0,0 +1,80 @@
const AUTH_MODES = new Set(["none", "basic", "ui_login", "bearer"]);
function integer(name, value, defaultValue, { min, max }) {
if (value === undefined || value === "") return defaultValue;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
throw new Error(`${name} must be an integer between ${min} and ${max}`);
}
return parsed;
}
function normalizedUrl(name, value) {
try {
return new URL(value).toString().replace(/\/$/, "");
} catch {
throw new Error(`${name} must be an absolute URL`);
}
}
export function loadConfig(env = process.env) {
const suwayomiUrl = normalizedUrl(
"SUWAYOMI_URL",
env.SUWAYOMI_URL || "http://suwayomi:4567",
);
const authMode = (env.SUWAYOMI_AUTH_MODE || "none").toLowerCase();
if (!AUTH_MODES.has(authMode)) {
throw new Error(
`SUWAYOMI_AUTH_MODE must be one of: ${[...AUTH_MODES].join(", ")}`,
);
}
if (["basic", "ui_login"].includes(authMode)) {
if (!env.SUWAYOMI_USERNAME || !env.SUWAYOMI_PASSWORD) {
throw new Error(
`SUWAYOMI_USERNAME and SUWAYOMI_PASSWORD are required for ${authMode} auth`,
);
}
}
if (authMode === "bearer" && !env.SUWAYOMI_TOKEN) {
throw new Error("SUWAYOMI_TOKEN is required for bearer auth");
}
if (Boolean(env.RSS_USERNAME) !== Boolean(env.RSS_PASSWORD)) {
throw new Error("RSS_USERNAME and RSS_PASSWORD must be set together");
}
return {
host: env.HOST || "0.0.0.0",
port: integer("PORT", env.PORT, 3000, { min: 1, max: 65535 }),
suwayomi: {
url: suwayomiUrl,
graphqlUrl:
env.SUWAYOMI_GRAPHQL_URL || `${suwayomiUrl}/api/graphql`,
authMode,
username: env.SUWAYOMI_USERNAME,
password: env.SUWAYOMI_PASSWORD,
token: env.SUWAYOMI_TOKEN,
timeoutMs: integer("SUWAYOMI_TIMEOUT_MS", env.SUWAYOMI_TIMEOUT_MS, 10000, {
min: 100,
max: 120000,
}),
},
feed: {
title: env.FEED_TITLE || "Suwayomi releases",
description:
env.FEED_DESCRIPTION || "New chapters in my Suwayomi library",
link: env.FEED_LINK || suwayomiUrl,
publicUrl: env.FEED_PUBLIC_URL || "",
itemLimit: integer("FEED_ITEM_LIMIT", env.FEED_ITEM_LIMIT, 50, {
min: 1,
max: 200,
}),
cacheSeconds: integer("FEED_CACHE_SECONDS", env.FEED_CACHE_SECONDS, 300, {
min: 0,
max: 86400,
}),
username: env.RSS_USERNAME,
password: env.RSS_PASSWORD,
},
};
}
+20
View File
@@ -0,0 +1,20 @@
import { createApp } from "./app.js";
import { loadConfig } from "./config.js";
const config = loadConfig();
const app = createApp(config);
const server = app.listen(config.port, config.host, () => {
console.log(`suwayomi-rss listening on http://${config.host}:${config.port}`);
});
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => {
server.close((error) => {
if (error) {
console.error(error);
process.exitCode = 1;
}
});
});
}
+108
View File
@@ -0,0 +1,108 @@
import { createHash } from "node:crypto";
function escapeXml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}
function timestamp(value, fallback) {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? number : fallback;
}
function itemXml(chapter, feedLink, generatedAt) {
const manga = chapter.manga;
const link = chapter.realUrl || manga.realUrl || feedLink;
const fetchedAt = timestamp(chapter.fetchedAt, generatedAt);
const uploadDate = timestamp(chapter.uploadDate, 0);
const details = [
`Chapter: ${chapter.name}`,
chapter.scanlator ? `Scanlator: ${chapter.scanlator}` : null,
uploadDate ? `Source upload date: ${new Date(uploadDate).toISOString()}` : null,
].filter(Boolean);
return [
" <item>",
` <title>${escapeXml(`${manga.title}${chapter.name}`)}</title>`,
` <link>${escapeXml(link)}</link>`,
` <guid isPermaLink="false">suwayomi:chapter:${chapter.id}</guid>`,
` <pubDate>${new Date(fetchedAt).toUTCString()}</pubDate>`,
` <description>${escapeXml(details.join("\n"))}</description>`,
" </item>",
].join("\n");
}
export function buildRss(chapters, config, generatedAt = Date.now()) {
const items = chapters.map((chapter) =>
itemXml(chapter, config.link, generatedAt),
);
const latest = chapters.reduce(
(value, chapter) => Math.max(value, timestamp(chapter.fetchedAt, 0)),
generatedAt,
);
const atom = config.publicUrl
? `\n <atom:link href="${escapeXml(config.publicUrl)}" rel="self" type="application/rss+xml" />`
: "";
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escapeXml(config.title)}</title>
<link>${escapeXml(config.link)}</link>
<description>${escapeXml(config.description)}</description>
<lastBuildDate>${new Date(latest).toUTCString()}</lastBuildDate>${atom}
${items.join("\n")}
</channel>
</rss>
`;
}
export function etagFor(value) {
return `"${createHash("sha256").update(value).digest("base64url")}"`;
}
export class FeedService {
#cached;
#pending;
constructor(client, config, now = Date.now) {
this.client = client;
this.config = config;
this.now = now;
}
async get() {
const now = this.now();
if (this.#cached && now < this.#cached.expiresAt) return this.#cached;
if (this.#pending) return this.#pending;
this.#pending = this.#refresh(now).finally(() => {
this.#pending = undefined;
});
return this.#pending;
}
async #refresh(now) {
const chapters = await this.client.recentLibraryChapters(
this.config.itemLimit,
);
const body = buildRss(chapters, this.config, now);
const fetchedValues = chapters
.map((chapter) => Number(chapter.fetchedAt))
.filter((value) => Number.isFinite(value) && value > 0);
const lastModified = new Date(
fetchedValues.length ? Math.max(...fetchedValues) : now,
);
this.#cached = {
body,
etag: etagFor(body),
lastModified,
expiresAt: now + this.config.cacheSeconds * 1000,
};
return this.#cached;
}
}
+177
View File
@@ -0,0 +1,177 @@
const CHAPTERS_QUERY = `
query RecentLibraryChapters($first: Int!) {
chapters(
filter: { inLibrary: { equalTo: true } }
order: [{ by: FETCHED_AT, byType: DESC }]
first: $first
) {
nodes {
id
name
chapterNumber
scanlator
uploadDate
fetchedAt
realUrl
manga {
id
title
realUrl
}
}
}
}
`;
const LOGIN_MUTATION = `
mutation Login($input: LoginInput!) {
login(input: $input) { accessToken refreshToken }
}
`;
const REFRESH_MUTATION = `
mutation Refresh($input: RefreshTokenInput!) {
refreshToken(input: $input) { accessToken }
}
`;
function tokenExpiry(token) {
try {
const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64url"));
return Number(payload.exp) * 1000;
} catch {
return 0;
}
}
export class SuwayomiError extends Error {
constructor(message, options = {}) {
super(message, options);
this.name = "SuwayomiError";
}
}
export class SuwayomiClient {
#accessToken;
#accessTokenExpiresAt = 0;
#refreshToken;
#tokenPromise;
constructor(config, fetchImpl = globalThis.fetch) {
this.config = config;
this.fetch = fetchImpl;
}
async recentLibraryChapters(limit) {
const data = await this.#graphql(CHAPTERS_QUERY, { first: limit });
return data.chapters.nodes;
}
async #graphql(query, variables, { authorization = true } = {}) {
const headers = { "content-type": "application/json" };
if (authorization) {
const auth = await this.#authorization();
if (auth) headers.authorization = auth;
}
let response;
try {
response = await this.fetch(this.config.graphqlUrl, {
method: "POST",
headers,
body: JSON.stringify({ query, variables }),
signal: AbortSignal.timeout(this.config.timeoutMs),
});
} catch (error) {
throw new SuwayomiError(`Could not reach Suwayomi: ${error.message}`, {
cause: error,
});
}
if (!response.ok) {
throw new SuwayomiError(
`Suwayomi returned HTTP ${response.status} ${response.statusText}`,
);
}
let result;
try {
result = await response.json();
} catch (error) {
throw new SuwayomiError("Suwayomi returned invalid JSON", { cause: error });
}
if (result.errors?.length) {
const message = result.errors.map((error) => error.message).join("; ");
throw new SuwayomiError(`Suwayomi GraphQL error: ${message}`);
}
if (!result.data) {
throw new SuwayomiError("Suwayomi returned no GraphQL data");
}
return result.data;
}
async #authorization() {
switch (this.config.authMode) {
case "none":
return undefined;
case "basic":
return `Basic ${Buffer.from(
`${this.config.username}:${this.config.password}`,
).toString("base64")}`;
case "bearer":
return `Bearer ${this.config.token}`;
case "ui_login":
return `Bearer ${await this.#uiLoginToken()}`;
default:
throw new SuwayomiError(`Unsupported auth mode: ${this.config.authMode}`);
}
}
async #uiLoginToken() {
if (this.#accessToken && Date.now() < this.#accessTokenExpiresAt - 30000) {
return this.#accessToken;
}
if (!this.#tokenPromise) {
this.#tokenPromise = this.#obtainToken().finally(() => {
this.#tokenPromise = undefined;
});
}
return this.#tokenPromise;
}
async #obtainToken() {
if (this.#refreshToken) {
try {
const data = await this.#graphql(
REFRESH_MUTATION,
{ input: { refreshToken: this.#refreshToken } },
{ authorization: false },
);
return this.#saveAccessToken(data.refreshToken.accessToken);
} catch {
this.#refreshToken = undefined;
}
}
const data = await this.#graphql(
LOGIN_MUTATION,
{
input: {
username: this.config.username,
password: this.config.password,
},
},
{ authorization: false },
);
this.#refreshToken = data.login.refreshToken;
return this.#saveAccessToken(data.login.accessToken);
}
#saveAccessToken(token) {
this.#accessToken = token;
this.#accessTokenExpiresAt = tokenExpiry(token) || Date.now() + 240000;
return token;
}
}
export { CHAPTERS_QUERY };
+72
View File
@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createApp } from "../src/app.js";
import { baseConfig, listen } from "./helpers.js";
const feed = {
body: "<rss></rss>",
etag: '"feed-tag"',
lastModified: new Date("2025-01-01T00:00:00Z"),
};
test("serves health and RSS endpoints with conditional GET", async (t) => {
const app = createApp(baseConfig, {
feedService: { async get() { return feed; } },
silent: true,
});
const server = await listen(app);
t.after(server.close);
const health = await fetch(`${server.url}/healthz`);
assert.equal(health.status, 200);
assert.deepEqual(await health.json(), { status: "ok" });
const response = await fetch(`${server.url}/rss.xml`);
assert.equal(response.status, 200);
assert.match(response.headers.get("content-type"), /application\/rss\+xml/);
assert.equal(response.headers.get("etag"), feed.etag);
assert.equal(await response.text(), feed.body);
const fresh = await fetch(`${server.url}/rss.xml`, {
headers: { "if-none-match": feed.etag },
});
assert.equal(fresh.status, 304);
});
test("optionally protects the feed with HTTP Basic auth", async (t) => {
const config = structuredClone(baseConfig);
config.feed.username = "reader";
config.feed.password = "secret:with-colon";
const app = createApp(config, {
feedService: { async get() { return feed; } },
silent: true,
});
const server = await listen(app);
t.after(server.close);
const denied = await fetch(`${server.url}/rss.xml`);
assert.equal(denied.status, 401);
assert.match(denied.headers.get("www-authenticate"), /Basic/);
const accepted = await fetch(`${server.url}/rss.xml`, {
headers: {
authorization: `Basic ${Buffer.from("reader:secret:with-colon").toString("base64")}`,
},
});
assert.equal(accepted.status, 200);
});
test("returns 502 without leaking upstream error details", async (t) => {
const app = createApp(baseConfig, {
feedService: { async get() { throw new Error("secret upstream detail"); } },
silent: true,
});
const server = await listen(app);
t.after(server.close);
const response = await fetch(`${server.url}/rss.xml`);
assert.equal(response.status, 502);
assert.deepEqual(await response.json(), {
error: "Could not generate feed from Suwayomi",
});
});
+26
View File
@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import test from "node:test";
import { loadConfig } from "../src/config.js";
test("loads defaults", () => {
const config = loadConfig({});
assert.equal(config.port, 3000);
assert.equal(config.suwayomi.graphqlUrl, "http://suwayomi:4567/api/graphql");
assert.equal(config.feed.itemLimit, 50);
});
test("requires credentials for authenticated modes", () => {
assert.throws(
() => loadConfig({ SUWAYOMI_AUTH_MODE: "ui_login" }),
/USERNAME and SUWAYOMI_PASSWORD/,
);
assert.throws(
() => loadConfig({ RSS_USERNAME: "reader" }),
/must be set together/,
);
});
test("validates bounded integer settings", () => {
assert.throws(() => loadConfig({ FEED_ITEM_LIMIT: "201" }), /between 1 and 200/);
assert.throws(() => loadConfig({ PORT: "wat" }), /PORT must be an integer/);
});
+41
View File
@@ -0,0 +1,41 @@
export const baseConfig = {
suwayomi: {
url: "http://suwayomi:4567",
graphqlUrl: "http://suwayomi:4567/api/graphql",
authMode: "none",
timeoutMs: 1000,
},
feed: {
title: "Manga releases",
description: "Recently fetched chapters",
link: "https://manga.example.test",
publicUrl: "https://rss.example.test/rss.xml",
itemLimit: 25,
cacheSeconds: 300,
},
};
export const chapter = {
id: 42,
name: "Chapter 12 & a half",
chapterNumber: 12.5,
scanlator: "A <Team>",
uploadDate: 1700000000000,
fetchedAt: 1700000100000,
realUrl: "https://source.example.test/chapter?x=1&y=2",
manga: {
id: 7,
title: "Example & Manga",
realUrl: "https://source.example.test/manga",
},
};
export async function listen(app) {
const server = app.listen(0, "127.0.0.1");
await new Promise((resolve) => server.once("listening", resolve));
const { port } = server.address();
return {
url: `http://127.0.0.1:${port}`,
close: () => new Promise((resolve) => server.close(resolve)),
};
}
+37
View File
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildRss, FeedService } from "../src/rss.js";
import { baseConfig, chapter } from "./helpers.js";
test("buildRss emits valid escaped RSS fields and a stable GUID", () => {
const xml = buildRss([chapter], baseConfig.feed, 1700000200000);
assert.match(xml, /<rss version="2.0"/);
assert.match(xml, /Example &amp; Manga — Chapter 12 &amp; a half/);
assert.match(xml, /A &lt;Team&gt;/);
assert.match(xml, /suwayomi:chapter:42/);
assert.match(xml, /x=1&amp;y=2/);
assert.match(xml, /atom:link/);
});
test("FeedService caches responses and coalesces concurrent refreshes", async () => {
let calls = 0;
let now = 1000;
const client = {
async recentLibraryChapters(limit) {
calls += 1;
assert.equal(limit, 25);
await Promise.resolve();
return [chapter];
},
};
const service = new FeedService(client, baseConfig.feed, () => now);
const [first, second] = await Promise.all([service.get(), service.get()]);
assert.strictEqual(first, second);
assert.equal(calls, 1);
now += 301000;
await service.get();
assert.equal(calls, 2);
});
+89
View File
@@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import test from "node:test";
import { SuwayomiClient } from "../src/suwayomi.js";
const config = {
graphqlUrl: "https://suwayomi.example.test/api/graphql",
authMode: "none",
timeoutMs: 1000,
};
function response(data, init = {}) {
return new Response(JSON.stringify(data), {
status: 200,
headers: { "content-type": "application/json" },
...init,
});
}
test("queries recent chapters from library in fetched order", async () => {
const calls = [];
const client = new SuwayomiClient(config, async (url, init) => {
calls.push({ url, init });
return response({ data: { chapters: { nodes: [{ id: 1 }] } } });
});
assert.deepEqual(await client.recentLibraryChapters(12), [{ id: 1 }]);
const body = JSON.parse(calls[0].init.body);
assert.equal(calls[0].url, config.graphqlUrl);
assert.equal(body.variables.first, 12);
assert.match(body.query, /inLibrary/);
assert.match(body.query, /FETCHED_AT/);
});
test("sends Basic credentials", async () => {
const headers = [];
const client = new SuwayomiClient(
{ ...config, authMode: "basic", username: "me", password: "pass" },
async (_url, init) => {
headers.push(init.headers.authorization);
return response({ data: { chapters: { nodes: [] } } });
},
);
await client.recentLibraryChapters(5);
assert.equal(headers[0], `Basic ${Buffer.from("me:pass").toString("base64")}`);
});
test("logs in once for UI login and sends the JWT", async () => {
const token = [
"header",
Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 300 })).toString("base64url"),
"signature",
].join(".");
const operations = [];
const client = new SuwayomiClient(
{
...config,
authMode: "ui_login",
username: "reader",
password: "secret",
},
async (_url, init) => {
const request = JSON.parse(init.body);
operations.push({ request, authorization: init.headers.authorization });
if (request.query.includes("mutation Login")) {
return response({
data: { login: { accessToken: token, refreshToken: "refresh" } },
});
}
return response({ data: { chapters: { nodes: [] } } });
},
);
await client.recentLibraryChapters(5);
await client.recentLibraryChapters(5);
assert.equal(operations.filter(({ request }) => request.query.includes("mutation Login")).length, 1);
assert.equal(operations[1].authorization, `Bearer ${token}`);
assert.equal(operations[2].authorization, `Bearer ${token}`);
});
test("turns GraphQL errors into useful upstream errors", async () => {
const client = new SuwayomiClient(config, async () =>
response({ errors: [{ message: "Unauthorized" }] }),
);
await assert.rejects(
client.recentLibraryChapters(5),
/Suwayomi GraphQL error: Unauthorized/,
);
});